Files
smartmigration/test/test.run.mongo.ts
T

289 lines
9.5 KiB
TypeScript

import { tap, expect } from '@git.zone/tstest/tapbundle';
import { makeTestDb } from './helpers/services.js';
import type * as smartdata from '@push.rocks/smartdata';
import { SmartMigration, SmartMigrationError } from '../ts/index.js';
// Shared db across the file (see test.mongoledger.ts for the rationale).
// Each test uses a unique ledgerName for isolation.
let db: smartdata.SmartdataDb;
let cleanup: () => Promise<void>;
async function setLedgerVersion(ledgerName: string, currentVersion: string): Promise<void> {
await db.mongoDb.collection('SmartdataEasyStore').updateOne(
{ nameId: `smartmigration:${ledgerName}` },
{
$set: {
nameId: `smartmigration:${ledgerName}`,
data: {
currentVersion,
steps: {},
lock: { holder: null, acquiredAt: null, expiresAt: null },
checkpoints: {},
},
},
},
{ upsert: true },
);
}
tap.test('setup: connect shared db', async () => {
const r = await makeTestDb('run_mongo');
db = r.db;
cleanup = r.cleanup;
});
tap.test('run: applies all steps from scratch', async () => {
const m = new SmartMigration({ targetVersion: '2.0.0', db, ledgerName: 'scratch' });
const log: string[] = [];
m
.step('a').from('1.0.0').to('1.1.0').up(async () => { log.push('a'); })
.step('b').from('1.1.0').to('1.5.0').up(async () => { log.push('b'); })
.step('c').from('1.5.0').to('2.0.0').up(async () => { log.push('c'); });
const r = await m.run();
expect(r.stepsApplied).toHaveLength(3);
expect(log).toEqual(['a', 'b', 'c']);
expect(r.currentVersionAfter).toEqual('2.0.0');
expect(r.wasUpToDate).toBeFalse();
expect(r.targetVersion).toEqual('2.0.0');
});
tap.test('run: second run is a no-op', async () => {
const m = new SmartMigration({ targetVersion: '2.0.0', db, ledgerName: 'idempotent' });
const log: string[] = [];
m
.step('a').from('1.0.0').to('1.1.0').up(async () => { log.push('a'); })
.step('b').from('1.1.0').to('2.0.0').up(async () => { log.push('b'); });
const r1 = await m.run();
expect(r1.stepsApplied).toHaveLength(2);
expect(log).toEqual(['a', 'b']);
// Re-run with a freshly-constructed runner using the same ledger.
const m2 = new SmartMigration({ targetVersion: '2.0.0', db, ledgerName: 'idempotent' });
m2
.step('a').from('1.0.0').to('1.1.0').up(async () => { log.push('a-again'); })
.step('b').from('1.1.0').to('2.0.0').up(async () => { log.push('b-again'); });
const r2 = await m2.run();
expect(r2.wasUpToDate).toBeTrue();
expect(r2.stepsApplied).toHaveLength(0);
expect(log).toEqual(['a', 'b']); // no new entries
});
tap.test('run: failing step throws and persists failure', async () => {
const m = new SmartMigration({ targetVersion: '2.0.0', db, ledgerName: 'failure' });
let aCalled = false;
m
.step('a').from('1.0.0').to('1.1.0').up(async () => { aCalled = true; })
.step('b').from('1.1.0').to('2.0.0').up(async () => { throw new Error('boom'); });
let caught: SmartMigrationError | undefined;
try {
await m.run();
} catch (err) {
caught = err as SmartMigrationError;
}
expect(caught).toBeInstanceOf(SmartMigrationError);
expect(caught!.code).toEqual('STEP_FAILED');
expect(aCalled).toBeTrue();
// Ledger should be at 1.1.0 (after step a, before failed step b).
const current = await m.getCurrentVersion();
expect(current).toEqual('1.1.0');
});
tap.test('run: ctx exposes db, mongo, and step metadata', async () => {
const m = new SmartMigration({ targetVersion: '1.1.0', db, ledgerName: 'ctx' });
let observed: { hasDb: boolean; hasMongo: boolean; stepId: string; from: string; to: string } | null = null;
m.step('check-ctx').from('1.0.0').to('1.1.0').up(async (ctx) => {
observed = {
hasDb: !!ctx.db,
hasMongo: !!ctx.mongo,
stepId: ctx.step.id,
from: ctx.step.fromVersion,
to: ctx.step.toVersion,
};
// exercise raw mongo
await ctx.mongo!.collection('users_ctx_test').insertOne({ name: 'alice' });
});
await m.run();
expect(observed).not.toBeNull();
expect(observed!.hasDb).toBeTrue();
expect(observed!.hasMongo).toBeTrue();
expect(observed!.stepId).toEqual('check-ctx');
expect(observed!.from).toEqual('1.0.0');
expect(observed!.to).toEqual('1.1.0');
// verify the data the step wrote
const found = await db.mongoDb.collection('users_ctx_test').findOne({ name: 'alice' });
expect(found).not.toBeNull();
});
tap.test('run: ctx.startSession works inside a step', async () => {
const m = new SmartMigration({ targetVersion: '1.1.0', db, ledgerName: 'session' });
let sessionWasUsed = false;
m.step('with-session').from('1.0.0').to('1.1.0').up(async (ctx) => {
const session = ctx.startSession();
try {
sessionWasUsed = !!session.id;
await session.endSession();
} catch (err) {
await session.endSession();
throw err;
}
});
await m.run();
expect(sessionWasUsed).toBeTrue();
});
tap.test('run: lock heartbeat prevents concurrent execution of a slow step', async () => {
let activeSteps = 0;
let maxActiveSteps = 0;
let totalCalls = 0;
const createRunner = () => {
const m = new SmartMigration({
targetVersion: '1.1.0',
db,
ledgerName: 'lock_heartbeat',
lockTtlMs: 100,
lockWaitMs: 1_500,
});
m.step('slow-step').from('1.0.0').to('1.1.0').up(async () => {
totalCalls++;
activeSteps++;
maxActiveSteps = Math.max(maxActiveSteps, activeSteps);
await new Promise((resolve) => setTimeout(resolve, 900));
activeSteps--;
});
return m;
};
const firstRun = createRunner().run();
await new Promise((resolve) => setTimeout(resolve, 20));
const secondRun = createRunner().run();
const [firstResult, secondResult] = await Promise.all([firstRun, secondRun]);
expect(firstResult.stepsApplied).toHaveLength(1);
expect(secondResult.wasUpToDate).toBeTrue();
expect(totalCalls).toEqual(1);
expect(maxActiveSteps).toEqual(1);
});
tap.test('run: strict target strategy rejects target beyond registered chain', async () => {
await setLedgerVersion('strict_target_gap', '2.0.0');
const m = new SmartMigration({ targetVersion: '3.0.0', db, ledgerName: 'strict_target_gap' });
m.step('a').from('1.0.0').to('2.0.0').up(async () => {});
let caught: SmartMigrationError | undefined;
try {
await m.run();
} catch (err) {
caught = err as SmartMigrationError;
}
expect(caught).toBeInstanceOf(SmartMigrationError);
expect(caught!.code).toEqual('TARGET_NOT_REACHABLE');
});
tap.test('run: bridge target strategy stamps from past chain end to target', async () => {
await setLedgerVersion('bridge_from_past_chain_end', '2.5.0');
let realStepCalled = false;
const m = new SmartMigration({
targetVersion: '3.0.0',
db,
ledgerName: 'bridge_from_past_chain_end',
targetVersionStrategy: 'bridge',
});
m.step('a').from('1.0.0').to('2.0.0').up(async () => { realStepCalled = true; });
const r = await m.run();
expect(realStepCalled).toBeFalse();
expect(r.stepsApplied).toHaveLength(1);
expect(r.stepsApplied[0].id).toEqual('smartmigration-auto-bridge-to-3.0.0');
expect(r.stepsApplied[0].fromVersion).toEqual('2.0.0');
expect(r.currentVersionAfter).toEqual('3.0.0');
expect(await m.getCurrentVersion()).toEqual('3.0.0');
});
tap.test('run: bridge target strategy runs real migrations before bridge', async () => {
await setLedgerVersion('bridge_after_real_steps', '1.0.0');
const log: string[] = [];
const m = new SmartMigration({
targetVersion: '3.0.0',
db,
ledgerName: 'bridge_after_real_steps',
targetVersionStrategy: 'bridge',
});
m.step('a').from('1.0.0').to('2.0.0').up(async () => { log.push('a'); });
const r = await m.run();
expect(log).toEqual(['a']);
expect(r.stepsApplied.map((step) => step.id)).toEqual([
'a',
'smartmigration-auto-bridge-to-3.0.0',
]);
expect(r.currentVersionAfter).toEqual('3.0.0');
});
tap.test('plan: bridge target strategy is read-only', async () => {
await setLedgerVersion('bridge_plan_readonly', '2.5.0');
const m = new SmartMigration({
targetVersion: '3.0.0',
db,
ledgerName: 'bridge_plan_readonly',
targetVersionStrategy: 'bridge',
});
m.step('a').from('1.0.0').to('2.0.0').up(async () => {});
const r = await m.plan();
expect(r.stepsSkipped).toHaveLength(1);
expect(r.stepsSkipped[0].id).toEqual('smartmigration-auto-bridge-to-3.0.0');
expect(r.currentVersionAfter).toEqual('3.0.0');
expect(await m.getCurrentVersion()).toEqual('2.5.0');
});
tap.test('run: future real migration applies after a bridged version stamp', async () => {
await setLedgerVersion('bridge_future_migration', '2.5.0');
const bridgeRunner = new SmartMigration({
targetVersion: '3.0.0',
db,
ledgerName: 'bridge_future_migration',
targetVersionStrategy: 'bridge',
});
bridgeRunner.step('a').from('1.0.0').to('2.0.0').up(async () => {});
await bridgeRunner.run();
let futureStepCalled = false;
const futureRunner = new SmartMigration({
targetVersion: '4.0.0',
db,
ledgerName: 'bridge_future_migration',
targetVersionStrategy: 'bridge',
});
futureRunner
.step('a').from('1.0.0').to('2.0.0').up(async () => {})
.step('future').from('2.0.0').to('4.0.0').up(async () => { futureStepCalled = true; });
const r = await futureRunner.run();
expect(futureStepCalled).toBeTrue();
expect(r.stepsApplied.map((step) => step.id)).toEqual(['future']);
expect(r.currentVersionAfter).toEqual('4.0.0');
});
tap.test('cleanup: close shared db', async () => {
await cleanup();
});
export default tap.start();