import { describe, expect, test } from "bun:test"; import type { LinearMetadata } from "../types/timeline"; import type { CapabilityContext } from "./context"; import { deleteTimelineEntry, getProjectActivity, updateTimelineEntry, } from "./timeline"; // Queued Drizzle stand-in: every builder method returns the chain, and each // `await` (via `.then`) resolves the next queued result set. `getProjectActivity` // issues its queries sequentially, so results are popped in call order: // 1. project lookup 2. project-root events 3. linear mappings 4. linear events function makeQueuedDb(results: unknown[][]) { let i = 0; // biome-ignore lint/suspicious/noExplicitAny: minimal chainable test double const chain: any = { select: () => chain, from: () => chain, innerJoin: () => chain, leftJoin: () => chain, where: () => chain, orderBy: () => chain, limit: () => chain, // biome-ignore lint/suspicious/noThenProperty: stands in for an awaitable Drizzle query builder then: (resolve: (rows: unknown[]) => unknown) => resolve(results[i++] ?? []), }; return chain; } function ctxWith(db: unknown): CapabilityContext { return { db: db as CapabilityContext["db"], userId: "user1", organizationId: "org1", deviceIds: ["d1"], }; } const project = [{ id: "p1", name: "Acme" }]; const gitEvent = { id: "g1", source: "git", timestamp: new Date("2026-06-01T10:00:00Z"), }; function linearRow(projectName: string | undefined) { const metadata: LinearMetadata = { issueId: "iss1", issueIdentifier: "OPL-1", title: "do the thing", action: "completed", projectName, }; return { id: "l1", source: "linear", timestamp: new Date("2026-06-02T10:00:00Z"), metadata, }; } describe("getProjectActivity — Linear mapping wiring (OPL-2722)", () => { test("includes a mapped Linear event whose project name is linked (case-insensitive)", async () => { const db = makeQueuedDb([ project, [gitEvent], [{ linearProjectName: "Acme Board", projectId: "p1" }], [linearRow("acme board")], // different case → still resolves to p1 ]); const result = await getProjectActivity.execute(ctxWith(db), { projectName: "Acme", days: 30, }); if ("error" in result) throw new Error(result.error); const sources = result.bySource.map((s) => s.source).sort(); expect(sources).toEqual(["git", "linear"]); }); test("excludes a Linear event whose project name is not mapped to this project", async () => { const db = makeQueuedDb([ project, [gitEvent], [{ linearProjectName: "Acme Board", projectId: "p1" }], [linearRow("Some Other Project")], // no mapping → dropped ]); const result = await getProjectActivity.execute(ctxWith(db), { projectName: "Acme", days: 30, }); if ("error" in result) throw new Error(result.error); expect(result.bySource.map((s) => s.source)).toEqual(["git"]); }); test("skips the Linear query entirely when the project has no mappings", async () => { const db = makeQueuedDb([ project, [gitEvent], [], // no mappings → linear events never fetched ]); const result = await getProjectActivity.execute(ctxWith(db), { projectName: "Acme", days: 30, }); if ("error" in result) throw new Error(result.error); expect(result.bySource.map((s) => s.source)).toEqual(["git"]); }); }); // Mutation stand-in: builder methods return the chain; each `await` resolves // the next queued result set; `set()` captures the update payload. function makeMutateQueuedDb(results: unknown[][]) { let i = 0; const captured: { set?: Record } = {}; // biome-ignore lint/suspicious/noExplicitAny: minimal chainable test double const chain: any = { select: () => chain, from: () => chain, innerJoin: () => chain, leftJoin: () => chain, where: () => chain, orderBy: () => chain, limit: () => chain, insert: () => chain, values: () => chain, update: () => chain, delete: () => chain, set: (v: Record) => { captured.set = v; return chain; }, returning: () => chain, // biome-ignore lint/suspicious/noThenProperty: stands in for an awaitable Drizzle query builder then: (resolve: (rows: unknown[]) => unknown) => resolve(results[i++] ?? []), }; return { chain, captured }; } describe("updateTimelineEntry", () => { test("edits title and duration on a manual entry", async () => { const { chain, captured } = makeMutateQueuedDb([ [ { id: "e1", source: "manual", metadata: { title: "Old" }, orgId: "org1", }, ], [{ id: "e1" }], // returning ]); const result = await updateTimelineEntry.execute(ctxWith(chain), { entryId: "e1", title: "New title", duration: 45, }); expect((captured.set?.metadata as { title: string }).title).toBe( "New title", ); expect(captured.set?.estimatedMinutes).toBe(45); expect(result).toMatchObject({ success: true }); }); test("refuses to edit a non-manual (scanned) entry", async () => { const { chain } = makeMutateQueuedDb([ [{ id: "e1", source: "git", metadata: null, orgId: "org1" }], ]); const result = await updateTimelineEntry.execute(ctxWith(chain), { entryId: "e1", title: "X", }); expect(result).toMatchObject({ error: expect.stringContaining("Only manual entries"), }); }); test("refuses an entry from another org", async () => { const { chain } = makeMutateQueuedDb([ [{ id: "e1", source: "manual", metadata: {}, orgId: "other-org" }], ]); const result = await updateTimelineEntry.execute(ctxWith(chain), { entryId: "e1", title: "X", }); expect(result).toMatchObject({ error: expect.stringContaining("not found in your organization"), }); }); }); describe("deleteTimelineEntry", () => { test("deletes a manual entry", async () => { const { chain } = makeMutateQueuedDb([ [{ id: "e1", source: "manual", metadata: {}, orgId: "org1" }], [], ]); const result = await deleteTimelineEntry.execute(ctxWith(chain), { entryId: "e1", }); expect(result).toMatchObject({ success: true }); }); test("refuses to delete a non-manual entry", async () => { const { chain } = makeMutateQueuedDb([ [{ id: "e1", source: "claude", metadata: {}, orgId: "org1" }], ]); const result = await deleteTimelineEntry.execute(ctxWith(chain), { entryId: "e1", }); expect(result).toMatchObject({ error: expect.stringContaining("Only manual entries"), }); }); });