import type { Db } from "@sttracker/db/db"; import { newId, nowIso } from "@sttracker/db/ids"; import type { Database } from "@sttracker/db/schema"; import { createTestContext } from "@sttracker/plugin-sdk/testing"; import { definePlugin, type SourcePlugin, type SourceRecord, type WorkHint } from "@sttracker/plugin-sdk/types"; import type { SyncDeps } from "../../src/sync/run.ts"; export { makeUser, makeWork } from "../user-layer-fixtures.ts"; /** Insert a `source_connections` row directly and return its id. */ export async function makeConnection(db: Db, userId: string, pluginId: string): Promise { const id = newId(); await db.k .insertInto("source_connections") .values({ id, user_id: userId, plugin_id: pluginId, name: pluginId, config: "{}", credentials_enc: null, schedule_minutes: null, cursor: null, status: "active", last_run_at: null, created_at: nowIso(), }) .execute(); return id; } /** A source whose records the test mutates between runs. `failAfter` makes `pull` throw after that many records. */ export interface FakeSource { records: SourceRecord[]; failAfter?: number; } export function fakePlugin(id: string, src: FakeSource): SourcePlugin { async function* gen(): AsyncIterable { let i = 0; for (const r of src.records) { if (src.failAfter !== undefined && i >= src.failAfter) throw new Error("source exploded"); yield r; i++; } if (src.failAfter !== undefined && i >= src.failAfter) throw new Error("source exploded"); } return definePlugin({ manifest: { id, name: id, version: "0.0.0", sdkVersion: 1, mediaTypes: ["book", "movie", "show"], capabilities: ["pull", "pullChanges"], config: [] }, pull: () => gen(), pullChanges: () => gen(), }); } export function deps(db: Db): SyncDeps { return { db, makeContext: () => createTestContext() }; } export function book(isbn: string, title = `Book ${isbn}`): WorkHint { return { title, mediaType: "book", externalIds: { isbn13: isbn } }; } export function entryRec(key: string, work: WorkHint, entry: Extract["entry"], extra: Partial = {}): SourceRecord { return { key, kind: "entry", work, entry, raw: { key, entry }, ...extra } as SourceRecord; } export function engagementRec( key: string, work: WorkHint, engagement: Extract["engagement"], ): SourceRecord { return { key, kind: "engagement", work, engagement, raw: { key } }; } export function listenRec(key: string, listen: Extract["listen"]): SourceRecord { return { key, kind: "listen", listen, raw: { key } }; } export async function count(db: Db, table: keyof Database): Promise { const r = await db.k .selectFrom(table) .select((eb) => eb.fn.countAll().as("n")) .executeTakeFirstOrThrow(); return Number(r.n); } export async function events(db: Db, where: { type?: string; status?: string } = {}) { let q = db.k.selectFrom("change_events").selectAll().orderBy("id"); if (where.type) q = q.where("type", "=", where.type as never); if (where.status) q = q.where("status", "=", where.status as never); const rows = await q.execute(); return rows.map((r) => ({ ...r, payload: JSON.parse(r.payload) as Record })); } export async function entryFor(db: Db, userId: string, isbn: string) { const ext = await db.k .selectFrom("external_ids") .select("work_id") .where("namespace", "=", "isbn13") .where("value", "=", isbn) .executeTakeFirst(); if (!ext) return undefined; const row = await db.k .selectFrom("entries") .selectAll() .where("user_id", "=", userId) .where("work_id", "=", ext.work_id) .executeTakeFirst(); return row ? { ...row, fieldSources: JSON.parse(row.field_sources) as Record } : undefined; }