refactor(stores): standardize state machine structure
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyEntitlementSnapshotToView,
|
||||
toView,
|
||||
} from "@/stores/user/user-machine.helpers";
|
||||
} from "@/stores/user/helper";
|
||||
|
||||
describe("toView", () => {
|
||||
it("fills optional user fields with stable defaults", () => {
|
||||
@@ -0,0 +1,77 @@
|
||||
import { vi } from "vitest";
|
||||
import { fromPromise } from "xstate";
|
||||
|
||||
import type { UserView } from "@/data/dto/user";
|
||||
import { userMachine } from "@/stores/user/user-machine";
|
||||
import type { UserFetchData } from "@/stores/user/machine/actors/profile";
|
||||
import type { InitData } from "@/stores/user/machine/actors/session";
|
||||
|
||||
export function makeUser(overrides: Partial<UserView> = {}): UserView {
|
||||
return {
|
||||
id: "user-1",
|
||||
username: "Elio Fan",
|
||||
countryCode: "HK",
|
||||
creditBalance: 7,
|
||||
dailyFreeChatLimit: 30,
|
||||
dailyFreeChatRemaining: 30,
|
||||
dailyFreePrivateLimit: 2,
|
||||
dailyFreePrivateRemaining: 2,
|
||||
isVip: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeEntitlementSnapshot(
|
||||
overrides: Partial<InitData["snapshot"]> = {},
|
||||
): NonNullable<InitData["snapshot"]> {
|
||||
return {
|
||||
isVip: false,
|
||||
creditBalance: 7,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTestUserMachine(
|
||||
overrides: Partial<{
|
||||
initData: InitData;
|
||||
initError: Error;
|
||||
fetchedUser: UserView | null;
|
||||
fetchedData: UserFetchData;
|
||||
fetchError: Error;
|
||||
logoutSpy: () => void;
|
||||
logoutError: Error;
|
||||
}> = {},
|
||||
) {
|
||||
const logoutSpy = overrides.logoutSpy ?? vi.fn<() => void>();
|
||||
|
||||
return userMachine.provide({
|
||||
actors: {
|
||||
userInit: fromPromise<InitData>(async () => {
|
||||
if (overrides.initError) throw overrides.initError;
|
||||
return {
|
||||
user: overrides.initData?.user ?? makeUser(),
|
||||
avatarUrl:
|
||||
overrides.initData?.avatarUrl ?? "https://example.com/avatar.png",
|
||||
snapshot: overrides.initData?.snapshot ?? null,
|
||||
};
|
||||
}),
|
||||
userFetch: fromPromise<UserFetchData>(async () => {
|
||||
if (overrides.fetchError) throw overrides.fetchError;
|
||||
if ("fetchedData" in overrides && overrides.fetchedData) {
|
||||
return overrides.fetchedData;
|
||||
}
|
||||
if ("fetchedUser" in overrides) {
|
||||
return { user: overrides.fetchedUser ?? null, snapshot: null };
|
||||
}
|
||||
return {
|
||||
user: makeUser({ username: "Fetched" }),
|
||||
snapshot: null,
|
||||
};
|
||||
}),
|
||||
userLogout: fromPromise<void>(async () => {
|
||||
logoutSpy();
|
||||
if (overrides.logoutError) throw overrides.logoutError;
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createActor, fromPromise, waitFor } from "xstate";
|
||||
|
||||
import type { UserView } from "@/data/dto/user";
|
||||
import { userMachine } from "@/stores/user/user-machine";
|
||||
import type {
|
||||
InitData,
|
||||
UserFetchData,
|
||||
} from "@/stores/user/user-machine.helpers";
|
||||
|
||||
function makeUser(overrides: Partial<UserView> = {}): UserView {
|
||||
return {
|
||||
id: "user-1",
|
||||
username: "Elio Fan",
|
||||
countryCode: "HK",
|
||||
creditBalance: 7,
|
||||
dailyFreeChatLimit: 30,
|
||||
dailyFreeChatRemaining: 30,
|
||||
dailyFreePrivateLimit: 2,
|
||||
dailyFreePrivateRemaining: 2,
|
||||
isVip: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEntitlementSnapshot(
|
||||
overrides: Partial<InitData["snapshot"]> = {},
|
||||
): NonNullable<InitData["snapshot"]> {
|
||||
return {
|
||||
isVip: false,
|
||||
creditBalance: 7,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestUserMachine(
|
||||
overrides: Partial<{
|
||||
initData: InitData;
|
||||
fetchedUser: UserView | null;
|
||||
fetchedData: UserFetchData;
|
||||
logoutSpy: () => void;
|
||||
}> = {},
|
||||
) {
|
||||
const logoutSpy = overrides.logoutSpy ?? vi.fn<() => void>();
|
||||
|
||||
return userMachine.provide({
|
||||
actors: {
|
||||
userInit: fromPromise<InitData>(async () => ({
|
||||
user: overrides.initData?.user ?? makeUser(),
|
||||
avatarUrl:
|
||||
overrides.initData?.avatarUrl ?? "https://example.com/avatar.png",
|
||||
snapshot: overrides.initData?.snapshot ?? null,
|
||||
})),
|
||||
userFetch: fromPromise<UserFetchData>(async () => {
|
||||
if ("fetchedData" in overrides && overrides.fetchedData) {
|
||||
return overrides.fetchedData;
|
||||
}
|
||||
if ("fetchedUser" in overrides) {
|
||||
return {
|
||||
user: overrides.fetchedUser ?? null,
|
||||
snapshot: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
user: makeUser({ username: "Fetched" }),
|
||||
snapshot: null,
|
||||
};
|
||||
}),
|
||||
userLogout: fromPromise<void>(async () => {
|
||||
logoutSpy();
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("userMachine", () => {
|
||||
it("initializes user and avatar from local storage actor output", async () => {
|
||||
const actor = createActor(
|
||||
createTestUserMachine({
|
||||
initData: {
|
||||
user: makeUser({ username: "Local User" }),
|
||||
avatarUrl: "https://example.com/local-avatar.png",
|
||||
snapshot: null,
|
||||
},
|
||||
fetchedUser: null,
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "UserInit" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
|
||||
|
||||
expect(actor.getSnapshot().context.currentUser?.username).toBe("Local User");
|
||||
expect(actor.getSnapshot().context.avatarUrl).toBe(
|
||||
"https://example.com/local-avatar.png",
|
||||
);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("updates user profile synchronously", () => {
|
||||
const actor = createActor(createTestUserMachine()).start();
|
||||
|
||||
actor.send({ type: "UserUpdate", user: makeUser({ username: "After" }) });
|
||||
|
||||
expect(actor.getSnapshot().context.currentUser?.username).toBe("After");
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("fetches the latest user and clears loading state when done", async () => {
|
||||
const actor = createActor(
|
||||
createTestUserMachine({
|
||||
fetchedData: {
|
||||
user: makeUser({ username: "Fresh User" }),
|
||||
snapshot: makeEntitlementSnapshot({
|
||||
isVip: true,
|
||||
creditBalance: 120,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "UserFetch" });
|
||||
expect(actor.getSnapshot().context.isLoading).toBe(true);
|
||||
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
|
||||
|
||||
const context = actor.getSnapshot().context;
|
||||
expect(context.currentUser?.username).toBe("Fresh User");
|
||||
expect(context.currentUser?.isVip).toBe(true);
|
||||
expect(context.currentUser?.creditBalance).toBe(120);
|
||||
expect(context.isVip).toBe(true);
|
||||
expect(context.creditBalance).toBe(120);
|
||||
expect(context.isLoading).toBe(false);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("keeps the current user when fetch returns null", async () => {
|
||||
const actor = createActor(
|
||||
createTestUserMachine({
|
||||
fetchedUser: null,
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "UserUpdate", user: makeUser({ username: "Cached" }) });
|
||||
actor.send({ type: "UserFetch" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
|
||||
|
||||
const context = actor.getSnapshot().context;
|
||||
expect(context.currentUser?.username).toBe("Cached");
|
||||
expect(context.isLoading).toBe(false);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("clears user data after logout", async () => {
|
||||
const logoutSpy = vi.fn<() => void>();
|
||||
const actor = createActor(createTestUserMachine({ logoutSpy })).start();
|
||||
|
||||
actor.send({ type: "UserUpdate", user: makeUser() });
|
||||
actor.send({ type: "UserLogout" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
|
||||
|
||||
const context = actor.getSnapshot().context;
|
||||
expect(logoutSpy).toHaveBeenCalledOnce();
|
||||
expect(context.currentUser).toBeNull();
|
||||
expect(context.avatarUrl).toBeNull();
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createActor, waitFor } from "xstate";
|
||||
|
||||
import {
|
||||
createTestUserMachine,
|
||||
makeEntitlementSnapshot,
|
||||
makeUser,
|
||||
} from "./user-machine.test-utils";
|
||||
|
||||
describe("user profile flow", () => {
|
||||
it("updates user profile synchronously", () => {
|
||||
const actor = createActor(createTestUserMachine()).start();
|
||||
actor.send({ type: "UserUpdate", user: makeUser({ username: "After" }) });
|
||||
expect(actor.getSnapshot().context.currentUser?.username).toBe("After");
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("fetches the latest user and entitlements", async () => {
|
||||
const actor = createActor(
|
||||
createTestUserMachine({
|
||||
fetchedData: {
|
||||
user: makeUser({ username: "Fresh User" }),
|
||||
snapshot: makeEntitlementSnapshot({
|
||||
isVip: true,
|
||||
creditBalance: 120,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
).start();
|
||||
actor.send({ type: "UserFetch" });
|
||||
expect(actor.getSnapshot().context.isLoading).toBe(true);
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
|
||||
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
isVip: true,
|
||||
creditBalance: 120,
|
||||
isLoading: false,
|
||||
});
|
||||
expect(actor.getSnapshot().context.currentUser).toMatchObject({
|
||||
username: "Fresh User",
|
||||
isVip: true,
|
||||
creditBalance: 120,
|
||||
});
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("keeps current user when fetch returns null", async () => {
|
||||
const actor = createActor(
|
||||
createTestUserMachine({ fetchedUser: null }),
|
||||
).start();
|
||||
actor.send({ type: "UserUpdate", user: makeUser({ username: "Cached" }) });
|
||||
actor.send({ type: "UserFetch" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
|
||||
expect(actor.getSnapshot().context.currentUser?.username).toBe("Cached");
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("clears loading state when fetch actor fails", async () => {
|
||||
const actor = createActor(
|
||||
createTestUserMachine({ fetchError: new Error("fetch failed") }),
|
||||
).start();
|
||||
actor.send({ type: "UserFetch" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
|
||||
expect(actor.getSnapshot().context.isLoading).toBe(false);
|
||||
actor.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createActor, waitFor } from "xstate";
|
||||
|
||||
import {
|
||||
createTestUserMachine,
|
||||
makeUser,
|
||||
} from "./user-machine.test-utils";
|
||||
|
||||
describe("user session flow", () => {
|
||||
it("initializes user and avatar from actor output", async () => {
|
||||
const actor = createActor(
|
||||
createTestUserMachine({
|
||||
initData: {
|
||||
user: makeUser({ username: "Local User" }),
|
||||
avatarUrl: "https://example.com/local-avatar.png",
|
||||
snapshot: null,
|
||||
},
|
||||
fetchedUser: null,
|
||||
}),
|
||||
).start();
|
||||
actor.send({ type: "UserInit" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
|
||||
|
||||
expect(actor.getSnapshot().context.currentUser?.username).toBe(
|
||||
"Local User",
|
||||
);
|
||||
expect(actor.getSnapshot().context.avatarUrl).toBe(
|
||||
"https://example.com/local-avatar.png",
|
||||
);
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("continues to fetch when initialization fails", async () => {
|
||||
const actor = createActor(
|
||||
createTestUserMachine({ initError: new Error("storage unavailable") }),
|
||||
).start();
|
||||
actor.send({ type: "UserInit" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
|
||||
expect(actor.getSnapshot().context.currentUser?.username).toBe("Fetched");
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("clears user data after logout", async () => {
|
||||
const logoutSpy = vi.fn<() => void>();
|
||||
const actor = createActor(
|
||||
createTestUserMachine({ logoutSpy }),
|
||||
).start();
|
||||
actor.send({ type: "UserUpdate", user: makeUser() });
|
||||
actor.send({ type: "UserLogout" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
|
||||
|
||||
expect(logoutSpy).toHaveBeenCalledOnce();
|
||||
expect(actor.getSnapshot().context.currentUser).toBeNull();
|
||||
expect(actor.getSnapshot().context.avatarUrl).toBeNull();
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("clears user data even when logout actor fails", async () => {
|
||||
const actor = createActor(
|
||||
createTestUserMachine({ logoutError: new Error("logout failed") }),
|
||||
).start();
|
||||
actor.send({ type: "UserUpdate", user: makeUser() });
|
||||
actor.send({ type: "UserLogout" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
|
||||
expect(actor.getSnapshot().context.currentUser).toBeNull();
|
||||
actor.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./view";
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { UserView } from "@/data/dto/user";
|
||||
import type { UserEntitlementSnapshotData } from "@/data/schemas/user";
|
||||
|
||||
export function toView(u: {
|
||||
id: string;
|
||||
username: string;
|
||||
countryCode?: string;
|
||||
dolBalance?: number;
|
||||
creditBalance?: number;
|
||||
dailyFreeChatLimit?: number;
|
||||
dailyFreeChatRemaining?: number;
|
||||
dailyFreePrivateLimit?: number;
|
||||
dailyFreePrivateRemaining?: number;
|
||||
isVip?: boolean;
|
||||
}): UserView {
|
||||
const creditBalance = u.creditBalance ?? u.dolBalance ?? 0;
|
||||
|
||||
return {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
countryCode: u.countryCode ?? "",
|
||||
creditBalance,
|
||||
dailyFreeChatLimit: u.dailyFreeChatLimit ?? 0,
|
||||
dailyFreeChatRemaining: u.dailyFreeChatRemaining ?? 0,
|
||||
dailyFreePrivateLimit: u.dailyFreePrivateLimit ?? 0,
|
||||
dailyFreePrivateRemaining: u.dailyFreePrivateRemaining ?? 0,
|
||||
isVip: u.isVip ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyEntitlementSnapshotToView(
|
||||
user: UserView | null,
|
||||
snapshot: UserEntitlementSnapshotData | null,
|
||||
): UserView | null {
|
||||
if (!user || !snapshot) return user;
|
||||
return {
|
||||
...user,
|
||||
isVip: snapshot.isVip,
|
||||
creditBalance: snapshot.creditBalance,
|
||||
};
|
||||
}
|
||||
|
||||
export function toEntitlementSnapshot(input: {
|
||||
isVip: boolean;
|
||||
creditBalance: number;
|
||||
}): UserEntitlementSnapshotData {
|
||||
return {
|
||||
isVip: input.isVip,
|
||||
creditBalance: input.creditBalance,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
/** @file User store barrel. */
|
||||
|
||||
export * from "./user-context";
|
||||
export * from "./user-events";
|
||||
export * from "./user-machine.actors";
|
||||
export * from "./user-machine.helpers";
|
||||
export * from "./helper";
|
||||
export * from "./user-machine";
|
||||
export * from "./user-state";
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { fromPromise } from "xstate";
|
||||
|
||||
import type { UserView } from "@/data/dto/user";
|
||||
import { getUserRepository } from "@/data/repositories/user_repository";
|
||||
import type { UserEntitlementSnapshotData } from "@/data/schemas/user";
|
||||
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
import {
|
||||
applyEntitlementSnapshotToView,
|
||||
toEntitlementSnapshot,
|
||||
toView,
|
||||
} from "../../helper/view";
|
||||
|
||||
export interface UserFetchData {
|
||||
user: UserView | null;
|
||||
snapshot: UserEntitlementSnapshotData | null;
|
||||
}
|
||||
|
||||
export const userFetchActor = fromPromise<UserFetchData>(async () => {
|
||||
const userRepo = getUserRepository();
|
||||
const userStorage = UserStorage.getInstance();
|
||||
const [userResult, entitlementsResult] = await Promise.all([
|
||||
userRepo.getCurrentUser(),
|
||||
userRepo.getEntitlements(),
|
||||
]);
|
||||
|
||||
const snapshot = Result.isOk(entitlementsResult)
|
||||
? toEntitlementSnapshot({
|
||||
isVip: entitlementsResult.data.isVip,
|
||||
creditBalance: entitlementsResult.data.creditBalance,
|
||||
})
|
||||
: null;
|
||||
if (snapshot) await userStorage.setEntitlementSnapshot(snapshot);
|
||||
|
||||
if (Result.isErr(userResult)) return { user: null, snapshot };
|
||||
|
||||
const view = applyEntitlementSnapshotToView(
|
||||
toView(userResult.data.toJson()),
|
||||
snapshot,
|
||||
);
|
||||
await userStorage.setUser(userResult.data.toJson());
|
||||
return { user: view, snapshot };
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { fromPromise } from "xstate";
|
||||
|
||||
import type { UserView } from "@/data/dto/user";
|
||||
import { getAuthRepository } from "@/data/repositories/auth_repository";
|
||||
import type { UserEntitlementSnapshotData } from "@/data/schemas/user";
|
||||
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
import { applyEntitlementSnapshotToView, toView } from "../../helper/view";
|
||||
|
||||
export interface InitData {
|
||||
user: UserView | null;
|
||||
avatarUrl: string | null;
|
||||
snapshot: UserEntitlementSnapshotData | null;
|
||||
}
|
||||
|
||||
export const userInitActor = fromPromise<InitData>(async () => {
|
||||
const userStorage = UserStorage.getInstance();
|
||||
const [userResult, avatarResult, snapshotResult] = await Promise.all([
|
||||
userStorage.getUser(),
|
||||
userStorage.getAvatarUrl(),
|
||||
userStorage.getEntitlementSnapshot(),
|
||||
]);
|
||||
|
||||
const snapshot =
|
||||
Result.isOk(snapshotResult) && snapshotResult.data
|
||||
? snapshotResult.data
|
||||
: null;
|
||||
const user =
|
||||
Result.isOk(userResult) && userResult.data
|
||||
? applyEntitlementSnapshotToView(toView(userResult.data), snapshot)
|
||||
: null;
|
||||
const avatarUrl =
|
||||
Result.isOk(avatarResult) &&
|
||||
avatarResult.data &&
|
||||
avatarResult.data.length > 0
|
||||
? avatarResult.data
|
||||
: null;
|
||||
|
||||
return { user, avatarUrl, snapshot };
|
||||
});
|
||||
|
||||
export const userLogoutActor = fromPromise<void>(async () => {
|
||||
const result = await getAuthRepository().logout();
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { DoneActorEvent, ErrorActorEvent } from "xstate";
|
||||
|
||||
import { applyEntitlementSnapshotToView } from "../helper/view";
|
||||
import type { UserFetchData } from "./actors/profile";
|
||||
import {
|
||||
baseUserMachineSetup,
|
||||
createUserActorActionSetup,
|
||||
} from "./setup";
|
||||
|
||||
const updateUserAction = baseUserMachineSetup.assign(({ event }) =>
|
||||
event.type === "UserUpdate"
|
||||
? {
|
||||
currentUser: event.user,
|
||||
isVip: event.user.isVip,
|
||||
creditBalance: event.user.creditBalance,
|
||||
}
|
||||
: {},
|
||||
);
|
||||
|
||||
const markUserLoadingAction = baseUserMachineSetup.assign({
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
export const profileMachineSetup = baseUserMachineSetup.extend({
|
||||
actions: {
|
||||
updateUser: updateUserAction,
|
||||
markUserLoading: markUserLoadingAction,
|
||||
},
|
||||
});
|
||||
|
||||
const userFetchDoneSetup =
|
||||
createUserActorActionSetup<DoneActorEvent<UserFetchData>>();
|
||||
const userFetchErrorSetup = createUserActorActionSetup<ErrorActorEvent>();
|
||||
|
||||
const applyFetchedUserAction = userFetchDoneSetup.assign(
|
||||
({ context, event }) => {
|
||||
const snapshot =
|
||||
event.output.snapshot ??
|
||||
(context.currentUser
|
||||
? {
|
||||
isVip: context.isVip,
|
||||
creditBalance: context.creditBalance,
|
||||
}
|
||||
: null);
|
||||
const baseUser = event.output.user ?? context.currentUser;
|
||||
return {
|
||||
currentUser:
|
||||
applyEntitlementSnapshotToView(baseUser, snapshot) ??
|
||||
context.currentUser,
|
||||
isVip: snapshot?.isVip ?? context.isVip,
|
||||
creditBalance: snapshot?.creditBalance ?? context.creditBalance,
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const markUserFetchFailedAction = userFetchErrorSetup.assign({
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
export const fetchingState = profileMachineSetup.createStateConfig({
|
||||
entry: "markUserLoading",
|
||||
invoke: {
|
||||
src: "userFetch",
|
||||
onDone: {
|
||||
target: "idle",
|
||||
actions: applyFetchedUserAction,
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
actions: markUserFetchFailedAction,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { DoneActorEvent } from "xstate";
|
||||
|
||||
import { initialState } from "../user-state";
|
||||
import type { InitData } from "./actors/session";
|
||||
import { fetchingState, profileMachineSetup } from "./profile-flow";
|
||||
import { createUserActorActionSetup } from "./setup";
|
||||
|
||||
const clearUserAction = profileMachineSetup.assign(() => ({
|
||||
currentUser: null,
|
||||
avatarUrl: null,
|
||||
isVip: false,
|
||||
creditBalance: 0,
|
||||
}));
|
||||
|
||||
export const userMachineSetup = profileMachineSetup.extend({
|
||||
actions: {
|
||||
clearUser: clearUserAction,
|
||||
},
|
||||
});
|
||||
|
||||
const userInitDoneSetup =
|
||||
createUserActorActionSetup<DoneActorEvent<InitData>>();
|
||||
|
||||
const applyInitializedUserAction = userInitDoneSetup.assign(({ event }) => ({
|
||||
currentUser: event.output.user,
|
||||
avatarUrl: event.output.avatarUrl,
|
||||
isVip: event.output.snapshot?.isVip ?? initialState.isVip,
|
||||
creditBalance:
|
||||
event.output.snapshot?.creditBalance ?? initialState.creditBalance,
|
||||
}));
|
||||
|
||||
const idleState = userMachineSetup.createStateConfig({
|
||||
on: {
|
||||
UserInit: "initializing",
|
||||
UserFetch: "fetching",
|
||||
UserUpdate: { actions: "updateUser" },
|
||||
UserClearLocal: { actions: "clearUser" },
|
||||
UserLogout: "loggingOut",
|
||||
},
|
||||
});
|
||||
|
||||
const initializingState = userMachineSetup.createStateConfig({
|
||||
invoke: {
|
||||
src: "userInit",
|
||||
onDone: {
|
||||
target: "fetching",
|
||||
actions: applyInitializedUserAction,
|
||||
},
|
||||
onError: { target: "fetching" },
|
||||
},
|
||||
});
|
||||
|
||||
const loggingOutState = userMachineSetup.createStateConfig({
|
||||
invoke: {
|
||||
src: "userLogout",
|
||||
onDone: {
|
||||
target: "idle",
|
||||
actions: "clearUser",
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
actions: "clearUser",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const userRootStateConfig = userMachineSetup.createStateConfig({
|
||||
initial: "idle",
|
||||
states: {
|
||||
idle: idleState,
|
||||
initializing: initializingState,
|
||||
fetching: fetchingState,
|
||||
loggingOut: loggingOutState,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { setup, type ActionFunction, type EventObject } from "xstate";
|
||||
|
||||
import type { UserEvent } from "../user-events";
|
||||
import type { UserState } from "../user-state";
|
||||
import { userFetchActor } from "./actors/profile";
|
||||
import { userInitActor, userLogoutActor } from "./actors/session";
|
||||
|
||||
export const baseUserMachineSetup = setup({
|
||||
types: {
|
||||
context: {} as UserState,
|
||||
events: {} as UserEvent,
|
||||
},
|
||||
actors: {
|
||||
userInit: userInitActor,
|
||||
userFetch: userFetchActor,
|
||||
userLogout: userLogoutActor,
|
||||
},
|
||||
});
|
||||
|
||||
type UserActorAction<TEvent extends EventObject> = ActionFunction<
|
||||
UserState,
|
||||
TEvent,
|
||||
UserEvent,
|
||||
undefined,
|
||||
never,
|
||||
never,
|
||||
never,
|
||||
never,
|
||||
never
|
||||
>;
|
||||
|
||||
export function createUserActorActionSetup<TEvent extends EventObject>() {
|
||||
const actorActionSetup = setup({
|
||||
types: {
|
||||
context: {} as UserState,
|
||||
events: {} as TEvent,
|
||||
},
|
||||
});
|
||||
return {
|
||||
assign(
|
||||
assignment: Parameters<typeof actorActionSetup.assign>[0],
|
||||
): UserActorAction<TEvent> {
|
||||
return actorActionSetup.assign(assignment) as unknown as UserActorAction<TEvent>;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* User 状态机:Actors(异步服务)
|
||||
*
|
||||
* 从 `user-machine.ts` 抽出的"Actors"段:
|
||||
* - `userInitActor`(fromPromise:读 UserStorage 并行 user + avatarUrl)
|
||||
* - `userFetchActor`(fromPromise:调 API + 持久化到本地)
|
||||
* - `userLogoutActor`(fromPromise:auth logout + 清本地数据)
|
||||
*
|
||||
* 设计:
|
||||
* - 依赖 `user-machine.helpers.ts`(toView, readInitData, userStorage)
|
||||
* - 仓库使用懒单例 getter,actor 执行时才创建实例
|
||||
* - 命名约定:`[user-machine]` 前缀日志(如未来加)—— 与 chat 模块对齐
|
||||
*/
|
||||
|
||||
import { fromPromise } from "xstate";
|
||||
|
||||
import { getUserRepository } from "@/data/repositories/user_repository";
|
||||
import { getAuthRepository } from "@/data/repositories/auth_repository";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
import {
|
||||
applyEntitlementSnapshotToView,
|
||||
type InitData,
|
||||
readInitData,
|
||||
toEntitlementSnapshot,
|
||||
toView,
|
||||
type UserFetchData,
|
||||
userStorage,
|
||||
} from "./user-machine.helpers";
|
||||
|
||||
// ============================================================
|
||||
// Init actor:从 UserStorage 读 user + avatarUrl
|
||||
// ============================================================
|
||||
/**
|
||||
* 读本地 user + avatarUrl(并行)。
|
||||
* - 失败/无数据 → 返回 null,不抛错(machine 走 onDone 回到 idle,context 是 null)
|
||||
* - onDone 赋 currentUser + avatarUrl 到 context
|
||||
*/
|
||||
export const userInitActor = fromPromise<InitData>(async () => readInitData());
|
||||
|
||||
// ============================================================
|
||||
// Fetch actor:调 API 拉当前用户 + 持久化
|
||||
// ============================================================
|
||||
/**
|
||||
* 调 `userRepo.getCurrentUser + getEntitlements` 拉当前 user 与权益:
|
||||
* - user 成功 → 转 UserView + 写本地(userStorage.setUser)
|
||||
* - entitlements 成功 → 提取 isVip/creditBalance 写本地轻量快照
|
||||
* - 任一失败都不阻断另一方,machine 层负责保留已有 context
|
||||
*/
|
||||
export const userFetchActor = fromPromise<UserFetchData>(async () => {
|
||||
const userRepo = getUserRepository();
|
||||
const [userResult, entitlementsResult] = await Promise.all([
|
||||
userRepo.getCurrentUser(),
|
||||
userRepo.getEntitlements(),
|
||||
]);
|
||||
|
||||
const snapshot = Result.isOk(entitlementsResult)
|
||||
? toEntitlementSnapshot({
|
||||
isVip: entitlementsResult.data.isVip,
|
||||
creditBalance: entitlementsResult.data.creditBalance,
|
||||
})
|
||||
: null;
|
||||
if (snapshot) {
|
||||
await userStorage.setEntitlementSnapshot(snapshot);
|
||||
}
|
||||
|
||||
if (Result.isErr(userResult)) {
|
||||
return { user: null, snapshot };
|
||||
}
|
||||
|
||||
const view = applyEntitlementSnapshotToView(
|
||||
toView(userResult.data.toJson()),
|
||||
snapshot,
|
||||
);
|
||||
// 持久化到本地
|
||||
await userStorage.setUser(userResult.data.toJson());
|
||||
return { user: view, snapshot };
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Logout actor:后端登出 + 清本地用户数据
|
||||
// ============================================================
|
||||
/**
|
||||
* 登出:
|
||||
* - 调 `authRepo.logout` 让后端吊销 token
|
||||
* - `userStorage.clearUserData` 清本地 user / avatar 等
|
||||
* - onDone / onError 都跑 `clearUser` action(machine 层)—— 失败也清本地
|
||||
*/
|
||||
export const userLogoutActor = fromPromise<void>(async () => {
|
||||
const result = await getAuthRepository().logout();
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* User 状态机:纯函数 + 数据加载
|
||||
*
|
||||
* 从 `user-machine.ts` 抽出的"Helpers"段:
|
||||
* - InitData 形状
|
||||
* - `toView`:DTO → UserView 映射(纯函数)
|
||||
* - `readInitData`:从 UserStorage 读 user + avatarUrl(并行)
|
||||
* - `userStorage` 单例:被 helpers 和 actors 共用
|
||||
*
|
||||
* 设计目标:
|
||||
* - helpers 无 XState 依赖(可独立 import / 测试)
|
||||
* - actors 依赖 helpers(import)
|
||||
* - machine 依赖 actors(import)—— 不直接依赖 helpers
|
||||
*/
|
||||
|
||||
import type { UserView } from "@/data/dto/user";
|
||||
import type { UserEntitlementSnapshotData } from "@/data/schemas/user";
|
||||
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
// ============================================================
|
||||
// Storage singleton
|
||||
// ============================================================
|
||||
/**
|
||||
* UserStorage 单例 —— 被 helpers(readInitData)和 actors(userFetchActor / userLogoutActor)
|
||||
* 共同使用。从 helpers 导出,避免在 actors 里重复 `UserStorage.getInstance()`。
|
||||
*/
|
||||
export const userStorage = UserStorage.getInstance();
|
||||
|
||||
// ============================================================
|
||||
// InitData 形状
|
||||
// ============================================================
|
||||
/**
|
||||
* `userInitActor` 的输出形状(user-machine.initializing.onDone 用)——
|
||||
* 拆分 user 和 avatarUrl 是因为它们来自 UserStorage 两个独立接口。
|
||||
*/
|
||||
export interface InitData {
|
||||
user: UserView | null;
|
||||
avatarUrl: string | null;
|
||||
snapshot: UserEntitlementSnapshotData | null;
|
||||
}
|
||||
|
||||
export interface UserFetchData {
|
||||
user: UserView | null;
|
||||
snapshot: UserEntitlementSnapshotData | null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DTO → UserView 映射
|
||||
// ============================================================
|
||||
/**
|
||||
* UserView DTO → UserView 视图模型(纯函数,可单测)
|
||||
* - 与 chat 的 `localMessagesToUi` 同模式
|
||||
* - 默认值收敛在此:undefined → "" / 0 / false,避免下游到处判空
|
||||
*/
|
||||
export function toView(u: {
|
||||
id: string;
|
||||
username: string;
|
||||
countryCode?: string;
|
||||
dolBalance?: number;
|
||||
creditBalance?: number;
|
||||
dailyFreeChatLimit?: number;
|
||||
dailyFreeChatRemaining?: number;
|
||||
dailyFreePrivateLimit?: number;
|
||||
dailyFreePrivateRemaining?: number;
|
||||
isVip?: boolean;
|
||||
}): UserView {
|
||||
const creditBalance = u.creditBalance ?? u.dolBalance ?? 0;
|
||||
|
||||
return {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
countryCode: u.countryCode ?? "",
|
||||
creditBalance,
|
||||
dailyFreeChatLimit: u.dailyFreeChatLimit ?? 0,
|
||||
dailyFreeChatRemaining: u.dailyFreeChatRemaining ?? 0,
|
||||
dailyFreePrivateLimit: u.dailyFreePrivateLimit ?? 0,
|
||||
dailyFreePrivateRemaining: u.dailyFreePrivateRemaining ?? 0,
|
||||
isVip: u.isVip ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyEntitlementSnapshotToView(
|
||||
user: UserView | null,
|
||||
snapshot: UserEntitlementSnapshotData | null,
|
||||
): UserView | null {
|
||||
if (!user || !snapshot) return user;
|
||||
return {
|
||||
...user,
|
||||
isVip: snapshot.isVip,
|
||||
creditBalance: snapshot.creditBalance,
|
||||
};
|
||||
}
|
||||
|
||||
export function toEntitlementSnapshot(input: {
|
||||
isVip: boolean;
|
||||
creditBalance: number;
|
||||
}): UserEntitlementSnapshotData {
|
||||
return {
|
||||
isVip: input.isVip,
|
||||
creditBalance: input.creditBalance,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Init 数据加载(userInitActor 用)
|
||||
// ============================================================
|
||||
/**
|
||||
* 并行读 user + avatarUrl(UserStorage 两个独立接口)。
|
||||
* - 任意一个失败 / 无数据 → 对应字段返回 null,不抛错(与 chat 的
|
||||
* `readAndSyncHistory` "失败不卡 init" 一致的设计)
|
||||
* - Result.isOk(_) && data 双重判空:Result 包装层 + 业务 null
|
||||
*/
|
||||
export async function readInitData(): Promise<InitData> {
|
||||
const [userResult, avatarResult, snapshotResult] = await Promise.all([
|
||||
userStorage.getUser(),
|
||||
userStorage.getAvatarUrl(),
|
||||
userStorage.getEntitlementSnapshot(),
|
||||
]);
|
||||
|
||||
let snapshot: UserEntitlementSnapshotData | null = null;
|
||||
if (Result.isOk(snapshotResult) && snapshotResult.data) {
|
||||
snapshot = snapshotResult.data;
|
||||
}
|
||||
|
||||
let user: UserView | null = null;
|
||||
if (Result.isOk(userResult) && userResult.data) {
|
||||
user = applyEntitlementSnapshotToView(toView(userResult.data), snapshot);
|
||||
}
|
||||
|
||||
let avatarUrl: string | null = null;
|
||||
if (Result.isOk(avatarResult) && avatarResult.data && avatarResult.data.length > 0) {
|
||||
avatarUrl = avatarResult.data;
|
||||
}
|
||||
|
||||
return { user, avatarUrl, snapshot };
|
||||
}
|
||||
@@ -1,138 +1,17 @@
|
||||
/**
|
||||
* User 状态机(XState v5)
|
||||
*/
|
||||
import { setup, assign } from "xstate";
|
||||
|
||||
import { UserState, initialState } from "./user-state";
|
||||
import type { UserEvent } from "./user-events";
|
||||
import {
|
||||
userInitActor,
|
||||
userFetchActor,
|
||||
userLogoutActor,
|
||||
} from "./user-machine.actors";
|
||||
import { applyEntitlementSnapshotToView } from "./user-machine.helpers";
|
||||
userMachineSetup,
|
||||
userRootStateConfig,
|
||||
} from "./machine/session-flow";
|
||||
import { initialState } from "./user-state";
|
||||
|
||||
// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变
|
||||
export type { UserState } from "./user-state";
|
||||
export { initialState } from "./user-state";
|
||||
export type { UserEvent } from "./user-events";
|
||||
|
||||
// ============================================================
|
||||
// Machine
|
||||
// ============================================================
|
||||
export const userMachine = setup({
|
||||
types: {
|
||||
context: {} as UserState,
|
||||
events: {} as UserEvent,
|
||||
},
|
||||
actors: {
|
||||
userInit: userInitActor,
|
||||
userFetch: userFetchActor,
|
||||
userLogout: userLogoutActor,
|
||||
},
|
||||
actions: {
|
||||
updateUser: assign(({ event }) => {
|
||||
if (event.type !== "UserUpdate") return {};
|
||||
return {
|
||||
currentUser: event.user,
|
||||
isVip: event.user.isVip,
|
||||
creditBalance: event.user.creditBalance,
|
||||
};
|
||||
}),
|
||||
|
||||
clearUser: assign(() => ({
|
||||
currentUser: null,
|
||||
avatarUrl: null,
|
||||
isVip: false,
|
||||
creditBalance: 0,
|
||||
})),
|
||||
},
|
||||
}).createMachine({
|
||||
export const userMachine = userMachineSetup.createMachine({
|
||||
id: "user",
|
||||
initial: "idle",
|
||||
context: initialState,
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
UserInit: "initializing",
|
||||
UserFetch: "fetching",
|
||||
UserUpdate: {
|
||||
actions: "updateUser",
|
||||
},
|
||||
UserClearLocal: {
|
||||
actions: "clearUser",
|
||||
},
|
||||
UserLogout: "loggingOut",
|
||||
},
|
||||
},
|
||||
|
||||
initializing: {
|
||||
invoke: {
|
||||
src: "userInit",
|
||||
onDone: {
|
||||
target: "fetching",
|
||||
actions: assign(({ event }) => ({
|
||||
currentUser: event.output.user,
|
||||
avatarUrl: event.output.avatarUrl,
|
||||
isVip: event.output.snapshot?.isVip ?? initialState.isVip,
|
||||
creditBalance:
|
||||
event.output.snapshot?.creditBalance ??
|
||||
initialState.creditBalance,
|
||||
})),
|
||||
},
|
||||
onError: {
|
||||
target: "fetching",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
fetching: {
|
||||
invoke: {
|
||||
src: "userFetch",
|
||||
onDone: {
|
||||
target: "idle",
|
||||
actions: assign(({ context, event }) => {
|
||||
const snapshot =
|
||||
event.output.snapshot ??
|
||||
(context.currentUser
|
||||
? {
|
||||
isVip: context.isVip,
|
||||
creditBalance: context.creditBalance,
|
||||
}
|
||||
: null);
|
||||
const baseUser = event.output.user ?? context.currentUser;
|
||||
return {
|
||||
currentUser:
|
||||
applyEntitlementSnapshotToView(baseUser, snapshot) ??
|
||||
context.currentUser,
|
||||
isVip: snapshot?.isVip ?? context.isVip,
|
||||
creditBalance: snapshot?.creditBalance ?? context.creditBalance,
|
||||
isLoading: false,
|
||||
};
|
||||
}),
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
actions: assign({ isLoading: false }),
|
||||
},
|
||||
},
|
||||
entry: assign({ isLoading: true }),
|
||||
},
|
||||
|
||||
loggingOut: {
|
||||
invoke: {
|
||||
src: "userLogout",
|
||||
onDone: {
|
||||
target: "idle",
|
||||
actions: "clearUser",
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
actions: "clearUser",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
...userRootStateConfig,
|
||||
});
|
||||
|
||||
export type UserMachine = typeof userMachine;
|
||||
|
||||
Reference in New Issue
Block a user