refactor(data): replace schema classes with readonly models
This commit is contained in:
@@ -12,7 +12,7 @@ description: Sync this Next.js frontend with backend API documentation
|
||||
当后端 API 新增、删除、字段结构调整、响应语义变化时,使用本 skill 更新:
|
||||
|
||||
- 网络层
|
||||
- schema / 不可变数据类
|
||||
- schema / 不可变纯数据
|
||||
- repository
|
||||
- storage
|
||||
- XState store
|
||||
@@ -23,10 +23,10 @@ description: Sync this Next.js frontend with backend API documentation
|
||||
## 当前项目结构
|
||||
|
||||
| 层级 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| -------------------- | --------------------------------------------------- | ----------------------------------------------- |
|
||||
| API Path | `src/data/services/api/api_path.ts` | 统一维护接口路径 |
|
||||
| API Service | `src/data/services/api/*_api.ts` | 调用 `httpClient` 并解析响应 |
|
||||
| Schema Model | `src/data/schemas/<module>/*.ts` | Zod schema 与不可变数据类,负责解析和序列化 |
|
||||
| Schema Model | `src/data/schemas/<module>/*.ts` | Zod schema 与不可变纯数据类型,负责解析和归一化 |
|
||||
| Repository Interface | `src/data/repositories/interfaces/i*_repository.ts` | 仓库接口 |
|
||||
| Repository | `src/data/repositories/*_repository.ts` | 业务仓库实现 |
|
||||
| Storage | `src/data/storage/*` | 本地持久化 |
|
||||
@@ -84,13 +84,16 @@ git status --short
|
||||
否则 schema 应尽量防御性解析,例如:
|
||||
|
||||
```ts
|
||||
z.string().nullable().transform((v) => v ?? "").default("")
|
||||
z.string()
|
||||
.nullable()
|
||||
.transform((v) => v ?? "")
|
||||
.default("");
|
||||
```
|
||||
|
||||
项目已有通用 helper:
|
||||
|
||||
```ts
|
||||
src/data/schemas/nullable-defaults.ts
|
||||
src / data / schemas / nullable - defaults.ts;
|
||||
```
|
||||
|
||||
### 3. 更新 API Path
|
||||
@@ -98,7 +101,7 @@ src/data/schemas/nullable-defaults.ts
|
||||
新增或修改:
|
||||
|
||||
```ts
|
||||
src/data/services/api/api_path.ts
|
||||
src / data / services / api / api_path.ts;
|
||||
```
|
||||
|
||||
示例:
|
||||
@@ -122,46 +125,32 @@ src/data/schemas/<module>/*.ts
|
||||
- 输入类型用 `z.input<typeof Schema>`。
|
||||
- 对后端可能返回 `null` 的字段做防御性处理。
|
||||
- 不要在 schema 中写 UI 逻辑。
|
||||
- Schema 与对应不可变数据类必须定义在同一个文件中。
|
||||
- 数据类的 `declare readonly` 只声明业务代码直接访问的字段。
|
||||
- Schema 输出必须是纯数据,不要定义 class 或工厂对象。
|
||||
- 对象 Schema 使用 `.readonly()`;已知嵌套对象、数组和 Record 也必须逐层 readonly。
|
||||
- transform 应先完成归一化,再在最终输出应用 `.readonly()`。
|
||||
- 容器默认值必须经过 Schema 解析;使用 `prefault` 或在最外层应用 readonly,不能让 `.default()` 返回未冻结对象。
|
||||
- 后端返回但前端既不读取、也不透传的字段,不要加入 Schema。
|
||||
- 仅用于请求序列化、响应兼容或内部透传的字段保留在 Schema 中,不声明 class 属性。
|
||||
- 数据类统一使用 `from()`、`fromJson()`、`toJson()` 与 `Object.freeze(this)`。
|
||||
- 没有嵌套转换或自定义方法时,使用 `src/data/schemas/schema_model.ts` 的 `createSchemaModel()`。
|
||||
- 仅用于请求序列化、响应兼容或内部透传的字段直接保留在 Schema 中。
|
||||
- 统一使用 `XxxSchema.parse()` 构造请求与解析响应,不增加 `from()`、`fromJson()` 或 `toJson()` 包装。
|
||||
- 不要再创建独立的数据传输对象层。
|
||||
|
||||
示例:
|
||||
|
||||
```ts
|
||||
export const XxxResponseSchema = z.object({
|
||||
export const XxxResponseSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
tags: z
|
||||
.array(z.string())
|
||||
.default(() => [])
|
||||
.readonly(),
|
||||
count: z.number().default(0),
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type XxxResponseInput = z.input<typeof XxxResponseSchema>;
|
||||
export type XxxResponseData = z.output<typeof XxxResponseSchema>;
|
||||
|
||||
export class XxxResponse {
|
||||
declare readonly id: string;
|
||||
|
||||
private constructor(input: XxxResponseInput) {
|
||||
const data = XxxResponseSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: XxxResponseInput): XxxResponse {
|
||||
return new XxxResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): XxxResponse {
|
||||
return XxxResponse.from(json as XxxResponseInput);
|
||||
}
|
||||
|
||||
toJson(): XxxResponseData {
|
||||
return XxxResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type XxxResponse = XxxResponseData;
|
||||
```
|
||||
|
||||
### 5. 更新 API Service
|
||||
@@ -177,9 +166,9 @@ src/data/services/api/<module>_api.ts
|
||||
```ts
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.xxx, {
|
||||
method: "POST",
|
||||
body: request.toJson(),
|
||||
body: request,
|
||||
});
|
||||
return XxxResponse.fromJson(unwrap(env) as Record<string, unknown>);
|
||||
return XxxResponseSchema.parse(unwrap(env));
|
||||
```
|
||||
|
||||
注意:
|
||||
@@ -207,7 +196,7 @@ async getXxx(): Promise<Result<XxxResponse>> {
|
||||
|
||||
Repository 可以做轻量编排,例如:
|
||||
|
||||
- request 数据类构造
|
||||
- request schema 解析
|
||||
- 多接口组合
|
||||
- 本地 storage 同步
|
||||
- 本地模型与接口模型转换
|
||||
@@ -333,7 +322,7 @@ pnpm exec vitest run <related tests>
|
||||
|
||||
1. 文档确认 path / method / payload。
|
||||
2. `api_path.ts` 新增路径。
|
||||
3. 在同一文件新增 schema 与不可变数据类。
|
||||
3. 在同一文件新增 readonly schema 与输入/输出类型。
|
||||
4. API service 新增方法。
|
||||
5. repository interface + implementation 新增方法。
|
||||
6. 如需要,接入 store actor / event。
|
||||
@@ -343,7 +332,7 @@ pnpm exec vitest run <related tests>
|
||||
### 修改响应字段
|
||||
|
||||
1. 更新 schema。
|
||||
2. 更新同文件数据类的公开字段和 `toJson`。
|
||||
2. 更新同文件输入/输出类型及相关 parse 调用。
|
||||
3. 更新 mapper/helper。
|
||||
4. 更新 UI 或状态机依赖字段。
|
||||
5. 更新 mock 和测试。
|
||||
|
||||
@@ -2,12 +2,12 @@ import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PaymentPlan } from "@/data/schemas/payment";
|
||||
import { PaymentPlan, PaymentPlanSchema } from "@/data/schemas/payment";
|
||||
import { behaviorAnalytics } from "@/lib/analytics";
|
||||
|
||||
import { usePaymentPlanAnalytics } from "../use-payment-plan-analytics";
|
||||
|
||||
const firstPlan = PaymentPlan.from({
|
||||
const firstPlan = PaymentPlanSchema.parse({
|
||||
planId: "plan-1",
|
||||
planName: "Plan One",
|
||||
orderType: "dol",
|
||||
@@ -18,7 +18,7 @@ const firstPlan = PaymentPlan.from({
|
||||
originalAmountCents: null,
|
||||
currency: "USD",
|
||||
});
|
||||
const secondPlan = PaymentPlan.from({
|
||||
const secondPlan = PaymentPlanSchema.parse({
|
||||
planId: "plan-2",
|
||||
planName: "Plan Two",
|
||||
orderType: "dol",
|
||||
@@ -43,8 +43,9 @@ describe("usePaymentPlanAnalytics", () => {
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PrivateAlbum } from "@/data/schemas/private-room";
|
||||
import {
|
||||
PrivateAlbumSchema,
|
||||
type PrivateAlbum,
|
||||
type PrivateAlbumInput,
|
||||
} from "@/data/schemas/private-room";
|
||||
|
||||
import { PrivateAlbumCard } from "../private-album-card";
|
||||
|
||||
const COVER_URL = "/images/private-room/banner.png";
|
||||
|
||||
function makeAlbum(
|
||||
overrides: Partial<Parameters<typeof PrivateAlbum.from>[0]> = {},
|
||||
): PrivateAlbum {
|
||||
return PrivateAlbum.from({
|
||||
function makeAlbum(overrides: Partial<PrivateAlbumInput> = {}): PrivateAlbum {
|
||||
return PrivateAlbumSchema.parse({
|
||||
albumId: "album-1",
|
||||
title: "Private morning",
|
||||
content: "A quiet morning by the water.",
|
||||
@@ -61,9 +63,7 @@ describe("PrivateAlbumCard", () => {
|
||||
expect(html).toContain("8 photos");
|
||||
expect(html).toContain("A quiet morning by the water.");
|
||||
expect(html).toContain('aria-label="Open 8 private album photos"');
|
||||
expect(html).toContain(
|
||||
'data-analytics-key="private_album.open_gallery"',
|
||||
);
|
||||
expect(html).toContain('data-analytics-key="private_album.open_gallery"');
|
||||
});
|
||||
|
||||
it("uses the backend first image as the locked cover", () => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PaymentPlan } from "@/data/schemas/payment";
|
||||
import {
|
||||
PaymentPlanSchema,
|
||||
type PaymentPlan,
|
||||
type PaymentPlanInput,
|
||||
} from "@/data/schemas/payment";
|
||||
|
||||
import {
|
||||
canChooseSubscriptionPayChannel,
|
||||
@@ -13,10 +17,8 @@ import {
|
||||
toVipOfferPlanViews,
|
||||
} from "../subscription-screen.helpers";
|
||||
|
||||
function makePlan(
|
||||
overrides: Partial<Parameters<typeof PaymentPlan.from>[0]>,
|
||||
): PaymentPlan {
|
||||
return PaymentPlan.from({
|
||||
function makePlan(overrides: Partial<PaymentPlanInput>): PaymentPlan {
|
||||
return PaymentPlanSchema.parse({
|
||||
planId: "plan",
|
||||
planName: "Plan",
|
||||
orderType: "vip_monthly",
|
||||
@@ -138,7 +140,9 @@ describe("subscription screen helpers", () => {
|
||||
originalPrice: "",
|
||||
},
|
||||
],
|
||||
coinPlans: [{ id: "coin_1000", coins: 1000, price: "9.90", currency: "US$" }],
|
||||
coinPlans: [
|
||||
{ id: "coin_1000", coins: 1000, price: "9.90", currency: "US$" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(selected?.id).toBe("coin_1000");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PaymentPlan } from "@/data/schemas/payment";
|
||||
import { PaymentPlan, PaymentPlanSchema } from "@/data/schemas/payment";
|
||||
import type { PaymentPlanInput } from "@/data/schemas/payment/payment_plan";
|
||||
|
||||
import {
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "../tip-screen.helpers";
|
||||
|
||||
function makePlan(input: Partial<PaymentPlanInput>): PaymentPlan {
|
||||
return PaymentPlan.from({
|
||||
return PaymentPlanSchema.parse({
|
||||
planId: "coins_100",
|
||||
planName: "Coins",
|
||||
orderType: "dol",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AuthRepository } from "@/data/repositories/auth_repository";
|
||||
import {
|
||||
FacebookIdentityResponse,
|
||||
FacebookPsidLoginResponse,
|
||||
FacebookIdentityResponseSchema,
|
||||
FacebookPsidLoginResponseSchema,
|
||||
LoginStatus,
|
||||
} from "@/data/schemas/auth";
|
||||
import { AuthRepository } from "@/data/repositories/auth_repository";
|
||||
import type { AuthApi } from "@/data/services/api";
|
||||
import type { IAuthStorage } from "@/data/storage/auth";
|
||||
import type { IUserStorage } from "@/data/storage/user";
|
||||
@@ -36,7 +36,7 @@ function createRepository(overrides: Partial<AuthApi>) {
|
||||
describe("AuthRepository facebook identity", () => {
|
||||
it("binds facebook identity and stores returned ids", async () => {
|
||||
const bindFacebookIdentity = vi.fn(async () =>
|
||||
FacebookIdentityResponse.fromJson({
|
||||
FacebookIdentityResponseSchema.parse({
|
||||
fbAsid: "asid-1",
|
||||
fbPsid: "psid-1",
|
||||
facebookBinding: { conflicts: [], bound: [] },
|
||||
@@ -57,7 +57,7 @@ describe("AuthRepository facebook identity", () => {
|
||||
|
||||
it("saves real login data from complete psid login responses", async () => {
|
||||
const facebookPsidLogin = vi.fn(async () =>
|
||||
FacebookPsidLoginResponse.fromJson({
|
||||
FacebookPsidLoginResponseSchema.parse({
|
||||
token: "login-token",
|
||||
refreshToken: "refresh-token",
|
||||
matchedBy: "psid",
|
||||
@@ -88,7 +88,7 @@ describe("AuthRepository facebook identity", () => {
|
||||
|
||||
it("saves guest data from incomplete psid login responses", async () => {
|
||||
const facebookPsidLogin = vi.fn(async () =>
|
||||
FacebookPsidLoginResponse.fromJson({
|
||||
FacebookPsidLoginResponseSchema.parse({
|
||||
token: "guest-token",
|
||||
matchedBy: "psid_incomplete",
|
||||
fbPsid: "psid-1",
|
||||
@@ -116,7 +116,7 @@ describe("AuthRepository facebook identity", () => {
|
||||
it("does not report guest psid login success when session persistence fails", async () => {
|
||||
const storageError = new Error("guest token write failed");
|
||||
const facebookPsidLogin = vi.fn(async () =>
|
||||
FacebookPsidLoginResponse.fromJson({
|
||||
FacebookPsidLoginResponseSchema.parse({
|
||||
token: "guest-token",
|
||||
matchedBy: "psid_incomplete",
|
||||
fbPsid: "psid-1",
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
GuestLoginResponse,
|
||||
LoginResponse,
|
||||
LoginStatus,
|
||||
RefreshTokenResponse,
|
||||
} from "@/data/schemas/auth";
|
||||
import { AuthRepository } from "@/data/repositories/auth_repository";
|
||||
import {
|
||||
GuestLoginResponseSchema,
|
||||
LoginResponseSchema,
|
||||
LoginStatus,
|
||||
RefreshTokenResponseSchema,
|
||||
} from "@/data/schemas/auth";
|
||||
import type { AuthApi } from "@/data/services/api";
|
||||
import type { IAuthStorage } from "@/data/storage/auth";
|
||||
import type { IUserStorage } from "@/data/storage/user";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
function createRepository(input: {
|
||||
function createRepository(
|
||||
input: {
|
||||
api?: Partial<AuthApi>;
|
||||
storage?: Partial<IAuthStorage>;
|
||||
userStorage?: Partial<IUserStorage>;
|
||||
} = {}) {
|
||||
} = {},
|
||||
) {
|
||||
const storage = {
|
||||
getRefreshToken: vi.fn(async () => Result.ok("refresh-token")),
|
||||
setLoginToken: vi.fn(async () => Result.ok(undefined)),
|
||||
@@ -52,7 +54,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("AuthRepository session Result handling", () => {
|
||||
const loginResponse = LoginResponse.from({
|
||||
const loginResponse = LoginResponseSchema.parse({
|
||||
token: "login-token",
|
||||
refreshToken: "refresh-token",
|
||||
user: { id: "user-1", username: "Elio" },
|
||||
@@ -112,7 +114,7 @@ describe("AuthRepository session Result handling", () => {
|
||||
|
||||
it("does not report refresh success when a new token cannot be stored", async () => {
|
||||
const storageError = new Error("login token write failed");
|
||||
const response = RefreshTokenResponse.from({
|
||||
const response = RefreshTokenResponseSchema.parse({
|
||||
token: "new-login-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
userId: "user-1",
|
||||
@@ -135,7 +137,7 @@ describe("AuthRepository session Result handling", () => {
|
||||
|
||||
it("does not report guest login success when session persistence fails", async () => {
|
||||
const storageError = new Error("guest token write failed");
|
||||
const response = GuestLoginResponse.from({
|
||||
const response = GuestLoginResponseSchema.parse({
|
||||
token: "guest-token",
|
||||
deviceId: "device-1",
|
||||
userId: "guest-1",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ChatMessage } from "@/data/schemas/chat";
|
||||
import { ChatMessage, ChatMessageSchema } from "@/data/schemas/chat";
|
||||
import { LocalMessage } from "@/data/storage/chat";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
import { ChatLocalMessageStore } from "../chat_local_message_store";
|
||||
|
||||
function makeMessage(id: string, content: string): ChatMessage {
|
||||
return ChatMessage.from({
|
||||
return ChatMessageSchema.parse({
|
||||
id,
|
||||
role: "assistant",
|
||||
type: "text",
|
||||
@@ -53,9 +53,8 @@ describe("ChatLocalMessageStore identity isolation", () => {
|
||||
it("reads, replaces, and clears only the current identity", async () => {
|
||||
const memory = createMemoryStorage();
|
||||
let identity = "user:account-a";
|
||||
const store = new ChatLocalMessageStore(
|
||||
memory.storage,
|
||||
async () => Result.ok(identity),
|
||||
const store = new ChatLocalMessageStore(memory.storage, async () =>
|
||||
Result.ok(identity),
|
||||
);
|
||||
|
||||
await store.saveMessages([makeMessage("a-1", "account A")]);
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
import { ChatMessage, ChatSendResponse } from "@/data/schemas/chat";
|
||||
import {
|
||||
ChatMessageSchema,
|
||||
ChatSendResponseSchema,
|
||||
type ChatMessage,
|
||||
type ChatMessageInput,
|
||||
type ChatSendResponse,
|
||||
type ChatSendResponseInput,
|
||||
} from "@/data/schemas/chat";
|
||||
|
||||
import {
|
||||
buildChatMediaCacheKey,
|
||||
@@ -11,10 +18,8 @@ import {
|
||||
uniqueMediaTargets,
|
||||
} from "../chat_media_cache_helpers";
|
||||
|
||||
function makeMessage(
|
||||
overrides: Partial<Parameters<typeof ChatMessage.from>[0]> = {},
|
||||
): ChatMessage {
|
||||
return ChatMessage.from({
|
||||
function makeMessage(overrides: Partial<ChatMessageInput> = {}): ChatMessage {
|
||||
return ChatMessageSchema.parse({
|
||||
id: "msg-1",
|
||||
role: "assistant",
|
||||
type: "image",
|
||||
@@ -31,9 +36,9 @@ function makeMessage(
|
||||
}
|
||||
|
||||
function makeResponse(
|
||||
overrides: Partial<Parameters<typeof ChatSendResponse.from>[0]> = {},
|
||||
overrides: Partial<ChatSendResponseInput> = {},
|
||||
): ChatSendResponse {
|
||||
return ChatSendResponse.from({
|
||||
return ChatSendResponseSchema.parse({
|
||||
reply: "",
|
||||
messageId: "msg-1",
|
||||
isGuest: false,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { TipPaymentPlansResponse } from "@/data/schemas/payment";
|
||||
import { PaymentRepository } from "@/data/repositories/payment_repository";
|
||||
import { TipPaymentPlansResponseSchema } from "@/data/schemas/payment";
|
||||
import type { PaymentApi } from "@/data/services/api";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
describe("PaymentRepository", () => {
|
||||
it("adapts tip plans without caching them as subscription plans", async () => {
|
||||
const getTipPlans = vi.fn().mockResolvedValue(
|
||||
TipPaymentPlansResponse.from({
|
||||
TipPaymentPlansResponseSchema.parse({
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
import { ExceptionHandler } from "@/core/errors";
|
||||
import { ApiError, AuthApi, authApi, ErrorCode } from "@/data/services/api";
|
||||
import type { IAuthRepository } from "@/data/repositories/interfaces";
|
||||
import {
|
||||
FacebookIdentityRequest,
|
||||
FacebookPsidLoginRequest,
|
||||
FacebookLoginRequest,
|
||||
FbIdLoginRequest,
|
||||
GoogleLoginRequest,
|
||||
GuestLoginRequest,
|
||||
FacebookIdentityRequestSchema,
|
||||
FacebookLoginRequestSchema,
|
||||
FacebookPsidLoginRequestSchema,
|
||||
FbIdLoginRequestSchema,
|
||||
GoogleLoginRequestSchema,
|
||||
GuestLoginRequestSchema,
|
||||
GuestLoginResponse,
|
||||
LoginRequest,
|
||||
LoginRequestSchema,
|
||||
LoginResponse,
|
||||
LoginResponseSchema,
|
||||
LoginStatus,
|
||||
type LoginStatus as LoginStatusT,
|
||||
RefreshTokenRequest,
|
||||
RefreshTokenRequestSchema,
|
||||
RefreshTokenResponse,
|
||||
RegisterRequest,
|
||||
RegisterRequestSchema,
|
||||
type LoginStatus as LoginStatusT,
|
||||
} from "@/data/schemas/auth";
|
||||
import { User } from "@/data/schemas/user";
|
||||
import { AppEnvUtil } from "@/utils/app-env";
|
||||
import { PlatformDetector } from "@/utils/platform-detect";
|
||||
import { Result } from "@/utils/result";
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { deviceIdentifier } from "@/utils/device_identifier";
|
||||
import type { IAuthRepository } from "@/data/repositories/interfaces";
|
||||
import { ApiError, AuthApi, authApi, ErrorCode } from "@/data/services/api";
|
||||
import { AuthStorage, type IAuthStorage } from "@/data/storage/auth";
|
||||
import { UserStorage, type IUserStorage } from "@/data/storage/user";
|
||||
import { AppEnvUtil } from "@/utils/app-env";
|
||||
import { deviceIdentifier } from "@/utils/device_identifier";
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { PlatformDetector } from "@/utils/platform-detect";
|
||||
import { Result } from "@/utils/result";
|
||||
import { createLazySingleton } from "./lazy_singleton";
|
||||
|
||||
const log = new Logger("DataRepositoriesAuthRepository");
|
||||
@@ -52,7 +53,7 @@ export class AuthRepository implements IAuthRepository {
|
||||
}): Promise<Result<void>> {
|
||||
return Result.wrap(async () => {
|
||||
await this.api.register(
|
||||
RegisterRequest.from(
|
||||
RegisterRequestSchema.parse(
|
||||
withTestAccountFlag({
|
||||
username: input.username,
|
||||
email: input.email,
|
||||
@@ -76,7 +77,7 @@ export class AuthRepository implements IAuthRepository {
|
||||
return Result.wrap(async () => {
|
||||
const email = input.email?.trim() ?? "";
|
||||
const response = await this.api.emailLogin(
|
||||
LoginRequest.from(
|
||||
LoginRequestSchema.parse(
|
||||
withTestAccountFlag({
|
||||
email,
|
||||
username: input.username,
|
||||
@@ -152,7 +153,7 @@ export class AuthRepository implements IAuthRepository {
|
||||
});
|
||||
return Result.wrap(async () => {
|
||||
const response = await this.api.guestLogin(
|
||||
GuestLoginRequest.from(withTestAccountFlag({ deviceId })),
|
||||
GuestLoginRequestSchema.parse(withTestAccountFlag({ deviceId })),
|
||||
);
|
||||
log.debug("[AuthRepository.guestLogin] API call SUCCESS (Zod parsed)", {
|
||||
hasToken: !!response.token,
|
||||
@@ -190,7 +191,7 @@ export class AuthRepository implements IAuthRepository {
|
||||
}): Promise<Result<LoginResponse>> {
|
||||
return this._socialLogin(LoginStatus.Google, () =>
|
||||
this.api.googleLogin(
|
||||
GoogleLoginRequest.from(
|
||||
GoogleLoginRequestSchema.parse(
|
||||
withTestAccountFlag({
|
||||
idToken: input.idToken,
|
||||
platform: getAuthPlatform(),
|
||||
@@ -210,7 +211,7 @@ export class AuthRepository implements IAuthRepository {
|
||||
}): Promise<Result<LoginResponse>> {
|
||||
return this._socialLogin(LoginStatus.Facebook, () =>
|
||||
this.api.facebookLogin(
|
||||
FacebookLoginRequest.from(
|
||||
FacebookLoginRequestSchema.parse(
|
||||
withTestAccountFlag({
|
||||
accessToken: input.accessToken,
|
||||
platform: getAuthPlatform(),
|
||||
@@ -230,7 +231,7 @@ export class AuthRepository implements IAuthRepository {
|
||||
}): Promise<Result<LoginResponse>> {
|
||||
return this._socialLogin(LoginStatus.Facebook, () =>
|
||||
this.api.facebookIdLogin(
|
||||
FbIdLoginRequest.from(
|
||||
FbIdLoginRequestSchema.parse(
|
||||
withTestAccountFlag({
|
||||
fbId: input.asid,
|
||||
avatarUrl: input.avatarUrl ?? "",
|
||||
@@ -250,12 +251,12 @@ export class AuthRepository implements IAuthRepository {
|
||||
|
||||
return Result.wrap(async () => {
|
||||
const response = await this.api.bindFacebookIdentity(
|
||||
FacebookIdentityRequest.from({
|
||||
FacebookIdentityRequestSchema.parse({
|
||||
asid: input.asid ?? "",
|
||||
psid: input.psid ?? "",
|
||||
}),
|
||||
);
|
||||
const data = response.toJson();
|
||||
const data = response;
|
||||
await this._saveFacebookIdentity({
|
||||
asid: data.fbAsid,
|
||||
psid: data.fbPsid,
|
||||
@@ -271,13 +272,13 @@ export class AuthRepository implements IAuthRepository {
|
||||
}): Promise<Result<LoginStatusT>> {
|
||||
return Result.wrap(async () => {
|
||||
const response = await this.api.facebookPsidLogin(
|
||||
FacebookPsidLoginRequest.from({
|
||||
FacebookPsidLoginRequestSchema.parse({
|
||||
psid: input.psid,
|
||||
deviceId: input.deviceId ?? "",
|
||||
bindToGuest: input.bindToGuest ?? true,
|
||||
}),
|
||||
);
|
||||
const data = response.toJson();
|
||||
const data = response;
|
||||
await this._saveFacebookIdentity({
|
||||
asid: data.fbAsid,
|
||||
psid: data.fbPsid,
|
||||
@@ -303,7 +304,7 @@ export class AuthRepository implements IAuthRepository {
|
||||
}
|
||||
|
||||
await this._saveLoginData(
|
||||
LoginResponse.from({
|
||||
LoginResponseSchema.parse({
|
||||
token: data.token,
|
||||
refreshToken: data.refreshToken,
|
||||
user: data.user ?? { id: data.userId },
|
||||
@@ -330,7 +331,7 @@ export class AuthRepository implements IAuthRepository {
|
||||
);
|
||||
}
|
||||
const response = await this.api.refreshToken(
|
||||
RefreshTokenRequest.from({ refreshToken: existing.data }),
|
||||
RefreshTokenRequestSchema.parse({ refreshToken: existing.data }),
|
||||
);
|
||||
const tokenResult = await this.storage.setLoginToken(response.token);
|
||||
if (Result.isErr(tokenResult)) throw tokenResult.error;
|
||||
@@ -404,7 +405,7 @@ export class AuthRepository implements IAuthRepository {
|
||||
|
||||
private async _cacheUser(user: User): Promise<void> {
|
||||
try {
|
||||
const userResult = await this.userStorage.setUser(user.toJson());
|
||||
const userResult = await this.userStorage.setUser(user);
|
||||
if (Result.isErr(userResult)) {
|
||||
log.warn("[AuthRepository] setUser failed", userResult.error);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChatMessage } from "@/data/schemas/chat";
|
||||
import type { UnlockedPrivateMessageLocalPatch } from "@/data/repositories/interfaces";
|
||||
import { ChatMessage, ChatMessageSchema } from "@/data/schemas/chat";
|
||||
import { LocalChatStorage, LocalMessage } from "@/data/storage/chat";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
@@ -20,8 +20,7 @@ type LocalMessageStorage = Pick<
|
||||
export class ChatLocalMessageStore {
|
||||
constructor(
|
||||
private readonly localStorage: LocalMessageStorage,
|
||||
private readonly resolveIdentity: ChatCacheIdentityResolver =
|
||||
resolveChatCacheOwnerKey,
|
||||
private readonly resolveIdentity: ChatCacheIdentityResolver = resolveChatCacheOwnerKey,
|
||||
) {}
|
||||
|
||||
async markMessageUnlocked(
|
||||
@@ -40,8 +39,8 @@ export class ChatLocalMessageStore {
|
||||
const updatedMessages = localResult.data.map((message) => {
|
||||
if (!shouldMarkMessageUnlocked(message, messageId)) return message;
|
||||
changed = true;
|
||||
return ChatMessage.from({
|
||||
...message.toJson(),
|
||||
return ChatMessageSchema.parse({
|
||||
...message,
|
||||
content: normalizeUnlockedContent(patch.content, message.content),
|
||||
audioUrl: normalizeUnlockedAudioUrl(patch.audioUrl, message.audioUrl),
|
||||
image: patch.image ?? message.image,
|
||||
@@ -85,7 +84,9 @@ export class ChatLocalMessageStore {
|
||||
});
|
||||
}
|
||||
|
||||
async getMessages(identity?: string): Promise<Result<ChatMessage[]>> {
|
||||
async getMessages(
|
||||
identity?: string,
|
||||
): Promise<Result<readonly ChatMessage[]>> {
|
||||
return Result.wrap(async () => {
|
||||
const ownerKey = await this._requireIdentity(identity);
|
||||
const result = await this._getMessages(ownerKey);
|
||||
@@ -128,12 +129,10 @@ export class ChatLocalMessageStore {
|
||||
|
||||
private async _getMessages(
|
||||
identity: string,
|
||||
): Promise<Result<ChatMessage[]>> {
|
||||
): Promise<Result<readonly ChatMessage[]>> {
|
||||
const result = await this.localStorage.getAllMessagesBySession(identity);
|
||||
if (Result.isErr(result)) return result;
|
||||
return Result.ok(
|
||||
result.data.map((message) => this._localToChat(message)),
|
||||
);
|
||||
return Result.ok(result.data.map((message) => this._localToChat(message)));
|
||||
}
|
||||
|
||||
private _saveMessages(
|
||||
@@ -161,7 +160,7 @@ export class ChatLocalMessageStore {
|
||||
}
|
||||
|
||||
private _localToChat(local: LocalMessage): ChatMessage {
|
||||
return ChatMessage.from({
|
||||
return ChatMessageSchema.parse({
|
||||
id: local.id,
|
||||
role: local.role,
|
||||
type: local.type,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { UnlockPrivateMessageInput } from "@/data/repositories/interfaces";
|
||||
import {
|
||||
ChatHistoryResponse,
|
||||
ChatSendResponse,
|
||||
SendMessageRequest,
|
||||
UnlockHistoryRequest,
|
||||
SendMessageRequestSchema,
|
||||
UnlockHistoryRequestSchema,
|
||||
UnlockHistoryResponse,
|
||||
UnlockPrivateRequest,
|
||||
UnlockPrivateRequestSchema,
|
||||
UnlockPrivateResponse,
|
||||
} from "@/data/schemas/chat";
|
||||
import type { ChatApi } from "@/data/services/api";
|
||||
import type { UnlockPrivateMessageInput } from "@/data/repositories/interfaces";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
export class ChatRemoteDataSource {
|
||||
@@ -20,7 +20,7 @@ export class ChatRemoteDataSource {
|
||||
options?: { image?: string; useWebSocket?: boolean },
|
||||
): Promise<Result<ChatSendResponse>> {
|
||||
return Result.wrap(async () => {
|
||||
const request = SendMessageRequest.from({
|
||||
const request = SendMessageRequestSchema.parse({
|
||||
characterId,
|
||||
message,
|
||||
image: options?.image ?? "",
|
||||
@@ -42,7 +42,7 @@ export class ChatRemoteDataSource {
|
||||
input: UnlockPrivateMessageInput,
|
||||
): Promise<Result<UnlockPrivateResponse>> {
|
||||
return Result.wrap(() =>
|
||||
this.api.unlockPrivateMessage(UnlockPrivateRequest.from(input)),
|
||||
this.api.unlockPrivateMessage(UnlockPrivateRequestSchema.parse(input)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export class ChatRemoteDataSource {
|
||||
characterId: string,
|
||||
): Promise<Result<UnlockHistoryResponse>> {
|
||||
return Result.wrap(() =>
|
||||
this.api.unlockHistory(UnlockHistoryRequest.from({ characterId })),
|
||||
this.api.unlockHistory(UnlockHistoryRequestSchema.parse({ characterId })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import type {
|
||||
CacheRemoteChatMediaInput,
|
||||
ChatMediaLookupInput,
|
||||
IChatRepository,
|
||||
UnlockPrivateMessageInput,
|
||||
UnlockedPrivateMessageLocalPatch,
|
||||
} from "@/data/repositories/interfaces";
|
||||
import type {
|
||||
ChatHistoryResponse,
|
||||
ChatMessage,
|
||||
@@ -11,13 +18,6 @@ import {
|
||||
LocalChatStorage,
|
||||
type LocalChatMediaRow,
|
||||
} from "@/data/storage/chat";
|
||||
import type {
|
||||
CacheRemoteChatMediaInput,
|
||||
ChatMediaLookupInput,
|
||||
IChatRepository,
|
||||
UnlockPrivateMessageInput,
|
||||
UnlockedPrivateMessageLocalPatch,
|
||||
} from "@/data/repositories/interfaces";
|
||||
import type { Result } from "@/utils/result";
|
||||
|
||||
import { ChatLocalMessageStore } from "./chat_local_message_store";
|
||||
@@ -60,7 +60,9 @@ export class ChatRepository implements IChatRepository {
|
||||
}
|
||||
|
||||
/** 一键解锁历史锁定消息。 */
|
||||
async unlockHistory(characterId: string): Promise<Result<UnlockHistoryResponse>> {
|
||||
async unlockHistory(
|
||||
characterId: string,
|
||||
): Promise<Result<UnlockHistoryResponse>> {
|
||||
return this.remote.unlockHistory(characterId);
|
||||
}
|
||||
|
||||
@@ -101,7 +103,7 @@ export class ChatRepository implements IChatRepository {
|
||||
/** 读取所有本地消息,按 dbId 升序。 */
|
||||
async getLocalMessages(
|
||||
cacheIdentity?: string,
|
||||
): Promise<Result<ChatMessage[]>> {
|
||||
): Promise<Result<readonly ChatMessage[]>> {
|
||||
return this.localMessages.getMessages(cacheIdentity);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,18 +4,19 @@
|
||||
* 聚合聊天远程操作、身份隔离的本地历史,以及媒体缓存能力。
|
||||
*/
|
||||
|
||||
import type { Result } from "@/utils/result";
|
||||
import type {
|
||||
ChatHistoryResponse,
|
||||
ChatImageData,
|
||||
ChatLockDetailData,
|
||||
ChatLockType,
|
||||
ChatMediaKind,
|
||||
ChatMessage,
|
||||
ChatSendResponse,
|
||||
UnlockHistoryResponse,
|
||||
UnlockPrivateResponse,
|
||||
} from "@/data/schemas/chat";
|
||||
import type { ChatLockDetailData } from "@/data/schemas/chat";
|
||||
import type { ChatImageData, ChatLockType } from "@/data/schemas/chat";
|
||||
import type { LocalChatMediaRow } from "@/data/storage/chat";
|
||||
import type { Result } from "@/utils/result";
|
||||
|
||||
export interface ChatMediaLookupInput {
|
||||
characterId: string;
|
||||
@@ -84,7 +85,9 @@ export interface IChatRepository {
|
||||
): Promise<Result<void>>;
|
||||
|
||||
/** 读取所有本地消息,按 dbId 升序。 */
|
||||
getLocalMessages(cacheIdentity?: string): Promise<Result<ChatMessage[]>>;
|
||||
getLocalMessages(
|
||||
cacheIdentity?: string,
|
||||
): Promise<Result<readonly ChatMessage[]>>;
|
||||
|
||||
/** 清空本地消息。 */
|
||||
clearLocalMessages(cacheIdentity?: string): Promise<Result<void>>;
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*
|
||||
* 指标/埋点仓库:纯远程 fire-and-forget 上报,无本地状态。
|
||||
*/
|
||||
import { MetricsApi, metricsApi } from "@/data/services/api";
|
||||
import { AppEvent, PwaEvent } from "@/data/schemas/metrics";
|
||||
import { Result } from "@/utils/result";
|
||||
import type { IMetricsRepository } from "@/data/repositories/interfaces";
|
||||
import { AppEventSchema, PwaEventSchema } from "@/data/schemas/metrics";
|
||||
import { MetricsApi, metricsApi } from "@/data/services/api";
|
||||
import { Result } from "@/utils/result";
|
||||
import { createLazySingleton } from "./lazy_singleton";
|
||||
|
||||
export class MetricsRepository implements IMetricsRepository {
|
||||
@@ -24,7 +24,7 @@ export class MetricsRepository implements IMetricsRepository {
|
||||
}): Promise<Result<void>> {
|
||||
return Result.wrap(async () => {
|
||||
await this.api.reportPwaEvent(
|
||||
PwaEvent.from({
|
||||
PwaEventSchema.parse({
|
||||
deviceId: input.deviceId,
|
||||
deviceType: input.deviceType,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
@@ -45,7 +45,7 @@ export class MetricsRepository implements IMetricsRepository {
|
||||
}): Promise<Result<void>> {
|
||||
return Result.wrap(async () => {
|
||||
await this.api.reportUserInfo(
|
||||
AppEvent.from({
|
||||
AppEventSchema.parse({
|
||||
userId: input.userId,
|
||||
browser: input.browser,
|
||||
userAgent: input.userAgent,
|
||||
|
||||
@@ -3,16 +3,17 @@
|
||||
*
|
||||
* 支付 / 付费墙相关远程调用。
|
||||
*/
|
||||
import type { IPaymentRepository } from "@/data/repositories/interfaces";
|
||||
import {
|
||||
CreatePaymentOrderRequest,
|
||||
CreatePaymentOrderRequestSchema,
|
||||
CreatePaymentOrderResponse,
|
||||
PayChannel,
|
||||
PaymentOrderStatusResponse,
|
||||
PaymentPlansResponse,
|
||||
PaymentPlansResponseSchema,
|
||||
} from "@/data/schemas/payment";
|
||||
import { PaymentApi, paymentApi } from "@/data/services/api";
|
||||
import { PaymentPlansStorage } from "@/data/storage/payment";
|
||||
import type { IPaymentRepository } from "@/data/repositories/interfaces";
|
||||
import { Result } from "@/utils/result";
|
||||
import { createLazySingleton } from "./lazy_singleton";
|
||||
|
||||
@@ -23,7 +24,7 @@ export class PaymentRepository implements IPaymentRepository {
|
||||
async getPlans(): Promise<Result<PaymentPlansResponse>> {
|
||||
return Result.wrap(async () => {
|
||||
const response = await this.api.getPlans();
|
||||
await PaymentPlansStorage.setPlans(response.toJson());
|
||||
await PaymentPlansStorage.setPlans(response);
|
||||
return response;
|
||||
});
|
||||
}
|
||||
@@ -33,7 +34,7 @@ export class PaymentRepository implements IPaymentRepository {
|
||||
return Result.wrap(async () => {
|
||||
const result = await PaymentPlansStorage.getPlans();
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
return result.data ? PaymentPlansResponse.from(result.data) : null;
|
||||
return result.data ? PaymentPlansResponseSchema.parse(result.data) : null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,9 +42,9 @@ export class PaymentRepository implements IPaymentRepository {
|
||||
async getTipPlans(): Promise<Result<PaymentPlansResponse>> {
|
||||
return Result.wrap(async () => {
|
||||
const response = await this.api.getTipPlans();
|
||||
return PaymentPlansResponse.from({
|
||||
return PaymentPlansResponseSchema.parse({
|
||||
plans: response.plans.map((plan) => ({
|
||||
...plan.toJson(),
|
||||
...plan,
|
||||
orderType: "tip",
|
||||
vipDays: null,
|
||||
dolAmount: null,
|
||||
@@ -72,7 +73,7 @@ export class PaymentRepository implements IPaymentRepository {
|
||||
payChannel: PayChannel,
|
||||
autoRenew: boolean,
|
||||
): Promise<Result<CreatePaymentOrderResponse>> {
|
||||
const request = CreatePaymentOrderRequest.from({
|
||||
const request = CreatePaymentOrderRequestSchema.parse({
|
||||
planId,
|
||||
payChannel,
|
||||
autoRenew,
|
||||
@@ -86,7 +87,6 @@ export class PaymentRepository implements IPaymentRepository {
|
||||
): Promise<Result<PaymentOrderStatusResponse>> {
|
||||
return Result.wrap(() => this.api.getOrderStatus(orderId));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/** 全局懒单例。 */
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type {
|
||||
GetPrivateAlbumsInput,
|
||||
IPrivateRoomRepository,
|
||||
} from "@/data/repositories/interfaces";
|
||||
import {
|
||||
UnlockPrivateAlbumRequest,
|
||||
type PrivateAlbumsResponse,
|
||||
type PrivateAlbumUnlockResponse,
|
||||
UnlockPrivateAlbumRequestSchema,
|
||||
} from "@/data/schemas/private-room";
|
||||
import {
|
||||
PrivateRoomApi,
|
||||
privateRoomApi,
|
||||
} from "@/data/services/api/private_room_api";
|
||||
import type {
|
||||
GetPrivateAlbumsInput,
|
||||
IPrivateRoomRepository,
|
||||
} from "@/data/repositories/interfaces";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
import { createLazySingleton } from "./lazy_singleton";
|
||||
@@ -31,7 +31,7 @@ export class PrivateRoomRepository implements IPrivateRoomRepository {
|
||||
return Result.wrap(() =>
|
||||
this.api.unlockAlbum(
|
||||
albumId,
|
||||
UnlockPrivateAlbumRequest.from({ expectedCost }),
|
||||
UnlockPrivateAlbumRequestSchema.parse({ expectedCost }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { FacebookIdentityResponseSchema } from "@/data/schemas/auth";
|
||||
import { ChatHistoryResponseSchema } from "@/data/schemas/chat";
|
||||
import { PaymentPlansResponseSchema } from "@/data/schemas/payment";
|
||||
import { PrivateAlbumsResponseSchema } from "@/data/schemas/private-room";
|
||||
import { UserSchema } from "@/data/schemas/user";
|
||||
|
||||
describe("immutable schemas", () => {
|
||||
it("freezes nested user data", () => {
|
||||
const user = UserSchema.parse({
|
||||
id: "user-1",
|
||||
username: "Ada",
|
||||
personalityTraits: null,
|
||||
});
|
||||
|
||||
expectDeepFrozen(user);
|
||||
});
|
||||
|
||||
it("freezes chat history messages and payloads", () => {
|
||||
const response = ChatHistoryResponseSchema.parse({
|
||||
messages: [
|
||||
{
|
||||
id: "message-1",
|
||||
role: "assistant",
|
||||
content: "Hello",
|
||||
image: { type: "jpg", url: "https://example.com/image.jpg" },
|
||||
lockDetail: { locked: false, reason: null },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expectDeepFrozen(response);
|
||||
});
|
||||
|
||||
it("freezes payment and private-room collections", () => {
|
||||
const plans = PaymentPlansResponseSchema.parse({
|
||||
firstRechargeOffer: {
|
||||
enabled: true,
|
||||
type: "half_price",
|
||||
discountPercent: 50,
|
||||
},
|
||||
plans: [
|
||||
{
|
||||
planId: "vip_monthly",
|
||||
planName: "Monthly VIP",
|
||||
orderType: "vip_monthly",
|
||||
creditBalance: 0,
|
||||
amountCents: 999,
|
||||
currency: "USD",
|
||||
},
|
||||
],
|
||||
});
|
||||
const albums = PrivateAlbumsResponseSchema.parse({
|
||||
items: [
|
||||
{
|
||||
albumId: "album-1",
|
||||
title: "Private album",
|
||||
images: [{ url: "https://example.com/image.jpg", index: 0 }],
|
||||
lockDetail: { locked: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expectDeepFrozen(plans);
|
||||
expectDeepFrozen(albums);
|
||||
});
|
||||
|
||||
it("freezes containers created from missing defaults", () => {
|
||||
const response = FacebookIdentityResponseSchema.parse({});
|
||||
|
||||
expectDeepFrozen(response);
|
||||
expect(response.facebookBinding).toEqual({ conflicts: [], bound: [] });
|
||||
});
|
||||
});
|
||||
|
||||
function expectDeepFrozen(value: unknown): void {
|
||||
if (value === null || typeof value !== "object") return;
|
||||
|
||||
expect(Object.isFrozen(value)).toBe(true);
|
||||
for (const child of Object.values(value)) {
|
||||
expectDeepFrozen(child);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createSchemaModel,
|
||||
type SchemaModel,
|
||||
} from "../schema_model";
|
||||
|
||||
const ExampleSchema = z.object({
|
||||
id: z.string(),
|
||||
traceId: z.string().default("generated-trace"),
|
||||
});
|
||||
|
||||
type ExampleModel = SchemaModel<typeof ExampleSchema, "id">;
|
||||
const ExampleModel = createSchemaModel<typeof ExampleSchema, "id">(
|
||||
ExampleSchema,
|
||||
);
|
||||
|
||||
describe("createSchemaModel", () => {
|
||||
it("parses, freezes and serializes through the source schema", () => {
|
||||
const model: ExampleModel = ExampleModel.from({ id: "item-1" });
|
||||
|
||||
expect(model).toBeInstanceOf(ExampleModel);
|
||||
expect(Object.isFrozen(model)).toBe(true);
|
||||
expect(model.id).toBe("item-1");
|
||||
expect(model.toJson()).toEqual({
|
||||
id: "item-1",
|
||||
traceId: "generated-trace",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps non-public schema fields out of the declared model interface", () => {
|
||||
const model = ExampleModel.fromJson({ id: "item-1", traceId: "trace-1" });
|
||||
|
||||
// @ts-expect-error traceId is serialized but not part of the public API.
|
||||
expect(model.traceId).toBe("trace-1");
|
||||
});
|
||||
|
||||
it("preserves schema validation errors", () => {
|
||||
expect(() => ExampleModel.fromJson({ id: 1 })).toThrow(z.ZodError);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
FacebookIdentityResponse,
|
||||
FacebookPsidLoginResponse,
|
||||
LoginRequest,
|
||||
FacebookIdentityResponseSchema,
|
||||
FacebookPsidLoginResponseSchema,
|
||||
LoginRequestSchema,
|
||||
} from "@/data/schemas/auth";
|
||||
import { UserSchema } from "@/data/schemas/user/user";
|
||||
|
||||
describe("facebook identity schema models", () => {
|
||||
it("parses facebook identity binding responses", () => {
|
||||
const response = FacebookIdentityResponse.fromJson({
|
||||
const response = FacebookIdentityResponseSchema.parse({
|
||||
fbAsid: "asid-1",
|
||||
fbPsid: "psid-1",
|
||||
facebookBinding: {
|
||||
@@ -18,7 +18,7 @@ describe("facebook identity schema models", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.toJson()).toMatchObject({
|
||||
expect(response).toMatchObject({
|
||||
fbAsid: "asid-1",
|
||||
fbPsid: "psid-1",
|
||||
facebookBinding: {
|
||||
@@ -30,7 +30,7 @@ describe("facebook identity schema models", () => {
|
||||
|
||||
it("parses facebook psid login responses for real users and guests", () => {
|
||||
expect(
|
||||
FacebookPsidLoginResponse.fromJson({
|
||||
FacebookPsidLoginResponseSchema.parse({
|
||||
token: "login-token",
|
||||
refreshToken: "refresh-token",
|
||||
matchedBy: "psid",
|
||||
@@ -39,7 +39,7 @@ describe("facebook identity schema models", () => {
|
||||
hasCompleteFacebookIdentity: true,
|
||||
isGuest: false,
|
||||
user: { id: "user-1", username: "Elio" },
|
||||
}).toJson(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
isGuest: false,
|
||||
hasCompleteFacebookIdentity: true,
|
||||
@@ -47,14 +47,14 @@ describe("facebook identity schema models", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
FacebookPsidLoginResponse.fromJson({
|
||||
FacebookPsidLoginResponseSchema.parse({
|
||||
token: "guest-token",
|
||||
matchedBy: "guest",
|
||||
fbPsid: "psid-1",
|
||||
hasCompleteFacebookIdentity: false,
|
||||
isGuest: true,
|
||||
userId: "guest-1",
|
||||
}).toJson(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
refreshToken: "",
|
||||
isGuest: true,
|
||||
@@ -64,7 +64,7 @@ describe("facebook identity schema models", () => {
|
||||
});
|
||||
|
||||
it("keeps psid in request serialization without a public field declaration", () => {
|
||||
const request = LoginRequest.from({
|
||||
const request = LoginRequestSchema.parse({
|
||||
email: "user@example.com",
|
||||
password: "password",
|
||||
platform: "web",
|
||||
@@ -72,7 +72,7 @@ describe("facebook identity schema models", () => {
|
||||
psid: "psid-1",
|
||||
});
|
||||
|
||||
expect(request.toJson()).toMatchObject({ psid: "psid-1" });
|
||||
expect(request).toMatchObject({ psid: "psid-1" });
|
||||
});
|
||||
|
||||
it("accepts facebook identity fields in user schema", () => {
|
||||
|
||||
@@ -8,37 +8,16 @@ import { z } from "zod";
|
||||
|
||||
import { stringOrNull } from "../nullable-defaults";
|
||||
|
||||
export const FacebookUserDataSchema = z.object({
|
||||
export const FacebookUserDataSchema = z
|
||||
.object({
|
||||
id: stringOrNull,
|
||||
name: stringOrNull,
|
||||
email: stringOrNull,
|
||||
pictureUrl: stringOrNull,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type FacebookUserDataInput = z.input<typeof FacebookUserDataSchema>;
|
||||
export type FacebookUserDataData = z.output<typeof FacebookUserDataSchema>;
|
||||
|
||||
export class FacebookUserData {
|
||||
declare readonly id: string | null;
|
||||
declare readonly name: string | null;
|
||||
declare readonly email: string | null;
|
||||
declare readonly pictureUrl: string | null;
|
||||
|
||||
private constructor(input: FacebookUserDataInput) {
|
||||
const data = FacebookUserDataSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: FacebookUserDataInput): FacebookUserData {
|
||||
return new FacebookUserData(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): FacebookUserData {
|
||||
return FacebookUserData.from(json as FacebookUserDataInput);
|
||||
}
|
||||
|
||||
toJson(): FacebookUserDataData {
|
||||
return FacebookUserDataSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type FacebookUserData = FacebookUserDataData;
|
||||
|
||||
@@ -3,17 +3,14 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createSchemaModel,
|
||||
type SchemaModel,
|
||||
} from "../../schema_model";
|
||||
|
||||
import { stringOrEmpty } from "../../nullable-defaults";
|
||||
|
||||
export const FacebookIdentityRequestSchema = z.object({
|
||||
export const FacebookIdentityRequestSchema = z
|
||||
.object({
|
||||
asid: stringOrEmpty,
|
||||
psid: stringOrEmpty,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type FacebookIdentityRequestInput = z.input<
|
||||
typeof FacebookIdentityRequestSchema
|
||||
@@ -21,10 +18,4 @@ export type FacebookIdentityRequestInput = z.input<
|
||||
export type FacebookIdentityRequestData = z.output<
|
||||
typeof FacebookIdentityRequestSchema
|
||||
>;
|
||||
|
||||
export type FacebookIdentityRequest = SchemaModel<
|
||||
typeof FacebookIdentityRequestSchema
|
||||
>;
|
||||
export const FacebookIdentityRequest = createSchemaModel(
|
||||
FacebookIdentityRequestSchema,
|
||||
);
|
||||
export type FacebookIdentityRequest = FacebookIdentityRequestData;
|
||||
|
||||
@@ -6,38 +6,21 @@ import { z } from "zod";
|
||||
|
||||
import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults";
|
||||
|
||||
export const FacebookLoginRequestSchema = z.object({
|
||||
export const FacebookLoginRequestSchema = z
|
||||
.object({
|
||||
accessToken: z.string(),
|
||||
platform: z.string(),
|
||||
guestId: stringOrEmpty,
|
||||
psid: stringOrEmpty,
|
||||
isTestAccount: booleanOrFalse,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type FacebookLoginRequestInput = z.input<typeof FacebookLoginRequestSchema>;
|
||||
export type FacebookLoginRequestData = z.output<typeof FacebookLoginRequestSchema>;
|
||||
export type FacebookLoginRequestInput = z.input<
|
||||
typeof FacebookLoginRequestSchema
|
||||
>;
|
||||
export type FacebookLoginRequestData = z.output<
|
||||
typeof FacebookLoginRequestSchema
|
||||
>;
|
||||
|
||||
export class FacebookLoginRequest {
|
||||
declare readonly accessToken: string;
|
||||
declare readonly platform: string;
|
||||
declare readonly guestId: string;
|
||||
declare readonly isTestAccount: boolean;
|
||||
|
||||
private constructor(input: FacebookLoginRequestInput) {
|
||||
const data = FacebookLoginRequestSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: FacebookLoginRequestInput): FacebookLoginRequest {
|
||||
return new FacebookLoginRequest(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): FacebookLoginRequest {
|
||||
return FacebookLoginRequest.from(json as FacebookLoginRequestInput);
|
||||
}
|
||||
|
||||
toJson(): FacebookLoginRequestData {
|
||||
return FacebookLoginRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type FacebookLoginRequest = FacebookLoginRequestData;
|
||||
|
||||
@@ -3,18 +3,15 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createSchemaModel,
|
||||
type SchemaModel,
|
||||
} from "../../schema_model";
|
||||
|
||||
import { booleanOrTrue, stringOrEmpty } from "../../nullable-defaults";
|
||||
|
||||
export const FacebookPsidLoginRequestSchema = z.object({
|
||||
export const FacebookPsidLoginRequestSchema = z
|
||||
.object({
|
||||
psid: z.string(),
|
||||
deviceId: stringOrEmpty,
|
||||
bindToGuest: booleanOrTrue,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type FacebookPsidLoginRequestInput = z.input<
|
||||
typeof FacebookPsidLoginRequestSchema
|
||||
@@ -22,10 +19,4 @@ export type FacebookPsidLoginRequestInput = z.input<
|
||||
export type FacebookPsidLoginRequestData = z.output<
|
||||
typeof FacebookPsidLoginRequestSchema
|
||||
>;
|
||||
|
||||
export type FacebookPsidLoginRequest = SchemaModel<
|
||||
typeof FacebookPsidLoginRequestSchema
|
||||
>;
|
||||
export const FacebookPsidLoginRequest = createSchemaModel(
|
||||
FacebookPsidLoginRequestSchema,
|
||||
);
|
||||
export type FacebookPsidLoginRequest = FacebookPsidLoginRequestData;
|
||||
|
||||
@@ -6,36 +6,16 @@ import { z } from "zod";
|
||||
|
||||
import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults";
|
||||
|
||||
export const FbIdLoginRequestSchema = z.object({
|
||||
export const FbIdLoginRequestSchema = z
|
||||
.object({
|
||||
fbId: z.string(),
|
||||
avatarUrl: stringOrEmpty,
|
||||
psid: stringOrEmpty,
|
||||
isTestAccount: booleanOrFalse,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type FbIdLoginRequestInput = z.input<typeof FbIdLoginRequestSchema>;
|
||||
export type FbIdLoginRequestData = z.output<typeof FbIdLoginRequestSchema>;
|
||||
|
||||
export class FbIdLoginRequest {
|
||||
declare readonly fbId: string;
|
||||
declare readonly avatarUrl: string;
|
||||
declare readonly isTestAccount: boolean;
|
||||
|
||||
private constructor(input: FbIdLoginRequestInput) {
|
||||
const data = FbIdLoginRequestSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: FbIdLoginRequestInput): FbIdLoginRequest {
|
||||
return new FbIdLoginRequest(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): FbIdLoginRequest {
|
||||
return FbIdLoginRequest.from(json as FbIdLoginRequestInput);
|
||||
}
|
||||
|
||||
toJson(): FbIdLoginRequestData {
|
||||
return FbIdLoginRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type FbIdLoginRequest = FbIdLoginRequestData;
|
||||
|
||||
@@ -6,38 +6,17 @@ import { z } from "zod";
|
||||
|
||||
import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults";
|
||||
|
||||
export const GoogleLoginRequestSchema = z.object({
|
||||
export const GoogleLoginRequestSchema = z
|
||||
.object({
|
||||
idToken: z.string(),
|
||||
platform: z.string(),
|
||||
guestId: stringOrEmpty,
|
||||
psid: stringOrEmpty,
|
||||
isTestAccount: booleanOrFalse,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type GoogleLoginRequestInput = z.input<typeof GoogleLoginRequestSchema>;
|
||||
export type GoogleLoginRequestData = z.output<typeof GoogleLoginRequestSchema>;
|
||||
|
||||
export class GoogleLoginRequest {
|
||||
declare readonly idToken: string;
|
||||
declare readonly platform: string;
|
||||
declare readonly guestId: string;
|
||||
declare readonly isTestAccount: boolean;
|
||||
|
||||
private constructor(input: GoogleLoginRequestInput) {
|
||||
const data = GoogleLoginRequestSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: GoogleLoginRequestInput): GoogleLoginRequest {
|
||||
return new GoogleLoginRequest(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): GoogleLoginRequest {
|
||||
return GoogleLoginRequest.from(json as GoogleLoginRequestInput);
|
||||
}
|
||||
|
||||
toJson(): GoogleLoginRequestData {
|
||||
return GoogleLoginRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type GoogleLoginRequest = GoogleLoginRequestData;
|
||||
|
||||
@@ -6,33 +6,14 @@ import { z } from "zod";
|
||||
|
||||
import { booleanOrFalse } from "../../nullable-defaults";
|
||||
|
||||
export const GuestLoginRequestSchema = z.object({
|
||||
export const GuestLoginRequestSchema = z
|
||||
.object({
|
||||
deviceId: z.string(),
|
||||
isTestAccount: booleanOrFalse,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type GuestLoginRequestInput = z.input<typeof GuestLoginRequestSchema>;
|
||||
export type GuestLoginRequestData = z.output<typeof GuestLoginRequestSchema>;
|
||||
|
||||
export class GuestLoginRequest {
|
||||
declare readonly deviceId: string;
|
||||
declare readonly isTestAccount: boolean;
|
||||
|
||||
private constructor(input: GuestLoginRequestInput) {
|
||||
const data = GuestLoginRequestSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: GuestLoginRequestInput): GuestLoginRequest {
|
||||
return new GuestLoginRequest(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): GuestLoginRequest {
|
||||
return GuestLoginRequest.from(json as GuestLoginRequestInput);
|
||||
}
|
||||
|
||||
toJson(): GuestLoginRequestData {
|
||||
return GuestLoginRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type GuestLoginRequest = GuestLoginRequestData;
|
||||
|
||||
@@ -6,7 +6,8 @@ import { z } from "zod";
|
||||
|
||||
import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults";
|
||||
|
||||
export const LoginRequestSchema = z.object({
|
||||
export const LoginRequestSchema = z
|
||||
.object({
|
||||
email: stringOrEmpty,
|
||||
username: stringOrEmpty,
|
||||
password: z.string(),
|
||||
@@ -14,34 +15,10 @@ export const LoginRequestSchema = z.object({
|
||||
guestId: stringOrEmpty,
|
||||
psid: stringOrEmpty,
|
||||
isTestAccount: booleanOrFalse,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type LoginRequestInput = z.input<typeof LoginRequestSchema>;
|
||||
export type LoginRequestData = z.output<typeof LoginRequestSchema>;
|
||||
|
||||
export class LoginRequest {
|
||||
declare readonly email: string;
|
||||
declare readonly username: string;
|
||||
declare readonly password: string;
|
||||
declare readonly platform: string;
|
||||
declare readonly guestId: string;
|
||||
declare readonly isTestAccount: boolean;
|
||||
|
||||
private constructor(input: LoginRequestInput) {
|
||||
const data = LoginRequestSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: LoginRequestInput): LoginRequest {
|
||||
return new LoginRequest(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): LoginRequest {
|
||||
return LoginRequest.from(json as LoginRequestInput);
|
||||
}
|
||||
|
||||
toJson(): LoginRequestData {
|
||||
return LoginRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type LoginRequest = LoginRequestData;
|
||||
|
||||
@@ -4,23 +4,16 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createSchemaModel,
|
||||
type SchemaModel,
|
||||
} from "../../schema_model";
|
||||
|
||||
export const RefreshTokenRequestSchema = z.object({
|
||||
export const RefreshTokenRequestSchema = z
|
||||
.object({
|
||||
refreshToken: z.string(),
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type RefreshTokenRequestInput = z.input<typeof RefreshTokenRequestSchema>;
|
||||
export type RefreshTokenRequestData = z.output<typeof RefreshTokenRequestSchema>;
|
||||
|
||||
export type RefreshTokenRequest = SchemaModel<
|
||||
typeof RefreshTokenRequestSchema,
|
||||
"refreshToken"
|
||||
export type RefreshTokenRequestInput = z.input<
|
||||
typeof RefreshTokenRequestSchema
|
||||
>;
|
||||
export const RefreshTokenRequest = createSchemaModel<
|
||||
typeof RefreshTokenRequestSchema,
|
||||
"refreshToken"
|
||||
>(RefreshTokenRequestSchema);
|
||||
export type RefreshTokenRequestData = z.output<
|
||||
typeof RefreshTokenRequestSchema
|
||||
>;
|
||||
export type RefreshTokenRequest = RefreshTokenRequestData;
|
||||
|
||||
@@ -6,41 +6,18 @@ import { z } from "zod";
|
||||
|
||||
import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults";
|
||||
|
||||
export const RegisterRequestSchema = z.object({
|
||||
export const RegisterRequestSchema = z
|
||||
.object({
|
||||
username: z.string(),
|
||||
email: z.string(),
|
||||
password: z.string(),
|
||||
platform: z.string(),
|
||||
guestId: stringOrEmpty,
|
||||
isTestAccount: booleanOrFalse,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type RegisterRequestInput = z.input<typeof RegisterRequestSchema>;
|
||||
export type RegisterRequestData = z.output<typeof RegisterRequestSchema>;
|
||||
|
||||
export class RegisterRequest {
|
||||
declare readonly username: string;
|
||||
declare readonly email: string;
|
||||
declare readonly password: string;
|
||||
declare readonly platform: string;
|
||||
declare readonly guestId: string;
|
||||
declare readonly isTestAccount: boolean;
|
||||
|
||||
private constructor(input: RegisterRequestInput) {
|
||||
const data = RegisterRequestSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: RegisterRequestInput): RegisterRequest {
|
||||
return new RegisterRequest(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): RegisterRequest {
|
||||
return RegisterRequest.from(json as RegisterRequestInput);
|
||||
}
|
||||
|
||||
toJson(): RegisterRequestData {
|
||||
return RegisterRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type RegisterRequest = RegisterRequestData;
|
||||
|
||||
@@ -3,26 +3,25 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createSchemaModel,
|
||||
type SchemaModel,
|
||||
} from "../../schema_model";
|
||||
|
||||
import { arrayOrEmpty, stringOrEmpty } from "../../nullable-defaults";
|
||||
|
||||
export const FacebookIdentityBindingSchema = z.object({
|
||||
export const FacebookIdentityBindingSchema = z
|
||||
.object({
|
||||
conflicts: arrayOrEmpty(z.unknown()),
|
||||
bound: arrayOrEmpty(z.unknown()),
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export const FacebookIdentityResponseSchema = z.object({
|
||||
export const FacebookIdentityResponseSchema = z
|
||||
.object({
|
||||
fbAsid: stringOrEmpty,
|
||||
fbPsid: stringOrEmpty,
|
||||
facebookBinding: FacebookIdentityBindingSchema.default({
|
||||
facebookBinding: FacebookIdentityBindingSchema.prefault({
|
||||
conflicts: [],
|
||||
bound: [],
|
||||
}),
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type FacebookIdentityResponseInput = z.input<
|
||||
typeof FacebookIdentityResponseSchema
|
||||
@@ -30,10 +29,4 @@ export type FacebookIdentityResponseInput = z.input<
|
||||
export type FacebookIdentityResponseData = z.output<
|
||||
typeof FacebookIdentityResponseSchema
|
||||
>;
|
||||
|
||||
export type FacebookIdentityResponse = SchemaModel<
|
||||
typeof FacebookIdentityResponseSchema
|
||||
>;
|
||||
export const FacebookIdentityResponse = createSchemaModel(
|
||||
FacebookIdentityResponseSchema,
|
||||
);
|
||||
export type FacebookIdentityResponse = FacebookIdentityResponseData;
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
} from "../../nullable-defaults";
|
||||
import { UserSchema } from "../../user/user";
|
||||
|
||||
export const FacebookPsidLoginResponseSchema = z.object({
|
||||
export const FacebookPsidLoginResponseSchema = z
|
||||
.object({
|
||||
token: z.string(),
|
||||
refreshToken: stringOrEmpty,
|
||||
matchedBy: stringOrEmpty,
|
||||
@@ -20,7 +21,8 @@ export const FacebookPsidLoginResponseSchema = z.object({
|
||||
isGuest: booleanOrFalse,
|
||||
user: schemaOrNull(UserSchema),
|
||||
userId: stringOrEmpty,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type FacebookPsidLoginResponseInput = z.input<
|
||||
typeof FacebookPsidLoginResponseSchema
|
||||
@@ -29,26 +31,4 @@ export type FacebookPsidLoginResponseData = z.output<
|
||||
typeof FacebookPsidLoginResponseSchema
|
||||
>;
|
||||
|
||||
export class FacebookPsidLoginResponse {
|
||||
private constructor(input: FacebookPsidLoginResponseInput) {
|
||||
const data = FacebookPsidLoginResponseSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(
|
||||
input: FacebookPsidLoginResponseInput,
|
||||
): FacebookPsidLoginResponse {
|
||||
return new FacebookPsidLoginResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): FacebookPsidLoginResponse {
|
||||
return FacebookPsidLoginResponse.from(
|
||||
json as FacebookPsidLoginResponseInput,
|
||||
);
|
||||
}
|
||||
|
||||
toJson(): FacebookPsidLoginResponseData {
|
||||
return FacebookPsidLoginResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type FacebookPsidLoginResponse = FacebookPsidLoginResponseData;
|
||||
|
||||
@@ -4,45 +4,21 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import { booleanOrTrue, numberOr, schemaOrNull } from "../../nullable-defaults";
|
||||
import { User, UserSchema } from "../../user/user";
|
||||
import { booleanOrTrue, numberOr, schemaOr } from "../../nullable-defaults";
|
||||
import { EMPTY_USER, UserSchema } from "../../user/user";
|
||||
|
||||
export const GuestLoginResponseSchema = z.object({
|
||||
export const GuestLoginResponseSchema = z
|
||||
.object({
|
||||
token: z.string(),
|
||||
deviceId: z.string(),
|
||||
userId: z.string(),
|
||||
isDeviceUser: booleanOrTrue,
|
||||
expiresIn: numberOr(2592000),
|
||||
user: schemaOrNull(UserSchema),
|
||||
});
|
||||
user: schemaOr(UserSchema, EMPTY_USER),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type GuestLoginResponseInput = z.input<typeof GuestLoginResponseSchema>;
|
||||
export type GuestLoginResponseData = z.output<typeof GuestLoginResponseSchema>;
|
||||
|
||||
export class GuestLoginResponse {
|
||||
declare readonly token: string;
|
||||
declare readonly userId: string;
|
||||
declare readonly user: User;
|
||||
|
||||
private constructor(input: GuestLoginResponseInput) {
|
||||
const parsed = GuestLoginResponseSchema.parse(input);
|
||||
Object.assign(this, {
|
||||
...parsed,
|
||||
user: parsed.user ? User.fromJson(parsed.user) : User.empty(),
|
||||
});
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: GuestLoginResponseInput): GuestLoginResponse {
|
||||
return new GuestLoginResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): GuestLoginResponse {
|
||||
return GuestLoginResponse.from(json as GuestLoginResponseInput);
|
||||
}
|
||||
|
||||
toJson(): GuestLoginResponseData {
|
||||
const data = GuestLoginResponseSchema.parse(this);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
export type GuestLoginResponse = GuestLoginResponseData;
|
||||
|
||||
@@ -5,40 +5,17 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { stringOrEmpty } from "../../nullable-defaults";
|
||||
import { User, UserSchema } from "../../user/user";
|
||||
import { UserSchema } from "../../user/user";
|
||||
|
||||
export const LoginResponseSchema = z.object({
|
||||
export const LoginResponseSchema = z
|
||||
.object({
|
||||
user: UserSchema,
|
||||
token: z.string(),
|
||||
refreshToken: stringOrEmpty,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type LoginResponseInput = z.input<typeof LoginResponseSchema>;
|
||||
export type LoginResponseData = z.output<typeof LoginResponseSchema>;
|
||||
|
||||
export class LoginResponse {
|
||||
declare readonly user: User;
|
||||
declare readonly token: string;
|
||||
declare readonly refreshToken: string;
|
||||
|
||||
private constructor(input: LoginResponseInput) {
|
||||
const data = LoginResponseSchema.parse(input);
|
||||
Object.assign(this, {
|
||||
...data,
|
||||
user: User.fromJson(data.user),
|
||||
});
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: LoginResponseInput): LoginResponse {
|
||||
return new LoginResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): LoginResponse {
|
||||
return LoginResponse.from(json as LoginResponseInput);
|
||||
}
|
||||
|
||||
toJson(): LoginResponseData {
|
||||
return LoginResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type LoginResponse = LoginResponseData;
|
||||
|
||||
@@ -4,25 +4,14 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createSchemaModel,
|
||||
type SchemaModel,
|
||||
} from "../../schema_model";
|
||||
|
||||
import { booleanOrTrue } from "../../nullable-defaults";
|
||||
|
||||
export const LogoutResponseSchema = z.object({
|
||||
export const LogoutResponseSchema = z
|
||||
.object({
|
||||
success: booleanOrTrue,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type LogoutResponseInput = z.input<typeof LogoutResponseSchema>;
|
||||
export type LogoutResponseData = z.output<typeof LogoutResponseSchema>;
|
||||
|
||||
export type LogoutResponse = SchemaModel<
|
||||
typeof LogoutResponseSchema,
|
||||
"success"
|
||||
>;
|
||||
export const LogoutResponse = createSchemaModel<
|
||||
typeof LogoutResponseSchema,
|
||||
"success"
|
||||
>(LogoutResponseSchema);
|
||||
export type LogoutResponse = LogoutResponseData;
|
||||
|
||||
@@ -4,34 +4,19 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
export const RefreshTokenResponseSchema = z.object({
|
||||
export const RefreshTokenResponseSchema = z
|
||||
.object({
|
||||
token: z.string(),
|
||||
refreshToken: z.string(),
|
||||
userId: z.string(),
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type RefreshTokenResponseInput = z.input<typeof RefreshTokenResponseSchema>;
|
||||
export type RefreshTokenResponseData = z.output<typeof RefreshTokenResponseSchema>;
|
||||
export type RefreshTokenResponseInput = z.input<
|
||||
typeof RefreshTokenResponseSchema
|
||||
>;
|
||||
export type RefreshTokenResponseData = z.output<
|
||||
typeof RefreshTokenResponseSchema
|
||||
>;
|
||||
|
||||
export class RefreshTokenResponse {
|
||||
declare readonly token: string;
|
||||
declare readonly refreshToken: string;
|
||||
|
||||
private constructor(input: RefreshTokenResponseInput) {
|
||||
const data = RefreshTokenResponseSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: RefreshTokenResponseInput): RefreshTokenResponse {
|
||||
return new RefreshTokenResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): RefreshTokenResponse {
|
||||
return RefreshTokenResponse.from(json as RefreshTokenResponseInput);
|
||||
}
|
||||
|
||||
toJson(): RefreshTokenResponseData {
|
||||
return RefreshTokenResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type RefreshTokenResponse = RefreshTokenResponseData;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Character } from "@/data/schemas/character";
|
||||
import { CharacterSchema } from "@/data/schemas/character";
|
||||
|
||||
describe("Character", () => {
|
||||
it("keeps only the four public character fields", () => {
|
||||
const character = Character.fromJson({
|
||||
const character = CharacterSchema.parse({
|
||||
id: "character_elio",
|
||||
slug: "elio",
|
||||
displayName: " Elio Silvestri ",
|
||||
@@ -13,7 +13,7 @@ describe("Character", () => {
|
||||
sortOrder: 1,
|
||||
});
|
||||
|
||||
expect(character.toJson()).toEqual({
|
||||
expect(character).toEqual({
|
||||
id: "character_elio",
|
||||
slug: "elio",
|
||||
displayName: "Elio Silvestri",
|
||||
@@ -23,7 +23,7 @@ describe("Character", () => {
|
||||
|
||||
it("rejects unsafe slugs and non-HTTPS avatars", () => {
|
||||
expect(() =>
|
||||
Character.from({
|
||||
CharacterSchema.parse({
|
||||
id: "character_aria",
|
||||
slug: "Aria Profile",
|
||||
displayName: "Aria",
|
||||
@@ -31,7 +31,7 @@ describe("Character", () => {
|
||||
}),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
Character.from({
|
||||
CharacterSchema.parse({
|
||||
id: "character_aria",
|
||||
slug: "aria",
|
||||
displayName: "Aria",
|
||||
|
||||
@@ -1,37 +1,17 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const CharacterSchema = z.object({
|
||||
export const CharacterSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).max(64),
|
||||
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||
displayName: z.string().trim().min(1).max(80),
|
||||
avatarUrl: z.url().refine((url) => url.startsWith("https://"), {
|
||||
message: "avatarUrl must use HTTPS",
|
||||
}),
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type CharacterInput = z.input<typeof CharacterSchema>;
|
||||
export type CharacterData = z.output<typeof CharacterSchema>;
|
||||
|
||||
export class Character {
|
||||
declare readonly id: string;
|
||||
declare readonly slug: string;
|
||||
declare readonly displayName: string;
|
||||
declare readonly avatarUrl: string;
|
||||
|
||||
private constructor(input: CharacterInput) {
|
||||
Object.assign(this, CharacterSchema.parse(input));
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: CharacterInput): Character {
|
||||
return new Character(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): Character {
|
||||
return Character.from(json as CharacterInput);
|
||||
}
|
||||
|
||||
toJson(): CharacterData {
|
||||
return CharacterSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type Character = CharacterData;
|
||||
|
||||
@@ -1,36 +1,17 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { Character, CharacterSchema } from "./character";
|
||||
import { CharacterSchema } from "./character";
|
||||
|
||||
export const CharactersResponseSchema = z.object({
|
||||
items: z.array(CharacterSchema).default([]),
|
||||
});
|
||||
export const CharactersResponseSchema = z
|
||||
.object({
|
||||
items: z
|
||||
.array(CharacterSchema)
|
||||
.default(() => [])
|
||||
.readonly(),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type CharactersResponseInput = z.input<typeof CharactersResponseSchema>;
|
||||
export type CharactersResponseData = z.output<typeof CharactersResponseSchema>;
|
||||
|
||||
export class CharactersResponse {
|
||||
declare readonly items: Character[];
|
||||
|
||||
private constructor(input: CharactersResponseInput) {
|
||||
const data = CharactersResponseSchema.parse(input);
|
||||
Object.assign(this, {
|
||||
items: data.items.map((item) => Character.from(item)),
|
||||
});
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: CharactersResponseInput): CharactersResponse {
|
||||
return new CharactersResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): CharactersResponse {
|
||||
return CharactersResponse.from(json as CharactersResponseInput);
|
||||
}
|
||||
|
||||
toJson(): CharactersResponseData {
|
||||
return CharactersResponseSchema.parse({
|
||||
items: this.items.map((item) => item.toJson()),
|
||||
});
|
||||
}
|
||||
}
|
||||
export type CharactersResponse = CharactersResponseData;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ChatHistoryResponse } from "@/data/schemas/chat";
|
||||
import { ChatHistoryResponseSchema } from "@/data/schemas/chat";
|
||||
|
||||
describe("ChatHistoryResponse", () => {
|
||||
it("exposes the pagination total and limit used by chat history", () => {
|
||||
const response = ChatHistoryResponse.from({
|
||||
const response = ChatHistoryResponseSchema.parse({
|
||||
messages: [],
|
||||
total: 120,
|
||||
limit: 50,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
import { UnlockPrivateRequest } from "@/data/schemas/chat";
|
||||
import { UnlockPrivateRequestSchema } from "@/data/schemas/chat";
|
||||
|
||||
describe("UnlockPrivateRequest", () => {
|
||||
it("serializes an existing backend message unlock", () => {
|
||||
expect(
|
||||
UnlockPrivateRequest.from({
|
||||
UnlockPrivateRequestSchema.parse({
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
messageId: "message-1",
|
||||
}).toJson(),
|
||||
}),
|
||||
).toEqual({
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
messageId: "message-1",
|
||||
@@ -18,11 +18,11 @@ describe("UnlockPrivateRequest", () => {
|
||||
|
||||
it("serializes a temporary promotion lock without a fake message id", () => {
|
||||
expect(
|
||||
UnlockPrivateRequest.from({
|
||||
UnlockPrivateRequestSchema.parse({
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
lockType: "voice_message",
|
||||
clientLockId: "promotion-1",
|
||||
}).toJson(),
|
||||
}),
|
||||
).toEqual({
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
lockType: "voice_message",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { UnlockPrivateResponse } from "@/data/schemas/chat";
|
||||
import { UnlockPrivateResponseSchema } from "@/data/schemas/chat";
|
||||
|
||||
describe("UnlockPrivateResponse", () => {
|
||||
it("parses a successful single-message unlock response", () => {
|
||||
const response = UnlockPrivateResponse.from({
|
||||
const response = UnlockPrivateResponseSchema.parse({
|
||||
unlocked: true,
|
||||
content: "完整消息内容",
|
||||
audioUrl: "https://example.com/unlocked-voice.mp3",
|
||||
@@ -26,7 +26,7 @@ describe("UnlockPrivateResponse", () => {
|
||||
});
|
||||
|
||||
it("parses canonical ids, audio, and unlocked images", () => {
|
||||
const response = UnlockPrivateResponse.from({
|
||||
const response = UnlockPrivateResponseSchema.parse({
|
||||
unlocked: true,
|
||||
messageId: "backend-message-1",
|
||||
clientLockId: "promotion-1",
|
||||
@@ -43,14 +43,14 @@ describe("UnlockPrivateResponse", () => {
|
||||
expect(response.content).toBe("Unlocked reply");
|
||||
expect(response.audioUrl).toBe("https://example.com/audio.mp3");
|
||||
expect(response.image.url).toBe("https://example.com/image.jpg");
|
||||
expect(response.toJson()).toMatchObject({
|
||||
expect(response).toMatchObject({
|
||||
clientLockId: "promotion-1",
|
||||
lockType: "image_paywall",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses an insufficient-credits unlock response", () => {
|
||||
const response = UnlockPrivateResponse.from({
|
||||
const response = UnlockPrivateResponseSchema.parse({
|
||||
unlocked: false,
|
||||
content: "",
|
||||
reason: "insufficient_credits",
|
||||
@@ -69,7 +69,7 @@ describe("UnlockPrivateResponse", () => {
|
||||
});
|
||||
|
||||
it("defaults nullable content to an empty string", () => {
|
||||
const response = UnlockPrivateResponse.from({
|
||||
const response = UnlockPrivateResponseSchema.parse({
|
||||
unlocked: false,
|
||||
content: null,
|
||||
audioUrl: null,
|
||||
@@ -81,7 +81,7 @@ describe("UnlockPrivateResponse", () => {
|
||||
});
|
||||
|
||||
it("defaults nullable credit fields to zero", () => {
|
||||
const response = UnlockPrivateResponse.from({
|
||||
const response = UnlockPrivateResponseSchema.parse({
|
||||
unlocked: false,
|
||||
content: "",
|
||||
reason: "insufficient_credits",
|
||||
@@ -98,7 +98,7 @@ describe("UnlockPrivateResponse", () => {
|
||||
});
|
||||
|
||||
it("strips legacy lock detail fields", () => {
|
||||
const response = UnlockPrivateResponse.fromJson({
|
||||
const response = UnlockPrivateResponseSchema.parse({
|
||||
unlocked: false,
|
||||
lockDetail: {
|
||||
locked: true,
|
||||
@@ -110,12 +110,12 @@ describe("UnlockPrivateResponse", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.toJson()).not.toHaveProperty("lockDetail");
|
||||
expect(response).not.toHaveProperty("lockDetail");
|
||||
expect(response).not.toHaveProperty("lockDetail");
|
||||
});
|
||||
|
||||
it("does not fall back to removed response aliases", () => {
|
||||
const response = UnlockPrivateResponse.fromJson({
|
||||
const response = UnlockPrivateResponseSchema.parse({
|
||||
unlocked: true,
|
||||
message_id: "legacy-message",
|
||||
reply: "Legacy reply",
|
||||
@@ -125,8 +125,8 @@ describe("UnlockPrivateResponse", () => {
|
||||
expect(response.messageId).toBe("");
|
||||
expect(response.content).toBe("");
|
||||
expect(response.audioUrl).toBe("");
|
||||
expect(response.toJson()).not.toHaveProperty("message_id");
|
||||
expect(response.toJson()).not.toHaveProperty("reply");
|
||||
expect(response.toJson()).not.toHaveProperty("audio_url");
|
||||
expect(response).not.toHaveProperty("message_id");
|
||||
expect(response).not.toHaveProperty("reply");
|
||||
expect(response).not.toHaveProperty("audio_url");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,12 +5,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { stringOr, stringOrEmpty, stringOrNull } from "../nullable-defaults";
|
||||
import {
|
||||
ChatImageSchema,
|
||||
ChatLockDetailSchema,
|
||||
type ChatImageData,
|
||||
type ChatLockDetailData,
|
||||
} from "./chat_payloads";
|
||||
import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads";
|
||||
|
||||
export const ChatMessageSchema = z
|
||||
.object({
|
||||
@@ -27,36 +22,10 @@ export const ChatMessageSchema = z
|
||||
.transform(({ created_at, ...data }) => ({
|
||||
...data,
|
||||
createdAt: data.createdAt || created_at || "",
|
||||
}));
|
||||
}))
|
||||
.readonly();
|
||||
|
||||
export type ChatMessageInput = z.input<typeof ChatMessageSchema>;
|
||||
export type ChatMessageData = z.output<typeof ChatMessageSchema>;
|
||||
|
||||
export class ChatMessage {
|
||||
declare readonly role: string;
|
||||
declare readonly type: string;
|
||||
declare readonly content: string;
|
||||
declare readonly id: string;
|
||||
declare readonly createdAt: string;
|
||||
declare readonly audioUrl: string | null;
|
||||
declare readonly image: ChatImageData;
|
||||
declare readonly lockDetail: ChatLockDetailData;
|
||||
|
||||
private constructor(input: ChatMessageInput) {
|
||||
const data = ChatMessageSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: ChatMessageInput): ChatMessage {
|
||||
return new ChatMessage(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): ChatMessage {
|
||||
return ChatMessage.from(json as ChatMessageInput);
|
||||
}
|
||||
|
||||
toJson(): ChatMessageData {
|
||||
return ChatMessageSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type ChatMessage = ChatMessageData;
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
booleanOrFalse,
|
||||
schemaOr,
|
||||
stringOrNull,
|
||||
} from "../nullable-defaults";
|
||||
import { booleanOrFalse, schemaOr, stringOrNull } from "../nullable-defaults";
|
||||
|
||||
const CHAT_IMAGE_DEFAULTS = { type: null, url: null } as const;
|
||||
|
||||
@@ -22,7 +18,7 @@ export const ChatImageSchema = schemaOr(
|
||||
url: stringOrNull,
|
||||
}),
|
||||
CHAT_IMAGE_DEFAULTS,
|
||||
);
|
||||
).readonly();
|
||||
|
||||
export const ChatLockDetailSchema = schemaOr(
|
||||
z.object({
|
||||
@@ -30,7 +26,7 @@ export const ChatLockDetailSchema = schemaOr(
|
||||
reason: stringOrNull,
|
||||
}),
|
||||
CHAT_LOCK_DETAIL_DEFAULTS,
|
||||
);
|
||||
).readonly();
|
||||
|
||||
export type ChatImageInput = z.input<typeof ChatImageSchema>;
|
||||
export type ChatImageData = z.output<typeof ChatImageSchema>;
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
stringOrEmpty,
|
||||
} from "../../nullable-defaults";
|
||||
|
||||
export const SendMessageRequestSchema = z.object({
|
||||
export const SendMessageRequestSchema = z
|
||||
.object({
|
||||
characterId: z.string().min(1),
|
||||
message: stringOrEmpty,
|
||||
image: stringOrEmpty,
|
||||
@@ -21,37 +22,10 @@ export const SendMessageRequestSchema = z.object({
|
||||
imageWidth: numberOrZero,
|
||||
imageHeight: numberOrZero,
|
||||
useWebSocket: booleanOrFalse,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type SendMessageRequestInput = z.input<typeof SendMessageRequestSchema>;
|
||||
export type SendMessageRequestData = z.output<typeof SendMessageRequestSchema>;
|
||||
|
||||
export class SendMessageRequest {
|
||||
declare readonly characterId: string;
|
||||
declare readonly message: string;
|
||||
declare readonly image: string;
|
||||
declare readonly imageId: string;
|
||||
declare readonly imageThumbUrl: string;
|
||||
declare readonly imageMediumUrl: string;
|
||||
declare readonly imageOriginalUrl: string;
|
||||
declare readonly imageWidth: number;
|
||||
declare readonly imageHeight: number;
|
||||
|
||||
private constructor(input: SendMessageRequestInput) {
|
||||
const data = SendMessageRequestSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: SendMessageRequestInput): SendMessageRequest {
|
||||
return new SendMessageRequest(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): SendMessageRequest {
|
||||
return SendMessageRequest.from(json as SendMessageRequestInput);
|
||||
}
|
||||
|
||||
toJson(): SendMessageRequestData {
|
||||
return SendMessageRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type SendMessageRequest = SendMessageRequestData;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const UnlockHistoryRequestSchema = z.object({
|
||||
export const UnlockHistoryRequestSchema = z
|
||||
.object({
|
||||
characterId: z.string().min(1),
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type UnlockHistoryRequestInput = z.input<
|
||||
typeof UnlockHistoryRequestSchema
|
||||
@@ -11,19 +13,4 @@ export type UnlockHistoryRequestData = z.output<
|
||||
typeof UnlockHistoryRequestSchema
|
||||
>;
|
||||
|
||||
export class UnlockHistoryRequest {
|
||||
declare readonly characterId: string;
|
||||
|
||||
private constructor(input: UnlockHistoryRequestInput) {
|
||||
Object.assign(this, UnlockHistoryRequestSchema.parse(input));
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: UnlockHistoryRequestInput): UnlockHistoryRequest {
|
||||
return new UnlockHistoryRequest(input);
|
||||
}
|
||||
|
||||
toJson(): UnlockHistoryRequestData {
|
||||
return UnlockHistoryRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type UnlockHistoryRequest = UnlockHistoryRequestData;
|
||||
|
||||
@@ -14,7 +14,8 @@ export const UnlockPrivateRequestSchema = z
|
||||
})
|
||||
.refine((value) => value.messageId || value.lockType, {
|
||||
message: "messageId or lockType is required",
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type UnlockPrivateRequestInput = z.input<
|
||||
typeof UnlockPrivateRequestSchema
|
||||
@@ -23,27 +24,4 @@ export type UnlockPrivateRequestData = z.output<
|
||||
typeof UnlockPrivateRequestSchema
|
||||
>;
|
||||
|
||||
export class UnlockPrivateRequest {
|
||||
declare readonly characterId: string;
|
||||
declare readonly messageId?: string;
|
||||
declare readonly lockType?: UnlockPrivateRequestData["lockType"];
|
||||
declare readonly clientLockId?: string;
|
||||
|
||||
private constructor(input: UnlockPrivateRequestInput) {
|
||||
const data = UnlockPrivateRequestSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: UnlockPrivateRequestInput): UnlockPrivateRequest {
|
||||
return new UnlockPrivateRequest(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): UnlockPrivateRequest {
|
||||
return UnlockPrivateRequest.from(json as UnlockPrivateRequestInput);
|
||||
}
|
||||
|
||||
toJson(): UnlockPrivateRequestData {
|
||||
return UnlockPrivateRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type UnlockPrivateRequest = UnlockPrivateRequestData;
|
||||
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
booleanOrFalse,
|
||||
numberOrZero,
|
||||
} from "../../nullable-defaults";
|
||||
import { ChatMessage, ChatMessageSchema } from "../chat_message";
|
||||
import { ChatMessageSchema } from "../chat_message";
|
||||
|
||||
export const ChatHistoryResponseSchema = z.object({
|
||||
export const ChatHistoryResponseSchema = z
|
||||
.object({
|
||||
messages: arrayOrEmpty(ChatMessageSchema),
|
||||
total: numberOrZero,
|
||||
limit: numberOrZero,
|
||||
@@ -20,34 +21,14 @@ export const ChatHistoryResponseSchema = z.object({
|
||||
privateFreeLimit: numberOrZero,
|
||||
privateUsedToday: numberOrZero,
|
||||
privateCanViewFree: booleanOrFalse,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type ChatHistoryResponseInput = z.input<typeof ChatHistoryResponseSchema>;
|
||||
export type ChatHistoryResponseData = z.output<typeof ChatHistoryResponseSchema>;
|
||||
export type ChatHistoryResponseInput = z.input<
|
||||
typeof ChatHistoryResponseSchema
|
||||
>;
|
||||
export type ChatHistoryResponseData = z.output<
|
||||
typeof ChatHistoryResponseSchema
|
||||
>;
|
||||
|
||||
export class ChatHistoryResponse {
|
||||
declare readonly messages: ChatMessage[];
|
||||
declare readonly total: number;
|
||||
declare readonly limit: number;
|
||||
|
||||
private constructor(input: ChatHistoryResponseInput) {
|
||||
const data = ChatHistoryResponseSchema.parse(input);
|
||||
Object.assign(this, {
|
||||
...data,
|
||||
messages: data.messages.map((m) => ChatMessage.from(m)),
|
||||
});
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: ChatHistoryResponseInput): ChatHistoryResponse {
|
||||
return new ChatHistoryResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): ChatHistoryResponse {
|
||||
return ChatHistoryResponse.from(json as ChatHistoryResponseInput);
|
||||
}
|
||||
|
||||
toJson(): ChatHistoryResponseData {
|
||||
return ChatHistoryResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type ChatHistoryResponse = ChatHistoryResponseData;
|
||||
|
||||
@@ -10,14 +10,10 @@ import {
|
||||
numberOrZero,
|
||||
stringOrEmpty,
|
||||
} from "../../nullable-defaults";
|
||||
import {
|
||||
ChatImageSchema,
|
||||
ChatLockDetailSchema,
|
||||
type ChatImageData,
|
||||
type ChatLockDetailData,
|
||||
} from "../chat_payloads";
|
||||
import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads";
|
||||
|
||||
export const ChatSendResponseSchema = z.object({
|
||||
export const ChatSendResponseSchema = z
|
||||
.object({
|
||||
reply: stringOrEmpty,
|
||||
audioUrl: stringOrEmpty,
|
||||
messageId: stringOrEmpty,
|
||||
@@ -31,39 +27,10 @@ export const ChatSendResponseSchema = z.object({
|
||||
creditsCharged: numberOrZero,
|
||||
requiredCredits: numberOrZero,
|
||||
shortfallCredits: numberOrZero,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type ChatSendResponseInput = z.input<typeof ChatSendResponseSchema>;
|
||||
export type ChatSendResponseData = z.output<typeof ChatSendResponseSchema>;
|
||||
|
||||
export class ChatSendResponse {
|
||||
declare readonly reply: string;
|
||||
declare readonly audioUrl: string;
|
||||
declare readonly messageId: string;
|
||||
declare readonly timestamp: number;
|
||||
declare readonly image: ChatImageData;
|
||||
declare readonly lockDetail: ChatLockDetailData;
|
||||
declare readonly canSendMessage: boolean;
|
||||
declare readonly creditBalance: number;
|
||||
declare readonly creditsCharged: number;
|
||||
declare readonly requiredCredits: number;
|
||||
declare readonly shortfallCredits: number;
|
||||
|
||||
private constructor(input: ChatSendResponseInput) {
|
||||
const data = ChatSendResponseSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: ChatSendResponseInput): ChatSendResponse {
|
||||
return new ChatSendResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): ChatSendResponse {
|
||||
return ChatSendResponse.from(json as ChatSendResponseInput);
|
||||
}
|
||||
|
||||
toJson(): ChatSendResponseData {
|
||||
return ChatSendResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type ChatSendResponse = ChatSendResponseData;
|
||||
|
||||
@@ -18,7 +18,8 @@ export const UnlockHistoryReasonSchema = z.enum([
|
||||
"no_locked_messages",
|
||||
]);
|
||||
|
||||
export const UnlockHistoryResponseSchema = z.object({
|
||||
export const UnlockHistoryResponseSchema = z
|
||||
.object({
|
||||
unlocked: booleanOrFalse,
|
||||
reason: UnlockHistoryReasonSchema,
|
||||
totalLocked: numberOrZero,
|
||||
@@ -32,7 +33,8 @@ export const UnlockHistoryResponseSchema = z.object({
|
||||
shortfallCredits: numberOrZero,
|
||||
costsByMessage: UnlockHistoryCostsByMessageSchema,
|
||||
messageIds: arrayOrEmpty(z.string()),
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type UnlockHistoryReason = z.output<typeof UnlockHistoryReasonSchema>;
|
||||
export type UnlockHistoryResponseInput = z.input<
|
||||
@@ -42,26 +44,4 @@ export type UnlockHistoryResponseData = z.output<
|
||||
typeof UnlockHistoryResponseSchema
|
||||
>;
|
||||
|
||||
export class UnlockHistoryResponse {
|
||||
declare readonly unlocked: boolean;
|
||||
declare readonly reason: UnlockHistoryReason;
|
||||
declare readonly shortfallCredits: number;
|
||||
|
||||
private constructor(input: UnlockHistoryResponseInput) {
|
||||
const data = UnlockHistoryResponseSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: UnlockHistoryResponseInput): UnlockHistoryResponse {
|
||||
return new UnlockHistoryResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): UnlockHistoryResponse {
|
||||
return UnlockHistoryResponse.from(json as UnlockHistoryResponseInput);
|
||||
}
|
||||
|
||||
toJson(): UnlockHistoryResponseData {
|
||||
return UnlockHistoryResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type UnlockHistoryResponse = UnlockHistoryResponseData;
|
||||
|
||||
@@ -6,15 +6,16 @@ import {
|
||||
stringOrEmpty,
|
||||
stringOrNull,
|
||||
} from "../../nullable-defaults";
|
||||
import { ChatImageSchema, type ChatImageData } from "../chat_payloads";
|
||||
import { ChatLockTypeSchema } from "../chat_lock_type";
|
||||
import { ChatImageSchema } from "../chat_payloads";
|
||||
|
||||
/**
|
||||
* 单条历史付费 / 私密消息解锁响应。
|
||||
*/
|
||||
export const UnlockPrivateReasonSchema = stringOrEmpty;
|
||||
|
||||
export const UnlockPrivateResponseSchema = z.object({
|
||||
export const UnlockPrivateResponseSchema = z
|
||||
.object({
|
||||
unlocked: booleanOrFalse,
|
||||
content: stringOrEmpty,
|
||||
messageId: stringOrEmpty,
|
||||
@@ -27,7 +28,8 @@ export const UnlockPrivateResponseSchema = z.object({
|
||||
creditsCharged: numberOrZero,
|
||||
requiredCredits: numberOrZero,
|
||||
shortfallCredits: numberOrZero,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type UnlockPrivateReason = z.output<typeof UnlockPrivateReasonSchema>;
|
||||
export type UnlockPrivateResponseInput = z.input<
|
||||
@@ -37,36 +39,4 @@ export type UnlockPrivateResponseData = z.output<
|
||||
typeof UnlockPrivateResponseSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* 单条历史付费 / 私密消息解锁响应数据类。
|
||||
*/
|
||||
export class UnlockPrivateResponse {
|
||||
declare readonly unlocked: boolean;
|
||||
declare readonly messageId: string;
|
||||
declare readonly content: string;
|
||||
declare readonly audioUrl: string;
|
||||
declare readonly image: ChatImageData;
|
||||
declare readonly reason: UnlockPrivateReason;
|
||||
declare readonly creditBalance: number;
|
||||
declare readonly creditsCharged: number;
|
||||
declare readonly requiredCredits: number;
|
||||
declare readonly shortfallCredits: number;
|
||||
|
||||
private constructor(input: UnlockPrivateResponseInput) {
|
||||
const data = UnlockPrivateResponseSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: UnlockPrivateResponseInput): UnlockPrivateResponse {
|
||||
return new UnlockPrivateResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): UnlockPrivateResponse {
|
||||
return UnlockPrivateResponse.from(json as UnlockPrivateResponseInput);
|
||||
}
|
||||
|
||||
toJson(): UnlockPrivateResponseData {
|
||||
return UnlockPrivateResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type UnlockPrivateResponse = UnlockPrivateResponseData;
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createSchemaModel,
|
||||
type SchemaModel,
|
||||
} from "../../schema_model";
|
||||
|
||||
export const FeedbackSubmitResponseSchema = z.object({
|
||||
export const FeedbackSubmitResponseSchema = z
|
||||
.object({
|
||||
feedbackId: z.string().min(1),
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type FeedbackSubmitResponseInput = z.input<
|
||||
typeof FeedbackSubmitResponseSchema
|
||||
@@ -15,12 +12,4 @@ export type FeedbackSubmitResponseInput = z.input<
|
||||
export type FeedbackSubmitResponseData = z.output<
|
||||
typeof FeedbackSubmitResponseSchema
|
||||
>;
|
||||
|
||||
export type FeedbackSubmitResponse = SchemaModel<
|
||||
typeof FeedbackSubmitResponseSchema,
|
||||
"feedbackId"
|
||||
>;
|
||||
export const FeedbackSubmitResponse = createSchemaModel<
|
||||
typeof FeedbackSubmitResponseSchema,
|
||||
"feedbackId"
|
||||
>(FeedbackSubmitResponseSchema);
|
||||
export type FeedbackSubmitResponse = FeedbackSubmitResponseData;
|
||||
|
||||
@@ -4,25 +4,16 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createSchemaModel,
|
||||
type SchemaModel,
|
||||
} from "../../schema_model";
|
||||
|
||||
import { stringOrEmpty } from "../../nullable-defaults";
|
||||
|
||||
export const AppEventSchema = z.object({
|
||||
export const AppEventSchema = z
|
||||
.object({
|
||||
userId: stringOrEmpty,
|
||||
browser: stringOrEmpty,
|
||||
userAgent: stringOrEmpty,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type AppEventInput = z.input<typeof AppEventSchema>;
|
||||
export type AppEventData = z.output<typeof AppEventSchema>;
|
||||
|
||||
type AppEventPublicKey = "userId" | "browser" | "userAgent";
|
||||
export type AppEvent = SchemaModel<typeof AppEventSchema, AppEventPublicKey>;
|
||||
export const AppEvent = createSchemaModel<
|
||||
typeof AppEventSchema,
|
||||
AppEventPublicKey
|
||||
>(AppEventSchema);
|
||||
export type AppEvent = AppEventData;
|
||||
|
||||
@@ -4,36 +4,22 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createSchemaModel,
|
||||
type SchemaModel,
|
||||
} from "../../schema_model";
|
||||
|
||||
import {
|
||||
booleanOrFalse,
|
||||
numberOrZero,
|
||||
stringOrEmpty,
|
||||
} from "../../nullable-defaults";
|
||||
|
||||
export const PwaEventSchema = z.object({
|
||||
export const PwaEventSchema = z
|
||||
.object({
|
||||
deviceId: stringOrEmpty,
|
||||
deviceType: stringOrEmpty,
|
||||
timestamp: numberOrZero,
|
||||
pwaInstalled: booleanOrFalse,
|
||||
pwaSupported: booleanOrFalse,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type PwaEventInput = z.input<typeof PwaEventSchema>;
|
||||
export type PwaEventData = z.output<typeof PwaEventSchema>;
|
||||
|
||||
type PwaEventPublicKey =
|
||||
| "deviceId"
|
||||
| "deviceType"
|
||||
| "timestamp"
|
||||
| "pwaInstalled"
|
||||
| "pwaSupported";
|
||||
export type PwaEvent = SchemaModel<typeof PwaEventSchema, PwaEventPublicKey>;
|
||||
export const PwaEvent = createSchemaModel<
|
||||
typeof PwaEventSchema,
|
||||
PwaEventPublicKey
|
||||
>(PwaEventSchema);
|
||||
export type PwaEvent = PwaEventData;
|
||||
|
||||
@@ -91,7 +91,8 @@ export function arrayOrEmpty<T extends z.ZodType>(schema: T) {
|
||||
.array(schema)
|
||||
.nullable()
|
||||
.transform((v) => v ?? [])
|
||||
.default(() => []);
|
||||
.default(() => [])
|
||||
.readonly();
|
||||
}
|
||||
|
||||
/** `Record<string, T> | null | undefined` → `Record<string, T>`(默认空对象) */
|
||||
@@ -100,7 +101,8 @@ export function recordOrEmpty<T extends z.ZodType>(schema: T) {
|
||||
.record(z.string(), schema)
|
||||
.nullable()
|
||||
.transform((v) => v ?? {})
|
||||
.default(() => ({}));
|
||||
.default(() => ({}))
|
||||
.readonly();
|
||||
}
|
||||
|
||||
/** `Record<string, unknown> | null | undefined` → `Record<string, unknown>` */
|
||||
@@ -129,10 +131,10 @@ export function schemaOr<T extends z.ZodType>(
|
||||
);
|
||||
|
||||
if (typeof defaultValue === "function") {
|
||||
return preprocessed.default(defaultValue as () => SchemaDefault<T>);
|
||||
return preprocessed.prefault(defaultValue as () => SchemaDefault<T>);
|
||||
}
|
||||
|
||||
return preprocessed.default(defaultValue);
|
||||
return preprocessed.prefault(defaultValue);
|
||||
}
|
||||
|
||||
/** `T | null | undefined` → `T | null`(默认 null) */
|
||||
|
||||
@@ -2,14 +2,14 @@ import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
PaymentPlan,
|
||||
PaymentPlansResponse,
|
||||
TipPaymentPlansResponse,
|
||||
PaymentPlanSchema,
|
||||
PaymentPlansResponseSchema,
|
||||
TipPaymentPlansResponseSchema,
|
||||
} from "@/data/schemas/payment";
|
||||
|
||||
describe("PaymentPlan", () => {
|
||||
it("parses the camelCase payment plan shape", () => {
|
||||
const plan = PaymentPlan.from({
|
||||
const plan = PaymentPlanSchema.parse({
|
||||
planId: "vip_monthly",
|
||||
planName: "VIP Monthly",
|
||||
orderType: "vip_monthly",
|
||||
@@ -26,7 +26,7 @@ describe("PaymentPlan", () => {
|
||||
expect(plan.planId).toBe("vip_monthly");
|
||||
expect(plan.creditBalance).toBe(3000);
|
||||
expect(plan.mostPopular).toBe(true);
|
||||
expect(plan.toJson()).toEqual({
|
||||
expect(plan).toEqual({
|
||||
planId: "vip_monthly",
|
||||
planName: "VIP Monthly",
|
||||
orderType: "vip_monthly",
|
||||
@@ -46,7 +46,7 @@ describe("PaymentPlan", () => {
|
||||
|
||||
it("rejects the legacy snake_case payment plan shape", () => {
|
||||
expect(() =>
|
||||
PaymentPlan.fromJson({
|
||||
PaymentPlanSchema.parse({
|
||||
plan_id: "vip_monthly",
|
||||
plan_name: "VIP Monthly",
|
||||
order_type: "vip_monthly",
|
||||
@@ -61,7 +61,7 @@ describe("PaymentPlan", () => {
|
||||
});
|
||||
|
||||
it("defaults mostPopular to false when the backend omits it", () => {
|
||||
const plan = PaymentPlan.from({
|
||||
const plan = PaymentPlanSchema.parse({
|
||||
planId: "vip_monthly",
|
||||
planName: "VIP Monthly",
|
||||
orderType: "vip_monthly",
|
||||
@@ -80,7 +80,7 @@ describe("PaymentPlan", () => {
|
||||
|
||||
describe("PaymentPlansResponse", () => {
|
||||
it("parses plans from the new backend response data shape", () => {
|
||||
const response = PaymentPlansResponse.from({
|
||||
const response = PaymentPlansResponseSchema.parse({
|
||||
plans: [
|
||||
{
|
||||
planId: "dol_1000",
|
||||
@@ -102,7 +102,7 @@ describe("PaymentPlansResponse", () => {
|
||||
});
|
||||
|
||||
it("parses first recharge offer metadata", () => {
|
||||
const response = PaymentPlansResponse.from({
|
||||
const response = PaymentPlansResponseSchema.parse({
|
||||
isFirstRecharge: true,
|
||||
firstRechargeOffer: {
|
||||
enabled: true,
|
||||
@@ -140,7 +140,7 @@ describe("PaymentPlansResponse", () => {
|
||||
});
|
||||
|
||||
it("defaults nullable first recharge offer metadata", () => {
|
||||
const response = PaymentPlansResponse.from({
|
||||
const response = PaymentPlansResponseSchema.parse({
|
||||
isFirstRecharge: true,
|
||||
firstRechargeOffer: {
|
||||
enabled: null,
|
||||
@@ -160,7 +160,7 @@ describe("PaymentPlansResponse", () => {
|
||||
|
||||
describe("TipPaymentPlansResponse", () => {
|
||||
it("keeps only fields used by the tip payment flow", () => {
|
||||
const response = TipPaymentPlansResponse.fromJson({
|
||||
const response = TipPaymentPlansResponseSchema.parse({
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
@@ -177,7 +177,7 @@ describe("TipPaymentPlansResponse", () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.toJson()).toEqual({
|
||||
expect(response).toEqual({
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
stringOrNull,
|
||||
} from "../nullable-defaults";
|
||||
|
||||
export const PaymentPlanSchema = z.object({
|
||||
export const PaymentPlanSchema = z
|
||||
.object({
|
||||
planId: z.string(),
|
||||
planName: z.string(),
|
||||
orderType: z.string(),
|
||||
@@ -24,41 +25,10 @@ export const PaymentPlanSchema = z.object({
|
||||
mostPopular: booleanOrFalse,
|
||||
firstRechargeDiscountPercent: numberOrNull,
|
||||
promotionType: stringOrNull,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type PaymentPlanInput = z.input<typeof PaymentPlanSchema>;
|
||||
export type PaymentPlanData = z.output<typeof PaymentPlanSchema>;
|
||||
|
||||
export class PaymentPlan {
|
||||
declare readonly planId: string;
|
||||
declare readonly planName: string;
|
||||
declare readonly orderType: string;
|
||||
declare readonly vipDays: number | null;
|
||||
declare readonly dolAmount: number | null;
|
||||
declare readonly creditBalance: number;
|
||||
declare readonly amountCents: number;
|
||||
declare readonly originalAmountCents: number | null;
|
||||
declare readonly currency: string;
|
||||
declare readonly isFirstRechargeOffer: boolean;
|
||||
declare readonly mostPopular: boolean;
|
||||
declare readonly firstRechargeDiscountPercent: number | null;
|
||||
declare readonly promotionType: string | null;
|
||||
|
||||
private constructor(input: PaymentPlanInput) {
|
||||
const data = PaymentPlanSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: PaymentPlanInput): PaymentPlan {
|
||||
return new PaymentPlan(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): PaymentPlan {
|
||||
return PaymentPlan.from(json as PaymentPlanInput);
|
||||
}
|
||||
|
||||
toJson(): PaymentPlanData {
|
||||
return PaymentPlanSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type PaymentPlan = PaymentPlanData;
|
||||
|
||||
@@ -5,11 +5,13 @@ import { z } from "zod";
|
||||
|
||||
export const PayChannelSchema = z.enum(["stripe", "ezpay"]);
|
||||
|
||||
export const CreatePaymentOrderRequestSchema = z.object({
|
||||
export const CreatePaymentOrderRequestSchema = z
|
||||
.object({
|
||||
planId: z.string(),
|
||||
payChannel: PayChannelSchema,
|
||||
autoRenew: z.boolean(),
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type PayChannel = z.output<typeof PayChannelSchema>;
|
||||
export type CreatePaymentOrderRequestInput = z.input<
|
||||
@@ -19,30 +21,4 @@ export type CreatePaymentOrderRequestData = z.output<
|
||||
typeof CreatePaymentOrderRequestSchema
|
||||
>;
|
||||
|
||||
export class CreatePaymentOrderRequest {
|
||||
declare readonly planId: string;
|
||||
declare readonly payChannel: PayChannel;
|
||||
declare readonly autoRenew: boolean;
|
||||
|
||||
private constructor(input: CreatePaymentOrderRequestInput) {
|
||||
const data = CreatePaymentOrderRequestSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(
|
||||
input: CreatePaymentOrderRequestInput,
|
||||
): CreatePaymentOrderRequest {
|
||||
return new CreatePaymentOrderRequest(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): CreatePaymentOrderRequest {
|
||||
return CreatePaymentOrderRequest.from(
|
||||
json as CreatePaymentOrderRequestInput,
|
||||
);
|
||||
}
|
||||
|
||||
toJson(): CreatePaymentOrderRequestData {
|
||||
return CreatePaymentOrderRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type CreatePaymentOrderRequest = CreatePaymentOrderRequestData;
|
||||
|
||||
@@ -19,9 +19,10 @@ const paymentUrlKeys = [
|
||||
"url",
|
||||
] as const;
|
||||
|
||||
export const CreatePaymentOrderResponseSchema = z.object({
|
||||
export const CreatePaymentOrderResponseSchema = z
|
||||
.object({
|
||||
orderId: z.string(),
|
||||
payParams: z.record(z.string(), z.unknown()),
|
||||
payParams: z.record(z.string(), z.unknown()).readonly(),
|
||||
cashierUrl: stringOrNull,
|
||||
cashier_url: stringOrNull,
|
||||
checkoutUrl: stringOrNull,
|
||||
@@ -33,12 +34,17 @@ export const CreatePaymentOrderResponseSchema = z.object({
|
||||
redirectUrl: stringOrNull,
|
||||
redirect_url: stringOrNull,
|
||||
url: stringOrNull,
|
||||
}).transform((data) => {
|
||||
})
|
||||
.transform((data) => {
|
||||
const payParams = { ...data.payParams };
|
||||
|
||||
for (const key of paymentUrlKeys) {
|
||||
const value = data[key];
|
||||
if (typeof value === "string" && value.length > 0 && !(key in payParams)) {
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
value.length > 0 &&
|
||||
!(key in payParams)
|
||||
) {
|
||||
payParams[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -47,7 +53,8 @@ export const CreatePaymentOrderResponseSchema = z.object({
|
||||
orderId: data.orderId,
|
||||
payParams,
|
||||
};
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type CreatePaymentOrderResponseInput = z.input<
|
||||
typeof CreatePaymentOrderResponseSchema
|
||||
@@ -56,29 +63,4 @@ export type CreatePaymentOrderResponseData = z.output<
|
||||
typeof CreatePaymentOrderResponseSchema
|
||||
>;
|
||||
|
||||
export class CreatePaymentOrderResponse {
|
||||
declare readonly orderId: string;
|
||||
declare readonly payParams: Record<string, unknown>;
|
||||
|
||||
private constructor(input: CreatePaymentOrderResponseInput) {
|
||||
const data = CreatePaymentOrderResponseSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(
|
||||
input: CreatePaymentOrderResponseInput,
|
||||
): CreatePaymentOrderResponse {
|
||||
return new CreatePaymentOrderResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): CreatePaymentOrderResponse {
|
||||
return CreatePaymentOrderResponse.from(
|
||||
json as CreatePaymentOrderResponseInput,
|
||||
);
|
||||
}
|
||||
|
||||
toJson(): CreatePaymentOrderResponseData {
|
||||
return CreatePaymentOrderResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type CreatePaymentOrderResponse = CreatePaymentOrderResponseData;
|
||||
|
||||
@@ -5,12 +5,14 @@ import { z } from "zod";
|
||||
|
||||
export const PaymentOrderStatusSchema = z.enum(["pending", "paid", "failed"]);
|
||||
|
||||
export const PaymentOrderStatusResponseSchema = z.object({
|
||||
export const PaymentOrderStatusResponseSchema = z
|
||||
.object({
|
||||
orderId: z.string(),
|
||||
status: PaymentOrderStatusSchema,
|
||||
orderType: z.string(),
|
||||
planId: z.string(),
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type PaymentOrderStatus = z.output<typeof PaymentOrderStatusSchema>;
|
||||
export type PaymentOrderStatusResponseInput = z.input<
|
||||
@@ -20,28 +22,4 @@ export type PaymentOrderStatusResponseData = z.output<
|
||||
typeof PaymentOrderStatusResponseSchema
|
||||
>;
|
||||
|
||||
export class PaymentOrderStatusResponse {
|
||||
declare readonly status: PaymentOrderStatus;
|
||||
|
||||
private constructor(input: PaymentOrderStatusResponseInput) {
|
||||
const data = PaymentOrderStatusResponseSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(
|
||||
input: PaymentOrderStatusResponseInput,
|
||||
): PaymentOrderStatusResponse {
|
||||
return new PaymentOrderStatusResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): PaymentOrderStatusResponse {
|
||||
return PaymentOrderStatusResponse.from(
|
||||
json as PaymentOrderStatusResponseInput,
|
||||
);
|
||||
}
|
||||
|
||||
toJson(): PaymentOrderStatusResponseData {
|
||||
return PaymentOrderStatusResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type PaymentOrderStatusResponse = PaymentOrderStatusResponseData;
|
||||
|
||||
@@ -10,19 +10,23 @@ import {
|
||||
schemaOrNull,
|
||||
stringOrEmpty,
|
||||
} from "../../nullable-defaults";
|
||||
import { PaymentPlan, PaymentPlanSchema } from "../payment_plan";
|
||||
import { PaymentPlanSchema } from "../payment_plan";
|
||||
|
||||
export const FirstRechargeOfferSchema = z.object({
|
||||
export const FirstRechargeOfferSchema = z
|
||||
.object({
|
||||
enabled: booleanOrFalse,
|
||||
type: stringOrEmpty,
|
||||
discountPercent: numberOrZero,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export const PaymentPlansResponseSchema = z.object({
|
||||
export const PaymentPlansResponseSchema = z
|
||||
.object({
|
||||
isFirstRecharge: booleanOrFalse,
|
||||
firstRechargeOffer: schemaOrNull(FirstRechargeOfferSchema),
|
||||
plans: arrayOrEmpty(PaymentPlanSchema),
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type PaymentPlansResponseInput = z.input<
|
||||
typeof PaymentPlansResponseSchema
|
||||
@@ -32,36 +36,6 @@ export type PaymentPlansResponseData = z.output<
|
||||
>;
|
||||
export type FirstRechargeOfferData = z.output<typeof FirstRechargeOfferSchema>;
|
||||
|
||||
export class PaymentPlansResponse {
|
||||
declare readonly isFirstRecharge: boolean;
|
||||
declare readonly firstRechargeOffer: FirstRechargeOfferData | null;
|
||||
declare readonly plans: PaymentPlan[];
|
||||
|
||||
private constructor(input: PaymentPlansResponseInput) {
|
||||
const data = PaymentPlansResponseSchema.parse(input);
|
||||
Object.assign(this, {
|
||||
isFirstRecharge: data.isFirstRecharge,
|
||||
firstRechargeOffer: data.firstRechargeOffer,
|
||||
plans: data.plans.map((plan) => PaymentPlan.from(plan)),
|
||||
});
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: PaymentPlansResponseInput): PaymentPlansResponse {
|
||||
return new PaymentPlansResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): PaymentPlansResponse {
|
||||
return PaymentPlansResponse.from(json as PaymentPlansResponseInput);
|
||||
}
|
||||
|
||||
toJson(): PaymentPlansResponseData {
|
||||
return {
|
||||
isFirstRecharge: this.isFirstRecharge,
|
||||
firstRechargeOffer: this.firstRechargeOffer,
|
||||
plans: this.plans.map((plan) => plan.toJson()),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type FirstRechargeOffer = FirstRechargeOfferData;
|
||||
|
||||
export type PaymentPlansResponse = PaymentPlansResponseData;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { arrayOrEmpty } from "../../nullable-defaults";
|
||||
import { TipPaymentPlan, TipPaymentPlanSchema } from "../tip_payment_plan";
|
||||
import { TipPaymentPlanSchema } from "../tip_payment_plan";
|
||||
|
||||
export const TipPaymentPlansResponseSchema = z.object({
|
||||
export const TipPaymentPlansResponseSchema = z
|
||||
.object({
|
||||
plans: arrayOrEmpty(TipPaymentPlanSchema),
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type TipPaymentPlansResponseInput = z.input<
|
||||
typeof TipPaymentPlansResponseSchema
|
||||
@@ -14,30 +16,4 @@ export type TipPaymentPlansResponseData = z.output<
|
||||
typeof TipPaymentPlansResponseSchema
|
||||
>;
|
||||
|
||||
export class TipPaymentPlansResponse {
|
||||
declare readonly plans: TipPaymentPlan[];
|
||||
|
||||
private constructor(input: TipPaymentPlansResponseInput) {
|
||||
const data = TipPaymentPlansResponseSchema.parse(input);
|
||||
Object.assign(this, {
|
||||
plans: data.plans.map((plan) => TipPaymentPlan.from(plan)),
|
||||
});
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: TipPaymentPlansResponseInput): TipPaymentPlansResponse {
|
||||
return new TipPaymentPlansResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): TipPaymentPlansResponse {
|
||||
return TipPaymentPlansResponse.from(
|
||||
json as TipPaymentPlansResponseInput,
|
||||
);
|
||||
}
|
||||
|
||||
toJson(): TipPaymentPlansResponseData {
|
||||
return {
|
||||
plans: this.plans.map((plan) => plan.toJson()),
|
||||
};
|
||||
}
|
||||
}
|
||||
export type TipPaymentPlansResponse = TipPaymentPlansResponseData;
|
||||
|
||||
@@ -1,36 +1,15 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const TipPaymentPlanSchema = z.object({
|
||||
export const TipPaymentPlanSchema = z
|
||||
.object({
|
||||
planId: z.string(),
|
||||
planName: z.string(),
|
||||
amountCents: z.number(),
|
||||
currency: z.string(),
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type TipPaymentPlanInput = z.input<typeof TipPaymentPlanSchema>;
|
||||
export type TipPaymentPlanData = z.output<typeof TipPaymentPlanSchema>;
|
||||
|
||||
export class TipPaymentPlan {
|
||||
declare readonly planId: string;
|
||||
declare readonly planName: string;
|
||||
declare readonly amountCents: number;
|
||||
declare readonly currency: string;
|
||||
|
||||
private constructor(input: TipPaymentPlanInput) {
|
||||
const data = TipPaymentPlanSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: TipPaymentPlanInput): TipPaymentPlan {
|
||||
return new TipPaymentPlan(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): TipPaymentPlan {
|
||||
return TipPaymentPlan.from(json as TipPaymentPlanInput);
|
||||
}
|
||||
|
||||
toJson(): TipPaymentPlanData {
|
||||
return TipPaymentPlanSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type TipPaymentPlan = TipPaymentPlanData;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
PrivateAlbumsResponse,
|
||||
PrivateAlbumUnlockResponse,
|
||||
UnlockPrivateAlbumRequest,
|
||||
PrivateAlbumUnlockResponseSchema,
|
||||
PrivateAlbumsResponseSchema,
|
||||
UnlockPrivateAlbumRequestSchema,
|
||||
} from "@/data/schemas/private-room";
|
||||
import { ApiPath } from "@/data/services/api";
|
||||
|
||||
@@ -40,7 +40,7 @@ const lockedAlbum = {
|
||||
|
||||
describe("Private Album schema models", () => {
|
||||
it("keeps a locked album cover URL while preserving the lock state", () => {
|
||||
const response = PrivateAlbumsResponse.fromJson({
|
||||
const response = PrivateAlbumsResponseSchema.parse({
|
||||
items: [lockedAlbum],
|
||||
creditBalance: 100,
|
||||
nextCursor: null,
|
||||
@@ -54,21 +54,21 @@ describe("Private Album schema models", () => {
|
||||
});
|
||||
|
||||
it("strips response fields that are not used by the frontend", () => {
|
||||
const response = PrivateAlbumsResponse.fromJson({
|
||||
const response = PrivateAlbumsResponseSchema.parse({
|
||||
items: [lockedAlbum],
|
||||
creditBalance: 100,
|
||||
packageOptions: [{ imageCount: 8, creditCost: 320 }],
|
||||
});
|
||||
const albumJson = response.items[0]?.toJson();
|
||||
const albumJson = response.items[0];
|
||||
|
||||
expect(albumJson).not.toHaveProperty("momentId");
|
||||
expect(albumJson).not.toHaveProperty("characterId");
|
||||
expect(albumJson).not.toHaveProperty("collectionKey");
|
||||
expect(response.toJson()).not.toHaveProperty("packageOptions");
|
||||
expect(response).not.toHaveProperty("packageOptions");
|
||||
});
|
||||
|
||||
it("parses an unlock response with all album images", () => {
|
||||
const response = PrivateAlbumUnlockResponse.fromJson({
|
||||
const response = PrivateAlbumUnlockResponseSchema.parse({
|
||||
albumId: ALBUM_ID,
|
||||
locked: false,
|
||||
unlocked: true,
|
||||
@@ -86,12 +86,12 @@ describe("Private Album schema models", () => {
|
||||
expect(response.unlocked).toBe(true);
|
||||
expect(response.images).toHaveLength(8);
|
||||
expect(response.creditBalance).toBe(180);
|
||||
expect(response.toJson()).not.toHaveProperty("creditsCharged");
|
||||
expect(response).not.toHaveProperty("creditsCharged");
|
||||
});
|
||||
|
||||
it("serializes the expected unlock cost", () => {
|
||||
expect(
|
||||
UnlockPrivateAlbumRequest.from({ expectedCost: 320 }).toJson(),
|
||||
UnlockPrivateAlbumRequestSchema.parse({ expectedCost: 320 }),
|
||||
).toEqual({ expectedCost: 320 });
|
||||
});
|
||||
|
||||
|
||||
@@ -9,17 +9,22 @@ import {
|
||||
stringOrNull,
|
||||
} from "../nullable-defaults";
|
||||
|
||||
export const PrivateAlbumImageSchema = z.object({
|
||||
export const PrivateAlbumImageSchema = z
|
||||
.object({
|
||||
url: stringOrEmpty,
|
||||
locked: booleanOrFalse,
|
||||
index: numberOrZero,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export const PrivateAlbumLockDetailSchema = z.object({
|
||||
export const PrivateAlbumLockDetailSchema = z
|
||||
.object({
|
||||
locked: booleanOrFalse,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export const PrivateAlbumSchema = z.object({
|
||||
export const PrivateAlbumSchema = z
|
||||
.object({
|
||||
albumId: stringOrEmpty,
|
||||
title: stringOrEmpty,
|
||||
content: stringOrNull,
|
||||
@@ -31,40 +36,11 @@ export const PrivateAlbumSchema = z.object({
|
||||
unlockCost: numberOrZero,
|
||||
publishedAt: stringOrNull,
|
||||
lockDetail: schemaOr(PrivateAlbumLockDetailSchema, { locked: false }),
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type PrivateAlbumImageData = z.output<typeof PrivateAlbumImageSchema>;
|
||||
export type PrivateAlbumInput = z.input<typeof PrivateAlbumSchema>;
|
||||
export type PrivateAlbumData = z.output<typeof PrivateAlbumSchema>;
|
||||
|
||||
export class PrivateAlbum {
|
||||
declare readonly albumId: string;
|
||||
declare readonly title: string;
|
||||
declare readonly content: string | null;
|
||||
declare readonly previewText: string;
|
||||
declare readonly imageCount: number;
|
||||
declare readonly images: PrivateAlbumData["images"];
|
||||
declare readonly locked: boolean;
|
||||
declare readonly unlocked: boolean;
|
||||
declare readonly unlockCost: number;
|
||||
declare readonly publishedAt: string | null;
|
||||
declare readonly lockDetail: PrivateAlbumData["lockDetail"];
|
||||
|
||||
private constructor(input: PrivateAlbumInput) {
|
||||
const data = PrivateAlbumSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: PrivateAlbumInput): PrivateAlbum {
|
||||
return new PrivateAlbum(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): PrivateAlbum {
|
||||
return PrivateAlbum.from(json as PrivateAlbumInput);
|
||||
}
|
||||
|
||||
toJson(): PrivateAlbumData {
|
||||
return PrivateAlbumSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type PrivateAlbum = PrivateAlbumData;
|
||||
|
||||
@@ -2,9 +2,11 @@ import { z } from "zod";
|
||||
|
||||
import { numberOrZero } from "../../nullable-defaults";
|
||||
|
||||
export const UnlockPrivateAlbumRequestSchema = z.object({
|
||||
export const UnlockPrivateAlbumRequestSchema = z
|
||||
.object({
|
||||
expectedCost: numberOrZero,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type UnlockPrivateAlbumRequestInput = z.input<
|
||||
typeof UnlockPrivateAlbumRequestSchema
|
||||
@@ -13,18 +15,4 @@ export type UnlockPrivateAlbumRequestData = z.output<
|
||||
typeof UnlockPrivateAlbumRequestSchema
|
||||
>;
|
||||
|
||||
export class UnlockPrivateAlbumRequest {
|
||||
private constructor(input: UnlockPrivateAlbumRequestInput) {
|
||||
const data = UnlockPrivateAlbumRequestSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: UnlockPrivateAlbumRequestInput): UnlockPrivateAlbumRequest {
|
||||
return new UnlockPrivateAlbumRequest(input);
|
||||
}
|
||||
|
||||
toJson(): UnlockPrivateAlbumRequestData {
|
||||
return UnlockPrivateAlbumRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type UnlockPrivateAlbumRequest = UnlockPrivateAlbumRequestData;
|
||||
|
||||
@@ -19,7 +19,8 @@ export const PrivateAlbumUnlockReasonSchema = z.enum([
|
||||
"not_found",
|
||||
]);
|
||||
|
||||
export const PrivateAlbumUnlockResponseSchema = z.object({
|
||||
export const PrivateAlbumUnlockResponseSchema = z
|
||||
.object({
|
||||
albumId: stringOrEmpty,
|
||||
locked: booleanOrFalse,
|
||||
unlocked: booleanOrFalse,
|
||||
@@ -29,7 +30,8 @@ export const PrivateAlbumUnlockResponseSchema = z.object({
|
||||
creditBalance: numberOrZero,
|
||||
shortfallCredits: numberOrZero,
|
||||
images: arrayOrEmpty(PrivateAlbumImageSchema),
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type PrivateAlbumUnlockReason = z.output<
|
||||
typeof PrivateAlbumUnlockReasonSchema
|
||||
@@ -41,34 +43,4 @@ export type PrivateAlbumUnlockResponseData = z.output<
|
||||
typeof PrivateAlbumUnlockResponseSchema
|
||||
>;
|
||||
|
||||
export class PrivateAlbumUnlockResponse {
|
||||
declare readonly albumId: string;
|
||||
declare readonly locked: boolean;
|
||||
declare readonly unlocked: boolean;
|
||||
declare readonly reason: PrivateAlbumUnlockReason | string;
|
||||
declare readonly unlockCost: number;
|
||||
declare readonly requiredCredits: number;
|
||||
declare readonly creditBalance: number;
|
||||
declare readonly shortfallCredits: number;
|
||||
declare readonly images: PrivateAlbumUnlockResponseData["images"];
|
||||
|
||||
private constructor(input: PrivateAlbumUnlockResponseInput) {
|
||||
const data = PrivateAlbumUnlockResponseSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: PrivateAlbumUnlockResponseInput): PrivateAlbumUnlockResponse {
|
||||
return new PrivateAlbumUnlockResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): PrivateAlbumUnlockResponse {
|
||||
return PrivateAlbumUnlockResponse.from(
|
||||
json as PrivateAlbumUnlockResponseInput,
|
||||
);
|
||||
}
|
||||
|
||||
toJson(): PrivateAlbumUnlockResponseData {
|
||||
return PrivateAlbumUnlockResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type PrivateAlbumUnlockResponse = PrivateAlbumUnlockResponseData;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { arrayOrEmpty, numberOrZero } from "../../nullable-defaults";
|
||||
import { PrivateAlbum, PrivateAlbumSchema } from "../private_album";
|
||||
import { PrivateAlbumSchema } from "../private_album";
|
||||
|
||||
export const PrivateAlbumsResponseSchema = z.object({
|
||||
export const PrivateAlbumsResponseSchema = z
|
||||
.object({
|
||||
items: arrayOrEmpty(PrivateAlbumSchema),
|
||||
creditBalance: numberOrZero,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type PrivateAlbumsResponseInput = z.input<
|
||||
typeof PrivateAlbumsResponseSchema
|
||||
@@ -15,31 +17,4 @@ export type PrivateAlbumsResponseData = z.output<
|
||||
typeof PrivateAlbumsResponseSchema
|
||||
>;
|
||||
|
||||
export class PrivateAlbumsResponse {
|
||||
declare readonly items: PrivateAlbum[];
|
||||
declare readonly creditBalance: number;
|
||||
|
||||
private constructor(input: PrivateAlbumsResponseInput) {
|
||||
const data = PrivateAlbumsResponseSchema.parse(input);
|
||||
Object.assign(this, {
|
||||
items: data.items.map((item) => PrivateAlbum.from(item)),
|
||||
creditBalance: data.creditBalance,
|
||||
});
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: PrivateAlbumsResponseInput): PrivateAlbumsResponse {
|
||||
return new PrivateAlbumsResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): PrivateAlbumsResponse {
|
||||
return PrivateAlbumsResponse.from(json as PrivateAlbumsResponseInput);
|
||||
}
|
||||
|
||||
toJson(): PrivateAlbumsResponseData {
|
||||
return {
|
||||
items: this.items.map((item) => item.toJson()),
|
||||
creditBalance: this.creditBalance,
|
||||
};
|
||||
}
|
||||
}
|
||||
export type PrivateAlbumsResponse = PrivateAlbumsResponseData;
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
export type SchemaModel<
|
||||
TSchema extends z.ZodType,
|
||||
TPublicKey extends keyof z.output<TSchema> = never,
|
||||
> = Readonly<Pick<z.output<TSchema>, TPublicKey>> & {
|
||||
toJson(): z.output<TSchema>;
|
||||
};
|
||||
|
||||
export interface SchemaModelFactory<
|
||||
TSchema extends z.ZodType,
|
||||
TPublicKey extends keyof z.output<TSchema> = never,
|
||||
> {
|
||||
readonly prototype: SchemaModel<TSchema, TPublicKey>;
|
||||
[Symbol.hasInstance](value: unknown): boolean;
|
||||
from(input: z.input<TSchema>): SchemaModel<TSchema, TPublicKey>;
|
||||
fromJson(json: unknown): SchemaModel<TSchema, TPublicKey>;
|
||||
}
|
||||
|
||||
/** Creates a shallow-frozen data class backed by a Zod schema. */
|
||||
export function createSchemaModel<
|
||||
TSchema extends z.ZodType,
|
||||
TPublicKey extends keyof z.output<TSchema> = never,
|
||||
>(schema: TSchema): SchemaModelFactory<TSchema, TPublicKey> {
|
||||
class GeneratedSchemaModel {
|
||||
private constructor(input: z.input<TSchema>) {
|
||||
Object.assign(this, schema.parse(input) as object);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(
|
||||
input: z.input<TSchema>,
|
||||
): SchemaModel<TSchema, TPublicKey> {
|
||||
return new GeneratedSchemaModel(input) as SchemaModel<
|
||||
TSchema,
|
||||
TPublicKey
|
||||
>;
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): SchemaModel<TSchema, TPublicKey> {
|
||||
return GeneratedSchemaModel.from(json as z.input<TSchema>);
|
||||
}
|
||||
|
||||
toJson(): z.output<TSchema> {
|
||||
return schema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
return GeneratedSchemaModel as unknown as SchemaModelFactory<
|
||||
TSchema,
|
||||
TPublicKey
|
||||
>;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { UserEntitlements } from "@/data/schemas/user";
|
||||
import { User } from "@/data/schemas/user/user";
|
||||
import { UserEntitlementsSchema } from "@/data/schemas/user";
|
||||
import { UserSchema } from "@/data/schemas/user/user";
|
||||
|
||||
describe("User", () => {
|
||||
it("parses the latest backend user payload", () => {
|
||||
const user = User.fromJson({
|
||||
const user = UserSchema.parse({
|
||||
id: "d7bb2b3d-e9c5-474c-85fe-f503048f30dd",
|
||||
username: "guest_bbc90a38",
|
||||
email: "device_bbc90a38b996187348f54cf2cb0b789e@guest.local",
|
||||
@@ -45,7 +45,7 @@ describe("User", () => {
|
||||
lastMessageAt: "2026-06-24T11:00:23.156561+00:00",
|
||||
});
|
||||
|
||||
expect(user.toJson()).toEqual({
|
||||
expect(user).toEqual({
|
||||
id: "d7bb2b3d-e9c5-474c-85fe-f503048f30dd",
|
||||
username: "guest_bbc90a38",
|
||||
email: "device_bbc90a38b996187348f54cf2cb0b789e@guest.local",
|
||||
@@ -75,17 +75,17 @@ describe("User", () => {
|
||||
lastMessageAt: "2026-06-24T11:00:23.156561+00:00",
|
||||
});
|
||||
|
||||
expect("dailyQuotas" in user.toJson()).toBe(false);
|
||||
expect("dailyQuotas" in user).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults nullable nested user fields through schema helpers", () => {
|
||||
const user = User.fromJson({
|
||||
const user = UserSchema.parse({
|
||||
id: "user-1",
|
||||
username: "guest_user",
|
||||
personalityTraits: null,
|
||||
});
|
||||
|
||||
expect(user.personalityTraits.toJson()).toEqual({
|
||||
expect(user.personalityTraits).toEqual({
|
||||
caring: 0.5,
|
||||
playful: 0.5,
|
||||
serious: 0.5,
|
||||
@@ -95,7 +95,7 @@ describe("User", () => {
|
||||
});
|
||||
|
||||
it("parses the latest backend entitlements payload", () => {
|
||||
const entitlements = UserEntitlements.fromJson({
|
||||
const entitlements = UserEntitlementsSchema.parse({
|
||||
userId: "user-1",
|
||||
isGuest: false,
|
||||
isVip: true,
|
||||
@@ -142,7 +142,7 @@ describe("User", () => {
|
||||
});
|
||||
|
||||
it("defaults nullable entitlement sections through schema helpers", () => {
|
||||
const entitlements = UserEntitlements.fromJson({
|
||||
const entitlements = UserEntitlementsSchema.parse({
|
||||
userId: "user-1",
|
||||
policy: null,
|
||||
costs: null,
|
||||
|
||||
@@ -6,33 +6,14 @@ import { z } from "zod";
|
||||
|
||||
import { stringOrEmpty } from "../nullable-defaults";
|
||||
|
||||
export const AvatarDataSchema = z.object({
|
||||
export const AvatarDataSchema = z
|
||||
.object({
|
||||
avatarUrl: stringOrEmpty,
|
||||
userId: stringOrEmpty,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type AvatarDataInput = z.input<typeof AvatarDataSchema>;
|
||||
export type AvatarDataData = z.output<typeof AvatarDataSchema>;
|
||||
|
||||
export class AvatarData {
|
||||
declare readonly avatarUrl: string;
|
||||
declare readonly userId: string;
|
||||
|
||||
private constructor(input: AvatarDataInput) {
|
||||
const data = AvatarDataSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: AvatarDataInput): AvatarData {
|
||||
return new AvatarData(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): AvatarData {
|
||||
return AvatarData.from(json as AvatarDataInput);
|
||||
}
|
||||
|
||||
toJson(): AvatarDataData {
|
||||
return AvatarDataSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type AvatarData = AvatarDataData;
|
||||
|
||||
@@ -8,7 +8,8 @@ import { z } from "zod";
|
||||
|
||||
import { numberOrZero, stringOrEmpty } from "../nullable-defaults";
|
||||
|
||||
export const PersistedUserSchema = z.object({
|
||||
export const PersistedUserSchema = z
|
||||
.object({
|
||||
id: stringOrEmpty,
|
||||
username: stringOrEmpty,
|
||||
countryCode: stringOrEmpty,
|
||||
@@ -16,7 +17,8 @@ export const PersistedUserSchema = z.object({
|
||||
dailyFreeChatRemaining: numberOrZero,
|
||||
dailyFreePrivateLimit: numberOrZero,
|
||||
dailyFreePrivateRemaining: numberOrZero,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type PersistedUserInput = z.input<typeof PersistedUserSchema>;
|
||||
export type PersistedUserData = z.output<typeof PersistedUserSchema>;
|
||||
|
||||
@@ -14,39 +14,17 @@ export const PERSONALITY_TRAITS_DEFAULTS = {
|
||||
romantic: 0.5,
|
||||
} as const;
|
||||
|
||||
export const PersonalityTraitsSchema = z.object({
|
||||
export const PersonalityTraitsSchema = z
|
||||
.object({
|
||||
cheerful: numberOr(0.5),
|
||||
caring: numberOr(0.5),
|
||||
playful: numberOr(0.5),
|
||||
serious: numberOr(0.5),
|
||||
romantic: numberOr(0.5),
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type PersonalityTraitsInput = z.input<typeof PersonalityTraitsSchema>;
|
||||
export type PersonalityTraitsData = z.output<typeof PersonalityTraitsSchema>;
|
||||
|
||||
export class PersonalityTraits {
|
||||
declare readonly cheerful: number;
|
||||
declare readonly caring: number;
|
||||
declare readonly playful: number;
|
||||
declare readonly serious: number;
|
||||
declare readonly romantic: number;
|
||||
|
||||
private constructor(input: PersonalityTraitsInput) {
|
||||
const data = PersonalityTraitsSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: PersonalityTraitsInput): PersonalityTraits {
|
||||
return new PersonalityTraits(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): PersonalityTraits {
|
||||
return PersonalityTraits.from(json as PersonalityTraitsInput);
|
||||
}
|
||||
|
||||
toJson(): PersonalityTraitsData {
|
||||
return PersonalityTraitsSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type PersonalityTraits = PersonalityTraitsData;
|
||||
|
||||
@@ -6,37 +6,16 @@ import { z } from "zod";
|
||||
|
||||
import { numberOrZero, stringOrEmpty } from "../nullable-defaults";
|
||||
|
||||
export const RecentMemorySchema = z.object({
|
||||
export const RecentMemorySchema = z
|
||||
.object({
|
||||
type: stringOrEmpty,
|
||||
content: stringOrEmpty,
|
||||
importance: numberOrZero,
|
||||
createdAt: stringOrEmpty,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type RecentMemoryInput = z.input<typeof RecentMemorySchema>;
|
||||
export type RecentMemoryData = z.output<typeof RecentMemorySchema>;
|
||||
|
||||
export class RecentMemory {
|
||||
declare readonly type: string;
|
||||
declare readonly content: string;
|
||||
declare readonly importance: number;
|
||||
declare readonly createdAt: string;
|
||||
|
||||
private constructor(input: RecentMemoryInput) {
|
||||
const data = RecentMemorySchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: RecentMemoryInput): RecentMemory {
|
||||
return new RecentMemory(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): RecentMemory {
|
||||
return RecentMemory.from(json as RecentMemoryInput);
|
||||
}
|
||||
|
||||
toJson(): RecentMemoryData {
|
||||
return RecentMemorySchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type RecentMemory = RecentMemoryData;
|
||||
|
||||
@@ -3,18 +3,14 @@
|
||||
*
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { numberOrZero, schemaOr, stringOrEmpty } from "../nullable-defaults";
|
||||
import {
|
||||
PersonalityTraits,
|
||||
PersonalityTraitsSchema,
|
||||
PERSONALITY_TRAITS_DEFAULTS,
|
||||
PersonalityTraitsSchema,
|
||||
} from "./personality_traits";
|
||||
import {
|
||||
numberOrZero,
|
||||
schemaOr,
|
||||
stringOrEmpty,
|
||||
} from "../nullable-defaults";
|
||||
|
||||
export const UserSchema = z.object({
|
||||
export const UserSchema = z
|
||||
.object({
|
||||
id: stringOrEmpty, // 兜底:理论 id 不会 null,但保持防御
|
||||
username: stringOrEmpty,
|
||||
email: stringOrEmpty,
|
||||
@@ -32,67 +28,23 @@ export const UserSchema = z.object({
|
||||
dailyFreePrivateLimit: numberOrZero,
|
||||
dailyFreePrivateUsed: numberOrZero,
|
||||
dailyFreePrivateRemaining: numberOrZero,
|
||||
personalityTraits: schemaOr(PersonalityTraitsSchema, PERSONALITY_TRAITS_DEFAULTS),
|
||||
personalityTraits: schemaOr(
|
||||
PersonalityTraitsSchema,
|
||||
PERSONALITY_TRAITS_DEFAULTS,
|
||||
),
|
||||
preferredLanguage: stringOrEmpty,
|
||||
createdAt: stringOrEmpty,
|
||||
// 后端新游客返回 null("还没发过消息"),前端统一收敛为空字符串。
|
||||
lastMessageAt: stringOrEmpty,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type UserInput = z.input<typeof UserSchema>;
|
||||
export type UserData = z.output<typeof UserSchema>;
|
||||
|
||||
export class User {
|
||||
declare readonly id: string;
|
||||
declare readonly username: string;
|
||||
declare readonly email: string;
|
||||
declare readonly platform: string;
|
||||
declare readonly country: string;
|
||||
declare readonly countryCode: string;
|
||||
declare readonly fbAsid: string;
|
||||
declare readonly fbPsid: string;
|
||||
declare readonly intimacy: number;
|
||||
declare readonly dolBalance: number;
|
||||
declare readonly creditBalance: number;
|
||||
declare readonly dailyFreeChatLimit: number;
|
||||
declare readonly dailyFreeChatUsed: number;
|
||||
declare readonly dailyFreeChatRemaining: number;
|
||||
declare readonly dailyFreePrivateLimit: number;
|
||||
declare readonly dailyFreePrivateUsed: number;
|
||||
declare readonly dailyFreePrivateRemaining: number;
|
||||
declare readonly personalityTraits: PersonalityTraits;
|
||||
declare readonly preferredLanguage: string;
|
||||
declare readonly createdAt: string;
|
||||
declare readonly lastMessageAt: string;
|
||||
export type User = UserData;
|
||||
|
||||
private constructor(input: UserInput) {
|
||||
const data = UserSchema.parse(input);
|
||||
Object.assign(this, {
|
||||
...data,
|
||||
personalityTraits: PersonalityTraits.from(data.personalityTraits),
|
||||
});
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: UserInput): User {
|
||||
return new User(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): User {
|
||||
return User.from(json as UserInput);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个具有空 id/username 的 User 实例(用于可选 user 字段的默认占位)
|
||||
*/
|
||||
static empty(): User {
|
||||
return new User({ id: "", username: "" });
|
||||
}
|
||||
|
||||
toJson(): UserData {
|
||||
return UserSchema.parse({
|
||||
...this,
|
||||
personalityTraits: this.personalityTraits.toJson(),
|
||||
});
|
||||
}
|
||||
}
|
||||
export const EMPTY_USER: User = UserSchema.parse({
|
||||
id: "",
|
||||
username: "",
|
||||
});
|
||||
|
||||
@@ -8,10 +8,12 @@ import { z } from "zod";
|
||||
|
||||
import { booleanOrFalse, numberOrZero } from "../nullable-defaults";
|
||||
|
||||
export const UserEntitlementSnapshotSchema = z.object({
|
||||
export const UserEntitlementSnapshotSchema = z
|
||||
.object({
|
||||
isVip: booleanOrFalse,
|
||||
creditBalance: numberOrZero,
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type UserEntitlementSnapshotInput = z.input<
|
||||
typeof UserEntitlementSnapshotSchema
|
||||
|
||||
@@ -56,7 +56,8 @@ export const UserEntitlementsPolicySchema = z
|
||||
guestSameAsLoggedInNonVip: booleanOrFalse,
|
||||
refreshAfterPayment: stringOrEmpty,
|
||||
})
|
||||
.passthrough();
|
||||
.passthrough()
|
||||
.readonly();
|
||||
|
||||
export const UserEntitlementsCostsSchema = z
|
||||
.object({
|
||||
@@ -68,7 +69,8 @@ export const UserEntitlementsCostsSchema = z
|
||||
private_album_10: numberOrZero,
|
||||
private_album_20: numberOrZero,
|
||||
})
|
||||
.passthrough();
|
||||
.passthrough()
|
||||
.readonly();
|
||||
|
||||
export const UserEntitlementsQuotasSchema = z
|
||||
.object({
|
||||
@@ -77,7 +79,8 @@ export const UserEntitlementsQuotasSchema = z
|
||||
voiceMessageFreeDaily: numberOrZero,
|
||||
photoFreeDaily: numberOrZero,
|
||||
})
|
||||
.passthrough();
|
||||
.passthrough()
|
||||
.readonly();
|
||||
|
||||
export const UserEntitlementsHistoryUnlockCostsSchema = z
|
||||
.object({
|
||||
@@ -85,7 +88,8 @@ export const UserEntitlementsHistoryUnlockCostsSchema = z
|
||||
voice_message: numberOrZero,
|
||||
photo: numberOrZero,
|
||||
})
|
||||
.passthrough();
|
||||
.passthrough()
|
||||
.readonly();
|
||||
|
||||
export const UserEntitlementsHistoryUnlockSchema = z
|
||||
.object({
|
||||
@@ -98,9 +102,11 @@ export const UserEntitlementsHistoryUnlockSchema = z
|
||||
HISTORY_UNLOCK_COSTS_DEFAULTS,
|
||||
),
|
||||
})
|
||||
.passthrough();
|
||||
.passthrough()
|
||||
.readonly();
|
||||
|
||||
export const UserEntitlementsSchema = z.object({
|
||||
export const UserEntitlementsSchema = z
|
||||
.object({
|
||||
userId: stringOrEmpty,
|
||||
isGuest: booleanOrFalse,
|
||||
isVip: booleanOrFalse,
|
||||
@@ -114,38 +120,10 @@ export const UserEntitlementsSchema = z.object({
|
||||
UserEntitlementsHistoryUnlockSchema,
|
||||
HISTORY_UNLOCK_DEFAULTS,
|
||||
),
|
||||
});
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type UserEntitlementsInput = z.input<typeof UserEntitlementsSchema>;
|
||||
export type UserEntitlementsData = z.output<typeof UserEntitlementsSchema>;
|
||||
|
||||
export class UserEntitlements {
|
||||
declare readonly userId: string;
|
||||
declare readonly isGuest: boolean;
|
||||
declare readonly isVip: boolean;
|
||||
declare readonly vipExpiresAt: string | null;
|
||||
declare readonly creditBalance: number;
|
||||
declare readonly dolBalance: number;
|
||||
declare readonly policy: UserEntitlementsData["policy"];
|
||||
declare readonly costs: UserEntitlementsData["costs"];
|
||||
declare readonly quotas: UserEntitlementsData["quotas"];
|
||||
declare readonly historyUnlock: UserEntitlementsData["historyUnlock"];
|
||||
|
||||
private constructor(input: UserEntitlementsInput) {
|
||||
const data = UserEntitlementsSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: UserEntitlementsInput): UserEntitlements {
|
||||
return new UserEntitlements(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): UserEntitlements {
|
||||
return UserEntitlements.from(json as UserEntitlementsInput);
|
||||
}
|
||||
|
||||
toJson(): UserEntitlementsData {
|
||||
return UserEntitlementsSchema.parse(this);
|
||||
}
|
||||
}
|
||||
export type UserEntitlements = UserEntitlementsData;
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("FeedbackApi", () => {
|
||||
images: [image],
|
||||
});
|
||||
|
||||
expect(response.toJson()).toEqual({ feedbackId: "feedback-123" });
|
||||
expect(response).toEqual({ feedbackId: "feedback-123" });
|
||||
expect(httpClientMock).toHaveBeenCalledWith(
|
||||
"/api/feedback",
|
||||
expect.objectContaining({ method: "POST", body: expect.any(FormData) }),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
SendMessageRequest,
|
||||
UnlockHistoryRequest,
|
||||
UnlockPrivateRequest,
|
||||
SendMessageRequestSchema,
|
||||
UnlockHistoryRequestSchema,
|
||||
UnlockPrivateRequestSchema,
|
||||
} from "@/data/schemas/chat";
|
||||
|
||||
const httpClientMock = vi.hoisted(() => vi.fn());
|
||||
@@ -34,7 +34,7 @@ describe("multi-character API contract", () => {
|
||||
});
|
||||
|
||||
await new ChatApi().sendMessage(
|
||||
SendMessageRequest.from({
|
||||
SendMessageRequestSchema.parse({
|
||||
characterId: CHARACTER_ID,
|
||||
message: "Hello",
|
||||
}),
|
||||
@@ -76,13 +76,13 @@ describe("multi-character API contract", () => {
|
||||
});
|
||||
|
||||
await api.unlockPrivateMessage(
|
||||
UnlockPrivateRequest.from({
|
||||
UnlockPrivateRequestSchema.parse({
|
||||
characterId: CHARACTER_ID,
|
||||
messageId: "message-1",
|
||||
}),
|
||||
);
|
||||
await api.unlockHistory(
|
||||
UnlockHistoryRequest.from({ characterId: CHARACTER_ID }),
|
||||
UnlockHistoryRequestSchema.parse({ characterId: CHARACTER_ID }),
|
||||
);
|
||||
|
||||
expect(httpClientMock).toHaveBeenNthCalledWith(
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("PaymentApi", () => {
|
||||
const response = await new PaymentApi().getTipPlans();
|
||||
|
||||
expect(httpClientMock).toHaveBeenCalledWith("/api/payment/tip-plans");
|
||||
expect(response.toJson()).toEqual({
|
||||
expect(response).toEqual({
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_19_99",
|
||||
|
||||
@@ -6,28 +6,33 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import { httpClient } from "./http_client";
|
||||
import { ApiPath } from "./api_path";
|
||||
import { ApiEnvelope, unwrap } from "./response_helper";
|
||||
import {
|
||||
FacebookIdentityRequest,
|
||||
FacebookIdentityResponse,
|
||||
FacebookIdentityResponseSchema,
|
||||
FacebookLoginRequest,
|
||||
FacebookPsidLoginRequest,
|
||||
FacebookPsidLoginResponse,
|
||||
FacebookPsidLoginResponseSchema,
|
||||
FbIdLoginRequest,
|
||||
GoogleLoginRequest,
|
||||
GuestLoginRequest,
|
||||
GuestLoginResponse,
|
||||
GuestLoginResponseSchema,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
LoginResponseSchema,
|
||||
RefreshTokenRequest,
|
||||
RefreshTokenResponse,
|
||||
RefreshTokenResponseSchema,
|
||||
RegisterRequest,
|
||||
} from "@/data/schemas/auth";
|
||||
import { User } from "@/data/schemas/user";
|
||||
import { User, UserSchema } from "@/data/schemas/user";
|
||||
import type { UserData } from "@/data/schemas/user/user";
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { ApiPath } from "./api_path";
|
||||
import { httpClient } from "./http_client";
|
||||
import { ApiEnvelope, unwrap } from "./response_helper";
|
||||
|
||||
const log = new Logger("DataServicesApiAuthApi");
|
||||
|
||||
@@ -36,11 +41,11 @@ export class AuthApi {
|
||||
* 邮箱密码登录
|
||||
*/
|
||||
async emailLogin(body: LoginRequest): Promise<LoginResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.emailLogin,
|
||||
{ method: "POST", body: body.toJson() }
|
||||
);
|
||||
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>);
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.emailLogin, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
return LoginResponseSchema.parse(unwrap(env) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,7 +54,7 @@ export class AuthApi {
|
||||
async register(body: RegisterRequest): Promise<void> {
|
||||
await httpClient<ApiEnvelope<unknown>>(ApiPath.register, {
|
||||
method: "POST",
|
||||
body: body.toJson(),
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,20 +64,20 @@ export class AuthApi {
|
||||
async googleLogin(body: GoogleLoginRequest): Promise<LoginResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.googleLogin, {
|
||||
method: "POST",
|
||||
body: body.toJson(),
|
||||
body,
|
||||
});
|
||||
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>);
|
||||
return LoginResponseSchema.parse(unwrap(env) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Facebook 登录
|
||||
*/
|
||||
async facebookLogin(body: FacebookLoginRequest): Promise<LoginResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.facebookLogin,
|
||||
{ method: "POST", body: body.toJson() }
|
||||
);
|
||||
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>);
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.facebookLogin, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
return LoginResponseSchema.parse(unwrap(env) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,9 +86,9 @@ export class AuthApi {
|
||||
async facebookIdLogin(body: FbIdLoginRequest): Promise<LoginResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.facebookIdLogin,
|
||||
{ method: "POST", body: body.toJson() }
|
||||
{ method: "POST", body },
|
||||
);
|
||||
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>);
|
||||
return LoginResponseSchema.parse(unwrap(env) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,9 +99,9 @@ export class AuthApi {
|
||||
): Promise<FacebookPsidLoginResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.facebookPsidLogin,
|
||||
{ method: "POST", body: body.toJson() },
|
||||
{ method: "POST", body },
|
||||
);
|
||||
return FacebookPsidLoginResponse.fromJson(
|
||||
return FacebookPsidLoginResponseSchema.parse(
|
||||
unwrap(env) as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
@@ -109,9 +114,9 @@ export class AuthApi {
|
||||
): Promise<FacebookIdentityResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.userFacebookIdentity,
|
||||
{ method: "POST", body: body.toJson() },
|
||||
{ method: "POST", body },
|
||||
);
|
||||
return FacebookIdentityResponse.fromJson(
|
||||
return FacebookIdentityResponseSchema.parse(
|
||||
unwrap(env) as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
@@ -120,7 +125,7 @@ export class AuthApi {
|
||||
* 退出登录(fire-and-forget)
|
||||
*
|
||||
* 后端 logout 端点的 envelope `data` 字段常为 `null`(登出端点没有业务数据),
|
||||
* 不要再用 `unwrap` + `LogoutResponse.fromJson` 解析 —— Zod schema 是 z.object,
|
||||
* 不要再用 `unwrap` + `LogoutResponseSchema.parse` 解析 —— Zod schema 是 z.object,
|
||||
* 对 null 会抛 ZodError "Invalid input: expected object, received null"。
|
||||
*
|
||||
* 对齐 `register` 的写法:调通就完事,不解析响应体。
|
||||
@@ -137,19 +142,20 @@ export class AuthApi {
|
||||
async guestLogin(body: GuestLoginRequest): Promise<GuestLoginResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.guestLogin, {
|
||||
method: "POST",
|
||||
body: body.toJson(),
|
||||
body,
|
||||
});
|
||||
log.debug("[AuthApi.guestLogin] raw envelope from backend", env);
|
||||
const unwrapped = unwrap(env) as Record<string, unknown>;
|
||||
const userObj = unwrapped.user as { lastMessageAt?: unknown } | undefined;
|
||||
log.debug("[AuthApi.guestLogin] unwrapped data (pre-parse)", {
|
||||
hasUser: "user" in unwrapped,
|
||||
userKeys: userObj && typeof userObj === "object" ? Object.keys(userObj) : null,
|
||||
userKeys:
|
||||
userObj && typeof userObj === "object" ? Object.keys(userObj) : null,
|
||||
lastMessageAt: userObj?.lastMessageAt,
|
||||
lastMessageAtType: typeof userObj?.lastMessageAt,
|
||||
});
|
||||
try {
|
||||
return GuestLoginResponse.fromJson(unwrapped);
|
||||
return GuestLoginResponseSchema.parse(unwrapped);
|
||||
} catch (e) {
|
||||
if (e instanceof z.ZodError) {
|
||||
log.error("[AuthApi.guestLogin] ZodError parsing response", {
|
||||
@@ -167,15 +173,13 @@ export class AuthApi {
|
||||
/**
|
||||
* 刷新 Token
|
||||
*/
|
||||
async refreshToken(
|
||||
body: RefreshTokenRequest
|
||||
): Promise<RefreshTokenResponse> {
|
||||
async refreshToken(body: RefreshTokenRequest): Promise<RefreshTokenResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.refresh, {
|
||||
method: "POST",
|
||||
body: body.toJson(),
|
||||
body,
|
||||
});
|
||||
return RefreshTokenResponse.fromJson(
|
||||
unwrap(env) as Record<string, unknown>
|
||||
return RefreshTokenResponseSchema.parse(
|
||||
unwrap(env) as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,10 +187,8 @@ export class AuthApi {
|
||||
* 获取当前用户信息
|
||||
*/
|
||||
async getCurrentUser(): Promise<User> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.getCurrentUser
|
||||
);
|
||||
return User.fromJson(unwrap(env) as UserData);
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.getCurrentUser);
|
||||
return UserSchema.parse(unwrap(env) as UserData);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { CharactersResponse } from "@/data/schemas/character";
|
||||
import {
|
||||
CharactersResponse,
|
||||
CharactersResponseSchema,
|
||||
} from "@/data/schemas/character";
|
||||
|
||||
import { ApiPath } from "./api_path";
|
||||
import { httpClient } from "./http_client";
|
||||
@@ -7,7 +10,7 @@ import { type ApiEnvelope, unwrap } from "./response_helper";
|
||||
export class CharacterApi {
|
||||
async getCharacters(): Promise<CharactersResponse> {
|
||||
const envelope = await httpClient<ApiEnvelope<unknown>>(ApiPath.characters);
|
||||
return CharactersResponse.fromJson(unwrap(envelope));
|
||||
return CharactersResponseSchema.parse(unwrap(envelope));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,18 +4,22 @@
|
||||
* 聊天相关接口
|
||||
*
|
||||
*/
|
||||
import { httpClient } from "./http_client";
|
||||
import { ApiPath } from "./api_path";
|
||||
import { ApiEnvelope, unwrap } from "./response_helper";
|
||||
import {
|
||||
ChatHistoryResponse,
|
||||
ChatHistoryResponseSchema,
|
||||
ChatSendResponse,
|
||||
ChatSendResponseSchema,
|
||||
SendMessageRequest,
|
||||
UnlockHistoryRequest,
|
||||
UnlockHistoryResponse,
|
||||
UnlockHistoryResponseSchema,
|
||||
UnlockPrivateRequest,
|
||||
UnlockPrivateResponse,
|
||||
UnlockPrivateResponseSchema,
|
||||
} from "@/data/schemas/chat";
|
||||
import { ApiPath } from "./api_path";
|
||||
import { httpClient } from "./http_client";
|
||||
import { ApiEnvelope, unwrap } from "./response_helper";
|
||||
|
||||
export class ChatApi {
|
||||
/**
|
||||
@@ -24,9 +28,9 @@ export class ChatApi {
|
||||
async sendMessage(body: SendMessageRequest): Promise<ChatSendResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatSend, {
|
||||
method: "POST",
|
||||
body: body.toJson(),
|
||||
body,
|
||||
});
|
||||
return ChatSendResponse.fromJson(unwrap(env) as Record<string, unknown>);
|
||||
return ChatSendResponseSchema.parse(unwrap(env) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,8 +44,8 @@ export class ChatApi {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatHistory, {
|
||||
query: { characterId, limit, offset },
|
||||
});
|
||||
return ChatHistoryResponse.fromJson(
|
||||
unwrap(env) as Record<string, unknown>
|
||||
return ChatHistoryResponseSchema.parse(
|
||||
unwrap(env) as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,10 +59,10 @@ export class ChatApi {
|
||||
ApiPath.chatUnlockPrivate,
|
||||
{
|
||||
method: "POST",
|
||||
body: body.toJson(),
|
||||
body,
|
||||
},
|
||||
);
|
||||
return UnlockPrivateResponse.fromJson(
|
||||
return UnlockPrivateResponseSchema.parse(
|
||||
unwrap(env) as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
@@ -73,10 +77,10 @@ export class ChatApi {
|
||||
ApiPath.chatUnlockHistory,
|
||||
{
|
||||
method: "POST",
|
||||
body: body.toJson(),
|
||||
body,
|
||||
},
|
||||
);
|
||||
return UnlockHistoryResponse.fromJson(
|
||||
return UnlockHistoryResponseSchema.parse(
|
||||
unwrap(env) as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
FeedbackSubmitResponse,
|
||||
FeedbackSubmitResponseSchema,
|
||||
type SubmitFeedbackInput,
|
||||
} from "@/data/schemas/feedback";
|
||||
|
||||
@@ -19,7 +20,7 @@ export class FeedbackApi {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
return FeedbackSubmitResponse.fromJson(unwrap(envelope));
|
||||
return FeedbackSubmitResponseSchema.parse(unwrap(envelope));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
* 数据看板/上报相关接口
|
||||
*
|
||||
*/
|
||||
import { httpClient } from "./http_client";
|
||||
import { ApiPath } from "./api_path";
|
||||
import { ApiEnvelope, unwrapOptional } from "./response_helper";
|
||||
import { AppEvent, PwaEvent } from "@/data/schemas/metrics";
|
||||
import { ApiPath } from "./api_path";
|
||||
import { httpClient } from "./http_client";
|
||||
import { ApiEnvelope, unwrapOptional } from "./response_helper";
|
||||
|
||||
export class MetricsApi {
|
||||
/**
|
||||
@@ -16,7 +16,7 @@ export class MetricsApi {
|
||||
async reportPwaEvent(body: PwaEvent): Promise<void> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.metricsPwaEvent,
|
||||
{ method: "POST", body: body.toJson() }
|
||||
{ method: "POST", body },
|
||||
);
|
||||
unwrapOptional(env);
|
||||
}
|
||||
@@ -25,10 +25,10 @@ export class MetricsApi {
|
||||
* 上报用户信息
|
||||
*/
|
||||
async reportUserInfo(body: AppEvent): Promise<void> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.reportUserInfo,
|
||||
{ method: "POST", body: body.toJson() }
|
||||
);
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.reportUserInfo, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
unwrapOptional(env);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,13 @@
|
||||
import {
|
||||
CreatePaymentOrderRequest,
|
||||
CreatePaymentOrderResponse,
|
||||
CreatePaymentOrderResponseSchema,
|
||||
PaymentOrderStatusResponse,
|
||||
PaymentOrderStatusResponseSchema,
|
||||
PaymentPlansResponse,
|
||||
PaymentPlansResponseSchema,
|
||||
TipPaymentPlansResponse,
|
||||
TipPaymentPlansResponseSchema,
|
||||
} from "@/data/schemas/payment";
|
||||
|
||||
import { ApiPath } from "./api_path";
|
||||
@@ -21,17 +25,15 @@ export class PaymentApi {
|
||||
*/
|
||||
async getPlans(): Promise<PaymentPlansResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentPlans);
|
||||
return PaymentPlansResponse.fromJson(
|
||||
return PaymentPlansResponseSchema.parse(
|
||||
unwrap(env) as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取咖啡打赏套餐列表。 */
|
||||
async getTipPlans(): Promise<TipPaymentPlansResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.paymentTipPlans,
|
||||
);
|
||||
return TipPaymentPlansResponse.fromJson(
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentTipPlans);
|
||||
return TipPaymentPlansResponseSchema.parse(
|
||||
unwrap(env) as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
@@ -46,10 +48,10 @@ export class PaymentApi {
|
||||
ApiPath.paymentCreateOrder,
|
||||
{
|
||||
method: "POST",
|
||||
body: body.toJson(),
|
||||
body,
|
||||
},
|
||||
);
|
||||
return CreatePaymentOrderResponse.fromJson(
|
||||
return CreatePaymentOrderResponseSchema.parse(
|
||||
unwrap(env) as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
@@ -57,20 +59,17 @@ export class PaymentApi {
|
||||
/**
|
||||
* 查询支付订单状态
|
||||
*/
|
||||
async getOrderStatus(
|
||||
orderId: string,
|
||||
): Promise<PaymentOrderStatusResponse> {
|
||||
async getOrderStatus(orderId: string): Promise<PaymentOrderStatusResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.paymentOrderStatus,
|
||||
{
|
||||
query: { order_id: orderId },
|
||||
},
|
||||
);
|
||||
return PaymentOrderStatusResponse.fromJson(
|
||||
return PaymentOrderStatusResponseSchema.parse(
|
||||
unwrap(env) as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
import {
|
||||
PrivateAlbumsResponse,
|
||||
PrivateAlbumsResponseSchema,
|
||||
PrivateAlbumUnlockResponse,
|
||||
PrivateAlbumUnlockResponseSchema,
|
||||
UnlockPrivateAlbumRequest,
|
||||
} from "@/data/schemas/private-room";
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
|
||||
import { ApiPath } from "./api_path";
|
||||
import { httpClient } from "./http_client";
|
||||
@@ -27,7 +29,7 @@ export class PrivateRoomApi {
|
||||
},
|
||||
},
|
||||
);
|
||||
return PrivateAlbumsResponse.fromJson(unwrap(env));
|
||||
return PrivateAlbumsResponseSchema.parse(unwrap(env));
|
||||
}
|
||||
|
||||
async unlockAlbum(
|
||||
@@ -38,10 +40,10 @@ export class PrivateRoomApi {
|
||||
ApiPath.privateRoomAlbumUnlock(albumId),
|
||||
{
|
||||
method: "POST",
|
||||
body: body.toJson(),
|
||||
body,
|
||||
},
|
||||
);
|
||||
return PrivateAlbumUnlockResponse.fromJson(unwrap(env));
|
||||
return PrivateAlbumUnlockResponseSchema.parse(unwrap(env));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,16 @@
|
||||
* 用户相关接口
|
||||
*
|
||||
*/
|
||||
import { httpClient } from "./http_client";
|
||||
import { ApiPath } from "./api_path";
|
||||
import { ApiEnvelope, unwrap } from "./response_helper";
|
||||
import {
|
||||
User,
|
||||
UserEntitlements,
|
||||
UserEntitlementsSchema,
|
||||
UserSchema,
|
||||
} from "@/data/schemas/user";
|
||||
import type { UserData } from "@/data/schemas/user/user";
|
||||
import { ApiPath } from "./api_path";
|
||||
import { httpClient } from "./http_client";
|
||||
import { ApiEnvelope, unwrap } from "./response_helper";
|
||||
|
||||
export class UserApi {
|
||||
/**
|
||||
@@ -19,15 +21,17 @@ export class UserApi {
|
||||
*/
|
||||
async getCurrentUser(): Promise<User> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.userProfile);
|
||||
return User.fromJson(unwrap(env) as UserData);
|
||||
return UserSchema.parse(unwrap(env) as UserData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户权益快照
|
||||
*/
|
||||
async getEntitlements(): Promise<UserEntitlements> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.userEntitlements);
|
||||
return UserEntitlements.fromJson(unwrap(env) as Record<string, unknown>);
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.userEntitlements,
|
||||
);
|
||||
return UserEntitlementsSchema.parse(unwrap(env) as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* - 不指定 version 走最新稳定版
|
||||
* - 不需要 appId / appSecret(accessToken 自身就是凭证)
|
||||
*/
|
||||
import { FacebookUserData } from "@/data/schemas/auth";
|
||||
import { FacebookUserData, FacebookUserDataSchema } from "@/data/schemas/auth";
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { Result, toError } from "@/utils/result";
|
||||
|
||||
@@ -43,26 +43,33 @@ export async function fetchFacebookUserData(
|
||||
|
||||
const url = `${GRAPH_ME_URL}?fields=${encodeURIComponent(FIELDS)}&access_token=${encodeURIComponent(accessToken)}`;
|
||||
|
||||
log.debug({ url: url.replace(accessToken, "<redacted>") }, "fetchFacebookUserData REQUEST");
|
||||
log.debug(
|
||||
{ url: url.replace(accessToken, "<redacted>") },
|
||||
"fetchFacebookUserData REQUEST",
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { method: "GET" });
|
||||
const json = (await res.json()) as Record<string, unknown>;
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = (json?.error as Record<string, unknown> | undefined) ?? {};
|
||||
const errBody =
|
||||
(json?.error as Record<string, unknown> | undefined) ?? {};
|
||||
const message =
|
||||
typeof errBody.message === "string"
|
||||
? errBody.message
|
||||
: `Graph /me failed: HTTP ${res.status}`;
|
||||
log.warn({ status: res.status, body: errBody }, "fetchFacebookUserData FAILED");
|
||||
log.warn(
|
||||
{ status: res.status, body: errBody },
|
||||
"fetchFacebookUserData FAILED",
|
||||
);
|
||||
return Result.err(new Error(message));
|
||||
}
|
||||
|
||||
// 提取 picture.data.url —— Graph API 返回嵌套结构
|
||||
const pictureUrl = extractPictureUrl(json.picture);
|
||||
|
||||
const data = FacebookUserData.from({
|
||||
const data = FacebookUserDataSchema.parse({
|
||||
id: typeof json.id === "string" ? json.id : null,
|
||||
name: typeof json.name === "string" ? json.name : null,
|
||||
email: typeof json.email === "string" ? json.email : null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PaymentPlan } from "@/data/schemas/payment";
|
||||
import { PaymentPlanSchema } from "@/data/schemas/payment";
|
||||
|
||||
import {
|
||||
behaviorAnalytics,
|
||||
@@ -31,7 +31,7 @@ function createSdkMock(): CozsweetBehaviorAnalyticsSdk {
|
||||
};
|
||||
}
|
||||
|
||||
const plan = PaymentPlan.from({
|
||||
const plan = PaymentPlanSchema.parse({
|
||||
planId: "credits-100",
|
||||
planName: "100 Credits",
|
||||
orderType: "dol",
|
||||
@@ -126,9 +126,7 @@ describe("behavior analytics adapter", () => {
|
||||
expect(sanitizeAnalyticsCheckoutUrl("stripe_embedded")).toBe(
|
||||
"stripe_embedded",
|
||||
);
|
||||
expect(sanitizeAnalyticsCheckoutUrl("not a url")).toBe(
|
||||
"external_checkout",
|
||||
);
|
||||
expect(sanitizeAnalyticsCheckoutUrl("not a url")).toBe("external_checkout");
|
||||
expect(sanitizeAnalyticsCheckoutUrl("javascript:alert('no')")).toBe(
|
||||
"external_checkout",
|
||||
);
|
||||
|
||||
@@ -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,10 +109,8 @@ export function createTestChatMachine(
|
||||
return () => undefined;
|
||||
},
|
||||
),
|
||||
unlockHistory: fromPromise<
|
||||
UnlockHistoryOutput,
|
||||
{ characterId: string }
|
||||
>(async () => {
|
||||
unlockHistory: fromPromise<UnlockHistoryOutput, { characterId: string }>(
|
||||
async () => {
|
||||
if (options.unlockHistoryError) {
|
||||
throw options.unlockHistoryError;
|
||||
}
|
||||
@@ -120,7 +121,8 @@ export function createTestChatMachine(
|
||||
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 }) => {
|
||||
refreshPlans: fromPromise<PaymentPlansResponse, PaymentPlansActorInput>(
|
||||
async ({ input }) => {
|
||||
overrides.onRefreshCatalog?.(input.catalog);
|
||||
if (overrides.refreshPlans) return overrides.refreshPlans();
|
||||
return (
|
||||
overrides.refreshedPlans ??
|
||||
PaymentPlansResponse.from({
|
||||
PaymentPlansResponseSchema.parse({
|
||||
plans: [monthlyPlan, lifetimePlan, creditPlan],
|
||||
})
|
||||
);
|
||||
}),
|
||||
createOrder: fromPromise<
|
||||
CreatePaymentOrderResponse,
|
||||
CreateOrderInput
|
||||
>(async ({ input }) => {
|
||||
},
|
||||
),
|
||||
createOrder: fromPromise<CreatePaymentOrderResponse, CreateOrderInput>(
|
||||
async ({ input }) => {
|
||||
createOrderSpy(input);
|
||||
if (overrides.createOrderError) throw overrides.createOrderError;
|
||||
return CreatePaymentOrderResponse.from({
|
||||
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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
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 }) => {
|
||||
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();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user