feat(server): W4-A integration endpoints and contract gaps
- POST /acquisition-plans: manual submit via download.plan -> download.submit
(same authorization/audit/idempotency path as the agent), slotId or entityId
- read-only /media-assets, /asset-locations, /asset-unit-coverage, /path-bindings,
/reconcile-runs, /server-mappings, /artifacts, GET /title-aliases/{id};
unimplemented write endpoints removed from api.ts; registry test now checks both directions
- candidate evaluation upsert per (slot, source_release) + migration 0022 dedupe/unique index
- organization records expose downloadTaskId (+ list filter); inventory adds
subscribed/candidateReasons; feed sources expose derived possibleGap
- GET /events?after=&limit= JSON replay page for API key callers (same sse_event table)
- preference tag revision history from audit_event; subscriptions/identities/aliases
lists carry mediaEntityTitle; subscription q/mediaKind search; slot subscriptionId
filter + currentAsset summary; policy preview samples carry title/reasons
- DecisionSummary, IdentityRevisionList, ReleaseIdentityResult, PolicyPreviewSummary exported from contracts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ea4c384664
commit
d3ab54f2bd
34 files changed
+1083
-123
No files matched your search
@@ -34,5 +34,10 @@ describe("Api 注册表覆盖", () => {
|
||||
const registered = new Set(listEndpoints().map((e) => `${e.method.toUpperCase()} ${e.path}`));
|
||||
const missing = served.filter((k) => !registered.has(k)).sort();
|
||||
expect(missing, `以下路由未登记到 packages/contracts/src/api.ts:\n${missing.join("\n")}`).toEqual([]);
|
||||
|
||||
// 反向:api.ts 登记的每条端点都必须有实现(OpenAPI 里可见)
|
||||
const servedSet = new Set(served);
|
||||
const unimplemented = [...registered].filter((k) => !servedSet.has(k)).sort();
|
||||
expect(unimplemented, `以下端点登记在 api.ts 但未实现:\n${unimplemented.join("\n")}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
completeDownload,
|
||||
createE2E,
|
||||
type E2E,
|
||||
pgAvailable,
|
||||
seedBase,
|
||||
seedMovieTarget,
|
||||
sql,
|
||||
waitFor,
|
||||
} from "./harness.ts";
|
||||
|
||||
// E6 W4-A 前后端集成端点:手动提交下载(与自动同流程)、候选评估去重、库存叠加补字段、
|
||||
// 整理记录 downloadTaskId、只读资产/产物端点、偏好标签修订历史、列表带作品标题、事件 JSON 补读。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
|
||||
describe.skipIf(!hasPg)("E6 W4-A 集成端点", () => {
|
||||
let e: E2E;
|
||||
let target: Awaited<ReturnType<typeof seedMovieTarget>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
e = await createE2E("e6");
|
||||
const base = await seedBase(e);
|
||||
target = await seedMovieTarget(e, base.siteId);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await e?.close();
|
||||
}, 60_000);
|
||||
|
||||
it("POST /acquisition-plans:硬条件不满足 → 不提交并给出原因;重复评估只留一条候选记录", async () => {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const r = await e.api("POST", "/acquisition-plans", { sourceReleaseId: target.r720, slotId: target.slotId });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data).toMatchObject({ submitted: false, plan: null, taskId: null });
|
||||
expect(r.body.data.rejected.length).toBeGreaterThan(0);
|
||||
expect(r.body.data.rejected[0].slotId).toBe(target.slotId);
|
||||
}
|
||||
const n = await sql<{ n: number }>`select count(*)::int as n from artifact
|
||||
where title = 'candidate_evaluation' and subject_id = ${target.slotId}
|
||||
and content->>'sourceReleaseId' = ${target.r720}`.execute(e.db);
|
||||
expect(n.rows[0]?.n).toBe(1);
|
||||
});
|
||||
|
||||
it("GET /inventory:subscribed 与 candidateReasons", async () => {
|
||||
const r = await e.api("GET", `/inventory?entityIds=${target.entityId}`);
|
||||
expect(r.status).toBe(200);
|
||||
const entry = r.body.data.items[0];
|
||||
expect(entry.subscribed).toBe(true);
|
||||
const c = entry.candidateReasons.find((x: { sourceReleaseId: string }) => x.sourceReleaseId === target.r720);
|
||||
expect(c).toMatchObject({ slotId: target.slotId, slotName: "1080p", decision: "reject" });
|
||||
expect(c.reasons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("POST /acquisition-plans:按作品选 Slot,走 download.plan → download.submit,qB 收到种子;整理记录带 downloadTaskId", async () => {
|
||||
const r = await e.api("POST", "/acquisition-plans", {
|
||||
sourceReleaseId: target.r1080,
|
||||
entityId: target.entityId,
|
||||
note: "工作台手动",
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data).toMatchObject({ submitted: true, slotId: target.slotId });
|
||||
expect(r.body.data.plan.status).toMatch(/reserved|submitted/);
|
||||
expect(r.body.data.taskId).toBeTruthy();
|
||||
const audits = await e.db
|
||||
.selectFrom("audit_event")
|
||||
.select(["action", "target_id"])
|
||||
.where("action", "in", ["capability.invoke", "acquisition_plan.manual_submit"])
|
||||
.execute();
|
||||
expect(audits.filter((a) => a.target_id === "download.submit")).toHaveLength(1);
|
||||
expect(audits.some((a) => a.action === "acquisition_plan.manual_submit")).toBe(true);
|
||||
|
||||
await completeDownload(e, "Dune.Part.Two.2024.1080p.BluRay.x264-GRP");
|
||||
const rec = await waitFor(
|
||||
async () => {
|
||||
const l = await e.api("GET", "/organization-records");
|
||||
return l.body.data.items.find((x: { status: string }) => x.status === "succeeded");
|
||||
},
|
||||
60_000,
|
||||
"整理记录成功",
|
||||
);
|
||||
const dt = await e.db.selectFrom("download_task").select("id").executeTakeFirstOrThrow();
|
||||
expect(rec.downloadTaskId).toBe(dt.id);
|
||||
const filtered = await e.api("GET", `/organization-records?downloadTaskId=${dt.id}`);
|
||||
expect(filtered.body.data.items.map((x: { id: string }) => x.id)).toEqual([rec.id]);
|
||||
}, 120_000);
|
||||
|
||||
it("只读资产端点与 Slot 当前资产摘要", async () => {
|
||||
const assets = await e.api("GET", `/media-assets?mediaEntityId=${target.entityId}`);
|
||||
expect(assets.status).toBe(200);
|
||||
expect(assets.body.data.items.length).toBeGreaterThan(0);
|
||||
const a = assets.body.data.items[0];
|
||||
expect((await e.api("GET", `/media-assets/${a.id}`)).body.data.id).toBe(a.id);
|
||||
const locs = await e.api("GET", `/asset-locations?mediaAssetId=${a.id}`);
|
||||
expect(locs.body.data.items.length).toBeGreaterThan(0);
|
||||
expect((await e.api("GET", `/path-bindings?mediaEntityId=${target.entityId}`)).body.data.items.length).toBe(1);
|
||||
expect((await e.api("GET", "/media-assets/00000000-0000-4000-8000-000000000000")).status).toBe(404);
|
||||
expect((await e.api("PATCH", `/media-assets/${a.id}`, { status: "missing" })).status).toBe(404);
|
||||
|
||||
const sub = await e.db.selectFrom("subscription").select("id").executeTakeFirstOrThrow();
|
||||
const slots = await e.api("GET", `/acquisition-slots?subscriptionId=${sub.id}`);
|
||||
expect(slots.body.data.items).toHaveLength(1);
|
||||
expect(slots.body.data.items[0].currentAsset).toMatchObject({ resolution: "1080p" });
|
||||
});
|
||||
|
||||
it("GET /artifacts:列表与详情", async () => {
|
||||
const list = await e.api("GET", "/artifacts?kind=evidence");
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.data.items.length).toBeGreaterThan(0);
|
||||
const id = list.body.data.items[0].id;
|
||||
const one = await e.api("GET", `/artifacts/${id}`);
|
||||
expect(one.body.data).toMatchObject({ id, kind: "evidence" });
|
||||
});
|
||||
|
||||
it("列表带作品标题;订阅按标题 / 类型搜索", async () => {
|
||||
const subs = await e.api("GET", "/subscriptions?q=Dune&mediaKind=movie");
|
||||
expect(subs.body.data.items).toHaveLength(1);
|
||||
expect(subs.body.data.items[0].mediaEntityTitle).toBe("沙丘2");
|
||||
expect((await e.api("GET", "/subscriptions?mediaKind=series")).body.data.items).toHaveLength(0);
|
||||
await e.api("POST", "/title-aliases", { mediaEntityId: target.entityId, alias: "沙丘:第二部", source: "user" });
|
||||
const aliases = await e.api("GET", `/title-aliases?mediaEntityId=${target.entityId}`);
|
||||
expect(aliases.body.data.items[0].mediaEntityTitle).toBe("沙丘2");
|
||||
const one = await e.api("GET", `/title-aliases/${aliases.body.data.items[0].id}`);
|
||||
expect(one.status).toBe(200);
|
||||
});
|
||||
|
||||
it("偏好标签修订历史:创建 / 修改 / 删除各一条(新→旧)", async () => {
|
||||
const c = await e.api("POST", "/preference-tags", { label: "偏好 FLAC", kind: "soft", rule: { audio: "flac" } });
|
||||
expect(c.status).toBe(201);
|
||||
const id = c.body.data.id;
|
||||
await e.api("PATCH", `/preference-tags/${id}`, { label: "偏好无损音轨" });
|
||||
await e.api("DELETE", `/preference-tags/${id}`);
|
||||
const r = await e.api("GET", `/preference-tags/${id}/revisions`);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.items.map((x: { action: string }) => x.action)).toEqual(["deleted", "updated", "created"]);
|
||||
expect(r.body.data.items[1]).toMatchObject({ changedFields: ["label"], revision: 2 });
|
||||
expect(r.body.data.items[1].snapshot.label).toBe("偏好无损音轨");
|
||||
});
|
||||
|
||||
it("GET /events?after=&limit= 返回 JSON 补读页", async () => {
|
||||
e.bus.publishSync("test.ping", { n: 1 });
|
||||
const r = await e.api("GET", "/events?after=0&limit=5");
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.items.length).toBeGreaterThan(0);
|
||||
expect(r.body.data.nextAfter).toBe(r.body.data.items.at(-1).seq);
|
||||
});
|
||||
});
|
||||
@@ -138,6 +138,15 @@ describe.skipIf(!up)("ingestFeed", () => {
|
||||
at: new Date(),
|
||||
});
|
||||
expect(route).toMatchObject({ task: "snapshotSite", input: { siteId, kind: "backfill", maxPages: 3 } });
|
||||
// 响应派生字段 possibleGap;下一次衔接上的抓取后清空
|
||||
const got = (await (await h.req(`/feed-sources/${f.id}`)).json()) as {
|
||||
data: { possibleGap: { backfillQueued: boolean } | null };
|
||||
};
|
||||
expect(got.data.possibleGap).toMatchObject({ backfillQueued: true });
|
||||
s.setItems([item("gap-9", "900209", "C.2020.1080p-X"), item("gap-10", "900210", "D.2020.1080p-X")]);
|
||||
await ingestFeed(h.rt, f.id);
|
||||
const after = (await (await h.req(`/feed-sources/${f.id}`)).json()) as { data: { possibleGap: unknown } };
|
||||
expect(after.data.possibleGap).toBeNull();
|
||||
});
|
||||
|
||||
it("登录失效(403):账号标失效 + 待处理,不重试", async () => {
|
||||
|
||||
@@ -39,3 +39,29 @@ describe("GET /api/v1/events", () => {
|
||||
await reader.cancel();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/v1/events JSON 补读", () => {
|
||||
it("带 limit 返回 JSON 页,按 nextAfter 续读", async () => {
|
||||
const { app, bus } = testApp();
|
||||
for (let i = 0; i < 5; i++) bus.publishSync("t", { i });
|
||||
const h = { Authorization: `Bearer ${DEV_KEY}` };
|
||||
const r1 = await app.request("/api/v1/events?after=1&limit=2", { headers: h });
|
||||
expect(r1.headers.get("content-type")).toContain("application/json");
|
||||
const b1 = (await r1.json()) as { data: { items: { seq: number }[]; nextAfter: number; hasMore: boolean } };
|
||||
expect(b1.data.items.map((e) => e.seq)).toEqual([2, 3]);
|
||||
expect(b1.data).toMatchObject({ nextAfter: 3, hasMore: true });
|
||||
const r2 = await app.request(`/api/v1/events?after=${b1.data.nextAfter}&limit=10`, { headers: h });
|
||||
const b2 = (await r2.json()) as { data: { items: { seq: number }[]; hasMore: boolean; latestSeq: number } };
|
||||
expect(b2.data.items.map((e) => e.seq)).toEqual([4, 5]);
|
||||
expect(b2.data).toMatchObject({ hasMore: false, latestSeq: 5 });
|
||||
});
|
||||
|
||||
it("Accept: application/json 也走 JSON;非法 limit 400", async () => {
|
||||
const { app, bus } = testApp();
|
||||
bus.publishSync("a", 1);
|
||||
const h = { Authorization: `Bearer ${DEV_KEY}` };
|
||||
const r = await app.request("/api/v1/events", { headers: { ...h, Accept: "application/json" } });
|
||||
expect(((await r.json()) as { data: { items: unknown[] } }).data.items).toHaveLength(1);
|
||||
expect((await app.request("/api/v1/events?limit=0", { headers: h })).status).toBe(400);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user