fix: preserve billing response shape

This commit is contained in:
2026-08-17 11:28:08 +08:00
parent 600cba023c
commit 2ef3da7af5
7 changed files with 488 additions and 6 deletions

View File

@@ -0,0 +1,87 @@
# Task: Fix billing empty collection contract
## Identity
- Task ID: 20260817-fix-billing-empty-arrays-4f9d2a7c
- Mode: Feature
- Branch: main
- Worktree: D:\Datas\OthersProjects\NianAIGC
- Base commit: 600cba023ca39d6dd81e526ba1e6014d4bfad65b
- Owner: codex
- Status: Ready for Integration
## Scope
- Make Go billing overview responses encode empty collections as JSON arrays,
never `null`.
- Add frontend response-shape validation so malformed billing payloads produce
an inline error instead of crashing the `/billing` route subtree.
- Replace the obsolete `npm run db:migrate` route-error guidance with wording
appropriate to the static Web + Go API architecture.
- Add regression coverage at both the Go HTTP contract seam and the pure
frontend payload parsing seam.
## Intent And Constraints
- Preserve the accepted static Web + Go-only API architecture and existing
authorization behavior.
- Do not change database schema, seed fake organizations, or require production
data to make empty-state rendering safe.
- Keep the fix surgical: Go owns valid JSON output; frontend validation is a
defense against incompatible or malformed deployments.
- Work test-first from the deterministic diagnosis recorded in
`20260817-diagnose-billing-page-7c3a91e2`.
## Outcome
- Go billing overview services now normalize every response collection used by
the member and super-administrator billing pages to a non-nil slice, so an
empty database result is encoded as `[]` rather than `null`.
- The browser validates every billing object, collection element, and nested
price dimension used by the renderer before committing API data to React
state. Incompatible successful responses now produce the existing inline
error state instead of throwing during render and activating the route error
boundary. Validation preserves Go `omitempty` behavior for optional fields,
including a fresh super-administrator without `organizationId`.
- The billing route error boundary and invalid-JSON fallback now direct
operators to the Go API status and server logs rather than the removed Node
migration workflow.
- Regression tests cover the real Go HTTP response contract with an empty store
and the frontend parser contract for empty, missing, null, and malformed
payloads.
## Verification
- RED: `go test ./internal/httpapi -run TestBillingOverviewResponsesEncodeEmptyCollectionsAsArrays -count=1`
failed because empty member/admin collections encoded as `null`.
- RED: `npx vitest run tests/billing-api-contract.test.ts` failed before the
frontend parser module existed.
- PASS: `go test ./...`.
- PASS: `go vet ./...`.
- Initial final review: FAIL because top-level array checks still allowed
malformed collection elements such as `priceRules: [null]` and a null member
`organization` to reach render-time property access.
- Remediation: added complete render-shape validation and regressions for admin
organizations/wallets, members, ledger entries, price rules/dimensions/tiers,
and member organization/wallet/summary/personal data.
- PASS after remediation: `npm test` (`54` files, `174` tests).
- PASS: `npx tsc --noEmit --incremental false`.
- PASS: `npm run deploy:check` (`7` ACK manifest files).
- PASS: `npm run build` (all routes statically exported, including `/billing`).
- PASS: `check_project_docs.py --target .`.
- PASS: task-aware `check_doc_drift.py` for this task ID.
- PASS: `git diff --check` (line-ending notices only).
- Final read-only review: PASS on both Standards and Spec after the malformed
nested-payload remediation; no blockers remain.
## Follow-ups
- After publishing new Web and Go images under a fresh immutable tag, verify an
authenticated super-administrator with zero organizations sees the billing
empty state in production. This requires deployment access and is not a code
acceptance blocker.
## Promotion Candidates
- None. This task applies the existing static Web + Go API architecture and does
not introduce a new architectural decision.

View File

