import { describe, expect, it } from "vitest"; import { DEFAULT_CHARACTER, DEFAULT_CHARACTER_ID, } from "@/data/constants/character"; import { ChatSendResponseSchema, type ChatSendResponse, type ChatSendResponseInput, } from "@/data/schemas/chat"; import type { ChatState } from "@/stores/chat/chat-state"; import { applyHttpSendOutput, applyNetworkHistoryLoadedOutput, countLockedHistoryMessages, localMessagesToUi, sendResponseToUiMessage, } from "@/stores/chat/helper"; function makeResponse( overrides: Partial = {}, ): ChatSendResponse { return ChatSendResponseSchema.parse({ reply: "AI reply", messageId: "msg-1", isGuest: false, timestamp: 1782356425363, image: { type: null, url: null }, lockDetail: { locked: false, reason: null, }, ...overrides, }); } function makeChatState(overrides: Partial = {}): ChatState { return { characterId: DEFAULT_CHARACTER_ID, emptyChatGreeting: DEFAULT_CHARACTER.emptyChatGreeting, messages: [], promotion: null, outgoingMessageRevision: 0, isReplyingAI: true, pendingReplyCount: 1, upgradePromptVisible: false, upgradeReason: null, canSendMessage: true, creditBalance: 0, creditsCharged: 0, requiredCredits: 0, shortfallCredits: 0, historyLoaded: true, historyTotal: 0, historyLimit: 50, nextHistoryOffset: 0, isLoadingMoreHistory: false, paymentUnlockPending: false, unlockHistoryPromptVisible: false, lockedHistoryCount: 0, unlockHistoryError: null, unlockingMessage: null, unlockMessageError: null, unlockPaywallRequest: null, ...overrides, }; } describe("sendResponseToUiMessage", () => { it("maps locked voice messages without treating them as private text", () => { const message = sendResponseToUiMessage( makeResponse({ lockDetail: { locked: true, reason: "voice_message", }, }), ); expect(message.content).toBe("AI reply"); expect(message.audioUrl).toBeUndefined(); expect(message.locked).toBe(true); expect(message.lockReason).toBe("voice_message"); expect(message.lockedPrivate).toBeUndefined(); expect(message.privateMessageHint).toBeUndefined(); }); it("does not expose an audio URL from a locked send response", () => { const message = sendResponseToUiMessage( makeResponse({ audioUrl: "https://example.com/locked-voice.mp3", lockDetail: { locked: true, reason: "voice_message", }, }), ); expect(message.audioUrl).toBeUndefined(); expect(message.locked).toBe(true); }); it("maps an audio URL from an unlocked send response", () => { const message = sendResponseToUiMessage( makeResponse({ audioUrl: "https://example.com/unlocked-voice.mp3", }), ); expect(message.audioUrl).toBe("https://example.com/unlocked-voice.mp3"); expect(message.locked).toBe(false); }); it("maps locked private text messages as private messages", () => { const message = sendResponseToUiMessage( makeResponse({ lockDetail: { locked: true, reason: "private_message", }, }), ); expect(message.content).toBe("AI reply"); expect(message.locked).toBe(true); expect(message.lockReason).toBe("private_message"); expect(message.isPrivate).toBe(true); expect(message.lockedPrivate).toBe(true); expect(message.privateMessageHint).toBeUndefined(); }); it("keeps paywalled image messages visible but locked", () => { const message = sendResponseToUiMessage( makeResponse({ image: { type: "jpg", url: "https://example.com/private-photo.jpg", }, lockDetail: { locked: true, reason: "image", }, }), ); expect(message.content).toBe(""); expect(message.imageUrl).toBe("https://example.com/private-photo.jpg"); expect(message.imagePaywalled).toBe(true); expect(message.locked).toBe(true); expect(message.lockReason).toBe("image"); expect(message.lockedPrivate).toBeUndefined(); }); it("does not mark a message as paywalled when its lock reason is unlocked", () => { const message = sendResponseToUiMessage( makeResponse({ image: { type: "jpg", url: "https://example.com/unlocked-photo.jpg", }, lockDetail: { locked: false, reason: "image_paywall", }, }), ); expect(message.locked).toBe(false); expect(message.lockReason).toBe("image_paywall"); expect(message.imagePaywalled).toBeUndefined(); }); }); describe("applyHttpSendOutput", () => { it("stores insufficient credit state and removes failed optimistic messages", () => { const context = makeChatState({ messages: [ { content: "hello", isFromAI: false, date: "2026-06-25", }, ], }); const nextState = applyHttpSendOutput(context, { response: makeResponse({ reply: "", messageId: "", canSendMessage: false, creditBalance: 0, creditsCharged: 0, requiredCredits: 2, shortfallCredits: 2, lockDetail: { locked: false, reason: null, }, }), reply: null, }); expect(nextState.messages).toEqual([]); expect(nextState.isReplyingAI).toBe(false); expect(nextState.upgradePromptVisible).toBe(true); expect(nextState.upgradeReason).toBe("insufficient_credits"); expect(nextState.canSendMessage).toBe(false); expect(nextState.requiredCredits).toBe(2); expect(nextState.shortfallCredits).toBe(2); }); it("keeps a valid reply while marking future sends as unavailable", () => { const context = makeChatState({ messages: [ { content: "hello", isFromAI: false, date: "2026-06-25", }, ], }); const reply = sendResponseToUiMessage( makeResponse({ reply: "This one went through, but you need credits next.", canSendMessage: false, creditBalance: 0, requiredCredits: 2, shortfallCredits: 2, }), ); const nextState = applyHttpSendOutput(context, { response: makeResponse({ reply: "This one went through, but you need credits next.", canSendMessage: false, creditBalance: 0, requiredCredits: 2, shortfallCredits: 2, }), reply, }); expect(nextState.messages).toHaveLength(2); expect(nextState.messages?.[1]).toMatchObject({ content: "This one went through, but you need credits next.", isFromAI: true, }); expect(nextState.upgradePromptVisible).toBe(true); expect(nextState.upgradeReason).toBe("insufficient_credits"); expect(nextState.canSendMessage).toBe(false); }); }); describe("chat history pagination helpers", () => { it("falls back to the bounded history limit when the backend limit is zero", () => { const nextState = applyNetworkHistoryLoadedOutput(makeChatState(), { messages: [], localCount: 0, total: 120, limit: 0, }); expect(nextState.historyLimit).toBe(50); expect(nextState.nextHistoryOffset).toBe(50); expect(nextState.historyTotal).toBe(120); }); }); describe("localMessagesToUi", () => { it("maps locked voice messages from history", () => { const [message] = localMessagesToUi([ { id: "voice-1", role: "assistant", type: "voice", content: "hidden voice transcript", createdAt: "2026-06-25T12:00:00.000Z", audioUrl: "https://example.com/locked-history-voice.mp3", image: { type: null, url: null }, lockDetail: { locked: true, reason: "voice_message", }, }, ]); expect(message.content).toBe("hidden voice transcript"); expect(message.audioUrl).toBeUndefined(); expect(message.locked).toBe(true); expect(message.lockReason).toBe("voice_message"); expect(message.lockedPrivate).toBeUndefined(); expect(message.privateMessageHint).toBeUndefined(); }); it("hides text content for image history messages", () => { const [message] = localMessagesToUi([ { id: "image-1", role: "assistant", type: "image", content: "This text should not render with an image.", createdAt: "2026-06-25T12:00:00.000Z", audioUrl: null, image: { type: "jpg", url: "https://example.com/image.jpg", }, lockDetail: { locked: false, reason: null, }, }, ]); expect(message.content).toBe(""); expect(message.imageUrl).toBe("https://example.com/image.jpg"); expect(message.imagePaywalled).toBeUndefined(); }); }); describe("countLockedHistoryMessages", () => { it("counts only unlockable locked AI messages", () => { expect( countLockedHistoryMessages([ { content: "", isFromAI: true, date: "2026-06-29", locked: true, lockReason: "private_message", }, { content: "", isFromAI: true, date: "2026-06-29", locked: true, lockReason: "voice_message", }, { content: "", isFromAI: true, date: "2026-06-29", locked: true, imageUrl: "https://example.com/locked.jpg", imagePaywalled: true, lockReason: "image", }, { content: "", isFromAI: true, date: "2026-06-29", locked: true, lockReason: "insufficient_credits", }, { content: "user text", isFromAI: false, date: "2026-06-29", locked: true, lockReason: "private_message", }, ]), ).toBe(3); }); });