The summarization runs server-side, but the SPA held the job only in component state and jobs lived only in memory — so navigating away from the Book Summarizer reset the UI (losing the handle to a still-running job) and past summaries vanished on restart. - PdfJobDb: SQLite store (its own file under ARCHIVE_DIR) persisting every job on each progress update. - JobStore: createdAt, persist-on-update, viewOf()/list() that serve live jobs first then fall back to the durable record; non-live jobs whose worker died surface as a new "interrupted" status. - routes: GET /v1/pdf-jobs (newest-first list); GET /:id + summary.md serve past jobs via viewOf; /:id/events sends a single terminal frame for a finished/past job (no hang) and live frames otherwise. - SPA: RecentJobs list (open any job → reconnect to live progress or show result), localStorage resume of the active job on return, and a "back to list (keeps running)" affordance. - mainnet buyer compose: persistent named volume + ARCHIVE_DIR so the archive + pdf-jobs.db survive container recreation/redeploys. Tests: persistence-across-restart, interrupted detection, list, and past-job serving (incl. terminal SSE). Full buyer suite green (185). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
229 lines
9.4 KiB
TypeScript
229 lines
9.4 KiB
TypeScript
/**
|
|
* buyer-pdf-routes.test.ts — the /v1/pdf-* HTTP surface, via supertest against
|
|
* createApp. Auth is unconfigured in these tests (no password/sessionSecret),
|
|
* so the routes are reachable directly — same construction shape as
|
|
* buyer-server-synth-speech.test.ts.
|
|
*/
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import { mkdtempSync, rmSync } from "fs";
|
|
import { tmpdir } from "os";
|
|
import { join } from "path";
|
|
import { createApp } from "../../buyer/src/server.js";
|
|
import { JobStore, type RunCallFn } from "../../buyer/src/pdf/summarize-job.js";
|
|
import { PdfJobDb } from "../../buyer/src/pdf/job-db.js";
|
|
import { loadPdfCaps } from "../../buyer/src/pdf/caps.js";
|
|
import type { Chunk } from "../../buyer/src/pdf/types.js";
|
|
import type { Marketplace } from "../../buyer/src/sdk/Marketplace.js";
|
|
import type { SupplierView } from "../../buyer/src/sdk/types.js";
|
|
import type { ChainProvider } from "@marketplace/shared/chain";
|
|
import type { WalletKey } from "@marketplace/shared/tx";
|
|
|
|
function supplierView(model: string, ref: string, price: string): SupplierView {
|
|
return {
|
|
utxo_ref: ref,
|
|
supplier_pkh: `pkh_${model}`,
|
|
capability_id: "llm.text.generate.v1",
|
|
model,
|
|
max_output_tokens: 512,
|
|
max_processing_ms: 60000,
|
|
price_lovelace: price,
|
|
supplier_bond_lovelace: "1000000",
|
|
buyer_bond_lovelace: "1000000",
|
|
endpoint_url: "http://supplier",
|
|
detail_uri: "",
|
|
detail_hash: "",
|
|
advertised_at: 0,
|
|
status: "active",
|
|
advert_status: "Active",
|
|
current_escrow_ref: null,
|
|
last_seen_iso: null,
|
|
created_slot: 0,
|
|
};
|
|
}
|
|
|
|
const MARKETPLACE = {
|
|
discoverSuppliers: async () => [supplierView("kimi-k2", `${"a".repeat(64)}#0`, "2000000")],
|
|
} as unknown as Marketplace;
|
|
const WALLET = { address: "addr_test", pubKeyHash: "pkh", pubKeyHex: "", privateKeyHex: "0".repeat(64) } as WalletKey;
|
|
const CHAIN = {} as ChainProvider;
|
|
|
|
function makeStore(opts?: { balance?: bigint; caps?: Record<string, string>; db?: PdfJobDb }): JobStore {
|
|
let n = 0;
|
|
const runCall: RunCallFn = async (sup) => {
|
|
n += 1;
|
|
return {
|
|
response: `summary ${n}`,
|
|
escrowRef: `${"f".repeat(64)}#${n}`,
|
|
supplierPkh: sup.supplierPkh,
|
|
model: sup.model,
|
|
receipt: {},
|
|
receiptSignature: "sig",
|
|
};
|
|
};
|
|
return new JobStore({
|
|
marketplace: MARKETPLACE,
|
|
chain: CHAIN,
|
|
walletKey: WALLET,
|
|
indexerUrl: "http://indexer",
|
|
caps: loadPdfCaps(opts?.caps ?? {}),
|
|
runCall,
|
|
walletBalance: async () => opts?.balance ?? 10_000_000_000n,
|
|
db: opts?.db,
|
|
});
|
|
}
|
|
|
|
function chunks(n: number): Chunk[] {
|
|
return Array.from({ length: n }, (_, i) => ({ index: i, text: `chunk ${i}`, tokenEstimate: 5 }));
|
|
}
|
|
|
|
describe("/v1/pdf-* routes", () => {
|
|
it("503s every pdf route when the feature is disabled (no jobStore)", async () => {
|
|
const app = createApp({});
|
|
const { default: request } = await import("supertest");
|
|
const up = await request(app).post("/v1/pdf-upload").send({});
|
|
expect(up.status).toBe(503);
|
|
const job = await request(app).get("/v1/pdf-jobs/whatever");
|
|
expect(job.status).toBe(503);
|
|
});
|
|
|
|
it("rejects an upload with no file (400 file_required)", async () => {
|
|
const app = createApp({ jobStore: makeStore(), pdfCaps: loadPdfCaps({}) });
|
|
const { default: request } = await import("supertest");
|
|
const res = await request(app).post("/v1/pdf-upload");
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toBe("file_required");
|
|
});
|
|
|
|
it("rejects a non-PDF upload (422)", async () => {
|
|
const app = createApp({ jobStore: makeStore(), pdfCaps: loadPdfCaps({}) });
|
|
const { default: request } = await import("supertest");
|
|
const res = await request(app)
|
|
.post("/v1/pdf-upload")
|
|
.attach("file", Buffer.from("this is not a pdf"), "junk.pdf");
|
|
expect(res.status).toBe(422);
|
|
expect(["pdf_parse_failed", "scanned_or_image_pdf", "no_text"]).toContain(res.body.error);
|
|
});
|
|
|
|
it("lists jobs newest-first via GET /v1/pdf-jobs", async () => {
|
|
const store = makeStore();
|
|
const a = store.createJob("First.pdf", 1, chunks(1));
|
|
const b = store.createJob("Second.pdf", 2, chunks(2));
|
|
const app = createApp({ jobStore: store, pdfCaps: loadPdfCaps({}) });
|
|
const { default: request } = await import("supertest");
|
|
const res = await request(app).get("/v1/pdf-jobs");
|
|
expect(res.status).toBe(200);
|
|
expect(Array.isArray(res.body.jobs)).toBe(true);
|
|
const ids = res.body.jobs.map((j: { job_id: string }) => j.job_id);
|
|
expect(ids).toContain(a.jobId);
|
|
expect(ids).toContain(b.jobId);
|
|
const found = res.body.jobs.find((j: { job_id: string }) => j.job_id === b.jobId);
|
|
expect(found.filename).toBe("Second.pdf");
|
|
expect(found.status).toBe("estimated");
|
|
});
|
|
|
|
it("serves a past (persisted, non-live) job's view, summary.md, and terminal SSE", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pdfroutes-"));
|
|
try {
|
|
const db1 = new PdfJobDb(dir);
|
|
const store1 = makeStore({ db: db1 });
|
|
const seeded = store1.createJob("Past.pdf", 2, chunks(2));
|
|
const app1 = createApp({ jobStore: store1, pdfCaps: loadPdfCaps({}) });
|
|
const { default: request } = await import("supertest");
|
|
await request(app1).post(`/v1/pdf-jobs/${seeded.jobId}/start`).send({ confirm: true });
|
|
let view = seeded.view();
|
|
for (let i = 0; i < 100 && view.status === "running"; i++) {
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
view = (await request(app1).get(`/v1/pdf-jobs/${seeded.jobId}`)).body;
|
|
}
|
|
expect(view.status).toBe("completed");
|
|
db1.close();
|
|
|
|
// Fresh store (no live job) backed by the same db = "after restart".
|
|
const db2 = new PdfJobDb(dir);
|
|
const store2 = makeStore({ db: db2 });
|
|
const app2 = createApp({ jobStore: store2, pdfCaps: loadPdfCaps({}) });
|
|
|
|
const v = await request(app2).get(`/v1/pdf-jobs/${seeded.jobId}`);
|
|
expect(v.status).toBe(200);
|
|
expect(v.body.status).toBe("completed");
|
|
expect(v.body.final_summary_md.length).toBeGreaterThan(0);
|
|
|
|
const md = await request(app2).get(`/v1/pdf-jobs/${seeded.jobId}/summary.md`);
|
|
expect(md.status).toBe(200);
|
|
|
|
// SSE on a non-live job returns a single terminal frame and ENDS (no hang).
|
|
const ev = await request(app2).get(`/v1/pdf-jobs/${seeded.jobId}/events`).buffer(true);
|
|
expect(ev.text).toContain("event: done");
|
|
|
|
const list = await request(app2).get("/v1/pdf-jobs");
|
|
expect(list.body.jobs.find((j: { job_id: string }) => j.job_id === seeded.jobId)?.has_summary).toBe(true);
|
|
db2.close();
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("404s estimate for an unknown job", async () => {
|
|
const app = createApp({ jobStore: makeStore(), pdfCaps: loadPdfCaps({}) });
|
|
const { default: request } = await import("supertest");
|
|
const res = await request(app).get("/v1/pdf-jobs/nope/estimate");
|
|
expect(res.status).toBe(404);
|
|
expect(res.body.error).toBe("job_not_found");
|
|
});
|
|
|
|
it("estimate → confirm-gate → start → run → summary (seeded job)", async () => {
|
|
const store = makeStore();
|
|
const seeded = store.createJob("MyBook.pdf", 4, chunks(4));
|
|
const app = createApp({ jobStore: store, pdfCaps: loadPdfCaps({}) });
|
|
const { default: request } = await import("supertest");
|
|
|
|
// estimate
|
|
const est = await request(app).get(`/v1/pdf-jobs/${seeded.jobId}/estimate`);
|
|
expect(est.status).toBe(200);
|
|
expect(est.body.mapCalls).toBe(4);
|
|
expect(est.body.would_drop_below_floor).toBe(false);
|
|
|
|
// start without confirm → 400
|
|
const noConfirm = await request(app).post(`/v1/pdf-jobs/${seeded.jobId}/start`).send({});
|
|
expect(noConfirm.status).toBe(400);
|
|
expect(noConfirm.body.error).toBe("not_confirmed");
|
|
|
|
// start with confirm → 202
|
|
const start = await request(app).post(`/v1/pdf-jobs/${seeded.jobId}/start`).send({ confirm: true });
|
|
expect(start.status).toBe(202);
|
|
|
|
// poll until finished
|
|
let view = seeded.view();
|
|
for (let i = 0; i < 100 && view.status === "running"; i++) {
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
const v = await request(app).get(`/v1/pdf-jobs/${seeded.jobId}`);
|
|
view = v.body;
|
|
}
|
|
expect(view.status).toBe("completed");
|
|
expect(view.coverage).toEqual({ done: 4, total: 4 });
|
|
expect(view.escrow_refs.length).toBeGreaterThanOrEqual(4);
|
|
|
|
// double-start now 409
|
|
const again = await request(app).post(`/v1/pdf-jobs/${seeded.jobId}/start`).send({ confirm: true });
|
|
expect(again.status).toBe(409);
|
|
|
|
// summary download
|
|
const md = await request(app).get(`/v1/pdf-jobs/${seeded.jobId}/summary.md`);
|
|
expect(md.status).toBe(200);
|
|
expect(md.headers["content-type"]).toMatch(/text\/markdown/);
|
|
expect(md.headers["content-disposition"]).toContain("MyBook-summary.md");
|
|
expect(md.text.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("refuses to start when spend would drop below the wallet floor (402)", async () => {
|
|
const store = makeStore({ balance: 1_000_000n, caps: { PDF_WALLET_FLOOR_LOVELACE: "50000000" } });
|
|
const seeded = store.createJob("book.pdf", 1, chunks(3));
|
|
const app = createApp({ jobStore: store, pdfCaps: loadPdfCaps({ PDF_WALLET_FLOOR_LOVELACE: "50000000" }) });
|
|
const { default: request } = await import("supertest");
|
|
const res = await request(app).post(`/v1/pdf-jobs/${seeded.jobId}/start`).send({ confirm: true });
|
|
expect(res.status).toBe(402);
|
|
expect(res.body.error).toBe("below_wallet_floor");
|
|
});
|
|
});
|