import { beforeEach, describe, expect, it, vi } from "vitest"; import { adoptDeletedSpaceFallback, type MobileBot, rpc, selectedSpaceId, selectSpace, } from "./api"; import { canDeleteInboxSpace, type InboxSpace, removeInboxSpace, retryInboxSpaceFallback, selectInboxSpace, spaceInboxItems, } from "./inbox-spaces"; vi.mock("./api", () => ({ adoptDeletedSpaceFallback: vi.fn(), rpc: vi.fn(), selectedSpaceId: vi.fn(), selectSpace: vi.fn(), })); function space(overrides: Partial = {}): InboxSpace { return { id: "space-work", name: "Work", isDefault: false, hasContent: false, canDelete: true, bots: [], groups: [], botSections: [], ...overrides, }; } const bot: MobileBot = { id: "bot-1", name: "Helper", preview: "", title: "", color: "gray", computerMode: "team", notifyOnFinish: true, threadId: "thread-1", pinned: false, status: "idle", sectionId: null, archivedAt: null, unread: false, updatedAt: "2026-01-01T00:00:00.000Z", modelProvider: null, modelId: null, thinkingLevel: null, }; describe("spaceInboxItems", () => { it("keeps an empty space visible and selectable by its own id", () => { const empty = space(); expect(spaceInboxItems([empty])).toEqual([ { type: "heading", key: empty.id, title: empty.name, space: empty }, ]); }); it("keeps the only empty default space visible without offering deletion", () => { const personal = space({ isDefault: true, canDelete: true }); const items = spaceInboxItems([personal]); expect(items).toHaveLength(1); expect(items[0]).toMatchObject({ type: "heading", space: personal }); expect(canDeleteInboxSpace(personal)).toBe(false); }); it("renders space actions once, separate from pinned and named bot sections", () => { const work = space({ bots: [ { ...bot, pinned: true }, { ...bot, id: "bot-2", sectionId: "section-1" }, ], botSections: [ { id: "section-1", name: "Research", position: 0, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }, ], }); const headings = spaceInboxItems([work]).filter((item) => item.type === "heading"); expect(headings.map((item) => item.title)).toEqual(["Work", "Pinned", "Research"]); expect(headings.filter((item) => item.space)).toEqual([ { type: "heading", key: work.id, title: work.name, space: work }, ]); }); it("keeps populated default-space chrome minimal and retains group rows", () => { const group = { id: "group-1", name: "Planning", preview: "", pinned: false, sectionId: null, archivedAt: null, unread: false, updatedAt: "2026-01-01T00:00:00.000Z", members: [], }; const items = spaceInboxItems([space({ isDefault: true, bots: [bot], groups: [group] })]); expect(items.map((item) => item.type)).toEqual(["bot", "group"]); }); it("lists a project's main conversation instead of its main bot", () => { const main = { id: "group-main", name: "Chief", kind: "main" as const, preview: "", pinned: false, sectionId: null, archivedAt: null, unread: false, updatedAt: "2026-01-01T00:00:00.000Z", members: [], }; const lead: MobileBot = { ...bot, id: "bot-lead", name: "Chief", kind: "main" }; const items = spaceInboxItems([space({ bots: [lead, bot], groups: [main] })]); expect(items.map((item) => (item.type === "heading" ? item.title : item.type))).toEqual([ "Work", "bot", "group", ]); expect(items.some((item) => item.type === "bot" && item.bot.id === lead.id)).toBe(false); }); it("keeps empty spaces beside a populated space", () => { const personal = space({ id: "personal", isDefault: true, bots: [bot] }); expect(spaceInboxItems([personal, space()]).map((item) => item.type)).toEqual([ "heading", "bot", "heading", ]); }); it.each([undefined, false])("hides deletion unless ownership is explicit (%s)", (canDelete) => { expect(canDeleteInboxSpace(space({ canDelete }))).toBe(false); }); it("offers deletion for an explicitly authorized non-default space", () => { expect(canDeleteInboxSpace(space())).toBe(true); expect(canDeleteInboxSpace(space({ hasContent: true, canDelete: true }))).toBe(true); }); it("deduplicates same-named projects into a single project entry", () => { const first = space({ id: "space-work-1", name: "Work", hasContent: false }); const second = space({ id: "space-work-2", name: " work ", hasContent: true, bots: [bot] }); const items = spaceInboxItems([first, second]); const headings = items.filter((item) => item.type === "heading" && item.space); expect(headings).toHaveLength(1); expect(headings[0]).toMatchObject({ key: "space-work-2", title: " work " }); }); }); describe("removeInboxSpace", () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(selectedSpaceId).mockReturnValue("space-work"); vi.mocked(rpc).mockResolvedValue({ ok: true, activeSpaceId: "personal" }); vi.mocked(selectSpace).mockResolvedValue(true); vi.mocked(adoptDeletedSpaceFallback).mockResolvedValue(true); }); it("awaits saving the server fallback before refreshing a deleted current space", async () => { let finishSelection!: (saved: boolean) => void; vi.mocked(selectSpace).mockImplementation( () => new Promise((resolve) => { finishSelection = resolve; }), ); const refresh = vi.fn(async () => undefined); const removing = removeInboxSpace("space-work", refresh); await vi.waitFor(() => expect(selectSpace).toHaveBeenCalledWith("personal")); expect(refresh).not.toHaveBeenCalled(); finishSelection(true); await expect(removing).resolves.toBeNull(); expect(rpc).toHaveBeenCalledWith("spaces/remove", { spaceId: "space-work" }); expect(refresh).toHaveBeenCalledOnce(); }); it("keeps the selection when deleting another space", async () => { vi.mocked(selectedSpaceId).mockReturnValue("personal"); const refresh = vi.fn(async () => undefined); await expect(removeInboxSpace("space-work", refresh)).resolves.toBeNull(); expect(selectSpace).not.toHaveBeenCalled(); expect(refresh).toHaveBeenCalledOnce(); }); it("adopts and refreshes the server fallback when direct persistence fails", async () => { vi.mocked(selectSpace).mockResolvedValue(false); const refresh = vi.fn(async () => undefined); await expect(removeInboxSpace("space-work", refresh)).resolves.toBeNull(); expect(adoptDeletedSpaceFallback).toHaveBeenCalledWith("personal"); expect(refresh).toHaveBeenCalledOnce(); expect(rpc).toHaveBeenCalledOnce(); }); it("retains a retry when neither fallback recovery write is durable", async () => { vi.mocked(selectSpace).mockResolvedValue(false); vi.mocked(adoptDeletedSpaceFallback).mockResolvedValue(false); const refresh = vi.fn(async () => undefined); const recoveryId = await removeInboxSpace("space-work", refresh); expect(recoveryId).toBe("personal"); expect(refresh).toHaveBeenCalledOnce(); vi.mocked(adoptDeletedSpaceFallback).mockResolvedValue(true); await expect(retryInboxSpaceFallback(recoveryId!, refresh)).resolves.toBe(true); expect(adoptDeletedSpaceFallback).toHaveBeenCalledTimes(2); expect(adoptDeletedSpaceFallback).toHaveBeenLastCalledWith("personal"); expect(selectSpace).toHaveBeenCalledOnce(); expect(refresh).toHaveBeenCalledTimes(2); expect(rpc).toHaveBeenCalledOnce(); }); it("keeps the retry visible when fallback persistence still fails", async () => { vi.mocked(adoptDeletedSpaceFallback).mockResolvedValue(false); const refresh = vi.fn(async () => undefined); await expect(retryInboxSpaceFallback("personal", refresh)).resolves.toBe(false); expect(refresh).not.toHaveBeenCalled(); expect(rpc).not.toHaveBeenCalled(); }); it("selects an empty inbox without creating a bot or starting onboarding", async () => { const refresh = vi.fn(async () => undefined); await expect(selectInboxSpace("space-work", refresh)).resolves.toBe(true); expect(selectSpace).toHaveBeenCalledWith("space-work"); expect(refresh).toHaveBeenCalledOnce(); expect(rpc).not.toHaveBeenCalled(); }); it("does not refresh when selecting an empty space fails", async () => { vi.mocked(selectSpace).mockResolvedValue(false); const refresh = vi.fn(async () => undefined); await expect(selectInboxSpace("space-work", refresh)).resolves.toBe(false); expect(refresh).not.toHaveBeenCalled(); expect(rpc).not.toHaveBeenCalled(); }); it("also adopts the fallback when native selection throws", async () => { vi.mocked(selectSpace).mockRejectedValue(new Error("storage unavailable")); const refresh = vi.fn(async () => undefined); await expect(removeInboxSpace("space-work", refresh)).resolves.toBeNull(); expect(adoptDeletedSpaceFallback).toHaveBeenCalledWith("personal"); expect(refresh).toHaveBeenCalledOnce(); }); it("leaves selection and inbox unchanged when deletion is rejected", async () => { vi.mocked(rpc).mockRejectedValue(new Error("Space is not empty")); const refresh = vi.fn(async () => undefined); await expect(removeInboxSpace("space-work", refresh)).rejects.toThrow("Space is not empty"); expect(selectSpace).not.toHaveBeenCalled(); expect(refresh).not.toHaveBeenCalled(); }); });