Files
ai-drama-platform/scripts/smoke-commercial-ops.mjs

198 lines
11 KiB
JavaScript

import assert from "node:assert/strict";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const studioScope = {
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
"x-project-id": "thunder-mouth"
};
const northstarScope = {
"x-organization-id": "org-northstar",
"x-workspace-id": "ws-northstar-main",
"x-project-id": "northstar-pilot"
};
async function request(path, headers = {}, options = {}) {
const response = await fetch(`${api}${path}`, {
...options,
headers: { "content-type": "application/json", ...headers, ...(options.headers || {}) }
});
const payload = await response.json().catch(() => ({}));
return { response, payload };
}
async function requestText(path, headers = {}, options = {}) {
const response = await fetch(`${api}${path}`, {
...options,
headers: { ...headers, ...(options.headers || {}) }
});
return { response, body: await response.text() };
}
function assertOk(result, label) {
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
function billingSnapshot(billing) {
return {
planName: billing.plan_name,
seatLimit: Number(billing.seat_limit),
storageGb: Number(billing.storage_gb),
monthlyClipQuota: Number(billing.monthly_clip_quota),
localRunnerOnly: Boolean(billing.local_runner_only),
cloudConnectorsRequireApproval: Boolean(billing.cloud_connectors_require_approval)
};
}
function quotaSnapshots(quotas) {
return (quotas || []).map((quota) => ({ id: quota.id, limitValue: Number(quota.limitValue ?? quota.limit_value) }));
}
function mutatedBilling(original, commercial) {
const reserved = Number(commercial.seat?.reserved || 0);
const clipUsed = Math.ceil(Math.max(...(commercial.quotas || []).filter((quota) => quota.metric === "clip").map((quota) => Number(quota.usedValue || 0)), 0));
const storageUsed = Math.ceil(Math.max(...(commercial.quotas || []).filter((quota) => quota.metric === "storage").map((quota) => Number(quota.usedValue || 0)), 0));
return {
planName: `${original.planName} · Smoke`,
seatLimit: Math.max(original.seatLimit + 1, reserved + 1),
storageGb: Math.max(original.storageGb + 1, storageUsed + 1),
monthlyClipQuota: Math.max(original.monthlyClipQuota + 1, clipUsed + 1),
localRunnerOnly: !original.localRunnerOnly,
cloudConnectorsRequireApproval: !original.cloudConnectorsRequireApproval
};
}
async function login(email) {
const result = await request("/api/auth/login", {}, {
method: "POST",
body: JSON.stringify({ email, password: "Demo@123456" })
});
assertOk(result, `${email} 登录`);
return { authorization: `Bearer ${result.payload.session.token}` };
}
async function patchBilling(headers, organizationId, body) {
return request(`/api/organizations/${encodeURIComponent(organizationId)}/billing`, headers, {
method: "PATCH",
body: JSON.stringify(body)
});
}
async function restoreOrganization(label, headers, organizationId, originalBilling, originalQuotas) {
const failures = [];
for (const quota of originalQuotas) {
const result = await request(`/api/organizations/${encodeURIComponent(organizationId)}/quotas/${encodeURIComponent(quota.id)}`, headers, {
method: "PATCH",
body: JSON.stringify({ limitValue: quota.limitValue })
});
if (!result.response.ok) failures.push(`${label} quota ${quota.id}: ${result.response.status} ${result.payload.error || ""}`);
}
const billingResult = await patchBilling(headers, organizationId, originalBilling);
if (!billingResult.response.ok) failures.push(`${label} billing: ${billingResult.response.status} ${billingResult.payload.error || ""}`);
if (failures.length) throw new Error(`恢复 ${label} 配置失败:${failures.join("; ")}`);
}
const ownerAuth = await login("producer@local.test");
const writerAuth = await login("writer@local.test");
const orgAdminAuth = await login("producer2@local.test");
const ownerHeaders = { ...ownerAuth, ...studioScope };
const writerHeaders = { ...writerAuth, ...studioScope };
const orgAdminHeaders = { ...orgAdminAuth, ...northstarScope };
let studioOriginal = null;
let studioOriginalQuotas = [];
let northstarOriginal = null;
let northstarOriginalQuotas = [];
try {
const studioBefore = assertOk(await request("/api/organizations/org-studio-lab/commercial", ownerHeaders), "系统管理员读取星河商业配置");
assert.ok(studioBefore.billing && studioBefore.seat && Array.isArray(studioBefore.quotas), "商业配置必须包含套餐、席位和工作区配额");
studioOriginal = billingSnapshot(studioBefore.billing);
studioOriginalQuotas = quotaSnapshots(studioBefore.quotas);
const writerCommercial = await request("/api/organizations/org-studio-lab/commercial", writerHeaders);
assert.equal(writerCommercial.response.status, 403, "普通编剧不应读取组织商业配置");
const writerBilling = await patchBilling(writerHeaders, "org-studio-lab", studioOriginal);
assert.equal(writerBilling.response.status, 403, "普通编剧不应修改组织套餐");
const usageBefore = assertOk(await request("/api/organizations/org-studio-lab/usage?from=2026-01-01&to=2026-12-31&page=1&pageSize=10", ownerHeaders), "系统管理员读取事件级用量");
assert.ok(Array.isArray(usageBefore.items), "用量明细必须返回事件列表");
assert.ok(usageBefore.pagination && Number.isInteger(usageBefore.pagination.total), "用量明细必须返回分页信息");
assert.ok(usageBefore.summary && Array.isArray(usageBefore.summary.byKind), "用量明细必须返回分类汇总");
assert.ok(usageBefore.facets && Array.isArray(usageBefore.facets.workspaces) && Array.isArray(usageBefore.facets.users), "用量明细必须返回筛选选项");
const workspaceUsage = assertOk(await request("/api/organizations/org-studio-lab/usage?workspaceId=ws-local-aidrama&from=2026-01-01&to=2026-12-31", ownerHeaders), "按工作区筛选用量");
assert.ok(workspaceUsage.items.every((item) => item.workspaceId === "ws-local-aidrama"), "工作区筛选不能返回其他工作区事件");
const costCenterUsage = assertOk(await request("/api/organizations/org-studio-lab/usage?costCenter=local-gpu&from=2026-01-01&to=2026-12-31", ownerHeaders), "按成本中心筛选用量");
assert.ok(costCenterUsage.items.every((item) => item.costCenter === "local-gpu"), "成本中心筛选不能返回其他成本中心事件");
const writerUsage = await request("/api/organizations/org-studio-lab/usage", writerHeaders);
assert.equal(writerUsage.response.status, 403, "普通编剧不应读取组织用量明细");
const writerUsageExport = await request("/api/organizations/org-studio-lab/usage/export?format=json", writerHeaders);
assert.equal(writerUsageExport.response.status, 403, "普通编剧不应导出组织用量明细");
const usageJsonExport = assertOk(await request("/api/organizations/org-studio-lab/usage/export?format=json&from=2026-01-01&to=2026-12-31", ownerHeaders), "导出用量 JSON");
assert.ok(Array.isArray(usageJsonExport.items), "JSON 用量导出必须包含事件列表");
const usageCsvExport = await requestText("/api/organizations/org-studio-lab/usage/export?format=csv&from=2026-01-01&to=2026-12-31", ownerHeaders);
assert.equal(usageCsvExport.response.status, 200, "导出用量 CSV 应成功");
assert.match(usageCsvExport.response.headers.get("content-type") || "", /text\/csv/, "用量 CSV content-type 不正确");
assert.match(usageCsvExport.body, /"id","created_at","workspace_id"/, "用量 CSV 表头不完整");
const studioMutation = mutatedBilling(studioOriginal, studioBefore);
const studioAfterUpdate = assertOk(await patchBilling(ownerHeaders, "org-studio-lab", studioMutation), "系统管理员更新星河套餐");
assert.equal(studioAfterUpdate.billing.plan_name, studioMutation.planName, "套餐名称未保存");
assert.equal(Number(studioAfterUpdate.billing.seat_limit), studioMutation.seatLimit, "席位上限未保存");
assert.equal(Boolean(studioAfterUpdate.billing.local_runner_only), studioMutation.localRunnerOnly, "本地 Runner 策略未保存");
assert.equal(Boolean(studioAfterUpdate.billing.cloud_connectors_require_approval), studioMutation.cloudConnectorsRequireApproval, "外部连接器审批策略未保存");
const studioClipQuota = studioBefore.quotas.find((quota) => quota.metric === "clip");
assert.ok(studioClipQuota, "星河组织缺少片段配额");
const nextClipLimit = Math.max(Number(studioClipQuota.usedValue || 0), Math.min(studioMutation.monthlyClipQuota, Number(studioClipQuota.limitValue || 0) + 1));
const clipQuotaUpdate = assertOk(await request(`/api/organizations/org-studio-lab/quotas/${encodeURIComponent(studioClipQuota.id)}`, ownerHeaders, {
method: "PATCH",
body: JSON.stringify({ limitValue: nextClipLimit })
}), "更新星河片段配额");
assert.equal(Number(clipQuotaUpdate.quotas.find((quota) => quota.id === studioClipQuota.id)?.limitValue), nextClipLimit, "片段配额未保存");
const overPlanQuota = await request(`/api/organizations/org-studio-lab/quotas/${encodeURIComponent(studioClipQuota.id)}`, ownerHeaders, {
method: "PATCH",
body: JSON.stringify({ limitValue: studioMutation.monthlyClipQuota + 1 })
});
assert.equal(overPlanQuota.response.status, 409, "工作区片段配额不能超过组织套餐");
assert.equal(overPlanQuota.payload.error, "quota_above_plan", "超套餐配额错误码不稳定");
if (Number(studioBefore.seat?.reserved || 0) > 1) {
const belowReserved = await patchBilling(ownerHeaders, "org-studio-lab", { seatLimit: Number(studioBefore.seat.reserved) - 1 });
assert.equal(belowReserved.response.status, 409, "席位上限不能低于已占用和待处理邀请");
assert.equal(belowReserved.payload.error, "seat_limit_below_reserved", "席位冲突错误码不稳定");
}
const northstarBefore = assertOk(await request("/api/organizations/org-northstar/commercial", orgAdminHeaders), "组织管理员读取北辰商业配置");
assert.ok(northstarBefore.billing && northstarBefore.seat, "组织管理员商业配置返回不完整");
northstarOriginal = billingSnapshot(northstarBefore.billing);
northstarOriginalQuotas = quotaSnapshots(northstarBefore.quotas);
assert.ok((await request("/api/context", orgAdminHeaders)).payload.context.permissions.includes("billing:manage"), "组织管理员缺少套餐管理权限");
const northstarMutation = mutatedBilling(northstarOriginal, northstarBefore);
const northstarAfterUpdate = assertOk(await patchBilling(orgAdminHeaders, "org-northstar", northstarMutation), "组织管理员更新北辰套餐");
assert.equal(northstarAfterUpdate.billing.plan_name, northstarMutation.planName, "组织管理员套餐名称未保存");
assert.equal(Number(northstarAfterUpdate.billing.seat_limit), northstarMutation.seatLimit, "组织管理员席位上限未保存");
console.log(`commercial ops smoke passed: ${api}`);
} finally {
const failures = [];
if (studioOriginal) {
try {
await restoreOrganization("星河组织", ownerHeaders, "org-studio-lab", studioOriginal, studioOriginalQuotas);
} catch (error) {
failures.push(error.message);
}
}
if (northstarOriginal) {
try {
await restoreOrganization("北辰组织", orgAdminHeaders, "org-northstar", northstarOriginal, northstarOriginalQuotas);
} catch (error) {
failures.push(error.message);
}
}
if (failures.length) throw new Error(failures.join("; "));
}