import { describe, expect, it } from "vitest"; import { createActor, fromCallback, fromPromise, waitFor } from "xstate"; import { ChatSendResponse, UnlockPrivateResponse, type UiMessage, } from "@/data/dto/chat"; import { chatMachine } from "@/stores/chat/chat-machine"; import type { ChatEvent } from "@/stores/chat/chat-events"; import type { UnlockMessageOutput as MachineUnlockMessageOutput, UnlockMessageRequest, } from "@/stores/chat/chat-machine.helpers"; interface LoadMoreHistoryOutput { messages: UiMessage[]; hasMore: boolean; newOffset: number; } interface SendMessageHttpOutput { response: ChatSendResponse; reply: UiMessage | null; } interface UnlockHistoryOutput { unlocked: boolean; reason: string; shortfallCredits: number; messages: UiMessage[]; hasMore: boolean; newOffset: number; } interface TestUnlockMessageOutput { messageId: string; response: UnlockPrivateResponse; } function makeChatSendResponse(): ChatSendResponse { return ChatSendResponse.from({ reply: "", audioUrl: "", messageId: "msg-1", isGuest: false, timestamp: Date.now(), image: { type: null, url: null }, lockDetail: { locked: false, showContent: true, showUpgrade: false, reason: null, hint: null, detail: null, }, }); } function makeUnlockPrivateResponse( overrides: Partial[0]> = {}, ): UnlockPrivateResponse { return UnlockPrivateResponse.from({ unlocked: true, content: "unlocked content", audioUrl: "", reason: "ok", creditBalance: 90, creditsCharged: 10, requiredCredits: 10, shortfallCredits: 0, lockDetail: { locked: false, showContent: true, showUpgrade: false, reason: null, hint: null, detail: null, }, ...overrides, }); } function createTestChatMachine( options: { historyMessages?: UiMessage[]; unlockHistoryOutput?: UnlockHistoryOutput; unlockMessageOutput?: TestUnlockMessageOutput; } = {}, ) { return chatMachine.provide({ actors: { loadHistory: createLoadHistoryCallback(options.historyMessages ?? []), loadMoreHistory: fromPromise( async () => ({ messages: [], hasMore: false, newOffset: 0, }), ), sendMessageHttp: fromPromise< SendMessageHttpOutput, { content: string } >(async () => ({ response: makeChatSendResponse(), reply: null, })), httpMessageQueue: fromCallback(({ receive, sendBack }) => { receive((event) => { if (event.type !== "ChatSendMessage") return; sendBack({ type: "ChatQueuedSendStarted" }); sendBack({ type: "ChatQueuedHttpDone", output: { response: makeChatSendResponse(), reply: null, }, }); }); return () => undefined; }), unlockHistory: fromPromise(async () => ({ unlocked: true, reason: "ok", shortfallCredits: 0, messages: [], hasMore: false, newOffset: 0, ...options.unlockHistoryOutput, })), unlockMessage: fromPromise< MachineUnlockMessageOutput, UnlockMessageRequest >(async ({ input }) => { const output = options.unlockMessageOutput ?? { messageId: input.messageId ?? input.displayMessageId, response: makeUnlockPrivateResponse(), }; return { displayMessageId: input.displayMessageId, request: input, response: output.response, }; }), }, }); } function createLoadHistoryCallback( messages: UiMessage[], networkMessages: UiMessage[] = messages, ) { return fromCallback(({ sendBack }) => { sendBack({ type: "ChatLocalHistoryLoaded", output: { messages, hasMore: false, newOffset: messages.length, localCount: messages.length, }, }); sendBack({ type: "ChatNetworkHistoryLoaded", output: { messages: networkMessages, hasMore: false, newOffset: networkMessages.length, localOverwritten: true, localCount: messages.length, networkCount: networkMessages.length, }, }); return () => undefined; }); } describe("chatMachine transitions", () => { it("keeps guest logout disabled and supports user to guest transition", async () => { const actor = createActor(createTestChatMachine()).start(); actor.send({ type: "ChatGuestLogin" }); await waitFor(actor, (snapshot) => snapshot.matches({ guestSession: "ready" }), ); actor.send({ type: "ChatLogout" }); expect(actor.getSnapshot().matches({ guestSession: "ready" })).toBe(true); actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatGuestLogin" }); await waitFor(actor, (snapshot) => snapshot.matches({ guestSession: "ready" }), ); actor.stop(); }); it("allows explicit logout from user session", async () => { const actor = createActor(createTestChatMachine()).start(); actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatLogout" }); expect(actor.getSnapshot().matches("idle")).toBe(true); actor.stop(); }); it("enters user ready after history is loaded", async () => { const actor = createActor(createTestChatMachine()).start(); actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); const snapshot = actor.getSnapshot(); expect(snapshot.context.historyLoaded).toBe(true); actor.stop(); }); it("keeps a promotion separate from normal history", async () => { const actor = createActor( createTestChatMachine({ historyMessages: [ { id: "history-1", content: "Existing history", isFromAI: true, date: "2026-07-13", }, ], }), ).start(); actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatPromotionInjected", promotion: { promotionType: "voice", lockType: "voice_message", clientLockId: "promotion-1", createdAt: 1, }, }); const context = actor.getSnapshot().context; expect(context.messages).toHaveLength(1); expect(context.promotion?.message).toMatchObject({ id: "promotion:promotion-1", locked: true, lockReason: "voice_message", }); actor.stop(); }); it("renders local history before network history sync finishes", async () => { let resolveNetwork!: () => void; const networkReleased = new Promise((resolve) => { resolveNetwork = resolve; }); const localMessage: UiMessage = { id: "local-msg", content: "cached local message", isFromAI: true, date: "2026-07-02", }; const networkMessage: UiMessage = { id: "network-msg", content: "fresh network message", isFromAI: true, date: "2026-07-02", }; const machine = chatMachine.provide({ actors: { loadHistory: fromCallback(({ sendBack }) => { sendBack({ type: "ChatLocalHistoryLoaded", output: { messages: [localMessage], hasMore: false, newOffset: 1, localCount: 1, }, }); void networkReleased.then(() => { sendBack({ type: "ChatNetworkHistoryLoaded", output: { messages: [networkMessage], hasMore: false, newOffset: 1, localOverwritten: true, localCount: 1, networkCount: 1, }, }); }); return () => undefined; }), }, }); const actor = createActor(machine).start(); actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); expect(actor.getSnapshot().context.messages).toMatchObject([ { id: "local-msg", content: "cached local message" }, ]); resolveNetwork(); await waitFor( actor, (snapshot) => snapshot.context.messages[0]?.id === "network-msg", ); expect(actor.getSnapshot().context.messages).toMatchObject([ { id: "network-msg", content: "fresh network message" }, ]); actor.stop(); }); it("switches to guest session when guest login arrives during user session", async () => { const actor = createActor(createTestChatMachine()).start(); actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatGuestLogin" }); await waitFor(actor, (snapshot) => snapshot.matches({ guestSession: "ready" }), ); actor.stop(); }); it("ignores repeated user login while an authenticated user session is active", async () => { const actor = createActor(createTestChatMachine()).start(); actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatSendMessage", content: "keep this message" }); await waitFor( actor, (snapshot) => snapshot.context.messages.length === 1, ); actor.send({ type: "ChatUserLogin", token: "another-token" }); expect(actor.getSnapshot().matches({ userSession: "ready" })).toBe(true); expect(actor.getSnapshot().context.messages).toMatchObject([ { content: "keep this message", isFromAI: false }, ]); actor.stop(); }); it("allows multiple messages to be queued without leaving ready state", async () => { const actor = createActor(createTestChatMachine()).start(); actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatSendMessage", content: "hello" }); actor.send({ type: "ChatSendMessage", content: "still there?" }); expect(actor.getSnapshot().matches({ userSession: "ready" })).toBe(true); expect(actor.getSnapshot().context.messages).toMatchObject([ { content: "hello", isFromAI: false }, { content: "still there?", isFromAI: false }, ]); await waitFor( actor, (snapshot) => snapshot.context.pendingReplyCount === 0, ); expect(actor.getSnapshot().context.isReplyingAI).toBe(false); actor.stop(); }); it("tracks replying state by queued batch instead of user message count", async () => { const machine = chatMachine.provide({ actors: { loadHistory: createLoadHistoryCallback([]), loadMoreHistory: fromPromise( async () => ({ messages: [], hasMore: false, newOffset: 0, }), ), sendMessageHttp: fromPromise< SendMessageHttpOutput, { content: string } >(async () => ({ response: makeChatSendResponse(), reply: null, })), httpMessageQueue: fromCallback(({ receive, sendBack }) => { const pending: string[] = []; receive((event) => { if (event.type !== "ChatSendMessage") return; pending.push(event.content); if (pending.length !== 2) return; sendBack({ type: "ChatQueuedSendStarted" }); sendBack({ type: "ChatQueuedHttpDone", output: { response: makeChatSendResponse(), reply: null, }, }); }); return () => undefined; }), unlockHistory: fromPromise(async () => ({ unlocked: true, reason: "ok", shortfallCredits: 0, messages: [], hasMore: false, newOffset: 0, })), }, }); const actor = createActor(machine).start(); actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatSendMessage", content: "first" }); actor.send({ type: "ChatSendMessage", content: "second" }); await waitFor( actor, (snapshot) => snapshot.context.messages.length === 2, ); expect(actor.getSnapshot().context.pendingReplyCount).toBe(0); expect(actor.getSnapshot().context.isReplyingAI).toBe(false); actor.stop(); }); it("prompts before unlocking multiple locked history messages after payment", async () => { const actor = createActor( createTestChatMachine({ historyMessages: [ { content: "", isFromAI: true, date: "2026-06-29", locked: true, lockReason: "private_message", }, { content: "", isFromAI: true, date: "2026-06-29", locked: true, lockReason: "voice_message", }, ], }), ).start(); actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatPaymentSucceeded" }); expect(actor.getSnapshot().context.unlockHistoryPromptVisible).toBe(true); expect(actor.getSnapshot().context.lockedHistoryCount).toBe(2); actor.stop(); }); it("keeps a single locked image message locked after payment", async () => { const actor = createActor( createTestChatMachine({ historyMessages: [ { id: "msg-image-locked", content: "", isFromAI: true, date: "2026-06-29", imageUrl: "https://example.com/locked.jpg", imagePaywalled: true, locked: true, lockReason: "image", }, ], unlockHistoryOutput: { unlocked: true, reason: "ok", shortfallCredits: 0, messages: [ { id: "msg-image-locked", content: "", isFromAI: true, date: "2026-06-29", imageUrl: "https://example.com/unlocked.jpg", locked: false, lockReason: null, }, ], hasMore: false, newOffset: 1, }, }), ).start(); actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatPaymentSucceeded" }); expect(actor.getSnapshot().matches({ userSession: "ready" })).toBe(true); expect(actor.getSnapshot().context.unlockHistoryPromptVisible).toBe(false); expect(actor.getSnapshot().context.messages).toMatchObject([ { id: "msg-image-locked", imageUrl: "https://example.com/locked.jpg", imagePaywalled: true, locked: true, }, ]); actor.stop(); }); it("unlocks history after the prompt is confirmed", async () => { const actor = createActor( createTestChatMachine({ historyMessages: [ { content: "", isFromAI: true, date: "2026-06-29", locked: true, lockReason: "private_message", }, { content: "", isFromAI: true, date: "2026-06-29", locked: true, lockReason: "voice_message", }, ], unlockHistoryOutput: { unlocked: true, reason: "ok", shortfallCredits: 0, messages: [ { content: "unlocked", isFromAI: true, date: "2026-06-29", locked: false, lockReason: null, }, ], hasMore: false, newOffset: 1, }, }), ).start(); actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatPaymentSucceeded" }); actor.send({ type: "ChatUnlockHistoryConfirmed" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); expect(actor.getSnapshot().context.unlockHistoryPromptVisible).toBe(false); expect(actor.getSnapshot().context.messages).toMatchObject([ { content: "unlocked", locked: false }, ]); actor.stop(); }); it("unlocks a single private message without navigating to payment", async () => { const actor = createActor( createTestChatMachine({ historyMessages: [ { id: "msg-private-locked", content: "Original private message content.", isFromAI: true, date: "2026-06-29", locked: true, lockReason: "private_message", lockedPrivate: true, privateMessageHint: "A private message is waiting.", }, ], unlockMessageOutput: { messageId: "msg-private-locked", response: makeUnlockPrivateResponse({ content: "Unlocked private message content.", }), }, }), ).start(); actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatUnlockMessageRequested", messageId: "msg-private-locked", kind: "private", }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); expect(actor.getSnapshot().context.unlockPaywallRequest).toBeNull(); expect(actor.getSnapshot().context.isUnlockingMessage).toBe(false); expect(actor.getSnapshot().context.messages).toMatchObject([ { id: "msg-private-locked", content: "Unlocked private message content.", locked: false, lockReason: null, lockedPrivate: false, privateMessageHint: null, }, ]); actor.stop(); }); it("does not overwrite a user message when it shares the private message id", async () => { const actor = createActor( createTestChatMachine({ historyMessages: [ { id: "msg-shared-id", content: "User original question", isFromAI: false, date: "2026-06-29", }, { id: "msg-shared-id", content: "Original AI private message", isFromAI: true, date: "2026-06-29", locked: true, lockReason: "private_message", lockedPrivate: true, privateMessageHint: "A private message is waiting.", }, ], unlockMessageOutput: { messageId: "msg-shared-id", response: makeUnlockPrivateResponse({ content: "Unlocked private AI message.", }), }, }), ).start(); actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatUnlockMessageRequested", messageId: "msg-shared-id", kind: "private", }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); expect(actor.getSnapshot().context.messages).toMatchObject([ { id: "msg-shared-id", content: "User original question", isFromAI: false, }, { id: "msg-shared-id", content: "Unlocked private AI message.", isFromAI: true, locked: false, lockReason: null, lockedPrivate: false, privateMessageHint: null, }, ]); actor.stop(); }); it("keeps image message text empty after a single image unlock succeeds", async () => { const actor = createActor( createTestChatMachine({ historyMessages: [ { id: "msg-image-locked", content: "", isFromAI: true, date: "2026-06-29", imageUrl: "https://example.com/locked.jpg", imagePaywalled: true, locked: true, lockReason: "image", }, ], unlockMessageOutput: { messageId: "msg-image-locked", response: makeUnlockPrivateResponse({ content: "This text should not render below the image.", }), }, }), ).start(); actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatUnlockMessageRequested", messageId: "msg-image-locked", kind: "image", }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); expect(actor.getSnapshot().context.messages).toMatchObject([ { id: "msg-image-locked", content: "", imageUrl: "https://example.com/locked.jpg", imagePaywalled: false, locked: false, lockReason: null, }, ]); actor.stop(); }); it("applies the real voice audio url after unlock succeeds", async () => { const actor = createActor( createTestChatMachine({ historyMessages: [ { id: "msg-voice-locked", content: "Original voice transcript.", isFromAI: true, date: "2026-06-29", locked: true, lockReason: "voice_message", privateMessageHint: "A voice message is waiting.", }, ], unlockMessageOutput: { messageId: "msg-voice-locked", response: makeUnlockPrivateResponse({ content: "This response content must be ignored.", audioUrl: "https://example.com/unlocked-voice.mp3", }), }, }), ).start(); actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatUnlockMessageRequested", messageId: "msg-voice-locked", kind: "voice", }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); expect(actor.getSnapshot().context.messages).toMatchObject([ { id: "msg-voice-locked", content: "Original voice transcript.", audioUrl: "https://example.com/unlocked-voice.mp3", locked: false, lockReason: null, privateMessageHint: null, }, ]); actor.stop(); }); it("requests payment when single message unlock lacks credits", async () => { const actor = createActor( createTestChatMachine({ historyMessages: [ { id: "msg-voice-locked", content: "", isFromAI: true, date: "2026-06-29", locked: true, lockReason: "voice_message", privateMessageHint: "A voice message is waiting.", }, ], unlockMessageOutput: { messageId: "msg-voice-locked", response: makeUnlockPrivateResponse({ unlocked: false, content: "", reason: "insufficient_balance", creditBalance: 3, creditsCharged: 0, requiredCredits: 10, shortfallCredits: 7, lockDetail: { locked: true, showContent: false, showUpgrade: true, reason: "insufficient_balance", hint: "Insufficient credits.", detail: { messageId: "msg-voice-locked" }, }, }), }, }), ).start(); actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatUnlockMessageRequested", messageId: "msg-voice-locked", kind: "voice", }); await waitFor( actor, (snapshot) => snapshot.context.unlockPaywallRequest !== null, ); expect(actor.getSnapshot().context.unlockPaywallRequest).toEqual({ displayMessageId: "msg-voice-locked", messageId: "msg-voice-locked", kind: "voice", reason: "insufficient_balance", creditBalance: 3, requiredCredits: 10, shortfallCredits: 7, }); expect(actor.getSnapshot().context.messages).toMatchObject([ { id: "msg-voice-locked", locked: true, lockReason: "voice_message", }, ]); expect(actor.getSnapshot().context.messages[0]?.audioUrl).toBeUndefined(); expect(actor.getSnapshot().context.isUnlockingMessage).toBe(false); actor.stop(); }); it("clears the insufficient credits prompt after payment succeeds", async () => { const actor = createActor(createTestChatMachine()).start(); actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatQueuedHttpDone", output: { response: ChatSendResponse.from({ reply: "", audioUrl: "", messageId: "", isGuest: false, timestamp: Date.now(), image: { type: null, url: null }, lockDetail: { locked: true, showContent: false, showUpgrade: true, reason: "insufficient_credits", hint: "Insufficient credits.", detail: { requiredCredits: 2, currentCredits: 0, shortfallCredits: 2, }, }, canSendMessage: false, cannotSendReason: "insufficient_credits", creditBalance: 0, requiredCredits: 2, shortfallCredits: 2, }), reply: null, }, }); expect(actor.getSnapshot().context.upgradePromptVisible).toBe(true); expect(actor.getSnapshot().context.upgradeReason).toBe( "insufficient_credits", ); expect(actor.getSnapshot().context.canSendMessage).toBe(false); actor.send({ type: "ChatPaymentSucceeded" }); expect(actor.getSnapshot().context.upgradePromptVisible).toBe(false); expect(actor.getSnapshot().context.upgradeReason).toBeNull(); expect(actor.getSnapshot().context.canSendMessage).toBe(true); expect(actor.getSnapshot().context.cannotSendReason).toBeNull(); actor.stop(); }); });