Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion api/v1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function restoreFile(file: string, snap: string | null): void {
// we never mutate process.env (the config singleton is frozen at load).

async function seededV1App(
opts: { apiToken?: string; corsOrigins?: string[]; logger?: any } = {},
opts: { apiToken?: string; corsOrigins?: string[]; logger?: any; enqueueSnapshot?: (a: { itemId: string; url: string | null }) => void } = {},
) {
const { initDb } = await import("../db/index.js");
const { seed } = await import("../db/seed.js");
Expand All @@ -42,6 +42,7 @@ async function seededV1App(
corsOrigins: opts.corsOrigins,
logger: opts.logger,
screenshotsDir: dir, // Story 12.2 delete-asset-file tests resolve files here
enqueueSnapshot: opts.enqueueSnapshot, // Story 16.2 — spy so the archive action runs no Chrome
});
return { app, handle, dir };
}
Expand Down Expand Up @@ -775,3 +776,65 @@ test("12.2 (NFR-BC): an item from the collections path is visible + mutable via
fs.rmSync(dir, { recursive: true, force: true });
}
});

// Story 16.2 — per-item "archive this" REST action (POST /api/v1/items/:id/archive).
test("16.2: POST /items/:id/archive enqueues exactly one snapshot for that item", async () => {
const snaps: Array<{ itemId: string; url: string | null }> = [];
const { app, handle, dir } = await seededV1App({ enqueueSnapshot: (a) => snaps.push(a) });
try {
handle.db.insert(items).values({ id: "arch-it", boardId: "library", source: "https://keep.me/x" }).run();
const res = await app.inject({ method: "POST", url: "/api/v1/items/arch-it/archive", headers: AUTH });
assert.equal(res.statusCode, 202);
assert.deepEqual(JSON.parse(res.body), { queued: true });
assert.deepEqual(snaps, [{ itemId: "arch-it", url: "https://keep.me/x" }], "exactly one snapshot for that item");
} finally {
handle.sqlite.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});

test("16.2: POST /items/:id/archive on an unknown item → 404, nothing enqueued", async () => {
const snaps: unknown[] = [];
const { app, handle, dir } = await seededV1App({ enqueueSnapshot: (a) => snaps.push(a) });
try {
const res = await app.inject({ method: "POST", url: "/api/v1/items/ghost/archive", headers: AUTH });
assert.equal(res.statusCode, 404);
assert.equal(snaps.length, 0);
} finally {
handle.sqlite.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});

// AC1 default-off — capturing to the Inbox enqueues NO snapshot (the cheap path is
// unchanged); the Inbox board is not flagged archive_on_promote.
test("16.2 (default-off): POST /items to the Inbox enqueues no snapshot", async () => {
const snaps: unknown[] = [];
const { app, handle, dir } = await seededV1App({ enqueueSnapshot: (a) => snaps.push(a) });
try {
const res = await app.inject({
method: "POST", url: "/api/v1/items", headers: AUTH,
body: JSON.stringify({ url: "https://capture.example" }), // no board → Inbox, cheap
});
assert.equal(res.statusCode, 201);
assert.equal(snaps.length, 0, "capture to a non-archival board snapshots nothing");
} finally {
handle.sqlite.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});

test("16.2: POST /items/:id/archive on an item with no source URL → 422", async () => {
const snaps: unknown[] = [];
const { app, handle, dir } = await seededV1App({ enqueueSnapshot: (a) => snaps.push(a) });
try {
// a manual-upload item legitimately has no source URL to snapshot
handle.db.insert(items).values({ id: "nosrc", boardId: "library", source: null as any }).run();
const res = await app.inject({ method: "POST", url: "/api/v1/items/nosrc/archive", headers: AUTH });
assert.equal(res.statusCode, 422);
assert.equal(snaps.length, 0, "nothing enqueued for a sourceless item");
} finally {
handle.sqlite.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
40 changes: 39 additions & 1 deletion api/v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import cors from "@fastify/cors";
import { createHash, timingSafeEqual } from "node:crypto";
import type { FastifyInstance } from "fastify";
import type { DbHandle } from "../db/index.js";
import { boards } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { boards, items } from "../db/schema.js";
import { runSnapshotJob } from "../capture/url-snapshot.js";
import { getItemForUi, listItemsForApi } from "../db/hydrate.js";
import { patchItemFields, deleteItemWithAssets } from "../db/item-actions.js";
import { addItemSkill } from "../skills/add-item.js";
Expand Down Expand Up @@ -37,6 +39,12 @@ export interface V1Options {
logger: Logger;
llm: LLMProvider;
screenshotsDir: string;
/**
* Story 16.2 — injectable archival snapshot enqueue (tests spy so no Chrome runs).
* Used by the per-item archive action AND threaded into the assign verb's trigger.
* Defaults to fire-and-forget the 16.1 snapshot job on the single worker.
*/
enqueueSnapshot?: (args: { itemId: string; url: string | null }) => void;
}

/** SHA-256 hex of a string. Exported so the server can hash an injected test token. */
Expand Down Expand Up @@ -109,6 +117,15 @@ export async function registerV1Api(app: FastifyInstance, opts: V1Options): Prom
}
});

// Story 16.2 — the archival enqueue (injectable for tests). Default: fire-and-forget
// the 16.1 snapshot job on the single worker (status-neutral, graceful). Used by the
// per-item archive action AND threaded into the assign verb's opt-in trigger.
const enqueueSnapshot =
opts.enqueueSnapshot ??
((a: { itemId: string; url: string | null }) => {
if (a.url) void runSnapshotJob(opts.resolveDb(), { itemId: a.itemId, url: a.url });
});

// Trivial liveness probe so 12.1 has a guarded target (12.2 adds CRUD here).
v1.get("/ping", async () => ({ ok: true }));

Expand Down Expand Up @@ -240,6 +257,7 @@ export async function registerV1Api(app: FastifyInstance, opts: V1Options): Prom
boardId,
llm: opts.llm,
registry: captureRegistry,
enqueueSnapshot, // Story 16.2 — opt-in archival fires here iff the target board is flagged
});
await result.settled; // manual assign returns the enriched result
return {
Expand Down Expand Up @@ -286,6 +304,26 @@ export async function registerV1Api(app: FastifyInstance, opts: V1Options): Prom
},
);

// POST /items/:id/archive — Story 16.2 per-item "archive this" action. A REST
// action (NOT a skill — the v1 skill list is fixed, Story 8.3), sibling to the
// per-item notes/favorite/delete. Enqueues exactly one 16.1 snapshot for the item
// (status-neutral, graceful); returns 202 without blocking on the capture. Unknown
// item → 404. Items with no source URL can't be snapshotted → 422.
v1.post<{ Params: { id: string } }>("/items/:id/archive", async (req, reply) => {
const item = opts.resolveDb().db.select().from(items).where(eq(items.id, req.params.id)).get();
if (!item) {
reply.code(404);
return { error: "Not found" };
}
if (!item.source) {
reply.code(422);
return { error: "item has no source URL to archive" };
}
enqueueSnapshot({ itemId: item.id, url: item.source });
reply.code(202);
return { queued: true };
});

// GET /boards — lean targeting list ({id,name,view}); no descriptor needed.
v1.get("/boards", async () =>
opts.resolveDb().db.select({ id: boards.id, name: boards.name, view: boards.view }).from(boards).all(),
Expand Down
Loading
Loading