refactor(data): replace schema classes with readonly models

This commit is contained in:
2026-07-17 13:21:40 +08:00
parent 3437312167
commit ae97366a4a
103 changed files with 1220 additions and 2117 deletions
@@ -1,20 +1,24 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { ChatSendResponse } from "@/data/schemas/chat";
import {
ChatSendResponseSchema,
type ChatSendResponse,
type ChatSendResponseInput,
} from "@/data/schemas/chat";
import type { ChatState } from "@/stores/chat/chat-state";
import {
applyNetworkHistoryLoadedOutput,
applyHttpSendOutput,
applyNetworkHistoryLoadedOutput,
countLockedHistoryMessages,
localMessagesToUi,
sendResponseToUiMessage,
} from "@/stores/chat/helper";
function makeResponse(
overrides: Partial<Parameters<typeof ChatSendResponse.from>[0]> = {},
overrides: Partial<ChatSendResponseInput> = {},
): ChatSendResponse {
return ChatSendResponse.from({
return ChatSendResponseSchema.parse({
reply: "AI reply",
messageId: "msg-1",
isGuest: false,
@@ -100,9 +104,7 @@ describe("sendResponseToUiMessage", () => {
}),
);
expect(message.audioUrl).toBe(
"https://example.com/unlocked-voice.mp3",
);
expect(message.audioUrl).toBe("https://example.com/unlocked-voice.mp3");
expect(message.locked).toBe(false);
});
@@ -1,12 +1,15 @@
import { fromCallback, fromPromise } from "xstate";
import {
ChatSendResponse,
UnlockPrivateResponse,
} from "@/data/schemas/chat";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { chatMachine } from "@/stores/chat/chat-machine";
import {
ChatSendResponseSchema,
UnlockPrivateResponseSchema,
type ChatSendResponse,
type UnlockPrivateResponse,
type UnlockPrivateResponseInput,
} from "@/data/schemas/chat";
import type { ChatEvent } from "@/stores/chat/chat-events";
import { chatMachine } from "@/stores/chat/chat-machine";
import type {
UnlockMessageOutput as MachineUnlockMessageOutput,
UnlockMessageRequest,
@@ -36,7 +39,7 @@ export const TEST_CHAT_MACHINE_INPUT = {
};
export function makeChatSendResponse(): ChatSendResponse {
return ChatSendResponse.from({
return ChatSendResponseSchema.parse({
reply: "",
audioUrl: "",
messageId: "msg-1",
@@ -51,9 +54,9 @@ export function makeChatSendResponse(): ChatSendResponse {
}
export function makeUnlockPrivateResponse(
overrides: Partial<Parameters<typeof UnlockPrivateResponse.from>[0]> = {},
overrides: Partial<UnlockPrivateResponseInput> = {},
): UnlockPrivateResponse {
return UnlockPrivateResponse.from({
return UnlockPrivateResponseSchema.parse({
unlocked: true,
content: "unlocked content",
audioUrl: "",
@@ -106,21 +109,20 @@ export function createTestChatMachine(
return () => undefined;
},
),
unlockHistory: fromPromise<
UnlockHistoryOutput,
{ characterId: string }
>(async () => {
if (options.unlockHistoryError) {
throw options.unlockHistoryError;
}
return {
unlocked: true,
reason: "ok",
shortfallCredits: 0,
messages: [],
...options.unlockHistoryOutput,
};
}),
unlockHistory: fromPromise<UnlockHistoryOutput, { characterId: string }>(
async () => {
if (options.unlockHistoryError) {
throw options.unlockHistoryError;
}
return {
unlocked: true,
reason: "ok",
shortfallCredits: 0,
messages: [],
...options.unlockHistoryOutput,
};
},
),
unlockMessage: fromPromise<
MachineUnlockMessageOutput,
UnlockMessageRequest & { characterId: string }
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { UnlockPrivateResponse } from "@/data/schemas/chat";
import { UnlockPrivateResponseSchema } from "@/data/schemas/chat";
import type { PendingChatPromotion } from "@/data/storage/navigation";
import {
appendPromotionMessage,
@@ -50,7 +50,7 @@ describe("chat promotion", () => {
lockType: "image_paywall",
clientLockId: "promotion-1",
},
response: UnlockPrivateResponse.from({
response: UnlockPrivateResponseSchema.parse({
unlocked: true,
messageId: "backend-1",
image: {
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { createActor, fromPromise, waitFor } from "xstate";
import { ChatSendResponse } from "@/data/schemas/chat";
import { ChatSendResponseSchema } from "@/data/schemas/chat";
import type {
UnlockMessageOutput,
UnlockMessageRequest,
@@ -666,7 +666,7 @@ describe("chat unlock flow", () => {
actor.send({
type: "ChatQueuedHttpDone",
output: {
response: ChatSendResponse.from({
response: ChatSendResponseSchema.parse({
reply: "",
audioUrl: "",
messageId: "",
@@ -1,7 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createActor, waitFor } from "xstate";
import { PaymentPlansResponse } from "@/data/schemas/payment";
import {
PaymentPlansResponse,
PaymentPlansResponseSchema,
} from "@/data/schemas/payment";
import {
createTestPaymentMachine,
@@ -32,7 +35,7 @@ describe("payment catalog flow", () => {
const refreshCatalog = vi.fn();
const actor = createActor(
createTestPaymentMachine({
refreshedPlans: PaymentPlansResponse.from({ plans: [tipPlan] }),
refreshedPlans: PaymentPlansResponseSchema.parse({ plans: [tipPlan] }),
onLoadCatalog: loadCatalog,
onRefreshCatalog: refreshCatalog,
}),
@@ -90,7 +93,7 @@ describe("payment catalog flow", () => {
it("preserves normal VIP original price outside first recharge", async () => {
const actor = createActor(
createTestPaymentMachine({
refreshedPlans: PaymentPlansResponse.from({
refreshedPlans: PaymentPlansResponseSchema.parse({
isFirstRecharge: false,
plans: [
{
@@ -120,7 +123,9 @@ describe("payment catalog flow", () => {
});
const actor = createActor(
createTestPaymentMachine({
cachedPlans: PaymentPlansResponse.from({ plans: [quarterlyPlan] }),
cachedPlans: PaymentPlansResponseSchema.parse({
plans: [quarterlyPlan],
}),
refreshPlans: async () => refreshPromise,
}),
).start();
@@ -131,7 +136,7 @@ describe("payment catalog flow", () => {
);
resolveRefresh(
PaymentPlansResponse.from({ plans: [monthlyPlan, lifetimePlan] }),
PaymentPlansResponseSchema.parse({ plans: [monthlyPlan, lifetimePlan] }),
);
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
expect(actor.getSnapshot().context.selectedPlanId).toBe("vip_monthly");
@@ -140,7 +145,7 @@ describe("payment catalog flow", () => {
});
function firstRechargeResponse(): PaymentPlansResponse {
return PaymentPlansResponse.from({
return PaymentPlansResponseSchema.parse({
isFirstRecharge: true,
firstRechargeOffer: {
enabled: true,
@@ -3,9 +3,11 @@ import { fromPromise } from "xstate";
import {
CreatePaymentOrderResponse,
CreatePaymentOrderResponseSchema,
type PayChannel,
PaymentOrderStatusResponse,
PaymentOrderStatusResponseSchema,
PaymentPlansResponse,
PaymentPlansResponseSchema,
} from "@/data/schemas/payment";
import { paymentMachine } from "@/stores/payment/payment-machine";
import type { PaymentPlanCatalog } from "@/stores/payment/payment-state";
@@ -114,39 +116,37 @@ export function createTestPaymentMachine(
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",
},
});
}),
refreshPlans: fromPromise<PaymentPlansResponse, PaymentPlansActorInput>(
async ({ input }) => {
overrides.onRefreshCatalog?.(input.catalog);
if (overrides.refreshPlans) return overrides.refreshPlans();
return (
overrides.refreshedPlans ??
PaymentPlansResponseSchema.parse({
plans: [monthlyPlan, lifetimePlan, creditPlan],
})
);
},
),
createOrder: fromPromise<CreatePaymentOrderResponse, CreateOrderInput>(
async ({ input }) => {
createOrderSpy(input);
if (overrides.createOrderError) throw overrides.createOrderError;
return CreatePaymentOrderResponseSchema.parse({
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({
return PaymentOrderStatusResponseSchema.parse({
orderId: "pay_test_001",
status,
orderType: "vip_monthly",
+18 -24
View File
@@ -1,4 +1,8 @@
import { PaymentPlan, type PaymentPlansResponse } from "@/data/schemas/payment";
import {
PaymentPlan,
PaymentPlanSchema,
type PaymentPlansResponse,
} from "@/data/schemas/payment";
import type { PaymentState } from "../payment-state";
import { resetOrderState } from "./order";
@@ -28,7 +32,7 @@ export function getDefaultPlanId(plans: readonly PaymentPlan[]): string {
}
export function hydratePlansState(
plans: PaymentPlan[],
plans: readonly PaymentPlan[],
selectedPlanId: string,
): Pick<
PaymentState,
@@ -48,11 +52,7 @@ export function hydratePlansResponseState(
selectedPlanId: string,
): Pick<
PaymentState,
| "plans"
| "isFirstRecharge"
| "selectedPlanId"
| "autoRenew"
| "errorMessage"
"plans" | "isFirstRecharge" | "selectedPlanId" | "autoRenew" | "errorMessage"
> {
const plans = normalizeFirstRechargePlans(
response.plans,
@@ -65,13 +65,15 @@ export function hydratePlansResponseState(
}
export function refreshPlansState(
plans: PaymentPlan[],
plans: readonly PaymentPlan[],
selectedPlanId: string,
): Pick<
PaymentState,
"plans" | "selectedPlanId" | "autoRenew" | "errorMessage"
> {
const nextSelectedPlanId = plans.some((plan) => plan.planId === selectedPlanId)
const nextSelectedPlanId = plans.some(
(plan) => plan.planId === selectedPlanId,
)
? selectedPlanId
: getDefaultPlanId(plans);
return {
@@ -87,11 +89,7 @@ export function refreshPlansResponseState(
selectedPlanId: string,
): Pick<
PaymentState,
| "plans"
| "isFirstRecharge"
| "selectedPlanId"
| "autoRenew"
| "errorMessage"
"plans" | "isFirstRecharge" | "selectedPlanId" | "autoRenew" | "errorMessage"
> {
const plans = normalizeFirstRechargePlans(
response.plans,
@@ -106,15 +104,15 @@ export function refreshPlansResponseState(
export function normalizeFirstRechargePlans(
plans: readonly PaymentPlan[],
isFirstRecharge: boolean,
): PaymentPlan[] {
): readonly PaymentPlan[] {
if (isFirstRecharge) return [...plans];
return plans.map(consumeFirstRechargePlan);
}
export function consumeFirstRechargePlan(plan: PaymentPlan): PaymentPlan {
if (!plan.isFirstRechargeOffer) {
return PaymentPlan.from({
...plan.toJson(),
return PaymentPlanSchema.parse({
...plan,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: null,
promotionType: null,
@@ -122,8 +120,8 @@ export function consumeFirstRechargePlan(plan: PaymentPlan): PaymentPlan {
}
const originalAmountCents = plan.originalAmountCents;
return PaymentPlan.from({
...plan.toJson(),
return PaymentPlanSchema.parse({
...plan,
amountCents: originalAmountCents ?? plan.amountCents,
originalAmountCents: originalAmountCents ?? plan.originalAmountCents,
isFirstRechargeOffer: false,
@@ -136,11 +134,7 @@ export function consumeFirstRechargeState(
context: PaymentState,
): Pick<
PaymentState,
| "plans"
| "isFirstRecharge"
| "selectedPlanId"
| "autoRenew"
| "errorMessage"
"plans" | "isFirstRecharge" | "selectedPlanId" | "autoRenew" | "errorMessage"
> {
const plans = context.plans.map(consumeFirstRechargePlan);
return {
+1 -1
View File
@@ -11,7 +11,7 @@ export type PaymentPlanCatalog = "default" | "tip";
export interface PaymentState {
planCatalog: PaymentPlanCatalog;
plans: PaymentPlan[];
plans: readonly PaymentPlan[];
isFirstRecharge: boolean;
selectedPlanId: string;
payChannel: PayChannel;
@@ -1,10 +1,14 @@
import { createActor, fromPromise, waitFor } from "xstate";
import {
PrivateAlbumsResponse,
PrivateAlbumUnlockResponse,
} from "@/data/schemas/private-room";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import {
PrivateAlbumUnlockResponseSchema,
PrivateAlbumsResponseSchema,
type PrivateAlbumUnlockResponse,
type PrivateAlbumUnlockResponseInput,
type PrivateAlbumsResponse,
type PrivateAlbumsResponseInput,
} from "@/data/schemas/private-room";
import { privateRoomMachine } from "@/stores/private-room/private-room-machine";
export const ALBUM_ID = "a1b2c3d4-0000-0000-0000-000000000000";
@@ -31,9 +35,9 @@ export function makeAlbum(index = 0) {
}
export function makeAlbumsResponse(
overrides: Partial<Parameters<typeof PrivateAlbumsResponse.from>[0]> = {},
overrides: Partial<PrivateAlbumsResponseInput> = {},
): PrivateAlbumsResponse {
return PrivateAlbumsResponse.from({
return PrivateAlbumsResponseSchema.parse({
items: [makeAlbum()],
creditBalance: 500,
...overrides,
@@ -41,9 +45,9 @@ export function makeAlbumsResponse(
}
export function makeUnlockResponse(
overrides: Partial<Parameters<typeof PrivateAlbumUnlockResponse.from>[0]> = {},
overrides: Partial<PrivateAlbumUnlockResponseInput> = {},
): PrivateAlbumUnlockResponse {
return PrivateAlbumUnlockResponse.from({
return PrivateAlbumUnlockResponseSchema.parse({
albumId: ALBUM_ID,
locked: false,
unlocked: true,
@@ -61,23 +65,24 @@ export function makeUnlockResponse(
});
}
export function createTestPrivateRoomMachine(options: {
loadResponse?: PrivateAlbumsResponse;
unlockResponse?: PrivateAlbumUnlockResponse;
loadError?: Error;
unlockError?: Error;
onLoad?: (input: { characterId: string }) => void;
} = {}) {
export function createTestPrivateRoomMachine(
options: {
loadResponse?: PrivateAlbumsResponse;
unlockResponse?: PrivateAlbumUnlockResponse;
loadError?: Error;
unlockError?: Error;
onLoad?: (input: { characterId: string }) => void;
} = {},
) {
return privateRoomMachine.provide({
actors: {
loadAlbums: fromPromise<
PrivateAlbumsResponse,
{ characterId: string }
>(async ({ input }) => {
options.onLoad?.(input);
if (options.loadError) throw options.loadError;
return options.loadResponse ?? makeAlbumsResponse();
}),
loadAlbums: fromPromise<PrivateAlbumsResponse, { characterId: string }>(
async ({ input }) => {
options.onLoad?.(input);
if (options.loadError) throw options.loadError;
return options.loadResponse ?? makeAlbumsResponse();
},
),
unlockAlbum: fromPromise(async () => {
if (options.unlockError) throw options.unlockError;
return options.unlockResponse ?? makeUnlockResponse();
+5 -4
View File
@@ -1,5 +1,6 @@
import {
PrivateAlbum,
PrivateAlbumSchema,
type PrivateAlbumsResponse,
type PrivateAlbumUnlockResponse,
} from "@/data/schemas/private-room";
@@ -33,8 +34,8 @@ export function patchAlbumFromUnlock(
album: PrivateAlbum,
response: PrivateAlbumUnlockResponse,
): PrivateAlbum {
return PrivateAlbum.from({
...album.toJson(),
return PrivateAlbumSchema.parse({
...album,
locked: response.locked,
unlocked: response.unlocked,
unlockCost: response.unlockCost || album.unlockCost,
@@ -46,7 +47,7 @@ export function patchAlbumFromUnlock(
export function patchAlbumInList(
items: readonly PrivateAlbum[],
response: PrivateAlbumUnlockResponse,
): PrivateAlbum[] {
): readonly PrivateAlbum[] {
return items.map((album) =>
album.albumId === response.albumId
? patchAlbumFromUnlock(album, response)
@@ -57,6 +58,6 @@ export function patchAlbumInList(
export function removeAlbum(
items: readonly PrivateAlbum[],
albumId: string,
): PrivateAlbum[] {
): readonly PrivateAlbum[] {
return items.filter((album) => album.albumId !== albumId);
}
@@ -1,5 +1,5 @@
import type { PrivateAlbum } from "@/data/schemas/private-room";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import type { PrivateAlbum } from "@/data/schemas/private-room";
export interface PrivateRoomUnlockPaywallRequest {
albumId: string;
@@ -11,7 +11,7 @@ export interface PrivateRoomUnlockPaywallRequest {
export interface PrivateRoomState {
characterId: string;
items: PrivateAlbum[];
items: readonly PrivateAlbum[];
creditBalance: number;
errorMessage: string | null;
unlockingAlbumId: string | null;
+3 -3
View File
@@ -1,9 +1,9 @@
import { fromPromise } from "xstate";
import type { UserView } from "@/stores/user/user-view";
import { getUserRepository } from "@/data/repositories/user_repository";
import type { UserEntitlementSnapshotData } from "@/data/schemas/user";
import { UserStorage } from "@/data/storage/user/user_storage";
import type { UserView } from "@/stores/user/user-view";
import { Result } from "@/utils/result";
import {
@@ -36,9 +36,9 @@ export const userFetchActor = fromPromise<UserFetchData>(async () => {
if (Result.isErr(userResult)) return { user: null, snapshot };
const view = applyEntitlementSnapshotToView(
toView(userResult.data.toJson()),
toView(userResult.data),
snapshot,
);
await userStorage.setUser(userResult.data.toJson());
await userStorage.setUser(userResult.data);
return { user: view, snapshot };
});