import { describe, expect, it, vi } from "vitest"; import type { AppServerClient } from "../../../../src/app-server/connection/client"; import type { ArchiveThreadResult } from "../../../../src/app-server/services/thread-archive"; import type { ArchiveExportDestination } from "../../../../src/app-server/services/thread-archive-markdown"; import { createThreadOperations, type ThreadOperationsHost } from "../../../../src/features/threads/workflows/thread-operations"; import { DEFAULT_SETTINGS } from "../../../../src/settings/model"; const archiveMock = vi.hoisted(() => ({ archiveThreadOnAppServer: vi.fn(), })); vi.mock("../../../../src/app-server/services/thread-archive", () => ({ archiveThreadOnAppServer: archiveMock.archiveThreadOnAppServer, })); describe("ThreadOperations", () => { it("renames a thread and notifies shared surfaces after success", async () => { const { operations, client, catalog } = operationsFixture(); await expect(operations.renameThread("thread", " Saved title ")).resolves.toBe(true); expect(client?.setThreadName).toHaveBeenCalledWith("thread", "Saved title"); expect(catalog.apply).toHaveBeenCalledWith({ type: "thread-renamed", threadId: "thread", name: "Saved title" }); }); it("can skip rename publication when the caller invalidates the save", async () => { const { operations, catalog } = operationsFixture(); await operations.renameThread("thread", "Generated title", { shouldPublish: () => false }); expect(catalog.apply).not.toHaveBeenCalled(); }); it("archives a thread, reports exported markdown, and notifies shared surfaces", async () => { const { operations, catalog, notice } = operationsFixture(); archiveMock.archiveThreadOnAppServer.mockResolvedValueOnce({ exportedPath: "Archive/thread.md" } satisfies ArchiveThreadResult); await expect(operations.archiveThread("thread", { saveMarkdown: true, closeOpenPanels: true })).resolves.toEqual({ exportedPath: "Archive/thread.md", }); expect(notice).toHaveBeenCalledWith("Saved archived thread to Archive/thread.md."); expect(catalog.apply).toHaveBeenCalledWith({ type: "thread-archived", threadId: "thread", options: { closeOpenPanels: true }, }); }); it("does not notify surfaces when an operation has no current client", async () => { const { operations, catalog } = operationsFixture({ client: null }); await expect(operations.renameThread("thread", "Title")).rejects.toThrow("No current client."); await expect(operations.archiveThread("thread")).rejects.toThrow("No current client."); expect(catalog.apply).not.toHaveBeenCalled(); }); it("does not publish stale rename results after the current client changes", async () => { const firstClient = clientMock(); const secondClient = clientMock(); let currentClient: MockClient | null = firstClient; const { operations, catalog } = operationsFixture({ client: () => currentClient }); firstClient.setThreadName.mockImplementationOnce(async () => { currentClient = secondClient; }); await expect(operations.renameThread("thread", "Title")).rejects.toThrow("Client changed."); expect(catalog.apply).not.toHaveBeenCalled(); }); it("does not publish stale archive results after the current client changes", async () => { const firstClient = clientMock(); const secondClient = clientMock(); let currentClient: MockClient | null = firstClient; const { operations, catalog } = operationsFixture({ client: () => currentClient }); archiveMock.archiveThreadOnAppServer.mockImplementationOnce(async () => { currentClient = secondClient; return { exportedPath: null } satisfies ArchiveThreadResult; }); await expect(operations.archiveThread("thread")).rejects.toThrow("Client changed."); expect(catalog.apply).not.toHaveBeenCalled(); }); }); function operationsFixture(options: { client?: MockClient | null | (() => MockClient | null) } = {}) { archiveMock.archiveThreadOnAppServer.mockReset(); archiveMock.archiveThreadOnAppServer.mockResolvedValue({ exportedPath: null } satisfies ArchiveThreadResult); const configuredClient = options.client === undefined ? clientMock() : options.client; const currentClient = typeof configuredClient === "function" ? configuredClient : () => configuredClient; const catalog = { apply: vi.fn(), }; const notice = vi.fn(); const host: ThreadOperationsHost = { clientAccess: { withClient: async (operation) => { const client = currentClient() as AppServerClient | null; if (!client) throw new Error("No current client."); const result = await operation(client); if ((currentClient() as AppServerClient | null) !== client) throw new Error("Client changed."); return result; }, }, archiveExport: { settings: () => ({ archiveExportFolderTemplate: DEFAULT_SETTINGS.archiveExportFolderTemplate, archiveExportFilenameTemplate: DEFAULT_SETTINGS.archiveExportFilenameTemplate, archiveExportTags: DEFAULT_SETTINGS.archiveExportTags, }), enabled: () => false, vaultPath: "/vault", vaultConfigDir: "vault-config", }, archiveDestination: () => archiveDestinationMock(), catalog, notice, }; return { operations: createThreadOperations(host), client: currentClient(), catalog, notice }; } type MockClient = ReturnType; function clientMock() { const client = { setThreadName: vi.fn().mockResolvedValue({}), archiveThread: vi.fn().mockResolvedValue({}), }; return { ...client, request: vi.fn((method: string, params: { threadId: string; name?: string }) => { if (method === "thread/name/set") return client.setThreadName(params.threadId, params.name); if (method === "thread/archive") return client.archiveThread(params.threadId); throw new Error(`Unexpected app-server request: ${method}`); }), }; } function archiveDestinationMock(): ArchiveExportDestination { return { normalizePath: (path) => path, exists: vi.fn().mockResolvedValue(false), createFolder: vi.fn().mockResolvedValue(undefined), createMarkdownFile: vi.fn().mockResolvedValue(undefined), }; }