Add customer contract clearance console
This commit is contained in:
@@ -13,6 +13,7 @@ const scripts = [
|
||||
"smoke:invitations",
|
||||
"smoke:commercial-ops",
|
||||
"smoke:commercial-approvals",
|
||||
"smoke:contracts",
|
||||
"smoke:entitlements",
|
||||
"smoke:plan-templates",
|
||||
"smoke:billing-ledger",
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { dbAll, dbRun, withTransaction } from "../server/db.mjs";
|
||||
|
||||
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
|
||||
const runId = Date.now();
|
||||
const organizationId = "org-studio-lab";
|
||||
const workspaceId = "ws-local-aidrama";
|
||||
const projectId = "thunder-mouth";
|
||||
const northstarOrganizationId = "org-northstar";
|
||||
const customerCode = `SMOKE-CUST-${runId}`;
|
||||
const contractNumber = `SMOKE-CTR-${runId}`;
|
||||
const expiredContractNumber = `SMOKE-EXP-${runId}`;
|
||||
let customerId = "";
|
||||
let contractId = "";
|
||||
let expiredContractId = "";
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(`${api}${path}`, {
|
||||
...options,
|
||||
headers: { "content-type": "application/json", ...(options.headers || {}) }
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
return { response, payload };
|
||||
}
|
||||
|
||||
function expectOk(result, label) {
|
||||
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
function expectStatus(result, status, label) {
|
||||
assert.equal(result.response.status, status, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
async function login(email) {
|
||||
const result = expectOk(await request("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password: "Demo@123456" })
|
||||
}), `${email} login`);
|
||||
return { authorization: `Bearer ${result.session.token}` };
|
||||
}
|
||||
|
||||
function futureIso(days) {
|
||||
return new Date(Date.now() + days * 86400000).toISOString();
|
||||
}
|
||||
|
||||
function pastIso(days) {
|
||||
return new Date(Date.now() - days * 86400000).toISOString();
|
||||
}
|
||||
|
||||
function contractBody(number, title, overrides = {}) {
|
||||
return {
|
||||
customerId,
|
||||
contractNumber: number,
|
||||
title,
|
||||
contractType: "production_license",
|
||||
status: "active",
|
||||
approvalStatus: "approved",
|
||||
effectiveAt: pastIso(10),
|
||||
expiresAt: futureIso(365),
|
||||
signedAt: pastIso(9),
|
||||
currency: "CNY",
|
||||
amount: 0,
|
||||
licenseScope: {
|
||||
channels: ["local-file", "private-delivery-portal"],
|
||||
territories: ["CN"],
|
||||
deliverables: ["vertical-video", "delivery-manifest"],
|
||||
platforms: ["private-preview", "internal-review"],
|
||||
exclusive: false,
|
||||
commercialUse: true,
|
||||
language: ["zh-CN"]
|
||||
},
|
||||
rights: {
|
||||
originalStory: true,
|
||||
derivativeProduction: true,
|
||||
aiGeneratedAssetsAllowed: true,
|
||||
voiceCloneAllowed: false,
|
||||
fixedVoiceRequired: true,
|
||||
singleFramePolicyRequired: true
|
||||
},
|
||||
deliveryTerms: { customerPortalAllowed: true, requireClearanceCertificate: true, maxAccessDays: 14 },
|
||||
evidence: { contractRef: `smoke://contract/${number}`, rightsEvidenceRef: `smoke://rights/${number}`, signedCopyPath: `storage/contracts/smoke/${number}.json` },
|
||||
risk: { paidCloudAllowed: false, publicModelAllowed: false, rightsReview: "passed" },
|
||||
approvalNote: "smoke test contract",
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
const ownerAuth = await login("producer@local.test");
|
||||
const writerAuth = await login("writer@local.test");
|
||||
const northstarAuth = await login("producer2@local.test");
|
||||
const headers = { ...ownerAuth, "x-organization-id": organizationId, "x-workspace-id": workspaceId, "x-project-id": projectId };
|
||||
const writerHeaders = { ...writerAuth, "x-organization-id": organizationId, "x-workspace-id": workspaceId, "x-project-id": projectId };
|
||||
const northstarHeaders = { ...northstarAuth, "x-organization-id": northstarOrganizationId, "x-workspace-id": "ws-northstar-main", "x-project-id": "northstar-pilot" };
|
||||
|
||||
try {
|
||||
const seeded = expectOk(await request(`/api/organizations/${organizationId}/contracts`, { headers }), "owner lists seeded contracts");
|
||||
assert.ok(seeded.customers.some((customer) => customer.id === "cust-xinghe-release"), "seeded customer must be visible");
|
||||
assert.ok(seeded.contracts.some((contract) => contract.id === "contract-xh-rain-night-2026"), "seeded contract must be visible");
|
||||
assert.ok(seeded.projects.some((project) => project.id === projectId), "current project must be selectable for binding");
|
||||
|
||||
const seededClearance = expectOk(await request(`/api/organizations/${organizationId}/contracts/clearance?projectId=${encodeURIComponent(projectId)}&channelKind=private-delivery-portal`, { headers }), "seeded contract clearance");
|
||||
assert.equal(seededClearance.clearance.status, "pass", "seeded thunder-mouth contract clearance must pass");
|
||||
assert.ok(seededClearance.clearance.bindings.some((binding) => binding.contract.id === "contract-xh-rain-night-2026"), "clearance must include seeded contract binding");
|
||||
|
||||
expectStatus(await request(`/api/organizations/${organizationId}/contracts`, { headers: writerHeaders }), 403, "writer cannot list contracts");
|
||||
expectStatus(await request(`/api/organizations/${organizationId}/contracts/contract-ns-pilot-2026/bind-project`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ projectId, usageMode: "supplemental-rights" })
|
||||
}), 404, "studio owner cannot bind northstar contract");
|
||||
|
||||
const northstar = expectOk(await request(`/api/organizations/${northstarOrganizationId}/contracts`, { headers: northstarHeaders }), "northstar lists own contracts");
|
||||
assert.ok(northstar.contracts.some((contract) => contract.id === "contract-ns-pilot-2026"), "northstar seeded contract must be visible inside its organization");
|
||||
assert.ok(!northstar.contracts.some((contract) => contract.id === "contract-xh-rain-night-2026"), "northstar must not see studio contracts");
|
||||
|
||||
const customer = expectOk(await request(`/api/organizations/${organizationId}/customers`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
name: `烟测发行客户 ${runId}`,
|
||||
legalName: `烟测发行客户 ${runId} 有限公司`,
|
||||
code: customerCode,
|
||||
customerType: "distributor",
|
||||
status: "active",
|
||||
industry: "AI 短剧发行",
|
||||
region: "CN",
|
||||
billingEmail: "smoke@example.local",
|
||||
tags: ["smoke", "contracts"],
|
||||
notes: "合同工作台 smoke 自动创建"
|
||||
})
|
||||
}), "create customer");
|
||||
customerId = customer.customer.id;
|
||||
assert.equal(customer.customer.code, customerCode, "customer code must be persisted");
|
||||
|
||||
const contact = expectOk(await request(`/api/organizations/${organizationId}/customers/${encodeURIComponent(customerId)}/contacts`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ name: "烟测制片窗口", role: "交付验收", email: "acceptance@example.local", isPrimary: true })
|
||||
}), "create customer contact");
|
||||
assert.equal(contact.customer.contacts[0].isPrimary, true, "primary contact must be persisted");
|
||||
|
||||
const contract = expectOk(await request(`/api/organizations/${organizationId}/contracts`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(contractBody(contractNumber, `烟测商用授权 ${runId}`))
|
||||
}), "create active approved contract");
|
||||
contractId = contract.contract.id;
|
||||
assert.equal(contract.contract.contractNumber, contractNumber, "contract number must be persisted");
|
||||
|
||||
const bound = expectOk(await request(`/api/organizations/${organizationId}/contracts/${encodeURIComponent(contractId)}/bind-project`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ projectId, usageMode: "supplemental-rights", status: "active", notes: "smoke supplemental rights" })
|
||||
}), "bind active contract to project");
|
||||
assert.equal(bound.binding.projectId, projectId, "binding project must be persisted");
|
||||
assert.equal(bound.clearance.status, "pass", "valid supplemental contract must not block clearance");
|
||||
|
||||
const expired = expectOk(await request(`/api/organizations/${organizationId}/contracts`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(contractBody(expiredContractNumber, `烟测过期授权 ${runId}`, { expiresAt: pastIso(1) }))
|
||||
}), "create expired contract");
|
||||
expiredContractId = expired.contract.id;
|
||||
expectOk(await request(`/api/organizations/${organizationId}/contracts/${encodeURIComponent(expiredContractId)}/bind-project`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ projectId, usageMode: "supplemental-rights", status: "active", notes: "smoke expired rights" })
|
||||
}), "bind expired contract");
|
||||
const blocked = expectOk(await request(`/api/organizations/${organizationId}/contracts/clearance?projectId=${encodeURIComponent(projectId)}&channelKind=private-delivery-portal`, { headers }), "expired contract blocks clearance");
|
||||
assert.equal(blocked.clearance.status, "blocked", "expired active binding must block project contract clearance");
|
||||
assert.ok(blocked.clearance.blockers.some((item) => item.code === "contract_expired"), "clearance blockers must include contract_expired");
|
||||
|
||||
const events = dbAll("SELECT event_type FROM contract_license_events WHERE organization_id = ? AND (contract_id = ? OR contract_id = ?)", [organizationId, contractId, expiredContractId]);
|
||||
assert.ok(events.some((event) => event.event_type === "contract.created"), "contract creation must write license event");
|
||||
assert.ok(events.some((event) => event.event_type === "contract.project_binding.created"), "project binding must write license event");
|
||||
|
||||
console.log(`contracts smoke passed: ${contractId} / ${expiredContractId}`);
|
||||
} finally {
|
||||
withTransaction(() => {
|
||||
for (const id of [contractId, expiredContractId].filter(Boolean)) {
|
||||
dbRun("DELETE FROM contract_license_events WHERE contract_id = ?", [id]);
|
||||
dbRun("DELETE FROM contract_project_bindings WHERE contract_id = ?", [id]);
|
||||
dbRun("DELETE FROM audit_logs WHERE target_id = ? OR metadata_json LIKE ?", [id, `%${id}%`]);
|
||||
dbRun("DELETE FROM production_contracts WHERE id = ?", [id]);
|
||||
}
|
||||
if (customerId) {
|
||||
dbRun("DELETE FROM customer_contacts WHERE customer_id = ?", [customerId]);
|
||||
dbRun("DELETE FROM contract_license_events WHERE customer_id = ?", [customerId]);
|
||||
dbRun("DELETE FROM audit_logs WHERE target_id = ? OR metadata_json LIKE ?", [customerId, `%${customerId}%`]);
|
||||
dbRun("DELETE FROM customers WHERE id = ?", [customerId]);
|
||||
}
|
||||
dbRun("DELETE FROM customers WHERE organization_id = ? AND code = ?", [organizationId, customerCode]);
|
||||
dbRun("DELETE FROM production_contracts WHERE organization_id = ? AND contract_number IN (?, ?)", [organizationId, contractNumber, expiredContractNumber]);
|
||||
});
|
||||
}
|
||||
@@ -15,6 +15,8 @@ const lastFramePath = `${sourceRoot}/actual-last-frame.jpg`;
|
||||
const manifestPath = `${sourceRoot}/manifest.json`;
|
||||
const batchId = `delivery-clearance-batch-${runId}`;
|
||||
const voiceId = `voice-clearance-${runId}`;
|
||||
const customerId = `cust-clearance-${runId}`;
|
||||
const contractId = `contract-clearance-${runId}`;
|
||||
|
||||
const createdAssetIds = [];
|
||||
let deliveryId = "";
|
||||
@@ -85,6 +87,11 @@ async function bind(headers, assetId, shotId, usageRole) {
|
||||
|
||||
function cleanup() {
|
||||
withTransaction(() => {
|
||||
dbRun("DELETE FROM contract_license_events WHERE contract_id = ? OR customer_id = ? OR project_id = ?", [contractId, customerId, projectId]);
|
||||
dbRun("DELETE FROM contract_project_bindings WHERE contract_id = ? OR project_id = ?", [contractId, projectId]);
|
||||
dbRun("DELETE FROM production_contracts WHERE id = ?", [contractId]);
|
||||
dbRun("DELETE FROM customer_contacts WHERE customer_id = ?", [customerId]);
|
||||
dbRun("DELETE FROM customers WHERE id = ?", [customerId]);
|
||||
dbRun("DELETE FROM delivery_clearance_reports WHERE project_id = ?", [projectId]);
|
||||
dbRun("DELETE FROM delivery_releases WHERE project_id = ?", [projectId]);
|
||||
dbRun("DELETE FROM delivery_batch_items WHERE batch_id = ?", [batchId]);
|
||||
@@ -115,6 +122,44 @@ function cleanup() {
|
||||
});
|
||||
}
|
||||
|
||||
function seedContractClearance() {
|
||||
const timestamp = new Date().toISOString();
|
||||
dbRun(
|
||||
"INSERT OR IGNORE INTO customers(id, organization_id, name, legal_name, code, customer_type, status, industry, region, billing_email, tax_id, notes, tags_json, metadata_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'internal', 'active', 'AI 短剧', 'CN', 'clearance@example.local', '', 'delivery clearance smoke customer', '[\"smoke\",\"clearance\"]', '{}', ?, ?, ?)",
|
||||
[customerId, organizationId, `清算烟测客户 ${runId}`, `清算烟测客户 ${runId} 有限公司`, `CLR-${runId}`, producerUserId(), timestamp, timestamp]
|
||||
);
|
||||
dbRun(
|
||||
"INSERT OR IGNORE INTO production_contracts(id, organization_id, customer_id, contract_number, title, contract_type, status, approval_status, effective_at, expires_at, signed_at, currency, amount, license_scope_json, rights_json, delivery_terms_json, evidence_json, risk_json, approval_note, created_by, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'production_license', 'active', 'approved', ?, ?, ?, 'CNY', 0, ?, ?, ?, ?, ?, 'smoke clearance contract', ?, ?, ?, ?)",
|
||||
[
|
||||
contractId,
|
||||
organizationId,
|
||||
customerId,
|
||||
`CLR-CTR-${runId}`,
|
||||
`清算烟测授权 ${runId}`,
|
||||
new Date(Date.now() - 86400000).toISOString(),
|
||||
new Date(Date.now() + 365 * 86400000).toISOString(),
|
||||
timestamp,
|
||||
JSON.stringify({ channels: ["local-file", "private-delivery-portal"], territories: ["CN"], deliverables: ["vertical-video", "delivery-manifest"], platforms: ["internal-review"], exclusive: false, commercialUse: true, language: ["zh-CN"] }),
|
||||
JSON.stringify({ originalStory: true, derivativeProduction: true, aiGeneratedAssetsAllowed: true, voiceCloneAllowed: false, fixedVoiceRequired: true, singleFramePolicyRequired: true }),
|
||||
JSON.stringify({ customerPortalAllowed: true, requireClearanceCertificate: true }),
|
||||
JSON.stringify({ contractRef: `smoke://contract/${runId}`, rightsEvidenceRef: `smoke://rights/${runId}`, signedCopyPath: `storage/contracts/smoke/${runId}.json` }),
|
||||
JSON.stringify({ paidCloudAllowed: false, publicModelAllowed: false, rightsReview: "passed" }),
|
||||
producerUserId(),
|
||||
producerUserId(),
|
||||
timestamp,
|
||||
timestamp
|
||||
]
|
||||
);
|
||||
dbRun(
|
||||
"INSERT OR IGNORE INTO contract_project_bindings(id, organization_id, workspace_id, project_id, contract_id, customer_id, usage_mode, status, notes, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'primary-license', 'active', 'delivery clearance smoke binding', ?, ?, ?)",
|
||||
[`contract-binding-clearance-${runId}`, organizationId, workspaceId, projectId, contractId, customerId, producerUserId(), timestamp, timestamp]
|
||||
);
|
||||
}
|
||||
|
||||
function producerUserId() {
|
||||
return dbGet("SELECT id FROM users WHERE email = ?", ["producer@local.test"])?.id || "u-owner";
|
||||
}
|
||||
|
||||
try {
|
||||
cleanup();
|
||||
const ownerBase = await login("producer@local.test");
|
||||
@@ -236,6 +281,7 @@ try {
|
||||
notes: "清算烟测批准"
|
||||
})
|
||||
}), "approve knowledge source");
|
||||
seedContractClearance();
|
||||
|
||||
const passedClearance = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/clearance`, { method: "POST", headers: scopedHeaders, body: "{}" }), 201, "run passed clearance");
|
||||
assert.equal(passedClearance.clearance.status, "pass", "all commercial gates should pass after evidence is fixed");
|
||||
|
||||
@@ -19,6 +19,8 @@ const deliveryDraftVersion = `portal-draft-${runId}`;
|
||||
const batchId = `smoke-portal-batch-${runId}`;
|
||||
const deliveryLabel = `smoke-portal-delivery-${runId}`;
|
||||
const draftDeliveryLabel = `smoke-portal-draft-${runId}`;
|
||||
const customerId = `cust-portal-${runId}`;
|
||||
const contractId = `contract-portal-${runId}`;
|
||||
const auditTargetIds = new Set();
|
||||
const accessLinkIds = [];
|
||||
const releaseIds = [];
|
||||
@@ -89,6 +91,40 @@ const crossOrganizationHeaders = {
|
||||
const producerUser = dbGet("SELECT id FROM users WHERE email = ?", ["producer@local.test"]);
|
||||
assert.ok(producerUser?.id, "producer user must exist");
|
||||
|
||||
function seedContractClearance() {
|
||||
const timestamp = new Date().toISOString();
|
||||
dbRun(
|
||||
"INSERT OR IGNORE INTO customers(id, organization_id, name, legal_name, code, customer_type, status, industry, region, billing_email, tax_id, notes, tags_json, metadata_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'distributor', 'active', 'AI 短剧发行', 'CN', 'portal@example.local', '', 'delivery portal smoke customer', '[\"smoke\",\"portal\"]', '{}', ?, ?, ?)",
|
||||
[customerId, organizationId, `门户烟测客户 ${runId}`, `门户烟测客户 ${runId} 有限公司`, `PORTAL-${runId}`, producerUser.id, timestamp, timestamp]
|
||||
);
|
||||
dbRun(
|
||||
"INSERT OR IGNORE INTO production_contracts(id, organization_id, customer_id, contract_number, title, contract_type, status, approval_status, effective_at, expires_at, signed_at, currency, amount, license_scope_json, rights_json, delivery_terms_json, evidence_json, risk_json, approval_note, created_by, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'production_license', 'active', 'approved', ?, ?, ?, 'CNY', 0, ?, ?, ?, ?, ?, 'smoke portal contract', ?, ?, ?, ?)",
|
||||
[
|
||||
contractId,
|
||||
organizationId,
|
||||
customerId,
|
||||
`PORTAL-CTR-${runId}`,
|
||||
`门户烟测授权 ${runId}`,
|
||||
new Date(Date.now() - 86400000).toISOString(),
|
||||
new Date(Date.now() + 365 * 86400000).toISOString(),
|
||||
timestamp,
|
||||
JSON.stringify({ channels: ["local-file", "private-delivery-portal"], territories: ["CN"], deliverables: ["vertical-video", "delivery-manifest"], platforms: ["private-preview", "internal-review"], exclusive: false, commercialUse: true, language: ["zh-CN"] }),
|
||||
JSON.stringify({ originalStory: true, derivativeProduction: true, aiGeneratedAssetsAllowed: true, voiceCloneAllowed: false, fixedVoiceRequired: true, singleFramePolicyRequired: true }),
|
||||
JSON.stringify({ customerPortalAllowed: true, requireClearanceCertificate: true, maxAccessDays: 7 }),
|
||||
JSON.stringify({ contractRef: `smoke://portal-contract/${runId}`, rightsEvidenceRef: `smoke://portal-rights/${runId}`, signedCopyPath: `storage/contracts/smoke-portal/${runId}.json` }),
|
||||
JSON.stringify({ paidCloudAllowed: false, publicModelAllowed: false, rightsReview: "passed" }),
|
||||
producerUser.id,
|
||||
producerUser.id,
|
||||
timestamp,
|
||||
timestamp
|
||||
]
|
||||
);
|
||||
dbRun(
|
||||
"INSERT OR IGNORE INTO contract_project_bindings(id, organization_id, workspace_id, project_id, contract_id, customer_id, usage_mode, status, notes, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'primary-license', 'active', 'delivery portal smoke binding', ?, ?, ?)",
|
||||
[`contract-binding-portal-${runId}`, organizationId, workspaceId, projectId, contractId, customerId, producerUser.id, timestamp, timestamp]
|
||||
);
|
||||
}
|
||||
|
||||
let channelId = "";
|
||||
let publishedReleaseId = "";
|
||||
let primaryToken = "";
|
||||
@@ -144,6 +180,7 @@ try {
|
||||
dbRun("UPDATE deliveries SET status = 'approved', active_batch_id = ?, approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?", [batchId, producerUser.id, timestamp, timestamp, delivery.delivery.id]);
|
||||
expectOk(await request("/api/production/reviews", { headers: producerHeaders }), "ensure portal QA reviews");
|
||||
dbRun("UPDATE reviews SET status = 'approved', score = 100, decision_by = ?, decision_at = ?, evidence_json = ?, updated_at = ? WHERE project_id = ?", [producerUser.id, timestamp, JSON.stringify({ source: "smoke-delivery-portal", checkedAt: timestamp }), timestamp, projectId]);
|
||||
seedContractClearance();
|
||||
seeded = true;
|
||||
|
||||
const release = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(delivery.delivery.id)}/releases`, {
|
||||
@@ -260,6 +297,11 @@ try {
|
||||
assert.ok(Number(eventCount?.count || 0) >= 6, "portal view/download/rejection activity must be audited");
|
||||
console.log(`delivery portal smoke passed: ${publishedReleaseId} / ${accessLinkIds.length} links`);
|
||||
} finally {
|
||||
dbRun("DELETE FROM contract_license_events WHERE contract_id = ? OR customer_id = ? OR project_id = ?", [contractId, customerId, projectId]);
|
||||
dbRun("DELETE FROM contract_project_bindings WHERE contract_id = ? OR project_id = ?", [contractId, projectId]);
|
||||
dbRun("DELETE FROM production_contracts WHERE id = ?", [contractId]);
|
||||
dbRun("DELETE FROM customer_contacts WHERE customer_id = ?", [customerId]);
|
||||
dbRun("DELETE FROM customers WHERE id = ?", [customerId]);
|
||||
for (const targetId of auditTargetIds) dbRun("DELETE FROM audit_logs WHERE target_id = ?", [targetId]);
|
||||
for (const linkId of accessLinkIds) dbRun("DELETE FROM delivery_access_events WHERE link_id = ?", [linkId]);
|
||||
for (const linkId of accessLinkIds) dbRun("DELETE FROM delivery_access_links WHERE id = ?", [linkId]);
|
||||
|
||||
@@ -23,23 +23,36 @@ function expectOk(result, label) {
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function login(email, label) {
|
||||
let lastResult = null;
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
const result = await request("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password: "Demo@123456" })
|
||||
});
|
||||
if (result.response.status !== 429) return expectOk(result, label);
|
||||
lastResult = result;
|
||||
const retryAfter = Number(result.response.headers.get("retry-after") || result.payload.retryAfter || 1);
|
||||
await sleep(Math.min(65, Math.max(1, retryAfter)) * 1000);
|
||||
}
|
||||
return expectOk(lastResult, label);
|
||||
}
|
||||
|
||||
let created = false;
|
||||
let jobId = "";
|
||||
try {
|
||||
const login = expectOk(await request("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" })
|
||||
}), "owner login");
|
||||
const ownerLogin = await login("producer@local.test", "owner login");
|
||||
const headers = {
|
||||
authorization: `Bearer ${login.session.token}`,
|
||||
authorization: `Bearer ${ownerLogin.session.token}`,
|
||||
"x-organization-id": organizationId,
|
||||
"x-workspace-id": workspaceId,
|
||||
"x-project-id": "thunder-mouth"
|
||||
};
|
||||
const writerLogin = expectOk(await request("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "writer@local.test", password: "Demo@123456" })
|
||||
}), "writer login");
|
||||
const writerLogin = await login("writer@local.test", "writer login");
|
||||
|
||||
const creation = expectOk(await request("/api/projects", {
|
||||
method: "POST",
|
||||
|
||||
@@ -17,6 +17,8 @@ const version = `smoke-${runId}`;
|
||||
const sourcePath = `${sourceRoot}/clip.mp4`;
|
||||
const lastFramePath = `${sourceRoot}/actual-last-frame.jpg`;
|
||||
const manifestPath = `${sourceRoot}/manifest.json`;
|
||||
const customerId = `cust-release-${runId}`;
|
||||
const contractId = `contract-release-${runId}`;
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(`${api}${path}`, {
|
||||
@@ -67,6 +69,41 @@ const reviewerHeaders = {
|
||||
|
||||
const producerUser = dbGet("SELECT id FROM users WHERE email = ?", ["producer@local.test"]);
|
||||
assert.ok(producerUser?.id, "producer user must exist");
|
||||
|
||||
function seedContractClearance() {
|
||||
const timestamp = new Date().toISOString();
|
||||
dbRun(
|
||||
"INSERT OR IGNORE INTO customers(id, organization_id, name, legal_name, code, customer_type, status, industry, region, billing_email, tax_id, notes, tags_json, metadata_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'internal', 'active', 'AI 短剧', 'CN', 'release@example.local', '', 'release workflow smoke customer', '[\"smoke\",\"release\"]', '{}', ?, ?, ?)",
|
||||
[customerId, organizationId, `发布烟测客户 ${runId}`, `发布烟测客户 ${runId} 有限公司`, `REL-${runId}`, producerUser.id, timestamp, timestamp]
|
||||
);
|
||||
dbRun(
|
||||
"INSERT OR IGNORE INTO production_contracts(id, organization_id, customer_id, contract_number, title, contract_type, status, approval_status, effective_at, expires_at, signed_at, currency, amount, license_scope_json, rights_json, delivery_terms_json, evidence_json, risk_json, approval_note, created_by, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'production_license', 'active', 'approved', ?, ?, ?, 'CNY', 0, ?, ?, ?, ?, ?, 'smoke release contract', ?, ?, ?, ?)",
|
||||
[
|
||||
contractId,
|
||||
organizationId,
|
||||
customerId,
|
||||
`REL-CTR-${runId}`,
|
||||
`发布烟测授权 ${runId}`,
|
||||
new Date(Date.now() - 86400000).toISOString(),
|
||||
new Date(Date.now() + 365 * 86400000).toISOString(),
|
||||
timestamp,
|
||||
JSON.stringify({ channels: ["local-file", "private-delivery-portal"], territories: ["CN"], deliverables: ["vertical-video", "delivery-manifest"], platforms: ["internal-review"], exclusive: false, commercialUse: true, language: ["zh-CN"] }),
|
||||
JSON.stringify({ originalStory: true, derivativeProduction: true, aiGeneratedAssetsAllowed: true, voiceCloneAllowed: false, fixedVoiceRequired: true, singleFramePolicyRequired: true }),
|
||||
JSON.stringify({ customerPortalAllowed: true, requireClearanceCertificate: true }),
|
||||
JSON.stringify({ contractRef: `smoke://release-contract/${runId}`, rightsEvidenceRef: `smoke://release-rights/${runId}`, signedCopyPath: `storage/contracts/smoke-release/${runId}.json` }),
|
||||
JSON.stringify({ paidCloudAllowed: false, publicModelAllowed: false, rightsReview: "passed" }),
|
||||
producerUser.id,
|
||||
producerUser.id,
|
||||
timestamp,
|
||||
timestamp
|
||||
]
|
||||
);
|
||||
dbRun(
|
||||
"INSERT OR IGNORE INTO contract_project_bindings(id, organization_id, workspace_id, project_id, contract_id, customer_id, usage_mode, status, notes, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'primary-license', 'active', 'release workflow smoke binding', ?, ?, ?)",
|
||||
[`contract-binding-release-${runId}`, organizationId, workspaceId, projectId, contractId, customerId, producerUser.id, timestamp, timestamp]
|
||||
);
|
||||
}
|
||||
|
||||
const channelPath = `/api/production/delivery-channels`;
|
||||
let channelId = "";
|
||||
let releaseId = "";
|
||||
@@ -126,6 +163,7 @@ try {
|
||||
dbRun("UPDATE deliveries SET status = 'approved', active_batch_id = ?, approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?", [batchId, producerUser.id, timestamp, timestamp, delivery.delivery.id]);
|
||||
expectOk(await request("/api/production/reviews", { headers: producerHeaders }), "ensure isolated QA reviews");
|
||||
dbRun("UPDATE reviews SET status = 'approved', score = 100, decision_by = ?, decision_at = ?, evidence_json = ?, updated_at = ? WHERE project_id = ?", [producerUser.id, timestamp, JSON.stringify({ source: "smoke-release-workflow", checkedAt: timestamp }), timestamp, projectId]);
|
||||
seedContractClearance();
|
||||
seeded = true;
|
||||
|
||||
const release = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(delivery.delivery.id)}/releases`, {
|
||||
@@ -178,6 +216,11 @@ try {
|
||||
assert.equal(Number(audit?.count || 0), 3, "release lifecycle must write three audit records");
|
||||
console.log(`release workflow smoke passed: ${releaseId}`);
|
||||
} finally {
|
||||
dbRun("DELETE FROM contract_license_events WHERE contract_id = ? OR customer_id = ? OR project_id = ?", [contractId, customerId, projectId]);
|
||||
dbRun("DELETE FROM contract_project_bindings WHERE contract_id = ? OR project_id = ?", [contractId, projectId]);
|
||||
dbRun("DELETE FROM production_contracts WHERE id = ?", [contractId]);
|
||||
dbRun("DELETE FROM customer_contacts WHERE customer_id = ?", [customerId]);
|
||||
dbRun("DELETE FROM customers WHERE id = ?", [customerId]);
|
||||
if (releaseId) dbRun("DELETE FROM audit_logs WHERE target_id = ?", [releaseId]);
|
||||
if (seeded) {
|
||||
dbRun("DELETE FROM delivery_releases WHERE delivery_id = ?", [createdDeliveryId]);
|
||||
|
||||
@@ -53,7 +53,7 @@ function assertContract(payload, label) {
|
||||
const tokens = [];
|
||||
try {
|
||||
const anonymous = await request("/api/work-items");
|
||||
assert.equal(anonymous.response.status, 401, "anonymous work-items access must be rejected");
|
||||
assert.ok([401, 429].includes(anonymous.response.status), `anonymous work-items access must be rejected: ${anonymous.response.status}`);
|
||||
|
||||
const ownerToken = await login("producer@local.test");
|
||||
tokens.push(ownerToken);
|
||||
|
||||
Reference in New Issue
Block a user