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
6 changes: 6 additions & 0 deletions api/generated-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,11 @@ type BrandingConfig {
"""
bannerImage: String

"""
Built-in case model value written to case-model.cfg when no custom override is supplied.
"""
caseModel: String

"""
Case model image source. Supports local path, remote URL, or data URI/base64.
"""
Expand Down Expand Up @@ -1433,6 +1438,7 @@ input BrandingConfigInput {
showBannerGradient: Boolean
theme: String
bannerImage: String
caseModel: String
caseModelImage: String
partnerLogoLightUrl: String
partnerLogoDarkUrl: String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,16 @@ export class BrandingConfig {
@Transform(({ value }) => sanitizeString(value))
bannerImage?: string | null;

@Field(() => String, {
nullable: true,
description:
'Built-in case model value written to case-model.cfg when no custom override is supplied.',
})
@IsOptional()
@IsString()
@Transform(({ value }) => sanitizeString(value))
caseModel?: string | null;

@Field(() => String, {
nullable: true,
description: 'Case model image source. Supports local path, remote URL, or data URI/base64.',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,27 @@ describe('OnboardingService', () => {
);
});

it('applyCaseModelConfig should write the built-in case model when no custom asset exists', async () => {
(service as any).activationData = plainToInstance(ActivationCode, {
...mockActivationData,
branding: {
...mockActivationData.branding,
caseModel: 'mid-tower',
caseModelImage: null,
},
});
(service as any).materializedPartnerMedia = {
banner: true,
caseModel: false,
};
vi.mocked(fileExists).mockResolvedValue(false);

await (service as any).applyCaseModelConfig();

expect(fs.writeFile).toHaveBeenCalledWith(caseModelCfg, 'mid-tower');
expect(loggerLogSpy).toHaveBeenCalledWith(`Case model set to mid-tower in ${caseModelCfg}`);
});

it('applyServerIdentity should call emcmd directly', async () => {
const updateSpy = vi.spyOn(service as any, 'updateCfgFile');

Expand Down Expand Up @@ -1478,7 +1499,7 @@ describe('applyActivationCustomizations specific tests', () => {
)
);
expect(loggerLogSpy).toHaveBeenCalledWith(
'No partner case-model image configured in activation code, skipping case model setup.'
'No partner case-model configured in activation code, skipping case model setup.'
);

// Other steps should still run
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -782,9 +782,10 @@ export class OnboardingService implements OnModuleInit {
this.logger.warn('No activation data available for case model setup.');
return;
}
if (!this.materializedPartnerMedia.caseModel) {
const configuredCaseModel = this.activationData.branding?.caseModel?.trim() ?? '';
if (!this.materializedPartnerMedia.caseModel && !configuredCaseModel) {
this.logger.log(
'No partner case-model image configured in activation code, skipping case model setup.'
'No partner case-model configured in activation code, skipping case model setup.'
);
return;
}
Expand All @@ -805,6 +806,10 @@ export class OnboardingService implements OnModuleInit {
await fs.mkdir(path.dirname(this.caseModelCfg), { recursive: true });
await fs.writeFile(this.caseModelCfg, modelToSet);
this.logger.log(`Case model set to ${modelToSet} in ${this.caseModelCfg}`);
} else if (configuredCaseModel) {
await fs.mkdir(path.dirname(this.caseModelCfg), { recursive: true });
await fs.writeFile(this.caseModelCfg, configuredCaseModel);
this.logger.log(`Case model set to ${configuredCaseModel} in ${this.caseModelCfg}`);
} else {
this.logger.log('No custom case model file found in activation assets.');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ export class BrandingConfigInput {
@IsString()
bannerImage?: string;

@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
caseModel?: string;

@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@ vi.mock('@/components/Onboarding/store/onboardingDraft', () => ({
useOnboardingDraftStore: () => draftStore,
}));

vi.mock('@/components/Onboarding/store/upgradeOnboarding', () => ({
useUpgradeOnboardingStore: () => onboardingStore,
vi.mock('@/components/Onboarding/store/onboardingStatus', () => ({
useOnboardingStore: () => onboardingStore,
}));

vi.mock('@vue/apollo-composable', async () => {
Expand Down
224 changes: 224 additions & 0 deletions web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { flushPromises, mount } from '@vue/test-utils';

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

import OnboardingInternalBootStep from '~/components/Onboarding/steps/OnboardingInternalBootStep.vue';
import { createTestI18n } from '../../utils/i18n';

type MockInternalBootSelection = {
poolName: string;
slotCount: number;
devices: string[];
bootSizeMiB: number;
updateBios: boolean;
};

type MockContext = {
array: {
state?: string | null;
boot?: { device?: string | null } | null;
parities: Array<{ device?: string | null }>;
disks: Array<{ device?: string | null }>;
caches: Array<{ name?: string | null; device?: string | null }>;
};
vars?: {
fsState?: string | null;
bootEligible?: boolean | null;
enableBootTransfer?: string | null;
reservedNames?: string | null;
} | null;
shares: Array<{ name?: string | null }>;
disks: Array<{
device: string;
size: number;
emhttpDeviceId?: string | null;
interfaceType?: string | null;
}>;
};

const { draftStore, contextResult, contextLoading, contextError } = vi.hoisted(() => {
const store = {
bootMode: 'usb' as 'usb' | 'storage',
internalBootSelection: null as MockInternalBootSelection | null,
skipInternalBoot: vi.fn(),
setBootMode: vi.fn<(mode: 'usb' | 'storage') => void>(),
setInternalBootSelection: vi.fn<(selection: MockInternalBootSelection) => void>(),
};

store.setBootMode.mockImplementation((mode: 'usb' | 'storage') => {
store.bootMode = mode;
});
store.setInternalBootSelection.mockImplementation((selection: MockInternalBootSelection) => {
store.internalBootSelection = selection;
});

return {
draftStore: store,
contextResult: { value: null as MockContext | null, __v_isRef: true },
contextLoading: { value: false, __v_isRef: true },
contextError: { value: null as unknown, __v_isRef: true },
};
});

vi.mock('@unraid/ui', () => ({
BrandButton: {
props: ['text', 'disabled', 'loading'],
emits: ['click'],
template:
'<button data-testid="brand-button" :disabled="disabled" @click="$emit(\'click\')">{{ text }}</button>',
},
}));

vi.mock('@vue/apollo-composable', () => ({
useQuery: () => ({
result: contextResult,
loading: contextLoading,
error: contextError,
}),
}));

vi.mock('@/components/Onboarding/store/onboardingDraft', () => ({
useOnboardingDraftStore: () => draftStore,
}));

const gib = (value: number) => value * 1024 * 1024 * 1024;

const mountComponent = () =>
mount(OnboardingInternalBootStep, {
props: {
onComplete: vi.fn(),
showBack: true,
},
global: {
plugins: [createTestI18n()],
},
});

describe('OnboardingInternalBootStep', () => {
beforeEach(() => {
vi.clearAllMocks();
draftStore.bootMode = 'usb';
draftStore.internalBootSelection = null;
contextLoading.value = false;
contextError.value = null;
contextResult.value = null;
});

it('renders all available server and disk eligibility codes when storage boot is blocked', async () => {
draftStore.bootMode = 'storage';
contextResult.value = {
array: {
state: 'STARTED',
boot: { device: '/dev/sda' },
parities: [{ device: '/dev/sdb' }],
disks: [{ device: '/dev/sdc' }],
caches: [{ name: 'cache', device: '/dev/sdd' }],
},
vars: {
fsState: 'Started',
bootEligible: null,
enableBootTransfer: 'maybe',
reservedNames: '',
},
shares: [],
disks: [
{ device: '/dev/sda', size: gib(32), emhttpDeviceId: 'boot-disk', interfaceType: 'SATA' },
{ device: '/dev/sdb', size: gib(32), emhttpDeviceId: 'parity-disk', interfaceType: 'SATA' },
{ device: '/dev/sdc', size: gib(32), emhttpDeviceId: 'array-disk', interfaceType: 'SATA' },
{ device: '/dev/sdd', size: gib(32), emhttpDeviceId: 'cache-disk', interfaceType: 'SATA' },
{ device: '/dev/sde', size: gib(6), emhttpDeviceId: 'small-disk', interfaceType: 'SATA' },
{ device: '/dev/sdf', size: gib(32), emhttpDeviceId: 'usb-disk', interfaceType: 'USB' },
],
};

const wrapper = mountComponent();
await flushPromises();

expect(wrapper.find('[data-testid="internal-boot-eligibility-panel"]').exists()).toBe(true);
expect(wrapper.text()).toContain('ARRAY_NOT_STOPPED');
expect(wrapper.text()).toContain('ENABLE_BOOT_TRANSFER_UNKNOWN');
expect(wrapper.text()).toContain('BOOT_ELIGIBLE_UNKNOWN');
expect(wrapper.text()).toContain('ASSIGNED_TO_BOOT');
expect(wrapper.text()).toContain('ASSIGNED_TO_PARITY');
expect(wrapper.text()).toContain('ASSIGNED_TO_ARRAY');
expect(wrapper.text()).toContain('ASSIGNED_TO_CACHE');
expect(wrapper.text()).toContain('USB_TRANSPORT');
expect(wrapper.text()).toContain('TOO_SMALL');
expect(wrapper.text()).not.toContain('NO_UNASSIGNED_DISKS');
expect(wrapper.find('[data-testid="brand-button"]').attributes('disabled')).toBeDefined();
});

it('shows explicit disabled and empty-disk codes when the system reports them', async () => {
draftStore.bootMode = 'storage';
contextResult.value = {
array: {
state: 'STOPPED',
boot: { device: '/dev/sda' },
parities: [{ device: '/dev/sdb' }],
disks: [],
caches: [],
},
vars: {
fsState: 'Stopped',
bootEligible: false,
enableBootTransfer: 'no',
reservedNames: '',
},
shares: [],
disks: [
{ device: '/dev/sda', size: gib(32), emhttpDeviceId: 'boot-disk', interfaceType: 'SATA' },
{ device: '/dev/sdb', size: gib(32), emhttpDeviceId: 'parity-disk', interfaceType: 'SATA' },
],
};

const wrapper = mountComponent();
await flushPromises();

expect(wrapper.text()).toContain('ENABLE_BOOT_TRANSFER_DISABLED');
expect(wrapper.text()).toContain('ALREADY_INTERNAL_BOOT');
expect(wrapper.text()).toContain('BOOT_ELIGIBLE_FALSE');
expect(wrapper.text()).toContain('NO_UNASSIGNED_DISKS');
});

it('shows disk-level ineligibility while keeping the form available for eligible disks', async () => {
draftStore.bootMode = 'storage';
contextResult.value = {
array: {
state: 'STOPPED',
boot: null,
parities: [],
disks: [],
caches: [{ name: 'cache', device: '/dev/sda' }],
},
vars: {
fsState: 'Stopped',
bootEligible: true,
enableBootTransfer: 'yes',
reservedNames: '',
},
shares: [],
disks: [
{ device: '/dev/sda', size: gib(32), emhttpDeviceId: 'cache-disk', interfaceType: 'SATA' },
{ device: '/dev/sdb', size: gib(6), emhttpDeviceId: 'small-disk', interfaceType: 'SATA' },
{ device: '/dev/sdc', size: gib(32), emhttpDeviceId: 'eligible-disk', interfaceType: 'SATA' },
{ device: '/dev/sdd', size: gib(32), emhttpDeviceId: 'usb-disk', interfaceType: 'USB' },
],
};

const wrapper = mountComponent();
await flushPromises();

expect(wrapper.find('[data-testid="internal-boot-eligibility-panel"]').exists()).toBe(true);
expect(wrapper.text()).toContain('ASSIGNED_TO_CACHE');
expect(wrapper.text()).toContain('USB_TRANSPORT');
expect(wrapper.text()).toContain('TOO_SMALL');
const selects = wrapper.findAll('select');
expect(selects).toHaveLength(3);
const deviceSelect = selects[1];
expect(deviceSelect.text()).toContain('eligible-disk');
expect(deviceSelect.text()).not.toContain('cache-disk');
expect(deviceSelect.text()).not.toContain('small-disk');
expect(deviceSelect.text()).not.toContain('usb-disk');
expect(wrapper.find('[data-testid="brand-button"]').attributes('disabled')).toBeUndefined();
});
});
Loading
Loading