diff --git a/barrelsby.json b/barrelsby.json index 6c8acef8..38c15d5b 100644 --- a/barrelsby.json +++ b/barrelsby.json @@ -43,8 +43,6 @@ "./src/app/subscription/components", "./src/hooks", "./src/integrations", - "./src/utils", - "./src/stores/auth", - "./src/stores/user" + "./src/utils" ] } diff --git a/src/stores/auth/__tests__/auth-credentials-flow.test.ts b/src/stores/auth/__tests__/auth-credentials-flow.test.ts new file mode 100644 index 00000000..b49d353e --- /dev/null +++ b/src/stores/auth/__tests__/auth-credentials-flow.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { createActor, waitFor } from "xstate"; + +import { createTestAuthMachine } from "./auth-machine.test-utils"; + +describe("auth credentials flow", () => { + it("email login updates login status", 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).toMatchObject({ + loginStatus: "email", + hasInitialized: true, + }); + actor.stop(); + }); + + it("registration completes through the email session", async () => { + const actor = createActor(createTestAuthMachine()).start(); + actor.send({ + type: "AuthEmailRegisterSubmitted", + email: "user@example.com", + password: "12345678", + username: "User", + confirmPassword: "12345678", + }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + expect(actor.getSnapshot().context.loginStatus).toBe("email"); + actor.stop(); + }); +}); diff --git a/src/stores/auth/__tests__/auth-machine.test-utils.ts b/src/stores/auth/__tests__/auth-machine.test-utils.ts new file mode 100644 index 00000000..e7536df3 --- /dev/null +++ b/src/stores/auth/__tests__/auth-machine.test-utils.ts @@ -0,0 +1,72 @@ +import { vi } from "vitest"; +import { fromPromise } from "xstate"; + +import type { LoginStatus } from "@/data/dto/auth"; +import type { AuthProvider } from "@/lib/auth/auth_platform"; +import { authMachine } from "@/stores/auth/auth-machine"; +import type { BindFacebookIdentityInput } from "@/stores/auth/auth-events"; + +export function createTestAuthMachine( + overrides: Partial<{ + checkAuthStatus: LoginStatus; + guestLogin: LoginStatus; + guestLoginSpy: () => void; + emailLogin: LoginStatus; + logoutSpy: () => void; + bindFacebookIdentitySpy: (input: BindFacebookIdentityInput) => void; + oauthSignInError: Error; + googleSync: LoginStatus; + facebookSync: LoginStatus; + }> = {}, +) { + const logoutSpy = overrides.logoutSpy ?? vi.fn<() => void>(); + const guestLoginSpy = overrides.guestLoginSpy ?? vi.fn<() => void>(); + const bindFacebookIdentitySpy = + overrides.bindFacebookIdentitySpy ?? + vi.fn<(input: BindFacebookIdentityInput) => void>(); + + return authMachine.provide({ + actors: { + checkAuthStatus: fromPromise( + async () => overrides.checkAuthStatus ?? "notLoggedIn", + ), + bindFacebookIdentity: fromPromise< + void, + BindFacebookIdentityInput + >(async ({ input }) => { + bindFacebookIdentitySpy(input); + }), + guestLogin: fromPromise(async () => { + guestLoginSpy(); + return 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 () => { + if (overrides.oauthSignInError) throw overrides.oauthSignInError; + }), + syncGoogleBackend: fromPromise( + async () => overrides.googleSync ?? "google", + ), + syncFacebookBackend: fromPromise< + LoginStatus, + { accessToken: string } + >(async () => overrides.facebookSync ?? "facebook"), + logout: fromPromise(async () => { + logoutSpy(); + return "guest"; + }), + }, + }); +} diff --git a/src/stores/auth/__tests__/auth-machine.test.ts b/src/stores/auth/__tests__/auth-machine.test.ts deleted file mode 100644 index efc73986..00000000 --- a/src/stores/auth/__tests__/auth-machine.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -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"; -import type { BindFacebookIdentityInput } from "@/stores/auth/auth-actors"; - -function createTestAuthMachine( - overrides: Partial<{ - checkAuthStatus: LoginStatus; - guestLogin: LoginStatus; - guestLoginSpy: () => void; - emailLogin: LoginStatus; - logoutSpy: () => void; - bindFacebookIdentitySpy: (input: BindFacebookIdentityInput) => void; - }> = {}, -) { - const logoutSpy = overrides.logoutSpy ?? vi.fn<() => void>(); - const guestLoginSpy = overrides.guestLoginSpy ?? vi.fn<() => void>(); - const bindFacebookIdentitySpy = - overrides.bindFacebookIdentitySpy ?? - vi.fn<(input: BindFacebookIdentityInput) => void>(); - - return authMachine.provide({ - actors: { - checkAuthStatus: fromPromise( - async () => overrides.checkAuthStatus ?? "notLoggedIn", - ), - bindFacebookIdentity: fromPromise< - void, - BindFacebookIdentityInput - >(async ({ input }) => { - bindFacebookIdentitySpy(input); - }), - guestLogin: fromPromise( - async () => { - guestLoginSpy(); - return 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(); - return "guest"; - }), - }, - }); -} - -describe("authMachine", () => { - it("updates panel mode", () => { - const actor = createActor(createTestAuthMachine()).start(); - - actor.send({ type: "AuthPanelModeChanged", mode: "email" }); - - expect(actor.getSnapshot().context.authPanelMode).toBe("email"); - - actor.stop(); - }); - - it("initializes from storage without triggering guest login", async () => { - const guestLoginSpy = vi.fn<() => void>(); - const bindFacebookIdentitySpy = - vi.fn<(input: BindFacebookIdentityInput) => void>(); - const actor = createActor( - createTestAuthMachine({ - checkAuthStatus: "facebook", - guestLoginSpy, - bindFacebookIdentitySpy, - }), - ).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(guestLoginSpy).not.toHaveBeenCalled(); - expect(bindFacebookIdentitySpy).not.toHaveBeenCalled(); - - actor.stop(); - }); - - it("binds facebook identity only after an explicit external-entry event", async () => { - const bindFacebookIdentitySpy = - vi.fn<(input: BindFacebookIdentityInput) => void>(); - const actor = createActor( - createTestAuthMachine({ - checkAuthStatus: "email", - bindFacebookIdentitySpy, - }), - ).start(); - - actor.send({ type: "AuthInit" }); - await waitFor(actor, (snapshot) => snapshot.matches("idle")); - actor.send({ - type: "AuthExternalFacebookIdentityBindRequested", - asid: "asid-1", - psid: "psid-1", - }); - await waitFor(actor, (snapshot) => snapshot.matches("idle")); - - expect(bindFacebookIdentitySpy).toHaveBeenCalledOnce(); - expect(bindFacebookIdentitySpy).toHaveBeenCalledWith({ - asid: "asid-1", - psid: "psid-1", - }); - expect(actor.getSnapshot().context.loginStatus).toBe("email"); - - actor.stop(); - }); - - it("keeps the current login status when external identity binding fails", async () => { - const actor = createActor( - createTestAuthMachine({ - checkAuthStatus: "google", - bindFacebookIdentitySpy: () => { - throw new Error("bind failed"); - }, - }), - ).start(); - - actor.send({ type: "AuthInit" }); - await waitFor(actor, (snapshot) => snapshot.matches("idle")); - actor.send({ - type: "AuthExternalFacebookIdentityBindRequested", - psid: "psid-1", - }); - await waitFor(actor, (snapshot) => snapshot.matches("idle")); - - expect(actor.getSnapshot().context.loginStatus).toBe("google"); - expect(actor.getSnapshot().context.errorMessage).toBeNull(); - - actor.stop(); - }); - - it("initializes as notLoggedIn without automatically guest logging in", async () => { - const guestLoginSpy = vi.fn<() => void>(); - const actor = createActor(createTestAuthMachine({ guestLoginSpy })).start(); - - actor.send({ type: "AuthInit" }); - await waitFor(actor, (snapshot) => snapshot.matches("idle")); - - const context = actor.getSnapshot().context; - expect(context.loginStatus).toBe("notLoggedIn"); - expect(context.hasInitialized).toBe(true); - expect(guestLoginSpy).not.toHaveBeenCalled(); - - actor.stop(); - }); - - it("guest logs in only after explicit guest login submission", async () => { - const guestLoginSpy = vi.fn<() => void>(); - const actor = createActor(createTestAuthMachine({ guestLoginSpy })).start(); - - actor.send({ type: "AuthInit" }); - await waitFor(actor, (snapshot) => snapshot.matches("idle")); - - 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(guestLoginSpy).toHaveBeenCalledOnce(); - - actor.stop(); - }); - - it("email login updates login status", 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.hasInitialized).toBe(true); - - actor.stop(); - }); - - it("logout transitions authenticated users back to guest", 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("guest"); - expect(context.hasInitialized).toBe(true); - - actor.stop(); - }); -}); diff --git a/src/stores/auth/__tests__/auth-oauth-flow.test.ts b/src/stores/auth/__tests__/auth-oauth-flow.test.ts new file mode 100644 index 00000000..b7f6132d --- /dev/null +++ b/src/stores/auth/__tests__/auth-oauth-flow.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { createActor, waitFor } from "xstate"; + +import { createTestAuthMachine } from "./auth-machine.test-utils"; + +describe("auth OAuth flow", () => { + it("applies Google backend sync status", async () => { + const actor = createActor(createTestAuthMachine()).start(); + actor.send({ type: "AuthGoogleSyncSubmitted", idToken: "google-token" }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + expect(actor.getSnapshot().context.loginStatus).toBe("google"); + actor.stop(); + }); + + it("applies Facebook backend sync status", async () => { + const actor = createActor(createTestAuthMachine()).start(); + actor.send({ + type: "AuthFacebookSyncSubmitted", + accessToken: "facebook-token", + }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + expect(actor.getSnapshot().context.loginStatus).toBe("facebook"); + actor.stop(); + }); + + it("returns to idle with an error when OAuth launch fails", async () => { + const actor = createActor( + createTestAuthMachine({ oauthSignInError: new Error("oauth failed") }), + ).start(); + actor.send({ type: "AuthGoogleLoginSubmitted", provider: "google" }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + expect(actor.getSnapshot().context.errorMessage).toBe("oauth failed"); + actor.stop(); + }); +}); diff --git a/src/stores/auth/__tests__/auth-session-flow.test.ts b/src/stores/auth/__tests__/auth-session-flow.test.ts new file mode 100644 index 00000000..f0f65614 --- /dev/null +++ b/src/stores/auth/__tests__/auth-session-flow.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from "vitest"; +import { createActor, waitFor } from "xstate"; + +import type { BindFacebookIdentityInput } from "@/stores/auth/auth-events"; + +import { createTestAuthMachine } from "./auth-machine.test-utils"; + +describe("auth session flow", () => { + it("updates panel mode", () => { + const actor = createActor(createTestAuthMachine()).start(); + actor.send({ type: "AuthPanelModeChanged", mode: "email" }); + expect(actor.getSnapshot().context.authPanelMode).toBe("email"); + actor.stop(); + }); + + it("initializes from storage without triggering guest login", async () => { + const guestLoginSpy = vi.fn<() => void>(); + const bindFacebookIdentitySpy = + vi.fn<(input: BindFacebookIdentityInput) => void>(); + const actor = createActor( + createTestAuthMachine({ + checkAuthStatus: "facebook", + guestLoginSpy, + bindFacebookIdentitySpy, + }), + ).start(); + + actor.send({ type: "AuthInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + + expect(actor.getSnapshot().context).toMatchObject({ + loginStatus: "facebook", + hasInitialized: true, + }); + expect(guestLoginSpy).not.toHaveBeenCalled(); + expect(bindFacebookIdentitySpy).not.toHaveBeenCalled(); + actor.stop(); + }); + + it("binds identity only after the external-entry event", async () => { + const bindFacebookIdentitySpy = + vi.fn<(input: BindFacebookIdentityInput) => void>(); + const actor = createActor( + createTestAuthMachine({ + checkAuthStatus: "email", + bindFacebookIdentitySpy, + }), + ).start(); + + actor.send({ type: "AuthInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + actor.send({ + type: "AuthExternalFacebookIdentityBindRequested", + asid: "asid-1", + psid: "psid-1", + }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + + expect(bindFacebookIdentitySpy).toHaveBeenCalledWith({ + asid: "asid-1", + psid: "psid-1", + }); + expect(actor.getSnapshot().context.loginStatus).toBe("email"); + actor.stop(); + }); + + it("keeps login status when identity binding fails", async () => { + const actor = createActor( + createTestAuthMachine({ + checkAuthStatus: "google", + bindFacebookIdentitySpy: () => { + throw new Error("bind failed"); + }, + }), + ).start(); + + actor.send({ type: "AuthInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + actor.send({ + type: "AuthExternalFacebookIdentityBindRequested", + psid: "psid-1", + }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + + expect(actor.getSnapshot().context.loginStatus).toBe("google"); + expect(actor.getSnapshot().context.errorMessage).toBeNull(); + actor.stop(); + }); + + it("does not automatically create a guest login during init", async () => { + const guestLoginSpy = vi.fn<() => void>(); + const actor = createActor(createTestAuthMachine({ guestLoginSpy })).start(); + actor.send({ type: "AuthInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + + expect(actor.getSnapshot().context.loginStatus).toBe("notLoggedIn"); + expect(guestLoginSpy).not.toHaveBeenCalled(); + actor.stop(); + }); + + it("guest logs in only after explicit submission", async () => { + const guestLoginSpy = vi.fn<() => void>(); + const actor = createActor(createTestAuthMachine({ guestLoginSpy })).start(); + actor.send({ type: "AuthInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + actor.send({ type: "AuthGuestLoginSubmitted" }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + + expect(actor.getSnapshot().context.loginStatus).toBe("guest"); + expect(guestLoginSpy).toHaveBeenCalledOnce(); + actor.stop(); + }); + + it("logs authenticated users out to guest", 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")); + + expect(logoutSpy).toHaveBeenCalledOnce(); + expect(actor.getSnapshot().context.loginStatus).toBe("guest"); + actor.stop(); + }); +}); diff --git a/src/stores/auth/auth-actors.ts b/src/stores/auth/auth-actors.ts deleted file mode 100644 index 48ed5d6b..00000000 --- a/src/stores/auth/auth-actors.ts +++ /dev/null @@ -1,339 +0,0 @@ -/** - * Auth 状态机:Actors(异步服务) - */ -import { fromPromise } from "xstate"; - -import { AuthPlatform, type AuthProvider } from "@/lib/auth/auth_platform"; -import type { LoginStatus } from "@/data/dto/auth"; -import { getAuthRepository } from "@/data/repositories/auth_repository"; -import { fetchFacebookUserData } from "@/data/services"; -import { AuthStorage } from "@/data/storage/auth/auth_storage"; -import { UserStorage } from "@/data/storage/user/user_storage"; -import { deviceIdentifier } from "@/utils/device_identifier"; -import { Logger } from "@/utils/logger"; -import { Result } from "@/utils/result"; -import { hasCompleteBusinessAuthSession } from "@/lib/auth/auth_session"; - -import { readGuestId, readPsid } from "./auth-helpers"; - -const log = new Logger("AuthActors"); - -export const emailLoginActor = fromPromise( - async ({ input }) => { - const authRepo = getAuthRepository(); - log.debug("[auth-machine] emailLoginActor ENTRY", { - emailLength: input.email.length, - emailPreview: input.email.slice(0, 8), - passwordLength: input.password.length, - }); - const guestId = await readGuestId(); - const psid = await readPsid(); - log.debug("[auth-machine] emailLoginActor calling authRepo.emailLogin", { - hasGuestId: !!guestId, - hasPsid: !!psid, - }); - const result = await authRepo.emailLogin({ ...input, guestId, psid }); - log.debug("[auth-machine] emailLoginActor authRepo.emailLogin DONE", { - success: result.success, - hasData: result.success ? !!result.data : null, - errorName: result.success ? null : result.error?.name, - errorMessage: result.success ? null : result.error?.message, - }); - if (Result.isErr(result)) throw result.error; - return "email" as LoginStatus; - }, -); - -export const emailRegisterThenLoginActor = fromPromise< - LoginStatus, - { email: string; password: string; username: string; confirmPassword: string } ->(async ({ input }) => { - const authRepo = getAuthRepository(); - log.debug("[auth-machine] emailRegisterThenLoginActor ENTRY", { - emailLength: input.email.length, - usernameLength: input.username.length, - passwordLength: input.password.length, - confirmPasswordLength: input.confirmPassword.length, - }); - const guestId = await readGuestId(); - - log.debug("[auth-machine] emailRegisterThenLoginActor calling authRepo.register"); - const registerResult = await authRepo.register({ ...input, guestId }); - log.debug("[auth-machine] emailRegisterThenLoginActor authRepo.register DONE", { - success: registerResult.success, - errorName: registerResult.success ? null : registerResult.error?.name, - errorMessage: registerResult.success ? null : registerResult.error?.message, - }); - if (Result.isErr(registerResult)) throw registerResult.error; - - const psid = await readPsid(); - // 注册后自动登录(对齐 Dart 行为) - log.debug( - "[auth-machine] emailRegisterThenLoginActor calling authRepo.emailLogin (post-register)", - ); - const loginResult = await authRepo.emailLogin({ - email: input.email, - password: input.password, - guestId, - psid, - }); - log.debug( - "[auth-machine] emailRegisterThenLoginActor authRepo.emailLogin DONE", - { - success: loginResult.success, - errorName: loginResult.success ? null : loginResult.error?.name, - errorMessage: loginResult.success ? null : loginResult.error?.message, - }, - ); - if (Result.isErr(loginResult)) throw loginResult.error; - return "email" as LoginStatus; -}); - -// OAuth 登录 actor:调 AuthPlatform 触发 OAuth 跳转。 -// 成功路径下 NextAuth 会重定向离开本应用,actor 通常不会 resolve; -// onDone 仍保留以处理 SDK 同步返回(极少见)。 -export const oauthSignInActor = fromPromise(async ({ input }) => { - if (input === "google") { - await AuthPlatform.googleSignIn(); - } else { - await AuthPlatform.facebookSignIn(); - } -}); - -export const logoutActor = fromPromise(async () => { - const authRepo = getAuthRepository(); - const result = await authRepo.logout(); - if (Result.isErr(result)) throw result.error; - return "guest" as LoginStatus; -}); - -// ========== OAuth token → 后端业务 token sync actors ========== -// 由 在 NextAuth 回调后派发(监听 useSession())。 -// 用 OAuth provider 的 idToken / accessToken 调后端换业务 token + user。 -// 后端返回的 LoginResponse 已经被 AuthRepository._saveLoginData 持久化。 - -export const syncGoogleBackendActor = fromPromise( - async ({ input }) => { - const authRepo = getAuthRepository(); - const guestId = await readGuestId(); - const psid = await readPsid(); - const result = await authRepo.googleLogin({ - idToken: input.idToken, - guestId, - psid, - }); - if (Result.isErr(result)) throw result.error; - return "google" as LoginStatus; - }, -); - -export const syncFacebookBackendActor = fromPromise< - LoginStatus, - { accessToken: string } ->(async ({ input }) => { - const authRepo = getAuthRepository(); - const guestId = await readGuestId(); - const psid = await readPsid(); - const result = await authRepo.facebookLogin({ - accessToken: input.accessToken, - guestId, - psid, - }); - if (Result.isErr(result)) throw result.error; - - // 业务 token 已到手 —— 顺带用同一个 accessToken 调 Facebook Graph `/me` - // 拉用户资料(id / name / email / picture),把 pictureUrl + Facebook ID - // 写本地。 - // - // 为什么要这一步?后端 LoginResponse.user.avatarUrl 不一定总有(依赖 - // 后端在 OAuth 流程里有没有抓过);前端用 accessToken 直接调 Graph - // 拿到的 picture 是最新的,独立于后端缓存。 - // - // 失败策略:best-effort。Graph 调用挂掉 / 用户没头像 / token 过期都不 - // 影响 Facebook 登录主流程 —— 业务 token 已经在 result 里。 - await persistFacebookProfile(input.accessToken); - - return "facebook" as LoginStatus; -}); - -/** - * 用 accessToken 调 Facebook Graph `/me` 拉用户资料,写本地: - * - `id` → `AuthStorage.setAsid()`(独立于后端 userId) - * - `pictureUrl` → `UserStorage.setAvatarUrl()`(头像专用 slot,便于快速读取) - * - * 全部 best-effort:单条失败只 warn,不抛错。`pictureUrl == null` 也算成功 - * (用户没设头像),只是不写入。 - */ -async function persistFacebookProfile(accessToken: string): Promise { - const fbR = await fetchFacebookUserData(accessToken); - if (Result.isErr(fbR)) { - log.warn( - { err: fbR.error.message }, - "[auth-machine] syncFacebookBackendActor: fetchFacebookUserData failed (continuing anyway)", - ); - return; - } - const fbUser = fbR.data; - log.info( - { - hasId: !!fbUser.id, - hasPicture: !!fbUser.pictureUrl, - }, - "[auth-machine] syncFacebookBackendActor: Facebook profile fetched", - ); - - const authStorage = AuthStorage.getInstance(); - if (fbUser.id) { - const r = await authStorage.setAsid(fbUser.id); - if (!r.success) { - log.warn( - { err: r.error.message }, - "[auth-machine] syncFacebookBackendActor: setAsid failed", - ); - } - } - - if (fbUser.pictureUrl) { - const r = await UserStorage.getInstance().setAvatarUrl(fbUser.pictureUrl); - if (!r.success) { - log.warn( - { err: r.error.message }, - "[auth-machine] syncFacebookBackendActor: setAvatarUrl failed", - ); - } - } -} - -// ========== 自动游客登录 actor ========== -// AuthInit 检查不到任何本地登录态时触发,用于自动补齐游客会话。 - -export const guestLoginActor = fromPromise(async () => { - log.debug("[auth-machine] guestLoginActor ENTRY"); - - // 1. 先查本地 guestToken —— 已有就直接进入游客态 - const hasTokenR = await AuthStorage.getInstance().hasGuestToken(); - log.debug("[auth-machine] guestLoginActor hasGuestToken", { - success: hasTokenR.success, - hasData: hasTokenR.success ? !!hasTokenR.data : null, - }); - if (hasTokenR.success && hasTokenR.data) { - log.debug("[auth-machine] guestLoginActor SHORT-CIRCUIT (has local guestToken) → return guest"); - return "guest" as LoginStatus; - } - // Result.err(_) 时不抛 —— 落到 API 路径让真正的错误信号出现 - - // 2. 没有(或读不出)才走完整流程:拿 deviceId → 调后端 - const deviceId = await deviceIdentifier.getDeviceId(); - log.debug("[auth-machine] guestLoginActor deviceId", { - length: deviceId.length, - prefix: deviceId.slice(0, 8), - }); - - log.debug("[auth-machine] guestLoginActor calling authRepo.guestLogin"); - const result = await getAuthRepository().guestLogin(deviceId); - log.debug("[auth-machine] guestLoginActor authRepo.guestLogin DONE", { - success: result.success, - hasData: result.success ? !!result.data : null, - errorName: result.success ? null : result.error?.name, - errorMessage: result.success ? null : result.error?.message, - }); - if (Result.isErr(result)) throw result.error; - return "guest" as LoginStatus; -}); - -export interface BindFacebookIdentityInput { - asid?: string; - psid?: string; -} - -export const bindFacebookIdentityActor = fromPromise< - void, - BindFacebookIdentityInput ->(async ({ input }) => { - const result = await getAuthRepository().bindFacebookIdentity(input); - if (Result.isErr(result)) { - log.warn( - { err: result.error.message }, - "[auth-machine] bindFacebookIdentityActor failed", - ); - } -}); - -// ========== 启动 / 恢复时检查登录态 actor(只查不创) ========== - -export const checkAuthStatusActor = fromPromise(async () => { - // 1. 拿启动期可能参与身份同步的本地标识。 - const deviceId = await deviceIdentifier.getDeviceId(); - const storage = AuthStorage.getInstance(); - const asidR = await storage.getAsid(); - const psidR = await storage.getPsid(); - const asid = asidR.success ? asidR.data : null; - const psid = psidR.success ? psidR.data : null; - const authRepo = getAuthRepository(); - - // 2. 查业务登录 token:loginToken 和 refreshToken 必须同时存在。 - if (await hasCompleteBusinessAuthSession(storage)) { - const providerR = await storage.getLoginProvider(); - const provider = - providerR.success && isBusinessLoginProvider(providerR.data) - ? providerR.data - : ("email" as LoginStatus); - - return provider; - } - - // 3. 有 PSID 时优先让后端用本地绑定关系直接登录;未匹配则返回游客态。 - if (psid) { - const psidLoginResult = await authRepo.facebookPsidLogin({ - psid, - deviceId, - bindToGuest: true, - }); - if (Result.isOk(psidLoginResult)) { - return psidLoginResult.data; - } - log.warn( - { err: psidLoginResult.error.message }, - "[auth-machine] checkAuthStatusActor: facebookPsidLogin failed", - ); - } - - // 4. 无 PSID 或 PSID 直登失败时,继续用 ASID fallback 换业务 token。 - if (asid) { - const avatarUrlR = await UserStorage.getInstance().getAvatarUrl(); - const result = await authRepo.facebookAsidLogin({ - asid, - avatarUrl: avatarUrlR.success ? avatarUrlR.data ?? undefined : undefined, - psid: psid ?? undefined, - }); - - if (Result.isErr(result)) { - log.warn( - { err: result.error.message }, - "[auth-machine] checkAuthStatusActor: facebookAsidLogin failed", - ); - return "notLoggedIn" as LoginStatus; - } - - return "facebook" as LoginStatus; - } - - // 5. 查 guestToken - const guestTokenR = await storage.getGuestToken(); - if (guestTokenR.success && guestTokenR.data) { - return "guest" as LoginStatus; - } - - // 6. 都没有 → "notLoggedIn"(保持 splash 页面 + 登录入口) - return "notLoggedIn" as LoginStatus; -}); - -function isBusinessLoginProvider( - provider: LoginStatus | null, -): provider is LoginStatus { - return ( - provider === "email" || - provider === "facebook" || - provider === "google" - ); -} diff --git a/src/stores/auth/auth-events.ts b/src/stores/auth/auth-events.ts index 7cdc254d..6c4f76fe 100644 --- a/src/stores/auth/auth-events.ts +++ b/src/stores/auth/auth-events.ts @@ -5,7 +5,11 @@ */ import type { AuthProvider } from "@/lib/auth/auth_platform"; import type { AuthPanelMode } from "@/data/dto/auth"; -import type { BindFacebookIdentityInput } from "./auth-actors"; + +export interface BindFacebookIdentityInput { + asid?: string; + psid?: string; +} export type AuthEvent = // 内部 UI 事件 diff --git a/src/stores/auth/auth-helpers.ts b/src/stores/auth/auth-helpers.ts deleted file mode 100644 index 4b3438a7..00000000 --- a/src/stores/auth/auth-helpers.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Auth 状态机:Helpers - * - * 状态机内复用的小函数 —— 独立成文件便于测试 / 复用。 - */ -import { AuthStorage } from "@/data/storage/auth/auth_storage"; - -/** 从本地存储读 guest deviceId;读不到 / 失败时返回 undefined(不抛)。 */ -export async function readGuestId(): Promise { - const r = await AuthStorage.getInstance().getDeviceId(); - if (r.success && r.data != null) { - return r.data; - } - return undefined; -} - -/** 从本地存储读 PSID;读不到 / 失败时返回 undefined(不抛)。 */ -export async function readPsid(): Promise { - const r = await AuthStorage.getInstance().getPsid(); - if (r.success && r.data != null) { - return r.data; - } - return undefined; -} diff --git a/src/stores/auth/auth-machine.ts b/src/stores/auth/auth-machine.ts index d33ef3d7..00762f8d 100644 --- a/src/stores/auth/auth-machine.ts +++ b/src/stores/auth/auth-machine.ts @@ -1,315 +1,17 @@ -/** - * Auth 状态机(XState v5) - */ -import { setup, assign } from "xstate"; - -import { ExceptionHandler } from "@/core/errors"; -import type { AuthProvider } from "@/lib/auth/auth_platform"; - -import { AuthState, initialState } from "./auth-state"; -import type { AuthEvent } from "./auth-events"; import { - emailLoginActor, - emailRegisterThenLoginActor, - oauthSignInActor, - syncGoogleBackendActor, - syncFacebookBackendActor, - guestLoginActor, - checkAuthStatusActor, - bindFacebookIdentityActor, - logoutActor, -} from "./auth-actors"; + authMachineSetup, + authRootStateConfig, +} from "./machine/session-flow"; +import { initialState } from "./auth-state"; -// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变 export type { AuthState } from "./auth-state"; export { initialState } from "./auth-state"; export type { AuthEvent } from "./auth-events"; -const toAuthErrorMessage = (error: unknown): string => - ExceptionHandler.message(error); - -// ============================================================ -// Machine -// -// 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。 -// -// 初始化会恢复已有登录态,并按后端 Facebook identity 规则处理 PSID 直登。 -// Identity 绑定只响应外部入口显式派发的业务事件。 -// 普通游客登录仍必须由用户明确操作触发;PSID 未匹配时后端可返回游客态。 -// ============================================================ -export const authMachine = setup({ - types: { - context: {} as AuthState, - events: {} as AuthEvent, - }, - actors: { - emailLogin: emailLoginActor, - emailRegisterThenLogin: emailRegisterThenLoginActor, - oauthSignIn: oauthSignInActor, - syncGoogleBackend: syncGoogleBackendActor, - syncFacebookBackend: syncFacebookBackendActor, - guestLogin: guestLoginActor, - checkAuthStatus: checkAuthStatusActor, - bindFacebookIdentity: bindFacebookIdentityActor, - logout: logoutActor, - }, - guards: { - canSubmitGuestLogin: ({ context }) => - context.loginStatus === "notLoggedIn", - canLogoutToGuest: ({ context }) => - context.loginStatus !== "notLoggedIn" && - context.loginStatus !== "guest", - }, -}).createMachine({ - /** @xstate-layout N4IgpgJg5mDOIC5QEMCuAXAFgOgJYQBswBiAQQ0wAVkA7MAgWQHsIwBhTWmCAbQAYAuolAAHJrFzpcTGsJAAPRABYA7NgAc6gEzqAjCvUBOLbvUqlWgDQgAnohWnsAZgCsSpXyeHdTgGy6lXwBfIOs0LDxCEnIsZlYOLkh+ISQQMQkpGTlFBF81Y1MzX3UvdT5NaztcrS1sD10tQ1dvPi0+YNCQcJx8IjIKADEmACcAWzYiZGGkwTl0yWlZVJzzbHMarSUvNpctYsrEXd86pT0SvfULXyUQsIpIvpjMACU4MHRkufEFrOXEXT4fGwvkMLicmxUTT4qj4hgOCC2QKUoL4KkaShc5ycty69160QoAEkaJJPql5pklqAcl4nGtDCCtGDdqotCp4YY9MC0Xw3CpfH5Gi4cd0HgSsABxVBwdAAGSYUFwNAAyqgAEajSToGYpUTfSnZRC0+mM5kQtnwlzlDQuQynEpOJz6BoqEV4qL9LAAUVGyFwBHlipV6s16G1vFm5P1i0NCGNkNNrnN7Ns9gx2D2LhUDjcl0BN06ovxnswPr9BFeitg2uGqo1Wp1XwyMb+caaJuMZtZKaquhBQKMsOhHiafY6dwixaeEqYCqIgaVddD4bJeubv2pRvbCc7Se78P0SjqYLaewuTtObsnHqeA2QAGMwGrZwBrBfB+thxtR9dUhRbukdyZPc0R7RAaiPUEnRKREjH0dQrx6G8KFIEQRHnBVFxDBsI11NJow3f8EVqVRfCzJ14L4Qp4UdI83F0Bkz3BJxtEQsUSxnOcwGVGwaHvJccNXfDf1jeMGV3FlQIPDEj1OQITAYjwijYqdBgfJ9Xx4viBK-XCmx+P8aW3cTgMki1UwQBpHFRB1IQMdRMXHXFr0eCgJjAKZKDAGgICVKBXl86Z7w+SM1wM2MLGwQFopimKAnhLQWOwTFNF2JxAWuDwtBUkkpGQAhcAALz84gIBkMA8BoAA3JgXwqotctwfKir8hAlRq+9kEpZIhIpFtNwRAVkt8JkdCaXxWhMeFNHUNZTEMTwXExaEXF0HKFma4qaCgYgwGGYYRmwdCuoAMxGUZsAajaCq2qA2uqphOu6wReoIwzlGubAFoFUw3BMEpQXhSEXDWMiVDSlkJtdQt7gIJhkF87apRld9SvKyqarqy7YfhxGoGR6t33ujqusWHrQuE8LWytIFwT0fxAW0F14SUXQQe+x0HDZRy7TYuGEb8gm5Uwmhdv2w7jvQM6xmxiJ+bxoWifax7SZkcm8L6wicitXRuQYq1-C2cwXBomovrBX7jDaVonNFeW-LLf1UbKugMdq+qcYF7bHYDEXiZV56BFekTqdaPXxs5PJdGj+EyNqJ0z08Cx6b53GHd9J2RbFg7hiOghTvO2WcHt72M99oN-aesmXopzX3oQK0jyolxrkS05JuzS1bWcW1Am8Jw0SdW3Pbxn3K1was9rR13laxu209L8tx8n4ZK9Vmh1f0g1WwsXWLacEcjAPxuD1tfIB-B8FPBUcFsphuWF6gMewCrGts4l-OpcL+evafsvl5rGvQOwcqYDQsLUMwjcBQGxbmBSyjcvqXAmgxTkAQDCp1-gAeSeNPCqs8PYPywU8IB1cg61zehFUEyUDCmD8M6TQ-IErg2PEPCwB9QQmAwXjbBFB3650ltLC6P9uHEOVlXNWNcNYUJ3gyL6-1NCGAMDTPw0097RVWrvCEAQ2KwF4veQWs4oBEAAEIPjqj5XBbs573F0XxAxXFTH3nMRAEhEiyFSJDmArYUU2Y-VMCCTQugDx0OSheEEnIHKeDvhOHAtj9FI0MSYsx3kIB8LzgXGWoo4n2KMWARxzjXEb0kVvfqRF3BHmaBYVEo4TYWQaMUahJRTAGEaIUHRei-J3kfM+Wq+SUmWPwUXbA2TtpdI0r05JPlCmbx-KAspexnANHIiYTkqIBQHiWdgJZ-jszgnWO0uxoz1I9JfH0ixe0c7pK-pkmxHSjndNfGclxYj14zLCtvMBbJgQtzZA5PQ0cmgHiiVsnkrhYHFE5CEToNAWBwDkN0EpWtEAAFp2aGHRRizF6L1C+HhMi4eLkwCIvrpsowlxsxgjRAEUwqjDDOFRBoxKI1ygIXvj0Rqm0-LEtjAEnudo-DjQ8CNVRQJo43zWQKR0pguGC2lITEW3LWy8sqQKvwQqrAWU5vS1QrNEo6BvgS4uj8fbvkVQNZVvdVXXHaBqqo5E5oAl8GDXkvyZWL39AAvaZqiIWv5SCNVNrT4MWcHQ8iQ5mluqgDwrA3qci+q2P661wq6lh30E68pmh3CbGxGy4Zdz8aJLyZMiAsbEDxqteqg8OhajGE5OYWEA8jDQxiXmw5UAxknKeaW3InI+UJsFYGlNgRkqJVtOUJRE1WUhCAA */ +export const authMachine = authMachineSetup.createMachine({ id: "auth", - initial: "idle", context: initialState, - states: { - idle: { - on: { - AuthPanelModeChanged: { - actions: assign({ - authPanelMode: ({ event }) => event.mode, - errorMessage: null, - }), - }, - AuthLogoutSubmitted: { - guard: "canLogoutToGuest", - target: "loggingOut", - }, - // 启动一次性 init:从 storage 同步 loginStatus 到状态机 —— 由 派发 - AuthInit: "initializing", - AuthExternalFacebookIdentityBindRequested: - "bindingFacebookIdentity", - - AuthGuestLoginSubmitted: { - guard: "canSubmitGuestLogin", - target: "loadingGuestLogin", - }, - AuthEmailLoginSubmitted: "loadingEmailLogin", - AuthEmailRegisterSubmitted: "loadingEmailRegister", - AuthGoogleLoginSubmitted: "loadingOAuth", - AuthFacebookLoginSubmitted: "loadingOAuth", - - // OAuth 回调后 sync 入口 —— 由 派发 - AuthGoogleSyncSubmitted: "syncingGoogleBackend", - AuthFacebookSyncSubmitted: "syncingFacebookBackend", - }, - }, - - initializing: { - entry: assign({ errorMessage: null }), - invoke: { - src: "checkAuthStatus", - onDone: { - target: "idle", // ← init 完回 idle(从 storage 拿到 loginStatus 后落地) - actions: assign({ - loginStatus: ({ event }) => event.output, - hasInitialized: true, - errorMessage: null, - }), - }, - onError: { - target: "idle", - actions: assign({ - errorMessage: ({ event }) => toAuthErrorMessage(event.error), - hasInitialized: true, - }), - }, - }, - }, - - bindingFacebookIdentity: { - invoke: { - src: "bindFacebookIdentity", - input: ({ event }) => - event.type === "AuthExternalFacebookIdentityBindRequested" - ? { asid: event.asid, psid: event.psid } - : {}, - onDone: { target: "idle" }, - onError: { target: "idle" }, - }, - }, - - loadingGuestLogin: { - entry: assign({ errorMessage: null }), - invoke: { - src: "guestLogin", - onDone: { - target: "idle", - // 内联 assign —— XState v5 type inference 保留 loadingGuestLogin state 类型 - actions: assign({ - loginStatus: ({ event }) => event.output, - errorMessage: null, - hasInitialized: true, - }), - }, - onError: { - target: "idle", - actions: assign({ - errorMessage: ({ event }) => toAuthErrorMessage(event.error), - hasInitialized: true, - }), - }, - }, - }, - - loadingEmailLogin: { - entry: assign({ errorMessage: null }), - invoke: { - src: "emailLogin", - input: ({ event }) => { - if (event.type !== "AuthEmailLoginSubmitted") - return { email: "", password: "" }; - return { email: event.email, password: event.password }; - }, - onDone: { - target: "idle", - actions: assign({ - loginStatus: ({ event }) => event.output, - errorMessage: null, - hasInitialized: true, - }), - }, - onError: { - target: "idle", - actions: assign({ - errorMessage: ({ event }) => toAuthErrorMessage(event.error), - hasInitialized: true, - }), - }, - }, - }, - - loadingEmailRegister: { - entry: assign({ errorMessage: null }), - invoke: { - src: "emailRegisterThenLogin", - input: ({ event }) => { - if (event.type !== "AuthEmailRegisterSubmitted") - return { email: "", password: "", username: "", confirmPassword: "" }; - return { - email: event.email, - password: event.password, - username: event.username, - confirmPassword: event.confirmPassword, - }; - }, - onDone: { - target: "idle", - actions: assign({ - loginStatus: ({ event }) => event.output, - errorMessage: null, - hasInitialized: true, - }), - }, - onError: { - target: "idle", - actions: assign({ - errorMessage: ({ event }) => toAuthErrorMessage(event.error), - hasInitialized: true, - }), - }, - }, - }, - - loadingOAuth: { - entry: assign({ errorMessage: null }), - invoke: { - src: "oauthSignIn", - input: ({ event }) => { - if (event.type === "AuthGoogleLoginSubmitted") return event.provider; - if (event.type === "AuthFacebookLoginSubmitted") return event.provider; - return "google" as AuthProvider; - }, - onDone: { - target: "idle", - }, - onError: { - target: "idle", - actions: assign({ - errorMessage: ({ event }) => toAuthErrorMessage(event.error), - }), - }, - }, - }, - - loggingOut: { - entry: assign({ errorMessage: null }), - invoke: { - src: "logout", - onDone: { - target: "idle", - actions: assign({ - loginStatus: ({ event }) => event.output, - hasInitialized: true, - errorMessage: null, - }), - }, - onError: { - target: "idle", - actions: assign(({ event }) => ({ - loginStatus: "guest", - hasInitialized: true, - errorMessage: toAuthErrorMessage(event.error), - })), - }, - }, - }, - - syncingGoogleBackend: { - entry: assign({ errorMessage: null }), - invoke: { - src: "syncGoogleBackend", - input: ({ event }) => { - if (event.type !== "AuthGoogleSyncSubmitted") return { idToken: "" }; - return { idToken: event.idToken }; - }, - onDone: { - target: "idle", - actions: assign({ - loginStatus: ({ event }) => event.output, - errorMessage: null, - hasInitialized: true, - }), - }, - onError: { - target: "idle", - actions: assign({ - errorMessage: ({ event }) => toAuthErrorMessage(event.error), - hasInitialized: true, - }), - }, - }, - }, - - syncingFacebookBackend: { - entry: assign({ errorMessage: null }), - invoke: { - src: "syncFacebookBackend", - input: ({ event }) => { - if (event.type !== "AuthFacebookSyncSubmitted") return { accessToken: "" }; - return { accessToken: event.accessToken }; - }, - onDone: { - target: "idle", - actions: assign({ - loginStatus: ({ event }) => event.output, - errorMessage: null, - hasInitialized: true, - }), - }, - onError: { - target: "idle", - actions: assign({ - errorMessage: ({ event }) => toAuthErrorMessage(event.error), - hasInitialized: true, - }), - }, - }, - }, - }, + ...authRootStateConfig, }); export type AuthMachine = typeof authMachine; diff --git a/src/stores/auth/helper/error.ts b/src/stores/auth/helper/error.ts new file mode 100644 index 00000000..558e2124 --- /dev/null +++ b/src/stores/auth/helper/error.ts @@ -0,0 +1,5 @@ +import { ExceptionHandler } from "@/core/errors"; + +export function toAuthErrorMessage(error: unknown): string { + return ExceptionHandler.message(error); +} diff --git a/src/stores/auth/helper/index.ts b/src/stores/auth/helper/index.ts new file mode 100644 index 00000000..0ad13d91 --- /dev/null +++ b/src/stores/auth/helper/index.ts @@ -0,0 +1 @@ +export * from "./error"; diff --git a/src/stores/auth/index.ts b/src/stores/auth/index.ts index b0ea37cf..6f8285a0 100644 --- a/src/stores/auth/index.ts +++ b/src/stores/auth/index.ts @@ -1,11 +1,8 @@ -/** - * @file Automatically generated by barrelsby. - */ +/** @file Auth store barrel. */ -export * from "./auth-actors"; export * from "./auth-context"; export * from "./auth-events"; -export * from "./auth-helpers"; +export * from "./helper"; export * from "./auth-machine"; export * from "./auth-state"; export * from "./auth-status-checker"; diff --git a/src/stores/auth/machine/actors/credentials.ts b/src/stores/auth/machine/actors/credentials.ts new file mode 100644 index 00000000..00d2cbc4 --- /dev/null +++ b/src/stores/auth/machine/actors/credentials.ts @@ -0,0 +1,86 @@ +import { fromPromise } from "xstate"; + +import type { LoginStatus } from "@/data/dto/auth"; +import { getAuthRepository } from "@/data/repositories/auth_repository"; +import { Logger } from "@/utils/logger"; +import { Result } from "@/utils/result"; + +import { readGuestId, readPsid } from "./storage"; + +const log = new Logger("AuthActors"); + +export const emailLoginActor = fromPromise< + LoginStatus, + { email: string; password: string } +>(async ({ input }) => { + const authRepo = getAuthRepository(); + log.debug("[auth-machine] emailLoginActor ENTRY", { + emailLength: input.email.length, + emailPreview: input.email.slice(0, 8), + passwordLength: input.password.length, + }); + const guestId = await readGuestId(); + const psid = await readPsid(); + log.debug("[auth-machine] emailLoginActor calling authRepo.emailLogin", { + hasGuestId: !!guestId, + hasPsid: !!psid, + }); + const result = await authRepo.emailLogin({ ...input, guestId, psid }); + log.debug("[auth-machine] emailLoginActor authRepo.emailLogin DONE", { + success: result.success, + hasData: result.success ? !!result.data : null, + errorName: result.success ? null : result.error?.name, + errorMessage: result.success ? null : result.error?.message, + }); + if (Result.isErr(result)) throw result.error; + return "email" as LoginStatus; +}); + +export const emailRegisterThenLoginActor = fromPromise< + LoginStatus, + { + email: string; + password: string; + username: string; + confirmPassword: string; + } +>(async ({ input }) => { + const authRepo = getAuthRepository(); + log.debug("[auth-machine] emailRegisterThenLoginActor ENTRY", { + emailLength: input.email.length, + usernameLength: input.username.length, + passwordLength: input.password.length, + confirmPasswordLength: input.confirmPassword.length, + }); + const guestId = await readGuestId(); + + log.debug("[auth-machine] emailRegisterThenLoginActor calling authRepo.register"); + const registerResult = await authRepo.register({ ...input, guestId }); + log.debug("[auth-machine] emailRegisterThenLoginActor authRepo.register DONE", { + success: registerResult.success, + errorName: registerResult.success ? null : registerResult.error?.name, + errorMessage: registerResult.success ? null : registerResult.error?.message, + }); + if (Result.isErr(registerResult)) throw registerResult.error; + + const psid = await readPsid(); + log.debug( + "[auth-machine] emailRegisterThenLoginActor calling authRepo.emailLogin (post-register)", + ); + const loginResult = await authRepo.emailLogin({ + email: input.email, + password: input.password, + guestId, + psid, + }); + log.debug( + "[auth-machine] emailRegisterThenLoginActor authRepo.emailLogin DONE", + { + success: loginResult.success, + errorName: loginResult.success ? null : loginResult.error?.name, + errorMessage: loginResult.success ? null : loginResult.error?.message, + }, + ); + if (Result.isErr(loginResult)) throw loginResult.error; + return "email" as LoginStatus; +}); diff --git a/src/stores/auth/machine/actors/identity.ts b/src/stores/auth/machine/actors/identity.ts new file mode 100644 index 00000000..e6beb4dc --- /dev/null +++ b/src/stores/auth/machine/actors/identity.ts @@ -0,0 +1,22 @@ +import { fromPromise } from "xstate"; + +import { getAuthRepository } from "@/data/repositories/auth_repository"; +import { Logger } from "@/utils/logger"; +import { Result } from "@/utils/result"; + +import type { BindFacebookIdentityInput } from "../../auth-events"; + +const log = new Logger("AuthActors"); + +export const bindFacebookIdentityActor = fromPromise< + void, + BindFacebookIdentityInput +>(async ({ input }) => { + const result = await getAuthRepository().bindFacebookIdentity(input); + if (Result.isErr(result)) { + log.warn( + { err: result.error.message }, + "[auth-machine] bindFacebookIdentityActor failed", + ); + } +}); diff --git a/src/stores/auth/machine/actors/oauth.ts b/src/stores/auth/machine/actors/oauth.ts new file mode 100644 index 00000000..f6be22b0 --- /dev/null +++ b/src/stores/auth/machine/actors/oauth.ts @@ -0,0 +1,98 @@ +import { fromPromise } from "xstate"; + +import type { LoginStatus } from "@/data/dto/auth"; +import { getAuthRepository } from "@/data/repositories/auth_repository"; +import { fetchFacebookUserData } from "@/data/services"; +import { AuthStorage } from "@/data/storage/auth/auth_storage"; +import { UserStorage } from "@/data/storage/user/user_storage"; +import { AuthPlatform, type AuthProvider } from "@/lib/auth/auth_platform"; +import { Logger } from "@/utils/logger"; +import { Result } from "@/utils/result"; + +import { readGuestId, readPsid } from "./storage"; + +const log = new Logger("AuthActors"); + +export const oauthSignInActor = fromPromise(async ({ input }) => { + if (input === "google") { + await AuthPlatform.googleSignIn(); + } else { + await AuthPlatform.facebookSignIn(); + } +}); + +export const syncGoogleBackendActor = fromPromise< + LoginStatus, + { idToken: string } +>(async ({ input }) => { + const authRepo = getAuthRepository(); + const guestId = await readGuestId(); + const psid = await readPsid(); + const result = await authRepo.googleLogin({ + idToken: input.idToken, + guestId, + psid, + }); + if (Result.isErr(result)) throw result.error; + return "google" as LoginStatus; +}); + +export const syncFacebookBackendActor = fromPromise< + LoginStatus, + { accessToken: string } +>(async ({ input }) => { + const authRepo = getAuthRepository(); + const guestId = await readGuestId(); + const psid = await readPsid(); + const result = await authRepo.facebookLogin({ + accessToken: input.accessToken, + guestId, + psid, + }); + if (Result.isErr(result)) throw result.error; + + await persistFacebookProfile(input.accessToken); + return "facebook" as LoginStatus; +}); + +async function persistFacebookProfile(accessToken: string): Promise { + const fbR = await fetchFacebookUserData(accessToken); + if (Result.isErr(fbR)) { + log.warn( + { err: fbR.error.message }, + "[auth-machine] syncFacebookBackendActor: fetchFacebookUserData failed (continuing anyway)", + ); + return; + } + const fbUser = fbR.data; + log.info( + { + hasId: !!fbUser.id, + hasPicture: !!fbUser.pictureUrl, + }, + "[auth-machine] syncFacebookBackendActor: Facebook profile fetched", + ); + + const authStorage = AuthStorage.getInstance(); + if (fbUser.id) { + const result = await authStorage.setAsid(fbUser.id); + if (!result.success) { + log.warn( + { err: result.error.message }, + "[auth-machine] syncFacebookBackendActor: setAsid failed", + ); + } + } + + if (fbUser.pictureUrl) { + const result = await UserStorage.getInstance().setAvatarUrl( + fbUser.pictureUrl, + ); + if (!result.success) { + log.warn( + { err: result.error.message }, + "[auth-machine] syncFacebookBackendActor: setAvatarUrl failed", + ); + } + } +} diff --git a/src/stores/auth/machine/actors/session.ts b/src/stores/auth/machine/actors/session.ts new file mode 100644 index 00000000..aace5389 --- /dev/null +++ b/src/stores/auth/machine/actors/session.ts @@ -0,0 +1,110 @@ +import { fromPromise } from "xstate"; + +import type { LoginStatus } from "@/data/dto/auth"; +import { getAuthRepository } from "@/data/repositories/auth_repository"; +import { AuthStorage } from "@/data/storage/auth/auth_storage"; +import { UserStorage } from "@/data/storage/user/user_storage"; +import { hasCompleteBusinessAuthSession } from "@/lib/auth/auth_session"; +import { deviceIdentifier } from "@/utils/device_identifier"; +import { Logger } from "@/utils/logger"; +import { Result } from "@/utils/result"; + +const log = new Logger("AuthActors"); + +export const logoutActor = fromPromise(async () => { + const result = await getAuthRepository().logout(); + if (Result.isErr(result)) throw result.error; + return "guest" as LoginStatus; +}); + +export const guestLoginActor = fromPromise(async () => { + log.debug("[auth-machine] guestLoginActor ENTRY"); + + const hasTokenR = await AuthStorage.getInstance().hasGuestToken(); + log.debug("[auth-machine] guestLoginActor hasGuestToken", { + success: hasTokenR.success, + hasData: hasTokenR.success ? !!hasTokenR.data : null, + }); + if (hasTokenR.success && hasTokenR.data) { + log.debug("[auth-machine] guestLoginActor SHORT-CIRCUIT (has local guestToken) → return guest"); + return "guest" as LoginStatus; + } + + const deviceId = await deviceIdentifier.getDeviceId(); + log.debug("[auth-machine] guestLoginActor deviceId", { + length: deviceId.length, + prefix: deviceId.slice(0, 8), + }); + + log.debug("[auth-machine] guestLoginActor calling authRepo.guestLogin"); + const result = await getAuthRepository().guestLogin(deviceId); + log.debug("[auth-machine] guestLoginActor authRepo.guestLogin DONE", { + success: result.success, + hasData: result.success ? !!result.data : null, + errorName: result.success ? null : result.error?.name, + errorMessage: result.success ? null : result.error?.message, + }); + if (Result.isErr(result)) throw result.error; + return "guest" as LoginStatus; +}); + +export const checkAuthStatusActor = fromPromise(async () => { + const deviceId = await deviceIdentifier.getDeviceId(); + const storage = AuthStorage.getInstance(); + const asidR = await storage.getAsid(); + const psidR = await storage.getPsid(); + const asid = asidR.success ? asidR.data : null; + const psid = psidR.success ? psidR.data : null; + const authRepo = getAuthRepository(); + + if (await hasCompleteBusinessAuthSession(storage)) { + const providerR = await storage.getLoginProvider(); + return providerR.success && isBusinessLoginProvider(providerR.data) + ? providerR.data + : ("email" as LoginStatus); + } + + if (psid) { + const psidLoginResult = await authRepo.facebookPsidLogin({ + psid, + deviceId, + bindToGuest: true, + }); + if (Result.isOk(psidLoginResult)) return psidLoginResult.data; + log.warn( + { err: psidLoginResult.error.message }, + "[auth-machine] checkAuthStatusActor: facebookPsidLogin failed", + ); + } + + if (asid) { + const avatarUrlR = await UserStorage.getInstance().getAvatarUrl(); + const result = await authRepo.facebookAsidLogin({ + asid, + avatarUrl: avatarUrlR.success ? avatarUrlR.data ?? undefined : undefined, + psid: psid ?? undefined, + }); + if (Result.isErr(result)) { + log.warn( + { err: result.error.message }, + "[auth-machine] checkAuthStatusActor: facebookAsidLogin failed", + ); + return "notLoggedIn" as LoginStatus; + } + return "facebook" as LoginStatus; + } + + const guestTokenR = await storage.getGuestToken(); + if (guestTokenR.success && guestTokenR.data) return "guest" as LoginStatus; + return "notLoggedIn" as LoginStatus; +}); + +function isBusinessLoginProvider( + provider: LoginStatus | null, +): provider is LoginStatus { + return ( + provider === "email" || + provider === "facebook" || + provider === "google" + ); +} diff --git a/src/stores/auth/machine/actors/storage.ts b/src/stores/auth/machine/actors/storage.ts new file mode 100644 index 00000000..a28c212e --- /dev/null +++ b/src/stores/auth/machine/actors/storage.ts @@ -0,0 +1,11 @@ +import { AuthStorage } from "@/data/storage/auth/auth_storage"; + +export async function readGuestId(): Promise { + const result = await AuthStorage.getInstance().getDeviceId(); + return result.success && result.data != null ? result.data : undefined; +} + +export async function readPsid(): Promise { + const result = await AuthStorage.getInstance().getPsid(); + return result.success && result.data != null ? result.data : undefined; +} diff --git a/src/stores/auth/machine/credentials-flow.ts b/src/stores/auth/machine/credentials-flow.ts new file mode 100644 index 00000000..c2c449a5 --- /dev/null +++ b/src/stores/auth/machine/credentials-flow.ts @@ -0,0 +1,84 @@ +import type { DoneActorEvent, ErrorActorEvent } from "xstate"; + +import type { LoginStatus } from "@/data/dto/auth"; + +import { toAuthErrorMessage } from "../helper/error"; +import { + baseAuthMachineSetup, + createAuthActorActionSetup, +} from "./setup"; + +const clearAuthErrorAction = baseAuthMachineSetup.assign({ + errorMessage: null, +}); + +export const credentialsMachineSetup = baseAuthMachineSetup.extend({ + actions: { + clearAuthError: clearAuthErrorAction, + }, +}); + +const authSuccessActionSetup = + createAuthActorActionSetup>(); +const authErrorActionSetup = createAuthActorActionSetup(); + +const applyAuthSuccessAction = authSuccessActionSetup.assign(({ event }) => ({ + loginStatus: event.output, + errorMessage: null, + hasInitialized: true, +})); + +const applyAuthErrorAction = authErrorActionSetup.assign(({ event }) => ({ + errorMessage: toAuthErrorMessage(event.error), + hasInitialized: true, +})); + +export const loadingEmailLoginState = + credentialsMachineSetup.createStateConfig({ + entry: "clearAuthError", + invoke: { + src: "emailLogin", + input: ({ event }) => + event.type === "AuthEmailLoginSubmitted" + ? { email: event.email, password: event.password } + : { email: "", password: "" }, + onDone: { + target: "idle", + actions: applyAuthSuccessAction, + }, + onError: { + target: "idle", + actions: applyAuthErrorAction, + }, + }, + }); + +export const loadingEmailRegisterState = + credentialsMachineSetup.createStateConfig({ + entry: "clearAuthError", + invoke: { + src: "emailRegisterThenLogin", + input: ({ event }) => + event.type === "AuthEmailRegisterSubmitted" + ? { + email: event.email, + password: event.password, + username: event.username, + confirmPassword: event.confirmPassword, + } + : { + email: "", + password: "", + username: "", + confirmPassword: "", + }, + onDone: { + target: "idle", + actions: applyAuthSuccessAction, + }, + onError: { + target: "idle", + actions: applyAuthErrorAction, + }, + }, + }); diff --git a/src/stores/auth/machine/oauth-flow.ts b/src/stores/auth/machine/oauth-flow.ts new file mode 100644 index 00000000..3185e8b9 --- /dev/null +++ b/src/stores/auth/machine/oauth-flow.ts @@ -0,0 +1,83 @@ +import type { DoneActorEvent, ErrorActorEvent } from "xstate"; + +import type { LoginStatus } from "@/data/dto/auth"; +import type { AuthProvider } from "@/lib/auth/auth_platform"; + +import { toAuthErrorMessage } from "../helper/error"; +import { credentialsMachineSetup } from "./credentials-flow"; +import { createAuthActorActionSetup } from "./setup"; + +export const oauthMachineSetup = credentialsMachineSetup; + +const oauthSyncDoneSetup = + createAuthActorActionSetup>(); +const oauthErrorSetup = createAuthActorActionSetup(); + +const applyOAuthSyncSuccessAction = oauthSyncDoneSetup.assign(({ event }) => ({ + loginStatus: event.output, + errorMessage: null, + hasInitialized: true, +})); + +const applyOAuthSyncErrorAction = oauthErrorSetup.assign(({ event }) => ({ + errorMessage: toAuthErrorMessage(event.error), + hasInitialized: true, +})); + +const applyOAuthLaunchErrorAction = oauthErrorSetup.assign(({ event }) => ({ + errorMessage: toAuthErrorMessage(event.error), +})); + +export const loadingOAuthState = oauthMachineSetup.createStateConfig({ + entry: "clearAuthError", + invoke: { + src: "oauthSignIn", + input: ({ event }) => { + if (event.type === "AuthGoogleLoginSubmitted") return event.provider; + if (event.type === "AuthFacebookLoginSubmitted") return event.provider; + return "google" as AuthProvider; + }, + onDone: { target: "idle" }, + onError: { + target: "idle", + actions: applyOAuthLaunchErrorAction, + }, + }, +}); + +export const syncingGoogleBackendState = oauthMachineSetup.createStateConfig({ + entry: "clearAuthError", + invoke: { + src: "syncGoogleBackend", + input: ({ event }) => ({ + idToken: event.type === "AuthGoogleSyncSubmitted" ? event.idToken : "", + }), + onDone: { + target: "idle", + actions: applyOAuthSyncSuccessAction, + }, + onError: { + target: "idle", + actions: applyOAuthSyncErrorAction, + }, + }, +}); + +export const syncingFacebookBackendState = oauthMachineSetup.createStateConfig({ + entry: "clearAuthError", + invoke: { + src: "syncFacebookBackend", + input: ({ event }) => ({ + accessToken: + event.type === "AuthFacebookSyncSubmitted" ? event.accessToken : "", + }), + onDone: { + target: "idle", + actions: applyOAuthSyncSuccessAction, + }, + onError: { + target: "idle", + actions: applyOAuthSyncErrorAction, + }, + }, +}); diff --git a/src/stores/auth/machine/session-flow.ts b/src/stores/auth/machine/session-flow.ts new file mode 100644 index 00000000..9bd2e5bf --- /dev/null +++ b/src/stores/auth/machine/session-flow.ts @@ -0,0 +1,146 @@ +import type { DoneActorEvent, ErrorActorEvent } from "xstate"; + +import type { LoginStatus } from "@/data/dto/auth"; + +import { toAuthErrorMessage } from "../helper/error"; +import { + loadingEmailLoginState, + loadingEmailRegisterState, +} from "./credentials-flow"; +import { + loadingOAuthState, + oauthMachineSetup, + syncingFacebookBackendState, + syncingGoogleBackendState, +} from "./oauth-flow"; +import { createAuthActorActionSetup } from "./setup"; + +const updatePanelModeAction = oauthMachineSetup.assign(({ event }) => { + if (event.type !== "AuthPanelModeChanged") return {}; + return { + authPanelMode: event.mode, + errorMessage: null, + }; +}); + +export const authMachineSetup = oauthMachineSetup.extend({ + actions: { + updatePanelMode: updatePanelModeAction, + }, +}); + +const sessionDoneSetup = + createAuthActorActionSetup>(); +const sessionErrorSetup = createAuthActorActionSetup(); + +const applySessionSuccessAction = sessionDoneSetup.assign(({ event }) => ({ + loginStatus: event.output, + hasInitialized: true, + errorMessage: null, +})); + +const applySessionErrorAction = sessionErrorSetup.assign(({ event }) => ({ + errorMessage: toAuthErrorMessage(event.error), + hasInitialized: true, +})); + +const applyLogoutErrorAction = sessionErrorSetup.assign(({ event }) => ({ + loginStatus: "guest", + hasInitialized: true, + errorMessage: toAuthErrorMessage(event.error), +})); + +const idleState = authMachineSetup.createStateConfig({ + on: { + AuthPanelModeChanged: { actions: "updatePanelMode" }, + AuthLogoutSubmitted: { + guard: "canLogoutToGuest", + target: "loggingOut", + }, + AuthInit: "initializing", + AuthExternalFacebookIdentityBindRequested: "bindingFacebookIdentity", + AuthGuestLoginSubmitted: { + guard: "canSubmitGuestLogin", + target: "loadingGuestLogin", + }, + AuthEmailLoginSubmitted: "loadingEmailLogin", + AuthEmailRegisterSubmitted: "loadingEmailRegister", + AuthGoogleLoginSubmitted: "loadingOAuth", + AuthFacebookLoginSubmitted: "loadingOAuth", + AuthGoogleSyncSubmitted: "syncingGoogleBackend", + AuthFacebookSyncSubmitted: "syncingFacebookBackend", + }, +}); + +const initializingState = authMachineSetup.createStateConfig({ + entry: "clearAuthError", + invoke: { + src: "checkAuthStatus", + onDone: { + target: "idle", + actions: applySessionSuccessAction, + }, + onError: { + target: "idle", + actions: applySessionErrorAction, + }, + }, +}); + +const bindingFacebookIdentityState = authMachineSetup.createStateConfig({ + invoke: { + src: "bindFacebookIdentity", + input: ({ event }) => + event.type === "AuthExternalFacebookIdentityBindRequested" + ? { asid: event.asid, psid: event.psid } + : {}, + onDone: { target: "idle" }, + onError: { target: "idle" }, + }, +}); + +const loadingGuestLoginState = authMachineSetup.createStateConfig({ + entry: "clearAuthError", + invoke: { + src: "guestLogin", + onDone: { + target: "idle", + actions: applySessionSuccessAction, + }, + onError: { + target: "idle", + actions: applySessionErrorAction, + }, + }, +}); + +const loggingOutState = authMachineSetup.createStateConfig({ + entry: "clearAuthError", + invoke: { + src: "logout", + onDone: { + target: "idle", + actions: applySessionSuccessAction, + }, + onError: { + target: "idle", + actions: applyLogoutErrorAction, + }, + }, +}); + +export const authRootStateConfig = authMachineSetup.createStateConfig({ + initial: "idle", + states: { + idle: idleState, + initializing: initializingState, + bindingFacebookIdentity: bindingFacebookIdentityState, + loadingGuestLogin: loadingGuestLoginState, + loadingEmailLogin: loadingEmailLoginState, + loadingEmailRegister: loadingEmailRegisterState, + loadingOAuth: loadingOAuthState, + loggingOut: loggingOutState, + syncingGoogleBackend: syncingGoogleBackendState, + syncingFacebookBackend: syncingFacebookBackendState, + }, +}); diff --git a/src/stores/auth/machine/setup.ts b/src/stores/auth/machine/setup.ts new file mode 100644 index 00000000..2ef33a4b --- /dev/null +++ b/src/stores/auth/machine/setup.ts @@ -0,0 +1,72 @@ +import { setup, type ActionFunction, type EventObject } from "xstate"; + +import type { AuthEvent } from "../auth-events"; +import type { AuthState } from "../auth-state"; +import { + emailLoginActor, + emailRegisterThenLoginActor, +} from "./actors/credentials"; +import { bindFacebookIdentityActor } from "./actors/identity"; +import { + oauthSignInActor, + syncFacebookBackendActor, + syncGoogleBackendActor, +} from "./actors/oauth"; +import { + checkAuthStatusActor, + guestLoginActor, + logoutActor, +} from "./actors/session"; + +export const baseAuthMachineSetup = setup({ + types: { + context: {} as AuthState, + events: {} as AuthEvent, + }, + actors: { + emailLogin: emailLoginActor, + emailRegisterThenLogin: emailRegisterThenLoginActor, + oauthSignIn: oauthSignInActor, + syncGoogleBackend: syncGoogleBackendActor, + syncFacebookBackend: syncFacebookBackendActor, + guestLogin: guestLoginActor, + checkAuthStatus: checkAuthStatusActor, + bindFacebookIdentity: bindFacebookIdentityActor, + logout: logoutActor, + }, + guards: { + canSubmitGuestLogin: ({ context }) => + context.loginStatus === "notLoggedIn", + canLogoutToGuest: ({ context }) => + context.loginStatus !== "notLoggedIn" && + context.loginStatus !== "guest", + }, +}); + +type AuthActorAction = ActionFunction< + AuthState, + TEvent, + AuthEvent, + undefined, + never, + never, + never, + never, + never +>; + +export function createAuthActorActionSetup() { + const actorActionSetup = setup({ + types: { + context: {} as AuthState, + events: {} as TEvent, + }, + }); + return { + assign( + assignment: Parameters[0], + ): AuthActorAction { + return actorActionSetup.assign(assignment) as unknown as AuthActorAction; + }, + }; +} diff --git a/src/stores/chat/index.ts b/src/stores/chat/index.ts index 60372e7f..fa67d6bf 100644 --- a/src/stores/chat/index.ts +++ b/src/stores/chat/index.ts @@ -1,6 +1,4 @@ -/** - * @file Automatically generated by barrelsby. - */ +/** @file Chat store barrel. */ export * from "./chat-context"; export * from "./chat-events"; diff --git a/src/stores/payment/__tests__/payment-catalog-flow.test.ts b/src/stores/payment/__tests__/payment-catalog-flow.test.ts new file mode 100644 index 00000000..4a976965 --- /dev/null +++ b/src/stores/payment/__tests__/payment-catalog-flow.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createActor, waitFor } from "xstate"; + +import { PaymentPlansResponse } from "@/data/dto/payment"; + +import { + createTestPaymentMachine, + lifetimePlan, + monthlyPlan, + quarterlyPlan, + tipPlan, +} from "./payment-machine.test-utils"; + +describe("payment catalog flow", () => { + afterEach(() => vi.restoreAllMocks()); + + it("loads plans and selects the monthly VIP plan", async () => { + const actor = createActor(createTestPaymentMachine()).start(); + actor.send({ type: "PaymentInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + + expect(actor.getSnapshot().context).toMatchObject({ + selectedPlanId: "vip_monthly", + autoRenew: true, + errorMessage: null, + }); + actor.stop(); + }); + + it("loads the tip catalog and disables auto renew", async () => { + const loadCatalog = vi.fn(); + const refreshCatalog = vi.fn(); + const actor = createActor( + createTestPaymentMachine({ + refreshedPlans: PaymentPlansResponse.from({ plans: [tipPlan] }), + onLoadCatalog: loadCatalog, + onRefreshCatalog: refreshCatalog, + }), + ).start(); + actor.send({ type: "PaymentInit", catalog: "tip" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + + expect(loadCatalog).toHaveBeenCalledWith("tip"); + expect(refreshCatalog).toHaveBeenCalledWith("tip"); + expect(actor.getSnapshot().context).toMatchObject({ + planCatalog: "tip", + selectedPlanId: tipPlan.planId, + autoRenew: false, + }); + actor.stop(); + }); + + it("keeps first recharge plan metadata", async () => { + const actor = createActor( + createTestPaymentMachine({ + refreshedPlans: firstRechargeResponse(), + }), + ).start(); + actor.send({ type: "PaymentInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + + expect(actor.getSnapshot().context.isFirstRecharge).toBe(true); + expect(actor.getSnapshot().context.plans[0]).toMatchObject({ + amountCents: 995, + originalAmountCents: 1990, + isFirstRechargeOffer: true, + }); + actor.stop(); + }); + + it("consumes first recharge state", async () => { + const actor = createActor( + createTestPaymentMachine({ refreshedPlans: firstRechargeResponse() }), + ).start(); + actor.send({ type: "PaymentInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + actor.send({ type: "PaymentFirstRechargeConsumed" }); + + expect(actor.getSnapshot().context.isFirstRecharge).toBe(false); + expect(actor.getSnapshot().context.plans[0]).toMatchObject({ + amountCents: 1990, + originalAmountCents: 1990, + isFirstRechargeOffer: false, + firstRechargeDiscountPercent: null, + promotionType: null, + }); + actor.stop(); + }); + + it("preserves normal VIP original price outside first recharge", async () => { + const actor = createActor( + createTestPaymentMachine({ + refreshedPlans: PaymentPlansResponse.from({ + isFirstRecharge: false, + plans: [ + { + ...monthlyPlan, + amountCents: 1990, + originalAmountCents: 2499, + }, + ], + }), + }), + ).start(); + actor.send({ type: "PaymentInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + + expect(actor.getSnapshot().context.plans[0]).toMatchObject({ + amountCents: 1990, + originalAmountCents: 2499, + isFirstRechargeOffer: false, + }); + actor.stop(); + }); + + it("hydrates cached plans before applying network plans", async () => { + let resolveRefresh!: (value: PaymentPlansResponse) => void; + const refreshPromise = new Promise((resolve) => { + resolveRefresh = resolve; + }); + const actor = createActor( + createTestPaymentMachine({ + cachedPlans: PaymentPlansResponse.from({ plans: [quarterlyPlan] }), + refreshPlans: async () => refreshPromise, + }), + ).start(); + actor.send({ type: "PaymentInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("refreshingPlans")); + expect(actor.getSnapshot().context.plans[0]?.planId).toBe( + quarterlyPlan.planId, + ); + + resolveRefresh( + PaymentPlansResponse.from({ plans: [monthlyPlan, lifetimePlan] }), + ); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + expect(actor.getSnapshot().context.selectedPlanId).toBe("vip_monthly"); + actor.stop(); + }); +}); + +function firstRechargeResponse(): PaymentPlansResponse { + return PaymentPlansResponse.from({ + isFirstRecharge: true, + firstRechargeOffer: { + enabled: true, + type: "first_recharge_half_price", + discountPercent: 50, + }, + plans: [ + { + ...monthlyPlan, + amountCents: 995, + originalAmountCents: 1990, + isFirstRechargeOffer: true, + firstRechargeDiscountPercent: 50, + promotionType: "first_recharge_half_price", + }, + ], + }); +} diff --git a/src/stores/payment/__tests__/payment-machine.test-utils.ts b/src/stores/payment/__tests__/payment-machine.test-utils.ts new file mode 100644 index 00000000..affe25fb --- /dev/null +++ b/src/stores/payment/__tests__/payment-machine.test-utils.ts @@ -0,0 +1,158 @@ +import { vi } from "vitest"; +import { fromPromise } from "xstate"; + +import { + CreatePaymentOrderResponse, + type PayChannel, + PaymentOrderStatusResponse, + PaymentPlansResponse, +} from "@/data/dto/payment"; +import { paymentMachine } from "@/stores/payment/payment-machine"; +import type { PaymentPlanCatalog } from "@/stores/payment/payment-state"; + +export interface PaymentPlansActorInput { + catalog: PaymentPlanCatalog; +} + +export interface CreateOrderInput { + planId: string; + payChannel: PayChannel; + autoRenew: boolean; +} + +export type CreateOrderSpy = (input: CreateOrderInput) => void; + +export const monthlyPlan = { + planId: "vip_monthly", + planName: "VIP Monthly", + orderType: "vip_monthly", + vipDays: 30, + dolAmount: null, + creditBalance: 3000, + amountCents: 999, + originalAmountCents: null, + dailyPriceCents: 33, + currency: "usd", +}; + +export const lifetimePlan = { + planId: "vip_lifetime", + planName: "VIP Lifetime", + orderType: "vip_lifetime", + vipDays: 3650, + dolAmount: null, + creditBalance: 0, + amountCents: 4999, + originalAmountCents: null, + dailyPriceCents: null, + currency: "usd", +}; + +export const creditPlan = { + planId: "dol_1000", + planName: "1000 Credits", + orderType: "dol", + vipDays: null, + dolAmount: 1000, + creditBalance: 1000, + amountCents: 990, + originalAmountCents: null, + dailyPriceCents: null, + currency: "usd", +}; + +export const quarterlyPlan = { + planId: "vip_quarterly", + planName: "VIP Quarterly", + orderType: "vip_quarterly", + vipDays: 90, + dolAmount: null, + creditBalance: 9000, + amountCents: 2499, + originalAmountCents: null, + dailyPriceCents: 28, + currency: "usd", +}; + +export const tipPlan = { + planId: "tip_coffee_usd_4_99", + planName: "Small Coffee", + orderType: "tip", + vipDays: null, + dolAmount: null, + creditBalance: 0, + amountCents: 499, + originalAmountCents: null, + dailyPriceCents: null, + currency: "USD", +}; + +export function createTestPaymentMachine( + overrides: Partial<{ + cachedPlans: PaymentPlansResponse | null; + refreshedPlans: PaymentPlansResponse; + refreshPlans: () => Promise; + onLoadCatalog: (catalog: PaymentPlanCatalog) => void; + onRefreshCatalog: (catalog: PaymentPlanCatalog) => void; + createOrderSpy: CreateOrderSpy; + createOrderError: Error; + orderStatus: "pending" | "paid" | "failed"; + orderStatuses: ("pending" | "paid" | "failed")[]; + }> = {}, +) { + const createOrderSpy = overrides.createOrderSpy ?? vi.fn(); + const orderStatus = overrides.orderStatus ?? "paid"; + const orderStatuses = overrides.orderStatuses ?? []; + let pollCount = 0; + + return paymentMachine.provide({ + actors: { + loadCachedPlans: fromPromise< + PaymentPlansResponse | null, + PaymentPlansActorInput + >(async ({ input }) => { + overrides.onLoadCatalog?.(input.catalog); + return overrides.cachedPlans ?? null; + }), + refreshPlans: fromPromise< + PaymentPlansResponse, + PaymentPlansActorInput + >(async ({ input }) => { + overrides.onRefreshCatalog?.(input.catalog); + if (overrides.refreshPlans) return overrides.refreshPlans(); + return ( + overrides.refreshedPlans ?? + PaymentPlansResponse.from({ + plans: [monthlyPlan, lifetimePlan, creditPlan], + }) + ); + }), + createOrder: fromPromise< + CreatePaymentOrderResponse, + CreateOrderInput + >(async ({ input }) => { + createOrderSpy(input); + if (overrides.createOrderError) throw overrides.createOrderError; + return CreatePaymentOrderResponse.from({ + orderId: "pay_test_001", + payParams: { + provider: "stripe", + clientSecret: "pi_test_secret_test", + }, + }); + }), + pollOrderStatus: fromPromise(async () => { + const status = + orderStatuses[Math.min(pollCount, orderStatuses.length - 1)] ?? + orderStatus; + pollCount += 1; + return PaymentOrderStatusResponse.from({ + orderId: "pay_test_001", + status, + orderType: "vip_monthly", + planId: "vip_monthly", + }); + }), + }, + }); +} diff --git a/src/stores/payment/__tests__/payment-machine.test.ts b/src/stores/payment/__tests__/payment-machine.test.ts deleted file mode 100644 index 9af872d7..00000000 --- a/src/stores/payment/__tests__/payment-machine.test.ts +++ /dev/null @@ -1,721 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createActor, fromPromise, waitFor } from "xstate"; - -import { - CreatePaymentOrderResponse, - type PayChannel, - PaymentOrderStatusResponse, - PaymentPlansResponse, -} from "@/data/dto/payment"; -import { behaviorAnalytics } from "@/lib/analytics"; -import { paymentMachine } from "@/stores/payment/payment-machine"; -import { - MAX_ORDER_POLLING_MS, - PAYMENT_TIMEOUT_ERROR_MESSAGE, -} from "@/stores/payment/payment-machine.helpers"; -import type { PaymentPlanCatalog } from "@/stores/payment/payment-state"; - -interface PaymentPlansActorInput { - catalog: PaymentPlanCatalog; -} - -interface CreateOrderInput { - planId: string; - payChannel: PayChannel; - autoRenew: boolean; -} - -type CreateOrderSpy = (input: CreateOrderInput) => void; - -const monthlyPlan = { - planId: "vip_monthly", - planName: "VIP Monthly", - orderType: "vip_monthly", - vipDays: 30, - dolAmount: null, - creditBalance: 3000, - amountCents: 999, - originalAmountCents: null, - dailyPriceCents: 33, - currency: "usd", -}; - -const lifetimePlan = { - planId: "vip_lifetime", - planName: "VIP Lifetime", - orderType: "vip_lifetime", - vipDays: 3650, - dolAmount: null, - creditBalance: 0, - amountCents: 4999, - originalAmountCents: null, - dailyPriceCents: null, - currency: "usd", -}; - -const creditPlan = { - planId: "dol_1000", - planName: "1000 Credits", - orderType: "dol", - vipDays: null, - dolAmount: 1000, - creditBalance: 1000, - amountCents: 990, - originalAmountCents: null, - dailyPriceCents: null, - currency: "usd", -}; - -const quarterlyPlan = { - planId: "vip_quarterly", - planName: "VIP Quarterly", - orderType: "vip_quarterly", - vipDays: 90, - dolAmount: null, - creditBalance: 9000, - amountCents: 2499, - originalAmountCents: null, - dailyPriceCents: 28, - currency: "usd", -}; - -const tipPlan = { - planId: "tip_coffee_usd_4_99", - planName: "Small Coffee", - orderType: "tip", - vipDays: null, - dolAmount: null, - creditBalance: 0, - amountCents: 499, - originalAmountCents: null, - dailyPriceCents: null, - currency: "USD", -}; - -function createTestPaymentMachine( - overrides: Partial<{ - createOrderSpy: CreateOrderSpy; - createOrderError: Error; - orderStatus: "pending" | "paid" | "failed"; - orderStatuses: ("pending" | "paid" | "failed")[]; - }> = {}, -) { - const createOrderSpy = overrides.createOrderSpy ?? vi.fn(); - const orderStatus = overrides.orderStatus ?? "paid"; - const orderStatuses = overrides.orderStatuses ?? []; - let pollCount = 0; - - return paymentMachine.provide({ - actors: { - loadCachedPlans: fromPromise< - PaymentPlansResponse | null, - PaymentPlansActorInput - >( - async () => null, - ), - refreshPlans: fromPromise< - PaymentPlansResponse, - PaymentPlansActorInput - >(async () => - PaymentPlansResponse.from({ - plans: [monthlyPlan, lifetimePlan], - }), - ), - createOrder: fromPromise< - CreatePaymentOrderResponse, - CreateOrderInput - >(async ({ input }) => { - createOrderSpy(input); - if (overrides.createOrderError) throw overrides.createOrderError; - return CreatePaymentOrderResponse.from({ - orderId: "pay_test_001", - payParams: { - provider: "stripe", - clientSecret: "pi_test_secret_test", - }, - }); - }), - pollOrderStatus: fromPromise(async () => { - const status = - orderStatuses[Math.min(pollCount, orderStatuses.length - 1)] ?? - orderStatus; - pollCount += 1; - - return PaymentOrderStatusResponse.from({ - orderId: "pay_test_001", - status, - orderType: "vip_monthly", - planId: "vip_monthly", - }); - }), - }, - }); -} - -describe("paymentMachine", () => { - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - 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("loads the tip catalog and disables auto renew", async () => { - const loadCachedCatalog = vi.fn(); - const refreshCatalog = vi.fn(); - const createOrderSpy = vi.fn(); - const actor = createActor( - paymentMachine.provide({ - actors: { - loadCachedPlans: fromPromise< - PaymentPlansResponse | null, - PaymentPlansActorInput - >(async ({ input }) => { - loadCachedCatalog(input.catalog); - return null; - }), - refreshPlans: fromPromise< - PaymentPlansResponse, - PaymentPlansActorInput - >(async ({ input }) => { - refreshCatalog(input.catalog); - return PaymentPlansResponse.from({ plans: [tipPlan] }); - }), - createOrder: fromPromise< - CreatePaymentOrderResponse, - CreateOrderInput - >(async ({ input }) => { - createOrderSpy(input); - return CreatePaymentOrderResponse.from({ - orderId: "pay_tip_001", - payParams: { url: "https://checkout.example/tip" }, - }); - }), - pollOrderStatus: fromPromise(async () => - PaymentOrderStatusResponse.from({ - orderId: "pay_tip_001", - status: "paid", - orderType: "tip", - planId: tipPlan.planId, - }), - ), - }, - }), - ).start(); - - actor.send({ type: "PaymentInit", catalog: "tip" }); - await waitFor(actor, (snapshot) => snapshot.matches("ready")); - - expect(loadCachedCatalog).toHaveBeenCalledWith("tip"); - expect(refreshCatalog).toHaveBeenCalledWith("tip"); - expect(actor.getSnapshot().context).toMatchObject({ - planCatalog: "tip", - selectedPlanId: "tip_coffee_usd_4_99", - autoRenew: false, - }); - - actor.send({ type: "PaymentCreateOrderSubmitted" }); - await waitFor(actor, (snapshot) => snapshot.matches("paid")); - expect(createOrderSpy).toHaveBeenCalledWith({ - planId: "tip_coffee_usd_4_99", - payChannel: "stripe", - autoRenew: false, - }); - - actor.stop(); - }); - - it("keeps first recharge flag and plan metadata from the plans response", async () => { - const actor = createActor( - paymentMachine.provide({ - actors: { - loadCachedPlans: fromPromise< - PaymentPlansResponse | null, - PaymentPlansActorInput - >( - async () => null, - ), - refreshPlans: fromPromise< - PaymentPlansResponse, - PaymentPlansActorInput - >(async () => - PaymentPlansResponse.from({ - isFirstRecharge: true, - firstRechargeOffer: { - enabled: true, - type: "first_recharge_half_price", - discountPercent: 50, - }, - plans: [ - { - ...monthlyPlan, - amountCents: 995, - originalAmountCents: 1990, - isFirstRechargeOffer: true, - firstRechargeDiscountPercent: 50, - promotionType: "first_recharge_half_price", - }, - ], - }), - ), - }, - }), - ).start(); - - actor.send({ type: "PaymentInit" }); - await waitFor(actor, (snapshot) => snapshot.matches("ready")); - - const context = actor.getSnapshot().context; - expect(context.isFirstRecharge).toBe(true); - expect(context.plans[0]).toMatchObject({ - amountCents: 995, - originalAmountCents: 1990, - isFirstRechargeOffer: true, - }); - - actor.stop(); - }); - - it("consumes first recharge offer state after payment succeeds", async () => { - const actor = createActor( - paymentMachine.provide({ - actors: { - loadCachedPlans: fromPromise< - PaymentPlansResponse | null, - PaymentPlansActorInput - >( - async () => null, - ), - refreshPlans: fromPromise< - PaymentPlansResponse, - PaymentPlansActorInput - >(async () => - PaymentPlansResponse.from({ - isFirstRecharge: true, - firstRechargeOffer: { - enabled: true, - type: "first_recharge_half_price", - discountPercent: 50, - }, - plans: [ - { - ...monthlyPlan, - amountCents: 995, - originalAmountCents: 1990, - isFirstRechargeOffer: true, - firstRechargeDiscountPercent: 50, - promotionType: "first_recharge_half_price", - }, - ], - }), - ), - }, - }), - ).start(); - - actor.send({ type: "PaymentInit" }); - await waitFor(actor, (snapshot) => snapshot.matches("ready")); - - actor.send({ type: "PaymentFirstRechargeConsumed" }); - - const context = actor.getSnapshot().context; - expect(context.isFirstRecharge).toBe(false); - expect(context.plans[0]).toMatchObject({ - amountCents: 1990, - originalAmountCents: 1990, - isFirstRechargeOffer: false, - firstRechargeDiscountPercent: null, - promotionType: null, - }); - - actor.stop(); - }); - - it("preserves normal VIP original price outside first recharge activity", async () => { - const actor = createActor( - paymentMachine.provide({ - actors: { - loadCachedPlans: fromPromise< - PaymentPlansResponse | null, - PaymentPlansActorInput - >( - async () => null, - ), - refreshPlans: fromPromise< - PaymentPlansResponse, - PaymentPlansActorInput - >(async () => - PaymentPlansResponse.from({ - isFirstRecharge: false, - plans: [ - { - ...monthlyPlan, - amountCents: 1990, - originalAmountCents: 2499, - isFirstRechargeOffer: false, - firstRechargeDiscountPercent: null, - promotionType: null, - }, - ], - }), - ), - }, - }), - ).start(); - - actor.send({ type: "PaymentInit" }); - await waitFor(actor, (snapshot) => snapshot.matches("ready")); - - expect(actor.getSnapshot().context.plans[0]).toMatchObject({ - amountCents: 1990, - originalAmountCents: 2499, - isFirstRechargeOffer: false, - }); - - actor.stop(); - }); - - it("hydrates cached plans before refreshing with network plans", async () => { - let resolveRefresh!: (value: PaymentPlansResponse) => void; - const refreshPlansPromise = new Promise((resolve) => { - resolveRefresh = resolve; - }); - const actor = createActor( - paymentMachine.provide({ - actors: { - loadCachedPlans: fromPromise< - PaymentPlansResponse | null, - PaymentPlansActorInput - >( - async () => - PaymentPlansResponse.from({ - plans: [quarterlyPlan], - }), - ), - refreshPlans: fromPromise< - PaymentPlansResponse, - PaymentPlansActorInput - >( - async () => refreshPlansPromise, - ), - }, - }), - ).start(); - - actor.send({ type: "PaymentInit" }); - - await waitFor(actor, (snapshot) => - snapshot.matches("refreshingPlans"), - ); - expect(actor.getSnapshot().context.plans[0]?.planId).toBe( - "vip_quarterly", - ); - - resolveRefresh( - PaymentPlansResponse.from({ - plans: [monthlyPlan, creditPlan], - }), - ); - await waitFor(actor, (snapshot) => snapshot.matches("ready")); - - const context = actor.getSnapshot().context; - expect(context.plans.map((plan) => plan.planId)).toEqual([ - "vip_monthly", - "dol_1000", - ]); - expect(context.selectedPlanId).toBe("vip_monthly"); - - 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 createOrderStart = vi - .spyOn(behaviorAnalytics, "createOrderStart") - .mockImplementation(() => undefined); - const createOrderSuccess = vi - .spyOn(behaviorAnalytics, "createOrderSuccess") - .mockImplementation(() => undefined); - const statusPollStart = vi - .spyOn(behaviorAnalytics, "statusPollStart") - .mockImplementation(() => undefined); - const statusPollUpdate = vi - .spyOn(behaviorAnalytics, "statusPollUpdate") - .mockImplementation(() => undefined); - 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.orderPollingStartedAt).toBeNull(); - expect(context.launchNonce).toBe(1); - expect(createOrderStart).toHaveBeenCalledWith( - expect.objectContaining({ planId: "vip_monthly" }), - "stripe", - ); - expect(createOrderSuccess).toHaveBeenCalledWith( - expect.objectContaining({ planId: "vip_monthly" }), - "pay_test_001", - "stripe", - ); - expect(statusPollStart).toHaveBeenCalledWith( - "pay_test_001", - expect.objectContaining({ planId: "vip_monthly" }), - ); - expect(statusPollUpdate).toHaveBeenCalledWith( - "pay_test_001", - "paid", - expect.objectContaining({ planId: "vip_monthly" }), - ); - - actor.stop(); - }); - - it("tracks order creation failures without interrupting failure handling", async () => { - const createOrderFailed = vi - .spyOn(behaviorAnalytics, "createOrderFailed") - .mockImplementation(() => undefined); - const actor = createActor( - createTestPaymentMachine({ - createOrderError: new Error("backend unavailable"), - }), - ).start(); - - actor.send({ type: "PaymentInit" }); - await waitFor(actor, (snapshot) => snapshot.matches("ready")); - actor.send({ type: "PaymentCreateOrderSubmitted" }); - await waitFor(actor, (snapshot) => snapshot.matches("failed")); - - expect(createOrderFailed).toHaveBeenCalledWith( - expect.objectContaining({ planId: "vip_monthly" }), - "stripe", - ); - expect(actor.getSnapshot().context.errorMessage).toBe( - "backend unavailable", - ); - - 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"); - expect(actor.getSnapshot().context.orderPollingStartedAt).not.toBeNull(); - - 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.orderPollingStartedAt).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.orderPollingStartedAt).toBeNull(); - expect(context.errorMessage).toBe("Payment failed or was cancelled."); - - actor.stop(); - }); - - it("moves to failed state when a pending order exceeds polling timeout", async () => { - const statusPollTimeout = vi - .spyOn(behaviorAnalytics, "statusPollTimeout") - .mockImplementation(() => undefined); - const now = new Date("2026-06-30T00:00:00.000Z"); - vi.useFakeTimers(); - vi.setSystemTime(now); - - 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")); - - vi.setSystemTime(now.getTime() + MAX_ORDER_POLLING_MS); - await vi.advanceTimersByTimeAsync(4000); - await waitFor(actor, (snapshot) => snapshot.matches("failed")); - - const context = actor.getSnapshot().context; - expect(context.orderStatus).toBe("failed"); - expect(context.orderPollingStartedAt).toBeNull(); - expect(context.errorMessage).toBe(PAYMENT_TIMEOUT_ERROR_MESSAGE); - expect(statusPollTimeout).toHaveBeenCalledWith( - "pay_test_001", - expect.objectContaining({ planId: "vip_monthly" }), - ); - - actor.stop(); - }); - - it("keeps paid response higher priority than polling timeout", async () => { - const now = new Date("2026-06-30T00:00:00.000Z"); - vi.useFakeTimers(); - vi.setSystemTime(now); - - const actor = createActor( - createTestPaymentMachine({ orderStatuses: ["pending", "paid"] }), - ).start(); - - actor.send({ type: "PaymentInit" }); - await waitFor(actor, (snapshot) => snapshot.matches("ready")); - - actor.send({ type: "PaymentCreateOrderSubmitted" }); - await waitFor(actor, (snapshot) => snapshot.matches("waitingForPayment")); - - vi.setSystemTime(now.getTime() + MAX_ORDER_POLLING_MS); - await vi.advanceTimersByTimeAsync(4000); - await waitFor(actor, (snapshot) => snapshot.matches("paid")); - - const context = actor.getSnapshot().context; - expect(context.orderStatus).toBe("paid"); - expect(context.orderPollingStartedAt).toBeNull(); - expect(context.errorMessage).toBeNull(); - - actor.stop(); - }); - - it("uses restored order creation time when polling returned payment", async () => { - const now = new Date("2026-06-30T00:00:00.000Z"); - vi.useFakeTimers(); - vi.setSystemTime(now); - - const actor = createActor( - createTestPaymentMachine({ orderStatus: "pending" }), - ).start(); - - actor.send({ type: "PaymentInit" }); - await waitFor(actor, (snapshot) => snapshot.matches("ready")); - - actor.send({ - type: "PaymentReturned", - orderId: "pay_returned_001", - createdAt: now.getTime() - MAX_ORDER_POLLING_MS, - }); - await waitFor(actor, (snapshot) => snapshot.matches("failed")); - - const context = actor.getSnapshot().context; - expect(context.currentOrderId).toBe("pay_returned_001"); - expect(context.orderStatus).toBe("failed"); - expect(context.orderPollingStartedAt).toBeNull(); - expect(context.errorMessage).toBe(PAYMENT_TIMEOUT_ERROR_MESSAGE); - - 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(); - expect(context.orderPollingStartedAt).toBeNull(); - - actor.stop(); - }); - - it("disables auto renew for credit recharge plans", async () => { - const actor = createActor(createTestPaymentMachine()).start(); - - actor.send({ type: "PaymentInit" }); - await waitFor(actor, (snapshot) => snapshot.matches("ready")); - - actor.send({ type: "PaymentPlanSelected", planId: creditPlan.planId }); - - const context = actor.getSnapshot().context; - expect(context.selectedPlanId).toBe("dol_1000"); - expect(context.autoRenew).toBe(false); - - actor.stop(); - }); -}); diff --git a/src/stores/payment/__tests__/payment-order-flow.test.ts b/src/stores/payment/__tests__/payment-order-flow.test.ts new file mode 100644 index 00000000..80c8ec61 --- /dev/null +++ b/src/stores/payment/__tests__/payment-order-flow.test.ts @@ -0,0 +1,239 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createActor, waitFor } from "xstate"; + +import { behaviorAnalytics } from "@/lib/analytics"; +import { + MAX_ORDER_POLLING_MS, + PAYMENT_TIMEOUT_ERROR_MESSAGE, +} from "@/stores/payment/helper"; + +import { + createTestPaymentMachine, + creditPlan, + type CreateOrderSpy, +} from "./payment-machine.test-utils"; + +describe("payment order flow", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("does not create an order before agreement is accepted", async () => { + const createOrderSpy = vi.fn(); + const actor = createActor( + createTestPaymentMachine({ createOrderSpy }), + ).start(); + await initialize(actor); + actor.send({ type: "PaymentAgreementChanged", agreed: false }); + actor.send({ type: "PaymentCreateOrderSubmitted" }); + + expect(actor.getSnapshot().matches("ready")).toBe(true); + expect(actor.getSnapshot().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", async () => { + const createOrderStart = vi + .spyOn(behaviorAnalytics, "createOrderStart") + .mockImplementation(() => undefined); + const createOrderSuccess = vi + .spyOn(behaviorAnalytics, "createOrderSuccess") + .mockImplementation(() => undefined); + const statusPollStart = vi + .spyOn(behaviorAnalytics, "statusPollStart") + .mockImplementation(() => undefined); + const statusPollUpdate = vi + .spyOn(behaviorAnalytics, "statusPollUpdate") + .mockImplementation(() => undefined); + const createOrderSpy = vi.fn(); + const actor = createActor( + createTestPaymentMachine({ createOrderSpy }), + ).start(); + await initialize(actor); + actor.send({ type: "PaymentCreateOrderSubmitted" }); + await waitFor(actor, (snapshot) => snapshot.matches("paid")); + + expect(createOrderSpy).toHaveBeenCalledWith({ + planId: "vip_monthly", + payChannel: "stripe", + autoRenew: true, + }); + expect(actor.getSnapshot().context).toMatchObject({ + currentOrderId: "pay_test_001", + orderStatus: "paid", + orderPollingStartedAt: null, + launchNonce: 1, + }); + expect(createOrderStart).toHaveBeenCalledOnce(); + expect(createOrderSuccess).toHaveBeenCalledOnce(); + expect(statusPollStart).toHaveBeenCalledOnce(); + expect(statusPollUpdate).toHaveBeenCalledWith( + "pay_test_001", + "paid", + expect.objectContaining({ planId: "vip_monthly" }), + ); + actor.stop(); + }); + + it("tracks order creation failures", async () => { + const createOrderFailed = vi + .spyOn(behaviorAnalytics, "createOrderFailed") + .mockImplementation(() => undefined); + const actor = createActor( + createTestPaymentMachine({ + createOrderError: new Error("backend unavailable"), + }), + ).start(); + await initialize(actor); + actor.send({ type: "PaymentCreateOrderSubmitted" }); + await waitFor(actor, (snapshot) => snapshot.matches("failed")); + + expect(createOrderFailed).toHaveBeenCalledOnce(); + expect(actor.getSnapshot().context.errorMessage).toBe( + "backend unavailable", + ); + actor.stop(); + }); + + it("waits for pending payment and can reset", async () => { + const actor = createActor( + createTestPaymentMachine({ orderStatus: "pending" }), + ).start(); + await initialize(actor); + actor.send({ type: "PaymentCreateOrderSubmitted" }); + await waitFor(actor, (snapshot) => snapshot.matches("waitingForPayment")); + expect(actor.getSnapshot().context.orderStatus).toBe("pending"); + + actor.send({ type: "PaymentReset" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + expect(actor.getSnapshot().context).toMatchObject({ + currentOrderId: null, + payParams: null, + orderStatus: null, + orderPollingStartedAt: null, + errorMessage: null, + }); + actor.stop(); + }); + + it("moves to failed when polling reports failure", async () => { + const actor = createActor( + createTestPaymentMachine({ orderStatus: "failed" }), + ).start(); + await initialize(actor); + actor.send({ type: "PaymentCreateOrderSubmitted" }); + await waitFor(actor, (snapshot) => snapshot.matches("failed")); + + expect(actor.getSnapshot().context).toMatchObject({ + orderStatus: "failed", + orderPollingStartedAt: null, + errorMessage: "Payment failed or was cancelled.", + }); + actor.stop(); + }); + + it("times out an old pending order", async () => { + const statusPollTimeout = vi + .spyOn(behaviorAnalytics, "statusPollTimeout") + .mockImplementation(() => undefined); + const now = new Date("2026-06-30T00:00:00.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); + const actor = createActor( + createTestPaymentMachine({ orderStatus: "pending" }), + ).start(); + await initialize(actor); + actor.send({ type: "PaymentCreateOrderSubmitted" }); + await waitFor(actor, (snapshot) => snapshot.matches("waitingForPayment")); + + vi.setSystemTime(now.getTime() + MAX_ORDER_POLLING_MS); + await vi.advanceTimersByTimeAsync(4000); + await waitFor(actor, (snapshot) => snapshot.matches("failed")); + expect(actor.getSnapshot().context.errorMessage).toBe( + PAYMENT_TIMEOUT_ERROR_MESSAGE, + ); + expect(statusPollTimeout).toHaveBeenCalledOnce(); + actor.stop(); + }); + + it("prioritizes paid response over timeout", async () => { + const now = new Date("2026-06-30T00:00:00.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); + const actor = createActor( + createTestPaymentMachine({ orderStatuses: ["pending", "paid"] }), + ).start(); + await initialize(actor); + actor.send({ type: "PaymentCreateOrderSubmitted" }); + await waitFor(actor, (snapshot) => snapshot.matches("waitingForPayment")); + + vi.setSystemTime(now.getTime() + MAX_ORDER_POLLING_MS); + await vi.advanceTimersByTimeAsync(4000); + await waitFor(actor, (snapshot) => snapshot.matches("paid")); + expect(actor.getSnapshot().context.errorMessage).toBeNull(); + actor.stop(); + }); + + it("uses restored order creation time", async () => { + const now = new Date("2026-06-30T00:00:00.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); + const actor = createActor( + createTestPaymentMachine({ orderStatus: "pending" }), + ).start(); + await initialize(actor); + actor.send({ + type: "PaymentReturned", + orderId: "pay_returned_001", + createdAt: now.getTime() - MAX_ORDER_POLLING_MS, + }); + await waitFor(actor, (snapshot) => snapshot.matches("failed")); + + expect(actor.getSnapshot().context).toMatchObject({ + currentOrderId: "pay_returned_001", + orderStatus: "failed", + errorMessage: PAYMENT_TIMEOUT_ERROR_MESSAGE, + }); + actor.stop(); + }); + + it("clears order data and disables renewal for lifetime plans", async () => { + const actor = createActor(createTestPaymentMachine()).start(); + await initialize(actor); + 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")); + + expect(actor.getSnapshot().context).toMatchObject({ + selectedPlanId: "vip_lifetime", + autoRenew: false, + currentOrderId: null, + payParams: null, + orderStatus: null, + }); + actor.stop(); + }); + + it("disables renewal for credit recharge plans", async () => { + const actor = createActor(createTestPaymentMachine()).start(); + await initialize(actor); + actor.send({ type: "PaymentPlanSelected", planId: creditPlan.planId }); + expect(actor.getSnapshot().context).toMatchObject({ + selectedPlanId: creditPlan.planId, + autoRenew: false, + }); + actor.stop(); + }); +}); + +async function initialize( + actor: ReturnType>>, +): Promise { + actor.send({ type: "PaymentInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); +} diff --git a/src/stores/payment/payment-machine.helpers.ts b/src/stores/payment/helper/catalog.ts similarity index 83% rename from src/stores/payment/payment-machine.helpers.ts rename to src/stores/payment/helper/catalog.ts index fad18e34..f5659b62 100644 --- a/src/stores/payment/payment-machine.helpers.ts +++ b/src/stores/payment/helper/catalog.ts @@ -1,23 +1,7 @@ -import { ExceptionHandler } from "@/core/errors"; import { PaymentPlan, type PaymentPlansResponse } from "@/data/dto/payment"; -import type { PaymentState } from "./payment-state"; - -export const MAX_ORDER_POLLING_MS = 5 * 60 * 1000; -export const PAYMENT_TIMEOUT_ERROR_MESSAGE = - "Payment timed out. Please try again."; - -export function toPaymentErrorMessage(error: unknown): string { - return ExceptionHandler.message(error); -} - -export function hasOrderPollingTimedOut( - startedAt: number | null, - now = Date.now(), - maxDurationMs = MAX_ORDER_POLLING_MS, -): boolean { - return startedAt !== null && now - startedAt >= maxDurationMs; -} +import type { PaymentState } from "../payment-state"; +import { resetOrderState } from "./order"; export function defaultAutoRenewForPlan( planId: string, @@ -175,14 +159,3 @@ export function selectPlanState( autoRenew: defaultAutoRenewForPlan(planId, plans), }; } - -export function resetOrderState(): Partial { - return { - currentOrderId: null, - currentOrderPlanId: null, - payParams: null, - orderStatus: null, - orderPollingStartedAt: null, - errorMessage: null, - }; -} diff --git a/src/stores/payment/helper/index.ts b/src/stores/payment/helper/index.ts new file mode 100644 index 00000000..265e7c06 --- /dev/null +++ b/src/stores/payment/helper/index.ts @@ -0,0 +1,2 @@ +export * from "./catalog"; +export * from "./order"; diff --git a/src/stores/payment/helper/order.ts b/src/stores/payment/helper/order.ts new file mode 100644 index 00000000..8ec2e43b --- /dev/null +++ b/src/stores/payment/helper/order.ts @@ -0,0 +1,46 @@ +import { ExceptionHandler } from "@/core/errors"; +import type { PaymentPlan } from "@/data/dto/payment"; + +import type { PaymentState } from "../payment-state"; + +export const MAX_ORDER_POLLING_MS = 5 * 60 * 1000; +export const PAYMENT_TIMEOUT_ERROR_MESSAGE = + "Payment timed out. Please try again."; + +export function toPaymentErrorMessage(error: unknown): string { + return ExceptionHandler.message(error); +} + +export function hasOrderPollingTimedOut( + startedAt: number | null, + now = Date.now(), + maxDurationMs = MAX_ORDER_POLLING_MS, +): boolean { + return startedAt !== null && now - startedAt >= maxDurationMs; +} + +export function resetOrderState(): Partial { + return { + currentOrderId: null, + currentOrderPlanId: null, + payParams: null, + orderStatus: null, + orderPollingStartedAt: null, + errorMessage: null, + }; +} + +export function getSelectedPlan(context: PaymentState): PaymentPlan | undefined { + return context.plans.find( + (plan) => plan.planId === context.selectedPlanId, + ); +} + +export function getCurrentOrderPlan( + context: PaymentState, +): PaymentPlan | undefined { + if (!context.currentOrderPlanId) return undefined; + return context.plans.find( + (plan) => plan.planId === context.currentOrderPlanId, + ); +} diff --git a/src/stores/payment/index.ts b/src/stores/payment/index.ts index f80c359b..2442afba 100644 --- a/src/stores/payment/index.ts +++ b/src/stores/payment/index.ts @@ -4,6 +4,6 @@ export * from "./payment-context"; export * from "./payment-events"; +export * from "./helper"; export * from "./payment-machine"; -export * from "./payment-machine.actors"; export * from "./payment-state"; diff --git a/src/stores/payment/machine/actors/order.ts b/src/stores/payment/machine/actors/order.ts new file mode 100644 index 00000000..9e204876 --- /dev/null +++ b/src/stores/payment/machine/actors/order.ts @@ -0,0 +1,31 @@ +import { fromPromise } from "xstate"; + +import { + CreatePaymentOrderResponse, + PayChannel, + PaymentOrderStatusResponse, +} from "@/data/dto/payment"; +import { getPaymentRepository } from "@/data/repositories/payment_repository"; +import { Result } from "@/utils/result"; + +export const createPaymentOrderActor = fromPromise< + CreatePaymentOrderResponse, + { planId: string; payChannel: PayChannel; autoRenew: boolean } +>(async ({ input }) => { + const result = await getPaymentRepository().createOrder( + input.planId, + input.payChannel, + input.autoRenew, + ); + if (Result.isErr(result)) throw result.error; + return result.data; +}); + +export const pollPaymentOrderStatusActor = fromPromise< + PaymentOrderStatusResponse, + { orderId: string } +>(async ({ input }) => { + const result = await getPaymentRepository().getOrderStatus(input.orderId); + if (Result.isErr(result)) throw result.error; + return result.data; +}); diff --git a/src/stores/payment/machine/actors/plans.ts b/src/stores/payment/machine/actors/plans.ts new file mode 100644 index 00000000..27be0868 --- /dev/null +++ b/src/stores/payment/machine/actors/plans.ts @@ -0,0 +1,30 @@ +import { fromPromise } from "xstate"; + +import { PaymentPlansResponse } from "@/data/dto/payment"; +import { getPaymentRepository } from "@/data/repositories/payment_repository"; +import { Result } from "@/utils/result"; + +import type { PaymentPlanCatalog } from "../../payment-state"; + +export const loadCachedPaymentPlansActor = fromPromise< + PaymentPlansResponse | null, + { catalog: PaymentPlanCatalog } +>(async ({ input }) => { + if (input.catalog === "tip") return null; + const result = await getPaymentRepository().getCachedPlans(); + if (Result.isErr(result)) throw result.error; + return result.data; +}); + +export const refreshPaymentPlansActor = fromPromise< + PaymentPlansResponse, + { catalog: PaymentPlanCatalog } +>(async ({ input }) => { + const paymentRepo = getPaymentRepository(); + const result = + input.catalog === "tip" + ? await paymentRepo.getTipPlans() + : await paymentRepo.getPlans(); + if (Result.isErr(result)) throw result.error; + return result.data; +}); diff --git a/src/stores/payment/machine/catalog-flow.ts b/src/stores/payment/machine/catalog-flow.ts new file mode 100644 index 00000000..d2cf324f --- /dev/null +++ b/src/stores/payment/machine/catalog-flow.ts @@ -0,0 +1,212 @@ +import type { DoneActorEvent, ErrorActorEvent } from "xstate"; + +import type { PaymentPlansResponse } from "@/data/dto/payment"; + +import { + consumeFirstRechargeState, + hydratePlansResponseState, + refreshPlansResponseState, + selectPlanState, +} from "../helper/catalog"; +import { + resetOrderState, + toPaymentErrorMessage, +} from "../helper/order"; +import { + basePaymentMachineSetup, + createPaymentActorActionSetup, +} from "./setup"; + +const initializeCatalogAction = basePaymentMachineSetup.assign( + ({ context, event }) => { + if (event.type !== "PaymentInit") return {}; + return { + planCatalog: event.catalog ?? context.planCatalog, + ...(event.payChannel ? { payChannel: event.payChannel } : {}), + }; + }, +); + +const refreshCatalogAction = basePaymentMachineSetup.assign( + ({ context, event }) => { + if (event.type !== "PaymentInit") return {}; + const planCatalog = event.catalog ?? context.planCatalog; + const catalogChanged = planCatalog !== context.planCatalog; + return { + planCatalog, + ...(event.payChannel ? { payChannel: event.payChannel } : {}), + ...(catalogChanged + ? { + plans: [], + selectedPlanId: "", + isFirstRecharge: false, + autoRenew: planCatalog !== "tip", + } + : {}), + }; + }, +); + +const selectPlanAction = basePaymentMachineSetup.assign( + ({ context, event }) => + event.type === "PaymentPlanSelected" + ? selectPlanState(event.planId, context.plans) + : {}, +); + +const changePayChannelAction = basePaymentMachineSetup.assign(({ event }) => + event.type === "PaymentPayChannelChanged" + ? { + ...resetOrderState(), + payChannel: event.payChannel, + errorMessage: null, + } + : {}, +); + +const changeAutoRenewAction = basePaymentMachineSetup.assign(({ event }) => + event.type === "PaymentAutoRenewChanged" + ? { autoRenew: event.autoRenew, errorMessage: null } + : {}, +); + +const changeAgreementAction = basePaymentMachineSetup.assign(({ event }) => + event.type === "PaymentAgreementChanged" + ? { agreed: event.agreed, errorMessage: null } + : {}, +); + +const showCreateOrderValidationErrorAction = basePaymentMachineSetup.assign({ + errorMessage: "Choose a plan and accept the agreement before continuing.", +}); + +const clearPaymentErrorAction = basePaymentMachineSetup.assign({ + errorMessage: null, +}); + +const resetOrderAction = basePaymentMachineSetup.assign(() => + resetOrderState(), +); + +const consumeFirstRechargeAction = basePaymentMachineSetup.assign( + ({ context }) => consumeFirstRechargeState(context), +); + +export const catalogMachineSetup = basePaymentMachineSetup.extend({ + actions: { + initializeCatalog: initializeCatalogAction, + refreshCatalog: refreshCatalogAction, + selectPlan: selectPlanAction, + changePayChannel: changePayChannelAction, + changeAutoRenew: changeAutoRenewAction, + changeAgreement: changeAgreementAction, + showCreateOrderValidationError: showCreateOrderValidationErrorAction, + clearPaymentError: clearPaymentErrorAction, + resetOrder: resetOrderAction, + consumeFirstRecharge: consumeFirstRechargeAction, + }, +}); + +const cachedPlansDoneSetup = + createPaymentActorActionSetup< + DoneActorEvent + >(); +const plansDoneSetup = + createPaymentActorActionSetup>(); +const plansErrorSetup = createPaymentActorActionSetup(); + +const hydrateCachedPlansAction = cachedPlansDoneSetup.assign( + ({ context, event }) => + event.output + ? hydratePlansResponseState(event.output, context.selectedPlanId) + : {}, +); + +const hydratePlansAction = plansDoneSetup.assign(({ context, event }) => + hydratePlansResponseState(event.output, context.selectedPlanId), +); + +const refreshPlansAction = plansDoneSetup.assign(({ context, event }) => + refreshPlansResponseState(event.output, context.selectedPlanId), +); + +const applyPlansErrorAction = plansErrorSetup.assign(({ event }) => ({ + errorMessage: toPaymentErrorMessage(event.error), +})); + +export const idleState = catalogMachineSetup.createStateConfig({ + on: { + PaymentInit: { + target: "loadingCachedPlans", + actions: "initializeCatalog", + }, + }, +}); + +export const loadingCachedPlansState = + catalogMachineSetup.createStateConfig({ + invoke: { + src: "loadCachedPlans", + input: ({ context }) => ({ catalog: context.planCatalog }), + onDone: [ + { + guard: ({ event }) => (event.output?.plans.length ?? 0) > 0, + target: "refreshingPlans", + actions: hydrateCachedPlansAction, + }, + { target: "loadingPlans" }, + ], + onError: { target: "loadingPlans" }, + }, + }); + +export const loadingPlansState = catalogMachineSetup.createStateConfig({ + invoke: { + src: "refreshPlans", + input: ({ context }) => ({ catalog: context.planCatalog }), + onDone: { + target: "ready", + actions: hydratePlansAction, + }, + onError: { + target: "ready", + actions: applyPlansErrorAction, + }, + }, +}); + +export const refreshingPlansState = catalogMachineSetup.createStateConfig({ + invoke: { + src: "refreshPlans", + input: ({ context }) => ({ catalog: context.planCatalog }), + onDone: { + target: "ready", + actions: refreshPlansAction, + }, + onError: { + target: "ready", + actions: applyPlansErrorAction, + }, + }, +}); + +export const readyState = catalogMachineSetup.createStateConfig({ + on: { + PaymentInit: { + target: "refreshingPlans", + actions: "refreshCatalog", + }, + PaymentPlanSelected: { actions: "selectPlan" }, + PaymentPayChannelChanged: { actions: "changePayChannel" }, + PaymentAutoRenewChanged: { actions: "changeAutoRenew" }, + PaymentAgreementChanged: { actions: "changeAgreement" }, + PaymentCreateOrderSubmitted: [ + { + guard: "canCreateOrder", + target: "creatingOrder", + }, + { actions: "showCreateOrderValidationError" }, + ], + PaymentErrorCleared: { actions: "clearPaymentError" }, + }, +}); diff --git a/src/stores/payment/machine/order-flow.ts b/src/stores/payment/machine/order-flow.ts new file mode 100644 index 00000000..49c2076f --- /dev/null +++ b/src/stores/payment/machine/order-flow.ts @@ -0,0 +1,305 @@ +import type { DoneActorEvent, ErrorActorEvent } from "xstate"; + +import type { + CreatePaymentOrderResponse, + PaymentOrderStatusResponse, +} from "@/data/dto/payment"; +import { behaviorAnalytics } from "@/lib/analytics"; + +import { + getCurrentOrderPlan, + getSelectedPlan, + hasOrderPollingTimedOut, + PAYMENT_TIMEOUT_ERROR_MESSAGE, + toPaymentErrorMessage, +} from "../helper/order"; +import { + catalogMachineSetup, + idleState, + loadingCachedPlansState, + loadingPlansState, + readyState, + refreshingPlansState, +} from "./catalog-flow"; +import { createPaymentActorActionSetup } from "./setup"; + +const POLL_DELAY_MS = 4000; + +const trackCreateOrderStartAction = catalogMachineSetup.createAction( + ({ context }) => { + const plan = getSelectedPlan(context); + if (plan) behaviorAnalytics.createOrderStart(plan, context.payChannel); + }, +); + +const trackReturnedPollStartAction = catalogMachineSetup.createAction( + ({ event }) => { + if (event.type === "PaymentReturned") { + behaviorAnalytics.statusPollStart(event.orderId); + } + }, +); + +const applyReturnedOrderAction = catalogMachineSetup.assign(({ event }) => { + if (event.type !== "PaymentReturned") return {}; + return { + currentOrderId: event.orderId, + currentOrderPlanId: null, + payParams: null, + orderStatus: "pending", + orderPollingStartedAt: event.createdAt ?? Date.now(), + errorMessage: null, + }; +}); + +const applyLaunchFailureAction = catalogMachineSetup.assign(({ event }) => + event.type === "PaymentLaunchFailed" + ? { errorMessage: event.errorMessage } + : {}, +); + +export const paymentMachineSetup = catalogMachineSetup.extend({ + actions: { + trackCreateOrderStart: trackCreateOrderStartAction, + trackReturnedPollStart: trackReturnedPollStartAction, + applyReturnedOrder: applyReturnedOrderAction, + applyLaunchFailure: applyLaunchFailureAction, + }, +}); + +const createOrderDoneSetup = + createPaymentActorActionSetup< + DoneActorEvent + >(); +const pollOrderDoneSetup = + createPaymentActorActionSetup< + DoneActorEvent + >(); +const orderErrorSetup = createPaymentActorActionSetup(); + +const trackCreateOrderSuccessAction = createOrderDoneSetup.createAction( + ({ context, event }) => { + const plan = getSelectedPlan(context); + if (plan) { + behaviorAnalytics.createOrderSuccess( + plan, + event.output.orderId, + context.payChannel, + ); + } + behaviorAnalytics.statusPollStart(event.output.orderId, plan); + }, +); + +const applyCreateOrderSuccessAction = createOrderDoneSetup.assign( + ({ context, event }) => ({ + currentOrderId: event.output.orderId, + currentOrderPlanId: context.selectedPlanId, + payParams: event.output.payParams, + orderStatus: "pending", + orderPollingStartedAt: Date.now(), + errorMessage: null, + launchNonce: context.launchNonce + 1, + }), +); + +const trackCreateOrderFailureAction = orderErrorSetup.createAction( + ({ context }) => { + const plan = getSelectedPlan(context); + if (plan) behaviorAnalytics.createOrderFailed(plan, context.payChannel); + }, +); + +const applyOrderErrorAction = orderErrorSetup.assign(({ event }) => ({ + errorMessage: toPaymentErrorMessage(event.error), +})); + +const trackPollUpdateAction = pollOrderDoneSetup.createAction( + ({ context, event }) => + behaviorAnalytics.statusPollUpdate( + context.currentOrderId ?? "", + event.output.status, + getCurrentOrderPlan(context), + ), +); + +const trackPollTimeoutAction = pollOrderDoneSetup.createAction( + ({ context, event }) => { + const orderId = context.currentOrderId ?? ""; + const plan = getCurrentOrderPlan(context); + behaviorAnalytics.statusPollUpdate(orderId, event.output.status, plan); + behaviorAnalytics.statusPollTimeout(orderId, plan); + }, +); + +const applyPaidOrderAction = pollOrderDoneSetup.assign(({ event }) => ({ + orderStatus: event.output.status, + orderPollingStartedAt: null, + errorMessage: null, +})); + +const applyFailedOrderAction = pollOrderDoneSetup.assign(({ event }) => ({ + orderStatus: event.output.status, + orderPollingStartedAt: null, + errorMessage: "Payment failed or was cancelled.", +})); + +const applyTimedOutOrderAction = pollOrderDoneSetup.assign({ + orderStatus: "failed", + orderPollingStartedAt: null, + errorMessage: PAYMENT_TIMEOUT_ERROR_MESSAGE, +}); + +const applyPendingOrderAction = pollOrderDoneSetup.assign(({ event }) => ({ + orderStatus: event.output.status, + errorMessage: null, +})); + +const creatingOrderState = paymentMachineSetup.createStateConfig({ + entry: "trackCreateOrderStart", + invoke: { + src: "createOrder", + input: ({ context }) => ({ + planId: context.selectedPlanId, + payChannel: context.payChannel, + autoRenew: context.autoRenew, + }), + onDone: { + target: "pollingOrder", + actions: [ + trackCreateOrderSuccessAction, + applyCreateOrderSuccessAction, + ], + }, + onError: { + target: "failed", + actions: [trackCreateOrderFailureAction, applyOrderErrorAction], + }, + }, +}); + +const pollingOrderState = paymentMachineSetup.createStateConfig({ + invoke: { + src: "pollOrderStatus", + input: ({ context }) => ({ orderId: context.currentOrderId ?? "" }), + onDone: [ + { + guard: ({ event }) => event.output.status === "paid", + target: "paid", + actions: [trackPollUpdateAction, applyPaidOrderAction], + }, + { + guard: ({ event }) => event.output.status === "failed", + target: "failed", + actions: [trackPollUpdateAction, applyFailedOrderAction], + }, + { + guard: ({ context }) => + hasOrderPollingTimedOut(context.orderPollingStartedAt), + target: "failed", + actions: [trackPollTimeoutAction, applyTimedOutOrderAction], + }, + { + target: "waitingForPayment", + actions: [trackPollUpdateAction, applyPendingOrderAction], + }, + ], + onError: { + target: "failed", + actions: applyOrderErrorAction, + }, + }, + on: { + PaymentReset: { + target: "ready", + actions: "resetOrder", + }, + }, +}); + +const waitingForPaymentState = paymentMachineSetup.createStateConfig({ + after: { + [POLL_DELAY_MS]: "pollingOrder", + }, + on: { + PaymentReset: { + target: "ready", + actions: "resetOrder", + }, + }, +}); + +const paidState = paymentMachineSetup.createStateConfig({ + on: { + PaymentPlanSelected: { + target: "ready", + actions: "selectPlan", + }, + PaymentPayChannelChanged: { + target: "ready", + actions: "changePayChannel", + }, + PaymentCreateOrderSubmitted: [ + { + guard: "canCreateOrder", + target: "creatingOrder", + actions: "resetOrder", + }, + { + target: "ready", + actions: "showCreateOrderValidationError", + }, + ], + PaymentReset: { + target: "ready", + actions: "resetOrder", + }, + }, +}); + +const failedState = paymentMachineSetup.createStateConfig({ + on: { + PaymentCreateOrderSubmitted: [ + { + guard: "canCreateOrder", + target: "creatingOrder", + actions: "resetOrder", + }, + ], + PaymentErrorCleared: { + target: "ready", + actions: "clearPaymentError", + }, + PaymentReset: { + target: "ready", + actions: "resetOrder", + }, + }, +}); + +export const paymentRootStateConfig = paymentMachineSetup.createStateConfig({ + initial: "idle", + states: { + idle: idleState, + loadingCachedPlans: loadingCachedPlansState, + loadingPlans: loadingPlansState, + refreshingPlans: refreshingPlansState, + ready: readyState, + creatingOrder: creatingOrderState, + pollingOrder: pollingOrderState, + waitingForPayment: waitingForPaymentState, + paid: paidState, + failed: failedState, + }, + on: { + PaymentFirstRechargeConsumed: { actions: "consumeFirstRecharge" }, + PaymentReturned: { + target: ".pollingOrder", + actions: ["trackReturnedPollStart", "applyReturnedOrder"], + }, + PaymentLaunchFailed: { + target: ".failed", + actions: "applyLaunchFailure", + }, + }, +}); diff --git a/src/stores/payment/machine/setup.ts b/src/stores/payment/machine/setup.ts new file mode 100644 index 00000000..4bfbe834 --- /dev/null +++ b/src/stores/payment/machine/setup.ts @@ -0,0 +1,62 @@ +import { setup, type ActionFunction, type EventObject } from "xstate"; + +import type { PaymentEvent } from "../payment-events"; +import type { PaymentState } from "../payment-state"; +import { + createPaymentOrderActor, + pollPaymentOrderStatusActor, +} from "./actors/order"; +import { + loadCachedPaymentPlansActor, + refreshPaymentPlansActor, +} from "./actors/plans"; + +export const basePaymentMachineSetup = setup({ + types: { + context: {} as PaymentState, + events: {} as PaymentEvent, + }, + actors: { + loadCachedPlans: loadCachedPaymentPlansActor, + refreshPlans: refreshPaymentPlansActor, + createOrder: createPaymentOrderActor, + pollOrderStatus: pollPaymentOrderStatusActor, + }, + guards: { + canCreateOrder: ({ context }) => + context.agreed && context.selectedPlanId.length > 0, + }, +}); + +type PaymentActorAction = ActionFunction< + PaymentState, + TEvent, + PaymentEvent, + undefined, + never, + never, + never, + never, + never +>; + +export function createPaymentActorActionSetup() { + const actorActionSetup = setup({ + types: { + context: {} as PaymentState, + events: {} as TEvent, + }, + }); + return { + assign( + assignment: Parameters[0], + ): PaymentActorAction { + return actorActionSetup.assign(assignment) as unknown as PaymentActorAction; + }, + createAction( + action: Parameters[0], + ): PaymentActorAction { + return actorActionSetup.createAction(action) as unknown as PaymentActorAction; + }, + }; +} diff --git a/src/stores/payment/payment-machine.actors.ts b/src/stores/payment/payment-machine.actors.ts deleted file mode 100644 index d8b3bb05..00000000 --- a/src/stores/payment/payment-machine.actors.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Payment 状态机:Actors(异步服务) - */ -import { fromPromise } from "xstate"; - -import { - CreatePaymentOrderResponse, - PayChannel, - PaymentOrderStatusResponse, - PaymentPlansResponse, -} from "@/data/dto/payment"; -import { getPaymentRepository } from "@/data/repositories/payment_repository"; -import { Result } from "@/utils/result"; - -import type { PaymentPlanCatalog } from "./payment-state"; - -export const loadCachedPaymentPlansActor = - fromPromise< - PaymentPlansResponse | null, - { catalog: PaymentPlanCatalog } - >(async ({ input }) => { - if (input.catalog === "tip") return null; - const paymentRepo = getPaymentRepository(); - const result = await paymentRepo.getCachedPlans(); - if (Result.isErr(result)) throw result.error; - return result.data; - }); - -export const refreshPaymentPlansActor = fromPromise< - PaymentPlansResponse, - { catalog: PaymentPlanCatalog } ->( - async ({ input }) => { - const paymentRepo = getPaymentRepository(); - const result = - input.catalog === "tip" - ? await paymentRepo.getTipPlans() - : await paymentRepo.getPlans(); - if (Result.isErr(result)) throw result.error; - return result.data; - }, -); - -export const createPaymentOrderActor = fromPromise< - CreatePaymentOrderResponse, - { planId: string; payChannel: PayChannel; autoRenew: boolean } ->(async ({ input }) => { - const paymentRepo = getPaymentRepository(); - const result = await paymentRepo.createOrder( - input.planId, - input.payChannel, - input.autoRenew, - ); - if (Result.isErr(result)) throw result.error; - return result.data; -}); - -export const pollPaymentOrderStatusActor = fromPromise< - PaymentOrderStatusResponse, - { orderId: string } ->(async ({ input }) => { - const paymentRepo = getPaymentRepository(); - const result = await paymentRepo.getOrderStatus(input.orderId); - if (Result.isErr(result)) throw result.error; - return result.data; -}); diff --git a/src/stores/payment/payment-machine.ts b/src/stores/payment/payment-machine.ts index dfbe801a..9c975311 100644 --- a/src/stores/payment/payment-machine.ts +++ b/src/stores/payment/payment-machine.ts @@ -1,457 +1,17 @@ -/** - * Payment 状态机(XState v5) - */ -import { assign, setup } from "xstate"; - -import { behaviorAnalytics } from "@/lib/analytics"; -import type { PaymentEvent } from "./payment-events"; -import { initialState, type PaymentState } from "./payment-state"; import { - createPaymentOrderActor, - loadCachedPaymentPlansActor, - pollPaymentOrderStatusActor, - refreshPaymentPlansActor, -} from "./payment-machine.actors"; -import { - hasOrderPollingTimedOut, - consumeFirstRechargeState, - hydratePlansResponseState, - PAYMENT_TIMEOUT_ERROR_MESSAGE, - refreshPlansResponseState, - resetOrderState, - selectPlanState, - toPaymentErrorMessage, -} from "./payment-machine.helpers"; + paymentMachineSetup, + paymentRootStateConfig, +} from "./machine/order-flow"; +import { initialState } from "./payment-state"; export type { PaymentEvent } from "./payment-events"; export type { PaymentState } from "./payment-state"; export { initialState } from "./payment-state"; -const POLL_DELAY_MS = 4000; - -function getSelectedPlan(context: PaymentState) { - return context.plans.find( - (plan) => plan.planId === context.selectedPlanId, - ); -} - -function getCurrentOrderPlan(context: PaymentState) { - if (!context.currentOrderPlanId) return undefined; - return context.plans.find( - (plan) => plan.planId === context.currentOrderPlanId, - ); -} - -export const paymentMachine = setup({ - types: { - context: {} as PaymentState, - events: {} as PaymentEvent, - }, - actors: { - loadCachedPlans: loadCachedPaymentPlansActor, - refreshPlans: refreshPaymentPlansActor, - createOrder: createPaymentOrderActor, - pollOrderStatus: pollPaymentOrderStatusActor, - }, -}).createMachine({ - /** @xstate-layout N4IgpgJg5mDOIC5QAcCGBPAtmAdgFwGIAFDbfAGVQFccBjACwDFUBLAG0gG0AGAXURQB7WCzwtBOASAAeiACwAmADQh0iAIwA2bnIB0CuQE5j67t20AOBQF9rKtFlx5dLCB2KknASRyie-JBBkYVFxSUDZBAVDPUMFdQs5dXUAVk0Adk0AZhSUlTUEdUMLXXV0w00LCyzzc00U23tPfBc3MA9HfAAlMAAzACc4egA1FmQAZTxUPCpYfylgkTEJKUjo2PjE5LTMnLzVRBTq3R1iuRTdzWz0xqDm5zZBVAgWHCgiNlQcWAIICTAXDgAG6CADWAIcZAeTxebw+X1gCFeINo0zC-nmgUWoRWEQ0WQsKV0lXSSQuFk0MUM6XyiAs6l0RmMxXSWS0FIUNjsd060Oer3en2+BDA-X6gn6umQnzwvQlmCl910j35cKFiORglRyxwGL4CxCOtW+MJxIspNS6QpVJpB0KpMZKTZBiSWXiMQst0hTl0g2e6A6UJ8fn1WMNYWNhRShiyujOCiq5yOSXUtKihm4jOilTkbKO6Ru3O9LT9EADJF58Jw4zAHFoeC4oaESwjeMKhlMunS3BqCiycm4VviWTTpnSCn02iycSdigMmi9StL5fuFYAwvQvjhaxuvjAIJjmzjwqBIgkrPoe9wFNxUhk5HI09TNLoauOMtxzRYO4ZF7zfWA-qBk4ACCVB4IIPTbgA7rubyNgER5Gm256xpocjVDkCgXNoCiaGmVSZjkSRJOhqTRlyTT-suwH4CBUCDGAUJwfuh5BOGuKnhoVREnhuTxCkWg4WmVp6AOphaEUKScvUf5QgBQEVsxfoNgA8v0ECiuMVAAEaYKIDYHk27EtpxMiIJyRRxuY6iKPScQ1I+drJOOXbqFh6RHLeTpyT6NFKU4a4qWA6maf02l6QZXDqIhJnHpGCQJG5FLnBUeHlPhdqVCUD5WhmpJVDGnpFkugFlrReA9AMQyjBMUwzHMxnYshXGFEYmZ8YJGbnOkWjKHa-aZnEFRWPEZR9b5JZlSuvIAKJihKa4cKggxGbFzWtq1RTqBOBjFISBYKFamRphRjIxJkTrXmkySTc4tAqQKoWir8-yAiC4KKv+D2AWIbzPf0SLAlqaISHq60cSe5lRCRr47VkGQUgS2SjtGeiksyn6JvSDQld9j3-RpL2iuKkrStMcr9Aqxb3QTUAA0DKKg7qfBsRtZlrGSxJpNwHb0vehijm+XYjfUD7xBhxVUfJwRsGwT1E-0r3bu9YIQkqsvy4TYWMyDOrgwaplQ2sMRxpsKY7NkuRptksYpEYiQI9GKTXoW0s+prCthcrAKap9NNSoIcte6KuvauirMxYb8VtusZsJBbGRW-sBTuTojJyJU1xuuh0Z3YHwfay9fwq376v-p7ReA5q4dg6zCgQ0bkZx3ECfbEnexPjUjKsjGmimAOWT9vnlf04rIoLWTMqU9TGtB1rY86zXzMG2GTex6brdbHeuzW85Y3nVkrIWNwgnJHh+fQawf1QIwEoBfgFU9LAYB4GzkORhSmZWjtO3GAO9QLBPj7K+DM-dM4fmqFLHk8kr6hDeHffoD9CDSFgPVAEqBegNn6AACgHGYAAlAQAOcCb6IOQe-derUjB6G-KfeoToMhD3OARC81JpxH2pNEGMbsYE+gYGAWgoIBS1UmNMWYPtVb+yVAIoRIixhiIamHFerMmofzbF-Ls9J4jcIAUcESHYTjTnoaYMwpJeEB1kcIt4oj6oSJLr7YG0jvr0EEdYqAtjxEamBrXFmvBOBRzXjHVqmif46P-uYfRdpM4MgyCNbYFQcgWJka4uRNiFF2J+CTCUUpp7yi+vJKx8i6peOUfrVRjdgnQ1Cdov+MRIlAKysUE4hIYw6ASPbWy+cinpJKQ1CepNckU3yZY1J7jPFKOXuU-xgSkKbWqdoLRv9dENNOvbYkboT45mSJSEerAIBPzgK-ShVSzwEiJCSMkVpKRGFtAUAshg4y2VPkUXMHZPJ7NcIc6qsARgZK8Sclq0MOwvk6kcXIMRNg2zkOkXQ4KYzYU5N2Ps+deisA4Ac5BQVfohUVhFfSeBDKAvmWc00lzLTWluaOB8jyCz9gTAyjsaRUXosgBVeapMlqAVWsSjmJoLnmiuZS6ko46i6ApL1OlGZsLXhZewNlyDn7HLUVQ6G7kyWCopTckVzk+ripvL2aogDoi2G5DgQQml4BYnuNHIFkQAC0mUCj2qJMyN17qMyn3zq4DgtqSXcVdZsPMmdFAJBHHaC4sL3JskMNGdp05fx43kiqWEgoER+r5YUMotKEiVHtrsQko5PKlAyjGfsmF7z52XBm42GhqQMm7L2TkVhpyslHGUWMw5BLlBPrkVk3S6YAxrc3fusKeE5DiZ61Mzk0hEhzmkct15eaUT4S0UeQ6gl2vkP1VON49BulJGYG6R7L7XwFOQm1m7-UIE0HhOG84Bx7quE6xAMQXyjp0e+bQPZuljOKYo2Yw62z9xKPUW95hEnfkaQUfu762naLiKyDInyIBAa2jeF8v9rzfmMAJHddIeylAATZKSJI5UYrQ2qvsRIew7JqIlKoQs70dqSCfbC34CymusEAA */ +export const paymentMachine = paymentMachineSetup.createMachine({ id: "payment", - initial: "idle", context: initialState, - states: { - idle: { - on: { - PaymentInit: { - target: "loadingCachedPlans", - actions: assign(({ context, event }) => ({ - planCatalog: event.catalog ?? context.planCatalog, - ...(event.payChannel ? { payChannel: event.payChannel } : {}), - })), - }, - }, - }, - - loadingCachedPlans: { - invoke: { - src: "loadCachedPlans", - input: ({ context }) => ({ catalog: context.planCatalog }), - onDone: [ - { - guard: ({ event }) => (event.output?.plans.length ?? 0) > 0, - target: "refreshingPlans", - actions: assign(({ context, event }) => { - if (!event.output) return {}; - return hydratePlansResponseState( - event.output, - context.selectedPlanId, - ); - }), - }, - { - target: "loadingPlans", - }, - ], - onError: { - target: "loadingPlans", - }, - }, - }, - - loadingPlans: { - invoke: { - src: "refreshPlans", - input: ({ context }) => ({ catalog: context.planCatalog }), - onDone: { - target: "ready", - actions: assign(({ context, event }) => { - return hydratePlansResponseState( - event.output, - context.selectedPlanId, - ); - }), - }, - onError: { - target: "ready", - actions: assign(({ event }) => ({ - errorMessage: toPaymentErrorMessage(event.error), - })), - }, - }, - }, - - refreshingPlans: { - invoke: { - src: "refreshPlans", - input: ({ context }) => ({ catalog: context.planCatalog }), - onDone: { - target: "ready", - actions: assign(({ context, event }) => { - return refreshPlansResponseState( - event.output, - context.selectedPlanId, - ); - }), - }, - onError: { - target: "ready", - actions: assign(({ event }) => ({ - errorMessage: toPaymentErrorMessage(event.error), - })), - }, - }, - }, - - ready: { - on: { - PaymentInit: { - target: "refreshingPlans", - actions: assign(({ context, event }) => { - const planCatalog = event.catalog ?? context.planCatalog; - const catalogChanged = planCatalog !== context.planCatalog; - return { - planCatalog, - ...(event.payChannel ? { payChannel: event.payChannel } : {}), - ...(catalogChanged - ? { - plans: [], - selectedPlanId: "", - isFirstRecharge: false, - autoRenew: planCatalog !== "tip", - } - : {}), - }; - }), - }, - PaymentPlanSelected: { - actions: assign(({ context, event }) => ({ - ...selectPlanState(event.planId, context.plans), - })), - }, - PaymentPayChannelChanged: { - actions: assign(({ event }) => ({ - ...resetOrderState(), - payChannel: event.payChannel, - errorMessage: null, - })), - }, - PaymentAutoRenewChanged: { - actions: assign(({ event }) => ({ - autoRenew: event.autoRenew, - errorMessage: null, - })), - }, - PaymentAgreementChanged: { - actions: assign(({ event }) => ({ - agreed: event.agreed, - errorMessage: null, - })), - }, - PaymentCreateOrderSubmitted: [ - { - guard: ({ context }) => - context.agreed && context.selectedPlanId.length > 0, - target: "creatingOrder", - }, - { - actions: assign({ - errorMessage: - "Choose a plan and accept the agreement before continuing.", - }), - }, - ], - PaymentErrorCleared: { - actions: assign({ errorMessage: null }), - }, - }, - }, - - creatingOrder: { - entry: ({ context }) => { - const plan = getSelectedPlan(context); - if (plan) behaviorAnalytics.createOrderStart(plan, context.payChannel); - }, - invoke: { - src: "createOrder", - input: ({ context }) => ({ - planId: context.selectedPlanId, - payChannel: context.payChannel, - autoRenew: context.autoRenew, - }), - onDone: { - target: "pollingOrder", - actions: [ - ({ context, event }) => { - const plan = getSelectedPlan(context); - if (plan) { - behaviorAnalytics.createOrderSuccess( - plan, - event.output.orderId, - context.payChannel, - ); - } - behaviorAnalytics.statusPollStart( - event.output.orderId, - plan, - ); - }, - assign(({ context, event }) => ({ - currentOrderId: event.output.orderId, - currentOrderPlanId: context.selectedPlanId, - payParams: event.output.payParams, - orderStatus: "pending", - orderPollingStartedAt: Date.now(), - errorMessage: null, - launchNonce: context.launchNonce + 1, - })), - ], - }, - onError: { - target: "failed", - actions: [ - ({ context }) => { - const plan = getSelectedPlan(context); - if (plan) { - behaviorAnalytics.createOrderFailed(plan, context.payChannel); - } - }, - assign(({ event }) => ({ - errorMessage: toPaymentErrorMessage(event.error), - })), - ], - }, - }, - }, - - pollingOrder: { - invoke: { - src: "pollOrderStatus", - input: ({ context }) => ({ - orderId: context.currentOrderId ?? "", - }), - onDone: [ - { - guard: ({ event }) => event.output.status === "paid", - target: "paid", - actions: [ - ({ context, event }) => - behaviorAnalytics.statusPollUpdate( - context.currentOrderId ?? "", - event.output.status, - getCurrentOrderPlan(context), - ), - assign(({ event }) => ({ - orderStatus: event.output.status, - orderPollingStartedAt: null, - errorMessage: null, - })), - ], - }, - { - guard: ({ event }) => event.output.status === "failed", - target: "failed", - actions: [ - ({ context, event }) => - behaviorAnalytics.statusPollUpdate( - context.currentOrderId ?? "", - event.output.status, - getCurrentOrderPlan(context), - ), - assign(({ event }) => ({ - orderStatus: event.output.status, - orderPollingStartedAt: null, - errorMessage: "Payment failed or was cancelled.", - })), - ], - }, - { - guard: ({ context }) => - hasOrderPollingTimedOut(context.orderPollingStartedAt), - target: "failed", - actions: [ - ({ context, event }) => { - const orderId = context.currentOrderId ?? ""; - const plan = getCurrentOrderPlan(context); - behaviorAnalytics.statusPollUpdate( - orderId, - event.output.status, - plan, - ); - behaviorAnalytics.statusPollTimeout(orderId, plan); - }, - assign({ - orderStatus: "failed", - orderPollingStartedAt: null, - errorMessage: PAYMENT_TIMEOUT_ERROR_MESSAGE, - }), - ], - }, - { - target: "waitingForPayment", - actions: [ - ({ context, event }) => - behaviorAnalytics.statusPollUpdate( - context.currentOrderId ?? "", - event.output.status, - getCurrentOrderPlan(context), - ), - assign(({ event }) => ({ - orderStatus: event.output.status, - errorMessage: null, - })), - ], - }, - ], - onError: { - target: "failed", - actions: assign(({ event }) => ({ - errorMessage: toPaymentErrorMessage(event.error), - })), - }, - }, - on: { - PaymentReset: { - target: "ready", - actions: assign(resetOrderState), - }, - }, - }, - - waitingForPayment: { - after: { - [POLL_DELAY_MS]: "pollingOrder", - }, - on: { - PaymentReset: { - target: "ready", - actions: assign(resetOrderState), - }, - }, - }, - - paid: { - on: { - PaymentPlanSelected: { - target: "ready", - actions: assign(({ context, event }) => ({ - ...selectPlanState(event.planId, context.plans), - })), - }, - PaymentPayChannelChanged: { - target: "ready", - actions: assign(({ event }) => ({ - ...resetOrderState(), - payChannel: event.payChannel, - errorMessage: null, - })), - }, - PaymentCreateOrderSubmitted: [ - { - guard: ({ context }) => - context.agreed && context.selectedPlanId.length > 0, - target: "creatingOrder", - actions: assign(resetOrderState), - }, - { - target: "ready", - actions: assign({ - errorMessage: - "Choose a plan and accept the agreement before continuing.", - }), - }, - ], - PaymentReset: { - target: "ready", - actions: assign(resetOrderState), - }, - }, - }, - - failed: { - on: { - PaymentCreateOrderSubmitted: [ - { - guard: ({ context }) => - context.agreed && context.selectedPlanId.length > 0, - target: "creatingOrder", - actions: assign(resetOrderState), - }, - ], - PaymentErrorCleared: { - target: "ready", - actions: assign({ errorMessage: null }), - }, - PaymentReset: { - target: "ready", - actions: assign(resetOrderState), - }, - }, - }, - }, - on: { - PaymentFirstRechargeConsumed: { - actions: assign(({ context }) => consumeFirstRechargeState(context)), - }, - PaymentReturned: { - target: ".pollingOrder", - actions: [ - ({ event }) => behaviorAnalytics.statusPollStart(event.orderId), - assign(({ event }) => ({ - currentOrderId: event.orderId, - currentOrderPlanId: null, - payParams: null, - orderStatus: "pending", - orderPollingStartedAt: event.createdAt ?? Date.now(), - errorMessage: null, - })), - ], - }, - PaymentLaunchFailed: { - target: ".failed", - actions: assign(({ event }) => ({ - errorMessage: event.errorMessage, - })), - }, - }, + ...paymentRootStateConfig, }); export type PaymentMachine = typeof paymentMachine; diff --git a/src/stores/private-room/__tests__/private-room-album-flow.test.ts b/src/stores/private-room/__tests__/private-room-album-flow.test.ts new file mode 100644 index 00000000..dac51270 --- /dev/null +++ b/src/stores/private-room/__tests__/private-room-album-flow.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { createActor, waitFor } from "xstate"; + +import { + createTestPrivateRoomMachine, + loadPrivateRoom, + makeAlbum, + makeAlbumsResponse, +} from "./private-room-machine.test-utils"; + +describe("private room album flow", () => { + it("loads only the first non-paginated album page", async () => { + const actor = await loadPrivateRoom({ + loadResponse: makeAlbumsResponse({ + items: Array.from({ length: 20 }, (_, index) => makeAlbum(index)), + }), + }); + expect(actor.getSnapshot().context.items).toHaveLength(20); + expect(actor.getSnapshot().context).not.toHaveProperty("hasMore"); + expect(actor.getSnapshot().context).not.toHaveProperty("nextCursor"); + actor.stop(); + }); + + it("moves to failed when the album actor rejects", async () => { + const actor = createActor( + createTestPrivateRoomMachine({ + loadError: new Error("albums unavailable"), + }), + ).start(); + actor.send({ type: "PrivateRoomInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("failed")); + expect(actor.getSnapshot().context.errorMessage).toBe( + "albums unavailable", + ); + actor.stop(); + }); +}); diff --git a/src/stores/private-room/__tests__/private-room-machine.test-utils.ts b/src/stores/private-room/__tests__/private-room-machine.test-utils.ts new file mode 100644 index 00000000..9ccd2525 --- /dev/null +++ b/src/stores/private-room/__tests__/private-room-machine.test-utils.ts @@ -0,0 +1,100 @@ +import { createActor, fromPromise, waitFor } from "xstate"; + +import { + PrivateAlbumsResponse, + PrivateAlbumUnlockResponse, +} from "@/data/dto/private-room"; +import { privateRoomMachine } from "@/stores/private-room/private-room-machine"; + +export const ALBUM_ID = "a1b2c3d4-0000-0000-0000-000000000000"; +export const COVER_URL = + "https://dbapi.banlv-ai.com/storage/v1/object/public/elio-schedules/01.jpg"; + +export function makeAlbum(index = 0) { + return { + albumId: index === 0 ? ALBUM_ID : `album-${index}`, + title: `Private album ${index + 1}`, + content: null, + previewText: "Only for you.", + imageCount: 8, + images: [{ url: `${COVER_URL}?album=${index}`, locked: true, index: 0 }], + locked: true, + unlocked: false, + unlockCost: 320, + publishedAt: "2026-07-13T00:00:00+00:00", + lockDetail: { locked: true }, + }; +} + +export function makeAlbumsResponse( + overrides: Partial[0]> = {}, +): PrivateAlbumsResponse { + return PrivateAlbumsResponse.from({ + items: [makeAlbum()], + creditBalance: 500, + ...overrides, + }); +} + +export function makeUnlockResponse( + overrides: Partial[0]> = {}, +): PrivateAlbumUnlockResponse { + return PrivateAlbumUnlockResponse.from({ + albumId: ALBUM_ID, + locked: false, + unlocked: true, + reason: "ok", + unlockCost: 320, + requiredCredits: 320, + creditBalance: 180, + shortfallCredits: 0, + images: Array.from({ length: 8 }, (_, index) => ({ + url: `${COVER_URL}?image=${index}`, + locked: false, + index, + })), + ...overrides, + }); +} + +export function createTestPrivateRoomMachine(options: { + loadResponse?: PrivateAlbumsResponse; + unlockResponse?: PrivateAlbumUnlockResponse; + loadError?: Error; + unlockError?: Error; + onLoad?: () => void; +} = {}) { + return privateRoomMachine.provide({ + actors: { + loadAlbums: fromPromise(async () => { + options.onLoad?.(); + if (options.loadError) throw options.loadError; + return options.loadResponse ?? makeAlbumsResponse(); + }), + unlockAlbum: fromPromise(async () => { + if (options.unlockError) throw options.unlockError; + return options.unlockResponse ?? makeUnlockResponse(); + }), + }, + }); +} + +export async function loadPrivateRoom( + options: Parameters[0] = {}, +) { + const actor = createActor(createTestPrivateRoomMachine(options)).start(); + actor.send({ type: "PrivateRoomInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + return actor; +} + +export async function unlockFirstAlbum( + actor: Awaited>, +) { + actor.send({ + type: "PrivateRoomUnlockRequested", + albumId: ALBUM_ID, + }); + actor.send({ type: "PrivateRoomUnlockConfirmed" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); +} diff --git a/src/stores/private-room/__tests__/private-room-machine.test.ts b/src/stores/private-room/__tests__/private-room-machine.test.ts deleted file mode 100644 index c5cce261..00000000 --- a/src/stores/private-room/__tests__/private-room-machine.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createActor, fromPromise, waitFor } from "xstate"; - -import { - PrivateAlbumsResponse, - PrivateAlbumUnlockResponse, -} from "@/data/dto/private-room"; -import { privateRoomMachine } from "@/stores/private-room/private-room-machine"; - -const ALBUM_ID = "a1b2c3d4-0000-0000-0000-000000000000"; -const COVER_URL = - "https://dbapi.banlv-ai.com/storage/v1/object/public/elio-schedules/01.jpg"; - -function makeAlbum(index = 0) { - return { - albumId: index === 0 ? ALBUM_ID : `album-${index}`, - title: `Private album ${index + 1}`, - content: null, - previewText: "Only for you.", - imageCount: 8, - images: [{ url: `${COVER_URL}?album=${index}`, locked: true, index: 0 }], - locked: true, - unlocked: false, - unlockCost: 320, - publishedAt: "2026-07-13T00:00:00+00:00", - lockDetail: { locked: true }, - }; -} - -function makeAlbumsResponse( - overrides: Partial[0]> = {}, -): PrivateAlbumsResponse { - return PrivateAlbumsResponse.from({ - items: [makeAlbum()], - creditBalance: 500, - ...overrides, - }); -} - -function makeUnlockResponse( - overrides: Partial[0]> = {}, -): PrivateAlbumUnlockResponse { - return PrivateAlbumUnlockResponse.from({ - albumId: ALBUM_ID, - locked: false, - unlocked: true, - reason: "ok", - unlockCost: 320, - requiredCredits: 320, - creditBalance: 180, - shortfallCredits: 0, - images: Array.from({ length: 8 }, (_, index) => ({ - url: `${COVER_URL}?image=${index}`, - locked: false, - index, - })), - ...overrides, - }); -} - -function createTestMachine(options: { - loadResponse?: PrivateAlbumsResponse; - unlockResponse?: PrivateAlbumUnlockResponse; - onLoad?: () => void; -} = {}) { - return privateRoomMachine.provide({ - actors: { - loadAlbums: fromPromise(async () => { - options.onLoad?.(); - return options.loadResponse ?? makeAlbumsResponse(); - }), - unlockAlbum: fromPromise(async () => - (options.unlockResponse ?? makeUnlockResponse()), - ), - }, - }); -} - -async function loadMachine(options: Parameters[0] = {}) { - const actor = createActor(createTestMachine(options)).start(); - actor.send({ type: "PrivateRoomInit" }); - await waitFor(actor, (snapshot) => snapshot.matches("ready")); - return actor; -} - -async function unlockFirstAlbum( - actor: Awaited>, -) { - actor.send({ - type: "PrivateRoomUnlockRequested", - albumId: ALBUM_ID, - }); - actor.send({ type: "PrivateRoomUnlockConfirmed" }); - await waitFor(actor, (snapshot) => snapshot.matches("ready")); -} - -describe("privateRoomMachine", () => { - it("loads only the first album page into its non-paginated state", async () => { - const actor = await loadMachine({ - loadResponse: makeAlbumsResponse({ - items: Array.from({ length: 20 }, (_, index) => makeAlbum(index)), - }), - }); - const context = actor.getSnapshot().context; - - expect(context.items).toHaveLength(20); - expect(context.creditBalance).toBe(500); - expect(context).not.toHaveProperty("hasMore"); - expect(context).not.toHaveProperty("nextCursor"); - actor.stop(); - }); - - it("patches the album images after a successful unlock", async () => { - const actor = await loadMachine(); - await unlockFirstAlbum(actor); - const context = actor.getSnapshot().context; - - expect(context.items[0]).toMatchObject({ - albumId: ALBUM_ID, - locked: false, - unlocked: true, - title: "Private album 1", - }); - expect(context.items[0]?.images).toHaveLength(8); - expect(context.items[0]?.lockDetail).toEqual({ locked: false }); - expect(context.unlockSuccessNonce).toBe(1); - actor.stop(); - }); - - it("keeps the album locked and exposes a paywall request when credits are low", async () => { - const actor = await loadMachine({ - unlockResponse: makeUnlockResponse({ - locked: true, - unlocked: false, - reason: "insufficient_credits", - creditBalance: 100, - shortfallCredits: 220, - images: [{ url: COVER_URL, locked: true, index: 0 }], - }), - }); - await unlockFirstAlbum(actor); - const context = actor.getSnapshot().context; - - expect(context.items[0]?.locked).toBe(true); - expect(context.unlockPaywallRequest).toEqual({ - albumId: ALBUM_ID, - reason: "insufficient_credits", - requiredCredits: 320, - currentCredits: 100, - shortfallCredits: 220, - }); - actor.stop(); - }); - - it("reloads the first page when the expected cost changed", async () => { - let loadCount = 0; - const actor = await loadMachine({ - onLoad: () => { - loadCount += 1; - }, - unlockResponse: makeUnlockResponse({ - locked: true, - unlocked: false, - reason: "cost_changed", - }), - }); - await unlockFirstAlbum(actor); - - expect(loadCount).toBe(2); - expect(actor.getSnapshot().context.unlockErrorMessage).toBe( - "Unlock price changed. Please confirm again.", - ); - actor.stop(); - }); - - it("removes an album that no longer exists", async () => { - const actor = await loadMachine({ - unlockResponse: makeUnlockResponse({ - locked: true, - unlocked: false, - reason: "not_found", - images: [], - }), - }); - await unlockFirstAlbum(actor); - - expect(actor.getSnapshot().context.items).toHaveLength(0); - actor.stop(); - }); - - it("keeps a refunded persistence failure locked and updates the balance", async () => { - const actor = await loadMachine({ - unlockResponse: makeUnlockResponse({ - locked: true, - unlocked: false, - reason: "persist_failed_refunded", - creditBalance: 500, - images: [{ url: COVER_URL, locked: true, index: 0 }], - }), - }); - await unlockFirstAlbum(actor); - const context = actor.getSnapshot().context; - - expect(context.items[0]?.locked).toBe(true); - expect(context.creditBalance).toBe(500); - expect(context.unlockErrorMessage).toContain("refunded"); - actor.stop(); - }); -}); diff --git a/src/stores/private-room/__tests__/private-room-unlock-flow.test.ts b/src/stores/private-room/__tests__/private-room-unlock-flow.test.ts new file mode 100644 index 00000000..f52f0de8 --- /dev/null +++ b/src/stores/private-room/__tests__/private-room-unlock-flow.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; + +import { + ALBUM_ID, + COVER_URL, + loadPrivateRoom, + makeUnlockResponse, + unlockFirstAlbum, +} from "./private-room-machine.test-utils"; + +describe("private room unlock flow", () => { + it("patches album images after a successful unlock", async () => { + const actor = await loadPrivateRoom(); + await unlockFirstAlbum(actor); + expect(actor.getSnapshot().context.items[0]).toMatchObject({ + albumId: ALBUM_ID, + locked: false, + unlocked: true, + lockDetail: { locked: false }, + }); + expect(actor.getSnapshot().context.items[0]?.images).toHaveLength(8); + expect(actor.getSnapshot().context.unlockSuccessNonce).toBe(1); + actor.stop(); + }); + + it("exposes a paywall request when credits are low", async () => { + const actor = await loadPrivateRoom({ + unlockResponse: makeUnlockResponse({ + locked: true, + unlocked: false, + reason: "insufficient_credits", + creditBalance: 100, + shortfallCredits: 220, + images: [{ url: COVER_URL, locked: true, index: 0 }], + }), + }); + await unlockFirstAlbum(actor); + expect(actor.getSnapshot().context.unlockPaywallRequest).toEqual({ + albumId: ALBUM_ID, + reason: "insufficient_credits", + requiredCredits: 320, + currentCredits: 100, + shortfallCredits: 220, + }); + actor.stop(); + }); + + it("reloads when expected cost changes", async () => { + let loadCount = 0; + const actor = await loadPrivateRoom({ + onLoad: () => { + loadCount += 1; + }, + unlockResponse: makeUnlockResponse({ + locked: true, + unlocked: false, + reason: "cost_changed", + }), + }); + await unlockFirstAlbum(actor); + expect(loadCount).toBe(2); + expect(actor.getSnapshot().context.unlockErrorMessage).toBe( + "Unlock price changed. Please confirm again.", + ); + actor.stop(); + }); + + it("removes an album that no longer exists", async () => { + const actor = await loadPrivateRoom({ + unlockResponse: makeUnlockResponse({ + locked: true, + unlocked: false, + reason: "not_found", + images: [], + }), + }); + await unlockFirstAlbum(actor); + expect(actor.getSnapshot().context.items).toHaveLength(0); + actor.stop(); + }); + + it("keeps refunded persistence failures locked", async () => { + const actor = await loadPrivateRoom({ + unlockResponse: makeUnlockResponse({ + locked: true, + unlocked: false, + reason: "persist_failed_refunded", + creditBalance: 500, + images: [{ url: COVER_URL, locked: true, index: 0 }], + }), + }); + await unlockFirstAlbum(actor); + expect(actor.getSnapshot().context.items[0]?.locked).toBe(true); + expect(actor.getSnapshot().context.creditBalance).toBe(500); + expect(actor.getSnapshot().context.unlockErrorMessage).toContain( + "refunded", + ); + actor.stop(); + }); + + it("returns to ready when the unlock actor rejects", async () => { + const actor = await loadPrivateRoom({ + unlockError: new Error("unlock unavailable"), + }); + await unlockFirstAlbum(actor); + expect(actor.getSnapshot().context.unlockErrorMessage).toBe( + "unlock unavailable", + ); + actor.stop(); + }); +}); diff --git a/src/stores/private-room/helper/albums.ts b/src/stores/private-room/helper/albums.ts new file mode 100644 index 00000000..8553c9c3 --- /dev/null +++ b/src/stores/private-room/helper/albums.ts @@ -0,0 +1,63 @@ +import { + PrivateAlbum, + type PrivateAlbumsResponse, + type PrivateAlbumUnlockResponse, +} from "@/data/dto/private-room"; + +import type { PrivateRoomState } from "../private-room-state"; + +export const PRIVATE_ROOM_CHARACTER = "elio"; +export const PRIVATE_ROOM_PAGE_SIZE = 20; + +export function applyAlbumsResponse( + response: PrivateAlbumsResponse, +): Partial { + return { + items: response.items, + creditBalance: response.creditBalance, + errorMessage: null, + }; +} + +export function getPendingAlbum( + context: PrivateRoomState, +): PrivateAlbum | null { + if (!context.pendingConfirmAlbumId) return null; + return ( + context.items.find( + (album) => album.albumId === context.pendingConfirmAlbumId, + ) ?? null + ); +} + +export function patchAlbumFromUnlock( + album: PrivateAlbum, + response: PrivateAlbumUnlockResponse, +): PrivateAlbum { + return PrivateAlbum.from({ + ...album.toJson(), + locked: response.locked, + unlocked: response.unlocked, + unlockCost: response.unlockCost || album.unlockCost, + images: response.images.length > 0 ? response.images : album.images, + lockDetail: { locked: response.locked }, + }); +} + +export function patchAlbumInList( + items: readonly PrivateAlbum[], + response: PrivateAlbumUnlockResponse, +): PrivateAlbum[] { + return items.map((album) => + album.albumId === response.albumId + ? patchAlbumFromUnlock(album, response) + : album, + ); +} + +export function removeAlbum( + items: readonly PrivateAlbum[], + albumId: string, +): PrivateAlbum[] { + return items.filter((album) => album.albumId !== albumId); +} diff --git a/src/stores/private-room/helper/index.ts b/src/stores/private-room/helper/index.ts new file mode 100644 index 00000000..a99c2593 --- /dev/null +++ b/src/stores/private-room/helper/index.ts @@ -0,0 +1,2 @@ +export * from "./albums"; +export * from "./unlock"; diff --git a/src/stores/private-room/helper/unlock.ts b/src/stores/private-room/helper/unlock.ts new file mode 100644 index 00000000..3cd216a7 --- /dev/null +++ b/src/stores/private-room/helper/unlock.ts @@ -0,0 +1,41 @@ +import type { PrivateAlbumUnlockResponse } from "@/data/dto/private-room"; + +import type { PrivateRoomUnlockPaywallRequest } from "../private-room-state"; + +export function toPrivateRoomErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + return "Private room is temporarily unavailable. Please try again."; +} + +export function toUnlockErrorMessage( + response: PrivateAlbumUnlockResponse, +): string | null { + switch (response.reason) { + case "cost_changed": + return "Unlock price changed. Please confirm again."; + case "unlock_in_progress": + return "This album is already unlocking. Please try again shortly."; + case "deduct_failed": + return "Credit deduction failed. Please try again."; + case "persist_failed_refunded": + return "Unlock failed and your credits were refunded. Please try again."; + case "not_found": + return "This private album is no longer available."; + default: + return response.unlocked && !response.locked + ? null + : "Unlock failed. Please try again."; + } +} + +export function toPaywallRequest( + response: PrivateAlbumUnlockResponse, +): PrivateRoomUnlockPaywallRequest { + return { + albumId: response.albumId, + reason: response.reason, + requiredCredits: response.requiredCredits || response.unlockCost, + currentCredits: response.creditBalance, + shortfallCredits: response.shortfallCredits, + }; +} diff --git a/src/stores/private-room/index.ts b/src/stores/private-room/index.ts index a2001286..db435fc3 100644 --- a/src/stores/private-room/index.ts +++ b/src/stores/private-room/index.ts @@ -1,4 +1,7 @@ +/** @file Private Room store barrel. */ + export * from "./private-room-context"; export * from "./private-room-events"; +export * from "./helper"; export * from "./private-room-machine"; export * from "./private-room-state"; diff --git a/src/stores/private-room/private-room-machine.actors.ts b/src/stores/private-room/machine/actors/albums.ts similarity index 80% rename from src/stores/private-room/private-room-machine.actors.ts rename to src/stores/private-room/machine/actors/albums.ts index 12267acf..d5139786 100644 --- a/src/stores/private-room/private-room-machine.actors.ts +++ b/src/stores/private-room/machine/actors/albums.ts @@ -10,12 +10,11 @@ import { Result } from "@/utils/result"; import { PRIVATE_ROOM_CHARACTER, PRIVATE_ROOM_PAGE_SIZE, -} from "./private-room-machine.helpers"; +} from "../../helper/albums"; export const loadPrivateAlbumsActor = fromPromise(async () => { - const repo = getPrivateRoomRepository(); - const result = await repo.getAlbums({ + const result = await getPrivateRoomRepository().getAlbums({ character: PRIVATE_ROOM_CHARACTER, limit: PRIVATE_ROOM_PAGE_SIZE, }); @@ -27,8 +26,7 @@ export const unlockPrivateAlbumActor = fromPromise< PrivateAlbumUnlockResponse, { albumId: string; expectedCost: number } >(async ({ input }) => { - const repo = getPrivateRoomRepository(); - const result = await repo.unlockAlbum( + const result = await getPrivateRoomRepository().unlockAlbum( input.albumId, input.expectedCost, ); diff --git a/src/stores/private-room/machine/album-flow.ts b/src/stores/private-room/machine/album-flow.ts new file mode 100644 index 00000000..8553c0ef --- /dev/null +++ b/src/stores/private-room/machine/album-flow.ts @@ -0,0 +1,64 @@ +import type { DoneActorEvent, ErrorActorEvent } from "xstate"; + +import type { PrivateAlbumsResponse } from "@/data/dto/private-room"; + +import { applyAlbumsResponse } from "../helper/albums"; +import { toPrivateRoomErrorMessage } from "../helper/unlock"; +import { + basePrivateRoomMachineSetup, + createPrivateRoomActorActionSetup, +} from "./setup"; + +const clearAlbumLoadStateAction = basePrivateRoomMachineSetup.assign({ + errorMessage: null, + unlockPaywallRequest: null, +}); + +export const albumMachineSetup = basePrivateRoomMachineSetup.extend({ + actions: { + clearAlbumLoadState: clearAlbumLoadStateAction, + }, +}); + +const albumsDoneSetup = + createPrivateRoomActorActionSetup< + DoneActorEvent + >(); +const albumErrorSetup = + createPrivateRoomActorActionSetup(); + +const applyAlbumsResponseAction = albumsDoneSetup.assign(({ event }) => + applyAlbumsResponse(event.output), +); + +const applyAlbumLoadErrorAction = albumErrorSetup.assign(({ event }) => ({ + errorMessage: toPrivateRoomErrorMessage(event.error), +})); + +export const idleState = albumMachineSetup.createStateConfig({ + on: { + PrivateRoomInit: "loading", + }, +}); + +export const loadingState = albumMachineSetup.createStateConfig({ + entry: "clearAlbumLoadState", + invoke: { + src: "loadAlbums", + onDone: { + target: "ready", + actions: applyAlbumsResponseAction, + }, + onError: { + target: "failed", + actions: applyAlbumLoadErrorAction, + }, + }, +}); + +export const failedState = albumMachineSetup.createStateConfig({ + on: { + PrivateRoomInit: "loading", + PrivateRoomRefresh: "loading", + }, +}); diff --git a/src/stores/private-room/machine/setup.ts b/src/stores/private-room/machine/setup.ts new file mode 100644 index 00000000..d8c845b3 --- /dev/null +++ b/src/stores/private-room/machine/setup.ts @@ -0,0 +1,55 @@ +import { setup, type ActionFunction, type EventObject } from "xstate"; + +import { getPendingAlbum } from "../helper/albums"; +import type { PrivateRoomEvent } from "../private-room-events"; +import type { PrivateRoomState } from "../private-room-state"; +import { + loadPrivateAlbumsActor, + unlockPrivateAlbumActor, +} from "./actors/albums"; + +export const basePrivateRoomMachineSetup = setup({ + types: { + context: {} as PrivateRoomState, + events: {} as PrivateRoomEvent, + }, + actors: { + loadAlbums: loadPrivateAlbumsActor, + unlockAlbum: unlockPrivateAlbumActor, + }, + guards: { + hasPendingAlbum: ({ context }) => getPendingAlbum(context) !== null, + }, +}); + +type PrivateRoomActorAction = ActionFunction< + PrivateRoomState, + TEvent, + PrivateRoomEvent, + undefined, + never, + never, + never, + never, + never +>; + +export function createPrivateRoomActorActionSetup< + TEvent extends EventObject, +>() { + const actorActionSetup = setup({ + types: { + context: {} as PrivateRoomState, + events: {} as TEvent, + }, + }); + return { + assign( + assignment: Parameters[0], + ): PrivateRoomActorAction { + return actorActionSetup.assign( + assignment, + ) as unknown as PrivateRoomActorAction; + }, + }; +} diff --git a/src/stores/private-room/machine/unlock-flow.ts b/src/stores/private-room/machine/unlock-flow.ts new file mode 100644 index 00000000..ca58fd5d --- /dev/null +++ b/src/stores/private-room/machine/unlock-flow.ts @@ -0,0 +1,188 @@ +import type { DoneActorEvent, ErrorActorEvent } from "xstate"; + +import type { PrivateAlbumUnlockResponse } from "@/data/dto/private-room"; + +import { + getPendingAlbum, + patchAlbumInList, + removeAlbum, +} from "../helper/albums"; +import { + toPaywallRequest, + toPrivateRoomErrorMessage, + toUnlockErrorMessage, +} from "../helper/unlock"; +import { + albumMachineSetup, + failedState, + idleState, + loadingState, +} from "./album-flow"; +import { createPrivateRoomActorActionSetup } from "./setup"; + +const requestUnlockAction = albumMachineSetup.assign(({ event }) => + event.type === "PrivateRoomUnlockRequested" + ? { + pendingConfirmAlbumId: event.albumId, + unlockErrorMessage: null, + unlockPaywallRequest: null, + } + : {}, +); + +const cancelUnlockAction = albumMachineSetup.assign({ + pendingConfirmAlbumId: null, +}); + +const clearUnlockPaywallAction = albumMachineSetup.assign({ + unlockPaywallRequest: null, +}); + +const beginUnlockAction = albumMachineSetup.assign(({ context }) => ({ + unlockingAlbumId: context.pendingConfirmAlbumId, + unlockErrorMessage: null, + unlockPaywallRequest: null, +})); + +export const privateRoomMachineSetup = albumMachineSetup.extend({ + actions: { + requestUnlock: requestUnlockAction, + cancelUnlock: cancelUnlockAction, + clearUnlockPaywall: clearUnlockPaywallAction, + beginUnlock: beginUnlockAction, + }, +}); + +const unlockDoneSetup = + createPrivateRoomActorActionSetup< + DoneActorEvent + >(); +const unlockErrorSetup = + createPrivateRoomActorActionSetup(); + +const applyUnlockSuccessAction = unlockDoneSetup.assign( + ({ context, event }) => ({ + items: patchAlbumInList(context.items, event.output), + creditBalance: event.output.creditBalance, + pendingConfirmAlbumId: null, + unlockingAlbumId: null, + unlockErrorMessage: null, + unlockSuccessNonce: context.unlockSuccessNonce + 1, + }), +); + +const applyUnlockPaywallAction = unlockDoneSetup.assign( + ({ context, event }) => ({ + items: patchAlbumInList(context.items, event.output), + creditBalance: event.output.creditBalance, + pendingConfirmAlbumId: null, + unlockingAlbumId: null, + unlockErrorMessage: null, + unlockPaywallRequest: toPaywallRequest(event.output), + }), +); + +const applyCostChangedAction = unlockDoneSetup.assign(({ event }) => ({ + pendingConfirmAlbumId: null, + unlockingAlbumId: null, + unlockErrorMessage: toUnlockErrorMessage(event.output), +})); + +const applyAlbumNotFoundAction = unlockDoneSetup.assign( + ({ context, event }) => ({ + items: removeAlbum(context.items, event.output.albumId), + creditBalance: event.output.creditBalance, + pendingConfirmAlbumId: null, + unlockingAlbumId: null, + unlockErrorMessage: toUnlockErrorMessage(event.output), + }), +); + +const applyUnlockFailureAction = unlockDoneSetup.assign( + ({ context, event }) => ({ + items: patchAlbumInList(context.items, event.output), + creditBalance: event.output.creditBalance, + pendingConfirmAlbumId: null, + unlockingAlbumId: null, + unlockErrorMessage: toUnlockErrorMessage(event.output), + }), +); + +const applyUnlockActorErrorAction = unlockErrorSetup.assign(({ event }) => ({ + pendingConfirmAlbumId: null, + unlockingAlbumId: null, + unlockErrorMessage: toPrivateRoomErrorMessage(event.error), +})); + +const readyState = privateRoomMachineSetup.createStateConfig({ + on: { + PrivateRoomInit: "loading", + PrivateRoomRefresh: "loading", + PrivateRoomUnlockRequested: { actions: "requestUnlock" }, + PrivateRoomUnlockCancelled: { actions: "cancelUnlock" }, + PrivateRoomUnlockConfirmed: [ + { + guard: "hasPendingAlbum", + target: "unlocking", + }, + ], + PrivateRoomUnlockPaywallConsumed: { actions: "clearUnlockPaywall" }, + }, +}); + +const unlockingState = privateRoomMachineSetup.createStateConfig({ + entry: "beginUnlock", + invoke: { + src: "unlockAlbum", + input: ({ context }) => { + const pendingAlbum = getPendingAlbum(context); + return { + albumId: pendingAlbum?.albumId ?? "", + expectedCost: pendingAlbum?.unlockCost ?? 0, + }; + }, + onDone: [ + { + guard: ({ event }) => event.output.unlocked && !event.output.locked, + target: "ready", + actions: applyUnlockSuccessAction, + }, + { + guard: ({ event }) => + event.output.reason === "insufficient_credits", + target: "ready", + actions: applyUnlockPaywallAction, + }, + { + guard: ({ event }) => event.output.reason === "cost_changed", + target: "loading", + actions: applyCostChangedAction, + }, + { + guard: ({ event }) => event.output.reason === "not_found", + target: "ready", + actions: applyAlbumNotFoundAction, + }, + { + target: "ready", + actions: applyUnlockFailureAction, + }, + ], + onError: { + target: "ready", + actions: applyUnlockActorErrorAction, + }, + }, +}); + +export const privateRoomRootStateConfig = + privateRoomMachineSetup.createStateConfig({ + initial: "idle", + states: { + idle: idleState, + loading: loadingState, + failed: failedState, + ready: readyState, + unlocking: unlockingState, + }, + }); diff --git a/src/stores/private-room/private-room-machine.helpers.ts b/src/stores/private-room/private-room-machine.helpers.ts deleted file mode 100644 index ffd3ef03..00000000 --- a/src/stores/private-room/private-room-machine.helpers.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { - PrivateAlbum, - type PrivateAlbumsResponse, - type PrivateAlbumUnlockResponse, -} from "@/data/dto/private-room"; - -import type { - PrivateRoomState, - PrivateRoomUnlockPaywallRequest, -} from "./private-room-state"; - -export const PRIVATE_ROOM_CHARACTER = "elio"; -export const PRIVATE_ROOM_PAGE_SIZE = 20; - -export function applyAlbumsResponse( - response: PrivateAlbumsResponse, -): Partial { - return { - items: response.items, - creditBalance: response.creditBalance, - errorMessage: null, - }; -} - -export function patchAlbumFromUnlock( - album: PrivateAlbum, - response: PrivateAlbumUnlockResponse, -): PrivateAlbum { - return PrivateAlbum.from({ - ...album.toJson(), - locked: response.locked, - unlocked: response.unlocked, - unlockCost: response.unlockCost || album.unlockCost, - images: response.images.length > 0 ? response.images : album.images, - lockDetail: { locked: response.locked }, - }); -} - -export function patchAlbumInList( - items: readonly PrivateAlbum[], - response: PrivateAlbumUnlockResponse, -): PrivateAlbum[] { - return items.map((album) => - album.albumId === response.albumId - ? patchAlbumFromUnlock(album, response) - : album, - ); -} - -export function removeAlbum( - items: readonly PrivateAlbum[], - albumId: string, -): PrivateAlbum[] { - return items.filter((album) => album.albumId !== albumId); -} - -export function toPrivateRoomErrorMessage(error: unknown): string { - if (error instanceof Error && error.message) return error.message; - return "Private room is temporarily unavailable. Please try again."; -} - -export function toUnlockErrorMessage( - response: PrivateAlbumUnlockResponse, -): string | null { - switch (response.reason) { - case "cost_changed": - return "Unlock price changed. Please confirm again."; - case "unlock_in_progress": - return "This album is already unlocking. Please try again shortly."; - case "deduct_failed": - return "Credit deduction failed. Please try again."; - case "persist_failed_refunded": - return "Unlock failed and your credits were refunded. Please try again."; - case "not_found": - return "This private album is no longer available."; - default: - return response.unlocked && !response.locked - ? null - : "Unlock failed. Please try again."; - } -} - -export function toPaywallRequest( - response: PrivateAlbumUnlockResponse, -): PrivateRoomUnlockPaywallRequest { - return { - albumId: response.albumId, - reason: response.reason, - requiredCredits: response.requiredCredits || response.unlockCost, - currentCredits: response.creditBalance, - shortfallCredits: response.shortfallCredits, - }; -} diff --git a/src/stores/private-room/private-room-machine.ts b/src/stores/private-room/private-room-machine.ts index 255fb3a9..30791f13 100644 --- a/src/stores/private-room/private-room-machine.ts +++ b/src/stores/private-room/private-room-machine.ts @@ -1,192 +1,17 @@ -import { assign, setup } from "xstate"; - -import type { PrivateAlbum } from "@/data/dto/private-room"; - -import type { PrivateRoomEvent } from "./private-room-events"; import { - applyAlbumsResponse, - patchAlbumInList, - removeAlbum, - toPaywallRequest, - toPrivateRoomErrorMessage, - toUnlockErrorMessage, -} from "./private-room-machine.helpers"; -import { - loadPrivateAlbumsActor, - unlockPrivateAlbumActor, -} from "./private-room-machine.actors"; -import { initialState, type PrivateRoomState } from "./private-room-state"; + privateRoomMachineSetup, + privateRoomRootStateConfig, +} from "./machine/unlock-flow"; +import { initialState } from "./private-room-state"; export type { PrivateRoomEvent } from "./private-room-events"; export type { PrivateRoomState } from "./private-room-state"; export { initialState } from "./private-room-state"; -function getPendingAlbum(context: PrivateRoomState): PrivateAlbum | null { - if (!context.pendingConfirmAlbumId) return null; - return ( - context.items.find( - (album) => album.albumId === context.pendingConfirmAlbumId, - ) ?? null - ); -} - -export const privateRoomMachine = setup({ - types: { - context: {} as PrivateRoomState, - events: {} as PrivateRoomEvent, - }, - actors: { - loadAlbums: loadPrivateAlbumsActor, - unlockAlbum: unlockPrivateAlbumActor, - }, -}).createMachine({ +export const privateRoomMachine = privateRoomMachineSetup.createMachine({ id: "privateRoom", - initial: "idle", context: initialState, - states: { - idle: { - on: { - PrivateRoomInit: "loading", - }, - }, - - loading: { - entry: assign({ - errorMessage: null, - unlockPaywallRequest: null, - }), - invoke: { - src: "loadAlbums", - onDone: { - target: "ready", - actions: assign(({ event }) => applyAlbumsResponse(event.output)), - }, - onError: { - target: "failed", - actions: assign(({ event }) => ({ - errorMessage: toPrivateRoomErrorMessage(event.error), - })), - }, - }, - }, - - failed: { - on: { - PrivateRoomInit: "loading", - PrivateRoomRefresh: "loading", - }, - }, - - ready: { - on: { - PrivateRoomInit: "loading", - PrivateRoomRefresh: "loading", - PrivateRoomUnlockRequested: { - actions: assign(({ event }) => ({ - pendingConfirmAlbumId: event.albumId, - unlockErrorMessage: null, - unlockPaywallRequest: null, - })), - }, - PrivateRoomUnlockCancelled: { - actions: assign({ pendingConfirmAlbumId: null }), - }, - PrivateRoomUnlockConfirmed: [ - { - guard: ({ context }) => getPendingAlbum(context) !== null, - target: "unlocking", - }, - ], - PrivateRoomUnlockPaywallConsumed: { - actions: assign({ unlockPaywallRequest: null }), - }, - }, - }, - - unlocking: { - entry: assign(({ context }) => ({ - unlockingAlbumId: context.pendingConfirmAlbumId, - unlockErrorMessage: null, - unlockPaywallRequest: null, - })), - invoke: { - src: "unlockAlbum", - input: ({ context }) => { - const pendingAlbum = getPendingAlbum(context); - return { - albumId: pendingAlbum?.albumId ?? "", - expectedCost: pendingAlbum?.unlockCost ?? 0, - }; - }, - onDone: [ - { - guard: ({ event }) => - event.output.unlocked && !event.output.locked, - target: "ready", - actions: assign(({ context, event }) => ({ - items: patchAlbumInList(context.items, event.output), - creditBalance: event.output.creditBalance, - pendingConfirmAlbumId: null, - unlockingAlbumId: null, - unlockErrorMessage: null, - unlockSuccessNonce: context.unlockSuccessNonce + 1, - })), - }, - { - guard: ({ event }) => - event.output.reason === "insufficient_credits", - target: "ready", - actions: assign(({ context, event }) => ({ - items: patchAlbumInList(context.items, event.output), - creditBalance: event.output.creditBalance, - pendingConfirmAlbumId: null, - unlockingAlbumId: null, - unlockErrorMessage: null, - unlockPaywallRequest: toPaywallRequest(event.output), - })), - }, - { - guard: ({ event }) => event.output.reason === "cost_changed", - target: "loading", - actions: assign(({ event }) => ({ - pendingConfirmAlbumId: null, - unlockingAlbumId: null, - unlockErrorMessage: toUnlockErrorMessage(event.output), - })), - }, - { - guard: ({ event }) => event.output.reason === "not_found", - target: "ready", - actions: assign(({ context, event }) => ({ - items: removeAlbum(context.items, event.output.albumId), - creditBalance: event.output.creditBalance, - pendingConfirmAlbumId: null, - unlockingAlbumId: null, - unlockErrorMessage: toUnlockErrorMessage(event.output), - })), - }, - { - target: "ready", - actions: assign(({ context, event }) => ({ - items: patchAlbumInList(context.items, event.output), - creditBalance: event.output.creditBalance, - pendingConfirmAlbumId: null, - unlockingAlbumId: null, - unlockErrorMessage: toUnlockErrorMessage(event.output), - })), - }, - ], - onError: { - target: "ready", - actions: assign(({ event }) => ({ - pendingConfirmAlbumId: null, - unlockingAlbumId: null, - unlockErrorMessage: toPrivateRoomErrorMessage(event.error), - })), - }, - }, - }, - }, + ...privateRoomRootStateConfig, }); export type PrivateRoomMachine = typeof privateRoomMachine; diff --git a/src/stores/user/__tests__/user-machine.helpers.test.ts b/src/stores/user/__tests__/user-helpers.test.ts similarity index 97% rename from src/stores/user/__tests__/user-machine.helpers.test.ts rename to src/stores/user/__tests__/user-helpers.test.ts index 289a35b4..552bc3e7 100644 --- a/src/stores/user/__tests__/user-machine.helpers.test.ts +++ b/src/stores/user/__tests__/user-helpers.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { applyEntitlementSnapshotToView, toView, -} from "@/stores/user/user-machine.helpers"; +} from "@/stores/user/helper"; describe("toView", () => { it("fills optional user fields with stable defaults", () => { diff --git a/src/stores/user/__tests__/user-machine.test-utils.ts b/src/stores/user/__tests__/user-machine.test-utils.ts new file mode 100644 index 00000000..02586324 --- /dev/null +++ b/src/stores/user/__tests__/user-machine.test-utils.ts @@ -0,0 +1,77 @@ +import { vi } from "vitest"; +import { fromPromise } from "xstate"; + +import type { UserView } from "@/data/dto/user"; +import { userMachine } from "@/stores/user/user-machine"; +import type { UserFetchData } from "@/stores/user/machine/actors/profile"; +import type { InitData } from "@/stores/user/machine/actors/session"; + +export function makeUser(overrides: Partial = {}): UserView { + return { + id: "user-1", + username: "Elio Fan", + countryCode: "HK", + creditBalance: 7, + dailyFreeChatLimit: 30, + dailyFreeChatRemaining: 30, + dailyFreePrivateLimit: 2, + dailyFreePrivateRemaining: 2, + isVip: false, + ...overrides, + }; +} + +export function makeEntitlementSnapshot( + overrides: Partial = {}, +): NonNullable { + return { + isVip: false, + creditBalance: 7, + ...overrides, + }; +} + +export function createTestUserMachine( + overrides: Partial<{ + initData: InitData; + initError: Error; + fetchedUser: UserView | null; + fetchedData: UserFetchData; + fetchError: Error; + logoutSpy: () => void; + logoutError: Error; + }> = {}, +) { + const logoutSpy = overrides.logoutSpy ?? vi.fn<() => void>(); + + return userMachine.provide({ + actors: { + userInit: fromPromise(async () => { + if (overrides.initError) throw overrides.initError; + return { + user: overrides.initData?.user ?? makeUser(), + avatarUrl: + overrides.initData?.avatarUrl ?? "https://example.com/avatar.png", + snapshot: overrides.initData?.snapshot ?? null, + }; + }), + userFetch: fromPromise(async () => { + if (overrides.fetchError) throw overrides.fetchError; + if ("fetchedData" in overrides && overrides.fetchedData) { + return overrides.fetchedData; + } + if ("fetchedUser" in overrides) { + return { user: overrides.fetchedUser ?? null, snapshot: null }; + } + return { + user: makeUser({ username: "Fetched" }), + snapshot: null, + }; + }), + userLogout: fromPromise(async () => { + logoutSpy(); + if (overrides.logoutError) throw overrides.logoutError; + }), + }, + }); +} diff --git a/src/stores/user/__tests__/user-machine.test.ts b/src/stores/user/__tests__/user-machine.test.ts deleted file mode 100644 index d1622565..00000000 --- a/src/stores/user/__tests__/user-machine.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -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, - UserFetchData, -} from "@/stores/user/user-machine.helpers"; - -function makeUser(overrides: Partial = {}): UserView { - return { - id: "user-1", - username: "Elio Fan", - countryCode: "HK", - creditBalance: 7, - dailyFreeChatLimit: 30, - dailyFreeChatRemaining: 30, - dailyFreePrivateLimit: 2, - dailyFreePrivateRemaining: 2, - isVip: false, - ...overrides, - }; -} - -function makeEntitlementSnapshot( - overrides: Partial = {}, -): NonNullable { - return { - isVip: false, - creditBalance: 7, - ...overrides, - }; -} - -function createTestUserMachine( - overrides: Partial<{ - initData: InitData; - fetchedUser: UserView | null; - fetchedData: UserFetchData; - logoutSpy: () => void; - }> = {}, -) { - const logoutSpy = overrides.logoutSpy ?? vi.fn<() => void>(); - - return userMachine.provide({ - actors: { - userInit: fromPromise(async () => ({ - user: overrides.initData?.user ?? makeUser(), - avatarUrl: - overrides.initData?.avatarUrl ?? "https://example.com/avatar.png", - snapshot: overrides.initData?.snapshot ?? null, - })), - userFetch: fromPromise(async () => { - if ("fetchedData" in overrides && overrides.fetchedData) { - return overrides.fetchedData; - } - if ("fetchedUser" in overrides) { - return { - user: overrides.fetchedUser ?? null, - snapshot: null, - }; - } - return { - user: makeUser({ username: "Fetched" }), - snapshot: null, - }; - }), - 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", - snapshot: null, - }, - fetchedUser: null, - }), - ).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 synchronously", () => { - const actor = createActor(createTestUserMachine()).start(); - - actor.send({ type: "UserUpdate", user: makeUser({ username: "After" }) }); - - expect(actor.getSnapshot().context.currentUser?.username).toBe("After"); - - actor.stop(); - }); - - it("fetches the latest user and clears loading state when done", async () => { - const actor = createActor( - createTestUserMachine({ - fetchedData: { - user: makeUser({ username: "Fresh User" }), - snapshot: makeEntitlementSnapshot({ - isVip: true, - creditBalance: 120, - }), - }, - }), - ).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.currentUser?.creditBalance).toBe(120); - expect(context.isVip).toBe(true); - expect(context.creditBalance).toBe(120); - 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/__tests__/user-profile-flow.test.ts b/src/stores/user/__tests__/user-profile-flow.test.ts new file mode 100644 index 00000000..2c47139d --- /dev/null +++ b/src/stores/user/__tests__/user-profile-flow.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { createActor, waitFor } from "xstate"; + +import { + createTestUserMachine, + makeEntitlementSnapshot, + makeUser, +} from "./user-machine.test-utils"; + +describe("user profile flow", () => { + it("updates user profile synchronously", () => { + const actor = createActor(createTestUserMachine()).start(); + actor.send({ type: "UserUpdate", user: makeUser({ username: "After" }) }); + expect(actor.getSnapshot().context.currentUser?.username).toBe("After"); + actor.stop(); + }); + + it("fetches the latest user and entitlements", async () => { + const actor = createActor( + createTestUserMachine({ + fetchedData: { + user: makeUser({ username: "Fresh User" }), + snapshot: makeEntitlementSnapshot({ + isVip: true, + creditBalance: 120, + }), + }, + }), + ).start(); + actor.send({ type: "UserFetch" }); + expect(actor.getSnapshot().context.isLoading).toBe(true); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + + expect(actor.getSnapshot().context).toMatchObject({ + isVip: true, + creditBalance: 120, + isLoading: false, + }); + expect(actor.getSnapshot().context.currentUser).toMatchObject({ + username: "Fresh User", + isVip: true, + creditBalance: 120, + }); + actor.stop(); + }); + + it("keeps 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")); + expect(actor.getSnapshot().context.currentUser?.username).toBe("Cached"); + actor.stop(); + }); + + it("clears loading state when fetch actor fails", async () => { + const actor = createActor( + createTestUserMachine({ fetchError: new Error("fetch failed") }), + ).start(); + actor.send({ type: "UserFetch" }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + expect(actor.getSnapshot().context.isLoading).toBe(false); + actor.stop(); + }); +}); diff --git a/src/stores/user/__tests__/user-session-flow.test.ts b/src/stores/user/__tests__/user-session-flow.test.ts new file mode 100644 index 00000000..779f6481 --- /dev/null +++ b/src/stores/user/__tests__/user-session-flow.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vitest"; +import { createActor, waitFor } from "xstate"; + +import { + createTestUserMachine, + makeUser, +} from "./user-machine.test-utils"; + +describe("user session flow", () => { + it("initializes user and avatar from actor output", async () => { + const actor = createActor( + createTestUserMachine({ + initData: { + user: makeUser({ username: "Local User" }), + avatarUrl: "https://example.com/local-avatar.png", + snapshot: null, + }, + fetchedUser: null, + }), + ).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("continues to fetch when initialization fails", async () => { + const actor = createActor( + createTestUserMachine({ initError: new Error("storage unavailable") }), + ).start(); + actor.send({ type: "UserInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + expect(actor.getSnapshot().context.currentUser?.username).toBe("Fetched"); + 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")); + + expect(logoutSpy).toHaveBeenCalledOnce(); + expect(actor.getSnapshot().context.currentUser).toBeNull(); + expect(actor.getSnapshot().context.avatarUrl).toBeNull(); + actor.stop(); + }); + + it("clears user data even when logout actor fails", async () => { + const actor = createActor( + createTestUserMachine({ logoutError: new Error("logout failed") }), + ).start(); + actor.send({ type: "UserUpdate", user: makeUser() }); + actor.send({ type: "UserLogout" }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + expect(actor.getSnapshot().context.currentUser).toBeNull(); + actor.stop(); + }); +}); diff --git a/src/stores/user/helper/index.ts b/src/stores/user/helper/index.ts new file mode 100644 index 00000000..6f08b553 --- /dev/null +++ b/src/stores/user/helper/index.ts @@ -0,0 +1 @@ +export * from "./view"; diff --git a/src/stores/user/helper/view.ts b/src/stores/user/helper/view.ts new file mode 100644 index 00000000..816ac987 --- /dev/null +++ b/src/stores/user/helper/view.ts @@ -0,0 +1,51 @@ +import type { UserView } from "@/data/dto/user"; +import type { UserEntitlementSnapshotData } from "@/data/schemas/user"; + +export function toView(u: { + id: string; + username: string; + countryCode?: string; + dolBalance?: number; + creditBalance?: number; + dailyFreeChatLimit?: number; + dailyFreeChatRemaining?: number; + dailyFreePrivateLimit?: number; + dailyFreePrivateRemaining?: number; + isVip?: boolean; +}): UserView { + const creditBalance = u.creditBalance ?? u.dolBalance ?? 0; + + return { + id: u.id, + username: u.username, + countryCode: u.countryCode ?? "", + creditBalance, + dailyFreeChatLimit: u.dailyFreeChatLimit ?? 0, + dailyFreeChatRemaining: u.dailyFreeChatRemaining ?? 0, + dailyFreePrivateLimit: u.dailyFreePrivateLimit ?? 0, + dailyFreePrivateRemaining: u.dailyFreePrivateRemaining ?? 0, + isVip: u.isVip ?? false, + }; +} + +export function applyEntitlementSnapshotToView( + user: UserView | null, + snapshot: UserEntitlementSnapshotData | null, +): UserView | null { + if (!user || !snapshot) return user; + return { + ...user, + isVip: snapshot.isVip, + creditBalance: snapshot.creditBalance, + }; +} + +export function toEntitlementSnapshot(input: { + isVip: boolean; + creditBalance: number; +}): UserEntitlementSnapshotData { + return { + isVip: input.isVip, + creditBalance: input.creditBalance, + }; +} diff --git a/src/stores/user/index.ts b/src/stores/user/index.ts index b6c636b5..fd2af279 100644 --- a/src/stores/user/index.ts +++ b/src/stores/user/index.ts @@ -1,10 +1,7 @@ -/** - * @file Automatically generated by barrelsby. - */ +/** @file User store barrel. */ export * from "./user-context"; export * from "./user-events"; -export * from "./user-machine.actors"; -export * from "./user-machine.helpers"; +export * from "./helper"; export * from "./user-machine"; export * from "./user-state"; diff --git a/src/stores/user/machine/actors/profile.ts b/src/stores/user/machine/actors/profile.ts new file mode 100644 index 00000000..c99fb534 --- /dev/null +++ b/src/stores/user/machine/actors/profile.ts @@ -0,0 +1,44 @@ +import { fromPromise } from "xstate"; + +import type { UserView } from "@/data/dto/user"; +import { getUserRepository } from "@/data/repositories/user_repository"; +import type { UserEntitlementSnapshotData } from "@/data/schemas/user"; +import { UserStorage } from "@/data/storage/user/user_storage"; +import { Result } from "@/utils/result"; + +import { + applyEntitlementSnapshotToView, + toEntitlementSnapshot, + toView, +} from "../../helper/view"; + +export interface UserFetchData { + user: UserView | null; + snapshot: UserEntitlementSnapshotData | null; +} + +export const userFetchActor = fromPromise(async () => { + const userRepo = getUserRepository(); + const userStorage = UserStorage.getInstance(); + const [userResult, entitlementsResult] = await Promise.all([ + userRepo.getCurrentUser(), + userRepo.getEntitlements(), + ]); + + const snapshot = Result.isOk(entitlementsResult) + ? toEntitlementSnapshot({ + isVip: entitlementsResult.data.isVip, + creditBalance: entitlementsResult.data.creditBalance, + }) + : null; + if (snapshot) await userStorage.setEntitlementSnapshot(snapshot); + + if (Result.isErr(userResult)) return { user: null, snapshot }; + + const view = applyEntitlementSnapshotToView( + toView(userResult.data.toJson()), + snapshot, + ); + await userStorage.setUser(userResult.data.toJson()); + return { user: view, snapshot }; +}); diff --git a/src/stores/user/machine/actors/session.ts b/src/stores/user/machine/actors/session.ts new file mode 100644 index 00000000..15c1bacd --- /dev/null +++ b/src/stores/user/machine/actors/session.ts @@ -0,0 +1,46 @@ +import { fromPromise } from "xstate"; + +import type { UserView } from "@/data/dto/user"; +import { getAuthRepository } from "@/data/repositories/auth_repository"; +import type { UserEntitlementSnapshotData } from "@/data/schemas/user"; +import { UserStorage } from "@/data/storage/user/user_storage"; +import { Result } from "@/utils/result"; + +import { applyEntitlementSnapshotToView, toView } from "../../helper/view"; + +export interface InitData { + user: UserView | null; + avatarUrl: string | null; + snapshot: UserEntitlementSnapshotData | null; +} + +export const userInitActor = fromPromise(async () => { + const userStorage = UserStorage.getInstance(); + const [userResult, avatarResult, snapshotResult] = await Promise.all([ + userStorage.getUser(), + userStorage.getAvatarUrl(), + userStorage.getEntitlementSnapshot(), + ]); + + const snapshot = + Result.isOk(snapshotResult) && snapshotResult.data + ? snapshotResult.data + : null; + const user = + Result.isOk(userResult) && userResult.data + ? applyEntitlementSnapshotToView(toView(userResult.data), snapshot) + : null; + const avatarUrl = + Result.isOk(avatarResult) && + avatarResult.data && + avatarResult.data.length > 0 + ? avatarResult.data + : null; + + return { user, avatarUrl, snapshot }; +}); + +export const userLogoutActor = fromPromise(async () => { + const result = await getAuthRepository().logout(); + if (Result.isErr(result)) throw result.error; +}); diff --git a/src/stores/user/machine/profile-flow.ts b/src/stores/user/machine/profile-flow.ts new file mode 100644 index 00000000..6cef2972 --- /dev/null +++ b/src/stores/user/machine/profile-flow.ts @@ -0,0 +1,74 @@ +import type { DoneActorEvent, ErrorActorEvent } from "xstate"; + +import { applyEntitlementSnapshotToView } from "../helper/view"; +import type { UserFetchData } from "./actors/profile"; +import { + baseUserMachineSetup, + createUserActorActionSetup, +} from "./setup"; + +const updateUserAction = baseUserMachineSetup.assign(({ event }) => + event.type === "UserUpdate" + ? { + currentUser: event.user, + isVip: event.user.isVip, + creditBalance: event.user.creditBalance, + } + : {}, +); + +const markUserLoadingAction = baseUserMachineSetup.assign({ + isLoading: true, +}); + +export const profileMachineSetup = baseUserMachineSetup.extend({ + actions: { + updateUser: updateUserAction, + markUserLoading: markUserLoadingAction, + }, +}); + +const userFetchDoneSetup = + createUserActorActionSetup>(); +const userFetchErrorSetup = createUserActorActionSetup(); + +const applyFetchedUserAction = userFetchDoneSetup.assign( + ({ context, event }) => { + const snapshot = + event.output.snapshot ?? + (context.currentUser + ? { + isVip: context.isVip, + creditBalance: context.creditBalance, + } + : null); + const baseUser = event.output.user ?? context.currentUser; + return { + currentUser: + applyEntitlementSnapshotToView(baseUser, snapshot) ?? + context.currentUser, + isVip: snapshot?.isVip ?? context.isVip, + creditBalance: snapshot?.creditBalance ?? context.creditBalance, + isLoading: false, + }; + }, +); + +const markUserFetchFailedAction = userFetchErrorSetup.assign({ + isLoading: false, +}); + +export const fetchingState = profileMachineSetup.createStateConfig({ + entry: "markUserLoading", + invoke: { + src: "userFetch", + onDone: { + target: "idle", + actions: applyFetchedUserAction, + }, + onError: { + target: "idle", + actions: markUserFetchFailedAction, + }, + }, +}); diff --git a/src/stores/user/machine/session-flow.ts b/src/stores/user/machine/session-flow.ts new file mode 100644 index 00000000..be0281d8 --- /dev/null +++ b/src/stores/user/machine/session-flow.ts @@ -0,0 +1,75 @@ +import type { DoneActorEvent } from "xstate"; + +import { initialState } from "../user-state"; +import type { InitData } from "./actors/session"; +import { fetchingState, profileMachineSetup } from "./profile-flow"; +import { createUserActorActionSetup } from "./setup"; + +const clearUserAction = profileMachineSetup.assign(() => ({ + currentUser: null, + avatarUrl: null, + isVip: false, + creditBalance: 0, +})); + +export const userMachineSetup = profileMachineSetup.extend({ + actions: { + clearUser: clearUserAction, + }, +}); + +const userInitDoneSetup = + createUserActorActionSetup>(); + +const applyInitializedUserAction = userInitDoneSetup.assign(({ event }) => ({ + currentUser: event.output.user, + avatarUrl: event.output.avatarUrl, + isVip: event.output.snapshot?.isVip ?? initialState.isVip, + creditBalance: + event.output.snapshot?.creditBalance ?? initialState.creditBalance, +})); + +const idleState = userMachineSetup.createStateConfig({ + on: { + UserInit: "initializing", + UserFetch: "fetching", + UserUpdate: { actions: "updateUser" }, + UserClearLocal: { actions: "clearUser" }, + UserLogout: "loggingOut", + }, +}); + +const initializingState = userMachineSetup.createStateConfig({ + invoke: { + src: "userInit", + onDone: { + target: "fetching", + actions: applyInitializedUserAction, + }, + onError: { target: "fetching" }, + }, +}); + +const loggingOutState = userMachineSetup.createStateConfig({ + invoke: { + src: "userLogout", + onDone: { + target: "idle", + actions: "clearUser", + }, + onError: { + target: "idle", + actions: "clearUser", + }, + }, +}); + +export const userRootStateConfig = userMachineSetup.createStateConfig({ + initial: "idle", + states: { + idle: idleState, + initializing: initializingState, + fetching: fetchingState, + loggingOut: loggingOutState, + }, +}); diff --git a/src/stores/user/machine/setup.ts b/src/stores/user/machine/setup.ts new file mode 100644 index 00000000..e7ee3118 --- /dev/null +++ b/src/stores/user/machine/setup.ts @@ -0,0 +1,46 @@ +import { setup, type ActionFunction, type EventObject } from "xstate"; + +import type { UserEvent } from "../user-events"; +import type { UserState } from "../user-state"; +import { userFetchActor } from "./actors/profile"; +import { userInitActor, userLogoutActor } from "./actors/session"; + +export const baseUserMachineSetup = setup({ + types: { + context: {} as UserState, + events: {} as UserEvent, + }, + actors: { + userInit: userInitActor, + userFetch: userFetchActor, + userLogout: userLogoutActor, + }, +}); + +type UserActorAction = ActionFunction< + UserState, + TEvent, + UserEvent, + undefined, + never, + never, + never, + never, + never +>; + +export function createUserActorActionSetup() { + const actorActionSetup = setup({ + types: { + context: {} as UserState, + events: {} as TEvent, + }, + }); + return { + assign( + assignment: Parameters[0], + ): UserActorAction { + return actorActionSetup.assign(assignment) as unknown as UserActorAction; + }, + }; +} diff --git a/src/stores/user/user-machine.actors.ts b/src/stores/user/user-machine.actors.ts deleted file mode 100644 index fe47eee8..00000000 --- a/src/stores/user/user-machine.actors.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * User 状态机:Actors(异步服务) - * - * 从 `user-machine.ts` 抽出的"Actors"段: - * - `userInitActor`(fromPromise:读 UserStorage 并行 user + avatarUrl) - * - `userFetchActor`(fromPromise:调 API + 持久化到本地) - * - `userLogoutActor`(fromPromise:auth logout + 清本地数据) - * - * 设计: - * - 依赖 `user-machine.helpers.ts`(toView, readInitData, userStorage) - * - 仓库使用懒单例 getter,actor 执行时才创建实例 - * - 命名约定:`[user-machine]` 前缀日志(如未来加)—— 与 chat 模块对齐 - */ - -import { fromPromise } from "xstate"; - -import { getUserRepository } from "@/data/repositories/user_repository"; -import { getAuthRepository } from "@/data/repositories/auth_repository"; -import { Result } from "@/utils/result"; - -import { - applyEntitlementSnapshotToView, - type InitData, - readInitData, - toEntitlementSnapshot, - toView, - type UserFetchData, - userStorage, -} from "./user-machine.helpers"; - -// ============================================================ -// Init actor:从 UserStorage 读 user + avatarUrl -// ============================================================ -/** - * 读本地 user + avatarUrl(并行)。 - * - 失败/无数据 → 返回 null,不抛错(machine 走 onDone 回到 idle,context 是 null) - * - onDone 赋 currentUser + avatarUrl 到 context - */ -export const userInitActor = fromPromise(async () => readInitData()); - -// ============================================================ -// Fetch actor:调 API 拉当前用户 + 持久化 -// ============================================================ -/** - * 调 `userRepo.getCurrentUser + getEntitlements` 拉当前 user 与权益: - * - user 成功 → 转 UserView + 写本地(userStorage.setUser) - * - entitlements 成功 → 提取 isVip/creditBalance 写本地轻量快照 - * - 任一失败都不阻断另一方,machine 层负责保留已有 context - */ -export const userFetchActor = fromPromise(async () => { - const userRepo = getUserRepository(); - const [userResult, entitlementsResult] = await Promise.all([ - userRepo.getCurrentUser(), - userRepo.getEntitlements(), - ]); - - const snapshot = Result.isOk(entitlementsResult) - ? toEntitlementSnapshot({ - isVip: entitlementsResult.data.isVip, - creditBalance: entitlementsResult.data.creditBalance, - }) - : null; - if (snapshot) { - await userStorage.setEntitlementSnapshot(snapshot); - } - - if (Result.isErr(userResult)) { - return { user: null, snapshot }; - } - - const view = applyEntitlementSnapshotToView( - toView(userResult.data.toJson()), - snapshot, - ); - // 持久化到本地 - await userStorage.setUser(userResult.data.toJson()); - return { user: view, snapshot }; -}); - -// ============================================================ -// Logout actor:后端登出 + 清本地用户数据 -// ============================================================ -/** - * 登出: - * - 调 `authRepo.logout` 让后端吊销 token - * - `userStorage.clearUserData` 清本地 user / avatar 等 - * - onDone / onError 都跑 `clearUser` action(machine 层)—— 失败也清本地 - */ -export const userLogoutActor = fromPromise(async () => { - const result = await getAuthRepository().logout(); - if (Result.isErr(result)) throw result.error; -}); diff --git a/src/stores/user/user-machine.helpers.ts b/src/stores/user/user-machine.helpers.ts deleted file mode 100644 index 9dfa1f1a..00000000 --- a/src/stores/user/user-machine.helpers.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * User 状态机:纯函数 + 数据加载 - * - * 从 `user-machine.ts` 抽出的"Helpers"段: - * - InitData 形状 - * - `toView`:DTO → UserView 映射(纯函数) - * - `readInitData`:从 UserStorage 读 user + avatarUrl(并行) - * - `userStorage` 单例:被 helpers 和 actors 共用 - * - * 设计目标: - * - helpers 无 XState 依赖(可独立 import / 测试) - * - actors 依赖 helpers(import) - * - machine 依赖 actors(import)—— 不直接依赖 helpers - */ - -import type { UserView } from "@/data/dto/user"; -import type { UserEntitlementSnapshotData } from "@/data/schemas/user"; -import { UserStorage } from "@/data/storage/user/user_storage"; -import { Result } from "@/utils/result"; - -// ============================================================ -// Storage singleton -// ============================================================ -/** - * UserStorage 单例 —— 被 helpers(readInitData)和 actors(userFetchActor / userLogoutActor) - * 共同使用。从 helpers 导出,避免在 actors 里重复 `UserStorage.getInstance()`。 - */ -export const userStorage = UserStorage.getInstance(); - -// ============================================================ -// InitData 形状 -// ============================================================ -/** - * `userInitActor` 的输出形状(user-machine.initializing.onDone 用)—— - * 拆分 user 和 avatarUrl 是因为它们来自 UserStorage 两个独立接口。 - */ -export interface InitData { - user: UserView | null; - avatarUrl: string | null; - snapshot: UserEntitlementSnapshotData | null; -} - -export interface UserFetchData { - user: UserView | null; - snapshot: UserEntitlementSnapshotData | null; -} - -// ============================================================ -// DTO → UserView 映射 -// ============================================================ -/** - * UserView DTO → UserView 视图模型(纯函数,可单测) - * - 与 chat 的 `localMessagesToUi` 同模式 - * - 默认值收敛在此:undefined → "" / 0 / false,避免下游到处判空 - */ -export function toView(u: { - id: string; - username: string; - countryCode?: string; - dolBalance?: number; - creditBalance?: number; - dailyFreeChatLimit?: number; - dailyFreeChatRemaining?: number; - dailyFreePrivateLimit?: number; - dailyFreePrivateRemaining?: number; - isVip?: boolean; -}): UserView { - const creditBalance = u.creditBalance ?? u.dolBalance ?? 0; - - return { - id: u.id, - username: u.username, - countryCode: u.countryCode ?? "", - creditBalance, - dailyFreeChatLimit: u.dailyFreeChatLimit ?? 0, - dailyFreeChatRemaining: u.dailyFreeChatRemaining ?? 0, - dailyFreePrivateLimit: u.dailyFreePrivateLimit ?? 0, - dailyFreePrivateRemaining: u.dailyFreePrivateRemaining ?? 0, - isVip: u.isVip ?? false, - }; -} - -export function applyEntitlementSnapshotToView( - user: UserView | null, - snapshot: UserEntitlementSnapshotData | null, -): UserView | null { - if (!user || !snapshot) return user; - return { - ...user, - isVip: snapshot.isVip, - creditBalance: snapshot.creditBalance, - }; -} - -export function toEntitlementSnapshot(input: { - isVip: boolean; - creditBalance: number; -}): UserEntitlementSnapshotData { - return { - isVip: input.isVip, - creditBalance: input.creditBalance, - }; -} - -// ============================================================ -// Init 数据加载(userInitActor 用) -// ============================================================ -/** - * 并行读 user + avatarUrl(UserStorage 两个独立接口)。 - * - 任意一个失败 / 无数据 → 对应字段返回 null,不抛错(与 chat 的 - * `readAndSyncHistory` "失败不卡 init" 一致的设计) - * - Result.isOk(_) && data 双重判空:Result 包装层 + 业务 null - */ -export async function readInitData(): Promise { - const [userResult, avatarResult, snapshotResult] = await Promise.all([ - userStorage.getUser(), - userStorage.getAvatarUrl(), - userStorage.getEntitlementSnapshot(), - ]); - - let snapshot: UserEntitlementSnapshotData | null = null; - if (Result.isOk(snapshotResult) && snapshotResult.data) { - snapshot = snapshotResult.data; - } - - let user: UserView | null = null; - if (Result.isOk(userResult) && userResult.data) { - user = applyEntitlementSnapshotToView(toView(userResult.data), snapshot); - } - - let avatarUrl: string | null = null; - if (Result.isOk(avatarResult) && avatarResult.data && avatarResult.data.length > 0) { - avatarUrl = avatarResult.data; - } - - return { user, avatarUrl, snapshot }; -} diff --git a/src/stores/user/user-machine.ts b/src/stores/user/user-machine.ts index eff3353d..a9c2724d 100644 --- a/src/stores/user/user-machine.ts +++ b/src/stores/user/user-machine.ts @@ -1,138 +1,17 @@ -/** - * User 状态机(XState v5) - */ -import { setup, assign } from "xstate"; - -import { UserState, initialState } from "./user-state"; -import type { UserEvent } from "./user-events"; import { - userInitActor, - userFetchActor, - userLogoutActor, -} from "./user-machine.actors"; -import { applyEntitlementSnapshotToView } from "./user-machine.helpers"; + userMachineSetup, + userRootStateConfig, +} from "./machine/session-flow"; +import { initialState } from "./user-state"; -// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变 export type { UserState } from "./user-state"; export { initialState } from "./user-state"; export type { UserEvent } from "./user-events"; -// ============================================================ -// Machine -// ============================================================ -export const userMachine = setup({ - types: { - context: {} as UserState, - events: {} as UserEvent, - }, - actors: { - userInit: userInitActor, - userFetch: userFetchActor, - userLogout: userLogoutActor, - }, - actions: { - updateUser: assign(({ event }) => { - if (event.type !== "UserUpdate") return {}; - return { - currentUser: event.user, - isVip: event.user.isVip, - creditBalance: event.user.creditBalance, - }; - }), - - clearUser: assign(() => ({ - currentUser: null, - avatarUrl: null, - isVip: false, - creditBalance: 0, - })), - }, -}).createMachine({ +export const userMachine = userMachineSetup.createMachine({ id: "user", - initial: "idle", context: initialState, - states: { - idle: { - on: { - UserInit: "initializing", - UserFetch: "fetching", - UserUpdate: { - actions: "updateUser", - }, - UserClearLocal: { - actions: "clearUser", - }, - UserLogout: "loggingOut", - }, - }, - - initializing: { - invoke: { - src: "userInit", - onDone: { - target: "fetching", - actions: assign(({ event }) => ({ - currentUser: event.output.user, - avatarUrl: event.output.avatarUrl, - isVip: event.output.snapshot?.isVip ?? initialState.isVip, - creditBalance: - event.output.snapshot?.creditBalance ?? - initialState.creditBalance, - })), - }, - onError: { - target: "fetching", - }, - }, - }, - - fetching: { - invoke: { - src: "userFetch", - onDone: { - target: "idle", - actions: assign(({ context, event }) => { - const snapshot = - event.output.snapshot ?? - (context.currentUser - ? { - isVip: context.isVip, - creditBalance: context.creditBalance, - } - : null); - const baseUser = event.output.user ?? context.currentUser; - return { - currentUser: - applyEntitlementSnapshotToView(baseUser, snapshot) ?? - context.currentUser, - isVip: snapshot?.isVip ?? context.isVip, - creditBalance: snapshot?.creditBalance ?? context.creditBalance, - isLoading: false, - }; - }), - }, - onError: { - target: "idle", - actions: assign({ isLoading: false }), - }, - }, - entry: assign({ isLoading: true }), - }, - - loggingOut: { - invoke: { - src: "userLogout", - onDone: { - target: "idle", - actions: "clearUser", - }, - onError: { - target: "idle", - actions: "clearUser", - }, - }, - }, - }, + ...userRootStateConfig, }); export type UserMachine = typeof userMachine;