Skip to content

Commit c1f1eb8

Browse files
committed
feat(ups): enhance UPS service with improved killpower configuration handling and refactor for better testability; update settings for additional test commands
1 parent 4bab6d0 commit c1f1eb8

File tree

2 files changed

+406
-219
lines changed

2 files changed

+406
-219
lines changed

api/src/unraid-api/graph/resolvers/ups/ups.service.spec.ts

Lines changed: 171 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { Logger } from '@nestjs/common';
21
import { Test, TestingModule } from '@nestjs/testing';
3-
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
2+
import { mkdtemp, readFile, rm, unlink, writeFile } from 'fs/promises';
43
import { tmpdir } from 'os';
54
import { join } from 'path';
65

@@ -20,9 +19,7 @@ vi.mock('execa');
2019
vi.mock('@app/core/utils/files/file-exists.js');
2120

2221
const mockExeca = vi.mocked((await import('execa')).execa);
23-
const mockFileExistsSync = vi.mocked(
24-
(await import('@app/core/utils/files/file-exists.js')).fileExistsSync
25-
);
22+
const mockFileExists = vi.mocked((await import('@app/core/utils/files/file-exists.js')).fileExists);
2623

2724
describe('UPSService', () => {
2825
let service: UPSService;
@@ -85,7 +82,7 @@ describe('UPSService', () => {
8582
vi.spyOn(service['logger'], 'error').mockImplementation(() => {});
8683

8784
// Default mocks
88-
mockFileExistsSync.mockImplementation((path) => {
85+
mockFileExists.mockImplementation(async (path) => {
8986
if (path === configPath) {
9087
return true;
9188
}
@@ -174,53 +171,51 @@ describe('UPSService', () => {
174171
});
175172

176173
it('should handle killpower configuration for enable + yes', async () => {
174+
// Create a mock rc.6 file for this test
175+
const mockRc6Path = join(tempDir, 'mock-rc.6');
176+
await writeFile(mockRc6Path, '/sbin/poweroff', 'utf-8');
177+
service['rc6Path'] = mockRc6Path;
178+
179+
// Update mock to indicate rc6 file exists
180+
mockFileExists.mockImplementation(async (path) => {
181+
if (path === configPath || path === mockRc6Path) {
182+
return true;
183+
}
184+
return false;
185+
});
186+
177187
const config: UPSConfigInput = {
178188
service: UPSServiceState.ENABLE,
179189
killUps: UPSKillPower.YES,
180190
};
181191

182-
// Mock grep to return exit code 1 (not found)
183-
mockExeca.mockImplementation(((cmd: any, args: any) => {
184-
if (cmd === 'grep' && Array.isArray(args) && args.includes('apccontrol')) {
185-
return Promise.resolve({ stdout: '', stderr: '', exitCode: 1 } as any);
186-
}
187-
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 } as any);
188-
}) as any);
189-
190192
await service.configureUPS(config);
191193

192-
// Should call sed to add killpower
193-
expect(mockExeca).toHaveBeenCalledWith('sed', [
194-
'-i',
195-
'-e',
196-
's:/sbin/poweroff:/etc/apcupsd/apccontrol killpower; /sbin/poweroff:',
197-
'/etc/rc.d/rc.6',
198-
]);
194+
// Should have modified the rc.6 file
195+
const rc6Content = await readFile(mockRc6Path, 'utf-8');
196+
expect(rc6Content).toContain('/etc/apcupsd/apccontrol killpower; /sbin/poweroff');
197+
198+
expect(mockExeca).toHaveBeenCalledWith('/etc/rc.d/rc.apcupsd', ['start'], {
199+
timeout: 10000,
200+
});
199201
});
200202

201203
it('should handle killpower configuration for disable case', async () => {
204+
// Create a mock rc.6 file with killpower already enabled
205+
const mockRc6Path = join(tempDir, 'mock-rc.6-2');
206+
await writeFile(mockRc6Path, '/etc/apcupsd/apccontrol killpower; /sbin/poweroff', 'utf-8');
207+
service['rc6Path'] = mockRc6Path;
208+
202209
const config: UPSConfigInput = {
203210
service: UPSServiceState.DISABLE,
204211
killUps: UPSKillPower.YES, // should be ignored since service is disabled
205212
};
206213

207-
// Mock grep to return exit code 0 (found)
208-
mockExeca.mockImplementation(((cmd: any, args: any) => {
209-
if (cmd === 'grep' && Array.isArray(args) && args.includes('apccontrol')) {
210-
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 } as any);
211-
}
212-
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 } as any);
213-
}) as any);
214-
215214
await service.configureUPS(config);
216215

217-
// Should call sed to remove killpower
218-
expect(mockExeca).toHaveBeenCalledWith('sed', [
219-
'-i',
220-
'-e',
221-
's:/etc/apcupsd/apccontrol killpower; /sbin/poweroff:/sbin/poweroff:',
222-
'/etc/rc.d/rc.6',
223-
]);
216+
// Should NOT have modified the rc.6 file since service is disabled
217+
const rc6Content = await readFile(mockRc6Path, 'utf-8');
218+
expect(rc6Content).toContain('/etc/apcupsd/apccontrol killpower; /sbin/poweroff');
224219
});
225220

226221
it('should start service when enabled', async () => {
@@ -324,4 +319,144 @@ describe('UPSService', () => {
324319
expect(currentContent).toBe(originalContent);
325320
});
326321
});
322+
323+
describe('killpower functionality', () => {
324+
let tempRc6Path: string;
325+
326+
beforeEach(async () => {
327+
// Create a temporary rc.6 file for testing
328+
tempRc6Path = join(tempDir, 'rc.6');
329+
330+
// Create a mock rc.6 content
331+
const mockRc6Content = `#!/bin/sh
332+
# Shutdown script
333+
echo "Shutting down..."
334+
/sbin/poweroff
335+
exit 0
336+
`;
337+
await writeFile(tempRc6Path, mockRc6Content, 'utf-8');
338+
339+
// Override the rc6Path in the service (we'll need to make it configurable)
340+
service['rc6Path'] = tempRc6Path;
341+
342+
// Update mock to indicate rc6 file exists
343+
mockFileExists.mockImplementation(async (path) => {
344+
if (path === configPath || path === tempRc6Path) {
345+
return true;
346+
}
347+
return false;
348+
});
349+
});
350+
351+
it('should enable killpower when killUps=yes and service=enable', async () => {
352+
const config: UPSConfigInput = {
353+
killUps: UPSKillPower.YES,
354+
service: UPSServiceState.ENABLE,
355+
upsType: UPSType.USB,
356+
};
357+
358+
await service.configureUPS(config);
359+
360+
const rc6Content = await readFile(tempRc6Path, 'utf-8');
361+
expect(rc6Content).toContain('/etc/apcupsd/apccontrol killpower; /sbin/poweroff');
362+
// The file still contains "exit 0" on a separate line
363+
expect(rc6Content).toContain('exit 0');
364+
});
365+
366+
it('should disable killpower when killUps=no', async () => {
367+
// First enable killpower
368+
const enableConfig: UPSConfigInput = {
369+
killUps: UPSKillPower.YES,
370+
service: UPSServiceState.ENABLE,
371+
upsType: UPSType.USB,
372+
};
373+
await service.configureUPS(enableConfig);
374+
375+
// Then disable it
376+
const disableConfig: UPSConfigInput = {
377+
killUps: UPSKillPower.NO,
378+
service: UPSServiceState.ENABLE,
379+
upsType: UPSType.USB,
380+
};
381+
await service.configureUPS(disableConfig);
382+
383+
const rc6Content = await readFile(tempRc6Path, 'utf-8');
384+
expect(rc6Content).not.toContain('apccontrol killpower');
385+
expect(rc6Content).toContain('/sbin/poweroff\nexit 0'); // Should be restored
386+
});
387+
388+
it('should not enable killpower when service=disable', async () => {
389+
const config: UPSConfigInput = {
390+
killUps: UPSKillPower.YES,
391+
service: UPSServiceState.DISABLE, // Service is disabled
392+
upsType: UPSType.USB,
393+
};
394+
395+
await service.configureUPS(config);
396+
397+
const rc6Content = await readFile(tempRc6Path, 'utf-8');
398+
expect(rc6Content).not.toContain('apccontrol killpower');
399+
});
400+
401+
it('should handle missing rc.6 file gracefully', async () => {
402+
// Remove the file
403+
await unlink(tempRc6Path);
404+
405+
// Update mock to indicate rc6 file does NOT exist
406+
mockFileExists.mockImplementation(async (path) => {
407+
if (path === configPath) {
408+
return true;
409+
}
410+
return false;
411+
});
412+
413+
const config: UPSConfigInput = {
414+
killUps: UPSKillPower.YES,
415+
service: UPSServiceState.ENABLE,
416+
upsType: UPSType.USB,
417+
};
418+
419+
// Should not throw - just skip killpower configuration
420+
await expect(service.configureUPS(config)).resolves.not.toThrow();
421+
});
422+
423+
it('should be idempotent - enabling killpower multiple times', async () => {
424+
const config: UPSConfigInput = {
425+
killUps: UPSKillPower.YES,
426+
service: UPSServiceState.ENABLE,
427+
upsType: UPSType.USB,
428+
};
429+
430+
// Enable killpower twice
431+
await service.configureUPS(config);
432+
const firstContent = await readFile(tempRc6Path, 'utf-8');
433+
434+
await service.configureUPS(config);
435+
const secondContent = await readFile(tempRc6Path, 'utf-8');
436+
437+
// Content should be the same after second run
438+
expect(firstContent).toBe(secondContent);
439+
// Should only have one instance of killpower
440+
expect((secondContent.match(/apccontrol killpower/g) || []).length).toBe(1);
441+
});
442+
443+
it('should be idempotent - disabling killpower multiple times', async () => {
444+
const config: UPSConfigInput = {
445+
killUps: UPSKillPower.NO,
446+
service: UPSServiceState.ENABLE,
447+
upsType: UPSType.USB,
448+
};
449+
450+
// Disable killpower twice (when it's not enabled)
451+
await service.configureUPS(config);
452+
const firstContent = await readFile(tempRc6Path, 'utf-8');
453+
454+
await service.configureUPS(config);
455+
const secondContent = await readFile(tempRc6Path, 'utf-8');
456+
457+
// Content should be the same after second run
458+
expect(firstContent).toBe(secondContent);
459+
expect(secondContent).not.toContain('apccontrol killpower');
460+
});
461+
});
327462
});

0 commit comments

Comments
 (0)