Files
picpeak/backend/__tests__/integration/picpeakCrossEngine.test.js
T
Luca 8809564aad feat(backup): open sqlite → pg .picpeak restore as the supported upgrade direction (#1041) (#1043)
Reshaped onto main after #1039 landed the coercion engine
(typedColumnsFor / epochToIso / coerceForTargetEngine) — this PR is now
only the policy delta on top of it:

- validateManifest: replace the CLI-only allowEngineSwitch opt-in with a
  direction rule — sqlite → pg allowed (upload UI and CLI alike),
  pg → sqlite refused with a message naming the supported direction
- importFromPicpeak: derive crossEngine from the manifest's engine
  (absent field = target engine, the exact pre-change behavior), log it,
  return it; route passes it through
- scripts/migrate-sqlite-to-postgres.js: rely on the shared gate, drop
  the flag
- restore card: direction stated in the intro, cross-engine notice after
  a converting restore; both strings in en.json + de.json; removed the
  orphaned settings.backup.picpeak locale node (unreferenced, stale copy)
- picpeakCrossEngine.test.js: direction policy, epochToIso (ms, seconds,
  numeric strings), coerceForTargetEngine units, plus
  PICPEAK_PG_TEST_URL-gated real-Postgres stored-value assertions

Co-authored-by: Paul Nothaft <53005142+the-luap@users.noreply.github.com>
2026-08-16 19:01:36 +02:00

198 lines
7.9 KiB
JavaScript

'use strict';
/**
* Cross-engine .picpeak restore policy (#1041): a SQLite archive restored onto
* a PostgreSQL instance — the official small-install → full-stack upgrade
* path — now allowed by validateManifest's direction rule instead of the
* former CLI-only allowEngineSwitch flag. The coercion engine itself
* (typedColumnsFor / epochToIso / coerceForTargetEngine) landed with #1039;
* these tests pin the direction policy and the coercion's cross-engine
* value-correctness.
*
* Ungated: validateManifest direction rules and the pure coercion units.
* The reverse direction (pg backup onto a sqlite instance) staying blocked is
* pinned by picpeakRoundtrip.test.js, which runs on the real sqlite harness.
*
* Gated on PICPEAK_PG_TEST_URL (same contract as picpeakRestorePg.test.js):
* sqlite-shaped NDJSON rows land in real Postgres with correct stored VALUES,
* not just row counts, e.g.
* PICPEAK_PG_TEST_URL="postgres://picpeak:pw@127.0.0.1:7102/picpeak_xengine_test" \
* npx jest __tests__/integration/picpeakCrossEngine.test.js
*/
const knexLib = require('knex');
describe('validateManifest cross-engine direction (pg target)', () => {
let validateManifest;
beforeAll(() => {
jest.resetModules();
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
// validateManifest wraps its knex_migrations lookup in try/catch — a
// throwing stub simply skips the forward-only check, which is not under
// test here.
jest.doMock('../../src/database/db', () => ({ db: () => { throw new Error('stub'); } }));
({ validateManifest } = require('../../src/services/picpeakImportService'));
});
afterAll(() => {
jest.dontMock('../../src/database/db');
jest.dontMock('../../knexfile');
jest.resetModules();
});
it('allows a sqlite backup onto a pg instance (upgrade direction)', async () => {
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1, database: { engine: 'sqlite' }, tables: {},
});
expect(blockers.filter((b) => /engine/i.test(b))).toHaveLength(0);
});
it('still allows same-engine pg → pg', async () => {
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {},
});
expect(blockers.filter((b) => /engine/i.test(b))).toHaveLength(0);
});
});
describe('epochToIso (landed with #1039)', () => {
let epochToIso;
beforeAll(() => {
jest.resetModules();
({ epochToIso } = require('../../src/services/picpeakImportService'));
});
it('converts epoch milliseconds', () => {
expect(epochToIso(1723400000000)).toBe('2024-08-11T18:13:20.000Z');
});
it('converts epoch SECONDS to the same instant, not January 1970', () => {
expect(epochToIso(1723400000)).toBe('2024-08-11T18:13:20.000Z');
});
it('converts numeric strings', () => {
expect(epochToIso('1723400000000')).toBe('2024-08-11T18:13:20.000Z');
});
it('passes non-numeric values through untouched', () => {
expect(epochToIso('2026-08-12 10:00:00')).toBe('2026-08-12 10:00:00');
});
});
describe('coerceForTargetEngine on sqlite-shaped rows', () => {
let coerceForTargetEngine;
beforeAll(() => {
jest.resetModules();
({ coerceForTargetEngine } = require('../../src/services/picpeakImportService'));
});
const types = { timestamps: ['created_at', 'expires_at'], booleans: ['is_active'] };
it('coerces 0/1 booleans and epoch timestamps, leaves date strings alone', () => {
const [row] = coerceForTargetEngine(
[{ id: 1, is_active: 1, created_at: 1723400000000, expires_at: '2026-09-01 12:00:00' }],
types
);
expect(row.is_active).toBe(true);
expect(row.created_at).toBe('2024-08-11T18:13:20.000Z');
expect(row.expires_at).toBe('2026-09-01 12:00:00'); // pg parses this natively
});
it('coerces falsy variants and passes null/empty through', () => {
const [row] = coerceForTargetEngine(
[{ is_active: 0, created_at: null, expires_at: '' }],
types
);
expect(row.is_active).toBe(false);
expect(row.created_at).toBeNull();
expect(row.expires_at).toBe('');
});
});
// ── Real-Postgres integration (gated) ────────────────────────────────────────
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
const maybe = PG_URL ? describe : describe.skip;
maybe('sqlite-shaped rows land correctly in real Postgres', () => {
let pgDb;
let svc;
beforeAll(async () => {
pgDb = knexLib({ client: 'pg', connection: PG_URL });
await pgDb.raw('DROP TABLE IF EXISTS xengine_events, xengine_settings CASCADE');
await pgDb.schema.createTable('xengine_events', (t) => {
t.increments('id');
t.string('slug');
t.boolean('is_active').defaultTo(true);
t.boolean('allow_downloads').defaultTo(true);
t.timestamp('created_at');
t.timestamp('expires_at');
});
await pgDb.schema.createTable('xengine_settings', (t) => {
t.increments('id');
t.string('setting_key').notNullable().unique();
t.jsonb('setting_value');
});
jest.resetModules();
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
jest.doMock('../../src/database/db', () => ({ db: pgDb }));
svc = require('../../src/services/picpeakImportService');
});
afterAll(async () => {
jest.dontMock('../../src/database/db');
jest.dontMock('../../knexfile');
if (pgDb) {
await pgDb.raw('DROP TABLE IF EXISTS xengine_events, xengine_settings CASCADE');
await pgDb.destroy();
}
});
it('typedColumnsFor classifies boolean and timestamp columns via columnInfo()', async () => {
const types = await svc.typedColumnsFor(pgDb, 'xengine_events');
expect(types.booleans.sort()).toEqual(['allow_downloads', 'is_active']);
expect(types.timestamps.sort()).toEqual(['created_at', 'expires_at']);
});
it('inserts a sqlite archive row (0/1 booleans, epoch dates, json text) with correct stored values', async () => {
// Exactly what a sqlite-created .picpeak carries: integers for booleans,
// epoch numbers for #485-shape timestamps (ms here, seconds covered by the
// epochToIso unit), a "YYYY-MM-DD HH:MM:SS" string for clean ones, and
// json columns as TEXT (the crossEngine path skips serialiseJsonColumns —
// the text is already what pg wants).
const epoch = 1723400000000;
const eventRows = [
{ id: 1, slug: 'wedding', is_active: 1, allow_downloads: 0, created_at: epoch, expires_at: '2026-09-01 12:00:00' },
];
const settingRows = [{ id: 1, setting_key: 'brand', setting_value: '{"name":"PicPeak","dark":true}' }];
await pgDb.transaction(async (trx) => {
const evTypes = await svc.typedColumnsFor(trx, 'xengine_events');
await trx.batchInsert('xengine_events', svc.coerceForTargetEngine(eventRows, evTypes), 100);
const stTypes = await svc.typedColumnsFor(trx, 'xengine_settings');
await trx.batchInsert('xengine_settings', svc.coerceForTargetEngine(settingRows, stTypes), 100);
});
const ev = await pgDb('xengine_events').where({ id: 1 }).first();
expect(ev.is_active).toBe(true); // 1 → true, not backwards (#1028 class)
expect(ev.allow_downloads).toBe(false); // 0 → false
expect(new Date(ev.created_at).getTime()).toBe(epoch);
expect(new Date(ev.expires_at).toISOString().slice(0, 10)).toBe('2026-09-01');
const st = await pgDb('xengine_settings').where({ id: 1 }).first();
// jsonb parsed back by the driver — value intact, no double encoding.
expect(st.setting_value).toEqual({ name: 'PicPeak', dark: true });
});
it('id sequence works after explicit-id insert + resync (next natural insert)', async () => {
await svc.resyncSequences(['xengine_events']);
const [next] = await pgDb('xengine_events')
.insert({ slug: 'fresh', is_active: true })
.returning('id');
expect(Number(next.id || next)).toBe(2);
});
});