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 = {}) { 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); }); });