feat: bootstrap commercial AI drama platform
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
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("; "));
|
||||
}
|
||||
Reference in New Issue
Block a user