From 6081f2896a9f63b0ba5f02f8d9e3f90da941911f Mon Sep 17 00:00:00 2001 From: chenhang Date: Thu, 25 Jun 2026 13:28:08 +0800 Subject: [PATCH] test(stores): add state machine coverage --- .../auth/__tests__/auth-machine.test.ts | 146 +++++++++++ .../__tests__/chat-machine.helpers.test.ts | 234 ++++++++++++++++++ .../chat-machine.transitions.test.ts | 223 +++++++++++++++++ .../payment/__tests__/payment-machine.test.ts | 221 +++++++++++++++++ .../sidebar/__tests__/sidebar-machine.test.ts | 61 +++++ .../__tests__/user-machine.helpers.test.ts | 51 ++++ .../user/__tests__/user-machine.test.ts | 142 +++++++++++ src/stores/user/user-machine.ts | 5 +- 8 files changed, 1081 insertions(+), 2 deletions(-) create mode 100644 src/stores/auth/__tests__/auth-machine.test.ts create mode 100644 src/stores/chat/__tests__/chat-machine.helpers.test.ts create mode 100644 src/stores/chat/__tests__/chat-machine.transitions.test.ts create mode 100644 src/stores/payment/__tests__/payment-machine.test.ts create mode 100644 src/stores/sidebar/__tests__/sidebar-machine.test.ts create mode 100644 src/stores/user/__tests__/user-machine.helpers.test.ts create mode 100644 src/stores/user/__tests__/user-machine.test.ts diff --git a/src/stores/auth/__tests__/auth-machine.test.ts b/src/stores/auth/__tests__/auth-machine.test.ts new file mode 100644 index 00000000..e8e496cc --- /dev/null +++ b/src/stores/auth/__tests__/auth-machine.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from "vitest"; +import { createActor, fromPromise, waitFor } from "xstate"; + +import type { LoginStatus } from "@/data/dto/auth"; +import type { AuthProvider } from "@/lib/auth/auth_platform"; +import { authMachine } from "@/stores/auth/auth-machine"; + +function createTestAuthMachine( + overrides: Partial<{ + checkAuthStatus: LoginStatus; + guestLogin: LoginStatus; + emailLogin: LoginStatus; + logoutSpy: () => void; + }> = {}, +) { + const logoutSpy = overrides.logoutSpy ?? vi.fn<() => void>(); + + return authMachine.provide({ + actors: { + checkAuthStatus: fromPromise( + async () => overrides.checkAuthStatus ?? "notLoggedIn", + ), + guestLogin: fromPromise( + async () => overrides.guestLogin ?? "guest", + ), + emailLogin: fromPromise< + LoginStatus, + { email: string; password: string } + >(async () => overrides.emailLogin ?? "email"), + emailRegisterThenLogin: fromPromise< + LoginStatus, + { + email: string; + password: string; + username: string; + confirmPassword: string; + } + >(async () => "email"), + oauthSignIn: fromPromise(async () => {}), + syncGoogleBackend: fromPromise( + async () => "google", + ), + syncFacebookBackend: fromPromise< + LoginStatus, + { accessToken: string } + >(async () => "facebook"), + logout: fromPromise(async () => { + logoutSpy(); + }), + }, + }); +} + +describe("authMachine", () => { + it("updates panel mode, auth mode, and unsupported Apple login errors", () => { + const actor = createActor(createTestAuthMachine()).start(); + + actor.send({ type: "AuthPanelModeChanged", mode: "email" }); + actor.send({ type: "AuthModeChanged", mode: "register" }); + + expect(actor.getSnapshot().context.authPanelMode).toBe("email"); + expect(actor.getSnapshot().context.authMode).toBe("register"); + + actor.send({ type: "AuthAppleLoginSubmitted" }); + expect(actor.getSnapshot().context.errorMessage).toBe( + "Apple login not implemented", + ); + + actor.send({ type: "AuthFormCleared" }); + expect(actor.getSnapshot().context.errorMessage).toBeNull(); + + actor.stop(); + }); + + it("initializes from storage without triggering a pending redirect", async () => { + const actor = createActor( + createTestAuthMachine({ checkAuthStatus: "facebook" }), + ).start(); + + actor.send({ type: "AuthInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + + const context = actor.getSnapshot().context; + expect(context.loginStatus).toBe("facebook"); + expect(context.hasInitialized).toBe(true); + expect(context.pendingRedirect).toBe(false); + + actor.stop(); + }); + + it("guest login marks initialized but does not set pending redirect", async () => { + const actor = createActor(createTestAuthMachine()).start(); + + actor.send({ type: "AuthGuestLoginSubmitted" }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + + const context = actor.getSnapshot().context; + expect(context.loginStatus).toBe("guest"); + expect(context.hasInitialized).toBe(true); + expect(context.pendingRedirect).toBe(false); + + actor.stop(); + }); + + it("email login sets pending redirect until it is cleared", async () => { + const actor = createActor(createTestAuthMachine()).start(); + + actor.send({ + type: "AuthEmailLoginSubmitted", + email: "user@example.com", + password: "12345678", + }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + + expect(actor.getSnapshot().context.loginStatus).toBe("email"); + expect(actor.getSnapshot().context.pendingRedirect).toBe(true); + + actor.send({ type: "AuthClearPendingRedirect" }); + expect(actor.getSnapshot().context.pendingRedirect).toBe(false); + + actor.stop(); + }); + + it("logout resets auth state and keeps initialization completed", async () => { + const logoutSpy = vi.fn<() => void>(); + const actor = createActor(createTestAuthMachine({ logoutSpy })).start(); + + actor.send({ + type: "AuthEmailLoginSubmitted", + email: "user@example.com", + password: "12345678", + }); + await waitFor(actor, (snapshot) => snapshot.context.loginStatus === "email"); + + actor.send({ type: "AuthLogoutSubmitted" }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + + const context = actor.getSnapshot().context; + expect(logoutSpy).toHaveBeenCalledOnce(); + expect(context.loginStatus).toBe("notLoggedIn"); + expect(context.pendingRedirect).toBe(false); + expect(context.hasInitialized).toBe(true); + + actor.stop(); + }); +}); diff --git a/src/stores/chat/__tests__/chat-machine.helpers.test.ts b/src/stores/chat/__tests__/chat-machine.helpers.test.ts new file mode 100644 index 00000000..39803af7 --- /dev/null +++ b/src/stores/chat/__tests__/chat-machine.helpers.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from "vitest"; + +import { ChatSendResponse } from "@/data/dto/chat/chat_send_response"; +import type { ChatState } from "@/stores/chat/chat-state"; +import { + applyHttpSendOutput, + localMessagesToUi, + sendResponseToUiMessage, +} from "@/stores/chat/chat-machine.helpers"; + +function makeResponse( + overrides: Partial[0]> = {}, +): ChatSendResponse { + return ChatSendResponse.from({ + reply: "AI reply", + audioUrl: "", + messageId: "msg-1", + isGuest: false, + timestamp: 1782356425363, + image: { type: null, url: null }, + lockDetail: { + locked: false, + showContent: true, + showUpgrade: false, + reason: null, + hint: null, + detail: null, + }, + ...overrides, + }); +} + +function makeChatState(overrides: Partial = {}): ChatState { + return { + messages: [], + isReplyingAI: true, + wsConnected: false, + upgradePromptVisible: false, + upgradeReason: null, + upgradeHint: null, + upgradeDetail: null, + unlockingPrivateMessageId: null, + isLoadingMore: false, + hasMore: true, + historyOffset: 0, + historyLoaded: true, + ...overrides, + }; +} + +describe("sendResponseToUiMessage", () => { + it("maps locked voice messages without treating them as private text", () => { + const message = sendResponseToUiMessage( + makeResponse({ + audioUrl: + "https://proapi.banlv-ai.com/audio/5198b93c-2915-406a.mp3", + lockDetail: { + locked: true, + showContent: false, + showUpgrade: true, + reason: "voice_message", + hint: "这语气好撩呀,让人有点心跳加速呢…", + detail: { messageId: "msg-1" }, + }, + }), + ); + + expect(message.content).toBe(""); + expect(message.audioUrl).toBe( + "https://proapi.banlv-ai.com/audio/5198b93c-2915-406a.mp3", + ); + expect(message.locked).toBe(true); + expect(message.lockReason).toBe("voice_message"); + expect(message.lockedPrivate).toBeUndefined(); + expect(message.privateMessageHint).toBe( + "这语气好撩呀,让人有点心跳加速呢…", + ); + }); + + it("maps locked private text messages as private messages", () => { + const message = sendResponseToUiMessage( + makeResponse({ + lockDetail: { + locked: true, + showContent: false, + showUpgrade: true, + reason: "private_message", + hint: "Elio has a private message for you.", + detail: { messageId: "msg-1" }, + }, + }), + ); + + expect(message.content).toBe(""); + expect(message.locked).toBe(true); + expect(message.lockReason).toBe("private_message"); + expect(message.isPrivate).toBe(true); + expect(message.lockedPrivate).toBe(true); + expect(message.privateMessageHint).toBe( + "Elio has a private message for you.", + ); + }); + + 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, + showContent: false, + showUpgrade: true, + reason: "image", + hint: "Activate VIP to view the full photo.", + detail: { imageId: "image-1" }, + }, + }), + ); + + 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(); + }); +}); + +describe("applyHttpSendOutput", () => { + it("removes the optimistic user message when the daily limit is reached", () => { + const context = makeChatState({ + messages: [ + { + content: "hello", + isFromAI: false, + date: "2026-06-25", + }, + ], + }); + + const nextState = applyHttpSendOutput(context, { + response: makeResponse({ + reply: "", + messageId: "", + lockDetail: { + locked: true, + showContent: false, + showUpgrade: true, + reason: "daily_limit", + hint: "Free message limit reached.", + detail: { + type: "daily_msg_limit", + usedToday: 3, + limit: 3, + }, + }, + }), + reply: null, + }); + + expect(nextState.messages).toEqual([]); + expect(nextState.isReplyingAI).toBe(false); + expect(nextState.upgradePromptVisible).toBe(true); + expect(nextState.upgradeReason).toBe("daily_limit"); + expect(nextState.upgradeHint).toBeNull(); + expect(nextState.upgradeDetail).toEqual({ + type: "daily_msg_limit", + usedToday: 3, + limit: 3, + }); + }); +}); + +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/voice.mp3", + image: { type: null, url: null }, + lockDetail: { + locked: true, + showContent: false, + showUpgrade: true, + reason: "voice_message", + hint: "A locked voice message is waiting.", + detail: { messageId: "voice-1" }, + }, + }, + ]); + + expect(message.content).toBe(""); + expect(message.audioUrl).toBe("https://example.com/voice.mp3"); + expect(message.locked).toBe(true); + expect(message.lockReason).toBe("voice_message"); + expect(message.lockedPrivate).toBeUndefined(); + expect(message.privateMessageHint).toBe("A locked voice message is waiting."); + }); + + 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, + showContent: true, + showUpgrade: false, + reason: null, + hint: null, + detail: null, + }, + }, + ]); + + expect(message.content).toBe(""); + expect(message.imageUrl).toBe("https://example.com/image.jpg"); + expect(message.imagePaywalled).toBeUndefined(); + }); +}); diff --git a/src/stores/chat/__tests__/chat-machine.transitions.test.ts b/src/stores/chat/__tests__/chat-machine.transitions.test.ts new file mode 100644 index 00000000..0b423782 --- /dev/null +++ b/src/stores/chat/__tests__/chat-machine.transitions.test.ts @@ -0,0 +1,223 @@ +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"; + +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, + })), + sendMessageWs: fromPromise(async () => {}), + 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", + }), + })), + chatWebSocket: fromCallback(({ sendBack }) => { + sendBack({ + type: "ChatWebSocketConnected", + userId: "user-1", + }); + return () => undefined; + }), + }, + }); +} + +describe("chatMachine transitions", () => { + it("keeps guest logout disabled while allowing upgrade to non-VIP 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: "ChatNonVipLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ nonVipUserSession: "ready" }), + ); + + actor.send({ type: "ChatLogout" }); + expect(actor.getSnapshot().matches("idle")).toBe(true); + + actor.stop(); + }); + + it("enters VIP ready only after history and websocket are ready", async () => { + const actor = createActor(createTestChatMachine()).start(); + + actor.send({ type: "ChatVipLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ vipUserSession: "ready" }), + ); + + const snapshot = actor.getSnapshot(); + expect(snapshot.context.historyLoaded).toBe(true); + expect(snapshot.context.wsConnected).toBe(true); + + actor.send({ type: "ChatNonVipLogin", token: "token" }); + await waitFor(actor, (nextSnapshot) => + nextSnapshot.matches({ nonVipUserSession: "ready" }), + ); + + expect(actor.getSnapshot().context.wsConnected).toBe(false); + + actor.stop(); + }); + + it("ignores guest login while an authenticated user session is active", async () => { + const actor = createActor(createTestChatMachine()).start(); + + actor.send({ type: "ChatNonVipLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ nonVipUserSession: "ready" }), + ); + + actor.send({ type: "ChatGuestLogin" }); + expect(actor.getSnapshot().matches({ nonVipUserSession: "ready" })).toBe( + true, + ); + + actor.send({ type: "ChatVipLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ vipUserSession: "ready" }), + ); + + actor.send({ type: "ChatGuestLogin" }); + expect(actor.getSnapshot().matches({ vipUserSession: "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: "ChatNonVipLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ nonVipUserSession: "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(); + }); +}); diff --git a/src/stores/payment/__tests__/payment-machine.test.ts b/src/stores/payment/__tests__/payment-machine.test.ts new file mode 100644 index 00000000..87bf9099 --- /dev/null +++ b/src/stores/payment/__tests__/payment-machine.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it, vi } from "vitest"; +import { createActor, fromPromise, waitFor } from "xstate"; + +import { + CreatePaymentOrderResponse, + type PayChannel, + PaymentOrderStatusResponse, + PaymentPlansResponse, + PaymentVipStatusResponse, +} from "@/data/dto/payment"; +import { paymentMachine } from "@/stores/payment/payment-machine"; + +interface CreateOrderInput { + planId: string; + payChannel: PayChannel; + autoRenew: boolean; +} + +type CreateOrderSpy = (input: CreateOrderInput) => void; + +const monthlyPlan = { + plan_id: "vip_monthly", + plan_name: "VIP Monthly", + order_type: "vip_monthly", + amount_cents: 999, + original_amount_cents: null, + daily_price_cents: 33, + currency: "usd", + vip_days: 30, + dol_amount: null, +}; + +const lifetimePlan = { + plan_id: "vip_lifetime", + plan_name: "VIP Lifetime", + order_type: "vip_lifetime", + amount_cents: 4999, + original_amount_cents: null, + daily_price_cents: null, + currency: "usd", + vip_days: null, + dol_amount: null, +}; + +function createTestPaymentMachine( + overrides: Partial<{ + createOrderSpy: CreateOrderSpy; + orderStatus: "pending" | "paid" | "failed"; + }> = {}, +) { + const createOrderSpy = overrides.createOrderSpy ?? vi.fn(); + const orderStatus = overrides.orderStatus ?? "paid"; + + return paymentMachine.provide({ + actors: { + loadPlans: fromPromise(async () => + PaymentPlansResponse.from({ + plans: [monthlyPlan, lifetimePlan], + }), + ), + createOrder: fromPromise< + CreatePaymentOrderResponse, + CreateOrderInput + >(async ({ input }) => { + createOrderSpy(input); + return CreatePaymentOrderResponse.from({ + orderId: "pay_test_001", + payParams: { + provider: "stripe", + clientSecret: "pi_test_secret_test", + }, + }); + }), + pollOrderStatus: fromPromise(async () => + PaymentOrderStatusResponse.from({ + orderId: "pay_test_001", + status: orderStatus, + orderType: "vip_monthly", + planId: "vip_monthly", + }), + ), + loadVipStatus: fromPromise(async () => + PaymentVipStatusResponse.from({ + isVip: true, + vipExpiresAt: "2026-07-25T00:00:00.000Z", + }), + ), + }, + }); +} + +describe("paymentMachine", () => { + it("loads plans and selects the default monthly VIP plan", async () => { + const actor = createActor(createTestPaymentMachine()).start(); + + actor.send({ type: "PaymentInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + + const context = actor.getSnapshot().context; + expect(context.plans).toHaveLength(2); + expect(context.selectedPlanId).toBe("vip_monthly"); + expect(context.autoRenew).toBe(true); + expect(context.errorMessage).toBeNull(); + + actor.stop(); + }); + + it("does not create an order before the agreement is accepted", async () => { + const createOrderSpy = vi.fn(); + const actor = createActor( + createTestPaymentMachine({ createOrderSpy }), + ).start(); + + actor.send({ type: "PaymentInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + + actor.send({ type: "PaymentAgreementChanged", agreed: false }); + actor.send({ type: "PaymentCreateOrderSubmitted" }); + + const snapshot = actor.getSnapshot(); + expect(snapshot.matches("ready")).toBe(true); + expect(snapshot.context.errorMessage).toBe( + "Choose a plan and accept the agreement before continuing.", + ); + expect(createOrderSpy).not.toHaveBeenCalled(); + + actor.stop(); + }); + + it("creates an order, polls it, and reaches paid state", async () => { + const createOrderSpy = vi.fn(); + const actor = createActor( + createTestPaymentMachine({ createOrderSpy }), + ).start(); + + actor.send({ type: "PaymentInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + + actor.send({ type: "PaymentCreateOrderSubmitted" }); + await waitFor(actor, (snapshot) => snapshot.matches("paid")); + + const context = actor.getSnapshot().context; + expect(createOrderSpy).toHaveBeenCalledWith({ + planId: "vip_monthly", + payChannel: "stripe", + autoRenew: true, + }); + expect(context.currentOrderId).toBe("pay_test_001"); + expect(context.orderStatus).toBe("paid"); + expect(context.launchNonce).toBe(1); + expect(context.vipStatus?.isVip).toBe(true); + + actor.stop(); + }); + + it("moves to waiting state for pending orders and can reset order data", async () => { + const actor = createActor( + createTestPaymentMachine({ orderStatus: "pending" }), + ).start(); + + actor.send({ type: "PaymentInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + + actor.send({ type: "PaymentCreateOrderSubmitted" }); + await waitFor(actor, (snapshot) => snapshot.matches("waitingForPayment")); + + expect(actor.getSnapshot().context.currentOrderId).toBe("pay_test_001"); + expect(actor.getSnapshot().context.orderStatus).toBe("pending"); + + actor.send({ type: "PaymentReset" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + + const context = actor.getSnapshot().context; + expect(context.currentOrderId).toBeNull(); + expect(context.payParams).toBeNull(); + expect(context.orderStatus).toBeNull(); + expect(context.errorMessage).toBeNull(); + + actor.stop(); + }); + + it("moves to failed state when polling returns failed", async () => { + const actor = createActor( + createTestPaymentMachine({ orderStatus: "failed" }), + ).start(); + + actor.send({ type: "PaymentInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + + actor.send({ type: "PaymentCreateOrderSubmitted" }); + await waitFor(actor, (snapshot) => snapshot.matches("failed")); + + const context = actor.getSnapshot().context; + expect(context.orderStatus).toBe("failed"); + expect(context.errorMessage).toBe("Payment failed or was cancelled."); + + actor.stop(); + }); + + it("clears stale order data and disables auto renew for lifetime plans", async () => { + const actor = createActor(createTestPaymentMachine()).start(); + + actor.send({ type: "PaymentInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + + actor.send({ type: "PaymentCreateOrderSubmitted" }); + await waitFor(actor, (snapshot) => snapshot.matches("paid")); + + actor.send({ type: "PaymentPlanSelected", planId: "vip_lifetime" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + + const context = actor.getSnapshot().context; + expect(context.selectedPlanId).toBe("vip_lifetime"); + expect(context.autoRenew).toBe(false); + expect(context.currentOrderId).toBeNull(); + expect(context.payParams).toBeNull(); + expect(context.orderStatus).toBeNull(); + + actor.stop(); + }); +}); diff --git a/src/stores/sidebar/__tests__/sidebar-machine.test.ts b/src/stores/sidebar/__tests__/sidebar-machine.test.ts new file mode 100644 index 00000000..bb1b9f1f --- /dev/null +++ b/src/stores/sidebar/__tests__/sidebar-machine.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { createActor } from "xstate"; + +import { sidebarMachine } from "@/stores/sidebar/sidebar-machine"; +import { + BottomNavItem, + SidebarPage, +} from "@/stores/sidebar/sidebar-state"; + +describe("sidebarMachine", () => { + it("shows a page and selects the matching bottom nav item", () => { + const actor = createActor(sidebarMachine).start(); + + actor.send({ + type: "SidebarShowPage", + page: SidebarPage.Topics, + bottomNavItem: BottomNavItem.Topics, + }); + + expect(actor.getSnapshot().matches("idle")).toBe(true); + expect(actor.getSnapshot().context.currentPage).toBe(SidebarPage.Topics); + expect(actor.getSnapshot().context.selectedBottomNavItem).toBe( + BottomNavItem.Topics, + ); + + actor.stop(); + }); + + it("clears bottom nav without changing the current page", () => { + const actor = createActor(sidebarMachine).start(); + + actor.send({ + type: "SidebarShowPage", + page: SidebarPage.Gifts, + bottomNavItem: BottomNavItem.Gifts, + }); + actor.send({ type: "SidebarClearBottomNav" }); + + expect(actor.getSnapshot().context.currentPage).toBe(SidebarPage.Gifts); + expect(actor.getSnapshot().context.selectedBottomNavItem).toBe( + BottomNavItem.None, + ); + + actor.stop(); + }); + + it("updates character progress", () => { + const actor = createActor(sidebarMachine).start(); + + actor.send({ + type: "SidebarUpdateCharacter", + name: "Elio", + progress: 72, + }); + + expect(actor.getSnapshot().context.characterName).toBe("Elio"); + expect(actor.getSnapshot().context.characterProgress).toBe(72); + + actor.stop(); + }); +}); diff --git a/src/stores/user/__tests__/user-machine.helpers.test.ts b/src/stores/user/__tests__/user-machine.helpers.test.ts new file mode 100644 index 00000000..c63b9dcf --- /dev/null +++ b/src/stores/user/__tests__/user-machine.helpers.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { toView } from "@/stores/user/user-machine.helpers"; + +describe("toView", () => { + it("fills optional user fields with stable defaults", () => { + expect(toView({ id: "user-1", username: "Luna" })).toEqual({ + id: "user-1", + username: "Luna", + email: "", + avatarUrl: "", + intimacy: 0, + dolBalance: 0, + relationshipStage: "", + currentMood: "", + isGuest: false, + isVip: false, + voiceMinutesRemaining: 0, + }); + }); + + it("preserves provided user fields", () => { + expect( + toView({ + id: "user-2", + username: "Elio", + email: "elio@example.com", + avatarUrl: "https://example.com/avatar.png", + intimacy: 42, + dolBalance: 12, + relationshipStage: "close_friend", + currentMood: "playful", + isGuest: true, + isVip: true, + voiceMinutesRemaining: 30, + }), + ).toEqual({ + id: "user-2", + username: "Elio", + email: "elio@example.com", + avatarUrl: "https://example.com/avatar.png", + intimacy: 42, + dolBalance: 12, + relationshipStage: "close_friend", + currentMood: "playful", + isGuest: true, + isVip: true, + voiceMinutesRemaining: 30, + }); + }); +}); diff --git a/src/stores/user/__tests__/user-machine.test.ts b/src/stores/user/__tests__/user-machine.test.ts new file mode 100644 index 00000000..f2f6ed65 --- /dev/null +++ b/src/stores/user/__tests__/user-machine.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from "vitest"; +import { createActor, fromPromise, waitFor } from "xstate"; + +import type { UserView } from "@/data/dto/user"; +import { userMachine } from "@/stores/user/user-machine"; +import type { InitData } from "@/stores/user/user-machine.helpers"; + +function makeUser(overrides: Partial = {}): UserView { + return { + id: "user-1", + username: "Elio Fan", + email: "user@example.com", + avatarUrl: "https://example.com/avatar.png", + intimacy: 42, + dolBalance: 7, + relationshipStage: "close_friend", + currentMood: "happy", + isGuest: false, + isVip: false, + voiceMinutesRemaining: 12, + ...overrides, + }; +} + +function createTestUserMachine( + overrides: Partial<{ + initData: InitData; + fetchedUser: UserView | null; + logoutSpy: () => void; + }> = {}, +) { + const logoutSpy = overrides.logoutSpy ?? vi.fn<() => void>(); + + return userMachine.provide({ + actors: { + userInit: fromPromise(async () => ({ + user: makeUser(), + avatarUrl: "https://example.com/avatar.png", + ...overrides.initData, + })), + userFetch: fromPromise( + async () => + "fetchedUser" in overrides + ? overrides.fetchedUser ?? null + : makeUser({ username: "Fetched" }), + ), + userLogout: fromPromise(async () => { + logoutSpy(); + }), + }, + }); +} + +describe("userMachine", () => { + it("initializes user and avatar from local storage actor output", async () => { + const actor = createActor( + createTestUserMachine({ + initData: { + user: makeUser({ username: "Local User" }), + avatarUrl: "https://example.com/local-avatar.png", + }, + }), + ).start(); + + actor.send({ type: "UserInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + + expect(actor.getSnapshot().context.currentUser?.username).toBe("Local User"); + expect(actor.getSnapshot().context.avatarUrl).toBe( + "https://example.com/local-avatar.png", + ); + + actor.stop(); + }); + + it("updates user profile fields synchronously", () => { + const actor = createActor(createTestUserMachine()).start(); + + actor.send({ type: "UserUpdate", user: makeUser({ username: "Before" }) }); + actor.send({ type: "UserUpdateUsername", username: "After" }); + actor.send({ type: "UserUpdatePronouns", pronouns: "They" }); + + expect(actor.getSnapshot().context.currentUser?.username).toBe("After"); + expect(actor.getSnapshot().context.pronouns).toBe("They"); + + actor.stop(); + }); + + it("fetches the latest user and clears loading state when done", async () => { + const actor = createActor( + createTestUserMachine({ + fetchedUser: makeUser({ username: "Fresh User", isVip: true }), + }), + ).start(); + + actor.send({ type: "UserFetch" }); + expect(actor.getSnapshot().context.isLoading).toBe(true); + + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + + const context = actor.getSnapshot().context; + expect(context.currentUser?.username).toBe("Fresh User"); + expect(context.currentUser?.isVip).toBe(true); + expect(context.isLoading).toBe(false); + + actor.stop(); + }); + + it("keeps the current user when fetch returns null", async () => { + const actor = createActor( + createTestUserMachine({ + fetchedUser: null, + }), + ).start(); + + actor.send({ type: "UserUpdate", user: makeUser({ username: "Cached" }) }); + actor.send({ type: "UserFetch" }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + + const context = actor.getSnapshot().context; + expect(context.currentUser?.username).toBe("Cached"); + expect(context.isLoading).toBe(false); + + actor.stop(); + }); + + it("clears user data after logout", async () => { + const logoutSpy = vi.fn<() => void>(); + const actor = createActor(createTestUserMachine({ logoutSpy })).start(); + + actor.send({ type: "UserUpdate", user: makeUser() }); + actor.send({ type: "UserLogout" }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + + const context = actor.getSnapshot().context; + expect(logoutSpy).toHaveBeenCalledOnce(); + expect(context.currentUser).toBeNull(); + expect(context.avatarUrl).toBeNull(); + + actor.stop(); + }); +}); diff --git a/src/stores/user/user-machine.ts b/src/stores/user/user-machine.ts index fd1b0dcd..1c0d47ff 100644 --- a/src/stores/user/user-machine.ts +++ b/src/stores/user/user-machine.ts @@ -100,8 +100,9 @@ export const userMachine = setup({ src: "userFetch", onDone: { target: "idle", - actions: assign(({ event }) => ({ - currentUser: event.output, + actions: assign(({ context, event }) => ({ + currentUser: event.output ?? context.currentUser, + isLoading: false, })), }, onError: {