feat(server): W2-B sources, identity resolution, metadata and site workbench
- Sites / accounts / feeds: registry view (/site-definitions), site CRUD with
enable/disable (history stays queryable, marked site_disabled), accounts and
feed URLs stored as secrets (only secret_ref / redacted urlDisplay exposed),
feed enable/disable/reset/fetch; one shared rate limiter + adapter per site.
- Tasks: ingestFeed (FeedFetchState/FeedCursor on feed_source, observation
feed+guid upsert with digest-change updates, source_release site+id
aggregation across feeds, auth failure -> account invalid + decision, no
retry, gap -> feed.gap_suspected -> budgeted backfill snapshot),
resolveObservation (parseRelease -> local recall via title_alias pg_trgm +
accepted identities -> TMDB -> resolveIdentity; auto binds entity /
provisional units / identities / aliases, otherwise identity_candidate pool
with evidence version and bounded review backoff; manual lock respected),
refreshMetadata (metadata_field + field_override, user locks win, paths
untouched), snapshotSite (site_snapshot coverage records).
- Events: feed.due, feed.gap_suspected, release.observed (self-consumed),
release.resolved, decision.created/updated; scheduler in worker start().
- Identity API: entity detail with redirects, manual mapping correction with
identity_revision, merge with identity_redirect, pool list/assign/dismiss/
retry, content unit numbering lock, aliases, external identities.
- Decisions: list/summary (by entity and kind)/detail/resolve; identity
options executed directly.
- Workbench: /sites/{id}/releases (snapshot first, coalesced live search with
backoff, origin + freshness) and /sites/{id}/releases/{rid} (volatile field
recheck times, refresh keeps data on failure).
- Capabilities: catalog.lookup, release.search, release.inspect,
identity.resolve.
- Migrations 0018-0020: site.adapter api, source_release.resolution_locked,
site_snapshot, identity_candidate, resolution_attempt, identity_redirect,
metadata_field, field_override.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
829ca4f7ac
commit
a24bda952b
39 files changed
+6861
-15
No files matched your search
@@ -31,7 +31,7 @@ const pingModule: ServerModule = {
|
||||
registerTasks(registry) {
|
||||
registry.register(
|
||||
defineTask({
|
||||
name: "ingestFeed",
|
||||
name: "testPing",
|
||||
version: 1,
|
||||
inputSchema: z.object({ feedSourceId: z.string() }),
|
||||
handler: async () => "real",
|
||||
@@ -75,8 +75,8 @@ describe("ServerModule 骨架", () => {
|
||||
|
||||
it("任务注册表:模块任务优先,其余 §3.1-8 任务由空壳兜底;重复注册报错", async () => {
|
||||
const reg = buildTaskRegistry([...SERVER_MODULES, pingModule], fakeCtx);
|
||||
expect(reg.names()).toEqual([...TASK_NAMES].sort());
|
||||
const ingest = reg.latest("ingestFeed");
|
||||
expect(reg.names()).toEqual(expect.arrayContaining([...TASK_NAMES, "testPing"]));
|
||||
const ingest = reg.latest("testPing");
|
||||
expect(await ingest.handler({ input: {} } as never)).toBe("real");
|
||||
expect(() => buildTaskRegistry([pingModule, { ...pingModule, name: "dup" }], fakeCtx)).toThrow(/重复注册/);
|
||||
});
|
||||
@@ -85,6 +85,7 @@ describe("ServerModule 骨架", () => {
|
||||
const merged = Object.keys(collectOutboxRoutes([...SERVER_MODULES, pingModule], fakeCtx));
|
||||
expect(merged).toContain("test.ping");
|
||||
expect(merged).toContain("release.resolved");
|
||||
expect(merged).toEqual(expect.arrayContaining(["feed.due", "release.observed", "feed.gap_suspected"]));
|
||||
expect(() => collectOutboxRoutes([pingModule, { ...pingModule, name: "dup" }], fakeCtx)).toThrow(/重复认领/);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { createMockNexusPhpSite, type MockNexusPhpSite } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { snapshotSite } from "../../src/modules/sources-identity/service/snapshot.ts";
|
||||
import { liveSearch } from "../../src/modules/sources-identity/service/workbench.ts";
|
||||
import { createHarness, EXEC_KEY, type Harness, json, pgAvailable, READ_KEY } from "./harness.ts";
|
||||
|
||||
const up = await pgAvailable();
|
||||
|
||||
describe.skipIf(!up)("来源管理与站点工作台 API", () => {
|
||||
let h: Harness;
|
||||
let mock: MockNexusPhpSite;
|
||||
let siteId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
mock = await createMockNexusPhpSite();
|
||||
h = await createHarness("api");
|
||||
});
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await mock?.close();
|
||||
});
|
||||
|
||||
it("鉴权:无 key 401,只读 key 写站点 403,organize.execute 不能改站点配置", async () => {
|
||||
expect((await h.app.request("/api/v1/sites")).status).toBe(401);
|
||||
expect((await h.req("/sites", { key: READ_KEY })).status).toBe(200);
|
||||
const body = { key: "audiences", name: "观众", adapter: "nexusphp", baseUrl: `${mock.baseUrl}/` };
|
||||
expect((await h.req("/sites", { key: READ_KEY, body })).status).toBe(403);
|
||||
expect((await h.req("/sites", { key: EXEC_KEY, body })).status).toBe(403);
|
||||
});
|
||||
|
||||
it("站点注册表 + 登记站点;适配器类型与定义不符 400;重复 409;校验失败 422", async () => {
|
||||
const defs = await json(await h.req("/site-definitions"));
|
||||
expect(defs.status).toBe(200);
|
||||
const aud = defs.body.data.items.find((d: { key: string }) => d.key === "audiences");
|
||||
expect(aud).toMatchObject({ adapter: "nexusphp", siteId: null, shippedEnabled: true });
|
||||
expect(defs.body.data.items.find((d: { key: string }) => d.key === "mteam")?.adapter).toBe("mteam_api");
|
||||
|
||||
const bad = await json(
|
||||
await h.req("/sites", {
|
||||
body: { key: "audiences", name: "观众", adapter: "mteam_api", baseUrl: `${mock.baseUrl}/` },
|
||||
}),
|
||||
);
|
||||
expect(bad.status).toBe(400);
|
||||
const unknown = await json(
|
||||
await h.req("/sites", { body: { key: "nosuch", name: "x", adapter: "nexusphp", baseUrl: "https://x.example/" } }),
|
||||
);
|
||||
expect(unknown.status).toBe(400);
|
||||
expect(
|
||||
(await h.req("/sites", { body: { key: "BAD KEY", name: "x", adapter: "nexusphp", baseUrl: "nope" } })).status,
|
||||
).toBe(422);
|
||||
|
||||
const ok = await json(
|
||||
await h.req("/sites", {
|
||||
body: {
|
||||
key: "audiences",
|
||||
name: "观众",
|
||||
adapter: "nexusphp",
|
||||
baseUrl: `${mock.baseUrl}/`,
|
||||
rateLimit: { minIntervalMs: 0, concurrency: 4 },
|
||||
snapshotEnabled: true,
|
||||
snapshotIntervalSec: 3600,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(ok.status).toBe(200);
|
||||
siteId = ok.body.data.id;
|
||||
expect(
|
||||
(
|
||||
await h.req("/sites", {
|
||||
body: { key: "audiences", name: "观众", adapter: "nexusphp", baseUrl: `${mock.baseUrl}/` },
|
||||
})
|
||||
).status,
|
||||
).toBe(409);
|
||||
const defs2 = await json(await h.req("/site-definitions"));
|
||||
expect(defs2.body.data.items.find((d: { key: string }) => d.key === "audiences").siteId).toBe(siteId);
|
||||
});
|
||||
|
||||
it("站点账号:凭证只写(存 secret),响应里只有 secret_ref", async () => {
|
||||
const r = await json(
|
||||
await h.req("/site-accounts", { body: { siteId, label: "主号", authKind: "cookie", credential: mock.cookie } }),
|
||||
);
|
||||
expect(r.status).toBe(200);
|
||||
expect(JSON.stringify(r.body)).not.toContain(mock.cookie);
|
||||
expect(h.secrets.plain.get(r.body.data.secretRef)).toBe(mock.cookie);
|
||||
expect(
|
||||
(await h.req("/site-accounts", { body: { siteId, label: "主号", authKind: "cookie", credential: "x" } })).status,
|
||||
).toBe(409);
|
||||
});
|
||||
|
||||
it("Feed CRUD:URL 存 secret,对外只给脱敏 URL;启停;立即抓取入队", async () => {
|
||||
const url = "https://audiences.example/torrentrss.php?rows=50&passkey=abcdef0123456789";
|
||||
const c = await json(
|
||||
await h.req("/feed-sources", { body: { siteId, name: "电影", purpose: "movie", url, pollIntervalSec: 600 } }),
|
||||
);
|
||||
expect(c.status).toBe(200);
|
||||
expect(c.body.data.urlDisplay).toContain("passkey=%5BREDACTED%5D".slice(0, 8));
|
||||
expect(JSON.stringify(c.body)).not.toContain("abcdef0123456789");
|
||||
const id = c.body.data.id;
|
||||
expect((await h.req("/feed-sources", { body: { siteId, name: "x", url: "not a url" } })).status).toBe(422);
|
||||
const off = await json(await h.req(`/feed-sources/${id}/disable`, { body: {} }));
|
||||
expect(off.body.data).toMatchObject({ enabled: false, healthStatus: "disabled" });
|
||||
expect((await h.req(`/feed-sources/${id}/fetch`, { body: {} })).status).toBe(412);
|
||||
await h.req(`/feed-sources/${id}/enable`, { body: {} });
|
||||
const f = await json(await h.req(`/feed-sources/${id}/fetch`, { body: {} }));
|
||||
expect(f.status).toBe(200);
|
||||
expect(h.engine.enqueued.at(-1)).toMatchObject({ task: "ingestFeed", input: { feedSourceId: id, manual: true } });
|
||||
const patched = await json(
|
||||
await h.req(`/feed-sources/${id}`, {
|
||||
method: "PATCH",
|
||||
body: { url: "https://audiences.example/rss?passkey=NEWPASSKEY99" },
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(patched.body)).not.toContain("NEWPASSKEY99");
|
||||
expect(h.secrets.plain.get(patched.body.data.urlSecretRef)).toContain("NEWPASSKEY99");
|
||||
expect((await h.req("/feed-sources/00000000-0000-4000-8000-000000000000")).status).toBe(404);
|
||||
});
|
||||
|
||||
it("快照:列表页 upsert 到 source_release,记录覆盖范围;站点级限流器每站一个实例", async () => {
|
||||
const r = await snapshotSite(h.rt, { siteId, kind: "periodic" });
|
||||
expect(r.status).toBe("ok");
|
||||
expect(r.pages).toEqual([1]);
|
||||
expect(r.newCount).toBe(r.itemCount);
|
||||
const limiter = h.rt.limiterOf(siteId);
|
||||
expect(limiter).not.toBeNull();
|
||||
await snapshotSite(h.rt, { siteId, kind: "manual" });
|
||||
expect(h.rt.limiterOf(siteId)).toBe(limiter);
|
||||
expect(limiter!.stats.granted).toBeGreaterThanOrEqual(2);
|
||||
const snaps = await json(await h.req(`/sites/${siteId}/snapshots`));
|
||||
expect(snaps.body.data.items[0]).toMatchObject({ kind: "manual", status: "ok", pages: [1] });
|
||||
const observed = await h.outbox("release.observed");
|
||||
expect(observed.length).toBe(r.newCount);
|
||||
const site = await json(await h.req(`/sites/${siteId}`));
|
||||
expect(site.body.data.lastSnapshotAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("工作台列表:快照优先,标注来源与更新时间;筛选与分页", async () => {
|
||||
const l = await json(await h.req(`/sites/${siteId}/releases?limit=2`));
|
||||
expect(l.status).toBe(200);
|
||||
expect(l.body.data.items).toHaveLength(2);
|
||||
expect(l.body.data.nextCursor).not.toBeNull();
|
||||
expect(l.body.data.coverage).toMatchObject({ siteEnabled: true, stale: false, live: false });
|
||||
expect(l.body.data.items[0]).toMatchObject({ origin: "snapshot", entity: null });
|
||||
expect(l.body.data.items[0].dataUpdatedAt).toBeTruthy();
|
||||
const p2 = await json(await h.req(`/sites/${siteId}/releases?limit=2&cursor=${l.body.data.nextCursor}`));
|
||||
expect(p2.body.data.items[0].id).not.toBe(l.body.data.items[0].id);
|
||||
const free = await json(await h.req(`/sites/${siteId}/releases?promotion=free`));
|
||||
expect(free.body.data.items.every((i: { promotion: { kind: string } }) => i.promotion.kind === "free")).toBe(true);
|
||||
const q = await json(await h.req(`/sites/${siteId}/releases?q=Dune&live=false`));
|
||||
expect(q.body.data.items.map((i: { siteReleaseId: string }) => i.siteReleaseId)).toContain("812345");
|
||||
});
|
||||
|
||||
it("实时补查:并发相同搜索合并为一次站点请求;API 标注实时来源", async () => {
|
||||
const site = await h.db.selectFrom("site").selectAll().where("id", "=", siteId).executeTakeFirstOrThrow();
|
||||
const searches = () => mock.requests.filter((r) => r.path.includes("search=")).length;
|
||||
const before = searches();
|
||||
const [a, b] = await Promise.all([liveSearch(h.rt, site, "Bear"), liveSearch(h.rt, site, "bear")]);
|
||||
expect(searches() - before).toBe(1);
|
||||
expect([a.coalesced, b.coalesced].sort()).toEqual([false, true]);
|
||||
expect(a.error).toBeNull();
|
||||
const r = await json(await h.req(`/sites/${siteId}/releases?q=Last&live=true`));
|
||||
expect(r.body.data.coverage).toMatchObject({ live: true, liveError: null });
|
||||
expect(r.body.data.items.map((i: { siteReleaseId: string }) => i.siteReleaseId)).toContain("812348");
|
||||
const snaps = await h.db.selectFrom("site_snapshot").selectAll().where("kind", "=", "live_search").execute();
|
||||
expect(snaps).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("详情:易变字段复核时间;refresh 失败保留既有数据", async () => {
|
||||
const rel = await h.db
|
||||
.selectFrom("source_release")
|
||||
.selectAll()
|
||||
.where("site_release_id", "=", "812345")
|
||||
.executeTakeFirstOrThrow();
|
||||
const d = await json(await h.req(`/sites/${siteId}/releases/${rel.id}?refresh=true`));
|
||||
expect(d.status).toBe(200);
|
||||
expect(d.body.data.refreshError).toBeNull();
|
||||
expect(d.body.data.volatile.recheckBeforeDownload).toBe(true);
|
||||
expect(d.body.data.volatile.detailFetchedAt).not.toBeNull();
|
||||
mock.setLoggedIn(false);
|
||||
const d2 = await json(await h.req(`/sites/${siteId}/releases/${rel.id}?refresh=true`));
|
||||
expect(d2.status).toBe(200);
|
||||
expect(d2.body.data.refreshError).toMatch(/auth/);
|
||||
expect(d2.body.data.release.title).toBe(rel.title);
|
||||
// 登录失效 → 账号标失效 + 待处理,周期快照不再访问
|
||||
const acc = await h.db
|
||||
.selectFrom("site_account")
|
||||
.selectAll()
|
||||
.where("site_id", "=", siteId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(["invalid", "expired"]).toContain(acc.status);
|
||||
expect(
|
||||
(await h.db.selectFrom("decision").selectAll().where("dedupe_key", "=", `site-auth:${siteId}`).execute()).length,
|
||||
).toBe(1);
|
||||
const n = mock.requests.length;
|
||||
expect((await snapshotSite(h.rt, { siteId, kind: "periodic" })).reason).toBe("auth_invalid");
|
||||
expect(mock.requests.length).toBe(n);
|
||||
// 换凭证:账号复位、待处理关闭
|
||||
mock.setLoggedIn(true);
|
||||
await h.req(`/site-accounts/${acc.id}`, { method: "PATCH", body: { credential: mock.cookie } });
|
||||
const d3 = await h.db
|
||||
.selectFrom("decision")
|
||||
.select(["status"])
|
||||
.where("dedupe_key", "=", `site-auth:${siteId}`)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(d3.status).toBe("resolved");
|
||||
expect((await snapshotSite(h.rt, { siteId, kind: "periodic" })).status).toBe("ok");
|
||||
});
|
||||
|
||||
it("关闭站点:停止网络访问,历史资源仍可查并标注 site_disabled", async () => {
|
||||
const off = await json(await h.req(`/sites/${siteId}/disable`, { body: {} }));
|
||||
expect(off.body.data).toMatchObject({ enabled: false, healthStatus: "disabled" });
|
||||
const n = mock.requests.length;
|
||||
const l = await json(await h.req(`/sites/${siteId}/releases?q=Nothing.Here&live=true`));
|
||||
expect(l.body.data.coverage).toMatchObject({ siteEnabled: false, live: false });
|
||||
expect(l.body.data.coverage.liveError).toMatch(/关闭/);
|
||||
expect(mock.requests.length).toBe(n);
|
||||
const all = await json(await h.req(`/sites/${siteId}/releases`));
|
||||
expect(all.body.data.items.length).toBeGreaterThan(0);
|
||||
expect(all.body.data.items.every((i: { availability: string }) => i.availability === "site_disabled")).toBe(true);
|
||||
expect((await h.req(`/sites/${siteId}/snapshot`, { body: {} })).status).toBe(412);
|
||||
await h.req(`/sites/${siteId}/enable`, { body: {} });
|
||||
const back = await json(await h.req(`/sites/${siteId}/releases?limit=1`));
|
||||
expect(back.body.data.items[0].availability).toBe("active");
|
||||
});
|
||||
|
||||
it("来源资源 / 观察 / 作品列表与 OpenAPI 文档", async () => {
|
||||
const l = await json(await h.req(`/source-releases?siteId=${siteId}&q=Dune`));
|
||||
expect(l.body.data.items.length).toBeGreaterThan(0);
|
||||
expect((await h.req("/source-releases/not-a-uuid")).status).toBe(422);
|
||||
expect((await json(await h.req("/observations"))).status).toBe(200);
|
||||
expect((await json(await h.req("/media-entities"))).status).toBe(200);
|
||||
expect((await json(await h.req("/identity-pool"))).body.data.items).toEqual([]);
|
||||
const doc = await json(await h.app.request("/api/openapi.json"));
|
||||
const paths = Object.keys((doc.body as unknown as { paths: Record<string, unknown> }).paths);
|
||||
expect(paths).toEqual(
|
||||
expect.arrayContaining(["/api/v1/sites/{id}/releases", "/api/v1/identity-pool", "/api/v1/decisions/summary"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createTmdbClient } from "@umt/adapters";
|
||||
import { DEFAULT_SETTINGS, type GlobalSettings, type SettingsKey } from "@umt/contracts";
|
||||
import { type DB, type Kysely, migrateToLatest, sql } from "@umt/db";
|
||||
import type { TaskEngine } from "@umt/tasks";
|
||||
import { Kysely as K, PostgresDialect } from "kysely";
|
||||
import pg from "pg";
|
||||
import { createOutboxPort } from "../../src/context.ts";
|
||||
import { createApp } from "../../src/http/app.ts";
|
||||
import { EventBus } from "../../src/http/sse.ts";
|
||||
import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
|
||||
import type { RuntimeOverrides } from "../../src/modules/sources-identity/runtime.ts";
|
||||
import type { SecretStore, ServerContext, SettingsReader } from "../../src/modules/types.ts";
|
||||
import { DEV_KEY, memoryLogger, memoryStore } from "../helpers.ts";
|
||||
|
||||
export const READ_KEY = "w2b-read-key";
|
||||
export const EXEC_KEY = "w2b-exec-key";
|
||||
|
||||
// W2-B 集成测试夹具:独立 PG schema + 真实迁移 + fake 引擎/凭证 + 模块运行时。
|
||||
|
||||
export const PG_URL = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
|
||||
|
||||
export async function pgAvailable(): Promise<boolean> {
|
||||
const c = new pg.Client({ connectionString: PG_URL, connectionTimeoutMillis: 2000 });
|
||||
try {
|
||||
await c.connect();
|
||||
await c.end();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 内存凭证库(W2-A 的 SecretStore 实现前);记录明文以便断言"明文不入业务表" */
|
||||
export function fakeSecrets(db: () => Kysely<DB>): SecretStore & { plain: Map<string, string> } {
|
||||
const plain = new Map<string, string>();
|
||||
return {
|
||||
plain,
|
||||
async resolve(ref) {
|
||||
const v = plain.get(ref);
|
||||
if (v === undefined) throw new Error(`unknown secret ${ref}`);
|
||||
return v;
|
||||
},
|
||||
async create({ plaintext, purpose }) {
|
||||
const id = randomUUID();
|
||||
plain.set(id, plaintext);
|
||||
// 业务表 secret_ref 有外键:落一行占位(密文不是真的)
|
||||
await db()
|
||||
.insertInto("secret")
|
||||
.values({ id, name: `test-${id}`, purpose, ciphertext: Buffer.from("x"), nonce: Buffer.from("x") })
|
||||
.execute();
|
||||
return id;
|
||||
},
|
||||
async replace(ref, plaintext) {
|
||||
plain.set(ref, plaintext);
|
||||
},
|
||||
async remove(ref) {
|
||||
plain.delete(ref);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 每次读库的设置读取(不缓存,测试里改 settings 表立即生效) */
|
||||
export function plainSettings(db: () => Kysely<DB>): SettingsReader {
|
||||
const all = async (): Promise<GlobalSettings> => {
|
||||
const rows = await db().selectFrom("settings").select(["key", "value"]).execute();
|
||||
const out = { ...DEFAULT_SETTINGS } as Record<string, unknown>;
|
||||
for (const r of rows) {
|
||||
const def = (DEFAULT_SETTINGS as Record<string, object>)[r.key];
|
||||
if (def) out[r.key] = { ...def, ...(r.value as object) };
|
||||
}
|
||||
return out as GlobalSettings;
|
||||
};
|
||||
return { all, get: async <K extends SettingsKey>(k: K) => (await all())[k] } as SettingsReader;
|
||||
}
|
||||
|
||||
export interface Enqueued {
|
||||
task: string;
|
||||
input: unknown;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export function fakeEngine(): TaskEngine & { enqueued: Enqueued[] } {
|
||||
const enqueued: Enqueued[] = [];
|
||||
const keys = new Set<string>();
|
||||
return {
|
||||
enqueued,
|
||||
async enqueue(task, input, opts) {
|
||||
const dedup = keys.has(opts.idempotencyKey);
|
||||
if (!dedup) {
|
||||
keys.add(opts.idempotencyKey);
|
||||
enqueued.push({ task, input, key: opts.idempotencyKey });
|
||||
}
|
||||
return { runId: randomUUID(), deduplicated: dedup };
|
||||
},
|
||||
async signal() {
|
||||
return { delivered: 0 };
|
||||
},
|
||||
async cancel() {
|
||||
return false;
|
||||
},
|
||||
async get() {
|
||||
return null;
|
||||
},
|
||||
async list() {
|
||||
return [];
|
||||
},
|
||||
async startWorker() {},
|
||||
async stop() {},
|
||||
};
|
||||
}
|
||||
|
||||
export interface Harness {
|
||||
db: Kysely<DB>;
|
||||
ctx: ServerContext;
|
||||
engine: ReturnType<typeof fakeEngine>;
|
||||
secrets: ReturnType<typeof fakeSecrets>;
|
||||
module: ReturnType<typeof createSourcesIdentityModule>;
|
||||
rt: ReturnType<ReturnType<typeof createSourcesIdentityModule>["runtime"]>;
|
||||
app: ReturnType<typeof createApp>;
|
||||
/** 带 dev admin key 的请求 */
|
||||
req(path: string, init?: { method?: string; body?: unknown; key?: string }): Promise<Response>;
|
||||
outbox(type?: string): Promise<{ type: string; payload: Record<string, unknown>; idempotency_key: string | null }[]>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface HarnessOptions extends RuntimeOverrides {
|
||||
tmdbUrl?: string;
|
||||
}
|
||||
|
||||
export async function createHarness(name: string, opts: HarnessOptions = {}): Promise<Harness> {
|
||||
const schema = `w2b_${name}_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||
const admin = new K<unknown>({
|
||||
dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: PG_URL, max: 1 }) }),
|
||||
});
|
||||
await sql.raw(`create schema ${schema}`).execute(admin);
|
||||
// 与 @umt/db createDb 相同的 int8 解析(createDb 导入即注册全局解析器)
|
||||
await import("@umt/db");
|
||||
const db = new K<DB>({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new pg.Pool({ connectionString: PG_URL, max: 8, options: `-c search_path=${schema},public` }),
|
||||
}),
|
||||
}) as unknown as Kysely<DB>;
|
||||
await migrateToLatest(db, { migrationTableSchema: schema });
|
||||
|
||||
const { logger } = memoryLogger();
|
||||
const engine = fakeEngine();
|
||||
const secrets = fakeSecrets(() => db);
|
||||
const outboxPort = createOutboxPort();
|
||||
const ctx = {
|
||||
role: "all",
|
||||
db,
|
||||
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY } as never,
|
||||
logger,
|
||||
events: new EventBus(5),
|
||||
engine,
|
||||
outbox: outboxPort,
|
||||
secrets,
|
||||
settings: plainSettings(() => db),
|
||||
capabilities: {} as never,
|
||||
} as ServerContext;
|
||||
const { tmdbUrl, ...overrides } = opts;
|
||||
const module = createSourcesIdentityModule({
|
||||
...overrides,
|
||||
tmdb:
|
||||
overrides.tmdb ??
|
||||
(() => (tmdbUrl ? createTmdbClient({ apiKey: "test-tmdb-key", baseUrl: `${tmdbUrl}/3`, retries: 0 }) : null)),
|
||||
});
|
||||
const rt = module.runtime(ctx);
|
||||
const app = createApp({
|
||||
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
|
||||
logger,
|
||||
bus: new EventBus(5),
|
||||
role: "all",
|
||||
version: "0.0.0-test",
|
||||
modules: [module],
|
||||
ctx,
|
||||
apiKeyStore: memoryStore({
|
||||
[READ_KEY]: { id: "reader", scopes: ["read"] },
|
||||
[EXEC_KEY]: { id: "operator", scopes: ["organize.execute"] },
|
||||
}),
|
||||
});
|
||||
return {
|
||||
db,
|
||||
ctx,
|
||||
engine,
|
||||
secrets,
|
||||
module,
|
||||
rt,
|
||||
app,
|
||||
req(path, init = {}) {
|
||||
const headers: Record<string, string> = { authorization: `Bearer ${init.key ?? DEV_KEY}` };
|
||||
if (init.body !== undefined) headers["content-type"] = "application/json";
|
||||
return Promise.resolve(
|
||||
app.request(`/api/v1${path}`, {
|
||||
method: init.method ?? (init.body !== undefined ? "POST" : "GET"),
|
||||
headers,
|
||||
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
async outbox(type) {
|
||||
let q = db.selectFrom("outbox_event").select(["type", "payload", "idempotency_key"]).orderBy("at").orderBy("id");
|
||||
if (type) q = q.where("type", "=", type);
|
||||
return (await q.execute()) as never;
|
||||
},
|
||||
async close() {
|
||||
await db.destroy();
|
||||
await sql.raw(`drop schema if exists ${schema} cascade`).execute(admin);
|
||||
await admin.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 登记一个指向 mock 的站点 + 可选账号 */
|
||||
export async function seedSite(
|
||||
h: Harness,
|
||||
opts: { key?: string; baseUrl: string; cookie?: string; adapter?: "nexusphp" | "mteam_api" | "generic_rss" },
|
||||
) {
|
||||
const site = await h.db
|
||||
.insertInto("site")
|
||||
.values({
|
||||
key: opts.key ?? "audiences",
|
||||
name: opts.key ?? "观众",
|
||||
adapter: opts.adapter ?? "nexusphp",
|
||||
base_url: opts.baseUrl,
|
||||
rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }),
|
||||
snapshot_enabled: true,
|
||||
snapshot_interval_sec: 3600,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
let accountId: string | null = null;
|
||||
if (opts.cookie) {
|
||||
const ref = await h.secrets.create({ purpose: "site_cookie", plaintext: opts.cookie });
|
||||
const acc = await h.db
|
||||
.insertInto("site_account")
|
||||
.values({ site_id: site.id, label: "main", auth_kind: "cookie", secret_ref: ref })
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
accountId = acc.id;
|
||||
}
|
||||
return { site, accountId };
|
||||
}
|
||||
|
||||
/** 登记 Feed(URL 存 fake secret;feed_source.url_secret_ref 外键指向 secret 表,这里补一行占位) */
|
||||
export async function seedFeed(
|
||||
h: Harness,
|
||||
opts: {
|
||||
siteId: string | null;
|
||||
url: string;
|
||||
name?: string;
|
||||
accountId?: string | null;
|
||||
purpose?: "movie" | "promotion" | "generic";
|
||||
},
|
||||
) {
|
||||
const ref = await h.secrets.create({ purpose: "feed_url", plaintext: opts.url });
|
||||
return h.db
|
||||
.insertInto("feed_source")
|
||||
.values({
|
||||
site_id: opts.siteId,
|
||||
site_account_id: opts.accountId ?? null,
|
||||
name: opts.name ?? "feed",
|
||||
purpose: opts.purpose ?? "generic",
|
||||
url_secret_ref: ref,
|
||||
url_display: opts.url.replace(/passkey=[^&]+/, "passkey=[REDACTED]"),
|
||||
poll_interval_sec: 300,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 测试断言用
|
||||
type AnyJson = any;
|
||||
export const json = async (r: Response) => ({
|
||||
status: r.status,
|
||||
body: (await r.json()) as { ok: boolean; data: AnyJson; error?: AnyJson },
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { createMockRssServer, type MockFeedItem, type MockRssServer } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { schedulerTick } from "../../src/modules/sources-identity/scheduler.ts";
|
||||
import { extractSiteReleaseId, ingestFeed } from "../../src/modules/sources-identity/service/feeds.ts";
|
||||
import { sourcesOutboxRoutes } from "../../src/modules/sources-identity/tasks.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedFeed, seedSite } from "./harness.ts";
|
||||
|
||||
const up = await pgAvailable();
|
||||
const BASE = "https://audiences.example";
|
||||
|
||||
const item = (guid: string, id: string, title: string, extra: Partial<MockFeedItem> = {}): MockFeedItem => ({
|
||||
guid,
|
||||
title,
|
||||
link: `${BASE}/details.php?id=${id}&hit=1`,
|
||||
enclosureUrl: `${BASE}/download.php?id=${id}&passkey=SECRETPK123`,
|
||||
size: 1024 * 1024 * 1024,
|
||||
pubDate: new Date("2026-09-20T08:00:00Z"),
|
||||
categories: ["电影"],
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe.skipIf(!up)("ingestFeed", () => {
|
||||
let h: Harness;
|
||||
const servers: MockRssServer[] = [];
|
||||
let siteId: string;
|
||||
let accountId: string | null;
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createHarness("ingest");
|
||||
const s = await seedSite(h, { baseUrl: `${BASE}/`, cookie: "c=1" });
|
||||
siteId = s.site.id;
|
||||
accountId = s.accountId;
|
||||
});
|
||||
afterAll(async () => {
|
||||
for (const s of servers) await s.close();
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
const feedServer = async (items: MockFeedItem[], passkey = "FEEDPK") => {
|
||||
const s = await createMockRssServer({ items, passkey });
|
||||
servers.push(s);
|
||||
return s;
|
||||
};
|
||||
|
||||
it("从链接推出站点资源 ID", () => {
|
||||
expect(extractSiteReleaseId({ guid: "x", link: `${BASE}/details.php?id=812345&hit=1` })).toBe("812345");
|
||||
expect(extractSiteReleaseId({ guid: "x", link: "https://kp.m-team.cc/detail/912233" })).toBe("912233");
|
||||
expect(extractSiteReleaseId({ guid: "abc", link: null })).toBeNull();
|
||||
expect(extractSiteReleaseId({ guid: "12345", link: null })).toBe("12345");
|
||||
});
|
||||
|
||||
it("三个 Feed 看到同一资源只建一条 source_release,保留三条观察;release.observed 只发一次", async () => {
|
||||
const title = "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX-FraMeSToR";
|
||||
const a = await feedServer([item("movie-1", "812345", title)]);
|
||||
const b = await feedServer([item("promo-xyz", "812345", title)]);
|
||||
const c = await feedServer([item("https://audiences.example/details.php?id=812345", "812345", title)]);
|
||||
const feeds = [
|
||||
await seedFeed(h, { siteId, url: a.feedUrl, name: "电影", purpose: "movie", accountId }),
|
||||
await seedFeed(h, { siteId, url: b.feedUrl, name: "促销", purpose: "promotion" }),
|
||||
await seedFeed(h, { siteId, url: c.feedUrl, name: "综合" }),
|
||||
];
|
||||
for (const f of feeds) expect((await ingestFeed(h.rt, f.id)).status).toBe("ok");
|
||||
|
||||
const rels = await h.db.selectFrom("source_release").selectAll().where("site_release_id", "=", "812345").execute();
|
||||
expect(rels).toHaveLength(1);
|
||||
const obs = await h.db.selectFrom("observation").selectAll().where("source_release_id", "=", rels[0]!.id).execute();
|
||||
expect(new Set(obs.map((o) => o.feed_id)).size).toBe(3);
|
||||
expect(obs.every((o) => o.status === "linked")).toBe(true);
|
||||
const observed = (await h.outbox("release.observed")).filter((e) => e.payload.sourceReleaseId === rels[0]!.id);
|
||||
expect(observed).toHaveLength(1);
|
||||
expect(observed[0]!.payload.isNew).toBe(true);
|
||||
// 时间:来源发布时间与首次发现分开保存
|
||||
expect(rels[0]!.source_published_at?.toISOString()).toBe("2026-09-20T08:00:00.000Z");
|
||||
expect(rels[0]!.first_seen_at.getTime()).toBeGreaterThan(rels[0]!.source_published_at!.getTime());
|
||||
// feed 状态持久化:游标与条件请求头
|
||||
const f0 = await h.db
|
||||
.selectFrom("feed_source")
|
||||
.selectAll()
|
||||
.where("id", "=", feeds[0]!.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(f0.health_status).toBe("healthy");
|
||||
expect(f0.etag).toBeTruthy();
|
||||
expect((f0.cursor as { window: { lastGuidSet: string[] } }).window.lastGuidSet).toContain("movie-1");
|
||||
expect(f0.next_fetch_at!.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("enclosure/passkey 只存脱敏值;未变化时 304 不产生新条目", async () => {
|
||||
const s = await feedServer([item("g-1", "900001", "Some.Movie.2020.1080p.WEB-DL-GRP")], "TOPSECRETKEY");
|
||||
const f = await seedFeed(h, { siteId, url: s.feedUrl, name: "脱敏" });
|
||||
await ingestFeed(h.rt, f.id);
|
||||
const dump =
|
||||
JSON.stringify(await h.db.selectFrom("observation").selectAll().where("feed_id", "=", f.id).execute()) +
|
||||
JSON.stringify(
|
||||
await h.db.selectFrom("source_release").selectAll().where("site_release_id", "=", "900001").execute(),
|
||||
) +
|
||||
JSON.stringify(await h.db.selectFrom("feed_source").selectAll().where("id", "=", f.id).execute());
|
||||
expect(dump).not.toContain("SECRETPK123");
|
||||
expect(dump).not.toContain("TOPSECRETKEY");
|
||||
expect(dump).toContain("[REDACTED]");
|
||||
const again = await ingestFeed(h.rt, f.id);
|
||||
expect(again.status).toBe("not_modified");
|
||||
expect(s.notModifiedCount).toBe(1);
|
||||
});
|
||||
|
||||
it("同 GUID 内容变化视为更新:观察与资源标题更新,重新触发识别", async () => {
|
||||
const s = await feedServer([item("upd-1", "900100", "Show.2021.1080p.WEB-DL-AAA")]);
|
||||
const f = await seedFeed(h, { siteId, url: s.feedUrl, name: "更新" });
|
||||
await ingestFeed(h.rt, f.id);
|
||||
s.updateItem("upd-1", { title: "Show.2021.2160p.WEB-DL-AAA" });
|
||||
const r = await ingestFeed(h.rt, f.id);
|
||||
expect(r).toMatchObject({ status: "ok", newItems: 0, updatedItems: 1, newReleases: 0 });
|
||||
const obs = await h.db.selectFrom("observation").selectAll().where("feed_id", "=", f.id).execute();
|
||||
expect(obs).toHaveLength(1);
|
||||
expect(obs[0]!.title).toBe("Show.2021.2160p.WEB-DL-AAA");
|
||||
const rel = await h.db
|
||||
.selectFrom("source_release")
|
||||
.selectAll()
|
||||
.where("site_release_id", "=", "900100")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(rel.title).toBe("Show.2021.2160p.WEB-DL-AAA");
|
||||
const ev = (await h.outbox("release.observed")).filter((e) => e.payload.sourceReleaseId === rel.id);
|
||||
expect(ev.map((e) => e.payload.isNew)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it("窗口零重叠 → feed.gap_suspected,并路由到有预算的补漏快照", async () => {
|
||||
const s = await feedServer([item("gap-1", "900201", "A.2020.1080p-X"), item("gap-2", "900202", "B.2020.1080p-X")]);
|
||||
const f = await seedFeed(h, { siteId, url: s.feedUrl, name: "缺口" });
|
||||
await ingestFeed(h.rt, f.id);
|
||||
s.setItems([item("gap-9", "900209", "C.2020.1080p-X")]);
|
||||
const r = await ingestFeed(h.rt, f.id);
|
||||
expect(r.possibleGap).toBe(true);
|
||||
const gap = (await h.outbox("feed.gap_suspected")).find((e) => e.payload.feedId === f.id);
|
||||
expect(gap).toBeDefined();
|
||||
const route = sourcesOutboxRoutes["feed.gap_suspected"]!({
|
||||
id: "1",
|
||||
type: "feed.gap_suspected",
|
||||
payload: gap!.payload,
|
||||
at: new Date(),
|
||||
});
|
||||
expect(route).toMatchObject({ task: "snapshotSite", input: { siteId, kind: "backfill", maxPages: 3 } });
|
||||
});
|
||||
|
||||
it("登录失效(403):账号标失效 + 待处理,不重试", async () => {
|
||||
const s = await feedServer([item("auth-1", "900300", "X.2020.1080p-Y")], "RIGHTKEY");
|
||||
const badUrl = s.feedUrl.replace("RIGHTKEY", "WRONGKEY");
|
||||
const f = await seedFeed(h, { siteId, url: badUrl, name: "失效", accountId });
|
||||
const r = await ingestFeed(h.rt, f.id);
|
||||
expect(r.status).toBe("auth_failed");
|
||||
const acc = await h.db
|
||||
.selectFrom("site_account")
|
||||
.selectAll()
|
||||
.where("id", "=", accountId!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(acc.status).toBe("invalid");
|
||||
const d = await h.db
|
||||
.selectFrom("decision")
|
||||
.selectAll()
|
||||
.where("dedupe_key", "=", `feed-auth:${f.id}`)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(d.kind).toBe("source_unreachable");
|
||||
expect(JSON.stringify(d)).not.toContain("WRONGKEY");
|
||||
const row = await h.db.selectFrom("feed_source").selectAll().where("id", "=", f.id).executeTakeFirstOrThrow();
|
||||
expect(row.health_status).toBe("failing");
|
||||
expect(row.backoff_until!.getTime()).toBeGreaterThan(Date.now() + 23 * 3600_000);
|
||||
// 再次调度:退避期内不发请求
|
||||
const before = s.requests.length;
|
||||
const again = await ingestFeed(h.rt, f.id);
|
||||
expect(again.status).toBe("skipped");
|
||||
expect(s.requests.length).toBe(before);
|
||||
// 调度器也不会把它当作到期
|
||||
await h.db
|
||||
.updateTable("feed_source")
|
||||
.set({ next_fetch_at: new Date(0) })
|
||||
.where("id", "=", f.id)
|
||||
.execute();
|
||||
await schedulerTick(h.rt);
|
||||
const due = (await h.outbox("feed.due")).filter((e) => e.payload.feedId === f.id);
|
||||
expect(due).toHaveLength(0);
|
||||
// 恢复账号给后续用例
|
||||
await h.db.updateTable("site_account").set({ status: "active" }).where("id", "=", accountId!).execute();
|
||||
});
|
||||
|
||||
it("调度器:到期 Feed 发 feed.due(幂等),快照到期入队 snapshotSite", async () => {
|
||||
const s = await feedServer([item("sch-1", "900400", "S.2020.1080p-Z")]);
|
||||
const f = await seedFeed(h, { siteId, url: s.feedUrl, name: "调度" });
|
||||
const t1 = await schedulerTick(h.rt);
|
||||
expect(t1.feedsDue).toBeGreaterThanOrEqual(1);
|
||||
const due = (await h.outbox("feed.due")).filter((e) => e.payload.feedId === f.id);
|
||||
expect(due).toHaveLength(1);
|
||||
expect(
|
||||
sourcesOutboxRoutes["feed.due"]!({ id: "1", type: "feed.due", payload: due[0]!.payload, at: new Date() }),
|
||||
).toEqual({
|
||||
task: "ingestFeed",
|
||||
input: { feedSourceId: f.id, manual: false },
|
||||
});
|
||||
// next_fetch_at 已顺延:紧接着的 tick 不再重复
|
||||
await schedulerTick(h.rt);
|
||||
expect((await h.outbox("feed.due")).filter((e) => e.payload.feedId === f.id)).toHaveLength(1);
|
||||
expect(
|
||||
h.engine.enqueued.some((e) => e.task === "snapshotSite" && (e.input as { siteId: string }).siteId === siteId),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("站点关闭后 Feed 不再抓取", async () => {
|
||||
const s = await feedServer([item("off-1", "900500", "O.2020.1080p-Z")]);
|
||||
const f = await seedFeed(h, { siteId, url: s.feedUrl, name: "关站" });
|
||||
await h.db.updateTable("site").set({ enabled: false }).where("id", "=", siteId).execute();
|
||||
const r = await ingestFeed(h.rt, f.id);
|
||||
expect(r).toMatchObject({ status: "skipped", reason: "site_disabled" });
|
||||
expect(s.requests.length).toBe(0);
|
||||
await h.db.updateTable("site").set({ enabled: true }).where("id", "=", siteId).execute();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { TaskRegistry } from "@umt/tasks";
|
||||
import { createMockMteamApi, type MockMteamApi } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { snapshotSite } from "../../src/modules/sources-identity/service/snapshot.ts";
|
||||
import { createHarness, type Harness, pgAvailable } from "./harness.ts";
|
||||
|
||||
const up = await pgAvailable();
|
||||
|
||||
describe.skipIf(!up)("M-Team 快照与任务注册", () => {
|
||||
let h: Harness;
|
||||
let api: MockMteamApi;
|
||||
let siteId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
api = await createMockMteamApi();
|
||||
h = await createHarness("mteam");
|
||||
const site = await h.db
|
||||
.insertInto("site")
|
||||
.values({
|
||||
key: "mteam",
|
||||
name: "馒头",
|
||||
adapter: "mteam_api",
|
||||
base_url: "https://kp.m-team.cc/",
|
||||
rate_limit: JSON.stringify({ minIntervalMs: 0 }),
|
||||
config: JSON.stringify({ apiBaseUrl: api.baseUrl }),
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
siteId = site.id;
|
||||
const ref = await h.secrets.create({ purpose: "site_api_key", plaintext: api.apiKey });
|
||||
await h.db
|
||||
.insertInto("site_account")
|
||||
.values({ site_id: siteId, label: "api", auth_kind: "api_key", secret_ref: ref })
|
||||
.execute();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await api?.close();
|
||||
});
|
||||
|
||||
it("API Key 站点快照:原始分类保留,促销带观察/过期时间;key 不入库", async () => {
|
||||
const r = await snapshotSite(h.rt, { siteId, kind: "periodic" });
|
||||
expect(r.status).toBe("ok");
|
||||
expect(r.itemCount).toBeGreaterThan(0);
|
||||
const rows = await h.db.selectFrom("source_release").selectAll().where("site_id", "=", siteId).execute();
|
||||
const sports = rows.find((x) => x.site_release_id === "912237");
|
||||
expect(sports?.category_raw).toBe("407");
|
||||
const promo = rows.find((x) => x.site_release_id === "912233")!;
|
||||
expect((promo.promotion as { observedAt?: string }).observedAt).toBeTruthy();
|
||||
expect(JSON.stringify(rows)).not.toContain(api.apiKey);
|
||||
});
|
||||
|
||||
it("key 失效:账号标失效 + 待处理,快照结果 failed", async () => {
|
||||
api.setFailMode("banned_key");
|
||||
const r = await snapshotSite(h.rt, { siteId, kind: "manual" });
|
||||
expect(r.status).toBe("failed");
|
||||
const acc = await h.db
|
||||
.selectFrom("site_account")
|
||||
.select(["status"])
|
||||
.where("site_id", "=", siteId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(["invalid", "expired"]).toContain(acc.status);
|
||||
const d = await h.db
|
||||
.selectFrom("decision")
|
||||
.selectAll()
|
||||
.where("dedupe_key", "=", `site-auth:${siteId}`)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(d.status).toBe("open");
|
||||
api.setFailMode(null);
|
||||
});
|
||||
|
||||
it("模块注册 4 个任务与 3 条 outbox 路由;任务处理器经 step.run 调服务", async () => {
|
||||
const reg = new TaskRegistry();
|
||||
h.module.registerTasks(reg, h.ctx);
|
||||
expect(reg.names()).toEqual(["ingestFeed", "refreshMetadata", "resolveObservation", "snapshotSite"]);
|
||||
expect(Object.keys(h.module.outboxRoutes!(h.ctx)).sort()).toEqual([
|
||||
"feed.due",
|
||||
"feed.gap_suspected",
|
||||
"release.observed",
|
||||
]);
|
||||
const def = reg.latest("resolveObservation");
|
||||
expect(def.inputSchema.safeParse({}).success).toBe(false);
|
||||
expect(def.inputSchema.safeParse({ observationId: "00000000-0000-4000-8000-000000000000" }).success).toBe(true);
|
||||
const steps: string[] = [];
|
||||
const out = await reg.latest("snapshotSite").handler({
|
||||
input: { siteId, kind: "periodic" },
|
||||
step: {
|
||||
run: async (name: string, fn: () => unknown) => {
|
||||
steps.push(name);
|
||||
return fn();
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
expect(steps).toEqual(["snapshot"]);
|
||||
expect(out).toMatchObject({ status: "skipped", reason: "auth_invalid" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
import { CapabilityRegistry } from "@umt/agent";
|
||||
import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
|
||||
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
|
||||
import { createHarness, type Harness, json, pgAvailable, seedSite } from "./harness.ts";
|
||||
|
||||
const up = await pgAvailable();
|
||||
|
||||
const twin = (id: number): TmdbFixture => ({
|
||||
type: "movie",
|
||||
id,
|
||||
original_title: "Twins",
|
||||
original_language: "en",
|
||||
origin_country: ["US"],
|
||||
date: "1988-12-09",
|
||||
titles: { "zh-CN": "龙兄鼠弟", "en-US": "Twins" },
|
||||
popularity: 10 + id / 1000,
|
||||
alternative_titles: [],
|
||||
external_ids: {},
|
||||
});
|
||||
|
||||
describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
|
||||
let h: Harness;
|
||||
let tmdb: MockTmdb;
|
||||
let siteId: string;
|
||||
let n = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, twin(111), twin(112)] });
|
||||
h = await createHarness("resolve", { tmdbUrl: tmdb.url });
|
||||
siteId = (await seedSite(h, { baseUrl: "https://audiences.example/" })).site.id;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await tmdb?.close();
|
||||
});
|
||||
|
||||
const release = async (title: string, extra: { subtitle?: string; category?: string } = {}) =>
|
||||
h.db
|
||||
.insertInto("source_release")
|
||||
.values({
|
||||
site_id: siteId,
|
||||
site_release_id: String(700000 + ++n),
|
||||
title,
|
||||
subtitle: extra.subtitle ?? null,
|
||||
category_raw: extra.category ?? null,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
let oppId: string;
|
||||
|
||||
it("auto:按 TMDB 建本地作品 + accepted 外部身份 + 别名,发 release.resolved", async () => {
|
||||
const r = await release("Oppenheimer.2023.2160p.UHD.BluRay.REMUX.HDR.HEVC-FGT");
|
||||
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id });
|
||||
expect(res.outcome).toBe("auto");
|
||||
oppId = res.mediaEntityId!;
|
||||
const e = await h.db.selectFrom("media_entity").selectAll().where("id", "=", oppId).executeTakeFirstOrThrow();
|
||||
expect(e).toMatchObject({ kind: "movie", year: 2023, status: "active" });
|
||||
const ids = await h.db.selectFrom("external_identity").selectAll().where("media_entity_id", "=", oppId).execute();
|
||||
expect(ids.map((i) => `${i.provider}:${i.entity_type}:${i.external_id}:${i.status}`).sort()).toEqual([
|
||||
"imdb:movie:tt15398776:accepted",
|
||||
"tmdb:movie:872585:accepted",
|
||||
]);
|
||||
const revs = await h.db.selectFrom("identity_revision").selectAll().execute();
|
||||
expect(revs.length).toBeGreaterThanOrEqual(2);
|
||||
const aliases = await h.db
|
||||
.selectFrom("title_alias")
|
||||
.select(["alias"])
|
||||
.where("media_entity_id", "=", oppId)
|
||||
.execute();
|
||||
expect(aliases.map((a) => a.alias)).toEqual(expect.arrayContaining(["奥本海默", "Oppenheimer"]));
|
||||
const rel = await h.db.selectFrom("source_release").selectAll().where("id", "=", r.id).executeTakeFirstOrThrow();
|
||||
expect(rel).toMatchObject({ resolution_status: "resolved", media_entity_id: oppId, parser_version: "1" });
|
||||
expect((rel.parsed as { year: number }).year).toBe(2023);
|
||||
const ev = (await h.outbox("release.resolved")).find((x) => x.payload.sourceReleaseId === r.id);
|
||||
expect(ev?.payload).toMatchObject({ mediaEntityId: oppId, evidenceLevel: "rule_confirmed", contentUnitIds: [] });
|
||||
// 幂等:证据未变的重复识别直接短路
|
||||
expect((await resolveRelease(h.rt, { sourceReleaseId: r.id })).outcome).toBe("skipped");
|
||||
});
|
||||
|
||||
it("同一作品的其它资源经本地召回复用同一实体,不重复建档", async () => {
|
||||
const r = await release("奥本海默.Oppenheimer.2023.1080p.WEB-DL.H264-GRP");
|
||||
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id });
|
||||
expect(res).toMatchObject({ outcome: "auto", mediaEntityId: oppId });
|
||||
const count = await h.db
|
||||
.selectFrom("media_entity")
|
||||
.select((eb) => eb.fn.countAll<number>().as("c"))
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(Number(count.c)).toBe(1);
|
||||
});
|
||||
|
||||
it("剧集:建 series 作品与临时季/集单元", async () => {
|
||||
const r = await release("The.Bear.S03E02.2160p.DSNP.WEB-DL.DDP5.1.H.265-FLUX", { subtitle: "熊家餐馆 第三季" });
|
||||
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id });
|
||||
expect(res.outcome).toBe("auto");
|
||||
const units = await h.db
|
||||
.selectFrom("content_unit")
|
||||
.selectAll()
|
||||
.where("media_entity_id", "=", res.mediaEntityId!)
|
||||
.orderBy("kind")
|
||||
.execute();
|
||||
expect(units.map((u) => `${u.kind}:${u.season_number}:${u.episode_number}:${u.status}`)).toEqual([
|
||||
"episode:3:2:provisional",
|
||||
"season:3:null:provisional",
|
||||
]);
|
||||
expect(res.contentUnitIds).toEqual([units[0]!.id]);
|
||||
const e = await h.db
|
||||
.selectFrom("media_entity")
|
||||
.select(["kind"])
|
||||
.where("id", "=", res.mediaEntityId!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(e.kind).toBe("series");
|
||||
});
|
||||
|
||||
let twinsRelease: string;
|
||||
it("ambiguous:进待识别池 + 待处理事项,不建正式作品", async () => {
|
||||
const before = await h.db.selectFrom("media_entity").select("id").execute();
|
||||
const r = await release("Twins.1988.1080p.BluRay.x264-GRP");
|
||||
twinsRelease = r.id;
|
||||
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id });
|
||||
expect(res.outcome).toBe("ambiguous");
|
||||
expect((await h.db.selectFrom("media_entity").select("id").execute()).length).toBe(before.length);
|
||||
const pool = await h.db
|
||||
.selectFrom("identity_candidate")
|
||||
.selectAll()
|
||||
.where("source_release_id", "=", r.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(pool).toMatchObject({ status: "pending", reason: "ambiguous", attempts: 1 });
|
||||
expect((pool.candidates as unknown[]).length).toBe(2);
|
||||
expect(pool.next_review_at!.getTime()).toBeGreaterThan(Date.now());
|
||||
const d = await h.db.selectFrom("decision").selectAll().where("id", "=", res.decisionId!).executeTakeFirstOrThrow();
|
||||
expect(d).toMatchObject({ kind: "identity_ambiguous", status: "open", subject_id: r.id });
|
||||
expect((d.options as { key: string }[]).map((o) => o.key)).toEqual(["c0", "c1", "dismiss"]);
|
||||
expect((await h.outbox("decision.created")).some((e) => e.payload.decisionId === d.id)).toBe(true);
|
||||
const rel = await h.db.selectFrom("source_release").selectAll().where("id", "=", r.id).executeTakeFirstOrThrow();
|
||||
expect(rel.resolution_status).toBe("ambiguous");
|
||||
expect(rel.media_entity_id).toBeNull();
|
||||
const attempts = await h.db
|
||||
.selectFrom("resolution_attempt")
|
||||
.selectAll()
|
||||
.where("source_release_id", "=", r.id)
|
||||
.execute();
|
||||
expect(attempts.map((a) => a.outcome)).toEqual(["ambiguous"]);
|
||||
// 同一证据再次复查:计数递增(退避加长),不重复开待处理
|
||||
await resolveRelease(h.rt, { sourceReleaseId: r.id, force: true });
|
||||
const pool2 = await h.db
|
||||
.selectFrom("identity_candidate")
|
||||
.selectAll()
|
||||
.where("source_release_id", "=", r.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(pool2.attempts).toBe(2);
|
||||
expect(pool2.next_review_at!.getTime()).toBeGreaterThan(pool.next_review_at!.getTime());
|
||||
expect(
|
||||
await h.db.selectFrom("decision").select("id").where("dedupe_key", "=", `identity:${r.id}`).execute(),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("待识别池可按原始标题检索;经待处理中心选候选 = 人工指定并加锁", async () => {
|
||||
const list = await json(await h.req("/identity-pool?q=Twins"));
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.data.items.map((i: { sourceReleaseId: string }) => i.sourceReleaseId)).toContain(twinsRelease);
|
||||
const pool = list.body.data.items.find((i: { sourceReleaseId: string }) => i.sourceReleaseId === twinsRelease);
|
||||
const res = await json(await h.req(`/decisions/${pool.decisionId}/resolve`, { body: { optionKey: "c1" } }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.status).toBe("resolved");
|
||||
const rel = await h.db
|
||||
.selectFrom("source_release")
|
||||
.selectAll()
|
||||
.where("id", "=", twinsRelease)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(rel.resolution_locked).toBe(true);
|
||||
const chosen = pool.candidates[1];
|
||||
const ident = await h.db
|
||||
.selectFrom("external_identity")
|
||||
.selectAll()
|
||||
.where("media_entity_id", "=", rel.media_entity_id!)
|
||||
.where("provider", "=", "tmdb")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(ident).toMatchObject({
|
||||
external_id: chosen.externalId,
|
||||
evidence_level: "manual",
|
||||
locked: true,
|
||||
status: "accepted",
|
||||
});
|
||||
const p = await h.db
|
||||
.selectFrom("identity_candidate")
|
||||
.selectAll()
|
||||
.where("source_release_id", "=", twinsRelease)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(p.status).toBe("resolved");
|
||||
const ev = (await h.outbox("release.resolved")).find((x) => x.payload.sourceReleaseId === twinsRelease);
|
||||
expect(ev?.payload.evidenceLevel).toBe("manual");
|
||||
// 人工锁:自动识别不再改写
|
||||
const again = await resolveRelease(h.rt, { sourceReleaseId: twinsRelease, force: true });
|
||||
expect(again.outcome).toBe("skipped_locked");
|
||||
const after = await h.db
|
||||
.selectFrom("source_release")
|
||||
.select(["media_entity_id"])
|
||||
.where("id", "=", twinsRelease)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(after.media_entity_id).toBe(rel.media_entity_id);
|
||||
});
|
||||
|
||||
it("no_match 只进池不开待处理;TMDB 失败记为 lookup_failed 而不是无结果", async () => {
|
||||
const r1 = await release("Zzqxv.Nonexistent.2019.1080p.WEB-DL-NOPE");
|
||||
const a = await resolveRelease(h.rt, { sourceReleaseId: r1.id });
|
||||
expect(a.outcome).toBe("no_match");
|
||||
expect(a.decisionId).toBeNull();
|
||||
const rel = await h.db
|
||||
.selectFrom("source_release")
|
||||
.select(["resolution_status"])
|
||||
.where("id", "=", r1.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(rel.resolution_status).toBe("unidentifiable");
|
||||
|
||||
tmdb.fault(/\/search\//, { status: 500, times: 20 });
|
||||
const r2 = await release("Qwerty.Unknown.2020.1080p.WEB-DL-NOPE");
|
||||
const b = await resolveRelease(h.rt, { sourceReleaseId: r2.id });
|
||||
expect(b.outcome).toBe("lookup_failed");
|
||||
const pool = await h.db
|
||||
.selectFrom("identity_candidate")
|
||||
.selectAll()
|
||||
.where("source_release_id", "=", r2.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(pool.reason).toBe("lookup_failed");
|
||||
// 查询失败的复查间隔短于无结果
|
||||
expect(pool.next_review_at!.getTime() - Date.now()).toBeLessThan(20 * 60_000);
|
||||
tmdb.clearFaults();
|
||||
});
|
||||
|
||||
it("自动化关闭:证据足够也只提议(proposed),进池待确认", async () => {
|
||||
await h.db
|
||||
.insertInto("settings")
|
||||
.values({ key: "automation", value: JSON.stringify({ enabled: false }) } as never)
|
||||
.execute();
|
||||
const r = await release("Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX-FraMeSToR", { subtitle: "沙丘2" });
|
||||
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id });
|
||||
expect(res.outcome).toBe("proposed");
|
||||
expect(res.decisionId).not.toBeNull();
|
||||
await h.db.deleteFrom("settings").where("key", "=", "automation").execute();
|
||||
});
|
||||
|
||||
it("refreshMetadata:字段级刷新;用户锁定字段不被覆盖,解锁后回到来源值", async () => {
|
||||
const r1 = await refreshMetadata(h.rt, oppId);
|
||||
expect(r1.status).toBe("refreshed");
|
||||
const fields = await h.db.selectFrom("metadata_field").selectAll().where("media_entity_id", "=", oppId).execute();
|
||||
expect(fields.map((f) => f.field)).toEqual(expect.arrayContaining(["title", "overview", "year"]));
|
||||
let e = await h.db.selectFrom("media_entity").selectAll().where("id", "=", oppId).executeTakeFirstOrThrow();
|
||||
expect(e.title).toBe("奥本海默");
|
||||
expect((e.metadata as Record<string, { source: string }>).overview?.source).toBe("tmdb");
|
||||
|
||||
const put = await json(
|
||||
await h.req(`/media-entities/${oppId}/fields/title`, {
|
||||
method: "PUT",
|
||||
body: { value: "奥本海默(IMAX)", reason: "偏好" },
|
||||
}),
|
||||
);
|
||||
expect(put.status).toBe(200);
|
||||
expect(put.body.data.lockedFields).toContain("title");
|
||||
const r2 = await refreshMetadata(h.rt, oppId);
|
||||
expect(r2.lockedFields).toContain("title");
|
||||
e = await h.db.selectFrom("media_entity").selectAll().where("id", "=", oppId).executeTakeFirstOrThrow();
|
||||
expect(e.title).toBe("奥本海默(IMAX)");
|
||||
expect(e.field_locks).toContain("title");
|
||||
expect((e.metadata as Record<string, { source: string; locked: boolean }>).title).toMatchObject({
|
||||
source: "user",
|
||||
locked: true,
|
||||
});
|
||||
|
||||
const del = await json(await h.req(`/media-entities/${oppId}/fields/title`, { method: "DELETE" }));
|
||||
expect(del.status).toBe(200);
|
||||
e = await h.db.selectFrom("media_entity").selectAll().where("id", "=", oppId).executeTakeFirstOrThrow();
|
||||
expect(e.title).toBe("奥本海默");
|
||||
expect(e.field_locks).not.toContain("title");
|
||||
});
|
||||
|
||||
it("人工纠错映射:旧映射退役留修订,新映射 accepted + 锁;自动匹配不改回", async () => {
|
||||
const res = await json(
|
||||
await h.req(`/media-entities/${oppId}/identities`, {
|
||||
body: { provider: "tmdb", entityType: "movie", externalId: "693134", reason: "误匹配" },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.changed).toBe(true);
|
||||
const ids = await h.db
|
||||
.selectFrom("external_identity")
|
||||
.selectAll()
|
||||
.where("media_entity_id", "=", oppId)
|
||||
.where("provider", "=", "tmdb")
|
||||
.execute();
|
||||
const old = ids.find((i) => i.external_id === "872585")!;
|
||||
expect(old).toMatchObject({ status: "retired", locked: true });
|
||||
expect(ids.find((i) => i.external_id === "693134")).toMatchObject({
|
||||
status: "accepted",
|
||||
locked: true,
|
||||
evidence_level: "manual",
|
||||
});
|
||||
const revs = await json(await h.req(`/external-identities/${old.id}/revisions`));
|
||||
expect(revs.body.data.items.map((r: { action: string }) => r.action)).toEqual(["created", "status_changed"]);
|
||||
|
||||
// 新来一条奥本海默资源:TMDB 872585 不再自动绑回该作品
|
||||
const r = await release("Oppenheimer.2023.1080p.BluRay.x264-OTHER");
|
||||
await resolveRelease(h.rt, { sourceReleaseId: r.id });
|
||||
const accepted = await h.db
|
||||
.selectFrom("external_identity")
|
||||
.select(["media_entity_id"])
|
||||
.where("provider", "=", "tmdb")
|
||||
.where("external_id", "=", "872585")
|
||||
.where("status", "=", "accepted")
|
||||
.execute();
|
||||
expect(accepted.every((a) => a.media_entity_id !== oppId)).toBe(true);
|
||||
const still = await h.db
|
||||
.selectFrom("external_identity")
|
||||
.select(["external_id"])
|
||||
.where("media_entity_id", "=", oppId)
|
||||
.where("provider", "=", "tmdb")
|
||||
.where("status", "=", "accepted")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(still.external_id).toBe("693134");
|
||||
|
||||
// 目标身份已属于其它作品 → 409
|
||||
const bear = await h.db
|
||||
.selectFrom("external_identity")
|
||||
.select(["media_entity_id"])
|
||||
.where("external_id", "=", "136315")
|
||||
.executeTakeFirstOrThrow();
|
||||
const conflict = await json(
|
||||
await h.req(`/media-entities/${oppId}/identities`, {
|
||||
body: { provider: "tmdb", entityType: "tv", externalId: "136315" },
|
||||
}),
|
||||
);
|
||||
expect(conflict.status).toBe(409);
|
||||
expect(bear.media_entity_id).not.toBe(oppId);
|
||||
});
|
||||
|
||||
it("合并重复作品:旧 ID 跳转,来源资源与别名迁到目标", async () => {
|
||||
const dup = await json(
|
||||
await h.req("/media-entities", { body: { kind: "movie", title: "奥本海默 重复", year: 2023 } }),
|
||||
);
|
||||
expect(dup.status).toBe(200);
|
||||
const dupId = dup.body.data.id;
|
||||
const r = await release("Dup.Title.2023.1080p-X");
|
||||
await h.db.updateTable("source_release").set({ media_entity_id: dupId }).where("id", "=", r.id).execute();
|
||||
const m = await json(await h.req(`/media-entities/${dupId}/merge`, { body: { intoId: oppId, reason: "重复" } }));
|
||||
expect(m.status).toBe(200);
|
||||
expect(m.body.data.id).toBe(oppId);
|
||||
const detail = await json(await h.req(`/media-entities/${dupId}/detail`));
|
||||
expect(detail.body.data.entity.id).toBe(oppId);
|
||||
expect(detail.body.data.redirectedFrom).toBe(dupId);
|
||||
expect(detail.body.data.aliases.map((a: { alias: string }) => a.alias)).toContain("奥本海默 重复");
|
||||
const moved = await h.db
|
||||
.selectFrom("source_release")
|
||||
.select(["media_entity_id"])
|
||||
.where("id", "=", r.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(moved.media_entity_id).toBe(oppId);
|
||||
const redirect = await h.db
|
||||
.selectFrom("identity_redirect")
|
||||
.selectAll()
|
||||
.where("from_entity_id", "=", dupId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(redirect.to_entity_id).toBe(oppId);
|
||||
// 再合并一次 → 409
|
||||
expect((await h.req(`/media-entities/${dupId}/merge`, { body: { intoId: oppId } })).status).toBe(409);
|
||||
});
|
||||
|
||||
it("待处理中心聚合与列表", async () => {
|
||||
const s = await json(await h.req("/decisions/summary"));
|
||||
expect(s.status).toBe(200);
|
||||
expect(s.body.data.total).toBeGreaterThanOrEqual(1);
|
||||
expect(s.body.data.groups.some((g: { kind: string }) => g.kind === "identity_ambiguous")).toBe(true);
|
||||
const l = await json(await h.req("/decisions?kind=identity_ambiguous"));
|
||||
expect(
|
||||
l.body.data.items.every((d: { status: string }) => ["open", "investigating", "awaiting_user"].includes(d.status)),
|
||||
).toBe(true);
|
||||
// 已关闭的事项不可再处理
|
||||
const closed = await h.db
|
||||
.selectFrom("decision")
|
||||
.select(["id"])
|
||||
.where("status", "=", "resolved")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect((await h.req(`/decisions/${closed.id}/resolve`, { body: { dismiss: true } })).status).toBe(409);
|
||||
});
|
||||
|
||||
it("能力:catalog.lookup / release.search 可调用;identity.resolve(propose)未授权被拒", async () => {
|
||||
const reg = new CapabilityRegistry({});
|
||||
h.module.registerCapabilities!(reg, h.ctx);
|
||||
const c = { actorId: "t", taskId: "t", scopeIds: [], signal: new AbortController().signal };
|
||||
const look = (await reg.invoke("catalog.lookup", { query: "奥本海默", includeRemote: true }, c)) as {
|
||||
local: { entity: { id: string } }[];
|
||||
remote: { externalId: string }[];
|
||||
};
|
||||
expect(look.local.map((l) => l.entity.id)).toContain(oppId);
|
||||
expect(look.remote.map((r) => r.externalId)).toContain("872585");
|
||||
const search = (await reg.invoke("release.search", { query: "Oppenheimer", allowLiveSearch: false }, c)) as {
|
||||
items: unknown[];
|
||||
};
|
||||
expect(search.items.length).toBeGreaterThan(0);
|
||||
await expect(reg.invoke("identity.resolve", { sourceReleaseId: twinsRelease }, c)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user