refactor(stores): standardize state machine structure

This commit is contained in:
2026-07-15 11:19:08 +08:00
parent ed3e800245
commit 20d07023e4
68 changed files with 3492 additions and 3193 deletions
+1 -3
View File
@@ -43,8 +43,6 @@
"./src/app/subscription/components",
"./src/hooks",
"./src/integrations",
"./src/utils",
"./src/stores/auth",
"./src/stores/user"
"./src/utils"
]
}
@@ -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();
});
});
@@ -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<LoginStatus, void>(
async () => overrides.checkAuthStatus ?? "notLoggedIn",
),
bindFacebookIdentity: fromPromise<
void,
BindFacebookIdentityInput
>(async ({ input }) => {
bindFacebookIdentitySpy(input);
}),
guestLogin: fromPromise<LoginStatus, void>(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<void, AuthProvider>(async () => {
if (overrides.oauthSignInError) throw overrides.oauthSignInError;
}),
syncGoogleBackend: fromPromise<LoginStatus, { idToken: string }>(
async () => overrides.googleSync ?? "google",
),
syncFacebookBackend: fromPromise<
LoginStatus,
{ accessToken: string }
>(async () => overrides.facebookSync ?? "facebook"),
logout: fromPromise<LoginStatus>(async () => {
logoutSpy();
return "guest";
}),
},
});
}
@@ -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<LoginStatus, void>(
async () => overrides.checkAuthStatus ?? "notLoggedIn",
),
bindFacebookIdentity: fromPromise<
void,
BindFacebookIdentityInput
>(async ({ input }) => {
bindFacebookIdentitySpy(input);
}),
guestLogin: fromPromise<LoginStatus, void>(
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<void, AuthProvider>(async () => {}),
syncGoogleBackend: fromPromise<LoginStatus, { idToken: string }>(
async () => "google",
),
syncFacebookBackend: fromPromise<
LoginStatus,
{ accessToken: string }
>(async () => "facebook"),
logout: fromPromise<LoginStatus>(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();
});
});
@@ -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();
});
});
@@ -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();
});
});
-339
View File
@@ -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<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();
// 注册后自动登录(对齐 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<void, AuthProvider>(async ({ input }) => {
if (input === "google") {
await AuthPlatform.googleSignIn();
} else {
await AuthPlatform.facebookSignIn();
}
});
export const logoutActor = fromPromise<LoginStatus>(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 ==========
// 由 <OAuthSessionSync /> 在 NextAuth 回调后派发(监听 useSession())。
// 用 OAuth provider 的 idToken / accessToken 调后端换业务 token + user。
// 后端返回的 LoginResponse 已经被 AuthRepository._saveLoginData 持久化。
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;
// 业务 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<void> {
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<LoginStatus, void>(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<LoginStatus, void>(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. 查业务登录 tokenloginToken 和 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"
);
}
+5 -1
View File
@@ -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 事件
-24
View File
@@ -1,24 +0,0 @@
/**
* Auth 状态机:Helpers
*
* 状态机内复用的小函数 —— 独立成文件便于测试 / 复用。
*/
import { AuthStorage } from "@/data/storage/auth/auth_storage";
/** 从本地存储读 guest deviceId;读不到 / 失败时返回 undefined(不抛)。 */
export async function readGuestId(): Promise<string | undefined> {
const r = await AuthStorage.getInstance().getDeviceId();
if (r.success && r.data != null) {
return r.data;
}
return undefined;
}
/** 从本地存储读 PSID;读不到 / 失败时返回 undefined(不抛)。 */
export async function readPsid(): Promise<string | undefined> {
const r = await AuthStorage.getInstance().getPsid();
if (r.success && r.data != null) {
return r.data;
}
return undefined;
}
+6 -304
View File
@@ -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 到状态机 —— 由 <AuthStatusChecker /> 派发
AuthInit: "initializing",
AuthExternalFacebookIdentityBindRequested:
"bindingFacebookIdentity",
AuthGuestLoginSubmitted: {
guard: "canSubmitGuestLogin",
target: "loadingGuestLogin",
},
AuthEmailLoginSubmitted: "loadingEmailLogin",
AuthEmailRegisterSubmitted: "loadingEmailRegister",
AuthGoogleLoginSubmitted: "loadingOAuth",
AuthFacebookLoginSubmitted: "loadingOAuth",
// OAuth 回调后 sync 入口 —— 由 <OAuthSessionSync /> 派发
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;
+5
View File
@@ -0,0 +1,5 @@
import { ExceptionHandler } from "@/core/errors";
export function toAuthErrorMessage(error: unknown): string {
return ExceptionHandler.message(error);
}
+1
View File
@@ -0,0 +1 @@
export * from "./error";
+2 -5
View File
@@ -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";
@@ -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;
});
@@ -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",
);
}
});
+98
View File
@@ -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<void, AuthProvider>(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<void> {
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",
);
}
}
}
+110
View File
@@ -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<LoginStatus>(async () => {
const result = await getAuthRepository().logout();
if (Result.isErr(result)) throw result.error;
return "guest" as LoginStatus;
});
export const guestLoginActor = fromPromise<LoginStatus, void>(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<LoginStatus, void>(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"
);
}
+11
View File
@@ -0,0 +1,11 @@
import { AuthStorage } from "@/data/storage/auth/auth_storage";
export async function readGuestId(): Promise<string | undefined> {
const result = await AuthStorage.getInstance().getDeviceId();
return result.success && result.data != null ? result.data : undefined;
}
export async function readPsid(): Promise<string | undefined> {
const result = await AuthStorage.getInstance().getPsid();
return result.success && result.data != null ? result.data : undefined;
}
@@ -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<DoneActorEvent<LoginStatus>>();
const authErrorActionSetup = createAuthActorActionSetup<ErrorActorEvent>();
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,
},
},
});
+83
View File
@@ -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<DoneActorEvent<LoginStatus>>();
const oauthErrorSetup = createAuthActorActionSetup<ErrorActorEvent>();
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,
},
},
});
+146
View File
@@ -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<DoneActorEvent<LoginStatus>>();
const sessionErrorSetup = createAuthActorActionSetup<ErrorActorEvent>();
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,
},
});
+72
View File
@@ -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<TEvent extends EventObject> = ActionFunction<
AuthState,
TEvent,
AuthEvent,
undefined,
never,
never,
never,
never,
never
>;
export function createAuthActorActionSetup<TEvent extends EventObject>() {
const actorActionSetup = setup({
types: {
context: {} as AuthState,
events: {} as TEvent,
},
});
return {
assign(
assignment: Parameters<typeof actorActionSetup.assign>[0],
): AuthActorAction<TEvent> {
return actorActionSetup.assign(assignment) as unknown as AuthActorAction<TEvent>;
},
};
}
+1 -3
View File
@@ -1,6 +1,4 @@
/**
* @file Automatically generated by barrelsby.
*/
/** @file Chat store barrel. */
export * from "./chat-context";
export * from "./chat-events";
@@ -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<PaymentPlansResponse>((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",
},
],
});
}
@@ -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<PaymentPlansResponse>;
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<CreateOrderSpy>();
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",
});
}),
},
});
}
@@ -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<CreateOrderSpy>();
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<CreateOrderSpy>();
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<PaymentPlansResponse>((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<CreateOrderSpy>();
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<CreateOrderSpy>();
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();
});
});
@@ -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<CreateOrderSpy>();
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<CreateOrderSpy>();
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<typeof createActor<ReturnType<typeof createTestPaymentMachine>>>,
): Promise<void> {
actor.send({ type: "PaymentInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
}
@@ -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<PaymentState> {
return {
currentOrderId: null,
currentOrderPlanId: null,
payParams: null,
orderStatus: null,
orderPollingStartedAt: null,
errorMessage: null,
};
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./catalog";
export * from "./order";
+46
View File
@@ -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<PaymentState> {
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,
);
}
+1 -1
View File
@@ -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";
@@ -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;
});
@@ -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;
});
+212
View File
@@ -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<PaymentPlansResponse | null>
>();
const plansDoneSetup =
createPaymentActorActionSetup<DoneActorEvent<PaymentPlansResponse>>();
const plansErrorSetup = createPaymentActorActionSetup<ErrorActorEvent>();
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" },
},
});
+305
View File
@@ -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<CreatePaymentOrderResponse>
>();
const pollOrderDoneSetup =
createPaymentActorActionSetup<
DoneActorEvent<PaymentOrderStatusResponse>
>();
const orderErrorSetup = createPaymentActorActionSetup<ErrorActorEvent>();
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",
},
},
});
+62
View File
@@ -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<TEvent extends EventObject> = ActionFunction<
PaymentState,
TEvent,
PaymentEvent,
undefined,
never,
never,
never,
never,
never
>;
export function createPaymentActorActionSetup<TEvent extends EventObject>() {
const actorActionSetup = setup({
types: {
context: {} as PaymentState,
events: {} as TEvent,
},
});
return {
assign(
assignment: Parameters<typeof actorActionSetup.assign>[0],
): PaymentActorAction<TEvent> {
return actorActionSetup.assign(assignment) as unknown as PaymentActorAction<TEvent>;
},
createAction(
action: Parameters<typeof actorActionSetup.createAction>[0],
): PaymentActorAction<TEvent> {
return actorActionSetup.createAction(action) as unknown as PaymentActorAction<TEvent>;
},
};
}
@@ -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;
});
+6 -446
View File
@@ -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;
@@ -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();
});
});
@@ -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<Parameters<typeof PrivateAlbumsResponse.from>[0]> = {},
): PrivateAlbumsResponse {
return PrivateAlbumsResponse.from({
items: [makeAlbum()],
creditBalance: 500,
...overrides,
});
}
export function makeUnlockResponse(
overrides: Partial<Parameters<typeof PrivateAlbumUnlockResponse.from>[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<typeof createTestPrivateRoomMachine>[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<ReturnType<typeof loadPrivateRoom>>,
) {
actor.send({
type: "PrivateRoomUnlockRequested",
albumId: ALBUM_ID,
});
actor.send({ type: "PrivateRoomUnlockConfirmed" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
}
@@ -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<Parameters<typeof PrivateAlbumsResponse.from>[0]> = {},
): PrivateAlbumsResponse {
return PrivateAlbumsResponse.from({
items: [makeAlbum()],
creditBalance: 500,
...overrides,
});
}
function makeUnlockResponse(
overrides: Partial<Parameters<typeof PrivateAlbumUnlockResponse.from>[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<typeof createTestMachine>[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<ReturnType<typeof loadMachine>>,
) {
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();
});
});
@@ -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();
});
});
+63
View File
@@ -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<PrivateRoomState> {
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);
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./albums";
export * from "./unlock";
+41
View File
@@ -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,
};
}
+3
View File
@@ -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";
@@ -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<PrivateAlbumsResponse>(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,
);
@@ -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<PrivateAlbumsResponse>
>();
const albumErrorSetup =
createPrivateRoomActorActionSetup<ErrorActorEvent>();
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",
},
});
+55
View File
@@ -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<TEvent extends EventObject> = 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<typeof actorActionSetup.assign>[0],
): PrivateRoomActorAction<TEvent> {
return actorActionSetup.assign(
assignment,
) as unknown as PrivateRoomActorAction<TEvent>;
},
};
}
@@ -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<PrivateAlbumUnlockResponse>
>();
const unlockErrorSetup =
createPrivateRoomActorActionSetup<ErrorActorEvent>();
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,
},
});
@@ -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<PrivateRoomState> {
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,
};
}
+6 -181
View File
@@ -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;
@@ -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", () => {
@@ -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> = {}): 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<InitData["snapshot"]> = {},
): NonNullable<InitData["snapshot"]> {
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<InitData>(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<UserFetchData>(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<void>(async () => {
logoutSpy();
if (overrides.logoutError) throw overrides.logoutError;
}),
},
});
}
@@ -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> = {}): 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<InitData["snapshot"]> = {},
): NonNullable<InitData["snapshot"]> {
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<InitData>(async () => ({
user: overrides.initData?.user ?? makeUser(),
avatarUrl:
overrides.initData?.avatarUrl ?? "https://example.com/avatar.png",
snapshot: overrides.initData?.snapshot ?? null,
})),
userFetch: fromPromise<UserFetchData>(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<void>(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();
});
});
@@ -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();
});
});
@@ -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();
});
});
+1
View File
@@ -0,0 +1 @@
export * from "./view";
+51
View File
@@ -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,
};
}
+2 -5
View File
@@ -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";
+44
View File
@@ -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<UserFetchData>(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 };
});
+46
View File
@@ -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<InitData>(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<void>(async () => {
const result = await getAuthRepository().logout();
if (Result.isErr(result)) throw result.error;
});
+74
View File
@@ -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<DoneActorEvent<UserFetchData>>();
const userFetchErrorSetup = createUserActorActionSetup<ErrorActorEvent>();
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,
},
},
});
+75
View File
@@ -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<DoneActorEvent<InitData>>();
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,
},
});
+46
View File
@@ -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<TEvent extends EventObject> = ActionFunction<
UserState,
TEvent,
UserEvent,
undefined,
never,
never,
never,
never,
never
>;
export function createUserActorActionSetup<TEvent extends EventObject>() {
const actorActionSetup = setup({
types: {
context: {} as UserState,
events: {} as TEvent,
},
});
return {
assign(
assignment: Parameters<typeof actorActionSetup.assign>[0],
): UserActorAction<TEvent> {
return actorActionSetup.assign(assignment) as unknown as UserActorAction<TEvent>;
},
};
}
-92
View File
@@ -1,92 +0,0 @@
/**
* User Actors
*
* `user-machine.ts` "Actors"
* - `userInitActor`fromPromise UserStorage user + avatarUrl
* - `userFetchActor`fromPromise API +
* - `userLogoutActor`fromPromiseauth logout +
*
*
* - `user-machine.helpers.ts`toView, readInitData, userStorage
* - 使 getteractor
* - `[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
* - / nullmachine onDone idlecontext null
* - onDone currentUser + avatarUrl context
*/
export const userInitActor = fromPromise<InitData>(async () => readInitData());
// ============================================================
// Fetch actor:调 API 拉当前用户 + 持久化
// ============================================================
/**
* `userRepo.getCurrentUser + getEntitlements` user
* - user UserView + userStorage.setUser
* - entitlements isVip/creditBalance
* - machine context
*/
export const userFetchActor = fromPromise<UserFetchData>(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` actionmachine
*/
export const userLogoutActor = fromPromise<void>(async () => {
const result = await getAuthRepository().logout();
if (Result.isErr(result)) throw result.error;
});
-137
View File
@@ -1,137 +0,0 @@
/**
* User +
*
* `user-machine.ts` "Helpers"
* - InitData
* - `toView`DTO UserView
* - `readInitData` UserStorage user + avatarUrl
* - `userStorage` helpers actors
*
*
* - helpers XState import /
* - actors helpersimport
* - machine actorsimport 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 helpersreadInitData actorsuserFetchActor / 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 + avatarUrlUserStorage
* - / null chat
* `readAndSyncHistory` "失败不卡 init"
* - Result.isOk(_) && data Result + null
*/
export async function readInitData(): Promise<InitData> {
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 };
}
+6 -127
View File
@@ -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;