refactor(stores): standardize state machine structure
This commit is contained in:
+1
-3
@@ -43,8 +43,6 @@
|
|||||||
"./src/app/subscription/components",
|
"./src/app/subscription/components",
|
||||||
"./src/hooks",
|
"./src/hooks",
|
||||||
"./src/integrations",
|
"./src/integrations",
|
||||||
"./src/utils",
|
"./src/utils"
|
||||||
"./src/stores/auth",
|
|
||||||
"./src/stores/user"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { AuthProvider } from "@/lib/auth/auth_platform";
|
||||||
import type { AuthPanelMode } from "@/data/dto/auth";
|
import type { AuthPanelMode } from "@/data/dto/auth";
|
||||||
import type { BindFacebookIdentityInput } from "./auth-actors";
|
|
||||||
|
export interface BindFacebookIdentityInput {
|
||||||
|
asid?: string;
|
||||||
|
psid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type AuthEvent =
|
export type AuthEvent =
|
||||||
// 内部 UI 事件
|
// 内部 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 {
|
import {
|
||||||
emailLoginActor,
|
authMachineSetup,
|
||||||
emailRegisterThenLoginActor,
|
authRootStateConfig,
|
||||||
oauthSignInActor,
|
} from "./machine/session-flow";
|
||||||
syncGoogleBackendActor,
|
import { initialState } from "./auth-state";
|
||||||
syncFacebookBackendActor,
|
|
||||||
guestLoginActor,
|
|
||||||
checkAuthStatusActor,
|
|
||||||
bindFacebookIdentityActor,
|
|
||||||
logoutActor,
|
|
||||||
} from "./auth-actors";
|
|
||||||
|
|
||||||
// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变
|
|
||||||
export type { AuthState } from "./auth-state";
|
export type { AuthState } from "./auth-state";
|
||||||
export { initialState } from "./auth-state";
|
export { initialState } from "./auth-state";
|
||||||
export type { AuthEvent } from "./auth-events";
|
export type { AuthEvent } from "./auth-events";
|
||||||
|
|
||||||
const toAuthErrorMessage = (error: unknown): string =>
|
export const authMachine = authMachineSetup.createMachine({
|
||||||
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 */
|
|
||||||
id: "auth",
|
id: "auth",
|
||||||
initial: "idle",
|
|
||||||
context: initialState,
|
context: initialState,
|
||||||
states: {
|
...authRootStateConfig,
|
||||||
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,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AuthMachine = typeof authMachine;
|
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 Auth store barrel. */
|
||||||
* @file Automatically generated by barrelsby.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from "./auth-actors";
|
|
||||||
export * from "./auth-context";
|
export * from "./auth-context";
|
||||||
export * from "./auth-events";
|
export * from "./auth-events";
|
||||||
export * from "./auth-helpers";
|
export * from "./helper";
|
||||||
export * from "./auth-machine";
|
export * from "./auth-machine";
|
||||||
export * from "./auth-state";
|
export * from "./auth-state";
|
||||||
export * from "./auth-status-checker";
|
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 Chat store barrel. */
|
||||||
* @file Automatically generated by barrelsby.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from "./chat-context";
|
export * from "./chat-context";
|
||||||
export * from "./chat-events";
|
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 { PaymentPlan, type PaymentPlansResponse } from "@/data/dto/payment";
|
||||||
|
|
||||||
import type { PaymentState } from "./payment-state";
|
import type { PaymentState } from "../payment-state";
|
||||||
|
import { resetOrderState } from "./order";
|
||||||
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 defaultAutoRenewForPlan(
|
export function defaultAutoRenewForPlan(
|
||||||
planId: string,
|
planId: string,
|
||||||
@@ -175,14 +159,3 @@ export function selectPlanState(
|
|||||||
autoRenew: defaultAutoRenewForPlan(planId, plans),
|
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-context";
|
||||||
export * from "./payment-events";
|
export * from "./payment-events";
|
||||||
|
export * from "./helper";
|
||||||
export * from "./payment-machine";
|
export * from "./payment-machine";
|
||||||
export * from "./payment-machine.actors";
|
|
||||||
export * from "./payment-state";
|
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 {
|
import {
|
||||||
createPaymentOrderActor,
|
paymentMachineSetup,
|
||||||
loadCachedPaymentPlansActor,
|
paymentRootStateConfig,
|
||||||
pollPaymentOrderStatusActor,
|
} from "./machine/order-flow";
|
||||||
refreshPaymentPlansActor,
|
import { initialState } from "./payment-state";
|
||||||
} from "./payment-machine.actors";
|
|
||||||
import {
|
|
||||||
hasOrderPollingTimedOut,
|
|
||||||
consumeFirstRechargeState,
|
|
||||||
hydratePlansResponseState,
|
|
||||||
PAYMENT_TIMEOUT_ERROR_MESSAGE,
|
|
||||||
refreshPlansResponseState,
|
|
||||||
resetOrderState,
|
|
||||||
selectPlanState,
|
|
||||||
toPaymentErrorMessage,
|
|
||||||
} from "./payment-machine.helpers";
|
|
||||||
|
|
||||||
export type { PaymentEvent } from "./payment-events";
|
export type { PaymentEvent } from "./payment-events";
|
||||||
export type { PaymentState } from "./payment-state";
|
export type { PaymentState } from "./payment-state";
|
||||||
export { initialState } from "./payment-state";
|
export { initialState } from "./payment-state";
|
||||||
|
|
||||||
const POLL_DELAY_MS = 4000;
|
export const paymentMachine = paymentMachineSetup.createMachine({
|
||||||
|
|
||||||
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 */
|
|
||||||
id: "payment",
|
id: "payment",
|
||||||
initial: "idle",
|
|
||||||
context: initialState,
|
context: initialState,
|
||||||
states: {
|
...paymentRootStateConfig,
|
||||||
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,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type PaymentMachine = typeof paymentMachine;
|
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-context";
|
||||||
export * from "./private-room-events";
|
export * from "./private-room-events";
|
||||||
|
export * from "./helper";
|
||||||
export * from "./private-room-machine";
|
export * from "./private-room-machine";
|
||||||
export * from "./private-room-state";
|
export * from "./private-room-state";
|
||||||
|
|||||||
+3
-5
@@ -10,12 +10,11 @@ import { Result } from "@/utils/result";
|
|||||||
import {
|
import {
|
||||||
PRIVATE_ROOM_CHARACTER,
|
PRIVATE_ROOM_CHARACTER,
|
||||||
PRIVATE_ROOM_PAGE_SIZE,
|
PRIVATE_ROOM_PAGE_SIZE,
|
||||||
} from "./private-room-machine.helpers";
|
} from "../../helper/albums";
|
||||||
|
|
||||||
export const loadPrivateAlbumsActor =
|
export const loadPrivateAlbumsActor =
|
||||||
fromPromise<PrivateAlbumsResponse>(async () => {
|
fromPromise<PrivateAlbumsResponse>(async () => {
|
||||||
const repo = getPrivateRoomRepository();
|
const result = await getPrivateRoomRepository().getAlbums({
|
||||||
const result = await repo.getAlbums({
|
|
||||||
character: PRIVATE_ROOM_CHARACTER,
|
character: PRIVATE_ROOM_CHARACTER,
|
||||||
limit: PRIVATE_ROOM_PAGE_SIZE,
|
limit: PRIVATE_ROOM_PAGE_SIZE,
|
||||||
});
|
});
|
||||||
@@ -27,8 +26,7 @@ export const unlockPrivateAlbumActor = fromPromise<
|
|||||||
PrivateAlbumUnlockResponse,
|
PrivateAlbumUnlockResponse,
|
||||||
{ albumId: string; expectedCost: number }
|
{ albumId: string; expectedCost: number }
|
||||||
>(async ({ input }) => {
|
>(async ({ input }) => {
|
||||||
const repo = getPrivateRoomRepository();
|
const result = await getPrivateRoomRepository().unlockAlbum(
|
||||||
const result = await repo.unlockAlbum(
|
|
||||||
input.albumId,
|
input.albumId,
|
||||||
input.expectedCost,
|
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 {
|
import {
|
||||||
applyAlbumsResponse,
|
privateRoomMachineSetup,
|
||||||
patchAlbumInList,
|
privateRoomRootStateConfig,
|
||||||
removeAlbum,
|
} from "./machine/unlock-flow";
|
||||||
toPaywallRequest,
|
import { initialState } from "./private-room-state";
|
||||||
toPrivateRoomErrorMessage,
|
|
||||||
toUnlockErrorMessage,
|
|
||||||
} from "./private-room-machine.helpers";
|
|
||||||
import {
|
|
||||||
loadPrivateAlbumsActor,
|
|
||||||
unlockPrivateAlbumActor,
|
|
||||||
} from "./private-room-machine.actors";
|
|
||||||
import { initialState, type PrivateRoomState } from "./private-room-state";
|
|
||||||
|
|
||||||
export type { PrivateRoomEvent } from "./private-room-events";
|
export type { PrivateRoomEvent } from "./private-room-events";
|
||||||
export type { PrivateRoomState } from "./private-room-state";
|
export type { PrivateRoomState } from "./private-room-state";
|
||||||
export { initialState } from "./private-room-state";
|
export { initialState } from "./private-room-state";
|
||||||
|
|
||||||
function getPendingAlbum(context: PrivateRoomState): PrivateAlbum | null {
|
export const privateRoomMachine = privateRoomMachineSetup.createMachine({
|
||||||
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({
|
|
||||||
id: "privateRoom",
|
id: "privateRoom",
|
||||||
initial: "idle",
|
|
||||||
context: initialState,
|
context: initialState,
|
||||||
states: {
|
...privateRoomRootStateConfig,
|
||||||
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),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type PrivateRoomMachine = typeof privateRoomMachine;
|
export type PrivateRoomMachine = typeof privateRoomMachine;
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
applyEntitlementSnapshotToView,
|
applyEntitlementSnapshotToView,
|
||||||
toView,
|
toView,
|
||||||
} from "@/stores/user/user-machine.helpers";
|
} from "@/stores/user/helper";
|
||||||
|
|
||||||
describe("toView", () => {
|
describe("toView", () => {
|
||||||
it("fills optional user fields with stable defaults", () => {
|
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 User store barrel. */
|
||||||
* @file Automatically generated by barrelsby.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from "./user-context";
|
export * from "./user-context";
|
||||||
export * from "./user-events";
|
export * from "./user-events";
|
||||||
export * from "./user-machine.actors";
|
export * from "./helper";
|
||||||
export * from "./user-machine.helpers";
|
|
||||||
export * from "./user-machine";
|
export * from "./user-machine";
|
||||||
export * from "./user-state";
|
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 {
|
import {
|
||||||
userInitActor,
|
userMachineSetup,
|
||||||
userFetchActor,
|
userRootStateConfig,
|
||||||
userLogoutActor,
|
} from "./machine/session-flow";
|
||||||
} from "./user-machine.actors";
|
import { initialState } from "./user-state";
|
||||||
import { applyEntitlementSnapshotToView } from "./user-machine.helpers";
|
|
||||||
|
|
||||||
// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变
|
|
||||||
export type { UserState } from "./user-state";
|
export type { UserState } from "./user-state";
|
||||||
export { initialState } from "./user-state";
|
export { initialState } from "./user-state";
|
||||||
export type { UserEvent } from "./user-events";
|
export type { UserEvent } from "./user-events";
|
||||||
|
|
||||||
// ============================================================
|
export const userMachine = userMachineSetup.createMachine({
|
||||||
// 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({
|
|
||||||
id: "user",
|
id: "user",
|
||||||
initial: "idle",
|
|
||||||
context: initialState,
|
context: initialState,
|
||||||
states: {
|
...userRootStateConfig,
|
||||||
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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UserMachine = typeof userMachine;
|
export type UserMachine = typeof userMachine;
|
||||||
|
|||||||
Reference in New Issue
Block a user