Files
seaislee1209andClaude Opus 4.8 3f8547564c feat(admin): Phase 1 注册邀请码逻辑 — 码必填 + kind 分流 + validate-invite + 注册页码优先重做
后端: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>
2026-06-19 21:07:03 +08:00

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