Files
yingqing/core/qa/visual-parity/_admin-p7.mjs
T
seaislee1209andClaude Opus 4.8 39b4467258 feat(admin): Phase 7 计费审计+4层额度策略 — 流水浏览/手动调额/额度策略CRUD+拦截
后端:adminpanel ledgers(全局流水+筛)+ adjust(手动调额,落 ADJUSTMENT)+ quota-policies CRUD;
额度拦截 _enforce_quota_policy(单任务/月度/项目)接入 reserve_credit,仅团队有 active 策略才生效(无策略零回归);
adjust_credit helper。修真 bug:log_admin_action 加 savepoint + JSON 安全化(UUID/Decimal),
修复传 serializer.data 致审计报错污染外层事务 → TransactionManagementError。
前端:adminApi 计费/额度系列;计费审计页(流水表+手动调额弹窗)+ 额度策略页(CRUD 弹窗,团队下拉+三类上限+启用)。
测试:adminpanel 44 + billing + accounts = 70 单测过(调额±/超额拒/额度拦截 per_task+monthly/无策略零回归);
无头 e2e _admin-p7.mjs 6 断言过 + 0 console error(一次性团队,不动 demo 余额);tsc+build 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 22:33:52 +08:00

103 lines
5.6 KiB
JavaScript

// Phase 7 e2e:计费审计(流水 + 手动调额)+ 额度策略(CRUD)。
// 用一次性 P7 团队做调额/建策略,绝不动 demo 团队余额。建的策略跑完删。
import { chromium } from "playwright";
import fs from "node:fs";
import path from "node:path";
const BASE = process.env.BASE || "http://127.0.0.1:5188";
const API = process.env.API || "http://127.0.0.1:8010";
const OUT = path.resolve("output/admin");
fs.mkdirSync(OUT, { recursive: true });
const stamp = Date.now();
async function apiLogin(u, p) {
const res = await fetch(`${API}/api/auth/login/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: u, password: p }) });
return res.json();
}
const admin = await apiLogin("admin", "admin123");
const authH = { "Content-Type": "application/json", Authorization: `Token ${admin.token}` };
// 一次性团队
const code = (await (await fetch(`${API}/api/admin/invitations/`, { method: "POST", headers: authH, body: "{}" })).json()).code;
const reg = await (await fetch(`${API}/api/auth/register/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: `p7o-${stamp}`, password: "ownerpass1", team_name: `P7Team${stamp}`, invite_code: code }) })).json();
const teamId = reg.team.id;
const r = { billing: {}, adjust: {}, quota: {}, consoleErrors: [], pass: false };
const browser = await chromium.launch({ headless: true });
const hook = (p, tag) => {
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push(`${tag}:${m.text()}`); });
p.on("pageerror", (e) => r.consoleErrors.push(`${tag}:PAGEERR:${e.message}`));
};
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await ctx.addInitScript((tk) => localStorage.setItem("airshelf_token", tk), admin.token);
const p = await ctx.newPage(); hook(p, "billing");
// ── 计费审计 + 手动调额 ──
await p.goto(BASE + "/admin/billing", { waitUntil: "load" });
await p.waitForSelector(".admin-app", { timeout: 12000 });
await p.waitForFunction(() => document.querySelector(".admin-table") || (document.querySelector(".empty-state.show h3") || {}).textContent !== "加载中…", { timeout: 20000 }).catch(() => {});
r.billing.title = (await p.locator(".content h1").first().innerText().catch(() => "")).trim();
r.billing.adjustBtn = (await p.locator(".page-head .actions .btn-primary").count()) >= 1;
r.billing.rows = await p.locator(".admin-table tbody tr").count();
await p.screenshot({ path: path.join(OUT, "p7-ledgers.png"), fullPage: true });
await p.locator(".page-head .actions .btn-primary").click();
await p.waitForSelector(".modal", { timeout: 6000 });
await p.locator(".modal .select").selectOption(teamId);
await p.locator('.modal input[placeholder*="100"]').fill("88");
await p.locator('.modal input[placeholder*="原因"]').fill("e2e 调额");
await p.locator(".modal-f .btn-primary").click();
await p.waitForTimeout(1200);
const ledgers = await (await fetch(`${API}/api/admin/ledgers/?team=${teamId}&ledger_type=adjustment`, { headers: authH })).json();
r.adjust.persisted = ledgers.count >= 1 && ledgers.results.some((l) => l.amount.startsWith("88"));
// ── 额度策略 CRUD ──
await p.goto(BASE + "/admin/quota", { waitUntil: "load" });
await p.waitForSelector(".admin-app", { timeout: 12000 });
await p.waitForFunction(() => document.querySelector(".admin-table") || (document.querySelector(".empty-state.show h3") || {}).textContent !== "加载中…", { timeout: 20000 }).catch(() => {});
r.quota.title = (await p.locator(".content h1").first().innerText().catch(() => "")).trim();
r.quota.newBtn = (await p.locator(".page-head .actions .btn-primary").count()) >= 1;
await p.locator(".page-head .actions .btn-primary").click();
await p.waitForSelector(".modal", { timeout: 6000 });
await p.locator(".modal .select").selectOption(teamId);
await p.locator(".modal .input").nth(1).fill("5"); // 单任务上限(field-row 第二个)
await p.locator(".modal-f .btn-primary").click();
await p.waitForTimeout(1200);
const pols = await (await fetch(`${API}/api/admin/quota-policies/?team=${teamId}`, { headers: authH })).json();
r.quota.created = pols.count >= 1;
await p.screenshot({ path: path.join(OUT, "p7-quota.png"), fullPage: true });
// UI 删除我们建的策略(清理)
if (r.quota.created) {
// 该团队是新建的,本页仅它一条相关;直接删页面上属于它的行(按团队名定位)
const rowDel = p.locator(`.admin-table tbody tr:has-text("P7Team${stamp}") .btn-ghost.danger`).first();
if (await rowDel.count()) {
await rowDel.click();
await p.waitForTimeout(1000);
}
const after = await (await fetch(`${API}/api/admin/quota-policies/?team=${teamId}`, { headers: authH })).json();
r.quota.deleted = after.count === 0;
}
await ctx.close();
await browser.close();
// 兜底清理:删该团队残留策略
const left = await (await fetch(`${API}/api/admin/quota-policies/?team=${teamId}`, { headers: authH })).json();
for (const pol of left.results || []) {
await fetch(`${API}/api/admin/quota-policies/${pol.id}/`, { method: "DELETE", headers: authH });
}
const checks = {
billingPage: r.billing.title === "计费审计" && r.billing.adjustBtn === true && r.billing.rows >= 1,
adjustPersisted: r.adjust.persisted === true,
quotaPage: r.quota.title === "额度策略" && r.quota.newBtn === true,
quotaCreated: r.quota.created === true,
quotaDeleted: r.quota.deleted === true,
zeroConsoleErrors: r.consoleErrors.length === 0,
};
r.checks = checks;
r.pass = Object.values(checks).every(Boolean);
console.log(JSON.stringify(r, null, 2));
fs.writeFileSync(path.join(OUT, "p7-summary.json"), JSON.stringify(r, null, 2));
process.exit(r.pass ? 0 : 1);