- GET /inventory/media-servers: per-instance Promise.allSettled, per-instance
concurrency cap (2) and timeout (8s); failed/timed-out instances are unknown,
response carries partial=true (field not yet in contracts)
- S-7: POST /files/identify returns local matches plus remoteTaskId/remotePending
and enqueues identifyRemote; the worker runs the TMDB lookups and publishes SSE
identify.completed {taskId,status,items,truncated,excludedBySize,error};
?wait=true keeps the synchronous path, capped at 15s overall
- catalog.lookup / release.search honour the caller's abort signal
- scripts/dev-db-clean.ts: dry-run by default, --yes to drop leftover test
schemas and w32-/w33-/fe4-/qa- prefixed entities; refuses non localhost:55433
- runbook: dev DB cleanup
Co-Authored-By: Claude Opus 5.5 <[email protected]>
105 lines
4.2 KiB
TypeScript
105 lines
4.2 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
createHarness,
|
|
type Harness,
|
|
pgAvailable,
|
|
seedEntity,
|
|
} from "../../src/modules/acquisition-organize/__tests__/harness.ts";
|
|
import { MEDIA_SERVER_LIMITS } from "../../src/modules/acquisition-organize/routes/library.ts";
|
|
|
|
// C1-D:GET /inventory/media-servers 按实例并行(allSettled)+ 实例内并发上限 + 整实例超时;部分失败 partial=true。
|
|
|
|
const hasPg = await pgAvailable();
|
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
describe.skipIf(!hasPg)("GET /inventory/media-servers 并行 + 超时 + partial", () => {
|
|
let h: Harness;
|
|
let entityId: string;
|
|
const ids: Record<string, string> = {};
|
|
const saved = { ...MEDIA_SERVER_LIMITS };
|
|
const inflight: Record<string, { now: number; max: number }> = {};
|
|
|
|
beforeAll(async () => {
|
|
h = await createHarness("msinv");
|
|
MEDIA_SERVER_LIMITS.timeoutMs = 400;
|
|
MEDIA_SERVER_LIMITS.perInstanceConcurrency = 2;
|
|
entityId = await seedEntity(h.db, { kind: "movie", title: "并行库存", year: 2020 });
|
|
await h.db
|
|
.insertInto("external_identity")
|
|
.values(
|
|
["tmdb", "imdb", "tvdb"].map((provider, i) => ({
|
|
media_entity_id: entityId,
|
|
provider,
|
|
entity_type: "movie",
|
|
external_id: `x${i}`,
|
|
status: "accepted",
|
|
})) as never,
|
|
)
|
|
.execute();
|
|
for (const name of ["a-ok", "b-slow", "c-boom"]) {
|
|
const r = await h.db
|
|
.insertInto("media_server_instance")
|
|
.values({ name, base_url: `http://${name}.invalid` })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
ids[name] = r.id;
|
|
inflight[name] = { now: 0, max: 0 };
|
|
}
|
|
h.d.ports.mediaServer = (async (inst: { name: string }) => {
|
|
if (inst.name === "c-boom") throw new Error("connect refused");
|
|
return {
|
|
async findByProviderId(provider: string) {
|
|
const f = inflight[inst.name]!;
|
|
f.now++;
|
|
f.max = Math.max(f.max, f.now);
|
|
try {
|
|
await sleep(inst.name === "b-slow" ? 5_000 : 60);
|
|
return provider === "Tmdb"
|
|
? {
|
|
status: "present" as const,
|
|
items: [{ itemId: "i1", type: "Movie", name: "并行库存", year: 2020, path: "/m", versions: [] }],
|
|
}
|
|
: { status: "not_found" as const };
|
|
} finally {
|
|
f.now--;
|
|
}
|
|
},
|
|
};
|
|
}) as never;
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
Object.assign(MEDIA_SERVER_LIMITS, saved);
|
|
await h?.close();
|
|
});
|
|
|
|
it("实例并行、慢实例超时、异常实例 unknown,整体 partial", async () => {
|
|
const t0 = Date.now();
|
|
const r = await h.api("GET", `/inventory/media-servers?mediaEntityId=${entityId}`);
|
|
const took = Date.now() - t0;
|
|
expect(r.status).toBe(200);
|
|
type Inst = { name: string; status: string; reason: string | null; items: unknown[] };
|
|
const by = Object.fromEntries((r.body.data.instances as Inst[]).map((x) => [x.name, x]));
|
|
expect(by["a-ok"]).toMatchObject({ status: "present", reason: null });
|
|
expect(by["a-ok"]!.items).toHaveLength(1);
|
|
expect(by["b-slow"]).toMatchObject({ status: "unknown" });
|
|
expect(by["b-slow"]!.reason).toMatch(/^timeout/);
|
|
expect(by["c-boom"]).toMatchObject({ status: "unknown", reason: "connect refused" });
|
|
expect(r.body.data.partial).toBe(true);
|
|
// 串行时至少 0.4s(b 超时)+ 3×60ms;并行应接近单个超时
|
|
expect(took).toBeLessThan(2_000);
|
|
// 实例内并发上限 2(3 个外部 ID)
|
|
expect(inflight["a-ok"]!.max).toBe(2);
|
|
});
|
|
|
|
it("全部成功时 partial=false;无外部 ID 的 unknown 不算失败", async () => {
|
|
await h.db.updateTable("media_server_instance").set({ enabled: false }).where("name", "!=", "a-ok").execute();
|
|
const r = await h.api("GET", `/inventory/media-servers?mediaEntityId=${entityId}`);
|
|
expect(r.body.data.partial).toBe(false);
|
|
const bareId = await seedEntity(h.db, { kind: "movie", title: "无外部ID", year: 2021 });
|
|
const r2 = await h.api("GET", `/inventory/media-servers?mediaEntityId=${bareId}`);
|
|
expect(r2.body.data.instances[0]).toMatchObject({ status: "unknown", reason: "no_external_id" });
|
|
expect(r2.body.data.partial).toBe(false);
|
|
});
|
|
});
|