refactor(stores): standardize state machine structure
This commit is contained in:
+1
-3
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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. 查业务登录 token:loginToken 和 refreshToken 必须同时存在。
|
||||
if (await hasCompleteBusinessAuthSession(storage)) {
|
||||
const providerR = await storage.getLoginProvider();
|
||||
const provider =
|
||||
providerR.success && isBusinessLoginProvider(providerR.data)
|
||||
? providerR.data
|
||||
: ("email" as LoginStatus);
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
// 3. 有 PSID 时优先让后端用本地绑定关系直接登录;未匹配则返回游客态。
|
||||
if (psid) {
|
||||
const psidLoginResult = await authRepo.facebookPsidLogin({
|
||||
psid,
|
||||
deviceId,
|
||||
bindToGuest: true,
|
||||
});
|
||||
if (Result.isOk(psidLoginResult)) {
|
||||
return psidLoginResult.data;
|
||||
}
|
||||
log.warn(
|
||||
{ err: psidLoginResult.error.message },
|
||||
"[auth-machine] checkAuthStatusActor: facebookPsidLogin failed",
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 无 PSID 或 PSID 直登失败时,继续用 ASID fallback 换业务 token。
|
||||
if (asid) {
|
||||
const avatarUrlR = await UserStorage.getInstance().getAvatarUrl();
|
||||
const result = await authRepo.facebookAsidLogin({
|
||||
asid,
|
||||
avatarUrl: avatarUrlR.success ? avatarUrlR.data ?? undefined : undefined,
|
||||
psid: psid ?? undefined,
|
||||
});
|
||||
|
||||
if (Result.isErr(result)) {
|
||||
log.warn(
|
||||
{ err: result.error.message },
|
||||
"[auth-machine] checkAuthStatusActor: facebookAsidLogin failed",
|
||||
);
|
||||
return "notLoggedIn" as LoginStatus;
|
||||
}
|
||||
|
||||
return "facebook" as LoginStatus;
|
||||
}
|
||||
|
||||
// 5. 查 guestToken
|
||||
const guestTokenR = await storage.getGuestToken();
|
||||
if (guestTokenR.success && guestTokenR.data) {
|
||||
return "guest" as LoginStatus;
|
||||
}
|
||||
|
||||
// 6. 都没有 → "notLoggedIn"(保持 splash 页面 + 登录入口)
|
||||
return "notLoggedIn" as LoginStatus;
|
||||
});
|
||||
|
||||
function isBusinessLoginProvider(
|
||||
provider: LoginStatus | null,
|
||||
): provider is LoginStatus {
|
||||
return (
|
||||
provider === "email" ||
|
||||
provider === "facebook" ||
|
||||
provider === "google"
|
||||
);
|
||||
}
|
||||
@@ -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 事件
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ExceptionHandler } from "@/core/errors";
|
||||
|
||||
export function toAuthErrorMessage(error: unknown): string {
|
||||
return ExceptionHandler.message(error);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./error";
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,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"));
|
||||
}
|
||||
+2
-29
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./catalog";
|
||||
export * from "./order";
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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" },
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./albums";
|
||||
export * from "./unlock";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
+3
-5
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./view";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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>;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* User 状态机:Actors(异步服务)
|
||||
*
|
||||
* 从 `user-machine.ts` 抽出的"Actors"段:
|
||||
* - `userInitActor`(fromPromise:读 UserStorage 并行 user + avatarUrl)
|
||||
* - `userFetchActor`(fromPromise:调 API + 持久化到本地)
|
||||
* - `userLogoutActor`(fromPromise:auth logout + 清本地数据)
|
||||
*
|
||||
* 设计:
|
||||
* - 依赖 `user-machine.helpers.ts`(toView, readInitData, userStorage)
|
||||
* - 仓库使用懒单例 getter,actor 执行时才创建实例
|
||||
* - 命名约定:`[user-machine]` 前缀日志(如未来加)—— 与 chat 模块对齐
|
||||
*/
|
||||
|
||||
import { fromPromise } from "xstate";
|
||||
|
||||
import { getUserRepository } from "@/data/repositories/user_repository";
|
||||
import { getAuthRepository } from "@/data/repositories/auth_repository";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
import {
|
||||
applyEntitlementSnapshotToView,
|
||||
type InitData,
|
||||
readInitData,
|
||||
toEntitlementSnapshot,
|
||||
toView,
|
||||
type UserFetchData,
|
||||
userStorage,
|
||||
} from "./user-machine.helpers";
|
||||
|
||||
// ============================================================
|
||||
// Init actor:从 UserStorage 读 user + avatarUrl
|
||||
// ============================================================
|
||||
/**
|
||||
* 读本地 user + avatarUrl(并行)。
|
||||
* - 失败/无数据 → 返回 null,不抛错(machine 走 onDone 回到 idle,context 是 null)
|
||||
* - onDone 赋 currentUser + avatarUrl 到 context
|
||||
*/
|
||||
export const userInitActor = fromPromise<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` action(machine 层)—— 失败也清本地
|
||||
*/
|
||||
export const userLogoutActor = fromPromise<void>(async () => {
|
||||
const result = await getAuthRepository().logout();
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* User 状态机:纯函数 + 数据加载
|
||||
*
|
||||
* 从 `user-machine.ts` 抽出的"Helpers"段:
|
||||
* - InitData 形状
|
||||
* - `toView`:DTO → UserView 映射(纯函数)
|
||||
* - `readInitData`:从 UserStorage 读 user + avatarUrl(并行)
|
||||
* - `userStorage` 单例:被 helpers 和 actors 共用
|
||||
*
|
||||
* 设计目标:
|
||||
* - helpers 无 XState 依赖(可独立 import / 测试)
|
||||
* - actors 依赖 helpers(import)
|
||||
* - machine 依赖 actors(import)—— 不直接依赖 helpers
|
||||
*/
|
||||
|
||||
import type { UserView } from "@/data/dto/user";
|
||||
import type { UserEntitlementSnapshotData } from "@/data/schemas/user";
|
||||
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
// ============================================================
|
||||
// Storage singleton
|
||||
// ============================================================
|
||||
/**
|
||||
* UserStorage 单例 —— 被 helpers(readInitData)和 actors(userFetchActor / userLogoutActor)
|
||||
* 共同使用。从 helpers 导出,避免在 actors 里重复 `UserStorage.getInstance()`。
|
||||
*/
|
||||
export const userStorage = UserStorage.getInstance();
|
||||
|
||||
// ============================================================
|
||||
// InitData 形状
|
||||
// ============================================================
|
||||
/**
|
||||
* `userInitActor` 的输出形状(user-machine.initializing.onDone 用)——
|
||||
* 拆分 user 和 avatarUrl 是因为它们来自 UserStorage 两个独立接口。
|
||||
*/
|
||||
export interface InitData {
|
||||
user: UserView | null;
|
||||
avatarUrl: string | null;
|
||||
snapshot: UserEntitlementSnapshotData | null;
|
||||
}
|
||||
|
||||
export interface UserFetchData {
|
||||
user: UserView | null;
|
||||
snapshot: UserEntitlementSnapshotData | null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DTO → UserView 映射
|
||||
// ============================================================
|
||||
/**
|
||||
* UserView DTO → UserView 视图模型(纯函数,可单测)
|
||||
* - 与 chat 的 `localMessagesToUi` 同模式
|
||||
* - 默认值收敛在此:undefined → "" / 0 / false,避免下游到处判空
|
||||
*/
|
||||
export function toView(u: {
|
||||
id: string;
|
||||
username: string;
|
||||
countryCode?: string;
|
||||
dolBalance?: number;
|
||||
creditBalance?: number;
|
||||
dailyFreeChatLimit?: number;
|
||||
dailyFreeChatRemaining?: number;
|
||||
dailyFreePrivateLimit?: number;
|
||||
dailyFreePrivateRemaining?: number;
|
||||
isVip?: boolean;
|
||||
}): UserView {
|
||||
const creditBalance = u.creditBalance ?? u.dolBalance ?? 0;
|
||||
|
||||
return {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
countryCode: u.countryCode ?? "",
|
||||
creditBalance,
|
||||
dailyFreeChatLimit: u.dailyFreeChatLimit ?? 0,
|
||||
dailyFreeChatRemaining: u.dailyFreeChatRemaining ?? 0,
|
||||
dailyFreePrivateLimit: u.dailyFreePrivateLimit ?? 0,
|
||||
dailyFreePrivateRemaining: u.dailyFreePrivateRemaining ?? 0,
|
||||
isVip: u.isVip ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyEntitlementSnapshotToView(
|
||||
user: UserView | null,
|
||||
snapshot: UserEntitlementSnapshotData | null,
|
||||
): UserView | null {
|
||||
if (!user || !snapshot) return user;
|
||||
return {
|
||||
...user,
|
||||
isVip: snapshot.isVip,
|
||||
creditBalance: snapshot.creditBalance,
|
||||
};
|
||||
}
|
||||
|
||||
export function toEntitlementSnapshot(input: {
|
||||
isVip: boolean;
|
||||
creditBalance: number;
|
||||
}): UserEntitlementSnapshotData {
|
||||
return {
|
||||
isVip: input.isVip,
|
||||
creditBalance: input.creditBalance,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Init 数据加载(userInitActor 用)
|
||||
// ============================================================
|
||||
/**
|
||||
* 并行读 user + avatarUrl(UserStorage 两个独立接口)。
|
||||
* - 任意一个失败 / 无数据 → 对应字段返回 null,不抛错(与 chat 的
|
||||
* `readAndSyncHistory` "失败不卡 init" 一致的设计)
|
||||
* - Result.isOk(_) && data 双重判空:Result 包装层 + 业务 null
|
||||
*/
|
||||
export async function readInitData(): Promise<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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user