@@ -12,7 +12,7 @@ export default function BillingError({ reset }: { error: Error & { digest?: stri
<div className="billing-route-error-card"> <div className="billing-route-error-card">
<span className="billing-route-error-kicker"></span> <span className="billing-route-error-kicker"></span>
<h1></h1> <h1></h1>
<p> <code>npm run db:migrate</code> </p> <p> Go API </p>
<button type="button" onClick={() => reset()}></button> <button type="button" onClick={() => reset()}></button>
</div> </div>
</main> </main>

View File

@@ -53,6 +53,9 @@ func (s *Service) Overview(ctx context.Context, organizationID, accountID string
if err != nil { if err != nil {
return Overview{}, err return Overview{}, err
} }
if ledger == nil {
ledger = []LedgerEntry{}
}
personal, err := s.store.BillingLedger(ctx, organizationID, accountID, 500) personal, err := s.store.BillingLedger(ctx, organizationID, accountID, 500)
if err != nil { if err != nil {
return Overview{}, err return Overview{}, err
@@ -118,6 +121,18 @@ func (s *Service) AdminOverview(ctx context.Context) (AdminOverview, error) {
if err != nil { if err != nil {
return AdminOverview{}, err return AdminOverview{}, err
} }
if organizations == nil {
organizations = []Organization{}
}
if members == nil {
members = []Member{}
}
if ledger == nil {
ledger = []LedgerEntry{}
}
if rules == nil {
rules = []PriceRule{}
}
byOrganization := map[string]Wallet{} byOrganization := map[string]Wallet{}
for _, wallet := range wallets { for _, wallet := range wallets {
byOrganization[wallet.OrganizationID] = wallet byOrganization[wallet.OrganizationID] = wallet

View File

@@ -28,6 +28,15 @@ func TestBillingMemberRoutesUseRefreshedSessionScope(t *testing.T) {
} }
} }
func TestBillingOverviewResponsesEncodeEmptyCollectionsAsArrays(t *testing.T) {
service := billing.NewService(&emptyBillingStore{}, nil)
member := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeUser, User: identity.User{ID: "user", ClientID: "platform", OrganizationID: "org", Role: "user"}}, service, nil)
admin := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeAdmin, User: identity.User{ID: "root", ClientID: "platform", Role: "super_admin"}}, service, nil)
assertJSONArrays(t, serveJSON(t, member, http.MethodGet, "/api/billing", nil), "ledger")
assertJSONArrays(t, serveJSON(t, admin, http.MethodGet, "/api/admin/billing", nil), "organizations", "members", "ledger", "priceRules")
}
func TestBillingQuotePreparesProviderAndModelFromServerOwnedEngineTargets(t *testing.T) { func TestBillingQuotePreparesProviderAndModelFromServerOwnedEngineTargets(t *testing.T) {
service := &billingHTTPServiceStub{} service := &billingHTTPServiceStub{}
h := billingTestHandler(t, identity.Session{User: identity.User{ID: "db-user", ClientID: "platform", OrganizationID: "db-org", Role: "user"}}, service, nil) h := billingTestHandler(t, identity.Session{User: identity.User{ID: "db-user", ClientID: "platform", OrganizationID: "db-org", Role: "user"}}, service, nil)
@@ -165,6 +174,39 @@ func (s *billingAccountStoreStub) Save(_ context.Context, value billing.AccountC
return nil return nil
} }
type emptyBillingStore struct{}
func (*emptyBillingStore) BillingWallet(context.Context, string) (billing.Wallet, error) {
return billing.Wallet{}, nil
}
func (*emptyBillingStore) BillingWallets(context.Context) ([]billing.Wallet, error) {
return nil, nil
}
func (*emptyBillingStore) BillingLedger(context.Context, string, string, int) ([]billing.LedgerEntry, error) {
return nil, nil
}
func (*emptyBillingStore) BillingOrganizations(context.Context) ([]billing.Organization, error) {
return nil, nil
}
func (*emptyBillingStore) BillingMembers(context.Context) ([]billing.Member, error) {
return nil, nil
}
func (*emptyBillingStore) BillingOrganizationExists(context.Context, string) (bool, error) {
return false, nil
}
func (*emptyBillingStore) ListBillingPriceRules(context.Context, bool) ([]billing.PriceRule, error) {
return nil, nil
}
func (*emptyBillingStore) GetBillingPriceRule(context.Context, string) (*billing.PriceRule, error) {
return nil, nil
}
func (*emptyBillingStore) UpdateBillingPriceRule(context.Context, string, billing.PricePatch) (*billing.PriceRule, error) {
return nil, nil
}
func (*emptyBillingStore) PostBillingWalletEntry(context.Context, billing.WalletPostParams) (billing.WalletPosting, error) {
return billing.WalletPosting{}, nil
}
func billingTestHandler(t *testing.T, session identity.Session, service BillingHTTPService, accounts BillingAccountStore) http.Handler { func billingTestHandler(t *testing.T, session identity.Session, service BillingHTTPService, accounts BillingAccountStore) http.Handler {
t.Helper() t.Helper()
authorizer, err := NewPlatformAuthorizer(AuthState{Required: true, Configured: true}, &fixedSessionResolver{session: session}) authorizer, err := NewPlatformAuthorizer(AuthState{Required: true, Configured: true}, &fixedSessionResolver{session: session})
@@ -192,3 +234,19 @@ func serveJSON(t *testing.T, h http.Handler, method, path string, body any) *htt
h.ServeHTTP(response, req) h.ServeHTTP(response, req)
return response return response
} }
func assertJSONArrays(t *testing.T, response *httptest.ResponseRecorder, fields ...string) {
t.Helper()
if response.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
for _, field := range fields {
if string(payload[field]) != "[]" {
t.Errorf("%s = %s, want [] (body=%s)", field, payload[field], response.Body.String())
}
}
}

View File

@@ -20,6 +20,7 @@ import {
X X
} from "lucide-react"; } from "lucide-react";
import { billingUnitLabel, formatBillingAmount } from "@/lib/billing"; import { billingUnitLabel, formatBillingAmount } from "@/lib/billing";
import { parseAdminBillingPayload, parseMemberBillingPayload } from "@/lib/client/billing-api";
import { pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; import { pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
import type { BillingAccountConfig, BillingParameterDimension, BillingParameterTier, BillingPriceRule, OrganizationWallet } from "@/lib/types"; import type { BillingAccountConfig, BillingParameterDimension, BillingParameterTier, BillingPriceRule, OrganizationWallet } from "@/lib/types";
@@ -161,8 +162,9 @@ export function BillingManager({ isSuperAdmin }: { isSuperAdmin: boolean }) {
try { try {
if (isSuperAdmin) { if (isSuperAdmin) {
const adminResponse = await fetch("/api/admin/billing", { cache: "no-store" }); const adminResponse = await fetch("/api/admin/billing", { cache: "no-store" });
const adminPayload = await readApiPayload<AdminPayload>(adminResponse); const rawAdminPayload = await readApiPayload<AdminPayload>(adminResponse);
if (!adminResponse.ok) throw new Error(adminPayload.error || "读取超管计费数据失败"); if (!adminResponse.ok) throw new Error(rawAdminPayload.error || "读取超管计费数据失败");
const adminPayload = parseAdminBillingPayload(rawAdminPayload);
setAdmin(adminPayload); setAdmin(adminPayload);
setBilling(buildAdminBillingPayload(adminPayload)); setBilling(buildAdminBillingPayload(adminPayload));
setAccountDraft({ ...emptyAccountDraft, ...adminPayload.billingAccount }); setAccountDraft({ ...emptyAccountDraft, ...adminPayload.billingAccount });
@@ -172,8 +174,9 @@ export function BillingManager({ isSuperAdmin }: { isSuperAdmin: boolean }) {
})); }));
} else { } else {
const billingResponse = await fetch("/api/billing", { cache: "no-store" }); const billingResponse = await fetch("/api/billing", { cache: "no-store" });
const billingPayload = await readApiPayload<BillingPayload>(billingResponse); const rawBillingPayload = await readApiPayload<BillingPayload>(billingResponse);
if (!billingResponse.ok) throw new Error(billingPayload.error || "读取计费数据失败"); if (!billingResponse.ok) throw new Error(rawBillingPayload.error || "读取计费数据失败");
const billingPayload = parseMemberBillingPayload(rawBillingPayload);
setBilling(billingPayload); setBilling(billingPayload);
} }
} catch (nextError) { } catch (nextError) {
@@ -324,7 +327,7 @@ async function readApiPayload<T extends Record<string, unknown>>(response: Respo
} catch { } catch {
return { return {
error: response.status >= 500 error: response.status >= 500
? "计费服务返回了服务器错误,请检查服务端日志,并确认已执行 npm run db:migrate。" ? "计费服务返回了服务器错误,请检查 Go API 状态和服务端日志。"
: `服务器返回了无效响应HTTP ${response.status})。` : `服务器返回了无效响应HTTP ${response.status})。`
} as T & { error?: string }; } as T & { error?: string };
} }

172
lib/client/billing-api.ts Normal file
View File

@@ -0,0 +1,172 @@
type PayloadRecord = Record<string, unknown>;
function invalid(context: string, path: string, expected: string): never {
const separator = path === "响应" ? "" : " ";
throw new Error(`${context}数据格式错误:${path}${separator}必须是${expected}`);
}
function requireRecord(value: unknown, path: string, context: string): PayloadRecord {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
invalid(context, path, "对象");
}
return value as PayloadRecord;
}
function requireArray(record: PayloadRecord, field: string, context: string, path = field): unknown[] {
if (!Array.isArray(record[field])) invalid(context, path, "数组");
return record[field];
}
function requireString(record: PayloadRecord, field: string, context: string, path: string): void {
if (typeof record[field] !== "string") invalid(context, path, "字符串");
}
function requireOptionalString(record: PayloadRecord, field: string, context: string, path: string): void {
if (record[field] !== undefined && record[field] !== null && typeof record[field] !== "string") {
invalid(context, path, "字符串");
}
}
function requireNumber(record: PayloadRecord, field: string, context: string, path: string): void {
if (typeof record[field] !== "number" || !Number.isFinite(record[field])) {
invalid(context, path, "有限数字");
}
}
function requireBoolean(record: PayloadRecord, field: string, context: string, path: string): void {
if (typeof record[field] !== "boolean") invalid(context, path, "布尔值");
}
function requireScalar(value: unknown, context: string, path: string): void {
if (!(["string", "number", "boolean"] as string[]).includes(typeof value)) {
invalid(context, path, "字符串、数字或布尔值");
}
if (typeof value === "number" && !Number.isFinite(value)) invalid(context, path, "有限数字");
}
function validateBillingAccount(value: unknown, context: string, path: string): void {
const account = requireRecord(value, path, context);
for (const field of ["accountName", "bankName", "accountNumber", "contact"]) {
requireOptionalString(account, field, context, `${path}.${field}`);
}
}
function validateWallet(value: unknown, context: string, path: string): void {
const wallet = requireRecord(value, path, context);
requireString(wallet, "organizationId", context, `${path}.organizationId`);
for (const field of ["balanceFen", "totalRechargedFen", "totalChargedFen"]) {
requireNumber(wallet, field, context, `${path}.${field}`);
}
requireString(wallet, "updatedAt", context, `${path}.updatedAt`);
}
function validateSummary(value: unknown, context: string, path: string): void {
const summary = requireRecord(value, path, context);
for (const field of ["rechargeFen", "chargedFen", "refundedFen", "netConsumedFen"]) {
requireNumber(summary, field, context, `${path}.${field}`);
}
}
function validateLedgerEntry(value: unknown, context: string, path: string): void {
const entry = requireRecord(value, path, context);
requireString(entry, "id", context, `${path}.id`);
requireString(entry, "organizationId", context, `${path}.organizationId`);
requireOptionalString(entry, "accountId", context, `${path}.accountId`);
requireOptionalString(entry, "jobId", context, `${path}.jobId`);
requireString(entry, "kind", context, `${path}.kind`);
requireNumber(entry, "deltaFen", context, `${path}.deltaFen`);
requireNumber(entry, "balanceAfterFen", context, `${path}.balanceAfterFen`);
requireString(entry, "description", context, `${path}.description`);
requireRecord(entry.metadata, `${path}.metadata`, context);
requireString(entry, "createdAt", context, `${path}.createdAt`);
if (Number.isNaN(Date.parse(entry.createdAt as string))) invalid(context, `${path}.createdAt`, "有效时间字符串");
}
function validateOrganization(value: unknown, context: string, path: string): void {
const organization = requireRecord(value, path, context);
requireString(organization, "id", context, `${path}.id`);
requireString(organization, "name", context, `${path}.name`);
}
function validateAdminOrganization(value: unknown, context: string, path: string): void {
const organization = requireRecord(value, path, context);
requireString(organization, "id", context, `${path}.id`);
requireString(organization, "name", context, `${path}.name`);
requireString(organization, "status", context, `${path}.status`);
validateWallet(organization.wallet, context, `${path}.wallet`);
}
function validateAdminMember(value: unknown, context: string, path: string): void {
const member = requireRecord(value, path, context);
for (const field of ["id", "displayName", "phone", "role", "status"]) {
requireString(member, field, context, `${path}.${field}`);
}
requireOptionalString(member, "organizationId", context, `${path}.organizationId`);
}
function validatePriceRule(value: unknown, context: string, path: string): void {
const rule = requireRecord(value, path, context);
for (const field of ["id", "provider", "capability", "unit"]) {
requireString(rule, field, context, `${path}.${field}`);
}
requireOptionalString(rule, "reqKey", context, `${path}.reqKey`);
requireOptionalString(rule, "variantKey", context, `${path}.variantKey`);
requireOptionalString(rule, "note", context, `${path}.note`);
requireNumber(rule, "standardUnitPriceFen", context, `${path}.standardUnitPriceFen`);
requireNumber(rule, "markupMultiplier", context, `${path}.markupMultiplier`);
requireBoolean(rule, "enabled", context, `${path}.enabled`);
if (rule.conditions !== undefined && rule.conditions !== null) {
requireRecord(rule.conditions, `${path}.conditions`, context);
}
if (rule.source !== undefined && rule.source !== null) {
const source = requireRecord(rule.source, `${path}.source`, context);
requireOptionalString(source, "url", context, `${path}.source.url`);
}
if (rule.parameterDimensions !== undefined && rule.parameterDimensions !== null) {
if (!Array.isArray(rule.parameterDimensions)) invalid(context, `${path}.parameterDimensions`, "数组");
rule.parameterDimensions.forEach((dimension, dimensionIndex) => {
const dimensionPath = `${path}.parameterDimensions[${dimensionIndex}]`;
const item = requireRecord(dimension, dimensionPath, context);
requireString(item, "key", context, `${dimensionPath}.key`);
requireOptionalString(item, "label", context, `${dimensionPath}.label`);
requireScalar(item.baselineValue, context, `${dimensionPath}.baselineValue`);
if (item.defaultValue !== undefined && item.defaultValue !== null) {
requireScalar(item.defaultValue, context, `${dimensionPath}.defaultValue`);
}
requireArray(item, "tiers", context, `${dimensionPath}.tiers`).forEach((tier, tierIndex) => {
const tierPath = `${dimensionPath}.tiers[${tierIndex}]`;
const tierItem = requireRecord(tier, tierPath, context);
requireScalar(tierItem.value, context, `${tierPath}.value`);
requireOptionalString(tierItem, "label", context, `${tierPath}.label`);
requireNumber(tierItem, "standardFactor", context, `${tierPath}.standardFactor`);
requireNumber(tierItem, "markupMultiplier", context, `${tierPath}.markupMultiplier`);
requireBoolean(tierItem, "enabled", context, `${tierPath}.enabled`);
requireOptionalString(tierItem, "note", context, `${tierPath}.note`);
});
});
}
}
export function parseAdminBillingPayload<T>(payload: T): T {
const context = "超管计费";
const record = requireRecord(payload, "响应", context);
validateBillingAccount(record.billingAccount, context, "billingAccount");
requireArray(record, "organizations", context).forEach((item, index) => validateAdminOrganization(item, context, `organizations[${index}]`));
requireArray(record, "members", context).forEach((item, index) => validateAdminMember(item, context, `members[${index}]`));
requireArray(record, "ledger", context).forEach((item, index) => validateLedgerEntry(item, context, `ledger[${index}]`));
requireArray(record, "priceRules", context).forEach((item, index) => validatePriceRule(item, context, `priceRules[${index}]`));
return payload;
}
export function parseMemberBillingPayload<T>(payload: T): T {
const context = "成员计费";
const record = requireRecord(payload, "响应", context);
validateOrganization(record.organization, context, "organization");
validateBillingAccount(record.billingAccount, context, "billingAccount");
validateWallet(record.wallet, context, "wallet");
requireArray(record, "ledger", context).forEach((item, index) => validateLedgerEntry(item, context, `ledger[${index}]`));
validateSummary(record.summary, context, "summary");
validateSummary(record.personal, context, "personal");
return payload;
}

