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"; 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 UnlockMessageOutput { 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", 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?: UnlockMessageOutput; } = {}, ) { 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( async ({ input }) => options.unlockMessageOutput ?? { messageId: input.messageId, response: makeUnlockPrivateResponse(), }, ), }, }); } 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 while allowing upgrade to user login", 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: "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("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("ignores guest 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: "ChatGuestLogin" }); expect(actor.getSnapshot().matches({ userSession: "ready" })).toBe(true); 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("auto unlocks a single locked image message 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" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); expect(actor.getSnapshot().context.unlockHistoryPromptVisible).toBe(false); expect(actor.getSnapshot().context.messages).toMatchObject([ { id: "msg-image-locked", imageUrl: "https://example.com/unlocked.jpg", locked: false, }, ]); 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: "This response content must be ignored.", }), }, }), ).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: "Original 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: "This response content must be ignored.", }), }, }), ).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: "Original AI private 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("keeps voice message content unchanged after unlock succeeds", async () => { const actor = createActor( createTestChatMachine({ historyMessages: [ { id: "msg-voice-locked", content: "Original voice transcript.", isFromAI: true, date: "2026-06-29", audioUrl: "https://example.com/voice.mp3", 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.", }), }, }), ).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/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", audioUrl: "https://example.com/voice.mp3", 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({ messageId: "msg-voice-locked", kind: "voice", reason: "insufficient_balance", creditBalance: 3, requiredCredits: 10, shortfallCredits: 7, }); 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(); }); });