Store phone/tablet/photo-frame shells with catalogs, append pages from extra images or PDFs, and reorder drafts with drag-and-drop. Co-authored-by: Cursor <cursoragent@cursor.com>
241 lines
8.3 KiB
TypeScript
241 lines
8.3 KiB
TypeScript
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);
|
|
if (extra.frame) data.set("frame", extra.frame);
|
|
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.frame).toBe("none");
|
|
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("stores a catalog frame", async () => {
|
|
const app = await testApp();
|
|
const created = await app.request("/api/catalogs", {
|
|
method: "POST",
|
|
body: form([jpegFile()], { frame: "phone" }),
|
|
headers: { "x-forwarded-for": "10.0.0.21" },
|
|
});
|
|
expect(created.status).toBe(201);
|
|
const { id } = await created.json();
|
|
const pub = await app.request(`/api/catalogs/${id}`);
|
|
expect((await pub.json()).frame).toBe("phone");
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|