Files
UHD_Media_Tool/apps/server/test/sources-identity/api.test.ts
T
rootandClaude Opus 5.5 4a92dfd2dc feat(feeds): site-scoped RSS add infers account, parser and poll
Adding a feed from a site page now only asks for the URL, purpose
(discover / track / promotion) and an optional note. POST /feed-sources
with siteId infers the site account (passkey holder first), records the
site definition version as parserVersion, defaults the name to
"<site> RSS" and polling to 30 minutes, and returns non-blocking
warnings when the URL host does not match the site. The dialog shows the
redacted URL, fetches once right after saving and reports the item count
or the error.

The generic feed form on the sources page suggests linking a registered
site when the URL host matches (can be undone). name is now optional.

Takes effect after the stack is rebuilt.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 14:43:19 +08:00

283 lines
14 KiB
TypeScript

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("站点内添加 Feed:只给地址即可,账号 / 解析器 / 名称按站点推断;域名不符只提示", async () => {
const acc = (await json(await h.req(`/site-accounts?siteId=${siteId}`))).body.data.items[0];
const same = await json(
await h.req("/feed-sources", { body: { siteId, url: "https://audiences.me/torrentrss.php?passkey=k1" } }),
);
expect(same.status).toBe(200);
expect(same.body.data).toMatchObject({
name: "观众 RSS",
siteAccountId: acc.id,
purpose: "generic",
pollIntervalSec: 1800,
});
expect(same.body.data.parserVersion).toMatch(/^rss@1;audiences@/);
expect(same.body.data.warnings).toBeUndefined();
const other = await json(
await h.req("/feed-sources", {
body: { siteId, purpose: "promotion", name: "促销", url: "https://elsewhere.example/rss?passkey=k2" },
}),
);
expect(other.status).toBe(200);
expect(other.body.data.name).toBe("促销");
expect(other.body.data.warnings?.[0]).toContain("elsewhere.example");
const bare = await json(await h.req("/feed-sources", { body: { url: "https://any.example/feed.xml" } }));
expect(bare.body.data).toMatchObject({
name: "any.example",
siteId: null,
siteAccountId: null,
parserVersion: "1",
});
});
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();
// 限流间隔 > 0 时令牌在 PG 跨进程共享(rate_limit_bucket,key = site:<id>);0 = 不限流只走本地
await h.db
.updateTable("site")
.set({ rate_limit: JSON.stringify({ minIntervalMs: 5, concurrency: 5 }) })
.where("id", "=", siteId)
.execute();
expect((await snapshotSite(h.rt, { siteId, kind: "manual" })).status).toBe("ok");
const bucket = await h.db
.selectFrom("rate_limit_bucket")
.selectAll()
.where("key", "=", `site:${siteId}`)
.executeTakeFirst();
expect(bucket).toMatchObject({ capacity: 5, refill_per_sec: 200 });
});
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"]),
);
});
});