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 LoadHistoryOutput { messages: UiMessage[]; hasMore: boolean; newOffset: number; localOverwritten: boolean; localCount: number; networkCount: number; } interface LoadMoreHistoryOutput { messages: UiMessage[]; hasMore: boolean; newOffset: number; } interface SendMessageHttpOutput { response: ChatSendResponse; reply: UiMessage | null; } interface UnlockPrivateOutput { 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 createTestChatMachine( options: { historyMessages?: UiMessage[]; unlockedContent?: string; } = {}, ) { return chatMachine.provide({ actors: { loadHistory: fromPromise(async () => ({ messages: options.historyMessages ?? [], hasMore: false, newOffset: 0, localOverwritten: true, localCount: 0, networkCount: 0, })), 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; }), unlockPrivateMessage: fromPromise< UnlockPrivateOutput, { messageId: string } >(async ({ input }) => ({ messageId: input.messageId, response: UnlockPrivateResponse.from({ unlocked: true, content: options.unlockedContent ?? "unlocked", showUpgrade: false, paywallTriggered: false, privateFreeLimit: 0, privateUsedToday: 0, reason: "ok", }), })), }, }); } 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("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("unlocks a locked private message and clears unlocking state", async () => { const actor = createActor( createTestChatMachine({ historyMessages: [ { id: "private-1", content: "", isFromAI: true, date: "2026-06-25", locked: true, lockReason: "private_message", isPrivate: true, lockedPrivate: true, privateMessageHint: "A private message is waiting.", }, ], unlockedContent: "Here is the unlocked private message.", }), ).start(); actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatUnlockPrivateMessage", messageId: "private-1" }); await waitFor( actor, (snapshot) => snapshot.context.messages[0]?.content === "Here is the unlocked private message.", ); const [message] = actor.getSnapshot().context.messages; expect(message.content).toBe("Here is the unlocked private message."); expect(message.locked).toBe(false); expect(message.lockedPrivate).toBe(false); expect(message.privateMessageHint).toBeNull(); expect(actor.getSnapshot().context.unlockingPrivateMessageId).toBeNull(); 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: fromPromise(async () => ({ messages: [], hasMore: false, newOffset: 0, localOverwritten: true, localCount: 0, networkCount: 0, })), 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; }), unlockPrivateMessage: fromPromise< UnlockPrivateOutput, { messageId: string } >(async ({ input }) => ({ messageId: input.messageId, response: UnlockPrivateResponse.from({ unlocked: true, content: "unlocked", showUpgrade: false, paywallTriggered: false, privateFreeLimit: 0, privateUsedToday: 0, reason: "ok", }), })), }, }); 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(); }); });