import { describe, expect, it } from "vitest"; import { createActor, fromPromise, waitFor } from "xstate"; import { ChatSendResponse } from "@/data/dto/chat"; import type { UnlockMessageOutput, UnlockMessageRequest, } from "@/stores/chat/helper/unlock"; import { createTestChatMachine, makeUnlockPrivateResponse, } from "./chat-machine.test-utils"; describe("chat unlock flow", () => { 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, }, ], }, }), ).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, }, ], }, }), ).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("restores the unlock prompt when the history actor fails", 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", }, ], unlockHistoryError: new Error("unlock failed"), }), ).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).toMatchObject({ unlockHistoryPromptVisible: true, unlockHistoryError: "Failed to unlock messages. Please try again.", }); 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.unlockingMessage).toBeNull(); 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_credits", creditBalance: 3, creditsCharged: 0, requiredCredits: 10, shortfallCredits: 7, }), }, }), ).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_credits", 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.unlockingMessage).toBeNull(); actor.stop(); }); it("does not request payment when the unlock target is not found", async () => { const actor = createActor( createTestChatMachine({ historyMessages: [ { id: "msg-missing", content: "", isFromAI: true, date: "2026-06-29", locked: true, lockReason: "private_message", privateMessageHint: "A private message is waiting.", }, ], unlockMessageOutput: { messageId: "msg-missing", response: makeUnlockPrivateResponse({ unlocked: false, content: "", reason: "not_found", creditBalance: 3, creditsCharged: 0, requiredCredits: 0, shortfallCredits: 0, }), }, }), ).start(); actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatUnlockMessageRequested", messageId: "msg-missing", kind: "private", }); await waitFor( actor, (snapshot) => snapshot.context.unlockMessageError === "not_found", ); expect(actor.getSnapshot().context).toMatchObject({ unlockingMessage: null, unlockMessageError: "not_found", unlockPaywallRequest: null, }); expect(actor.getSnapshot().context.messages).toMatchObject([ { id: "msg-missing", locked: true, lockReason: "private_message", }, ]); actor.stop(); }); it("owns the active request in one object and ignores history refreshes while unlocking", async () => { let capturedRequest: UnlockMessageRequest | null = null; let resolveUnlock!: (output: UnlockMessageOutput) => void; const unlockDeferred = new Promise((resolve) => { resolveUnlock = resolve; }); const originalMessage = { id: "promotion:lock-1", content: "", isFromAI: true, date: "2026-07-16", locked: true, lockReason: "image_paywall" as const, imagePaywalled: true, }; const machine = createTestChatMachine({ historyMessages: [originalMessage], }).provide({ actors: { unlockMessage: fromPromise< UnlockMessageOutput, UnlockMessageRequest >(async ({ input }) => { capturedRequest = input; return unlockDeferred; }), }, }); const actor = createActor(machine).start(); actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatUnlockMessageRequested", messageId: "promotion:lock-1", remoteMessageId: "remote-1", kind: "image", lockType: "image_paywall", clientLockId: "lock-1", }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "unlockingMessage" }), ); expect(actor.getSnapshot().context.unlockingMessage).toEqual({ displayMessageId: "promotion:lock-1", messageId: "remote-1", kind: "image", lockType: "image_paywall", clientLockId: "lock-1", }); actor.send({ type: "ChatNetworkHistoryLoaded", output: { messages: [], localOverwritten: true, localCount: 0, networkCount: 0, total: 0, limit: 50, }, }); expect(actor.getSnapshot().context.messages).toEqual([originalMessage]); if (!capturedRequest) throw new Error("unlock actor did not start"); resolveUnlock({ displayMessageId: "promotion:lock-1", request: capturedRequest, response: makeUnlockPrivateResponse({ messageId: "remote-1", image: { type: "promotion", url: "https://example.com/unlocked.jpg", }, }), }); await waitFor(actor, (snapshot) => snapshot.matches({ userSession: "ready" }), ); expect(actor.getSnapshot().context.unlockingMessage).toBeNull(); 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: false, reason: null, }, canSendMessage: false, 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); actor.stop(); }); });