Files
UHD_Media_Tool/apps/server/test/sources-identity/migration-site-kind.test.ts
T
rootandClaude Opus 5.5 89d2c81de6 feat(sources): site kinds and capabilities (IA-2)
site.kind: pt / public_bt / rss_feed / torznab. Each kind declares the
acquisition modes it may support (search, list, rss, detail, login,
userinfo); a site's effective capabilities = kind ∩ site definition.
torznab only defines TorznabSiteConfig and cannot be added yet.

Migration 0055: generic_rss sites become rss_feed, the rest pt; feeds
without a site are grouped by host into new custom RSS source sites
(cursor window and conditional headers cleared so the next fetch writes
releases). User DB read-only dry-run: 0 orphan feeds.

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

91 lines
4.5 KiB
TypeScript

import { createMigrator, type DB, migrateToLatest, sql } from "@umt/db";
import { Kysely, PostgresDialect } from "kysely";
import pg from "pg";
import { afterAll, describe, expect, it } from "vitest";
import { PG_URL, pgAvailable, withSchemaLock } from "../auth-settings/harness.ts";
// 0055:site.kind;generic_rss 站点 → rss_feed;不属于任何站点的 RSS 按主机归入「自定义 RSS 源」站点。
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("0055 站点类型迁移", () => {
const schema = `ia2_mig_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const admin = async (q: string) => {
const c = new pg.Client({ connectionString: PG_URL });
await c.connect();
await c.query(q).finally(() => c.end());
};
let db: Kysely<DB>;
afterAll(async () => {
await db?.destroy();
await withSchemaLock(() => admin(`drop schema if exists ${schema} cascade`));
});
it("存量站点定类型;孤立 RSS 按主机建站点、清游标窗口;回滚只删列", async () => {
await admin(`create schema ${schema}`);
db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: PG_URL, max: 2, options: `-c search_path=${schema},public` }),
}),
});
await withSchemaLock(async () => {
const r = await createMigrator(db, { migrationTableSchema: schema }).migrateTo("0054_download_task_entity");
if (r.error) throw r.error;
});
await sql`insert into site (key, name, adapter, base_url) values
('audiences', '观众', 'nexusphp', 'https://audiences.example/'),
('myrss', '旧自定义', 'generic_rss', 'https://old.example/'),
('rss-nyaa-si', '占用', 'generic_rss', 'https://taken.example/')`.execute(db);
const cursor = JSON.stringify({ window: { lastGuidSet: ["a"], lastFetchedAt: "2026-09-01T00:00:00Z" } });
await sql`insert into feed_source (site_id, name, url_display, etag, cursor) values
(null, 'A', 'https://nyaa.si/?page=rss&q=frieren', 'W/"1"', ${cursor}::jsonb),
(null, 'B', 'https://nyaa.si/?page=rss&q=dune', null, ${cursor}::jsonb),
(null, 'C', 'https://www.example.org/feed?passkey=[REDACTED]', null, '{}'::jsonb),
((select id from site where key = 'audiences'), 'D', 'https://audiences.example/rss', 'W/"2"', ${cursor}::jsonb)`.execute(
db,
);
await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: schema }));
const sites = await sql<{ key: string; name: string; kind: string; base_url: string; search_default: boolean }>`
select key, name, kind, base_url, search_default from site order by key`.execute(db);
expect(sites.rows).toEqual([
{ key: "audiences", name: "观众", kind: "pt", base_url: "https://audiences.example/", search_default: true },
{ key: "myrss", name: "旧自定义", kind: "rss_feed", base_url: "https://old.example/", search_default: true },
{
key: "rss-example-org",
name: "www.example.org",
kind: "rss_feed",
base_url: "https://www.example.org",
search_default: false,
},
{ key: "rss-nyaa-si", name: "占用", kind: "rss_feed", base_url: "https://taken.example/", search_default: true },
{ key: "rss-nyaa-si-2", name: "nyaa.si", kind: "rss_feed", base_url: "https://nyaa.si", search_default: false },
]);
const feeds = await sql<{ name: string; key: string; etag: string | null; cursor: Record<string, unknown> }>`
select f.name, s.key, f.etag, f.cursor from feed_source f join site s on s.id = f.site_id order by f.name`.execute(
db,
);
expect(feeds.rows.map((f) => [f.name, f.key])).toEqual([
["A", "rss-nyaa-si-2"],
["B", "rss-nyaa-si-2"],
["C", "rss-example-org"],
["D", "audiences"],
]);
// 迁移过来的 RSS 清掉窗口与条件请求头(下次抓取重新写成资源);原本属于站点的不动
expect(feeds.rows[0]).toMatchObject({ etag: null, cursor: {} });
expect(feeds.rows[3]!.etag).toBe('W/"2"');
expect(feeds.rows[3]!.cursor).toHaveProperty("window");
await withSchemaLock(async () => {
const r = await createMigrator(db, { migrationTableSchema: schema }).migrateTo("0054_download_task_entity");
if (r.error) throw r.error;
});
const cols = await sql<{ n: number }>`select count(*)::int as n from information_schema.columns
where table_schema = ${schema} and table_name = 'site' and column_name = 'kind'`.execute(db);
expect(cols.rows[0]!.n).toBe(0);
await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: schema }));
});
});