import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { SessionContextMismatchError, SessionMetadataStore, } from "../../src/sessions/metadataStore.js"; describe("SessionMetadataStore", () => { let tempDir: string; let store: SessionMetadataStore; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "tjwater-session-")); store = new SessionMetadataStore(tempDir); await store.initialize(); }); it("does not return or ensure a session owned by another context", async () => { await store.ensure({ actorKey: "actor-1", projectId: "project-1", projectKey: "project-key-1", sessionId: "shared-id", userId: "user-1", }); expect( await store.get( { actorKey: "actor-2", projectId: "project-2", projectKey: "project-key-2", userId: "user-2", }, "shared-id", ), ).toBeNull(); expect( store.ensure({ actorKey: "actor-2", projectId: "project-2", projectKey: "project-key-2", sessionId: "shared-id", userId: "user-2", }), ).rejects.toBeInstanceOf(SessionContextMismatchError); }); afterEach(async () => { await rm(tempDir, { force: true, recursive: true }); }); it("persists the provided opencode session id", async () => { const { record, created } = await store.ensure({ actorKey: "actor-1", projectId: "project-1", projectKey: "project-key-1", sessionId: "opencode-session-1", userId: "user-1", }); expect(created).toBe(true); expect(record.sessionId).toBe("opencode-session-1"); expect(record.ownerUserId).toBe("user-1"); expect(record.status).toBe("active"); }); it("touches metadata and preserves scoped ownership", async () => { const { record } = await store.ensure({ actorKey: "actor-2", projectId: "project-2", projectKey: "project-key-2", sessionId: "existing-session", userId: "user-2", }); const touched = await store.touch(record, { title: "新标题", }); expect(touched.title).toBe("新标题"); expect(touched.updatedAt >= record.updatedAt).toBe(true); const fetched = await store.get( { actorKey: "actor-2", projectId: "project-2", projectKey: "project-key-2", userId: "user-2", }, "existing-session", ); expect(fetched?.title).toBe("新标题"); }); it("finds a triggered session only inside the owning context", async () => { const context = { actorKey: "actor-trigger", projectId: "project-trigger", projectKey: "project-key-trigger", userId: "user-trigger", }; const { record } = await store.ensure({ ...context, sessionId: "triggered-session", trigger: { triggerId: "peripheral.work_order.created:project:order:1", source: "peripheral_sample", subjectType: "work_order", subjectId: "50000000-0000-4000-8000-000000000001", workflow: "water_quality_complaint", occurredAt: "2026-09-22T10:00:00+08:00", }, }); expect( await store.findByTrigger( context, "peripheral.work_order.created:project:order:1", ), ).toEqual(record); expect( await store.findByTrigger( { ...context, actorKey: "other-actor", userId: "other-user" }, "peripheral.work_order.created:project:order:1", ), ).toBeNull(); }); it("keeps ordinary chat, task execution, and task collaboration distinct", async () => { const context = { actorKey: "actor-purpose", projectId: "project-purpose", projectKey: "project-key-purpose", userId: "user-purpose", }; await store.ensure({ ...context, sessionId: "ordinary" }); await store.ensure({ ...context, sessionId: "execution", purpose: "task_execution", taskId: "50000000-0000-4000-8000-000000000001", requestId: "60000000-0000-4000-8000-000000000001", }); await store.ensure({ ...context, sessionId: "collaboration", purpose: "task_collaboration", taskId: "50000000-0000-4000-8000-000000000001", }); expect((await store.list(context, { purpose: "ordinary_chat" })).map((item) => item.sessionId)).toEqual(["ordinary"]); expect((await store.list(context, { purpose: "task_execution" })).map((item) => item.sessionId)).toEqual(["execution"]); expect((await store.findByTask(context, "50000000-0000-4000-8000-000000000001", "task_collaboration"))?.sessionId).toBe("collaboration"); expect((await store.findByRequest(context, "60000000-0000-4000-8000-000000000001"))?.sessionId).toBe("execution"); }); it("updates its scoped index after writes and deletion without rereading unrelated records", async () => { const context = { actorKey: "actor-a", projectKey: "project-a", userId: "user-a" }; const { record } = await store.ensure({ ...context, sessionId: "first" }); expect((await store.list(context)).map((item) => item.sessionId)).toEqual(["first"]); // A listing already built the index; subsequent calls must not rescan files. await writeFile(join(tempDir, "unrelated.json"), "not valid JSON"); await store.touch(record, { title: "updated title" }); const listed = await store.list(context); expect(listed[0]?.title).toBe("updated title"); listed[0]!.actorKey = "other-actor"; expect((await store.list(context))[0]?.actorKey).toBe("actor-a"); await store.ensure({ ...context, sessionId: "second" }); expect((await store.list(context)).map((item) => item.sessionId).sort()).toEqual(["first", "second"]); await store.remove(record); expect((await store.list(context)).map((item) => item.sessionId)).toEqual(["second"]); expect(await store.get(context, "first")).toBeNull(); expect((await store.get(context, "first", { includeDeleted: true }))?.deletedAt).toBeDefined(); expect(await store.get({ ...context, userId: "user-b" }, "first", { includeDeleted: true })).toBeNull(); await expect(store.touch(record, { title: "revived" })).rejects.toThrow("session not found"); await expect(store.ensure({ ...context, sessionId: "first" })).rejects.toThrow("session not found"); }); });