后端:Invitation.kind(join_team/create_team)+ team 可空 + migration;RegisterSerializer 邀请码必填, create_team 开新团队当 owner+发试用额度、join_team 幂等入伙不重复发额度,删「空码开团队」; validate-invite 免登录验码端点;团管发码恒 join_team;InvitationSerializer 增 kind/team_name。 前端:注册页码优先(验过才展开;create_team 填团队名 / join_team 只读团队名,不暴露团队列表)、 邀请链接 ?invite= 自动验码、api.validateInvite、验码行/状态提示样式(仅 token)。 测试:accounts 22/22 单测过(RegisterInviteCodeTests + ValidateInviteTests); 无头 e2e _admin-p1.mjs 全断言过 + 0 console error;tsc+build 绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
144 lines
6.5 KiB
JavaScript
144 lines
6.5 KiB
JavaScript
// Phase 1 e2e:注册码优先流程 —— 验码才展开、create_team 开团队 / join_team 加入(只读团队名)、错码拦截、URL 自动验码。
|
|
// 沿用 _wave35-auth.mjs 模式:headless + 真点击 + locator 断言 + 截图 + 0 console error。
|
|
// create_team 码经 wrapper 用 manage.py shell 铸好,传 CT1/CT2 env;join_team 码经 airshelf owner API 发。
|
|
// 跑法:见 _admin-p1.run.sh(wrapper 负责铸码 + 传 env)。
|
|
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 CT1 = process.env.CT1;
|
|
const CT2 = process.env.CT2;
|
|
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 }),
|
|
});
|
|
if (!res.ok) throw new Error(`login ${u} -> ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
const r = { createTeam: {}, joinTeam: {}, invalid: {}, urlAuto: {}, consoleErrors: [], pass: false };
|
|
|
|
if (!CT1 || !CT2) {
|
|
console.error("缺 CT1/CT2(create_team 码),请用 wrapper 跑");
|
|
process.exit(2);
|
|
}
|
|
|
|
// 用 airshelf owner 发一个 join_team 码(返回含 team_name)
|
|
const owner = await apiLogin("airshelf", "Restraint2026");
|
|
const genRes = await fetch(`${API}/api/auth/team/invitations/`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: `Token ${owner.token}` },
|
|
body: JSON.stringify({ role: "member" }),
|
|
});
|
|
const gen = await genRes.json();
|
|
const JT = gen.code;
|
|
r.joinTeam.expectedTeam = gen.team_name || owner.team?.name || "";
|
|
|
|
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}`));
|
|
};
|
|
|
|
// ── A. create_team:验码 → 展开团队名输入 → 注册 → 进应用 ──
|
|
{
|
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
|
const p = await ctx.newPage(); hook(p, "createTeam");
|
|
await p.goto(BASE + "/register", { waitUntil: "load" });
|
|
await p.waitForTimeout(400);
|
|
r.createTeam.usernameHiddenBeforeVerify = (await p.locator("#reg-username").count()) === 0;
|
|
await p.fill("#reg-invite", CT1);
|
|
await p.click(".invite-verify-btn");
|
|
await p.waitForSelector("#reg-team", { timeout: 8000 }).catch(() => {});
|
|
await p.waitForTimeout(300);
|
|
r.createTeam.teamInputShown = (await p.locator("#reg-team").count()) === 1;
|
|
r.createTeam.okNote = (await p.locator(".field-note.ok").count()) >= 1;
|
|
await p.screenshot({ path: path.join(OUT, "p1-createteam-verified.png") });
|
|
await p.fill("#reg-team", `E2E团队${stamp}`);
|
|
await p.fill("#reg-username", `p1-ct-${stamp}`);
|
|
await p.fill("#reg-pwd", "strong-pass-1");
|
|
await p.fill("#reg-pwd2", "strong-pass-1");
|
|
await p.click("button.btn-cta");
|
|
try { await p.waitForFunction(() => !location.pathname.startsWith("/register"), { timeout: 15000 }); r.createTeam.registered = true; }
|
|
catch { r.createTeam.registered = false; }
|
|
await p.waitForTimeout(400);
|
|
r.createTeam.url = p.url();
|
|
await ctx.close();
|
|
}
|
|
|
|
// ── B. join_team:验码 → 只读团队名(不让填团队名)→ 注册 → 进应用 ──
|
|
{
|
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
|
const p = await ctx.newPage(); hook(p, "joinTeam");
|
|
await p.goto(BASE + "/register", { waitUntil: "load" });
|
|
await p.waitForTimeout(400);
|
|
await p.fill("#reg-invite", JT);
|
|
await p.click(".invite-verify-btn");
|
|
await p.waitForSelector("#reg-username", { timeout: 8000 }).catch(() => {});
|
|
await p.waitForTimeout(300);
|
|
r.joinTeam.readonlyTeam = await p.locator(".field-input-wrap.is-readonly input").inputValue().catch(() => "");
|
|
r.joinTeam.teamInputAbsent = (await p.locator("#reg-team").count()) === 0;
|
|
await p.screenshot({ path: path.join(OUT, "p1-jointeam-verified.png") });
|
|
await p.fill("#reg-username", `p1-jt-${stamp}`);
|
|
await p.fill("#reg-pwd", "strong-pass-1");
|
|
await p.fill("#reg-pwd2", "strong-pass-1");
|
|
await p.click("button.btn-cta");
|
|
try { await p.waitForFunction(() => !location.pathname.startsWith("/register"), { timeout: 15000 }); r.joinTeam.registered = true; }
|
|
catch { r.joinTeam.registered = false; }
|
|
await p.waitForTimeout(400);
|
|
await ctx.close();
|
|
}
|
|
|
|
// ── C. 错码:不展开表单 + 报错 ──
|
|
{
|
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
|
const p = await ctx.newPage(); hook(p, "invalid");
|
|
await p.goto(BASE + "/register", { waitUntil: "load" });
|
|
await p.waitForTimeout(400);
|
|
await p.fill("#reg-invite", "TEAM-ZZZZ-ZZZZ");
|
|
await p.click(".invite-verify-btn");
|
|
await p.waitForTimeout(900);
|
|
r.invalid.errNote = (await p.locator(".field-note.err").count()) >= 1;
|
|
r.invalid.usernameHidden = (await p.locator("#reg-username").count()) === 0;
|
|
await p.screenshot({ path: path.join(OUT, "p1-invalid-code.png") });
|
|
await ctx.close();
|
|
}
|
|
|
|
// ── D. URL ?invite= 自动验码 ──
|
|
{
|
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
|
const p = await ctx.newPage(); hook(p, "urlAuto");
|
|
await p.goto(BASE + "/register?invite=" + CT2, { waitUntil: "load" });
|
|
await p.waitForSelector(".field-note.ok", { timeout: 8000 }).catch(() => {});
|
|
await p.waitForTimeout(400);
|
|
r.urlAuto.autoVerified = (await p.locator(".field-note.ok").count()) >= 1;
|
|
r.urlAuto.codePrefilled = (await p.locator("#reg-invite").inputValue().catch(() => "")) === CT2;
|
|
await p.screenshot({ path: path.join(OUT, "p1-url-autovalidate.png") });
|
|
await ctx.close();
|
|
}
|
|
|
|
await browser.close();
|
|
|
|
const checks = {
|
|
ctVerifyRevealsTeam: r.createTeam.usernameHiddenBeforeVerify && r.createTeam.teamInputShown && r.createTeam.okNote,
|
|
ctRegistered: r.createTeam.registered === true,
|
|
jtReadonlyTeamShown: !!r.joinTeam.readonlyTeam && r.joinTeam.teamInputAbsent,
|
|
jtRegistered: r.joinTeam.registered === true,
|
|
invalidBlocks: r.invalid.errNote === true && r.invalid.usernameHidden === true,
|
|
urlAutoValidate: r.urlAuto.autoVerified === true && r.urlAuto.codePrefilled === 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, "p1-summary.json"), JSON.stringify(r, null, 2));
|
|
process.exit(r.pass ? 0 : 1);
|