Files
Jyotisha/frontend/tests/application-billing-contract.test.ts
T
jesse-uxandGrok (agent loop) cd7e0a3493
Independent Staging Quality Gate / validate (push) Successful in 13m51s
Independent Staging Quality Gate / publish (push) Successful in 4m6s
把普通对话的预留、取消、追加、寒暄结算和用量计算收进结算工厂。
完成回答、直达校正、输出审核和开流前加载仍留在路由里,使 diff 停在 1500 行以内。

Co-Authored-By: Grok (agent loop)
2026-10-15 19:12:44 +08:00

243 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { completeUsage } from "../src/lib/consultation-billing.ts";
import { rectificationAgentRouteSurface } from "./rectification-agent-route-surface.ts";
const root = new URL("../", import.meta.url);
// 原值: 读 app/api/rectification/agent/route.ts 单文件。新值: rectificationAgentRouteSurface(route.ts + 拆出的 agent-route-*.ts,见 rectification-agent-route-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
const rectificationRoute = rectificationAgentRouteSurface;
const consultRoute = readFileSync(new URL("src/app/api/consult/route.ts", root), "utf8");
const consultSettlement = readFileSync(new URL("src/lib/consult/settlement.ts", root), "utf8");
const packagesRoute = readFileSync(new URL("src/app/api/admin/packages/route.ts", root), "utf8");
function sourceBetween(source: string, start: string, end: string): string {
const startIndex = source.indexOf(start);
const endIndex = source.indexOf(end, startIndex + start.length);
assert.ok(startIndex >= 0, `missing start marker: ${start}`);
assert.ok(endIndex > startIndex, `missing end marker: ${end}`);
return source.slice(startIndex, endIndex);
}
test("Agentic rectification reuses one case-level usage authorization and the session-pinned model version", () => {
assert.match(rectificationRoute, /import \{ createServerSupabaseClient \} from "@\/lib\/supabase\/server"/);
assert.match(rectificationRoute, /supabase = await createServerSupabaseClient\(\)/);
// Former value: select("id,messages,session_type,model_id,model_config_version,agentic_rectification_case_id")
// Messages were unused on this route and inflated every rectification turn.
assert.match(rectificationRoute, /select\("id,session_type,model_id,model_config_version,agentic_rectification_case_id"\)/);
assert.match(rectificationRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/);
assert.match(rectificationRoute, /modelConfigVersion: selectedModel\.configVersion/);
assert.doesNotMatch(rectificationRoute, /loadLanguageModelCatalog|resolveLanguageModelFromCatalog|\bresolveLanguageModel\(|\bdefaultLanguageModel\(/);
assert.match(rectificationRoute, /const billingRequestPrefix = `rectification:case:\$\{caseId\}`/);
assert.match(rectificationRoute, /from\("usage_reservations"\)[\s\S]*\.eq\("feature_key", "rectification"\)[\s\S]*\.like\("request_id", `\$\{billingRequestPrefix\}%`\)/);
assert.match(rectificationRoute, /return reservations.length === 0[\s\S]*`\$\{billingRequestPrefix\}:retry:\$\{reservations.length\}`/);
assert.match(rectificationRoute, /authorizeUsage\(accounting, \{[\s\S]*requestId: billingRequestId/);
assert.match(rectificationRoute, /completeUsage\(accounting, userId, billingRequestId,/);
assert.match(rectificationRoute, /releaseUsage\(accounting, userId, billingRequestId,/);
});
test("Agentic rectification preserves pricing failure codes for logs and maps them to one safe public error", () => {
const reserveBilling = sourceBetween(rectificationRoute, "async reserve() {", "async complete(usage) {");
const errorMapping = sourceBetween(rectificationRoute, "const code = error instanceof RectificationToolServiceError", " } finally {");
assert.match(rectificationRoute, /import \{ FeaturePricingError, resolveFeaturePricing \} from "@\/lib\/feature-pricing"/);
assert.match(reserveBilling, /error instanceof FeaturePricingError \? error\.code : "billing_unavailable"/);
assert.match(reserveBilling, /reserve failed case=\$\{caseId\} code=\$\{reason\}/);
assert.match(reserveBilling, /return \{ success: false, reason, status: 503 \}/);
assert.match(errorMapping, /code\.startsWith\("feature_pricing_"\) \|\| code === "billing_unavailable"/);
assert.match(errorMapping, /code: "billing_unavailable", message: "当前服务的计费配置不可用,请联系支持人员。"/);
assert.ok(errorMapping.indexOf('code: "billing_unavailable"') < errorMapping.lastIndexOf('code: "run_failed"'));
assert.doesNotMatch(reserveBilling + errorMapping, /error\.message/);
});
test("free Agentic rectification turns bypass reservation, completion, and cancellation settlement", async () => {
// 旧:enum 到 stop_and_review
// 新:增加 skip_probe;非 message 仍不计费
// 原因:决策 4,核对跳过走即时结构化动作
assert.match(
rectificationRoute,
/action: z\.enum\(\["opening", "message", "read_only", "answer_choice", "stop_and_review", "skip_probe"\]\)/,
);
assert.equal(rectificationRoute.match(/if \(action !== "message"\)/g)?.length, 3);
// 原值: 从 route.ts 切 reserve / complete / release 三段(release 以 ` };\n\n try {` 收尾),
// 各匹配 `if (action !== "message")` 早退与 authorizeUsage / completeUsage / releaseUsage 调用。
// 新值: 调用 createRectificationRunBilling:非 message 动作三步都直接成功、一次也不碰账务库;
// message 动作三步都真的去读账务库(库不可用时 reserve 回 503 billing_unavailable,complete / release 回 false)。
// 原因: TASK-rectification-code-split-20260926 billing 对象原样搬进 agent-route-billing.ts,原 release 段的
// 收尾锚点不复存在;直接调用比按锚点切源码更强。
// feature-pricing.ts imports "server-only", so the adapter runs in a child
// process with that module mocked (same harness as the other route tests).
const script = String.raw`
import { mock } from "node:test";
mock.module("server-only", { namedExports: {} });
const { createRectificationRunBilling } = await import("./src/lib/rectification-agentic/v9/agent-route-billing.ts");
const touched = [];
const unreachableAccounting = new Proxy({}, {
get(_target, property) {
return () => {
touched.push(String(property));
throw new Error("billing store unavailable");
};
},
});
const model = { id: "model-a", configVersion: 1, inputCostMicrousdPerMillion: 0, outputCostMicrousdPerMillion: 0 };
const billingFor = (action) => createRectificationRunBilling({
action,
accounting: unreachableAccounting,
userId: "user-1",
caseId: "case-1",
requestId: "request-1",
selectedModel: model,
});
console.error = () => {};
console.warn = () => {};
const free = [];
for (const action of ["opening", "read_only", "answer_choice", "stop_and_review", "skip_probe"]) {
const billing = billingFor(action);
free.push({
action,
reserve: await billing.reserve(),
complete: await billing.complete({ inputTokens: 10, outputTokens: 5, durationMs: 1 }),
release: await billing.release(),
});
}
const touchedByFree = [...touched];
const paid = billingFor("message");
const paidResult = {
reserve: await paid.reserve(),
complete: await paid.complete({ inputTokens: 10, outputTokens: 5, durationMs: 1 }),
release: await paid.release(),
};
process.stdout.write("AYANAM_RESULT " + JSON.stringify({ free, touchedByFree, paid: paidResult, touched }) + "\n");
`;
const result = spawnSync(process.execPath, [
"--experimental-test-module-mocks",
"--import",
"tsx",
"--input-type=module",
"--eval",
script,
], {
cwd: fileURLToPath(root),
encoding: "utf8",
env: { ...process.env, NODE_NO_WARNINGS: "1" },
});
assert.equal(result.status, 0, result.stderr || result.stdout);
const line = result.stdout.split("\n").find((row) => row.startsWith("AYANAM_RESULT "));
assert.ok(line, result.stdout);
const probe = JSON.parse(line.slice("AYANAM_RESULT ".length)) as {
free: Array<{ action: string; reserve: unknown; complete: boolean; release: boolean }>;
touchedByFree: string[];
paid: { reserve: unknown; complete: boolean; release: boolean };
touched: string[];
};
for (const turn of probe.free) {
assert.deepEqual(turn.reserve, { success: true, status: 200 }, turn.action);
assert.equal(turn.complete, true, turn.action);
assert.equal(turn.release, true, turn.action);
}
assert.deepEqual(probe.touchedByFree, [], "free turns never read or write the billing store");
assert.deepEqual(probe.paid.reserve, { success: false, reason: "billing_unavailable", status: 503 });
assert.equal(probe.paid.complete, false);
assert.equal(probe.paid.release, false);
assert.deepEqual(probe.touched, ["from", "from", "from"], "each paid step looks up the case-bound reservation first");
assert.match(rectificationRoute, /authorizeUsage\(/);
assert.match(rectificationRoute, /completeUsage\(/);
assert.match(rectificationRoute, /releaseUsage\(/);
});
test("standard consultation resolves and settles the session-pinned model version", () => {
assert.match(consultRoute, /sessionId: z\.string\(\)\.uuid\(\)/);
// Former value: select("id,model_id,model_config_version,session_type,messages").
// First-round session titles need the current title, theme, and chart role.
// 原值: select includes chart_profile_role but not chart_profile_id or chart_profile_name.
// 新值: the same select also reads chart_profile_id and chart_profile_name.
// 原因: ordinary chat resolves the subject from the stored binding, not from chart_profile_role alone.
assert.match(consultRoute, /select\("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_id,chart_profile_name,chart_profile_role,context_summary"\)/);
assert.match(consultRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/);
// 原值: route.ts 的 usagePayload 写 actualModelId 与 modelConfigVersion
// 新值: 这两字段在 settlement.ts 的 usagePayload
// 原因: 用量载荷搬进结算工厂
assert.match(consultSettlement, /actualModelId: selectedModel\.id/);
assert.match(consultSettlement, /modelConfigVersion: selectedModel\.configVersion/);
// 原值: 守卫 .eq("title", expectedTitle) 用 RPC 前快照,append_consultation_question 已改写标题,更新恒 0 行
// 新值: shouldGenerate 仍用 RPC 前标题;守卫比较 RPC 后再 select 到的 title
// 原因: BUG-557,模型标题必须能在刷新前落库
assert.match(consultRoute, /const expectedTitle = typeof chatSession\.title === "string" \? chatSession\.title : ""/);
assert.match(consultRoute, /shouldGenerateSessionTitle\(\{\s*title: expectedTitle,/);
assert.match(consultRoute, /expectedTitle: titleAfterRpc/);
assert.match(consultRoute, /persistGuardedSessionTitle/);
});
test("standard consultation awaits real usage before durable response settlement", () => {
assert.doesNotMatch(consultRoute, /recordActualUsage|void usage\.then/);
assert.doesNotMatch(consultRoute, /inputTokens: 0,[\s\S]*outputTokens: 0,[\s\S]*costMicrousd: 0/);
// 原值: route.ts 里 async function usagePayload 的函数体含 mergeUsage
// 新值: 函数体在 settlement.ts;route.ts 转调用 consultSettlement.usagePayload,完成回答仍 await usagePayload 再写入 p_actual_usage
// 原因: 用量载荷搬进结算工厂,完成回答留在 route
assert.match(consultRoute, /return consultSettlement\.usagePayload\(usage\)/);
assert.match(consultSettlement, /async function usagePayload\(usage: Promise<\{ inputTokens\?: number; outputTokens\?: number \}>\)/);
// 原值 await usage;新值等待咨询与分类 usage 合并;原因:分类成本也必须结算。
assert.match(consultSettlement, /const resolved = await mergeUsage\(\[usage, Promise\.resolve\(classificationUsage\)\]\);/);
assert.match(consultRoute, /const actualUsage = await usagePayload\(usage\);[\s\S]*p_actual_usage: actualUsage/);
assert.match(consultSettlement, /export function mergeUsage\(usages: Promise<Usage>\[\]\): Promise<Usage> \{[\s\S]*Promise\.all\(usages\)/);
// Former: three agentic first streams each had `usages.push(result.totalUsage)`.
// New: the three first streams share streamWithOverflowRetry, which pushes the
// successful first attempt (and the overflow retry under a different name).
// Reason: context overflow must retry once on the same request.
assert.equal(consultRoute.match(/usages\.push\(result\.totalUsage\)/g)?.length, 1);
assert.match(consultRoute, /streamWithOverflowRetry/);
assert.match(consultRoute, /isContextOverflowError/);
// Three agentic first streams: public/general, declared-window, and natal.
// Window and natal each have a contract retry plus an answer retry; general
// has only an answer retry. Every one of those model calls spends tokens.
assert.equal(consultRoute.match(/usages\.push\(retried\.totalUsage\)/g)?.length, 5);
// 原值: /const retryForAnswer = async \(\) => \{/ 三处
// 新值: /const retryForAnswer = async \(retryHint\?: string\) => \{/ 三处
// 原因: BUG-1053 删除 compose 后,Pass 4 整篇被拒的重写提示改由 answer retry 携带;三处回答重试仍各计一次 usage
assert.equal(consultRoute.match(/const retryForAnswer = async \(retryHint\?: string\) => \{/g)?.length, 3);
});
test("standard consultation forwards its stable reservation request as the usage event key", async () => {
const eventKey = "00000000-0000-4000-8000-000000000002";
const calls: Record<string, unknown>[] = [];
const accounting = {
async rpc(_rpcName: string, args: Record<string, unknown>) {
calls.push(args);
return {
data: { success: true, reservation_id: "00000000-0000-4000-8000-000000000003", credits: 9, error_code: null },
error: null,
};
},
};
const usage = { eventKey, actualModelId: "model", inputTokens: 1, outputTokens: 2, costMicrousd: 3, durationMs: 4 };
await completeUsage(accounting, "00000000-0000-4000-8000-000000000001", eventKey, usage);
await completeUsage(accounting, "00000000-0000-4000-8000-000000000001", eventKey, usage);
assert.deepEqual(calls.map((call) => (call.p_actual_usage as { eventKey: string }).eventKey), [eventKey, eventKey]);
// 原值: route.ts 含 eventKey: requestId
// 新值: settlement.ts 的 usagePayload 含 eventKey: requestId;route.ts 仍把 usagePayload 的结果写入 p_actual_usage
// 原因: 用量载荷搬进结算工厂,完成回答仍用这份载荷
assert.match(consultSettlement, /eventKey: requestId,/);
assert.match(consultRoute, /const actualUsage = await usagePayload\(usage\);[\s\S]*p_actual_usage: actualUsage/);
assert.doesNotMatch(consultSettlement, /eventKey:\s*(?:globalThis\.)?crypto\.randomUUID\(\)/);
assert.doesNotMatch(consultRoute, /eventKey:\s*(?:globalThis\.)?crypto\.randomUUID\(\)/);
});
test("legacy admin packages cannot mutate detached payment_packages", () => {
assert.doesNotMatch(packagesRoute, /payment_packages|queryAdminRows|insert into|update public/);
assert.match(packagesRoute, /export function GET\(request: Request\)/);
assert.match(packagesRoute, /const replacement = "\/api\/admin\/products"/);
assert.match(packagesRoute, /NextResponse\.redirect\(new URL\(replacement, request\.url\), 308\)/);
for (const method of ["POST", "PATCH", "DELETE"]) {
assert.match(packagesRoute, new RegExp(`export function ${method}\\(\\)`));
}
assert.match(packagesRoute, /status: 410/);
assert.match(packagesRoute, /error: "旧套餐写接口已停用,请使用统一商品管理。",[\s\S]*replacement,/);
});