import { Database } from "bun:sqlite"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { MIGRATIONS_FOLDER } from "../db/fixtures"; import { commitProvenance, gitCommits, projectRoots, projects, timelineEvents, } from "../db/schema"; import type { TimelineEventDisplay, TimelineEventMetadata, } from "../types/timeline"; import type { CapabilityContext } from "./context"; import { buildReportPayload, generateReport, getReport, listReports, matchesClientFilter, } from "./report"; let idCounter = 0; function evt(partial: { source: TimelineEventDisplay["source"]; timestamp: string; projectId?: string | null; projectName?: string | null; clientId?: string | null; metadata?: TimelineEventMetadata | null; estimatedMinutes?: number | null; }): TimelineEventDisplay { idCounter += 1; return { id: `e${idCounter}`, timestamp: partial.timestamp, source: partial.source, sourceId: null, projectRootId: null, projectId: partial.projectId ?? null, metadata: partial.metadata ?? null, estimatedMinutes: partial.estimatedMinutes ?? null, projectName: partial.projectName ?? null, projectColor: null, clientId: partial.clientId, deviceId: null, deviceName: null, }; } function git( timestamp: string, project: { id: string; name: string; clientId?: string | null }, message: string, ): TimelineEventDisplay { return evt({ source: "git", timestamp, projectId: project.id, projectName: project.name, clientId: project.clientId, metadata: { message, author: "tester", filesChanged: ["a.ts"], additions: 1, deletions: 0, }, }); } // A client project (has clientId) and an internal one (clientId null). const acme = { id: "p1", name: "Acme", clientId: "c1" }; const internal = { id: "p2", name: "Internal", clientId: null }; function sampleEvents(): TimelineEventDisplay[] { return [ git("2026-06-01T10:00:00Z", acme, "acme-1"), git("2026-06-02T10:00:00Z", acme, "acme-2"), git("2026-06-01T10:00:00Z", internal, "internal-1"), // Unassigned work has no project and no client -> counts as internal. evt({ source: "git", timestamp: "2026-06-01T11:00:00Z", projectId: null }), ]; } describe("matchesClientFilter", () => { test("'both' keeps everything", () => { expect(matchesClientFilter("c1", "both")).toBe(true); expect(matchesClientFilter(null, "both")).toBe(true); expect(matchesClientFilter(undefined, "both")).toBe(true); }); test("'client' keeps only events with a clientId", () => { expect(matchesClientFilter("c1", "client")).toBe(true); expect(matchesClientFilter(null, "client")).toBe(false); expect(matchesClientFilter(undefined, "client")).toBe(false); }); test("'internal' keeps only events without a clientId", () => { expect(matchesClientFilter("c1", "internal")).toBe(false); expect(matchesClientFilter(null, "internal")).toBe(true); expect(matchesClientFilter(undefined, "internal")).toBe(true); }); }); describe("buildReportPayload filter", () => { test("'both' includes client and internal projects", () => { const { report } = buildReportPayload(sampleEvents(), { scope: "me", filter: "both", depth: "summary", }); const ids = report.projects.map((p) => p.projectId).sort(); expect(ids).toEqual(["p1", "p2", null].sort()); expect(report.totalEvents).toBe(4); }); test("'client' drops internal and unassigned work", () => { const { report } = buildReportPayload(sampleEvents(), { scope: "me", filter: "client", depth: "summary", }); expect(report.projects.map((p) => p.projectId)).toEqual(["p1"]); expect(report.totalEvents).toBe(2); }); test("'internal' keeps internal + unassigned, drops client work", () => { const { report } = buildReportPayload(sampleEvents(), { scope: "me", filter: "internal", depth: "summary", }); const ids = report.projects.map((p) => p.projectId).sort(); expect(ids).toEqual(["p2", null].sort()); expect(report.totalEvents).toBe(2); }); }); describe("buildReportPayload depth", () => { test("summary omits the raw events array", () => { const { report, depth } = buildReportPayload(sampleEvents(), { scope: "me", filter: "both", depth: "summary", }); expect(depth).toBe("summary"); expect(report.events).toBeUndefined(); }); test("exhaustive echoes the filtered events array", () => { const { report } = buildReportPayload(sampleEvents(), { scope: "me", filter: "client", depth: "exhaustive", }); // Only the two Acme (client) events survive the filter and are echoed. expect(report.events).toHaveLength(2); expect(report.events?.every((e) => e.clientId === "c1")).toBe(true); }); }); describe("buildReportPayload client breakdown", () => { test("splits client vs internal minutes and event counts", () => { const { report } = buildReportPayload(sampleEvents(), { scope: "me", filter: "both", depth: "summary", }); expect(report.byClientType).toBeDefined(); const breakdown = report.byClientType; if (!breakdown) throw new Error("expected byClientType"); // Acme: two events ~1 day apart -> two 15min windows = 30min, 2 events. expect(breakdown.client.eventCount).toBe(2); expect(breakdown.client.projectCount).toBe(1); expect(breakdown.client.estimatedMinutes).toBe(30); // Internal project + unassigned -> 2 events across two project rows. expect(breakdown.internal.eventCount).toBe(2); expect(breakdown.internal.projectCount).toBe(2); // Buckets sum to the report total (no double counting). expect( breakdown.client.estimatedMinutes + breakdown.internal.estimatedMinutes, ).toBe(report.totalEstimatedMinutes); }); test("echoes the resolved range and metadata onto the payload", () => { const payload = buildReportPayload(sampleEvents(), { scope: "team", filter: "both", depth: "summary", start: "2026-06-01", end: "2026-06-07", }); expect(payload.scope).toBe("team"); expect(payload.filter).toBe("both"); expect(payload.report.range).toEqual({ start: "2026-06-01", end: "2026-06-07", }); }); }); // Minimal chainable Drizzle stand-in: every builder method returns the chain, // and awaiting it resolves to `rows`. Captures the final `.limit()` so we can // distinguish Summary's page size from Exhaustive's fail-closed cap probe. type FakeChain = { select: () => FakeChain; from: () => FakeChain; leftJoin: () => FakeChain; where: () => FakeChain; orderBy: () => FakeChain; limit: (n: number) => FakeChain; then: (resolve: (rows: unknown[]) => unknown) => unknown; }; function makeFakeDb(rows: unknown[]) { const calls: { limit?: number } = {}; const chain: FakeChain = { select: () => chain, from: () => chain, leftJoin: () => chain, where: () => chain, orderBy: () => chain, limit: (n: number) => { calls.limit = n; return chain; }, // biome-ignore lint/suspicious/noThenProperty: deliberately a thenable, to stand in for an awaitable Drizzle query builder then: (resolve) => resolve(rows), }; return { chain, calls }; } function fakeCtx( chain: FakeChain, organizationId: string | null, deviceIds?: string[], ): CapabilityContext { return { db: chain as unknown as CapabilityContext["db"], userId: organizationId ? "user1" : "local", organizationId, deviceIds, }; } describe("generateReport.execute", () => { test("runs with zero args and uses a Summary page size, not a row cap", async () => { const { chain, calls } = makeFakeDb([]); const payload = await generateReport.execute(fakeCtx(chain, null), {}); // Defaults applied inside execute even though Zod isn't run. expect(payload.scope).toBe("me"); expect(payload.filter).toBe("both"); expect(payload.depth).toBe("summary"); // days=7 default → a real ISO start, not "Invalid Date". expect(payload.report.range.start).not.toBeNull(); expect(Number.isNaN(Date.parse(payload.report.range.start as string))).toBe( false, ); expect(payload.report.totalEvents).toBe(0); expect(calls.limit).toBe(3_000); expect(payload.eventsExport).toBeUndefined(); }); test("exhaustive depth streams with the same page size and returns an events-export reference", async () => { const { chain, calls } = makeFakeDb([]); const payload = await generateReport.execute(fakeCtx(chain, null), { depth: "exhaustive", }); expect(payload.depth).toBe("exhaustive"); // Same correctness-neutral page size as summary — no cap probe. expect(calls.limit).toBe(3_000); expect(payload.eventsExport).toMatchObject({ count: 0 }); expect(payload.eventsExport?.url).toStartWith( "/api/timeline/events/export?", ); expect(payload.eventsExport?.url).toContain("scope=me"); }); test("hosted org Summary reports use the same correctness-neutral page size", async () => { const { chain, calls } = makeFakeDb([]); await generateReport.execute(fakeCtx(chain, "org1", ["d1"]), { scope: "team", }); expect(calls.limit).toBe(3_000); }); test("carries a freshness signal (self-aware report, no separate call)", async () => { const { chain } = makeFakeDb([]); const payload = await generateReport.execute(fakeCtx(chain, null), {}); // The payload now exposes freshness + a (null when fresh/unknown) warning. expect(payload.freshness).not.toBeUndefined(); expect(payload.freshness).toMatchObject({ lastIngestAt: null, lastSyncAt: null, ingestRunning: false, syncRunning: false, }); // Empty freshness history is "unknown", never a false stale warning. expect(payload.staleWarning).toBeNull(); }); test("reading freshness does not disturb the Summary page size", async () => { // Freshness runs its own bounded queries; the LAST limit must still be the // report page size, not a freshness limit(1). const { chain, calls } = makeFakeDb([]); await generateReport.execute(fakeCtx(chain, "org1", ["d1"]), { scope: "team", }); expect(calls.limit).toBe(3_000); }); test("exhaustive spans of any length stream to completion — no span cap", async () => { const { chain } = makeFakeDb([]); // Three years — would have thrown exhaustive_span_too_long under the old // 14-day guard. Long horizons are a first-class product requirement. const payload = await generateReport.execute(fakeCtx(chain, null), { depth: "exhaustive", start: "2023-01-01T00:00:00Z", end: "2026-01-01T00:00:00Z", }); expect(payload.depth).toBe("exhaustive"); expect(payload.eventsExport?.url).toContain( `start=${encodeURIComponent("2023-01-01T00:00:00Z")}`, ); expect(payload.eventsExport?.url).toContain( `end=${encodeURIComponent("2026-01-01T00:00:00Z")}`, ); }); test("exhaustive rollups are computed from the stream with an exact export count — no event cap, no inline echo", async () => { const rows = Array.from({ length: 25 }, (_, index) => git( new Date( Date.parse("2026-01-01T00:00:00Z") + index * 60_000, ).toISOString(), acme, `event ${index}`, ), ); const { chain } = makeFakeDb(rows); const payload = await generateReport.execute(fakeCtx(chain, null), { depth: "exhaustive", start: "2026-01-01T00:00:00Z", end: "2026-01-02T00:00:00Z", }); expect(payload.report.totalEvents).toBe(25); expect(payload.eventsExport?.count).toBe(25); // The raw events are never inlined — they live behind the export URL. expect(payload.report.events).toBeUndefined(); }); test("hosted me-scope pins the export to the creating user for org viewers", async () => { const { chain } = makeFakeDb([]); const payload = await generateReport.execute( fakeCtx(chain, "org1", ["d1"]), { depth: "exhaustive", scope: "me" }, ); const url = payload.eventsExport?.url ?? ""; // scope stays "me" (strictly device-scoped — no org-project arm), with the // creator pinned via userIds so a different org viewer exports exactly the // document's event set, not their own or the whole team's. expect(url).toContain("scope=me"); expect(url).toContain("userIds=user1"); }); test("narrative=true without injected generator sets narrativeSkipped=unsupported", async () => { const { chain } = makeFakeDb([]); const payload = await generateReport.execute(fakeCtx(chain, null), { narrative: true, }); expect(payload.narrative).toBeUndefined(); expect(payload.narrativeSkipped).toBe("unsupported"); }); test("narrative=false (default) omits narrative fields entirely", async () => { const { chain } = makeFakeDb([]); const payload = await generateReport.execute(fakeCtx(chain, null), {}); expect(payload.narrative).toBeUndefined(); expect(payload.narrativeSkipped).toBeUndefined(); }); test("narrative failure yields a payload without narrative (inject throwing generator)", async () => { const { chain } = makeFakeDb([]); const ctx = fakeCtx(chain, "org1", ["d1"]); ctx.generateNarrative = async () => { throw new Error("gateway timeout"); }; const payload = await generateReport.execute(ctx, { narrative: true }); expect(payload.narrative).toBeUndefined(); // Soft-fail path: no skip reason (generation was attempted). expect(payload.narrativeSkipped).toBeUndefined(); // Report still succeeds. expect(payload.scope).toBe("me"); expect(payload.report).toBeDefined(); }); test("narrative success attaches summary + perProject (capped at 6)", async () => { const { chain } = makeFakeDb([]); const ctx = fakeCtx(chain, "org1", ["d1"]); ctx.generateNarrative = async () => ({ summary: " Team shipped the report engine this week. ", perProject: Array.from({ length: 8 }, (_, i) => ({ projectName: ` Project ${i + 1} `, sentence: ` Shipped item ${i + 1}. `, })), }); const payload = await generateReport.execute(ctx, { narrative: true }); expect(payload.narrativeSkipped).toBeUndefined(); expect(payload.narrative?.summary).toBe( "Team shipped the report engine this week.", ); expect(payload.narrative?.perProject).toHaveLength(6); expect(payload.narrative?.perProject[0]).toEqual({ projectName: "Project 1", sentence: "Shipped item 1.", }); }); test("narrative generator returning null omits narrative without skip reason", async () => { const { chain } = makeFakeDb([]); const ctx = fakeCtx(chain, "org1", ["d1"]); ctx.generateNarrative = async () => null; const payload = await generateReport.execute(ctx, { narrative: true }); expect(payload.narrative).toBeUndefined(); expect(payload.narrativeSkipped).toBeUndefined(); }); }); describe("saveReport", () => { test("refuses to save without an organization (desktop/local)", async () => { const { saveReport } = await import("./report"); const ctx = { db: {} as CapabilityContext["db"], userId: "user1", organizationId: null, } satisfies CapabilityContext; const result = await saveReport.execute(ctx, {}); expect(result).toHaveProperty("error"); expect((result as { error: string }).error).toMatch(/hosted hub/i); }); }); describe("listReports", () => { test("requires an org (hosted hub)", async () => { const { chain } = makeFakeDb([]); const result = await listReports.execute(fakeCtx(chain, null), { limit: 10, }); expect(result).toMatchObject({ error: expect.stringContaining("requires the hosted hub"), }); }); test("maps saved reports to cards with a link and hours", async () => { const { chain } = makeFakeDb([ { id: "r1", title: "May report", status: "ready", rangeStart: new Date("2026-05-01"), rangeEnd: new Date("2026-05-31"), totalEvents: 120, totalEstimatedMinutes: 630, activeDays: 8, createdAt: new Date("2026-06-01"), }, ]); const result = await listReports.execute(fakeCtx(chain, "org1"), { limit: 10, }); expect(result).toMatchObject({ count: 1 }); const [row] = (result as { reports: Array> }) .reports; expect(row.id).toBe("r1"); expect(row.url).toBe("/reports/r1"); expect(row.totalHours).toBe(10.5); // 630 min → 10.5h }); }); describe("getReport", () => { test("returns the parsed payload for a ready report", async () => { const payload = { report: { totalEvents: 5 } }; const { chain } = makeFakeDb([ { id: "r1", title: "May report", status: "ready", payload: JSON.stringify(payload), error: null, }, ]); const result = await getReport.execute(fakeCtx(chain, "org1"), { reportId: "r1", }); expect(result).toMatchObject({ id: "r1", url: "/reports/r1" }); expect((result as { report: typeof payload }).report).toEqual(payload); }); test("errors when the report is not found", async () => { const { chain } = makeFakeDb([]); const result = await getReport.execute(fakeCtx(chain, "org1"), { reportId: "missing", }); expect(result).toMatchObject({ error: expect.stringContaining("not found"), }); }); test("errors when the report is not ready", async () => { const { chain } = makeFakeDb([ { id: "r1", title: "Pending", status: "pending", payload: null, error: null, }, ]); const result = await getReport.execute(fakeCtx(chain, "org1"), { reportId: "r1", }); expect(result).toMatchObject({ error: expect.stringContaining("not ready"), }); }); }); // A team report filtered to specific members (userIds) must attribute // device-less GitHub commits by author, not blend the whole org's commits in. // The FakeChain above ignores WHERE conditions, so this exercises the scope SQL // against a real in-memory SQLite so the attribution join actually runs. describe("generateReport team scope narrows GitHub commits by author", () => { let sqlite: Database; let db: CapabilityContext["db"]; beforeEach(async () => { sqlite = new Database(":memory:"); db = drizzle({ client: sqlite }); migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }); // One org project; two device-less GitHub commits assigned to it, authored // by different members (resolved to userIds via commit provenance at ingest). await db .insert(projects) .values([{ id: "p1", name: "Acme", organizationId: "org1" }]); await db .insert(projectRoots) .values([{ id: "r1", projectId: "p1", path: "/acme" }]); await db.insert(gitCommits).values([ { id: "gc1", organizationId: "org1", commitKey: "k1", sha: "s1" }, { id: "gc2", organizationId: "org1", commitKey: "k2", sha: "s2" }, ]); await db.insert(commitProvenance).values([ { id: "cp1", commitId: "gc1", source: "github", userId: "u1" }, { id: "cp2", commitId: "gc2", source: "github", userId: "u2" }, ]); await db.insert(timelineEvents).values([ { id: "ev1", timestamp: new Date("2026-07-22T10:00:00Z"), source: "git", projectRootId: "r1", deviceId: null, commitKey: "k1", }, { id: "ev2", timestamp: new Date("2026-07-22T11:00:00Z"), source: "git", projectRootId: "r1", deviceId: null, commitKey: "k2", }, ]); }); afterEach(() => { sqlite.close(); }); function orgCtx(): CapabilityContext { return { db, userId: "u1", organizationId: "org1", deviceIds: [], } as CapabilityContext; } const range = { start: "2026-07-20T00:00:00Z", end: "2026-07-24T00:00:00Z" }; test("unfiltered team report includes every contributor's commits", async () => { const payload = await generateReport.execute(orgCtx(), { scope: "team", ...range, }); expect(payload.report.totalEvents).toBe(2); }); test("filtering to one member returns only that member's commits — no org-wide bleed-through", async () => { const payload = await generateReport.execute(orgCtx(), { scope: "team", userIds: ["u1"], ...range, }); expect(payload.report.totalEvents).toBe(1); }); test("filtering to both members returns both commits", async () => { const payload = await generateReport.execute(orgCtx(), { scope: "team", userIds: ["u1", "u2"], ...range, }); expect(payload.report.totalEvents).toBe(2); }); });