From 17aa9304c06fe98819bb0756bdcca0766222d031 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 23:31:20 +0800 Subject: [PATCH] =?UTF-8?q?test(identity):=20=E8=AF=86=E5=88=AB=E5=87=86?= =?UTF-8?q?=E7=A1=AE=E5=BA=A6=E4=B8=8E=E5=A4=B1=E8=B4=A5=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=E6=80=A7=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bgm 核对、缓存相容、分组键、组内相容、季表、快照失败事项、复查耗尽事项、详情失败不写负缓存、 改映射后旧别名不再召回、合并作品迁移单元。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../identity-accuracy.test.ts | 478 ++++++++++++++++++ 1 file changed, 478 insertions(+) create mode 100644 apps/server/test/sources-identity/identity-accuracy.test.ts diff --git a/apps/server/test/sources-identity/identity-accuracy.test.ts b/apps/server/test/sources-identity/identity-accuracy.test.ts new file mode 100644 index 00000000..159b41b5 --- /dev/null +++ b/apps/server/test/sources-identity/identity-accuracy.test.ts @@ -0,0 +1,478 @@ +/** + * 识别准确度与失败可见性(T4):bgm 搜索结果要与资源名称核对;人工指定回填的识别缓存不扩散给年份 / 类型不相容的同名资源; + * 合并事项按年份 / 类型分组,组内不相容的资源不随组锁定;临时季不进季表;快照连续失败 / TMDB 复查耗尽开待处理; + * 召回拉详情失败不写负缓存;改 TMDB 映射后旧 tmdb 别名不再召回;合并作品时内容单元迁到目标作品。全部走 mock,零真实请求。 + */ +import { SiteError } from "@umt/adapters"; +import { sql } from "@umt/db"; +import { parseRelease } from "@umt/domain"; +import { createMockTmdb, type MockTmdb, type TmdbFixture } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { releaseFitsEntity } from "../../src/modules/sources-identity/routes/decisions.ts"; +import type { DoubanSubject } from "../../src/modules/sources-identity/service/douban-subject.ts"; +import { loadSeasonTable } from "../../src/modules/sources-identity/service/episode-resolution.ts"; +import { + assignReleaseIdentity, + mergeEntities, + setEntityIdentity, +} from "../../src/modules/sources-identity/service/identity-admin.ts"; +import { identityGroupKey } from "../../src/modules/sources-identity/service/identity-decisions.ts"; +import { pickBangumiSubject } from "../../src/modules/sources-identity/service/meta-subjects.ts"; +import { decisionWanted, resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts"; +import { SNAPSHOT_FAIL_DECISION_AT, snapshotSite } from "../../src/modules/sources-identity/service/snapshot.ts"; +import { entryCompatible } from "../../src/modules/sources-identity/service/tmdb-recognize-cache.ts"; +import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts"; + +const pg = await pgAvailable(); + +const subject = (id: string, title: string, aliases: string[] = [], year: number | null = 2023): DoubanSubject => ({ + id, + title, + aliases, + year, + source: "bangumi_api", + ref: `https://bgm.tv/subject/${id}`, + at: "2026-10-01T00:00:00.000Z", +}); + +describe("纯函数:bgm 核对 / 缓存相容 / 分组键 / 组内相容 / 事项开关", () => { + it("bgm 搜索结果:第一条名称对不上不采用;唯一同名才采用;多个同名靠年份区分,区分不了不采用", () => { + const wrong = subject("1", "迷宫芙莉莲", ["迷宮のフリーレン"]); + const right = subject("2", "葬送的芙莉莲", ["葬送のフリーレン", "Sousou no Frieren"]); + expect(pickBangumiSubject(["Sousou no Frieren"], undefined, [wrong])).toBeNull(); + expect(pickBangumiSubject(["Sousou no Frieren"], undefined, [wrong, right])?.subject.id).toBe("2"); + // 第二季条目去掉季名后也同名,但第一季条目是完全同名:取完全同名的 + const s2 = subject("3", "葬送的芙莉莲 第二季", [], 2026); + expect(pickBangumiSubject(["葬送的芙莉莲"], undefined, [s2, right])?.subject.id).toBe("2"); + // 两个完全同名(重制版):资源年份能区分才采用 + const remake = subject("4", "葬送的芙莉莲", [], 2030); + expect(pickBangumiSubject(["葬送的芙莉莲"], undefined, [right, remake])).toBeNull(); + expect(pickBangumiSubject(["葬送的芙莉莲"], 2030, [right, remake])?.subject.id).toBe("4"); + }); + + it("识别缓存条目与资源相容:年份 ±1(剧集允许更晚),剧集线索不配电影,负缓存总相容", () => { + const p = (t: string) => parseRelease(t); + expect(entryCompatible(p("Echo Valley 2025 1080p"), { id: 1, type: "movie", year: "2025" })).toBe(true); + expect(entryCompatible(p("Echo Valley 2010 1080p"), { id: 1, type: "movie", year: "2025" })).toBe(false); + expect(entryCompatible(p("Echo Valley S01E01 2010"), { id: 1, type: "tv", year: "2025" })).toBe(false); + expect(entryCompatible(p("Echo Valley S03E01 2027"), { id: 1, type: "tv", year: "2025" })).toBe(true); + expect(entryCompatible(p("Echo Valley S01E01"), { id: 1, type: "movie", year: "2025" })).toBe(false); + expect(entryCompatible(p("Echo Valley 2010"), { id: 0 })).toBe(true); + }); + + it("合并事项分组键带年份与剧集线索;组内资源与所选作品的相容判定", () => { + expect(identityGroupKey("twins", parseRelease("Twins.1988.1080p.BluRay"))).toBe("twins:1988"); + expect(identityGroupKey("show", parseRelease("Show.S01E02.1080p"))).toBe("show:tv"); + expect(identityGroupKey("show", parseRelease("Show 1080p WEB-DL"))).toBe("show"); + expect(releaseFitsEntity(parseRelease("Twins.1988.1080p"), { kind: "movie", year: 1988 })).toBe(true); + expect(releaseFitsEntity(parseRelease("Twins.2016.1080p"), { kind: "movie", year: 1988 })).toBe(false); + expect(releaseFitsEntity(parseRelease("Twins.S01E01.1080p"), { kind: "movie", year: 1988 })).toBe(false); + expect(releaseFitsEntity(parseRelease("Twins.S03E01.2020"), { kind: "series", year: 2016 })).toBe(true); + expect(releaseFitsEntity(parseRelease("Twins.1080p"), { kind: "movie", year: 1988 })).toBe(true); + }); + + it("进池开事项:查询失败复查耗尽不论是否命中本地作品;无结果命中本地作品就开;升级推迟中不开", () => { + expect(decisionWanted("lookup_failed", 0, false, null, false)).toBe(true); + expect(decisionWanted("lookup_failed", 0, true, 60_000, false)).toBe(false); + expect(decisionWanted("no_match", 0, true, 60_000, false)).toBe(true); + expect(decisionWanted("no_match", 0, false, null, false)).toBe(false); + expect(decisionWanted("no_match", 0, true, null, true)).toBe(false); + expect(decisionWanted("ambiguous", 2, false, 60_000, false)).toBe(true); + }); +}); + +const fx = ( + type: "movie" | "tv", + id: number, + en: string, + zh: string, + date: string, + alt: string[] = [], +): TmdbFixture => ({ + type, + id, + original_title: en, + original_language: "en", + origin_country: ["US"], + date, + titles: { "zh-CN": zh, "en-US": en }, + popularity: 10, + alternative_titles: alt.map((title) => ({ iso_3166_1: "US", title, type: "" })), + external_ids: {}, + ...(type === "tv" + ? { + seasons: [ + { season_number: 1, air_date: date, names: { "zh-CN": "第 1 季", "en-US": "Season 1" }, episodes: [] }, + ], + } + : {}), +}); + +describe.skipIf(!pg)("识别准确度与失败可见性", () => { + let h: Harness; + let tmdb: MockTmdb; + let siteId: string; + let n = 0; + const USER = { type: "user" as const, id: "tester" }; + + beforeAll(async () => { + tmdb = await createMockTmdb({ + fixtures: [ + // 同名两部剧集:2025 与 2010 + fx("tv", 880001, "Echo Valley", "回声谷", "2025-03-01"), + fx("tv", 880002, "Echo Valley", "回声谷", "2010-03-01"), + // 同名电影各两部:1995 与 2016(各自同年同名 → 歧义) + fx("movie", 880011, "Harbor Lights", "港湾灯火", "1995-05-01"), + fx("movie", 880012, "Harbor Lights", "港口之光", "1995-08-01"), + fx("movie", 880013, "Harbor Lights", "海港之灯", "2016-05-01"), + fx("movie", 880014, "Harbor Lights", "港灯", "2016-08-01"), + // 英文名只在 TMDB 别名里:不取详情就对不上 + fx("movie", 880021, "Glass Field", "玻璃原野", "2024-04-01", ["Quartz Meadow"]), + // 改映射:旧作品带别名 Copper Harbour,新作品没有 + fx("movie", 880031, "Copper Bay", "铜湾", "2020-06-01", ["Copper Harbour"]), + fx("movie", 880032, "Iron Bay", "铁湾", "2020-06-01"), + ], + }); + h = await createHarness("identity_accuracy", { tmdbUrl: tmdb.url }); + siteId = (await seedSite(h, { key: "accuracy", baseUrl: "https://accuracy.example/" })).site.id; + }); + afterAll(async () => { + await h?.close(); + await tmdb?.close(); + }); + + const release = (title: string) => + h.db + .insertInto("source_release") + .values({ site_id: siteId, site_release_id: `acc-${++n}`, title, raw: JSON.stringify({}) } as never) + .returningAll() + .executeTakeFirstOrThrow(); + const row = (id: string) => + h.db.selectFrom("source_release").selectAll().where("id", "=", id).executeTakeFirstOrThrow(); + const tmdbIdOf = async (entityId: string) => + ( + await h.db + .selectFrom("external_identity") + .select("external_id") + .where("media_entity_id", "=", entityId) + .where("provider", "=", "tmdb") + .where("status", "=", "accepted") + .executeTakeFirstOrThrow() + ).external_id; + const recognizeKeys = async () => + (await sql<{ key: string }>`select key from kv_cache where region = 'tmdb.recognize'`.execute(h.db)).rows.map( + (r) => r.key, + ); + + it("H2:人工指定不扩散——无年份的资源不回填缓存,同名剧集照常召回出两个候选而不是只剩被指定的那部", async () => { + const a = await release("Echo Valley S01E01 1080p WEB-DL-GRP"); + expect((await resolveRelease(h.rt, { sourceReleaseId: a.id, trigger: "search" })).outcome).toBe("ambiguous"); + await assignReleaseIdentity( + h.rt, + a.id, + { external: { provider: "tmdb", entityType: "tv", externalId: "880001" }, lock: true }, + USER, + ); + expect(await recognizeKeys()).not.toContain("[电视剧][zh-CN]Echo Valley-None-1"); + const b = await release("Echo Valley S01E02 1080p WEB-DL-GRP"); + const rb = await resolveRelease(h.rt, { sourceReleaseId: b.id, trigger: "search" }); + expect(rb.outcome).toBe("ambiguous"); + expect((await row(b.id)).media_entity_id).toBeNull(); + }); + + it("H2:指定到年份不相容的作品不回填;同名不同年份的资源按自己的年份认到另一部", async () => { + const a = await release("Echo Valley 2010 S01E01 1080p HDTV-OLD"); + await assignReleaseIdentity( + h.rt, + a.id, + { external: { provider: "tmdb", entityType: "tv", externalId: "880001" }, lock: true }, + USER, + ); + expect(await recognizeKeys()).not.toContain("[电视剧][zh-CN]Echo Valley-2010-1"); + const b = await release("Echo Valley 2010 S01E05 1080p HDTV-OLD"); + const rb = await resolveRelease(h.rt, { sourceReleaseId: b.id, trigger: "search" }); + expect(rb.outcome).toBe("auto"); + expect(await tmdbIdOf(rb.mediaEntityId!)).toBe("880002"); + }); + + it("H2:用户指定且年份相容的照常回填(同年同名命中);Agent 的指定不回填", async () => { + const a = await release("Echo Valley 2025 S01E03 2160p WEB-DL-NEW"); + await assignReleaseIdentity( + h.rt, + a.id, + { external: { provider: "tmdb", entityType: "tv", externalId: "880001" }, lock: true }, + USER, + ); + expect(await recognizeKeys()).toContain("[电视剧][zh-CN]Echo Valley-2025-1"); + const c = await release("Echo Valley 2025 S02E01 2160p WEB-DL-NEW"); + await assignReleaseIdentity( + h.rt, + c.id, + { external: { provider: "tmdb", entityType: "tv", externalId: "880001" }, lock: true }, + { type: "agent", id: "agent-1" }, + ); + expect(await recognizeKeys()).not.toContain("[电视剧][zh-CN]Echo Valley-2025-2"); + }); + + it("H3:同名不同年份分成两个合并事项;旧的宽分组里年份不相容的资源不随组锁定,留在事项里", async () => { + const ids: string[] = []; + for (const t of [ + "Harbor.Lights.1995.1080p.BluRay-AAA", + "Harbor.Lights.1995.720p.BluRay-BBB", + "Harbor.Lights.2016.1080p.WEB-DL-CCC", + ]) { + const r = await release(t); + ids.push(r.id); + expect((await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" })).outcome).toBe("ambiguous"); + } + const decisions = await h.db + .selectFrom("decision") + .select(["id", "dedupe_key", "subject_id"]) + .where("dedupe_key", "like", `identity-group:${siteId}:harborlights%`) + .where("status", "=", "open") + .execute(); + expect(decisions.map((d) => d.dedupe_key).sort()).toEqual([ + `identity-group:${siteId}:harborlights:1995`, + `identity-group:${siteId}:harborlights:2016`, + ]); + // 模拟升级前按「站点 + 标题」建的宽分组:2016 的资源也挂在 1995 的事项上 + const d1995 = decisions.find((d) => d.dedupe_key.endsWith(":1995"))!; + await h.db + .updateTable("identity_candidate") + .set({ decision_id: d1995.id }) + .where("source_release_id", "=", ids[2]!) + .execute(); + const res = await h.req(`/decisions/${d1995.id}/resolve`, { body: { optionKey: "c0" } }); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { status: string; subject_id?: string; subjectId?: string } }; + expect(body.data.status).toBe("open"); + const r1995 = await Promise.all([row(ids[0]!), row(ids[1]!)]); + expect(r1995.every((r) => r.media_entity_id && r.resolution_locked)).toBe(true); + expect(r1995[0]!.media_entity_id).toBe(r1995[1]!.media_entity_id); + const r2016 = await row(ids[2]!); + expect(r2016.media_entity_id).toBeNull(); + expect(r2016.resolution_locked).toBe(false); + const after = await h.db.selectFrom("decision").selectAll().where("id", "=", d1995.id).executeTakeFirstOrThrow(); + expect(after.status).toBe("open"); + expect(after.subject_id).toBe(ids[2]); + }); + + it("季表只计 TMDB / 人工 / 已确认的季:资源懒建的临时季不进季表、不抬高最新季", async () => { + const e = await h.db + .insertInto("media_entity") + .values({ kind: "series", title: "季表测试" }) + .returning("id") + .executeTakeFirstOrThrow(); + await h.db + .insertInto("content_unit") + .values([ + { + media_entity_id: e.id, + kind: "season", + season_number: 1, + episode_total: 10, + status: "confirmed", + source: "tmdb", + }, + { media_entity_id: e.id, kind: "season", season_number: 2, status: "provisional", source: "user" }, + { media_entity_id: e.id, kind: "season", season_number: 5, status: "provisional", source: "release" }, + { media_entity_id: e.id, kind: "season", season_number: 6, status: "provisional", source: "file" }, + ]) + .execute(); + const table = await loadSeasonTable(h.db, e.id); + expect(table.seasons.map((s) => s.season)).toEqual([1, 2]); + expect(table.latestSeason).toBe(2); + }); + + it("快照连续失败 N 次开站点不可达事项(按站点去重),恢复后自动关闭", async () => { + // 用有站点定义(支持列表抓取)的 key,适配器换成会失败的假实现 + const { site } = await seedSite(h, { key: "hhanclub", baseUrl: "https://hhanclub.example/" }); + let fail = true; + const orig = h.rt.siteAdapter.bind(h.rt); + // biome-ignore lint/suspicious/noExplicitAny: 测试替换适配器 + (h.rt as any).siteAdapter = (s: { id: string }) => + s.id === site.id + ? { + definitionVersion: "test", + list: async () => { + if (fail) throw new SiteError(site.id, "challenge", "Cloudflare 验证页"); + return { items: [], totalPages: 1 }; + }, + } + : orig(s as never); + const open = () => + h.db + .selectFrom("decision") + .select(["id", "kind", "status"]) + .where("dedupe_key", "=", `site-snapshot:${site.id}`) + .where("status", "=", "open") + .execute(); + for (let i = 1; i < SNAPSHOT_FAIL_DECISION_AT; i++) { + expect((await snapshotSite(h.rt, { siteId: site.id, kind: "periodic" })).status).toBe("failed"); + expect(await open()).toHaveLength(0); + } + expect((await snapshotSite(h.rt, { siteId: site.id, kind: "periodic" })).status).toBe("failed"); + expect(await open()).toEqual([expect.objectContaining({ kind: "source_unreachable" })]); + // 再失败一次不重复开 + await snapshotSite(h.rt, { siteId: site.id, kind: "periodic" }); + expect(await open()).toHaveLength(1); + fail = false; + expect((await snapshotSite(h.rt, { siteId: site.id, kind: "periodic" })).status).toBe("ok"); + expect(await open()).toHaveLength(0); + const closed = await h.db + .selectFrom("decision") + .select(["status", "resolution"]) + .where("dedupe_key", "=", `site-snapshot:${site.id}`) + .executeTakeFirstOrThrow(); + expect(closed).toMatchObject({ status: "resolved", resolution: { by: "snapshot_recovered" } }); + // biome-ignore lint/suspicious/noExplicitAny: 还原 + (h.rt as any).siteAdapter = orig; + }); + + it("TMDB 查询失败复查耗尽:不命中本地作品的资源也开待处理", async () => { + const r = await release("Zephyr Quill 2026 1080p WEB-DL-ZZ"); + tmdb.fault(/\/3\/search\//, { status: 401, times: 100 }); + try { + const first = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" }); + expect(first.outcome).toBe("lookup_failed"); + expect(first.decisionId).toBeNull(); + // 已复查 7 次(同一证据):这次是第 8 次,停止自动复查 + await h.db.updateTable("identity_candidate").set({ attempts: 7 }).where("source_release_id", "=", r.id).execute(); + const last = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "review", force: true }); + expect(last.outcome).toBe("lookup_failed"); + expect(last.decisionId).not.toBeNull(); + const d = await h.db + .selectFrom("decision") + .select(["kind", "status", "summary"]) + .where("id", "=", last.decisionId!) + .executeTakeFirstOrThrow(); + expect(d).toMatchObject({ kind: "identity_unresolved", status: "open" }); + expect(d.summary).toMatch(/元数据查询多次失败/); + const pool = await h.db + .selectFrom("identity_candidate") + .select(["next_review_at"]) + .where("source_release_id", "=", r.id) + .executeTakeFirstOrThrow(); + expect(pool.next_review_at).toBeNull(); + } finally { + tmdb.clearFaults(); + } + }); + + it("召回时候选拉详情失败:这一轮无匹配不写负缓存;详情恢复后照常认出", async () => { + const r = await release("Quartz Meadow 2024 1080p WEB-DL-QM"); + tmdb.fault(/\/3\/movie\/880021/, { status: 404, times: 100 }); + try { + const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" }); + expect(res.outcome).toBe("no_match"); + expect(await recognizeKeys()).not.toContain("[未知][zh-CN]Quartz Meadow-2024-None"); + } finally { + tmdb.clearFaults(); + } + const again = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" }); + expect(again.outcome).toBe("auto"); + expect(await tmdbIdOf(again.mediaEntityId!)).toBe("880021"); + }); + + it("改 TMDB 映射:旧作品的 tmdb 别名删除、人工别名保留;旧别名的同名资源不再本地召回到这部作品", async () => { + const r = await release("Copper Bay 2020 1080p BluRay-CB"); + const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" }); + expect(res.outcome).toBe("auto"); + const id = res.mediaEntityId!; + await h.db + .insertInto("title_alias") + .values({ media_entity_id: id, alias: "铜湾人工名", normalized: "铜湾人工名", source: "user" }) + .execute(); + const aliases = async () => + ( + await h.db.selectFrom("title_alias").select(["alias", "source"]).where("media_entity_id", "=", id).execute() + ).map((a) => `${a.source}:${a.alias}`); + expect(await aliases()).toContain("tmdb:Copper Harbour"); + await setEntityIdentity( + h.rt, + id, + { provider: "tmdb", entityType: "movie", externalId: "880032", lock: true }, + USER, + ); + const after = await aliases(); + expect(after.filter((a) => a.startsWith("tmdb:"))).toEqual([]); + expect(after).toContain("user:铜湾人工名"); + // 旧作品的别名 Copper Harbour:不再命中本地作品(未订阅、不查 TMDB,只进池) + const old = await release("Copper Harbour 2020 1080p WEB-DL-CH"); + const ro = await resolveRelease(h.rt, { sourceReleaseId: old.id }); + expect(ro.outcome).toBe("unmatched"); + expect((await row(old.id)).media_entity_id).toBeNull(); + }); + + it("合并作品:被合并作品的内容单元迁到目标,同季同集的并入目标已有单元,资源引用跟着走", async () => { + const mk = async (title: string) => + ( + await h.db + .insertInto("media_entity") + .values({ kind: "series", title }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + const from = await mk("合并源"); + const into = await mk("合并目标"); + const unit = async (entity: string, kind: "season" | "episode", season: number, episode: number | null) => + ( + await h.db + .insertInto("content_unit") + .values({ + media_entity_id: entity, + kind, + season_number: season, + episode_number: episode, + status: "provisional", + source: "release", + }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + await unit(from, "season", 1, null); + const fromE1 = await unit(from, "episode", 1, 1); + const fromE3 = await unit(from, "episode", 1, 3); + await unit(into, "season", 1, null); + const intoE1 = await unit(into, "episode", 1, 1); + const r1 = await release("合并源 S01E01"); + const r3 = await release("合并源 S01E03"); + await h.db + .updateTable("source_release") + .set({ media_entity_id: from, content_unit_id: fromE1 }) + .where("id", "=", r1.id) + .execute(); + await h.db + .updateTable("source_release") + .set({ media_entity_id: from, content_unit_id: fromE3 }) + .where("id", "=", r3.id) + .execute(); + + await mergeEntities(h.rt, from, into, "重复作品", USER); + const left = await h.db + .selectFrom("content_unit") + .select("id") + .where("media_entity_id", "=", from) + .where("status", "<>", "retired") + .execute(); + expect(left).toHaveLength(0); + const units = await h.db + .selectFrom("content_unit") + .select(["id", "kind", "season_number", "episode_number"]) + .where("media_entity_id", "=", into) + .orderBy("kind", "desc") + .orderBy("episode_number") + .execute(); + expect(units.map((u) => `${u.kind}:${u.season_number}:${u.episode_number}`)).toEqual([ + "season:1:null", + "episode:1:1", + "episode:1:3", + ]); + const a1 = await row(r1.id); + const a3 = await row(r3.id); + expect(a1).toMatchObject({ media_entity_id: into }); + expect([intoE1, fromE1]).toContain(a1.content_unit_id); + expect(units.map((u) => u.id)).toContain(a1.content_unit_id); + expect(a3).toMatchObject({ media_entity_id: into, content_unit_id: fromE3 }); + }); +});