From 6039d236eb7de449ce22b1d9ea718389a3e2261d Mon Sep 17 00:00:00 2001 From: "Max P." Date: Fri, 30 May 2025 11:17:04 +0200 Subject: [PATCH 1/5] test(fs): add rollback tests for writeUnitFiles errors - Add tests to verify rollback behavior when file writing fails - Simulate various error scenarios to ensure proper cleanup - Log rollback failures when file deletion is prohibited --- src/utils/__tests__/fs.test.ts | 226 +++++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) 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(); +}); -- 2.49.1 From c4f4614a2daee68f9b33b9676106214c65a1a427 Mon Sep 17 00:00:00 2001 From: "Max P." Date: Fri, 30 May 2025 11:17:18 +0200 Subject: [PATCH 2/5] test(fs): update test descriptions and comments to English - Translate all test descriptions and inline comments from German to English --- src/utils/__tests__/fs.test.ts | 65 ++++++++++++++++------------------ 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/src/utils/__tests__/fs.test.ts b/src/utils/__tests__/fs.test.ts index b0d0b62..d0fa3c6 100644 --- a/src/utils/__tests__/fs.test.ts +++ b/src/utils/__tests__/fs.test.ts @@ -9,7 +9,7 @@ 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'; -Deno.test('writeUnitFiles schreibt .service und .timer korrekt', async () => { +Deno.test('writeUnitFiles: writes .service and .timer files correctly', async () => { const tmp = await Deno.makeTempDir({ prefix: 'test-units-' }); const options = { @@ -28,15 +28,15 @@ Deno.test('writeUnitFiles schreibt .service und .timer korrekt', async () => { options as TimerOptions, ) as { servicePath: string; timerPath: string }; - // Überprüfe Pfade + // Check file paths assertEquals(servicePath, join(tmp, 'test-backup.service')); assertEquals(timerPath, join(tmp, 'test-backup.timer')); - // Existieren Dateien? + // Check if files exist assertExists(await Deno.stat(servicePath)); assertExists(await Deno.stat(timerPath)); - // Enthält die Datei den erwarteten Inhalt? + // Check if file contents match expectations const readService = await Deno.readTextFile(servicePath); const readTimer = await Deno.readTextFile(timerPath); @@ -44,23 +44,23 @@ Deno.test('writeUnitFiles schreibt .service und .timer korrekt', async () => { assertStringIncludes(readTimer, 'OnCalendar=daily'); }); -Deno.test('resolveUnitTargetPath mit --output', () => { +Deno.test('resolveUnitTargetPath: with --output', () => { const result = resolveUnitTargetPath({ output: '/tmp/units', user: false }); assertEquals(result, '/tmp/units'); }); -Deno.test('resolveUnitTargetPath mit --user ohne output', () => { +Deno.test('resolveUnitTargetPath: with --user and no output', () => { Deno.env.set('HOME', '/home/maxp'); const result = resolveUnitTargetPath({ output: undefined, user: true }); assertEquals(result, '/home/maxp/.config/systemd/user'); }); -Deno.test('resolveUnitTargetPath ohne output und ohne user', () => { +Deno.test('resolveUnitTargetPath: with no output and no 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 () => { +Deno.test('writeUnitFiles: error writing .timer file triggers rollback', async () => { const tmp = await Deno.makeTempDir(); const options: TimerOptions = { @@ -77,14 +77,14 @@ Deno.test('writeUnitFiles: Fehler beim Schreiben der .timer-Datei führt zu Roll const servicePath = join(tmp, `${name}.service`); const timerPath = join(tmp, `${name}.timer`); - // Simuliere: Schreiben der .timer-Datei schlägt fehl + // Simulate: writing the .timer file fails 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'); + throw new Error('Simulated write error'); } else { return await originalWrite(path, data); } @@ -98,17 +98,17 @@ Deno.test('writeUnitFiles: Fehler beim Schreiben der .timer-Datei führt zu Roll options, ); - // Erwartung: Funktion gibt undefined zurück + // Expect: function returns undefined assertEquals(result, undefined); - // Erwartung: Beide Dateien wurden gelöscht (Rollback) + // Expect: both files have been deleted (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 () => { +Deno.test('writeUnitFiles: error writing .service file prevents further actions', async () => { const tmp = await Deno.makeTempDir(); const options: TimerOptions = { @@ -125,13 +125,13 @@ Deno.test('writeUnitFiles: Fehler beim Schreiben der .service-Datei verhindert F const servicePath = join(tmp, `${name}.service`); const timerPath = join(tmp, `${name}.timer`); - // Simuliere: Fehler beim Schreiben der .service-Datei + // Simulate: error writing the .service file const writeStub = stub( Deno, 'writeTextFile', (path: string | URL, _data: string | ReadableStream) => { if (typeof path === 'string' && path.endsWith('.service')) { - throw new Error('Simulierter Service-Schreibfehler'); + throw new Error('Simulated service write error'); } return Promise.resolve(); }, @@ -144,17 +144,17 @@ Deno.test('writeUnitFiles: Fehler beim Schreiben der .service-Datei verhindert F options, ); - // Erwartung: Funktion gibt undefined zurück + // Expect: function returns undefined assertEquals(result, undefined); - // Erwartung: Es wurden keine Dateien angelegt + // Expect: no files were created assertEquals(await exists(servicePath), false); assertEquals(await exists(timerPath), false); writeStub.restore(); }); -Deno.test('writeUnitFiles: beide Dateien geschrieben, danach Fehler → vollständiger Rollback', async () => { +Deno.test('writeUnitFiles: both files written, then error → full rollback', async () => { const tmp = await Deno.makeTempDir(); const options: TimerOptions = { @@ -179,18 +179,16 @@ Deno.test('writeUnitFiles: beide Dateien geschrieben, danach Fehler → vollstä 'writeTextFile', async (path: string | URL, data: string | ReadableStream) => { if (typeof path !== 'string') { - throw new Error('Unerwarteter Pfadtyp'); + throw new Error('Unexpected path type'); } - // Simuliere beide Schreibvorgänge, aber wirf nach dem zweiten eine Exception + // Simulate both writes, then throw an error after the second writeCount++; - await originalWriteTextFile(path, data); // wirklich schreiben + await originalWriteTextFile(path, data); if (writeCount === 2) { - throw new Error( - 'Simulierter Fehler nach vollständigem Schreiben', - ); + throw new Error('Simulated error after full write'); } }, ); @@ -202,17 +200,17 @@ Deno.test('writeUnitFiles: beide Dateien geschrieben, danach Fehler → vollstä options, ); - // Erwartung: Funktion gibt undefined zurück + // Expect: function returns undefined assertEquals(result, undefined); - // Erwartung: Beide Dateien wurden wieder entfernt + // Expect: both files were removed 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 () => { +Deno.test('writeUnitFiles: rollback fails if files cannot be deleted', async () => { const tmp = await Deno.makeTempDir(); const options: TimerOptions = { @@ -229,9 +227,8 @@ Deno.test('writeUnitFiles: Rollback schlägt fehl, wenn Dateien nicht gelöscht const servicePath = join(tmp, `${name}.service`); const timerPath = join(tmp, `${name}.timer`); - // Originale Methoden sichern const originalWriteTextFile = Deno.writeTextFile; - const originalRemove = Deno.remove; + const _originalRemove = Deno.remove; let writeCount = 0; @@ -242,7 +239,7 @@ Deno.test('writeUnitFiles: Rollback schlägt fehl, wenn Dateien nicht gelöscht writeCount++; await originalWriteTextFile(path, data); if (writeCount === 2) { - throw new Error('Fehler nach vollständigem Schreiben'); + throw new Error('Error after full write'); } }, ); @@ -252,11 +249,10 @@ Deno.test('writeUnitFiles: Rollback schlägt fehl, wenn Dateien nicht gelöscht 'remove', // deno-lint-ignore require-await async (_path: string | URL, _opts?: Deno.RemoveOptions) => { - throw new Error('Löschen verboten!'); + throw new Error('Deletion forbidden!'); }, ); - // capture console output const logs: string[] = []; const consoleStub = stub(console, 'error', (...args) => { logs.push(args.map((a) => String(a)).join(' ')); @@ -270,15 +266,14 @@ Deno.test('writeUnitFiles: Rollback schlägt fehl, wenn Dateien nicht gelöscht ); assertEquals(result, undefined); - // Dateien existieren noch, weil löschen fehlschlug + // Files still exist because deletion failed assertEquals(await exists(servicePath), true); assertEquals(await exists(timerPath), true); - // Fehlerausgabe enthält "rollback_failed" + // Error output contains "rollback_failed" const combinedLogs = logs.join('\n'); assertStringIncludes(combinedLogs, 'rollback_failed'); - // Cleanup writeStub.restore(); removeStub.restore(); consoleStub.restore(); -- 2.49.1 From f3c46e1222d925d31621dfd32d12889e38d8f69b Mon Sep 17 00:00:00 2001 From: "Max P." Date: Fri, 30 May 2025 11:17:30 +0200 Subject: [PATCH 3/5] chore(deps): update deno.lock with new dependency - Adds a new dependency to deno.lock for testing utilities --- deno.lock | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deno.lock b/deno.lock index a182b0a..4bf34d8 100644 --- a/deno.lock +++ b/deno.lock @@ -168,7 +168,8 @@ "https://deno.land/std@0.224.0/path/windows/relative.ts": "3e1abc7977ee6cc0db2730d1f9cb38be87b0ce4806759d271a70e4997fc638d7", "https://deno.land/std@0.224.0/path/windows/resolve.ts": "8dae1dadfed9d46ff46cc337c9525c0c7d959fb400a6308f34595c45bdca1972", "https://deno.land/std@0.224.0/path/windows/to_file_url.ts": "40e560ee4854fe5a3d4d12976cef2f4e8914125c81b11f1108e127934ced502e", - "https://deno.land/std@0.224.0/path/windows/to_namespaced_path.ts": "4ffa4fb6fae321448d5fe810b3ca741d84df4d7897e61ee29be961a6aac89a4c" + "https://deno.land/std@0.224.0/path/windows/to_namespaced_path.ts": "4ffa4fb6fae321448d5fe810b3ca741d84df4d7897e61ee29be961a6aac89a4c", + "https://deno.land/std@0.224.0/testing/mock.ts": "a963181c2860b6ba3eb60e08b62c164d33cf5da7cd445893499b2efda20074db" }, "workspace": { "dependencies": [ -- 2.49.1 From e5f9f2c45a26c8fc652ef1a052a96b02ac45eaa8 Mon Sep 17 00:00:00 2001 From: ghost-bot Date: Fri, 30 May 2025 09:17:47 +0000 Subject: [PATCH 4/5] chore(changelog): update unreleased changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8bb7dd..86d66fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. +## [unreleased] + +### 🧪 Testing + +- *(fs)* Update test descriptions and comments to English - ([5f55c73](https://git.0xmax42.io/maxp/systemd-timer/commit/5f55c735f96c787bb90b88e1aebb05660e3a4a42)) +- *(fs)* Add rollback tests for writeUnitFiles errors - ([140b18b](https://git.0xmax42.io/maxp/systemd-timer/commit/140b18b7cdd51d0e263003c105c9c809e7a594f1)) + ## [0.4.1](https://git.0xmax42.io/maxp/systemd-timer/compare/v0.4.0..v0.4.1) - 2025-05-28 ### 🐛 Bug Fixes -- 2.49.1 From 84a883fd8f78cc88ec3bfc75398fcbeebbd19207 Mon Sep 17 00:00:00 2001 From: ghost-bot Date: Fri, 30 May 2025 09:21:20 +0000 Subject: [PATCH 5/5] chore(changelog): update unreleased changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86d66fb..c26a5cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,8 @@ All notable changes to this project will be documented in this file. ### 🧪 Testing -- *(fs)* Update test descriptions and comments to English - ([5f55c73](https://git.0xmax42.io/maxp/systemd-timer/commit/5f55c735f96c787bb90b88e1aebb05660e3a4a42)) -- *(fs)* Add rollback tests for writeUnitFiles errors - ([140b18b](https://git.0xmax42.io/maxp/systemd-timer/commit/140b18b7cdd51d0e263003c105c9c809e7a594f1)) +- *(fs)* Update test descriptions and comments to English - ([c4f4614](https://git.0xmax42.io/maxp/systemd-timer/commit/c4f4614a2daee68f9b33b9676106214c65a1a427)) +- *(fs)* Add rollback tests for writeUnitFiles errors - ([6039d23](https://git.0xmax42.io/maxp/systemd-timer/commit/6039d236eb7de449ce22b1d9ea718389a3e2261d)) ## [0.4.1](https://git.0xmax42.io/maxp/systemd-timer/compare/v0.4.0..v0.4.1) - 2025-05-28 -- 2.49.1