Files
rootandClaude Opus 5.5 ec65435328 feat(server): gate scheduling, search and login by site capabilities (IA-2)
- snapshots only for sites with list; feed polling only for RSS kinds;
  user data / health checks only for sites with login; search site set
  only includes searchable sites; live search / snapshot skip with
  reason unsupported instead of hitting the site
- refresh-login / credentials need login, manual snapshot needs list (400)
- POST /sites accepts kind and rssUrl (site + feed in one transaction,
  key generated from host); RSS belongs to a site: feeds created without
  siteId go to the same-host custom RSS source site, siteId cannot be
  cleared
- fix: secret name clash when a site gets a second default-named feed
- command palette regrouped; agent site.list returns kind and
  capabilities; system prompt notes RSS lives under sites

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

309 lines
16 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");
// IA-2:RSS 属于站点。不带站点新建 → 归入同主机的「自定义 RSS 源」站点(没有就建一个)
const bare = await json(await h.req("/feed-sources", { body: { url: "https://any.example/feed.xml" } }));
expect(bare.body.data).toMatchObject({ name: "any.example RSS", siteAccountId: null, parserVersion: "rss@1" });
const bareSite = await json(await h.req(`/sites/${bare.body.data.siteId}`));
expect(bareSite.body.data).toMatchObject({
key: "rss-any-example",
name: "any.example",
kind: "rss_feed",
adapter: "generic_rss",
capabilities: ["rss"],
searchDefault: false,
});
const again = await json(await h.req("/feed-sources", { body: { url: "https://any.example/other.xml" } }));
expect(again.body.data.siteId).toBe(bare.body.data.siteId);
// 不能把 RSS 移出站点
const detach = await h.req(`/feed-sources/${bare.body.data.id}`, { method: "PATCH", body: { siteId: null } });
expect(detach.status).toBe(400);
});
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 });
// 手动快照要 5 页也只抓访问默认的页数上限(settings.sites.defaultSnapshotMaxPages 缺省 1)
expect((await snapshotSite(h.rt, { siteId, kind: "manual", maxPages: 5 })).pages).toEqual([1]);
// 每拿到一个令牌计一次请求,按小时聚合;站点页读最近 24 小时
await new Promise((r) => setTimeout(r, 50));
const stats = await json(await h.req(`/sites/${siteId}/request-stats`));
expect(stats.status).toBe(200);
expect(stats.body.data.last24h).toBeGreaterThanOrEqual(4);
expect(stats.body.data.hourly).toHaveLength(24);
expect(stats.body.data.access).toMatchObject({ snapshotMaxPages: 1, healthCheckIntervalSec: 21600 });
});
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();
// 详情缓存期内(settings.sites.defaultDetailCacheSec 缺省 24 小时)再次刷新不请求站点
const before = mock.requests.length;
const cached = await json(await h.req(`/sites/${siteId}/releases/${rel.id}?refresh=true`));
expect(cached.body.data.refreshError).toBeNull();
expect(mock.requests.length).toBe(before);
// 缓存过期后才重新抓
await h.db.updateTable("source_release").set({ detail_fetched_at: null }).where("id", "=", rel.id).execute();
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"]),
);
});
});