import { expect, spyOn, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { newId, nowIso } from "@sttracker/db/ids"; import { forEachDialect } from "@sttracker/db/testing"; import { definePlugin, type SourcePlugin } from "@sttracker/plugin-sdk/types"; import { listAttachmentLinks, readAttachment, requireLinkTarget, storeAttachment } from "../src/attachments.ts"; import { createAppToken, listAppTokens, revokeAppToken } from "../src/auth.ts"; import { createConnection, deleteConnection, devicePoll, deviceStart, getConnection, listConnections, listRuns, syncHandler, updateConnection } from "../src/connections.ts"; import { startScheduler } from "../src/jobs.ts"; import { DropJobError } from "../src/jobs.ts"; import { listSuggestions, quickAdd } from "../src/entries.ts"; import { addEngagement } from "../src/engagements.ts"; import { NotFoundError, ValidationError } from "../src/errors.ts"; import type { PluginRegistry } from "../src/registry.ts"; import { loadPlugins } from "../src/registry.ts"; import { makeUser, makeWork } from "./user-layer-fixtures.ts"; const SECRET = "test-secret"; const warn = spyOn(console, "warn").mockImplementation(() => {}); const reg = await loadPlugins([join(import.meta.dir, "fixtures/plugins")]); warn.mockRestore(); const echo = reg.get("echo")!; const fileish: SourcePlugin = definePlugin({ manifest: { id: "fileish", name: "Fileish", version: "1", sdkVersion: 1, mediaTypes: ["book"], capabilities: ["importFile"], config: [] }, async *importFile(_ctx, file) { yield { kind: "entry", key: file.name, raw: new TextDecoder().decode(file.bytes), work: { title: new TextDecoder().decode(file.bytes), mediaType: "book", externalIds: { isbn13: "9780000000009" } }, entry: { status: "planned" }, }; }, deviceAuth: { start: async () => ({ userCode: "ABCD", verificationUrl: "https://x.test/device", deviceCode: "dc", intervalSec: 5, expiresInSec: 600 }), poll: async (_ctx, deviceCode) => (deviceCode === "dc" ? { access_token: "tok" } : "pending"), }, }); const reg2: PluginRegistry = { get: (id) => (id === "fileish" ? fileish : reg.get(id)), list: () => [...reg.list(), fileish.manifest], reload: async () => {} }; forEachDialect("api support", (getDb) => { test("listAppTokens returns metadata only, revoked flagged, scoped to user", async () => { const db = getDb(); const u = await makeUser(db); const other = await makeUser(db, "bob"); const t = await createAppToken(db, u, "script", ["read"]); await createAppToken(db, other, "x", ["write"]); await revokeAppToken(db, u, t.id); const list = await listAppTokens(db, u); expect(list).toHaveLength(1); expect(list[0]).toMatchObject({ id: t.id, name: "script", scopes: ["read"], lastUsedAt: null }); expect(list[0]!.revokedAt).not.toBeNull(); expect(JSON.stringify(list)).not.toContain("hash"); }); test("listSuggestions lists pending suggestions for a visible work", async () => { const db = getDb(); const u = await makeUser(db); const draft = await quickAdd(db, u, { title: "Dune??", mediaType: "book" }); const good = await makeWork(db, { title: "Dune", externalIds: { isbn13: "1" } }); await db.k .insertInto("match_suggestions") .values({ id: newId(), user_id: u, work_id: draft.workId, candidate_work_id: good, score: 0.75, status: "pending", created_at: nowIso() }) .execute(); const s = await listSuggestions(db, u, draft.workId); expect(s).toHaveLength(1); expect(s[0]).toMatchObject({ workId: draft.workId, candidateWorkId: good, score: 0.75, status: "pending" }); const other = await makeUser(db, "bob"); await expect(listSuggestions(db, other, draft.workId)).rejects.toBeInstanceOf(NotFoundError); }); test("requireLinkTarget checks ownership per entity; links and filename are readable", async () => { const db = getDb(); const dir = await mkdtemp(join(tmpdir(), "stt-att-")); try { const u = await makeUser(db); const bob = await makeUser(db, "bob"); const e = await quickAdd(db, u, { title: "Dune" }); const g = await addEngagement(db, u, e.id, {}, "user"); await requireLinkTarget(db, u, "entry", e.id); await requireLinkTarget(db, u, "engagement", g.id); await requireLinkTarget(db, u, "work", e.workId); await expect(requireLinkTarget(db, bob, "entry", e.id)).rejects.toBeInstanceOf(NotFoundError); await expect(requireLinkTarget(db, bob, "engagement", g.id)).rejects.toBeInstanceOf(NotFoundError); await expect(requireLinkTarget(db, bob, "work", e.workId)).rejects.toBeInstanceOf(NotFoundError); await expect(requireLinkTarget(db, u, "nope" as never, e.id)).rejects.toBeInstanceOf(ValidationError); const a = await storeAttachment(db, dir, u, { bytes: new Uint8Array([1]), mime: "image/png", filename: "a.png" }, { entity: "entry", entityId: e.id }); const links = await listAttachmentLinks(db, u, a.id); expect(links).toEqual([{ id: expect.any(String), entity: "entry", entityId: e.id }]); expect(await listAttachmentLinks(db, bob, a.id)).toEqual([]); expect((await readAttachment(db, dir, u, a.id))?.filename).toBe("a.png"); } finally { await rm(dir, { recursive: true, force: true }); } }); test("getConnection hides other users and deleted connections; listRuns is newest first", async () => { const db = getDb(); const u = await makeUser(db); const bob = await makeUser(db, "bob"); const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {} }, echo.manifest); expect((await getConnection(db, u, id)).pluginId).toBe("echo"); await expect(getConnection(db, bob, id)).rejects.toBeInstanceOf(NotFoundError); const sync = syncHandler({ db, secret: SECRET, registry: reg }); await sync({ connectionId: id, mode: "full" }); await sync({ connectionId: id, mode: "full" }); const runs = await listRuns(db, u, id); expect(runs).toHaveLength(2); expect(runs[0]!.startedAt >= runs[1]!.startedAt).toBe(true); expect(runs[0]).toMatchObject({ mode: "full", status: "ok" }); await expect(listRuns(db, bob, id)).rejects.toBeInstanceOf(NotFoundError); await deleteConnection(db, u, id); await expect(getConnection(db, u, id)).rejects.toBeInstanceOf(NotFoundError); }); test("a paused connection without credentials stays listed and can be unpaused; a deleted one is gone", async () => { const db = getDb(); const u = await makeUser(db); const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {} }, echo.manifest); await updateConnection(db, SECRET, u, id, { status: "paused" }, echo.manifest); expect((await listConnections(db, u)).map((c) => c.id)).toEqual([id]); expect((await getConnection(db, u, id)).status).toBe("paused"); await updateConnection(db, SECRET, u, id, { status: "active" }, echo.manifest); expect((await getConnection(db, u, id)).status).toBe("active"); await deleteConnection(db, u, id); const row = await db.k.selectFrom("source_connections").selectAll().where("id", "=", id).executeTakeFirstOrThrow(); expect(row).toMatchObject({ status: "paused", credentials_enc: null }); expect(row.deleted_at).not.toBeNull(); expect(await listConnections(db, u)).toEqual([]); await expect(getConnection(db, u, id)).rejects.toBeInstanceOf(NotFoundError); await expect(updateConnection(db, SECRET, u, id, { status: "active" }, echo.manifest)).rejects.toBeInstanceOf(NotFoundError); await expect(deleteConnection(db, u, id)).rejects.toBeInstanceOf(NotFoundError); }); test("deleted connections are never scheduled and their queued syncs are dropped", async () => { const db = getDb(); const u = await makeUser(db); const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {}, scheduleMinutes: 1 }, echo.manifest); // Simulate a delete that left status "error" (e.g. a row edited by hand): deleted_at alone must exclude it. await deleteConnection(db, u, id); await db.k.updateTable("source_connections").set({ status: "error" }).where("id", "=", id).execute(); const sched = startScheduler(db, { tickMs: 3_600_000 }); await sched.tickNow(); sched.stop(); expect(await db.k.selectFrom("jobs").select("id").execute()).toEqual([]); const sync = syncHandler({ db, secret: SECRET, registry: reg }); await expect(sync({ connectionId: id })).rejects.toBeInstanceOf(DropJobError); }); test("syncHandler runs file mode from a base64 payload", async () => { const db = getDb(); const u = await makeUser(db); const id = await createConnection(db, SECRET, u, { pluginId: "fileish", name: "F", config: {} }, fileish.manifest); const sync = syncHandler({ db, secret: SECRET, registry: reg2 }); const s = await sync({ connectionId: id, file: { name: "x.txt", base64: Buffer.from("Dune").toString("base64") } }); expect(s).toMatchObject({ status: "ok" }); expect((await listRuns(db, u, id))[0]!.mode).toBe("file"); const entries = await db.k.selectFrom("entries").select("id").where("user_id", "=", u).execute(); expect(entries).toHaveLength(1); }); test("deviceStart and devicePoll store credentials on success", async () => { const db = getDb(); const u = await makeUser(db); const id = await createConnection(db, SECRET, u, { pluginId: "fileish", name: "F", config: {} }, fileish.manifest); expect(await deviceStart(db, SECRET, reg2, u, id)).toMatchObject({ userCode: "ABCD", deviceCode: "dc" }); expect(await devicePoll(db, SECRET, reg2, u, id, "other")).toBe("pending"); expect(await devicePoll(db, SECRET, reg2, u, id, "dc")).toBe("connected"); expect(await getConnection(db, u, id)).toMatchObject({ hasCredentials: true }); const e = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {} }, echo.manifest); await expect(deviceStart(db, SECRET, reg2, u, e)).rejects.toBeInstanceOf(ValidationError); }); });