Files
gitmost/apps/server/test/integration/search-ru-en-migration.int-spec.ts
T
agent_coder 747b125912 chore(search): retimestamp ru_en migration to 20260707T130000 to avoid collision (#529)
Develop introduced two migrations at the exact 20260707T120000 stamp
(ai-chat-metadata, page-history-kind). Three files sharing one timestamp
is fragile for deploy ordering, so move #529's ru_en-config migration to a
strictly-later, unique 20260707T130000 so it orders cleanly last. It touches
only the ru_en text-search config (different tables), so running it after the
develop migrations is order-independent and safe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:23:33 +03:00

112 lines
4.7 KiB
TypeScript

import { Kysely, sql } from 'kysely';
import { getTestDb, destroyTestDb } from './db';
import * as migration from '../../src/database/migrations/20260707T130000-search-ru-en-config';
/**
* #529 A1 — the ru_en config migration must be REVERSIBLE in the correct order:
* down() moves pages.tsv + page_embeddings.fts back to `english` BEFORE dropping
* the ru_en config (a generated column still depending on ru_en would block the
* DROP). This roundtrips down()→up() on the already-migrated test DB and asserts
* the config, the pages trigger and the fts generated expression each flip and
* flip back — the deploy-critical property.
*/
describe('search ru_en config migration [integration]', () => {
let db: Kysely<any>;
const configExists = async () => {
const r = await sql<{ n: number }>`
SELECT count(*)::int AS n FROM pg_ts_config WHERE cfgname = 'ru_en'
`.execute(db);
return r.rows[0].n > 0;
};
const ftsDef = async () => {
const r = await sql<{ def: string }>`
SELECT pg_get_expr(adbin, adrelid) AS def
FROM pg_attrdef d
JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum
WHERE a.attname = 'fts' AND a.attrelid = 'page_embeddings'::regclass
`.execute(db);
return r.rows[0]?.def ?? '';
};
const triggerSrc = async () => {
const r = await sql<{ src: string }>`
SELECT prosrc AS src FROM pg_proc WHERE proname = 'pages_tsvector_trigger'
`.execute(db);
return r.rows[0]?.src ?? '';
};
beforeAll(() => {
db = getTestDb();
});
afterAll(async () => {
// Restore the canonical ru_en state for any later suite regardless of where
// a test left off. up() is now safe on an existing config (ensureRuEnConfig
// no-ops) and re-asserts both stored sides to ru_en.
delete process.env.SEARCH_EMBEDDINGS_FTS_INLINE_REWRITE;
await migration.up(db);
await destroyTestDb();
});
it('starts at ru_en (config present, trigger + fts on ru_en)', async () => {
expect(await configExists()).toBe(true);
expect(await ftsDef()).toContain('ru_en');
expect(await triggerSrc()).toContain('ru_en');
});
it('down() reverts tsv + fts to english THEN drops the config', async () => {
await migration.down(db);
expect(await configExists()).toBe(false);
expect(await ftsDef()).toContain('english');
expect(await ftsDef()).not.toContain('ru_en');
expect(await triggerSrc()).toContain('english');
});
it('up() re-applies ru_en cleanly (idempotent config create)', async () => {
await migration.up(db);
expect(await configExists()).toBe(true);
expect(await ftsDef()).toContain('ru_en');
expect(await triggerSrc()).toContain('ru_en');
});
// B1.1 — running up() a SECOND time on an already-migrated DB is a true no-op:
// it must NOT throw (the old drop-recreate-config would fail on the fts hard
// dependency) and must NOT re-rewrite the embeddings table — the fts column
// already references ru_en, so the at-target check short-circuits.
it('up() is idempotent: a 2nd run does not error and leaves ru_en intact', async () => {
await expect(migration.up(db)).resolves.toBeUndefined();
expect(await configExists()).toBe(true);
expect(await ftsDef()).toContain('ru_en');
expect(await triggerSrc()).toContain('ru_en');
});
// B1.2 — env-gate opt-out: with SEARCH_EMBEDDINGS_FTS_INLINE_REWRITE=false the
// embeddings rewrite is SKIPPED (fts stays where it was) but pages.tsv still
// swaps inline, and the ru_en config is left in place (fts still depends on it).
// Runs down() as the vehicle: english is NOT the current fts config, so the
// skip path — not the at-target no-op — is exercised.
it('env-gate=false: skips the fts rewrite but still swaps pages.tsv', async () => {
// Precondition: fts + trigger on ru_en (from the prior test).
expect(await ftsDef()).toContain('ru_en');
process.env.SEARCH_EMBEDDINGS_FTS_INLINE_REWRITE = 'false';
try {
await migration.down(db);
// fts rewrite skipped → still ru_en (the ACCESS EXCLUSIVE rewrite avoided).
expect(await ftsDef()).toContain('ru_en');
expect(await ftsDef()).not.toContain('english');
// pages.tsv trigger still swapped inline to english (gate is fts-only).
expect(await triggerSrc()).toContain('english');
// Config left in place because fts still references it (guarded drop).
expect(await configExists()).toBe(true);
} finally {
// Restore ru_en fully for the afterAll / later suites.
delete process.env.SEARCH_EMBEDDINGS_FTS_INLINE_REWRITE;
await migration.up(db);
expect(await ftsDef()).toContain('ru_en');
expect(await triggerSrc()).toContain('ru_en');
}
});
});