Add digital catalog with shareable flipbook and ktg.montzar.ir deploy files.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { openDatabase } from "../../server/db";
|
||||
import { createCatalogRepo } from "../../server/catalog-repo";
|
||||
|
||||
const temps: string[] = [];
|
||||
|
||||
async function tempDb() {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "dc-db-"));
|
||||
temps.push(dir);
|
||||
const db = await openDatabase(path.join(dir, "catalogs.sqlite"));
|
||||
return createCatalogRepo(db);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temps.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("catalog repo", () => {
|
||||
it("inserts and reads a catalog", async () => {
|
||||
const repo = await tempDb();
|
||||
const created = repo.create({
|
||||
id: "abc12xyz90",
|
||||
deleteToken: "token-token-token-token-12",
|
||||
title: "کاتالوگ",
|
||||
effect: "magazine",
|
||||
pageCount: 2,
|
||||
});
|
||||
const found = repo.getById("abc12xyz90");
|
||||
expect(found).toEqual(created);
|
||||
expect(found?.flipMs).toBe(1000);
|
||||
expect(found?.deleteToken).toBe("token-token-token-token-12");
|
||||
});
|
||||
|
||||
it("returns null after delete", async () => {
|
||||
const repo = await tempDb();
|
||||
repo.create({
|
||||
id: "id1",
|
||||
deleteToken: "tok",
|
||||
title: "کاتالوگ",
|
||||
effect: "slide",
|
||||
pageCount: 1,
|
||||
});
|
||||
expect(repo.deleteById("id1")).toBe(true);
|
||||
expect(repo.getById("id1")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Minimal JPEG (SOI + EOI) used only for API/storage tests. */
|
||||
export const TINY_JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xd9]);
|
||||
|
||||
export const TINY_PNG = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
]);
|
||||
|
||||
export function jpegFile(name = "page.jpg"): File {
|
||||
return new File([TINY_JPEG], name, { type: "image/jpeg" });
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRateLimiter } from "../../server/rate-limit";
|
||||
|
||||
describe("createRateLimiter", () => {
|
||||
it("allows the first 10 hits from an IP", () => {
|
||||
let now = 1_000;
|
||||
const limiter = createRateLimiter(10, 60 * 60 * 1000, () => now);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(limiter.check("1.1.1.1")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects the 11th hit within the window", () => {
|
||||
let now = 1_000;
|
||||
const limiter = createRateLimiter(10, 60 * 60 * 1000, () => now);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
limiter.check("1.1.1.1");
|
||||
}
|
||||
|
||||
expect(limiter.check("1.1.1.1")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows another hit after the window elapses", () => {
|
||||
let now = 1_000;
|
||||
const windowMs = 60 * 60 * 1000;
|
||||
const limiter = createRateLimiter(10, windowMs, () => now);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
limiter.check("1.1.1.1");
|
||||
}
|
||||
|
||||
now += windowMs + 1;
|
||||
expect(limiter.check("1.1.1.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("tracks IPs independently", () => {
|
||||
const limiter = createRateLimiter(1, 60_000, () => 1_000);
|
||||
expect(limiter.check("a")).toBe(true);
|
||||
expect(limiter.check("a")).toBe(false);
|
||||
expect(limiter.check("b")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
deleteCatalogFiles,
|
||||
readPage,
|
||||
saveCatalogPages,
|
||||
StorageError,
|
||||
} from "../../server/storage";
|
||||
import { TINY_JPEG, TINY_PNG } from "./helpers";
|
||||
|
||||
const temps: string[] = [];
|
||||
|
||||
async function tempDir() {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "dc-storage-"));
|
||||
temps.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temps.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("saveCatalogPages", () => {
|
||||
it("writes numbered jpegs and can read them back", async () => {
|
||||
const dir = await tempDir();
|
||||
await saveCatalogPages(dir, "abc", [TINY_JPEG, TINY_JPEG]);
|
||||
const page1 = await readPage(dir, "abc", 1);
|
||||
expect(page1.equals(TINY_JPEG)).toBe(true);
|
||||
const page2 = await readPage(dir, "abc", 2);
|
||||
expect(page2.equals(TINY_JPEG)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-jpeg pages", async () => {
|
||||
const dir = await tempDir();
|
||||
await expect(saveCatalogPages(dir, "abc", [TINY_PNG])).rejects.toBeInstanceOf(
|
||||
StorageError,
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes files so later reads fail", async () => {
|
||||
const dir = await tempDir();
|
||||
await saveCatalogPages(dir, "abc", [TINY_JPEG]);
|
||||
await deleteCatalogFiles(dir, "abc");
|
||||
await expect(readPage(dir, "abc", 1)).rejects.toBeInstanceOf(StorageError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user