feat: cross-aspect project duplication + AEP convention/rule-engine spec
Build backend images / build content-svc (push) Failing after 1s
Build backend images / build file-svc (push) Failing after 0s
Build backend images / build gateway (push) Failing after 0s
Build backend images / build identity-svc (push) Failing after 0s
Build backend images / build notification-svc (push) Failing after 1s
Build backend images / build render-svc (push) Failing after 2s
Build backend images / build studio-svc (push) Failing after 0s

- content-svc: DuplicateProjectAsync clones full scene/element/colour graph
  (identical keys, new dimensions/aspect; AEP intentionally not copied;
  starts unpublished) + POST /v1/projects/{id}/duplicate.
- admin: «تکثیر» button + modal on each project row; aspects reduced to
  supported 16:9/1:1/9:16; free fps default 21 (clamped 1-60).
- docs/aep-template-convention.md: versioned (v1/v2) convention + rule-engine
  spec — modes, scene types, flatrender assembly, duration/fade model,
  fit-box, input types, expression-driven data flow, output spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 16:59:23 +03:30
parent 1ff6e494c0
commit ee670552a8
9 changed files with 872 additions and 3 deletions
+68
View File
@@ -0,0 +1,68 @@
// Functional test for project cross-aspect duplication.
// Mints an admin JWT (HS256, same secret/issuer/audience as identity-svc),
// adds a scene to a real project, duplicates it to 1:1, verifies the clone has
// the scene under a NEW project id + new aspect, then cleans up.
import crypto from "node:crypto";
const GW = "http://172.28.144.1:8088";
const SECRET = "p9Xv7Lm2Qq8Nz4TfKc1Hs6YwRe3Ud0BafwefWEFw324234QEWF";
const b64url = (b) => Buffer.from(b).toString("base64url");
function mintToken() {
const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const now = Math.floor(Date.now() / 1000);
const payload = b64url(JSON.stringify({
sub: "00000000-0000-0000-0000-0000000000aa",
jti: crypto.randomUUID(),
tenant_id: "00000000-0000-0000-0000-0000000000bb",
tenant_slug: "flatrender",
is_admin: "true", is_tenant_admin: "false", role: "Admin",
iss: "flatrender-identity", aud: "flatrender",
exp: now + 3600, iat: now,
}));
const sig = crypto.createHmac("sha256", SECRET).update(`${header}.${payload}`).digest("base64url");
return `${header}.${payload}.${sig}`;
}
const H = { "Content-Type": "application/json", Authorization: `Bearer ${mintToken()}` };
async function j(method, path, body) {
const res = await fetch(GW + path, { method, headers: H, body: body ? JSON.stringify(body) : undefined, redirect: "follow" });
const text = await res.text();
let data; try { data = text ? JSON.parse(text) : null; } catch { data = text; }
return { status: res.status, data };
}
// 1. find a project
const pl = await j("GET", "/v1/projects/?page=1&page_size=1");
const items = pl.data?.items ?? pl.data?.data ?? (Array.isArray(pl.data) ? pl.data : []);
const pid = items[0]?.id;
console.log("source project:", pid ?? "<none>");
if (!pid) { console.log("NO PROJECT — create one in admin first."); process.exit(0); }
// 2. add a test scene to source
const SK = "scene_dup_TEST";
console.log("add test scene:", (await j("POST", "/v1/scenes", {
project_id: pid, key: SK, title: "Dup Test Scene", scene_type: "Normal",
default_duration_sec: 4, overlap_at_end_sec: 0, can_handle_duration: true,
generate_kf: false, manual_color_selection: false, sort: 99, is_active: true,
})).status);
// 3. duplicate to 1:1
const dup = await j("POST", `/v1/projects/${pid}/duplicate`, { aspect: "1:1", original_width: 1080, original_height: 1080, name: "DUP TEST 1:1" });
const newId = dup.data?.id;
console.log("duplicate:", dup.status, "| new id:", newId, "| aspect:", dup.data?.aspect, "| width:", dup.data?.original_width);
// 4. verify clone has the scene, scoped to the new project
if (newId) {
const r = await j("GET", `/v1/scenes/?project_id=${newId}`);
const scenes = Array.isArray(r.data) ? r.data : r.data?.data ?? [];
const found = scenes.find((s) => s.key === SK);
console.log(`VERIFY → clone scenes: ${scenes.length} | test scene present: ${!!found} | scoped to new project: ${found?.project_id === newId} | new id != source: ${newId !== pid}`);
}
// 5. cleanup
if (newId) console.log("cleanup dup project:", (await j("DELETE", `/v1/projects/${newId}`)).status);
const ss = await j("GET", `/v1/scenes/?project_id=${pid}`);
const srcScenes = Array.isArray(ss.data) ? ss.data : ss.data?.data ?? [];
const ts = srcScenes.find((x) => x.key === SK);
if (ts) console.log("cleanup test scene:", (await j("DELETE", `/v1/scenes/${ts.id}`)).status);