import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"; import { apiClient, type ChatChannel, type ChatMessage, type ChatNotification, type ChatStateResponse, } from "../../../api-client"; import { ApiRequestError } from "../../../api-client/errors"; import { MemoryPluginPersistence as MemoryPersistence } from "../../../test-support/plugin-persistence"; import type { AppNotificationRequest } from "../../../types/plugin"; import { ChatController } from "./controller"; import { SESSION_RETRY_MS } from "./controller/state"; const testControllers = new Set(); function createController(): ChatController { const controller = new ChatController(); testControllers.add(controller); return controller; } function persistSession(persistence: MemoryPersistence, user: Record = {}) { persistence.setState("session", { sessionToken: "token-123", user: { id: "u1", username: "vince", ...user }, }, { schemaVersion: 1 }); } function chatMessage(input: Pick & Partial): ChatMessage { return { channelId: "everyone", replyToId: null, user: { id: "u1", username: "vince", displayName: "Vince" }, ...input }; } const TRANSCRIPT_KIND = "channel-transcript"; const TRANSCRIPT_KEY = "everyone"; const TRANSCRIPT_SOURCE = "server"; const TRANSCRIPT_SCHEMA_VERSION = 2; const originalConnectChannel = apiClient.connectChannel.bind(apiClient); const originalGetSession = apiClient.getSession.bind(apiClient); const originalGetMessages = apiClient.getMessages.bind(apiClient); const originalGetChannels = apiClient.getChannels.bind(apiClient); const originalGetChatPresence = apiClient.getChatPresence.bind(apiClient); const originalGetChatState = apiClient.getChatState.bind(apiClient); const originalGetAccountProfile = apiClient.getAccountProfile.bind(apiClient); const originalUpdateChatChannelState = apiClient.updateChatChannelState.bind(apiClient); const originalMarkChatNotificationsDelivered = apiClient.markChatNotificationsDelivered.bind(apiClient); const originalSubscribeChatNotifications = apiClient.subscribeChatNotifications.bind(apiClient); const originalSubscribeChatPresence = apiClient.subscribeChatPresence.bind(apiClient); const originalEditMessage = apiClient.editMessage.bind(apiClient); const SERVER_CHAT_CHANNELS: ChatChannel[] = [ { id: "everyone", name: "everyone", created_at: "2026-03-26T12:10:05.684Z" }, { id: "equities", name: "equities", created_at: "2026-05-09T00:00:00.000Z" }, { id: "options", name: "options", created_at: "2026-05-09T00:00:00.000Z" }, { id: "help", name: "help", created_at: "2026-05-09T00:00:00.000Z" }, ]; async function flushMicrotasks() { await Promise.resolve(); await Promise.resolve(); } function recentChatTimestamp(offsetMs = 60_000) { return new Date(Date.now() - offsetMs).toISOString(); } function mentionNotification(overrides: Partial = {}): ChatNotification { const channelId = overrides.channelId ?? "everyone"; const messageId = overrides.messageId ?? "m1"; const createdAt = overrides.createdAt ?? "2026-03-28T00:00:00.000Z"; return { id: overrides.id ?? "n1", type: overrides.type ?? "mention", channelId, messageId, createdAt, message: overrides.message ?? { id: messageId, channelId, content: "hey @vince", replyToId: null, createdAt, user: { id: "u2", username: "bob", displayName: "Bob" }, }, }; } class TrackingPersistence extends MemoryPersistence { stateWrites = 0; override setState(key: string, value: unknown, options?: { schemaVersion?: number }): void { this.stateWrites += 1; super.setState(key, value, options); } } beforeEach(() => { apiClient.getChatPresence = async () => ({ onlineCount: 0 }); apiClient.getChatState = async () => ({ channels: SERVER_CHAT_CHANNELS, onlineCount: 0, channelStates: SERVER_CHAT_CHANNELS.map((channel) => ({ channelId: channel.id, notificationsEnabled: false, lastReadMessageId: null, unreadCount: 0, })), notifications: [], }); apiClient.updateChatChannelState = async (channelId, body) => ({ channelId, notificationsEnabled: body.notificationsEnabled ?? false, lastReadMessageId: body.readThroughMessageId ?? null, unreadCount: 0, }); apiClient.markChatNotificationsDelivered = async () => ({ delivered: 1 }); apiClient.subscribeChatNotifications = () => () => {}; apiClient.subscribeChatPresence = () => () => {}; }); afterEach(() => { for (const controller of testControllers) controller.dispose(); testControllers.clear(); jest.useRealTimers(); apiClient.dispose(); apiClient.setSessionToken(null); apiClient.setCookieSessionMode(false); apiClient.connectChannel = originalConnectChannel; apiClient.getSession = originalGetSession; apiClient.getMessages = originalGetMessages; apiClient.getChannels = originalGetChannels; apiClient.getChatPresence = originalGetChatPresence; apiClient.getChatState = originalGetChatState; apiClient.getAccountProfile = originalGetAccountProfile; apiClient.updateChatChannelState = originalUpdateChatChannelState; apiClient.markChatNotificationsDelivered = originalMarkChatNotificationsDelivered; apiClient.subscribeChatNotifications = originalSubscribeChatNotifications; apiClient.subscribeChatPresence = originalSubscribeChatPresence; apiClient.editMessage = originalEditMessage; }); describe("ChatController", () => { test("hydrates cached session, draft, and transcript from plugin persistence", () => { const persistence = new MemoryPersistence(); const controller = createController(); const message: ChatMessage = chatMessage({ id: "m1", content: "hello", createdAt: "2026-03-28T00:00:00.000Z", }); persistSession(persistence); persistence.setState("channel:everyone", { draft: "cached draft", replyToId: "m1", lastCursor: "2026-03-28T00:00:00.000Z", lastViewedMessageId: "m1", }, { schemaVersion: 1 }); persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, { messages: [message], }, { sourceKey: TRANSCRIPT_SOURCE, schemaVersion: TRANSCRIPT_SCHEMA_VERSION, cachePolicy: { staleMs: 1_000, expireMs: 2_000 }, }); controller.attachPersistence(persistence); const snapshot = controller.getSnapshot(); expect(apiClient.getSessionToken()).toBe("token-123"); expect(snapshot.user?.username).toBe("vince"); expect(snapshot.draft).toBe("cached draft"); expect(snapshot.replyToId).toBe("m1"); expect(snapshot.messages.map((entry) => entry.id)).toEqual(["m1"]); }); test("uses a browser cookie session without exposing its token", async () => { const persistence = new MemoryPersistence(); const controller = createController(); const sentMessages: string[] = []; apiClient.setCookieSessionMode(true); apiClient.restoreCachedUser({ id: "u1", username: "vince", emailVerified: true, }); apiClient.getSession = async () => apiClient.getCurrentUser(); apiClient.connectChannel = () => ({ send: async (content) => { sentMessages.push(content); return { id: "m1", channelId: "everyone", content, replyToId: null, createdAt: "2026-08-23T00:00:00.000Z", user: { id: "u1", username: "vince", displayName: "Vince" }, }; }, close: () => {}, }); controller.attachPersistence(persistence); await controller.refreshSession(); expect(apiClient.getSessionToken()).toBeNull(); expect(controller.getSnapshot()).toMatchObject({ hasSavedSession: true, user: { id: "u1", username: "vince", emailVerified: true }, }); expect(controller.send("hello from the browser")).toBe(true); await flushMicrotasks(); expect(sentMessages).toEqual(["hello from the browser"]); controller.dispose(); }); test("rejects unknown shortcut channels after the server list loads", async () => { const controller = createController(); apiClient.getChannels = async () => SERVER_CHAT_CHANNELS; await expect(controller.resolveRequiredChannelId("help")).resolves.toBe("help"); await expect(controller.resolveRequiredChannelId("made-up")).rejects.toThrow( 'Unknown chat channel "#made-up".', ); }); test("keeps private channels when a public refresh finishes after chat state", async () => { const persistence = new MemoryPersistence(); const controller = createController(); const directChannel: ChatChannel = { id: "dm:u2", name: "u2", kind: "direct", created_at: "2026-03-28T00:00:00.000Z", }; const teamChannel: ChatChannel = { id: "team:org-1:trades", name: "trades", kind: "team", teamId: "org-1", created_at: "2026-09-14T00:00:00.000Z", }; let resolvePublicChannels: ((channels: ChatChannel[]) => void) | undefined; persistSession(persistence, { emailVerified: true }); apiClient.getChannels = () => new Promise((resolve) => { resolvePublicChannels = resolve; }); apiClient.getChatState = async () => ({ channels: [...SERVER_CHAT_CHANNELS, directChannel, teamChannel], onlineCount: 0, channelStates: [], notifications: [], }); controller.attachPersistence(persistence); const publicRefresh = controller.refreshChannels(); await controller.refreshChatState(); expect(controller.getChannels().map((channel) => channel.id)).toContain(directChannel.id); expect(controller.getChannels().map((channel) => channel.id)).toContain(teamChannel.id); resolvePublicChannels!(SERVER_CHAT_CHANNELS); await publicRefresh; expect(controller.getChannels().map((channel) => channel.id)).toContain(directChannel.id); expect(controller.getChannels().map((channel) => channel.id)).toContain(teamChannel.id); }); test("hydrates a cached verified user into the api client for offline use", async () => { const persistence = new MemoryPersistence(); const controller = createController(); persistSession(persistence, { emailVerified: true }); controller.attachPersistence(persistence); expect(apiClient.getCurrentUser()).toMatchObject({ id: "u1", username: "vince", emailVerified: true, }); await expect(apiClient.ensureVerifiedSession()).resolves.toMatchObject({ id: "u1", username: "vince", emailVerified: true, }); }); test("does not restore persisted websocket tokens", () => { const persistence = new MemoryPersistence(); const controller = createController(); persistence.setState("session", { sessionToken: "token-123", websocketToken: "stale-ws-token", user: { id: "u1", username: "vince", emailVerified: true }, }, { schemaVersion: 1 }); controller.attachPersistence(persistence); expect(apiClient.getSessionToken()).toBe("token-123"); expect(apiClient.getWebSocketToken()).toBeNull(); expect(apiClient.getCurrentUser()).toMatchObject({ id: "u1", username: "vince", emailVerified: true, }); }); test("reset clears persisted chat state and session token", () => { const persistence = new MemoryPersistence(); const controller = createController(); persistSession(persistence); persistence.setState("channel:everyone", { draft: "cached draft", replyToId: null, lastCursor: null, lastViewedMessageId: null, }, { schemaVersion: 1 }); persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, { messages: [], }, { sourceKey: TRANSCRIPT_SOURCE, schemaVersion: TRANSCRIPT_SCHEMA_VERSION, cachePolicy: { staleMs: 1_000, expireMs: 2_000 }, }); controller.attachPersistence(persistence); controller.reset(true); expect(apiClient.getSessionToken()).toBeNull(); expect(persistence.getState("session", { schemaVersion: 1 })).toBeNull(); expect(persistence.getResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, { sourceKey: TRANSCRIPT_SOURCE, schemaVersion: TRANSCRIPT_SCHEMA_VERSION, })).toBeNull(); }); test("defers draft persistence and subscriber sync until the user pauses or leaves", () => { const persistence = new TrackingPersistence(); const controller = createController(); const draftSnapshots: string[] = []; controller.attachPersistence(persistence); const unsubscribe = controller.subscribe((snapshot) => { draftSnapshots.push(snapshot.draft); }); draftSnapshots.length = 0; persistence.stateWrites = 0; controller.setDraft("h"); controller.setDraft("he"); expect(controller.getSnapshot().draft).toBe("he"); expect(draftSnapshots).toEqual([]); expect(persistence.stateWrites).toBe(0); expect(persistence.getState("channel:everyone", { schemaVersion: 1 })).toBeNull(); unsubscribe(); controller.dispose(); expect(persistence.getState<{ draft: string }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({ draft: "he", }); }); test("pauses verification polling while the app is backgrounded", () => { const controller = createController(); apiClient.setSessionToken("token-123"); (controller as any).session.sessionToken = "token-123"; (controller as any).session.user = { id: "u1", username: "vince", emailVerified: false }; controller.setAppActive(false); (controller as any).realtime.syncVerificationPolling(); expect((controller as any).realtime.verificationPollTimer).toBeNull(); controller.setAppActive(true); expect((controller as any).realtime.verificationPollTimer).not.toBeNull(); controller.reset(true); }); test("dispose stops verification polling and closes the live connection", () => { const controller = createController(); let closed = false; apiClient.setSessionToken("token-123"); (controller as any).session.sessionToken = "token-123"; (controller as any).session.user = { id: "u1", username: "vince", emailVerified: false }; (controller as any).realtime.syncVerificationPolling(); expect((controller as any).realtime.verificationPollTimer).not.toBeNull(); (controller as any).session.user = { id: "u1", username: "vince", emailVerified: true }; apiClient.connectChannel = () => ({ send: async () => { throw new Error("not implemented"); }, close: () => { closed = true; }, }); controller.ensureConnection(); controller.dispose(); expect(closed).toBe(true); expect((controller as any).realtime.verificationPollTimer).toBeNull(); }); test("runs a quiet safety refresh while the live connection is active", async () => { const persistence = new MemoryPersistence(); const controller = createController(); const originalSetInterval = globalThis.setInterval; const originalClearInterval = globalThis.clearInterval; const intervalCallbacks: Array<() => void> = []; const intervalHandle = { unref: () => {} }; let cleared = false; let getMessagesCalls = 0; const loadingSnapshots: boolean[] = []; persistSession(persistence, { emailVerified: true }); (globalThis as any).setInterval = (callback: () => void, timeout: number) => { expect(timeout).toBe(30_000); intervalCallbacks.push(callback); return intervalHandle; }; (globalThis as any).clearInterval = (handle: unknown) => { if (handle === intervalHandle) { cleared = true; } }; try { apiClient.getMessages = async () => { getMessagesCalls += 1; return []; }; apiClient.connectChannel = () => ({ send: async () => { throw new Error("not implemented"); }, close: () => {}, }); controller.attachPersistence(persistence); controller.ensureConnection(); await flushMicrotasks(); expect(intervalCallbacks).toHaveLength(1); expect(getMessagesCalls).toBe(1); expect(controller.getSnapshot().loading).toBe(false); controller.subscribe((snapshot) => { loadingSnapshots.push(snapshot.loading); }); loadingSnapshots.length = 0; intervalCallbacks[0]!(); await flushMicrotasks(); expect(getMessagesCalls).toBe(2); expect(loadingSnapshots).toEqual([false]); controller.clearSession(); expect((controller as any).realtime.safetyRefreshTimer).toBeNull(); expect(cleared).toBe(true); } finally { controller.dispose(); (globalThis as any).setInterval = originalSetInterval; (globalThis as any).clearInterval = originalClearInterval; } }); test("validates a persisted native session through protected chat state", async () => { const persistence = new MemoryPersistence(); const controller = createController(); const directChannel: ChatChannel = { id: "dm:u2", name: "u2", kind: "direct", created_at: "2026-03-28T00:00:00.000Z", }; persistSession(persistence, { emailVerified: true }); controller.attachPersistence(persistence); apiClient.getSession = async () => { apiClient.restoreCachedUser(null); return null; }; apiClient.getChatState = async () => ({ channels: [...SERVER_CHAT_CHANNELS, directChannel], onlineCount: 0, channelStates: [], notifications: [], }); await controller.refreshSession(); expect(apiClient.getSessionToken()).toBe("token-123"); expect(apiClient.getCurrentUser()?.id).toBe("u1"); expect(controller.getChannels()).toContainEqual(directChannel); expect(persistence.getState<{ sessionToken: string; user: { id: string; username: string; emailVerified: boolean }; }>("session", { schemaVersion: 1 })).toEqual({ sessionToken: "token-123", user: { id: "u1", username: "vince", emailVerified: true }, }); expect(controller.getSnapshot().user).toEqual({ id: "u1", username: "vince", emailVerified: true, }); controller.dispose(); }); test("clears an expired persisted session when protected chat rejects it", async () => { const persistence = new MemoryPersistence(); const controller = createController(); persistence.setState("session", { sessionToken: "expired-token", user: { id: "u1", username: "vince", emailVerified: true }, }, { schemaVersion: 1 }); controller.attachPersistence(persistence); apiClient.getSession = async () => { apiClient.restoreCachedUser(null); return null; }; apiClient.getChatState = async () => { throw new ApiRequestError("Unauthorized", 401); }; await controller.refreshSession(); expect(apiClient.getSessionToken()).toBeNull(); expect(apiClient.getCurrentUser()).toBeNull(); expect(controller.getSnapshot().user).toBeNull(); expect(persistence.getState("session", { schemaVersion: 1 })).toEqual({ sessionToken: null, user: null, }); controller.dispose(); }); test("clears an expired persisted session for an unverified user", async () => { const persistence = new MemoryPersistence(); const controller = createController(); let profileRequests = 0; persistence.setState("session", { sessionToken: "expired-token", user: { id: "u1", username: "vince", emailVerified: false }, }, { schemaVersion: 1 }); controller.attachPersistence(persistence); apiClient.getSession = async () => { apiClient.restoreCachedUser(null); return null; }; apiClient.getAccountProfile = async () => { profileRequests += 1; throw new ApiRequestError("Unauthorized", 401); }; await controller.refreshSession(); expect(profileRequests).toBe(1); expect(apiClient.getSessionToken()).toBeNull(); expect(apiClient.getCurrentUser()).toBeNull(); expect(controller.getSnapshot().user).toBeNull(); controller.dispose(); }); test("downgrades stale verification state when protected chat rejects it", async () => { const persistence = new MemoryPersistence(); const controller = createController(); persistSession(persistence, { emailVerified: true }); controller.attachPersistence(persistence); apiClient.getSession = async () => { apiClient.restoreCachedUser(null); return null; }; apiClient.getChatState = async () => { throw new ApiRequestError("Email verification required", 403); }; await controller.refreshSession(); expect(apiClient.getSessionToken()).toBe("token-123"); expect(apiClient.getCurrentUser()?.emailVerified).toBe(false); expect(controller.getSnapshot().user?.emailVerified).toBe(false); expect(persistence.getState<{ sessionToken: string; user: { id: string; username: string; emailVerified: boolean }; }>("session", { schemaVersion: 1 })?.user.emailVerified).toBe(false); controller.dispose(); }); test("does not let a stale validation failure clear a replacement session", async () => { const persistence = new MemoryPersistence(); const controller = createController(); let rejectProbe: ((error: Error) => void) | null = null; let markProbeStarted: (() => void) | null = null; const probeStarted = new Promise((resolve) => { markProbeStarted = resolve; }); persistence.setState("session", { sessionToken: "old-token", user: { id: "u1", username: "vince", emailVerified: true }, }, { schemaVersion: 1 }); controller.attachPersistence(persistence); apiClient.getSession = async () => { apiClient.restoreCachedUser(null); return null; }; apiClient.getChatState = () => new Promise((_, reject) => { rejectProbe = reject; markProbeStarted?.(); }); const refresh = controller.refreshSession(); await probeStarted; controller.adoptSession("new-token", { id: "u2", username: "mara", emailVerified: true, }); rejectProbe?.(new ApiRequestError("Unauthorized", 401)); await refresh; expect(apiClient.getSessionToken()).toBe("new-token"); expect(apiClient.getCurrentUser()?.id).toBe("u2"); expect(controller.getSnapshot().user?.id).toBe("u2"); expect(persistence.getState<{ sessionToken: string; user: { id: string }; }>("session", { schemaVersion: 1 })).toMatchObject({ sessionToken: "new-token", user: { id: "u2" }, }); controller.dispose(); }); test("does not apply stale chat state after a replacement session", async () => { const persistence = new MemoryPersistence(); const controller = createController(); let resolveProbe: ((state: ChatStateResponse) => void) | null = null; let markProbeStarted: (() => void) | null = null; const probeStarted = new Promise((resolve) => { markProbeStarted = resolve; }); const oldDirectChannel: ChatChannel = { id: "dm:old-account", name: "old-account", kind: "direct", created_at: "2026-03-28T00:00:00.000Z", }; persistence.setState("session", { sessionToken: "old-token", user: { id: "u1", username: "vince", emailVerified: true }, }, { schemaVersion: 1 }); controller.attachPersistence(persistence); apiClient.getSession = async () => { apiClient.restoreCachedUser(null); return null; }; apiClient.getChatState = () => new Promise((resolve) => { resolveProbe = resolve; markProbeStarted?.(); }); const refresh = controller.refreshSession(); await probeStarted; controller.adoptSession("new-token", { id: "u2", username: "mara", emailVerified: true, }); resolveProbe?.({ channels: [...SERVER_CHAT_CHANNELS, oldDirectChannel], onlineCount: 0, channelStates: [], notifications: [], }); await refresh; expect(apiClient.getSessionToken()).toBe("new-token"); expect(controller.getSnapshot().user?.id).toBe("u2"); expect(controller.getChannels()).not.toContainEqual(oldDirectChannel); controller.dispose(); }); test("revalidates a persisted session after an offline validation probe", async () => { jest.useFakeTimers(); const persistence = new MemoryPersistence(); const controller = createController(); persistSession(persistence, { emailVerified: true }); controller.attachPersistence(persistence); apiClient.getSession = async () => { apiClient.restoreCachedUser(null); return null; }; apiClient.getChatState = async () => { throw new Error("network down"); }; await controller.refreshSession(); expect(apiClient.getSessionToken()).toBe("token-123"); expect(apiClient.getCurrentUser()?.id).toBe("u1"); expect(controller.getSnapshot().user?.id).toBe("u1"); expect((controller as any).realtime.sessionRetryTimer).not.toBeNull(); apiClient.getChatState = async () => { throw new ApiRequestError("Unauthorized", 401); }; jest.advanceTimersByTime(SESSION_RETRY_MS); await controller.refreshSession(); expect(apiClient.getSessionToken()).toBeNull(); expect(controller.getSnapshot().user).toBeNull(); expect((controller as any).realtime.sessionRetryTimer).toBeNull(); controller.dispose(); jest.useRealTimers(); }); test("signs out when get-session returns no user and no token is stored", async () => { const persistence = new MemoryPersistence(); const controller = createController(); persistence.setState("session", { sessionToken: null, user: { id: "u1", username: "vince", emailVerified: true }, }, { schemaVersion: 1 }); controller.attachPersistence(persistence); apiClient.getSession = async () => null; await controller.refreshSession(); expect(apiClient.getSessionToken()).toBeNull(); expect(controller.getSnapshot().user).toBeNull(); controller.dispose(); }); test("keeps the cached session when session refresh fails transiently", async () => { const persistence = new MemoryPersistence(); const controller = createController(); persistSession(persistence, { emailVerified: true }); controller.attachPersistence(persistence); apiClient.getSession = async () => { throw new Error("network down"); }; await expect(controller.refreshSession()).rejects.toThrow("network down"); expect(apiClient.getSessionToken()).toBe("token-123"); expect(persistence.getState<{ sessionToken: string; user: { id: string; username: string; emailVerified: boolean }; }>("session", { schemaVersion: 1 })).toEqual({ sessionToken: "token-123", user: { id: "u1", username: "vince", emailVerified: true }, }); expect(controller.getSnapshot().user).toEqual({ id: "u1", username: "vince", emailVerified: true, }); expect((controller as any).realtime.sessionRetryTimer).not.toBeNull(); controller.dispose(); }); test("refreshes the public transcript without requiring a session", async () => { const persistence = new MemoryPersistence(); const controller = createController(); const message: ChatMessage = chatMessage({ id: "m1", content: "hello from the lobby", createdAt: "2026-03-28T00:00:00.000Z", }); controller.attachPersistence(persistence); apiClient.getMessages = async () => [message]; await controller.refreshMessages(); expect(controller.getSnapshot().messages).toEqual([message]); expect(persistence.getResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, { sourceKey: TRANSCRIPT_SOURCE, schemaVersion: TRANSCRIPT_SCHEMA_VERSION, })?.value).toEqual({ messages: [message], }); }); test("keeps per-channel drafts and transcripts isolated", () => { const persistence = new MemoryPersistence(); const controller = createController(); const everyoneMessage: ChatMessage = chatMessage({ id: "m1", content: "general", createdAt: "2026-03-28T00:00:00.000Z", }); const optionsMessage: ChatMessage = chatMessage({ id: "m2", channelId: "options", content: "options note", createdAt: "2026-03-28T00:01:00.000Z", user: { id: "u2", username: "bob", displayName: "Bob" }, }); persistence.setState("channel:everyone", { draft: "general draft", replyToId: null, lastCursor: "m1", lastViewedMessageId: "m1", }, { schemaVersion: 1 }); persistence.setState("channel:options", { draft: "options draft", replyToId: null, lastCursor: "m2", lastViewedMessageId: "m2", }, { schemaVersion: 1 }); persistence.setResource(TRANSCRIPT_KIND, "everyone", { messages: [everyoneMessage] }, { sourceKey: TRANSCRIPT_SOURCE, schemaVersion: TRANSCRIPT_SCHEMA_VERSION, cachePolicy: { staleMs: 1_000, expireMs: 2_000 }, }); persistence.setResource(TRANSCRIPT_KIND, "options", { messages: [optionsMessage] }, { sourceKey: TRANSCRIPT_SOURCE, schemaVersion: TRANSCRIPT_SCHEMA_VERSION, cachePolicy: { staleMs: 1_000, expireMs: 2_000 }, }); controller.attachPersistence(persistence); controller.setChannelDraft("options", "updated options"); expect(controller.getSnapshot().messages.map((entry) => entry.content)).toEqual(["general"]); expect(controller.getSnapshot().draft).toBe("general draft"); expect(controller.getSnapshot("options").messages.map((entry) => entry.content)).toEqual(["options note"]); expect(controller.getSnapshot("options").draft).toBe("updated options"); }); test("stores the latest message id as the incremental cursor", async () => { const persistence = new MemoryPersistence(); const controller = createController(); const initial: ChatMessage = chatMessage({ id: "m1", content: "hello", createdAt: "2026-03-28T00:00:00.000Z", }); const next: ChatMessage = chatMessage({ id: "m2", content: "new message", createdAt: "2026-03-28T00:01:00.000Z", user: { id: "u2", username: "bob", displayName: "Bob" }, }); persistence.setState("channel:everyone", { draft: "", replyToId: null, lastCursor: "m1", lastViewedMessageId: "m1", }, { schemaVersion: 1 }); persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, { messages: [initial], }, { sourceKey: TRANSCRIPT_SOURCE, schemaVersion: TRANSCRIPT_SCHEMA_VERSION, cachePolicy: { staleMs: 1_000, expireMs: 2_000 }, }); controller.attachPersistence(persistence); const calls: Array<{ channelId: string; opts?: { after?: string; before?: string; limit?: number } }> = []; apiClient.getMessages = async (channelId, opts) => { calls.push({ channelId, opts }); if (!opts?.after) return [initial, next]; return opts.after === "m1" ? [next] : []; }; await controller.refreshMessages(); expect(calls[0]?.opts?.after).toBeUndefined(); expect(controller.getSnapshot().messages.map((entry) => entry.id)).toEqual(["m1", "m2"]); expect(persistence.getState<{ lastCursor: string }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({ lastCursor: "m2", }); await controller.refreshMessages(); expect(calls[1]?.opts?.after).toBe("m2"); }); test("recovers a message the cursor already moved past", async () => { const persistence = new MemoryPersistence(); const controller = createController(); const asked: ChatMessage = chatMessage({ id: "m1", content: "why is the chart glitching", createdAt: "2026-03-28T00:00:00.000Z", }); const missed: ChatMessage = chatMessage({ id: "m2", content: "charts open with GP", replyToId: "m1", createdAt: "2026-03-28T00:01:00.000Z", user: { id: "u2", username: "gloombot", displayName: "Gloombot" }, }); const acknowledged: ChatMessage = chatMessage({ id: "m3", content: "got it", createdAt: "2026-03-28T00:02:00.000Z", }); // The cursor already sits on m3, so an incremental fetch can never ask for // m2 again even though the server still has it. persistence.setState("channel:everyone", { draft: "", replyToId: null, lastCursor: "m3", lastViewedMessageId: "m3", }, { schemaVersion: 1 }); persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, { messages: [asked, acknowledged], }, { sourceKey: TRANSCRIPT_SOURCE, schemaVersion: TRANSCRIPT_SCHEMA_VERSION, cachePolicy: { staleMs: 1_000, expireMs: 2_000 }, }); controller.attachPersistence(persistence); apiClient.getMessages = async (_channelId, opts) => ( opts?.after ? [] : [asked, missed, acknowledged] ); await controller.refreshMessages(); expect(controller.getSnapshot().messages.map((entry) => entry.id)).toEqual(["m1", "m2", "m3"]); }); test("hydrates missing transcript cache without marking history unread", async () => { const persistence = new MemoryPersistence(); const controller = createController(); const notifications: AppNotificationRequest[] = []; const history: ChatMessage = chatMessage({ id: "m1", content: "already seen", createdAt: "2026-03-28T00:00:00.000Z", user: { id: "u2", username: "bob", displayName: "Bob" }, }); persistSession(persistence, { emailVerified: true }); persistence.setState("channel:everyone", { draft: "", replyToId: null, lastCursor: "m1", lastViewedMessageId: "m1", }, { schemaVersion: 1 }); controller.setNotifier((notification) => { notifications.push(notification); }); controller.attachPersistence(persistence); const calls: Array<{ channelId: string; opts?: { after?: string; before?: string; limit?: number } }> = []; apiClient.getMessages = async (channelId, opts) => { calls.push({ channelId, opts }); return opts?.after ? [] : [history]; }; await controller.refreshMessages(); expect(calls).toEqual([{ channelId: "everyone", opts: { limit: 50, after: undefined }, }]); expect(controller.getSnapshot().messages).toEqual([history]); expect(controller.getSnapshot().channelStates.find((entry) => entry.channelId === "everyone")).toMatchObject({ unreadCount: 0, }); expect(notifications).toEqual([]); }); test("backfills from cached transcript when persisted cursor is ahead of the cache", async () => { const persistence = new MemoryPersistence(); const controller = createController(); const cached: ChatMessage = chatMessage({ id: "m1", content: "cached", createdAt: "2026-03-28T00:00:00.000Z", }); const fresh: ChatMessage = chatMessage({ id: "m2", content: "fresh", createdAt: "2026-03-28T00:01:00.000Z", user: { id: "u2", username: "bob", displayName: "Bob" }, }); persistence.setState("channel:everyone", { draft: "", replyToId: null, lastCursor: "m99", lastViewedMessageId: "m1", }, { schemaVersion: 1 }); persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, { messages: [cached], }, { sourceKey: TRANSCRIPT_SOURCE, schemaVersion: TRANSCRIPT_SCHEMA_VERSION, cachePolicy: { staleMs: 1_000, expireMs: 2_000 }, }); controller.attachPersistence(persistence); const calls: Array<{ channelId: string; opts?: { after?: string; before?: string; limit?: number } }> = []; apiClient.getMessages = async (channelId, opts) => { calls.push({ channelId, opts }); return opts?.after ? [] : [cached, fresh]; }; await controller.refreshMessages(); expect(calls[0]?.opts?.after).toBeUndefined(); expect(controller.getSnapshot().messages.map((entry) => entry.id)).toEqual(["m1", "m2"]); expect(persistence.getState<{ lastCursor: string }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({ lastCursor: "m2", }); await controller.refreshMessages(); // The stale m99 cursor never reaches the server. expect(calls[1]?.opts?.after).toBe("m2"); }); test("shows a pending message immediately and replaces it when the send succeeds", async () => { const persistence = new MemoryPersistence(); const controller = createController(); const replyTarget: ChatMessage = chatMessage({ id: "m1", content: "first", createdAt: "2026-03-28T00:00:00.000Z", user: { id: "u2", username: "bob", displayName: "Bob" }, }); const sentMessage: ChatMessage = chatMessage({ id: "m2", content: "hello", replyToId: "m1", createdAt: "2026-03-28T00:01:00.000Z", replyTo: { content: "first", user: { username: "bob" } }, }); persistSession(persistence, { emailVerified: true }); persistence.setState("channel:everyone", { draft: "hello", replyToId: "m1", lastCursor: "m1", lastViewedMessageId: "m1", }, { schemaVersion: 1 }); persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, { messages: [replyTarget], }, { sourceKey: TRANSCRIPT_SOURCE, schemaVersion: TRANSCRIPT_SCHEMA_VERSION, cachePolicy: { staleMs: 1_000, expireMs: 2_000 }, }); controller.attachPersistence(persistence); let resolveSend: (message: ChatMessage) => void = () => { throw new Error("send resolver was not captured"); }; apiClient.connectChannel = () => ({ send: () => new Promise((resolve) => { resolveSend = resolve; }), close: () => {}, }); controller.send("hello", "m1"); let snapshot = controller.getSnapshot(); expect(snapshot.draft).toBe(""); expect(snapshot.replyToId).toBeNull(); expect(snapshot.messages.map((message) => message.id)).toEqual(["m1", snapshot.messages[1]!.id]); expect(snapshot.messages[1]).toMatchObject({ content: "hello", replyToId: "m1", clientStatus: "sending", replyTo: { content: "first", user: { username: "bob" } }, }); resolveSend?.(sentMessage); await flushMicrotasks(); snapshot = controller.getSnapshot(); expect(snapshot.messages).toEqual([replyTarget, sentMessage]); }); test("sends one idempotency key while the same message is pending", () => { const persistence = new MemoryPersistence(); const controller = createController(); const clientMessageIds: Array = []; persistSession(persistence, { emailVerified: true }); controller.attachPersistence(persistence); apiClient.getMessages = async () => []; apiClient.connectChannel = () => ({ send: (_content, _replyToId, clientMessageId) => { clientMessageIds.push(clientMessageId); return new Promise(() => {}); }, close: () => {}, }); const detachFirstView = controller.attachChannelView("everyone"); const detachSecondView = controller.attachChannelView("everyone"); controller.sendToChannel("everyone", " hello "); controller.sendToChannel("everyone", "hello"); expect(clientMessageIds).toHaveLength(1); expect(clientMessageIds[0]).toBeTruthy(); expect(controller.getSnapshot("everyone").messages).toHaveLength(1); detachFirstView(); detachSecondView(); }); test("edits the latest message from the current user", async () => { const persistence = new MemoryPersistence(); const controller = createController(); const original: ChatMessage = chatMessage({ id: "m1", content: "helo", createdAt: recentChatTimestamp(), }); persistSession(persistence, { emailVerified: true }); persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, { messages: [original], }, { sourceKey: TRANSCRIPT_SOURCE, schemaVersion: TRANSCRIPT_SCHEMA_VERSION, cachePolicy: { staleMs: 1_000, expireMs: 2_000 }, }); controller.attachPersistence(persistence); apiClient.editMessage = async (channelId, messageId, content) => ({ ...original, channelId, id: messageId, content, editedAt: "2026-03-28T00:01:00.000Z", }); await expect(controller.editChannelMessage("everyone", "m1", "hello")).resolves.toBe(true); const [edited] = controller.getSnapshot().messages; expect(edited).toMatchObject({ id: "m1", content: "hello", editedAt: "2026-03-28T00:01:00.000Z", }); }); test("refuses to edit after the edit window expires", async () => { const persistence = new MemoryPersistence(); const controller = createController(); const notifications: AppNotificationRequest[] = []; const original: ChatMessage = chatMessage({ id: "m1", content: "old typo", createdAt: recentChatTimestamp(16 * 60_000), }); persistSession(persistence, { emailVerified: true }); persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, { messages: [original], }, { sourceKey: TRANSCRIPT_SOURCE, schemaVersion: TRANSCRIPT_SCHEMA_VERSION, cachePolicy: { staleMs: 1_000, expireMs: 2_000 }, }); controller.setNotifier((notification) => notifications.push(notification)); controller.attachPersistence(persistence); apiClient.editMessage = async () => { throw new Error("should not call server"); }; await expect(controller.editChannelMessage("everyone", "m1", "old fix")).resolves.toBe(false); expect(controller.getSnapshot().messages[0]?.content).toBe("old typo"); expect(notifications).toEqual([{ body: "Messages can only be edited within 15 minutes.", type: "error" }]); }); test("refuses to edit an older message from the current user", async () => { const persistence = new MemoryPersistence(); const controller = createController(); const notifications: AppNotificationRequest[] = []; const messages: ChatMessage[] = [ chatMessage({ id: "m1", content: "older", createdAt: "2026-03-28T00:00:00.000Z", }), chatMessage({ id: "m2", content: "newer", createdAt: "2026-03-28T00:01:00.000Z", }), ]; persistSession(persistence, { emailVerified: true }); persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, { messages, }, { sourceKey: TRANSCRIPT_SOURCE, schemaVersion: TRANSCRIPT_SCHEMA_VERSION, cachePolicy: { staleMs: 1_000, expireMs: 2_000 }, }); controller.setNotifier((notification) => notifications.push(notification)); controller.attachPersistence(persistence); apiClient.editMessage = async () => { throw new Error("should not call server"); }; await expect(controller.editChannelMessage("everyone", "m1", "edited")).resolves.toBe(false); expect(controller.getSnapshot().messages.map((message) => message.content)).toEqual(["older", "newer"]); expect(notifications).toEqual([{ body: "Only your latest sent message can be edited.", type: "error" }]); }); test("marks a pending message as failed when sending errors", async () => { const persistence = new MemoryPersistence(); const controller = createController(); const notifications: AppNotificationRequest[] = []; persistSession(persistence, { emailVerified: true }); controller.setNotifier((notification) => { notifications.push(notification); }); controller.attachPersistence(persistence); apiClient.connectChannel = () => ({ send: async () => { throw new Error("server offline"); }, close: () => {}, }); controller.send("hello"); await flushMicrotasks(); const snapshot = controller.getSnapshot(); expect(snapshot.messages).toHaveLength(1); expect(snapshot.messages[0]).toMatchObject({ content: "hello", clientStatus: "failed", clientError: "server offline", }); expect(notifications).toEqual([{ body: "server offline", type: "error" }]); }); test("tracks unread mentions from fetched messages without issuing local notifications", () => { const persistence = new MemoryPersistence(); const controller = createController(); const notifications: AppNotificationRequest[] = []; const message: ChatMessage = chatMessage({ id: "m1", content: "hey @Vince can you take a look?", createdAt: "2026-03-28T00:00:00.000Z", user: { id: "u2", username: "bob", displayName: "Bob" }, }); persistSession(persistence, { emailVerified: true }); controller.setNotifier((notification) => { notifications.push(notification); }); controller.attachPersistence(persistence); (controller as any).mergeMessages([message]); expect(controller.getSnapshot().unreadMentionCount).toBe(1); expect(notifications).toEqual([]); expect(persistence.getState<{ lastCursor: string | null; lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({ lastCursor: "m1", lastViewedMessageId: null, }); }); test("delivers background notifications while the focused chat stays unread", async () => { const persistence = new MemoryPersistence(); const controller = createController(); const notifications: AppNotificationRequest[] = []; const deliveredIds: string[][] = []; persistSession(persistence, { emailVerified: true }); controller.setNotifier((notification) => { notifications.push(notification); return { toastVisible: false, desktopRequested: true }; }); apiClient.markChatNotificationsDelivered = async (ids) => { deliveredIds.push(ids); return { delivered: ids.length }; }; controller.attachPersistence(persistence); const detachView = controller.attachView(true); controller.setAppActive(false); (controller as any).handleChatNotification(mentionNotification()); expect(notifications).toEqual([{ title: "#everyone", body: "@bob mentioned you: hey @vince", type: "info", desktop: "when-inactive", }]); expect(controller.getSnapshot().unreadMentionCount).toBe(1); expect(deliveredIds).toEqual([["n1"]]); controller.setAppActive(true); await flushMicrotasks(); await flushMicrotasks(); expect(controller.getSnapshot().unreadMentionCount).toBe(0); detachView(); }); test("keeps undeliverable background notifications pending for email fallback", () => { const persistence = new MemoryPersistence(); const controller = createController(); const deliveredIds: string[][] = []; let desktopAvailable = false; let presentationCount = 0; persistSession(persistence, { emailVerified: true }); controller.setNotifier(() => { presentationCount += 1; return { toastVisible: false, desktopRequested: desktopAvailable }; }); apiClient.markChatNotificationsDelivered = async (ids) => { deliveredIds.push(ids); return { delivered: ids.length }; }; controller.attachPersistence(persistence); controller.setAppActive(false); const notification = mentionNotification({ type: "reply" }); (controller as any).handleChatNotification(notification); expect(presentationCount).toBe(1); expect(deliveredIds).toEqual([]); desktopAvailable = true; (controller as any).handleChatNotification({ ...notification, id: "n2" }); expect(presentationCount).toBe(2); expect(deliveredIds).toEqual([["n2"]]); }); test("marks mentions viewed when a chat view opens", () => { const persistence = new MemoryPersistence(); const controller = createController(); const notifications: AppNotificationRequest[] = []; const message: ChatMessage = chatMessage({ id: "m1", content: "hey @vince", createdAt: "2026-03-28T00:00:00.000Z", user: { id: "u2", username: "bob", displayName: "Bob" }, }); persistSession(persistence, { emailVerified: true }); controller.setNotifier((notification) => { notifications.push(notification); }); controller.attachPersistence(persistence); (controller as any).mergeMessages([message]); expect(controller.getSnapshot().unreadMentionCount).toBe(1); const detachView = controller.attachView(); expect(controller.getSnapshot().unreadMentionCount).toBe(0); expect(notifications).toEqual([]); expect(persistence.getState<{ lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({ lastViewedMessageId: "m1", }); detachView(); }); test("keeps focused foreground notifications read without presenting an alert", () => { const persistence = new MemoryPersistence(); const controller = createController(); const notifications: AppNotificationRequest[] = []; const deliveredIds: string[][] = []; persistSession(persistence, { emailVerified: true }); controller.setNotifier((notification) => { notifications.push(notification); }); apiClient.markChatNotificationsDelivered = async (ids) => { deliveredIds.push(ids); return { delivered: ids.length }; }; controller.attachPersistence(persistence); const detachView = controller.attachView(true); (controller as any).handleChatNotification(mentionNotification()); expect(controller.getSnapshot().unreadMentionCount).toBe(0); expect(notifications).toEqual([]); expect(deliveredIds).toEqual([["n1"]]); expect(persistence.getState<{ lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({ lastViewedMessageId: "m1", }); detachView(); }); test("keeps an unfocused foreground chat unread while presenting an in-app alert", () => { const persistence = new MemoryPersistence(); const controller = createController(); const notifications: AppNotificationRequest[] = []; const deliveredIds: string[][] = []; persistSession(persistence, { emailVerified: true }); controller.setNotifier((notification) => { notifications.push(notification); return { toastVisible: true, desktopRequested: false }; }); apiClient.markChatNotificationsDelivered = async (ids) => { deliveredIds.push(ids); return { delivered: ids.length }; }; controller.attachPersistence(persistence); const detachUnfocusedView = controller.attachView(false); (controller as any).handleChatNotification(mentionNotification()); expect(notifications).toHaveLength(1); expect(controller.getSnapshot().unreadMentionCount).toBe(1); expect(deliveredIds).toEqual([["n1"]]); detachUnfocusedView(); const detachFocusedView = controller.attachView(true); expect(controller.getSnapshot().unreadMentionCount).toBe(0); detachFocusedView(); }); test("loads server chat state, pending reply notifications, and acks delivery", async () => { const persistence = new MemoryPersistence(); const controller = createController(); const notifications: AppNotificationRequest[] = []; const deliveredIds: string[][] = []; persistSession(persistence, { emailVerified: true }); apiClient.getSession = async () => ({ id: "u1", name: "Vince", email: "vince@example.com", username: "vince", emailVerified: true, image: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); apiClient.getChatState = async () => ({ channels: SERVER_CHAT_CHANNELS, onlineCount: 7, channelStates: [{ channelId: "options", notificationsEnabled: true, lastReadMessageId: "m1", unreadCount: 3, }], notifications: [{ id: "n1", type: "reply", channelId: "options", messageId: "m2", createdAt: "2026-03-28T00:02:00.000Z", message: chatMessage({ id: "m2", channelId: "options", content: "answering you", replyToId: "m1", createdAt: "2026-03-28T00:02:00.000Z", user: { id: "u2", username: "bob", displayName: "Bob" }, replyTo: { content: "question", user: { id: "u1", username: "vince" } }, }), }], }); apiClient.markChatNotificationsDelivered = async (ids) => { deliveredIds.push(ids); return { delivered: ids.length }; }; apiClient.connectChannel = () => ({ send: async () => { throw new Error("not implemented"); }, close: () => {}, }); controller.setNotifier((notification) => { notifications.push(notification); return { toastVisible: true, desktopRequested: false }; }); controller.attachPersistence(persistence); await controller.refreshSession(); expect(controller.getSnapshot("options").onlineCount).toBe(7); expect(controller.getSnapshot("options").channelStates.find((entry) => entry.channelId === "options")).toMatchObject({ notificationsEnabled: true, unreadCount: 3, }); expect(notifications).toEqual([{ title: "#options", body: "@bob replied to you: answering you", type: "info", desktop: "when-inactive", }]); expect(deliveredIds).toEqual([["n1"]]); }); test("toggles channel notifications optimistically and keeps the channel connected", async () => { const persistence = new MemoryPersistence(); const controller = createController(); const connectedChannels: string[] = []; persistSession(persistence, { emailVerified: true }); apiClient.updateChatChannelState = async (channelId, body) => ({ channelId, notificationsEnabled: body.notificationsEnabled === true, lastReadMessageId: null, unreadCount: 0, }); apiClient.connectChannel = (channelId) => { connectedChannels.push(channelId); return { send: async () => { throw new Error("not implemented"); }, close: () => {}, }; }; controller.attachPersistence(persistence); controller.setChannelNotificationsEnabled("options", true); await flushMicrotasks(); expect(controller.getSnapshot("options").channelStates.find((entry) => entry.channelId === "options")).toMatchObject({ notificationsEnabled: true, }); expect(connectedChannels).toContain("options"); }); test("dedupes reply notifications by message id", () => { const persistence = new MemoryPersistence(); const controller = createController(); const notifications: AppNotificationRequest[] = []; const notification: ChatNotification = { id: "n1", type: "reply", channelId: "everyone", messageId: "m2", createdAt: "2026-03-28T00:02:00.000Z", message: chatMessage({ id: "m2", content: "same reply", replyToId: "m1", createdAt: "2026-03-28T00:02:00.000Z", user: { id: "u2", username: "bob", displayName: "Bob" }, replyTo: { content: "question", user: { id: "u1", username: "vince" } }, }), }; persistSession(persistence, { emailVerified: true }); controller.setNotifier((entry) => { notifications.push(entry); return { toastVisible: true, desktopRequested: false }; }); controller.attachPersistence(persistence); (controller as any).handleChatNotification(notification); (controller as any).handleChatNotification({ ...notification, id: "n2" }); expect(notifications).toHaveLength(1); }); test("opens a server-issued channel notification at its exact message", () => { const persistence = new MemoryPersistence(); const controller = createController(); const notifications: AppNotificationRequest[] = []; const openedMessages: string[] = []; const message: ChatMessage = chatMessage({ id: "m1", channelId: "options", content: "new option flow", createdAt: "2026-03-28T00:00:00.000Z", user: { id: "u2", username: "bob", displayName: "Bob" }, }); persistSession(persistence, { emailVerified: true }); controller.setNotifier((entry) => { notifications.push(entry); }, (channelId, messageId) => { openedMessages.push(`${channelId}:${messageId}`); }); controller.attachPersistence(persistence); (controller as any).handleChatNotification({ id: "n1", type: "channel", channelId: "options", messageId: "m1", createdAt: "2026-03-28T00:00:00.000Z", message, } satisfies ChatNotification); expect(notifications).toEqual([{ title: "#options", body: "@bob: new option flow", type: "info", desktop: "when-inactive", action: expect.objectContaining({ label: "Open" }), }]); notifications[0]?.action?.onClick(); expect(openedMessages).toEqual(["options:m1"]); }); test("uses direct channel labels in server-issued notification titles", async () => { const persistence = new MemoryPersistence(); const controller = createController(); const notifications: AppNotificationRequest[] = []; const directChannel: ChatChannel = { id: "dm:u2", name: "u2", kind: "direct", created_at: "2026-03-28T00:00:00.000Z", dmUser: { id: "u2", username: "bob", displayName: "Bob" }, }; const message: ChatMessage = chatMessage({ id: "m1", channelId: directChannel.id, content: "ping", createdAt: "2026-03-28T00:00:00.000Z", user: { id: "u2", username: "bob", displayName: "Bob" }, }); apiClient.getChatState = async () => ({ channels: [...SERVER_CHAT_CHANNELS, directChannel], onlineCount: 0, channelStates: [...SERVER_CHAT_CHANNELS, directChannel].map((channel) => ({ channelId: channel.id, notificationsEnabled: false, lastReadMessageId: null, unreadCount: 0, })), notifications: [], }); persistSession(persistence, { emailVerified: true }); controller.setNotifier((entry) => { notifications.push(entry); }); controller.attachPersistence(persistence); await controller.refreshChatState(); (controller as any).handleChatNotification({ id: "n1", type: "channel", channelId: directChannel.id, messageId: "m1", createdAt: "2026-03-28T00:00:00.000Z", message, } satisfies ChatNotification); expect(notifications).toEqual([{ title: "@bob", body: "ping", type: "info", desktop: "when-inactive", }]); }); test("tracks unread channel messages and clears them when the channel opens", () => { const persistence = new MemoryPersistence(); const controller = createController(); const message: ChatMessage = chatMessage({ id: "m1", channelId: "options", content: "new option flow", createdAt: "2026-03-28T00:00:00.000Z", user: { id: "u2", username: "bob", displayName: "Bob" }, }); persistSession(persistence, { emailVerified: true }); controller.attachPersistence(persistence); (controller as any).mergeMessages("options", [message]); expect(controller.getSnapshot("options").channelStates.find((entry) => entry.channelId === "options")).toMatchObject({ unreadCount: 1, }); const detachView = controller.attachChannelView("options"); expect(controller.getSnapshot("options").channelStates.find((entry) => entry.channelId === "options")).toMatchObject({ unreadCount: 0, }); detachView(); }); test("server reply notifications fire even when channel notifications are disabled", () => { const persistence = new MemoryPersistence(); const controller = createController(); const notifications: AppNotificationRequest[] = []; const message: ChatMessage = chatMessage({ id: "m2", channelId: "options", content: "reply without channel notify", replyToId: "m1", createdAt: "2026-03-28T00:00:00.000Z", user: { id: "u2", username: "bob", displayName: "Bob" }, replyTo: { content: "question", user: { id: "u1", username: "vince" } }, }); persistSession(persistence, { emailVerified: true }); controller.setNotifier((notification) => { notifications.push(notification); }); controller.attachPersistence(persistence); (controller as any).handleChatNotification({ id: "n1", type: "reply", channelId: "options", messageId: "m2", createdAt: "2026-03-28T00:00:00.000Z", message, } satisfies ChatNotification); expect(notifications).toEqual([{ title: "#options", body: "@bob replied to you: reply without channel notify", type: "info", desktop: "when-inactive", }]); }); test("recovers from a legacy timestamp cursor by falling back to a full transcript fetch", async () => { const persistence = new MemoryPersistence(); const controller = createController(); const cached: ChatMessage = chatMessage({ id: "m1", content: "cached", createdAt: "2026-03-28T00:00:00.000Z", }); const fullTranscript: ChatMessage[] = [ cached, chatMessage({ id: "m2", content: "fresh", createdAt: "2026-03-28T00:01:00.000Z", user: { id: "u2", username: "bob", displayName: "Bob" }, }), ]; persistence.setState("channel:everyone", { draft: "", replyToId: null, lastCursor: "2026-03-28T00:00:00.000Z", lastViewedMessageId: "m1", }, { schemaVersion: 1 }); persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, { messages: [cached], }, { sourceKey: TRANSCRIPT_SOURCE, schemaVersion: TRANSCRIPT_SCHEMA_VERSION, cachePolicy: { staleMs: 1_000, expireMs: 2_000 }, }); controller.attachPersistence(persistence); const calls: Array<{ channelId: string; opts?: { after?: string; before?: string; limit?: number } }> = []; apiClient.getMessages = async (channelId, opts) => { calls.push({ channelId, opts }); return opts?.after ? [] : fullTranscript; }; await controller.refreshMessages(); // The session backfill already ignores the cursor, so the legacy timestamp // is never sent and the old wasted round trip is gone. expect(calls).toHaveLength(1); expect(calls[0]?.opts?.after).toBeUndefined(); expect(controller.getSnapshot().messages.map((entry) => entry.id)).toEqual(["m1", "m2"]); expect(persistence.getState<{ lastCursor: string }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({ lastCursor: "m2", }); }); test("loads older messages before the oldest cached message without moving the latest cursor", async () => { const persistence = new MemoryPersistence(); const controller = createController(); const cached: ChatMessage[] = [ chatMessage({ id: "m3", content: "cached older", createdAt: "2026-03-28T00:03:00.000Z", user: { id: "u3", username: "cara", displayName: "Cara" }, }), chatMessage({ id: "m4", content: "cached newer", createdAt: "2026-03-28T00:04:00.000Z", user: { id: "u4", username: "drew", displayName: "Drew" }, }), ]; const older: ChatMessage[] = [ chatMessage({ id: "m1", content: "oldest", createdAt: "2026-03-28T00:01:00.000Z", user: { id: "u1", username: "alice", displayName: "Alice" }, }), chatMessage({ id: "m2", content: "older", createdAt: "2026-03-28T00:02:00.000Z", user: { id: "u2", username: "bob", displayName: "Bob" }, }), ]; persistence.setState("channel:everyone", { draft: "", replyToId: null, lastCursor: "m4", lastViewedMessageId: "m4", }, { schemaVersion: 1 }); persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, { messages: cached, }, { sourceKey: TRANSCRIPT_SOURCE, schemaVersion: TRANSCRIPT_SCHEMA_VERSION, cachePolicy: { staleMs: 1_000, expireMs: 2_000 }, }); controller.attachPersistence(persistence); const calls: Array<{ channelId: string; opts?: { after?: string; before?: string; limit?: number } }> = []; apiClient.getMessages = async (channelId, opts) => { calls.push({ channelId, opts }); return opts?.before === "m3" ? older : []; }; await controller.loadOlderMessages(); expect(calls).toEqual([{ channelId: "everyone", opts: { limit: 50, before: "m3" }, }]); expect(controller.getSnapshot().messages.map((entry) => entry.id)).toEqual(["m1", "m2", "m3", "m4"]); expect(controller.getSnapshot().hasOlderMessages).toBe(false); expect(persistence.getState<{ lastCursor: string }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({ lastCursor: "m4", }); }); });