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