View File

@@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import { parseAdminBillingPayload, parseMemberBillingPayload } from "@/lib/client/billing-api";
const wallet = {
organizationId: "org-1",
balanceFen: 0,
totalRechargedFen: 0,
totalChargedFen: 0,
updatedAt: "2026-08-17T00:00:00.000Z"
};
const ledgerEntry = {
id: "ledger-1",
organizationId: "org-1",
kind: "recharge",
deltaFen: 100,
balanceAfterFen: 100,
description: "充值",
metadata: {},
createdAt: "2026-08-17T00:00:00.000Z"
};
function validAdminPayload() {
return {
billingAccount: {},
organizations: [],
members: [],
ledger: [],
priceRules: []
};
}
function validMemberPayload() {
return {
organization: { id: "org-1", name: "测试组织" },
billingAccount: {},
wallet,
ledger: [],
summary: { rechargeFen: 0, chargedFen: 0, refundedFen: 0, netConsumedFen: 0 },
personal: { rechargeFen: 0, chargedFen: 0, refundedFen: 0, netConsumedFen: 0 }
};
}
describe("billing API payload contract", () => {
it("accepts valid empty collection payloads", () => {
const admin = validAdminPayload();
const member = validMemberPayload();
expect(parseAdminBillingPayload(admin)).toBe(admin);
expect(parseMemberBillingPayload(member)).toBe(member);
});
it.each(["organizations", "members", "ledger", "priceRules"])(
"rejects a missing admin %s collection",
(field) => {
const payload: Record<string, unknown> = validAdminPayload();
delete payload[field];
expect(() => parseAdminBillingPayload(payload))
.toThrow(`超管计费数据格式错误:${field} 必须是数组。`);
}
);
it("rejects a malformed admin price-rule element", () => {
expect(() => parseAdminBillingPayload({ ...validAdminPayload(), priceRules: [null] }))
.toThrow("超管计费数据格式错误priceRules[0] 必须是对象。");
});
it("rejects malformed admin organization elements and wallets", () => {
expect(() => parseAdminBillingPayload({ ...validAdminPayload(), organizations: [null] }))
.toThrow("超管计费数据格式错误organizations[0] 必须是对象。");
expect(() => parseAdminBillingPayload({
...validAdminPayload(),
organizations: [{ id: "org-1", name: "测试组织", status: "active", wallet: { ...wallet, balanceFen: "0" } }]
})).toThrow("超管计费数据格式错误organizations[0].wallet.balanceFen 必须是有限数字。");
});
it("rejects a malformed admin member element", () => {
expect(() => parseAdminBillingPayload({ ...validAdminPayload(), members: [null] }))
.toThrow("超管计费数据格式错误members[0] 必须是对象。");
});
it("accepts a fresh super administrator without organizationId", () => {
const payload = {
...validAdminPayload(),
members: [{ id: "root", displayName: "管理员", phone: "13800000000", role: "super_admin", status: "active" }]
};
expect(parseAdminBillingPayload(payload)).toBe(payload);
});
it("rejects a malformed admin ledger element", () => {
expect(() => parseAdminBillingPayload({ ...validAdminPayload(), ledger: [null] }))
.toThrow("超管计费数据格式错误ledger[0] 必须是对象。");
});
it("rejects malformed member objects used by the renderer", () => {
expect(() => parseMemberBillingPayload({ ...validMemberPayload(), organization: null }))
.toThrow("成员计费数据格式错误organization 必须是对象。");
expect(() => parseMemberBillingPayload({ ...validMemberPayload(), wallet: null }))
.toThrow("成员计费数据格式错误wallet 必须是对象。");
expect(() => parseMemberBillingPayload({ ...validMemberPayload(), summary: null }))
.toThrow("成员计费数据格式错误summary 必须是对象。");
expect(() => parseMemberBillingPayload({ ...validMemberPayload(), personal: null }))
.toThrow("成员计费数据格式错误personal 必须是对象。");
});
it("rejects a malformed member ledger element", () => {
expect(() => parseMemberBillingPayload({ ...validMemberPayload(), ledger: [null] }))
.toThrow("成员计费数据格式错误ledger[0] 必须是对象。");
});
it("accepts fully populated collection elements", () => {
const admin = {
...validAdminPayload(),
organizations: [{ id: "org-1", name: "测试组织", status: "active", wallet }],
members: [{ id: "member-1", displayName: "成员", phone: "13800000000", role: "user", organizationId: "org-1", status: "active" }],
ledger: [ledgerEntry],
priceRules: [{
id: "rule-1",
provider: "evolink",
capability: "image.generate",
unit: "image",
standardUnitPriceFen: 100,
markupMultiplier: 1.2,
enabled: true,
parameterDimensions: [{
key: "quality",
label: "质量",
baselineValue: "standard",
tiers: [{ value: "standard", label: "标准", standardFactor: 1, markupMultiplier: 1.2, enabled: true }]
}]
}]
};
const member = { ...validMemberPayload(), ledger: [ledgerEntry] };
expect(parseAdminBillingPayload(admin)).toBe(admin);
expect(parseMemberBillingPayload(member)).toBe(member);
});
it("rejects non-object responses", () => {
expect(() => parseAdminBillingPayload("unexpected"))
.toThrow("超管计费数据格式错误:响应必须是对象。");
expect(() => parseMemberBillingPayload(null))
.toThrow("成员计费数据格式错误:响应必须是对象。");
});
});