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; 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({ 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 }>` 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 })); }); });