Add digital catalog with shareable flipbook and ktg.montzar.ir deploy files.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-19 14:37:49 +03:30
co-authored by Cursor
commit a8374e7d6c
54 changed files with 8462 additions and 0 deletions
+225
View File
@@ -0,0 +1,225 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createApp } from "../../server/app";
import { loadEnv } from "../../server/env";
import { LIMITS } from "../../shared/types";
import { jpegFile, TINY_PNG, TINY_JPEG } from "./helpers";
const temps: string[] = [];
async function testApp(now?: () => number) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "dc-app-"));
temps.push(dir);
const env = loadEnv({
PUBLIC_BASE_URL: "http://localhost:3000",
STORAGE_DIR: path.join(dir, "storage"),
DATABASE_PATH: path.join(dir, "data", "catalogs.sqlite"),
NODE_ENV: "test",
});
return createApp(env, { now });
}
function form(pages: File[], extra: Record<string, string> = {}) {
const data = new FormData();
data.set("title", extra.title ?? "");
data.set("effect", extra.effect ?? "magazine");
if (extra.flipMs) data.set("flipMs", extra.flipMs);
for (const page of pages) data.append("pages", page);
return data;
}
afterEach(async () => {
await Promise.all(temps.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe("catalog HTTP API", () => {
it("creates a catalog and hides delete token on public GET", async () => {
const app = await testApp();
const created = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile("a.jpg"), jpegFile("b.jpg")], { title: "نمونه" }),
headers: { "x-forwarded-for": "10.0.0.1" },
});
expect(created.status).toBe(201);
const body = await created.json();
expect(body.id).toMatch(/^[A-Za-z0-9_-]{10}$/);
expect(body.viewUrl).toBe(`http://localhost:3000/c/${body.id}`);
expect(body.deleteUrl).toContain("token=");
expect(body.doneUrl).toContain("/done?token=");
expect(body.qrUrl).toBe(`/api/catalogs/${body.id}/qr`);
const pub = await app.request(`/api/catalogs/${body.id}`);
expect(pub.status).toBe(200);
const publicBody = await pub.json();
expect(publicBody.title).toBe("نمونه");
expect(publicBody.pageCount).toBe(2);
expect(publicBody.flipMs).toBe(1000);
expect(publicBody.deleteToken).toBeUndefined();
expect(JSON.stringify(publicBody)).not.toContain("delete");
});
it("stores a custom flip duration", async () => {
const app = await testApp();
const created = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()], { flipMs: "1800" }),
headers: { "x-forwarded-for": "10.0.0.12" },
});
expect(created.status).toBe(201);
const { id } = await created.json();
const pub = await app.request(`/api/catalogs/${id}`);
expect((await pub.json()).flipMs).toBe(1800);
});
it("clamps flip duration to the allowed range", async () => {
const app = await testApp();
const tooFast = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()], { flipMs: "50" }),
headers: { "x-forwarded-for": "10.0.0.13" },
});
const tooSlow = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()], { flipMs: "9000" }),
headers: { "x-forwarded-for": "10.0.0.14" },
});
const fastId = (await tooFast.json()).id;
const slowId = (await tooSlow.json()).id;
expect((await (await app.request(`/api/catalogs/${fastId}`)).json()).flipMs).toBe(
LIMITS.minFlipMs,
);
expect((await (await app.request(`/api/catalogs/${slowId}`)).json()).flipMs).toBe(
LIMITS.maxFlipMs,
);
});
it("serves jpeg pages and png qr", async () => {
const app = await testApp();
const created = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()]),
headers: { "x-forwarded-for": "10.0.0.2" },
});
const { id } = await created.json();
const page = await app.request(`/api/catalogs/${id}/pages/1`);
expect(page.status).toBe(200);
expect(page.headers.get("content-type")).toBe("image/jpeg");
const qr = await app.request(`/api/catalogs/${id}/qr`);
expect(qr.status).toBe(200);
expect(qr.headers.get("content-type")).toBe("image/png");
const qrBytes = Buffer.from(await qr.arrayBuffer());
expect(qrBytes[0]).toBe(0x89);
expect(qrBytes[1]).toBe(0x50);
});
it("rejects delete without token and keeps the catalog", async () => {
const app = await testApp();
const created = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()]),
headers: { "x-forwarded-for": "10.0.0.3" },
});
const { id } = await created.json();
const denied = await app.request(`/api/catalogs/${id}`, { method: "DELETE" });
expect(denied.status).toBe(403);
expect((await app.request(`/api/catalogs/${id}`)).status).toBe(200);
});
it("deletes with token then returns 404", async () => {
const app = await testApp();
const created = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()]),
headers: { "x-forwarded-for": "10.0.0.4" },
});
const body = await created.json();
const token = new URL(body.deleteUrl).searchParams.get("token");
const del = await app.request(`/api/catalogs/${body.id}?token=${token}`, {
method: "DELETE",
});
expect(del.status).toBe(204);
expect((await app.request(`/api/catalogs/${body.id}`)).status).toBe(404);
});
it("rejects more pages than the limit", async () => {
const app = await testApp();
const pages = Array.from({ length: LIMITS.maxPages + 1 }, (_, i) =>
jpegFile(`${i}.jpg`),
);
const res = await app.request("/api/catalogs", {
method: "POST",
body: form(pages),
headers: { "x-forwarded-for": "10.0.0.5" },
});
expect(res.status).toBe(400);
expect((await res.json()).error).toContain("صفحه");
});
it("accepts a jpeg Blob the way the browser sends pages", async () => {
const app = await testApp();
const data = new FormData();
data.set("title", "");
data.set("effect", "slide");
data.append(
"pages",
new Blob([TINY_JPEG], { type: "image/jpeg" }),
"page-001.jpg",
);
const res = await app.request("/api/catalogs", {
method: "POST",
body: data,
headers: { "x-forwarded-for": "10.0.0.11" },
});
expect(res.status).toBe(201);
});
it("rejects an invalid effect", async () => {
const app = await testApp();
const res = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()], { effect: "explode" }),
headers: { "x-forwarded-for": "10.0.0.6" },
});
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("افکت نامعتبر است");
});
it("rejects non-jpeg pages", async () => {
const app = await testApp();
const res = await app.request("/api/catalogs", {
method: "POST",
body: form([new File([TINY_PNG], "x.png", { type: "image/png" })]),
headers: { "x-forwarded-for": "10.0.0.7" },
});
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("نوع فایل پشتیبانی نمی‌شود");
});
it("rate-limits the 11th create from one IP", async () => {
const app = await testApp(() => 1_000);
for (let i = 0; i < 10; i++) {
const res = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()]),
headers: { "x-forwarded-for": "10.0.0.8" },
});
expect(res.status).toBe(201);
}
const blocked = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()]),
headers: { "x-forwarded-for": "10.0.0.8" },
});
expect(blocked.status).toBe(429);
});
it("has no public catalog list", async () => {
const app = await testApp();
const res = await app.request("/api/catalogs");
expect(res.status).toBe(404);
});
});