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]>
91 lines
4.5 KiB
TypeScript
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 }));
|
|
});
|
|
});
|