Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import type { Cache } from 'cache-manager';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { ArrayDiskType } from '@app/unraid-api/graph/resolvers/array/array.model.js';
import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js';
import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js';
import { InternalBootStateService } from '@app/unraid-api/graph/resolvers/disks/internal-boot-state.service.js';

describe('InternalBootStateService', () => {
const cacheStore = new Map<string, unknown>();
const arrayService = {
getArrayData: vi.fn(),
};
const disksService = {
getInternalBootDevices: vi.fn(),
};
const cacheManager = {
async get<T>(key: string): Promise<T | undefined> {
return cacheStore.get(key) as T | undefined;
},
async set<T>(key: string, value: T): Promise<T> {
cacheStore.set(key, value);
return value;
},
async del(key: string): Promise<boolean> {
return cacheStore.delete(key);
},
};
const getSpy = vi.spyOn(cacheManager, 'get');
const setSpy = vi.spyOn(cacheManager, 'set');
const delSpy = vi.spyOn(cacheManager, 'del');

const createService = () =>
new InternalBootStateService(
arrayService as unknown as ArrayService,
disksService as unknown as DisksService,
cacheManager as unknown as Cache
);

beforeEach(() => {
cacheStore.clear();
vi.clearAllMocks();
});

it('returns false without scanning disks when the system is not booted from flash', async () => {
const service = createService();

const result = await service.getBootedFromFlashWithInternalBootSetupForBootDisk({
type: ArrayDiskType.CACHE,
});

expect(result).toBe(false);
expect(disksService.getInternalBootDevices).not.toHaveBeenCalled();
expect(getSpy).not.toHaveBeenCalled();
});

it('caches the internal boot device lookup result', async () => {
disksService.getInternalBootDevices.mockResolvedValue([{ device: '/dev/nvme0n1' }]);
const service = createService();

const firstResult = await service.getBootedFromFlashWithInternalBootSetupForBootDisk({
type: ArrayDiskType.FLASH,
});
const secondResult = await service.getBootedFromFlashWithInternalBootSetupForBootDisk({
type: ArrayDiskType.FLASH,
});

expect(firstResult).toBe(true);
expect(secondResult).toBe(true);
expect(disksService.getInternalBootDevices).toHaveBeenCalledTimes(1);
expect(setSpy).toHaveBeenCalledTimes(1);
});

it('coalesces concurrent cache misses into a single disk scan', async () => {
let resolveLookup: ((value: Array<{ device: string }>) => void) | undefined;

disksService.getInternalBootDevices.mockImplementation(
() =>
new Promise<Array<{ device: string }>>((resolve) => {
resolveLookup = resolve;
})
);
const service = createService();

const firstLookup = service.getBootedFromFlashWithInternalBootSetupForBootDisk({
type: ArrayDiskType.FLASH,
});
const secondLookup = service.getBootedFromFlashWithInternalBootSetupForBootDisk({
type: ArrayDiskType.FLASH,
});

await Promise.resolve();

expect(disksService.getInternalBootDevices).toHaveBeenCalledTimes(1);

resolveLookup?.([{ device: '/dev/nvme0n1' }]);

await expect(firstLookup).resolves.toBe(true);
await expect(secondLookup).resolves.toBe(true);
expect(setSpy).toHaveBeenCalledTimes(1);
});

it('invalidates the cached lookup result when requested', async () => {
disksService.getInternalBootDevices
.mockResolvedValueOnce([{ device: '/dev/nvme0n1' }])
.mockResolvedValueOnce([]);
const service = createService();

const initialResult = await service.getBootedFromFlashWithInternalBootSetupForBootDisk({
type: ArrayDiskType.FLASH,
});

await service.invalidateCachedInternalBootDeviceState();

const refreshedResult = await service.getBootedFromFlashWithInternalBootSetupForBootDisk({
type: ArrayDiskType.FLASH,
});

expect(initialResult).toBe(true);
expect(refreshedResult).toBe(false);
expect(disksService.getInternalBootDevices).toHaveBeenCalledTimes(2);
expect(delSpy).toHaveBeenCalledTimes(1);
});

it('does not repopulate the cache with a stale in-flight lookup after invalidation', async () => {
let resolveFirstLookup: ((value: Array<{ device: string }>) => void) | undefined;
let resolveSecondLookup: ((value: Array<{ device: string }>) => void) | undefined;

disksService.getInternalBootDevices
.mockImplementationOnce(
() =>
new Promise<Array<{ device: string }>>((resolve) => {
resolveFirstLookup = resolve;
})
)
.mockImplementationOnce(
() =>
new Promise<Array<{ device: string }>>((resolve) => {
resolveSecondLookup = resolve;
})
);
const service = createService();

const firstLookup = service.getBootedFromFlashWithInternalBootSetupForBootDisk({
type: ArrayDiskType.FLASH,
});

await Promise.resolve();
expect(disksService.getInternalBootDevices).toHaveBeenCalledTimes(1);

await service.invalidateCachedInternalBootDeviceState();
resolveFirstLookup?.([{ device: '/dev/nvme0n1' }]);

await expect(firstLookup).resolves.toBe(true);
expect(setSpy).not.toHaveBeenCalled();

const secondLookup = service.getBootedFromFlashWithInternalBootSetupForBootDisk({
type: ArrayDiskType.FLASH,
});

await Promise.resolve();
expect(disksService.getInternalBootDevices).toHaveBeenCalledTimes(2);

resolveSecondLookup?.([]);

await expect(secondLookup).resolves.toBe(false);
expect(setSpy).toHaveBeenCalledTimes(1);
expect(setSpy).toHaveBeenLastCalledWith(
'internal-boot-state:has-internal-boot-devices',
false,
10000
);
});

it('uses array boot data for the shared top-level lookup', async () => {
arrayService.getArrayData.mockResolvedValue({
boot: {
type: ArrayDiskType.FLASH,
},
});
disksService.getInternalBootDevices.mockResolvedValue([{ device: '/dev/nvme0n1' }]);
const service = createService();

await expect(service.getBootedFromFlashWithInternalBootSetup()).resolves.toBe(true);
expect(arrayService.getArrayData).toHaveBeenCalledTimes(1);
expect(disksService.getInternalBootDevices).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { Injectable } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable } from '@nestjs/common';

import type { Cache } from 'cache-manager';

import type { ArrayDisk } from '@app/unraid-api/graph/resolvers/array/array.model.js';
import { ArrayDiskType } from '@app/unraid-api/graph/resolvers/array/array.model.js';
Expand All @@ -7,9 +10,17 @@ import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.servic

@Injectable()
export class InternalBootStateService {
private readonly INTERNAL_BOOT_DEVICE_SETUP_CACHE_KEY =
'internal-boot-state:has-internal-boot-devices';
private readonly INTERNAL_BOOT_DEVICE_SETUP_TTL_MS = 10000;
private pendingHasInternalBootDevicesLookup: Promise<boolean> | null = null;
private pendingHasInternalBootDevicesLookupGeneration: number | null = null;
private internalBootLookupGeneration = 0;

constructor(
private readonly arrayService: ArrayService,
private readonly disksService: DisksService
private readonly disksService: DisksService,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache
) {}

public async getBootedFromFlashWithInternalBootSetup(): Promise<boolean> {
Expand All @@ -24,7 +35,57 @@ export class InternalBootStateService {
return false;
}

const internalBootDevices = await this.disksService.getInternalBootDevices();
return internalBootDevices.length > 0;
return this.getHasInternalBootDevices();
}

public async invalidateCachedInternalBootDeviceState(): Promise<void> {
this.internalBootLookupGeneration += 1;
this.pendingHasInternalBootDevicesLookup = null;
this.pendingHasInternalBootDevicesLookupGeneration = null;
await this.cacheManager.del(this.INTERNAL_BOOT_DEVICE_SETUP_CACHE_KEY);
}

private async getHasInternalBootDevices(): Promise<boolean> {
const cachedValue = await this.cacheManager.get<boolean>(
this.INTERNAL_BOOT_DEVICE_SETUP_CACHE_KEY
);
if (typeof cachedValue === 'boolean') {
return cachedValue;
}

const lookupGeneration = this.internalBootLookupGeneration;
if (
!this.pendingHasInternalBootDevicesLookup ||
this.pendingHasInternalBootDevicesLookupGeneration !== lookupGeneration
) {
this.pendingHasInternalBootDevicesLookup = this.loadHasInternalBootDevices(lookupGeneration);
this.pendingHasInternalBootDevicesLookupGeneration = lookupGeneration;
}

return this.pendingHasInternalBootDevicesLookup;
}

private async loadHasInternalBootDevices(lookupGeneration: number): Promise<boolean> {
try {
const hasInternalBootDevices = (await this.disksService.getInternalBootDevices()).length > 0;

if (lookupGeneration === this.internalBootLookupGeneration) {
await this.cacheManager.set(
this.INTERNAL_BOOT_DEVICE_SETUP_CACHE_KEY,
hasInternalBootDevices,
this.INTERNAL_BOOT_DEVICE_SETUP_TTL_MS
);
}

return hasInternalBootDevices;
} finally {
if (
lookupGeneration === this.internalBootLookupGeneration &&
this.pendingHasInternalBootDevicesLookupGeneration === lookupGeneration
) {
this.pendingHasInternalBootDevicesLookup = null;
this.pendingHasInternalBootDevicesLookupGeneration = null;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Logger } from '@nestjs/common';

import { execa } from 'execa';
import { beforeEach, describe, expect, it, vi } from 'vitest';

Expand Down Expand Up @@ -28,11 +30,13 @@ vi.mock('@app/store/services/state-file-loader.js', () => ({
describe('OnboardingInternalBootService', () => {
const internalBootStateService = {
getBootedFromFlashWithInternalBootSetup: vi.fn(),
invalidateCachedInternalBootDeviceState: vi.fn(),
};

beforeEach(() => {
vi.clearAllMocks();
internalBootStateService.getBootedFromFlashWithInternalBootSetup.mockResolvedValue(false);
internalBootStateService.invalidateCachedInternalBootDeviceState.mockResolvedValue(undefined);
vi.mocked(getters.emhttp).mockReturnValue({
devices: [],
disks: [],
Expand Down Expand Up @@ -88,6 +92,9 @@ describe('OnboardingInternalBootService', () => {
},
{ waitForToken: true }
);
expect(internalBootStateService.invalidateCachedInternalBootDeviceState).toHaveBeenCalledTimes(
1
);
});

it('runs efibootmgr update flow when updateBios is requested', async () => {
Expand Down Expand Up @@ -139,6 +146,9 @@ describe('OnboardingInternalBootService', () => {
expect(result.ok).toBe(true);
expect(result.code).toBe(0);
expect(result.output).toContain('BIOS boot entry updates completed successfully.');
expect(internalBootStateService.invalidateCachedInternalBootDeviceState).toHaveBeenCalledTimes(
1
);
expect(vi.mocked(emcmd)).toHaveBeenCalledTimes(4);
expect(vi.mocked(loadStateFileSync)).not.toHaveBeenCalled();
expect(vi.mocked(execa)).toHaveBeenNthCalledWith(1, 'efibootmgr', [], { reject: false });
Expand Down Expand Up @@ -209,6 +219,35 @@ describe('OnboardingInternalBootService', () => {
expect(result.output).toContain(
'BIOS boot entry updates completed with warnings; manual BIOS boot order changes may still be required.'
);
expect(internalBootStateService.invalidateCachedInternalBootDeviceState).toHaveBeenCalledTimes(
1
);
});

it('returns success when cache invalidation fails after setup and logs a warning', async () => {
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
vi.mocked(emcmd).mockResolvedValue({ ok: true } as Awaited<ReturnType<typeof emcmd>>);
internalBootStateService.invalidateCachedInternalBootDeviceState.mockRejectedValue(
new Error('cache delete failed')
);
const service = createService();

const result = await service.createInternalBootPool({
poolName: 'cache',
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: false,
});

expect(result.ok).toBe(true);
expect(result.code).toBe(0);
expect(vi.mocked(emcmd)).toHaveBeenCalledTimes(4);
expect(internalBootStateService.invalidateCachedInternalBootDeviceState).toHaveBeenCalledTimes(
1
);
expect(warnSpy).toHaveBeenCalledWith(
'Failed to invalidate cached internal boot device state after successful setup: cache delete failed'
);
});
Comment on lines +227 to 251
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

In Vitest v3, does vi.clearAllMocks() restore vi.spyOn() implementations on class prototypes, or should vi.restoreAllMocks() / spy.mockRestore() be used?

💡 Result:

In Vitest v3, vi.clearAllMocks() does not restore vi.spyOn() implementations (including spies placed on class prototypes)—it only clears call/instance history and keeps the mocked implementation in place. [1]

To put a spied prototype method back to its original implementation/descriptor, use vi.restoreAllMocks() (restores all spies) or spy.mockRestore() (restores one spy). [1][2]

Sources: [1] [2]


🏁 Script executed:

fd -t f "onboarding-internal-boot.service.spec.ts" -x cat -n {}

Repository: unraid/api

Length of output: 14868


Restore the Logger.prototype.warn spy after this test.

Line 228 creates a prototype spy but never restores it. vi.clearAllMocks() only clears call history—it does not restore spy implementations—so the mocked behavior leaks into subsequent tests.

🔧 Proposed fix
     it('returns success when cache invalidation fails after setup and logs a warning', async () => {
         const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
-        vi.mocked(emcmd).mockResolvedValue({ ok: true } as Awaited<ReturnType<typeof emcmd>>);
-        internalBootStateService.invalidateCachedInternalBootDeviceState.mockRejectedValue(
-            new Error('cache delete failed')
-        );
-        const service = createService();
-
-        const result = await service.createInternalBootPool({
-            poolName: 'cache',
-            devices: ['disk-1'],
-            bootSizeMiB: 16384,
-            updateBios: false,
-        });
-
-        expect(result.ok).toBe(true);
-        expect(result.code).toBe(0);
-        expect(vi.mocked(emcmd)).toHaveBeenCalledTimes(4);
-        expect(internalBootStateService.invalidateCachedInternalBootDeviceState).toHaveBeenCalledTimes(
-            1
-        );
-        expect(warnSpy).toHaveBeenCalledWith(
-            'Failed to invalidate cached internal boot device state after successful setup: cache delete failed'
-        );
+        try {
+            vi.mocked(emcmd).mockResolvedValue({ ok: true } as Awaited<ReturnType<typeof emcmd>>);
+            internalBootStateService.invalidateCachedInternalBootDeviceState.mockRejectedValue(
+                new Error('cache delete failed')
+            );
+            const service = createService();
+
+            const result = await service.createInternalBootPool({
+                poolName: 'cache',
+                devices: ['disk-1'],
+                bootSizeMiB: 16384,
+                updateBios: false,
+            });
+
+            expect(result.ok).toBe(true);
+            expect(result.code).toBe(0);
+            expect(vi.mocked(emcmd)).toHaveBeenCalledTimes(4);
+            expect(
+                internalBootStateService.invalidateCachedInternalBootDeviceState
+            ).toHaveBeenCalledTimes(1);
+            expect(warnSpy).toHaveBeenCalledWith(
+                'Failed to invalidate cached internal boot device state after successful setup: cache delete failed'
+            );
+        } finally {
+            warnSpy.mockRestore();
+        }
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('returns success when cache invalidation fails after setup and logs a warning', async () => {
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
vi.mocked(emcmd).mockResolvedValue({ ok: true } as Awaited<ReturnType<typeof emcmd>>);
internalBootStateService.invalidateCachedInternalBootDeviceState.mockRejectedValue(
new Error('cache delete failed')
);
const service = createService();
const result = await service.createInternalBootPool({
poolName: 'cache',
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: false,
});
expect(result.ok).toBe(true);
expect(result.code).toBe(0);
expect(vi.mocked(emcmd)).toHaveBeenCalledTimes(4);
expect(internalBootStateService.invalidateCachedInternalBootDeviceState).toHaveBeenCalledTimes(
1
);
expect(warnSpy).toHaveBeenCalledWith(
'Failed to invalidate cached internal boot device state after successful setup: cache delete failed'
);
});
it('returns success when cache invalidation fails after setup and logs a warning', async () => {
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
try {
vi.mocked(emcmd).mockResolvedValue({ ok: true } as Awaited<ReturnType<typeof emcmd>>);
internalBootStateService.invalidateCachedInternalBootDeviceState.mockRejectedValue(
new Error('cache delete failed')
);
const service = createService();
const result = await service.createInternalBootPool({
poolName: 'cache',
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: false,
});
expect(result.ok).toBe(true);
expect(result.code).toBe(0);
expect(vi.mocked(emcmd)).toHaveBeenCalledTimes(4);
expect(
internalBootStateService.invalidateCachedInternalBootDeviceState
).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
'Failed to invalidate cached internal boot device state after successful setup: cache delete failed'
);
} finally {
warnSpy.mockRestore();
}
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@api/src/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.spec.ts`
around lines 227 - 251, The Logger.prototype.warn spy created as warnSpy in the
test leaks into other tests because it's not restored; update the test (the spec
containing the it('returns success when cache invalidation fails after setup and
logs a warning' ...) block) to restore the spy after assertions by calling
warnSpy.mockRestore() (or ensure restoration in a finally/afterEach cleanup) so
the Logger.prototype.warn implementation is returned to its original behavior.


it('returns validation error for duplicate devices', async () => {
Expand All @@ -226,6 +265,7 @@ describe('OnboardingInternalBootService', () => {
code: 2,
output: 'mkbootpool: duplicate device id: disk-1',
});
expect(internalBootStateService.invalidateCachedInternalBootDeviceState).not.toHaveBeenCalled();
expect(vi.mocked(emcmd)).not.toHaveBeenCalled();
});

Expand All @@ -245,6 +285,7 @@ describe('OnboardingInternalBootService', () => {
code: 3,
});
expect(result.output).toContain('internal boot is already configured');
expect(internalBootStateService.invalidateCachedInternalBootDeviceState).not.toHaveBeenCalled();
expect(vi.mocked(emcmd)).not.toHaveBeenCalled();
});

Expand All @@ -265,6 +306,7 @@ describe('OnboardingInternalBootService', () => {
expect(result.code).toBe(1);
expect(result.output).toContain('mkbootpool: command failed or timed out');
expect(result.output).toContain('state lookup failed');
expect(internalBootStateService.invalidateCachedInternalBootDeviceState).not.toHaveBeenCalled();
expect(vi.mocked(emcmd)).not.toHaveBeenCalled();
});

Expand All @@ -283,5 +325,6 @@ describe('OnboardingInternalBootService', () => {
expect(result.code).toBe(1);
expect(result.output).toContain('mkbootpool: command failed or timed out');
expect(result.output).toContain('socket failure');
expect(internalBootStateService.invalidateCachedInternalBootDeviceState).not.toHaveBeenCalled();
});
});
Loading
Loading