diff --git a/src/utils/__tests__/fs.test.ts b/src/utils/__tests__/fs.test.ts index 5109a54..b0d0b62 100644 --- a/src/utils/__tests__/fs.test.ts +++ b/src/utils/__tests__/fs.test.ts @@ -3,6 +3,8 @@ import { assertExists, assertStringIncludes, } from 'https://deno.land/std@0.224.0/assert/mod.ts'; +import { stub } from 'https://deno.land/std@0.224.0/testing/mock.ts'; +import { exists } from 'https://deno.land/std@0.224.0/fs/mod.ts'; import { join } from 'https://deno.land/std@0.224.0/path/mod.ts'; import { resolveUnitTargetPath, writeUnitFiles } from '../mod.ts'; import { TimerOptions } from '../../types/options.ts'; @@ -57,3 +59,227 @@ Deno.test('resolveUnitTargetPath ohne output und ohne user', () => { const result = resolveUnitTargetPath({ output: undefined, user: false }); assertEquals(result, '/etc/systemd/system'); }); + +Deno.test('writeUnitFiles: Fehler beim Schreiben der .timer-Datei führt zu Rollback', async () => { + const tmp = await Deno.makeTempDir(); + + const options: TimerOptions = { + output: tmp, + user: false, + exec: '/bin/true', + calendar: 'never', + }; + + const name = 'fail-timer'; + const serviceContent = '[Service]\nExecStart=/bin/true'; + const timerContent = '[Timer]\nOnCalendar=never'; + + const servicePath = join(tmp, `${name}.service`); + const timerPath = join(tmp, `${name}.timer`); + + // Simuliere: Schreiben der .timer-Datei schlägt fehl + const originalWrite = Deno.writeTextFile; + const writeStub = stub( + Deno, + 'writeTextFile', + async (path: string | URL, data: string | ReadableStream) => { + if (typeof path === 'string' && path.endsWith('.timer')) { + throw new Error('Simulierter Schreibfehler'); + } else { + return await originalWrite(path, data); + } + }, + ); + + const result = await writeUnitFiles( + name, + serviceContent, + timerContent, + options, + ); + + // Erwartung: Funktion gibt undefined zurück + assertEquals(result, undefined); + + // Erwartung: Beide Dateien wurden gelöscht (Rollback) + assertEquals(await exists(servicePath), false); + assertEquals(await exists(timerPath), false); + + writeStub.restore(); +}); + +Deno.test('writeUnitFiles: Fehler beim Schreiben der .service-Datei verhindert Folgeaktionen', async () => { + const tmp = await Deno.makeTempDir(); + + const options: TimerOptions = { + output: tmp, + user: false, + exec: '/bin/true', + calendar: 'weekly', + }; + + const name = 'fail-service'; + const serviceContent = '[Service]\nExecStart=/bin/true'; + const timerContent = '[Timer]\nOnCalendar=weekly'; + + const servicePath = join(tmp, `${name}.service`); + const timerPath = join(tmp, `${name}.timer`); + + // Simuliere: Fehler beim Schreiben der .service-Datei + const writeStub = stub( + Deno, + 'writeTextFile', + (path: string | URL, _data: string | ReadableStream) => { + if (typeof path === 'string' && path.endsWith('.service')) { + throw new Error('Simulierter Service-Schreibfehler'); + } + return Promise.resolve(); + }, + ); + + const result = await writeUnitFiles( + name, + serviceContent, + timerContent, + options, + ); + + // Erwartung: Funktion gibt undefined zurück + assertEquals(result, undefined); + + // Erwartung: Es wurden keine Dateien angelegt + assertEquals(await exists(servicePath), false); + assertEquals(await exists(timerPath), false); + + writeStub.restore(); +}); + +Deno.test('writeUnitFiles: beide Dateien geschrieben, danach Fehler → vollständiger Rollback', async () => { + const tmp = await Deno.makeTempDir(); + + const options: TimerOptions = { + output: tmp, + user: false, + exec: '/bin/true', + calendar: 'never', + }; + + const name = 'fail-after-write'; + const serviceContent = '[Service]\nExecStart=/bin/true'; + const timerContent = '[Timer]\nOnCalendar=never'; + + const servicePath = join(tmp, `${name}.service`); + const timerPath = join(tmp, `${name}.timer`); + + let writeCount = 0; + const originalWriteTextFile = Deno.writeTextFile; + + const writeStub = stub( + Deno, + 'writeTextFile', + async (path: string | URL, data: string | ReadableStream) => { + if (typeof path !== 'string') { + throw new Error('Unerwarteter Pfadtyp'); + } + + // Simuliere beide Schreibvorgänge, aber wirf nach dem zweiten eine Exception + writeCount++; + + await originalWriteTextFile(path, data); // wirklich schreiben + + if (writeCount === 2) { + throw new Error( + 'Simulierter Fehler nach vollständigem Schreiben', + ); + } + }, + ); + + const result = await writeUnitFiles( + name, + serviceContent, + timerContent, + options, + ); + + // Erwartung: Funktion gibt undefined zurück + assertEquals(result, undefined); + + // Erwartung: Beide Dateien wurden wieder entfernt + assertEquals(await exists(servicePath), false); + assertEquals(await exists(timerPath), false); + + writeStub.restore(); +}); + +Deno.test('writeUnitFiles: Rollback schlägt fehl, wenn Dateien nicht gelöscht werden können', async () => { + const tmp = await Deno.makeTempDir(); + + const options: TimerOptions = { + output: tmp, + user: false, + exec: '/bin/true', + calendar: 'never', + }; + + const name = 'rollback-error'; + const serviceContent = '[Service]\nExecStart=/bin/true'; + const timerContent = '[Timer]\nOnCalendar=never'; + + const servicePath = join(tmp, `${name}.service`); + const timerPath = join(tmp, `${name}.timer`); + + // Originale Methoden sichern + const originalWriteTextFile = Deno.writeTextFile; + const originalRemove = Deno.remove; + + let writeCount = 0; + + const writeStub = stub( + Deno, + 'writeTextFile', + async (path: string | URL, data: string | ReadableStream) => { + writeCount++; + await originalWriteTextFile(path, data); + if (writeCount === 2) { + throw new Error('Fehler nach vollständigem Schreiben'); + } + }, + ); + + const removeStub = stub( + Deno, + 'remove', + // deno-lint-ignore require-await + async (_path: string | URL, _opts?: Deno.RemoveOptions) => { + throw new Error('Löschen verboten!'); + }, + ); + + // capture console output + const logs: string[] = []; + const consoleStub = stub(console, 'error', (...args) => { + logs.push(args.map((a) => String(a)).join(' ')); + }); + + const result = await writeUnitFiles( + name, + serviceContent, + timerContent, + options, + ); + assertEquals(result, undefined); + + // Dateien existieren noch, weil löschen fehlschlug + assertEquals(await exists(servicePath), true); + assertEquals(await exists(timerPath), true); + + // Fehlerausgabe enthält "rollback_failed" + const combinedLogs = logs.join('\n'); + assertStringIncludes(combinedLogs, 'rollback_failed'); + + // Cleanup + writeStub.restore(); + removeStub.restore(); + consoleStub.restore(); +});