import { describe, expect, it } from 'bun:test'; import { planSteps, runInstall, type InstallEffects, type StepName } from './installer'; import { byId } from './catalogue'; // The whole install machine, tested without Docker, Postgres, PM2 or an Immich to talk to — which is // the reason the effects are injected. What is being pinned here is the behaviour that only shows up // when something goes wrong: resume, blocking, and not doing work twice. const photos = byId('photos')!; // existing|provisioned, members: 'accounts' const transmission = byId('transmission')!; // existing|provisioned, members: 'none' const email = byId('email')!; // config only /** Records what was actually called, so a test can assert on absence as well as presence. */ function spyEffects(over: Partial = {}) { const calls: string[] = []; const effects: InstallEffects = { preflight: async () => { calls.push('preflight'); return { ok: true }; }, provision: async () => { calls.push('provision'); return { results: { url: 'http://127.0.0.1:18091' }, composeDir: '/root/dockers/x' }; }, connect: async () => { calls.push('connect'); return { status: 'done' }; }, applySchema: async () => void calls.push('schema'), startProcess: async () => void calls.push('process'), provisionMembers: async () => void calls.push('members'), ...over, }; return { effects, calls }; } describe('planSteps', () => { it('never provisions when pointing at an instance the user already runs', () => { // The guarantee that matters: choosing 'existing' cannot start a container. Enforced here rather // than remembered at each call site. expect(planSteps(photos, 'existing')).not.toContain('provision'); expect(planSteps(photos, 'provisioned')).toContain('provision'); }); it('omits the members step for a service with no user concept', () => { // Otherwise a Transmission install reports a members step that did nothing, which reads as a // silent failure to anyone debugging why a member has no access. expect(planSteps(transmission, 'provisioned')).not.toContain('members'); expect(planSteps(photos, 'provisioned')).toContain('members'); }); it('has nothing to connect for a config-only install', () => { expect(planSteps(email, 'config')).toEqual(['preflight', 'schema', 'process']); }); it('always starts with preflight', () => { // Checking the host before anything is written is the whole reason a half-install is avoidable. for (const mode of ['existing', 'provisioned', 'config'] as const) { expect(planSteps(photos, mode)[0]).toBe('preflight'); } }); }); describe('a clean run', () => { it('executes the plan in order and reports installed', async () => { const { effects, calls } = spyEffects(); const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects }); expect(out.status).toBe('installed'); expect(calls).toEqual(['preflight', 'provision', 'connect', 'schema', 'process', 'members']); }); it('feeds one step’s results forward to the next', async () => { // `provision` discovers the URL that `connect` writes down two steps later. Without this the // installer would have to ask the user for something it already knows. let seen: Record = {}; const { effects } = spyEffects({ connect: async (ctx) => { seen = { ...ctx.values }; return { status: 'done' }; }, }); await runInstall({ entry: photos, mode: 'provisioned', values: { given: 'yes' }, effects }); expect(seen.url).toBe('http://127.0.0.1:18091'); expect(seen.composeDir).toBe('/root/dockers/x'); expect(seen.given).toBe('yes'); }); it('does not mutate the caller’s values', async () => { const values = { given: 'yes' }; const { effects } = spyEffects(); await runInstall({ entry: photos, mode: 'provisioned', values, effects }); expect(values).toEqual({ given: 'yes' }); }); }); describe('resuming', () => { it('skips what an earlier attempt already did', async () => { // The point of persisting completedSteps: a resume must not provision a second container. const { effects, calls } = spyEffects(); const done: StepName[] = ['preflight', 'provision', 'connect']; const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, completed: done, effects }); expect(out.status).toBe('installed'); expect(calls).toEqual(['schema', 'process', 'members']); expect(calls).not.toContain('provision'); }); it('reports every completed step, including the ones it skipped', async () => { const { effects } = spyEffects(); const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, completed: ['preflight'], effects, }); expect(out.completed).toEqual(['preflight', 'provision', 'connect', 'schema', 'process', 'members']); }); }); describe('blocking on a human', () => { it('stops without failing, and does NOT mark the blocking step done', async () => { // Immich: the container is up and healthy, and only its own UI can mint an API key. Recording // `connect` as complete would mean a resume skipped the very step that is waiting. const { effects, calls } = spyEffects({ connect: async () => ({ status: 'blocked', reason: 'Needs an API key', completeAt: 'http://x/keys' }), }); const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects }); expect(out.status).toBe('blocked'); if (out.status !== 'blocked') throw new Error('unreachable'); expect(out.at).toBe('connect'); expect(out.completeAt).toBe('http://x/keys'); expect(out.completed).toEqual(['preflight', 'provision']); // Everything after the block is untouched — no process started against a service we cannot reach. expect(calls).not.toContain('process'); }); it('re-runs the blocking step on resume, once the human has answered', async () => { const { effects, calls } = spyEffects(); const out = await runInstall({ entry: photos, mode: 'provisioned', values: { secret: 'now-provided' }, completed: ['preflight', 'provision'], effects, }); expect(out.status).toBe('installed'); expect(calls).toContain('connect'); }); }); describe('failing', () => { it('stops at the failing step and keeps what came before', async () => { const { effects, calls } = spyEffects({ applySchema: async () => { throw new Error('relation already exists'); }, }); const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects }); expect(out.status).toBe('failed'); if (out.status !== 'failed') throw new Error('unreachable'); expect(out.at).toBe('schema'); expect(out.error).toBe('relation already exists'); // Resumable: the three that worked are recorded, so a retry does not redo them. expect(out.completed).toEqual(['preflight', 'provision', 'connect']); expect(calls).not.toContain('process'); }); it('treats a failed preflight as a failure before anything is written', async () => { const { effects, calls } = spyEffects({ preflight: async () => { calls.push('preflight'); return { ok: false, reason: 'Docker is not installed.', remedy: 'Install it.' }; }, }); const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects }); expect(out.status).toBe('failed'); if (out.status !== 'failed') throw new Error('unreachable'); expect(out.at).toBe('preflight'); // The remedy travels with the reason: "Docker is not installed" without "install it" is a dead end. expect(out.error).toContain('Install it.'); // Nothing provisioned, so there is nothing to unwind — the entire reason preflight goes first. expect(calls).toEqual(['preflight']); }); it('turns a thrown effect into a recorded failure rather than an escape', async () => { // An effect that throws is a bug in that effect. If it escaped, the row would be stranded in // `installing` with nothing to resume from. const { effects } = spyEffects({ startProcess: async () => { throw new Error('pm2 not found'); }, }); const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects }); expect(out.status).toBe('failed'); if (out.status !== 'failed') throw new Error('unreachable'); expect(out.at).toBe('process'); }); });