fix: preserve billing response shape
This commit is contained in:
@@ -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.
|
||||
@@ -12,7 +12,7 @@ export default function BillingError({ reset }: { error: Error & { digest?: stri
|
||||
<div className="billing-route-error-card">
|
||||
<span className="billing-route-error-kicker">计费中心</span>
|
||||
<h1>计费服务暂时不可用</h1>
|
||||
<p>请先刷新页面。如果问题持续,请检查服务端日志,并确认已通过 <code>npm run db:migrate</code> 完成数据库迁移。</p>
|
||||
<p>请先刷新页面。如果问题持续,请检查 Go API 状态和服务端日志。</p>
|
||||
<button type="button" onClick={() => reset()}>重新加载</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -53,6 +53,9 @@ func (s *Service) Overview(ctx context.Context, organizationID, accountID string
|
||||
if err != nil {
|
||||
return Overview{}, err
|
||||
}
|
||||
if ledger == nil {
|
||||
ledger = []LedgerEntry{}
|
||||
}
|
||||
personal, err := s.store.BillingLedger(ctx, organizationID, accountID, 500)
|
||||
if err != nil {
|
||||
return Overview{}, err
|
||||
@@ -118,6 +121,18 @@ func (s *Service) AdminOverview(ctx context.Context) (AdminOverview, error) {
|
||||
if err != nil {
|
||||
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{}
|
||||
for _, wallet := range wallets {
|
||||
byOrganization[wallet.OrganizationID] = wallet
|
||||
|
||||
@@ -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) {
|
||||
service := &billingHTTPServiceStub{}
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Helper()
|
||||
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)
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { billingUnitLabel, formatBillingAmount } from "@/lib/billing";
|
||||
import { parseAdminBillingPayload, parseMemberBillingPayload } from "@/lib/client/billing-api";
|
||||
import { pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
|
||||
import type { BillingAccountConfig, BillingParameterDimension, BillingParameterTier, BillingPriceRule, OrganizationWallet } from "@/lib/types";
|
||||
|
||||
@@ -161,8 +162,9 @@ export function BillingManager({ isSuperAdmin }: { isSuperAdmin: boolean }) {
|
||||
try {
|
||||
if (isSuperAdmin) {
|
||||
const adminResponse = await fetch("/api/admin/billing", { cache: "no-store" });
|
||||
const adminPayload = await readApiPayload<AdminPayload>(adminResponse);
|
||||
if (!adminResponse.ok) throw new Error(adminPayload.error || "读取超管计费数据失败");
|
||||
const rawAdminPayload = await readApiPayload<AdminPayload>(adminResponse);
|
||||
if (!adminResponse.ok) throw new Error(rawAdminPayload.error || "读取超管计费数据失败");
|
||||
const adminPayload = parseAdminBillingPayload(rawAdminPayload);
|
||||
setAdmin(adminPayload);
|
||||
setBilling(buildAdminBillingPayload(adminPayload));
|
||||
setAccountDraft({ ...emptyAccountDraft, ...adminPayload.billingAccount });
|
||||
@@ -172,8 +174,9 @@ export function BillingManager({ isSuperAdmin }: { isSuperAdmin: boolean }) {
|
||||
}));
|
||||
} else {
|
||||
const billingResponse = await fetch("/api/billing", { cache: "no-store" });
|
||||
const billingPayload = await readApiPayload<BillingPayload>(billingResponse);
|
||||
if (!billingResponse.ok) throw new Error(billingPayload.error || "读取计费数据失败");
|
||||
const rawBillingPayload = await readApiPayload<BillingPayload>(billingResponse);
|
||||
if (!billingResponse.ok) throw new Error(rawBillingPayload.error || "读取计费数据失败");
|
||||
const billingPayload = parseMemberBillingPayload(rawBillingPayload);
|
||||
setBilling(billingPayload);
|
||||
}
|
||||
} catch (nextError) {
|
||||
@@ -324,7 +327,7 @@ async function readApiPayload<T extends Record<string, unknown>>(response: Respo
|
||||
} catch {
|
||||
return {
|
||||
error: response.status >= 500
|
||||
? "计费服务返回了服务器错误,请检查服务端日志,并确认已执行 npm run db:migrate。"
|
||||
? "计费服务返回了服务器错误,请检查 Go API 状态和服务端日志。"
|
||||
: `服务器返回了无效响应(HTTP ${response.status})。`
|
||||
} as T & { error?: string };
|
||||
}
|
||||
|
||||
172
lib/client/billing-api.ts
Normal file
172
lib/client/billing-api.ts
Normal 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;
|
||||
}
|
||||
147
tests/billing-api-contract.test.ts
Normal file
147
tests/billing-api-contract.test.ts
Normal 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("成员计费数据格式错误:响应必须是对象。");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user