// 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);