diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 66bcc8231c..d6dd77cc91 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -2031,6 +2031,65 @@ type Info implements Node { primaryNetwork: InfoNetworkInterface } +type NotificationCounts { + info: Int! + warning: Int! + alert: Int! + total: Int! +} + +type NotificationOverview { + unread: NotificationCounts! + archive: NotificationCounts! +} + +type Notification implements Node { + id: PrefixedID! + + """Also known as 'event'""" + title: String! + subject: String! + description: String! + importance: NotificationImportance! + link: String + type: NotificationType! + + """ISO Timestamp for when the notification occurred""" + timestamp: String + formattedTimestamp: String +} + +enum NotificationImportance { + ALERT + INFO + WARNING +} + +enum NotificationType { + UNREAD + ARCHIVE +} + +type Notifications implements Node { + id: PrefixedID! + + """A cached overview of the notifications in the system & their severity.""" + overview: NotificationOverview! + list(filter: NotificationFilter!): [Notification!]! + + """ + Deduplicated list of unread warning and alert notifications, sorted latest first. + """ + warningsAndAlerts: [Notification!]! +} + +input NotificationFilter { + importance: NotificationImportance + type: NotificationType! + offset: Int! + limit: Int! +} + type ExplicitStatusItem { name: String! updateStatus: UpdateStatus! @@ -2328,65 +2387,6 @@ type FlatOrganizerEntry { meta: DockerContainer } -type NotificationCounts { - info: Int! - warning: Int! - alert: Int! - total: Int! -} - -type NotificationOverview { - unread: NotificationCounts! - archive: NotificationCounts! -} - -type Notification implements Node { - id: PrefixedID! - - """Also known as 'event'""" - title: String! - subject: String! - description: String! - importance: NotificationImportance! - link: String - type: NotificationType! - - """ISO Timestamp for when the notification occurred""" - timestamp: String - formattedTimestamp: String -} - -enum NotificationImportance { - ALERT - INFO - WARNING -} - -enum NotificationType { - UNREAD - ARCHIVE -} - -type Notifications implements Node { - id: PrefixedID! - - """A cached overview of the notifications in the system & their severity.""" - overview: NotificationOverview! - list(filter: NotificationFilter!): [Notification!]! - - """ - Deduplicated list of unread warning and alert notifications, sorted latest first. - """ - warningsAndAlerts: [Notification!]! -} - -input NotificationFilter { - importance: NotificationImportance - type: NotificationType! - offset: Int! - limit: Int! -} - type FlashBackupStatus { """Status message indicating the outcome of the backup initiation.""" status: String! @@ -3569,4 +3569,4 @@ type Subscription { systemMetricsTemperature: TemperatureMetrics upsUpdates: UPSDevice! pluginInstallUpdates(operationId: ID!): PluginInstallEvent! -} +} \ No newline at end of file diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index 1f03d6b58f..a12769f4ec 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -3361,6 +3361,7 @@ export type Vars = Node & { sysFlashSlots?: Maybe; sysModel?: Maybe; timeZone?: Maybe; + tpmGuid?: Maybe; /** Should a NTP server be used for time sync? */ useNtp?: Maybe; useSsh?: Maybe; diff --git a/api/src/unraid-api/config/api-config.test.ts b/api/src/unraid-api/config/api-config.test.ts index 231306488d..4b7d830def 100644 --- a/api/src/unraid-api/config/api-config.test.ts +++ b/api/src/unraid-api/config/api-config.test.ts @@ -163,9 +163,13 @@ describe('OnboardingTracker', () => { const tracker = createOnboardingTracker(configService); await tracker.onApplicationBootstrap(); - const state = tracker.getState(); - expect(state.completed).toBe(false); - expect(state.completedAtVersion).toBeUndefined(); + await expect(tracker.getStateResult()).resolves.toEqual({ + kind: 'missing', + state: { + completed: false, + completedAtVersion: undefined, + }, + }); }); it('returns completed state when previously marked', async () => { @@ -185,9 +189,13 @@ describe('OnboardingTracker', () => { const tracker = createOnboardingTracker(configService); await tracker.onApplicationBootstrap(); - const state = tracker.getState(); - expect(state.completed).toBe(true); - expect(state.completedAtVersion).toBe('7.1.0'); + await expect(tracker.getStateResult()).resolves.toEqual({ + kind: 'ok', + state: { + completed: true, + completedAtVersion: '7.1.0', + }, + }); }); it('marks onboarding as completed with current version', async () => { @@ -272,9 +280,13 @@ describe('OnboardingTracker', () => { const tracker = new OnboardingTrackerService(configService, overrides); await tracker.onApplicationBootstrap(); - const state = tracker.getState(); - expect(state.completed).toBe(true); - expect(state.completedAtVersion).toBe('6.12.0'); + await expect(tracker.getStateResult()).resolves.toEqual({ + kind: 'ok', + state: { + completed: true, + completedAtVersion: '6.12.0', + }, + }); }); }); diff --git a/api/src/unraid-api/config/onboarding-tracker.service.spec.ts b/api/src/unraid-api/config/onboarding-tracker.service.spec.ts index 4a17584954..1b65e1886a 100644 --- a/api/src/unraid-api/config/onboarding-tracker.service.spec.ts +++ b/api/src/unraid-api/config/onboarding-tracker.service.spec.ts @@ -87,3 +87,118 @@ describe('OnboardingTrackerService write retries', () => { expect(mockAtomicWriteFile).toHaveBeenCalledTimes(3); }); }); + +describe('OnboardingTrackerService tracker state availability', () => { + beforeEach(() => { + mockReadFile.mockReset(); + mockAtomicWriteFile.mockReset(); + }); + + it('treats a missing tracker file as a valid empty onboarding state', async () => { + const config = createConfigService(); + const overrides = new OnboardingOverrideService(); + + mockReadFile.mockImplementation(async (filePath) => { + if (String(filePath).includes('unraid-version')) { + return 'version="7.2.0"\n'; + } + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); + }); + + const tracker = new OnboardingTrackerService(config, overrides); + await tracker.onApplicationBootstrap(); + + await expect(tracker.getStateResult()).resolves.toEqual({ + kind: 'missing', + state: { + completed: false, + completedAtVersion: undefined, + }, + }); + }); + + it('captures tracker read failures when reading the tracker file fails for a non-ENOENT reason', async () => { + const config = createConfigService(); + const overrides = new OnboardingOverrideService(); + + mockReadFile.mockImplementation(async (filePath) => { + if (String(filePath).includes('unraid-version')) { + return 'version="7.2.0"\n'; + } + throw new Error('permission denied'); + }); + + const tracker = new OnboardingTrackerService(config, overrides); + await tracker.onApplicationBootstrap(); + + const stateResult = await tracker.getStateResult(); + expect(stateResult.kind).toBe('error'); + if (stateResult.kind === 'error') { + expect(stateResult.error).toBeInstanceOf(Error); + } + }); + + it('returns override-backed onboarding state as a successful read result', async () => { + const config = createConfigService(); + const overrides = new OnboardingOverrideService(); + + overrides.setState({ + onboarding: { + completed: true, + completedAtVersion: '7.2.0', + }, + }); + + mockReadFile.mockImplementation(async (filePath) => { + if (String(filePath).includes('unraid-version')) { + return 'version="7.2.0"\n'; + } + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); + }); + + const tracker = new OnboardingTrackerService(config, overrides); + await tracker.onApplicationBootstrap(); + + await expect(tracker.getStateResult()).resolves.toEqual({ + kind: 'ok', + state: { + completed: true, + completedAtVersion: '7.2.0', + }, + }); + }); + + it('propagates tracker read failures through isCompleted', async () => { + const config = createConfigService(); + const overrides = new OnboardingOverrideService(); + + mockReadFile.mockImplementation(async (filePath) => { + if (String(filePath).includes('unraid-version')) { + return 'version="7.2.0"\n'; + } + throw new Error('permission denied'); + }); + + const tracker = new OnboardingTrackerService(config, overrides); + await tracker.onApplicationBootstrap(); + + await expect(tracker.isCompleted()).rejects.toThrow('permission denied'); + }); + + it('propagates tracker read failures through getCompletedAtVersion', async () => { + const config = createConfigService(); + const overrides = new OnboardingOverrideService(); + + mockReadFile.mockImplementation(async (filePath) => { + if (String(filePath).includes('unraid-version')) { + return 'version="7.2.0"\n'; + } + throw new Error('permission denied'); + }); + + const tracker = new OnboardingTrackerService(config, overrides); + await tracker.onApplicationBootstrap(); + + await expect(tracker.getCompletedAtVersion()).rejects.toThrow('permission denied'); + }); +}); diff --git a/api/src/unraid-api/config/onboarding-tracker.service.ts b/api/src/unraid-api/config/onboarding-tracker.service.ts index a7fb9d785f..44979383dc 100644 --- a/api/src/unraid-api/config/onboarding-tracker.service.ts +++ b/api/src/unraid-api/config/onboarding-tracker.service.ts @@ -15,6 +15,13 @@ const DEFAULT_OS_VERSION_FILE_PATH = '/etc/unraid-version'; const WRITE_RETRY_ATTEMPTS = 3; const WRITE_RETRY_DELAY_MS = 100; +type PublicTrackerState = { completed: boolean; completedAtVersion?: string }; + +export type OnboardingTrackerStateResult = + | { kind: 'ok'; state: PublicTrackerState } + | { kind: 'missing'; state: PublicTrackerState } + | { kind: 'error'; error: Error }; + /** * Simplified onboarding tracker service. * Tracks whether onboarding has been completed and at which version. @@ -39,15 +46,12 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { async onApplicationBootstrap() { this.currentVersion = await this.readCurrentVersion(); - const previousState = await this.readTrackerState(); - this.state = previousState ?? {}; + const previousState = await this.readTrackerStateResult(); + this.state = previousState.kind === 'error' ? {} : previousState.state; this.syncConfig(); } - /** - * Get the current onboarding state. - */ - getState(): { completed: boolean; completedAtVersion?: string } { + private getCachedState(): PublicTrackerState { // Check for override first (for testing) const overrideState = this.onboardingOverrides.getState(); if (overrideState?.onboarding !== undefined) { @@ -66,15 +70,25 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { /** * Check if onboarding is completed. */ - isCompleted(): boolean { - return this.getState().completed; + async isCompleted(): Promise { + const stateResult = await this.getStateResult(); + if (stateResult.kind === 'error') { + throw stateResult.error; + } + + return stateResult.state.completed; } /** * Get the version at which onboarding was completed. */ - getCompletedAtVersion(): string | undefined { - return this.getState().completedAtVersion; + async getCompletedAtVersion(): Promise { + const stateResult = await this.getStateResult(); + if (stateResult.kind === 'error') { + throw stateResult.error; + } + + return stateResult.state.completedAtVersion; } /** @@ -84,6 +98,23 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { return this.currentVersion; } + async getStateResult(): Promise { + const overrideState = this.onboardingOverrides.getState(); + if (overrideState?.onboarding !== undefined) { + return { + kind: 'ok', + state: this.getCachedState(), + }; + } + + const trackerStateResult = await this.readTrackerStateResult(); + if (trackerStateResult.kind !== 'error') { + this.state = trackerStateResult.state; + } + + return trackerStateResult; + } + /** * Mark onboarding as completed for the current OS version. */ @@ -101,7 +132,7 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { }, }; this.onboardingOverrides.setState(updatedOverride); - return this.getState(); + return this.getCachedState(); } const updatedState: TrackerState = { @@ -112,7 +143,7 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { await this.writeTrackerState(updatedState); this.syncConfig(); - return this.getState(); + return this.getCachedState(); } /** @@ -131,7 +162,7 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { }, }; this.onboardingOverrides.setState(updatedOverride); - return this.getState(); + return this.getCachedState(); } const updatedState: TrackerState = { @@ -142,7 +173,7 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { await this.writeTrackerState(updatedState); this.syncConfig(); - return this.getState(); + return this.getCachedState(); } private syncConfig() { @@ -164,15 +195,39 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { } } - private async readTrackerState(): Promise { + private async readTrackerStateResult(): Promise { try { const content = await readFile(this.trackerPath, 'utf8'); - return JSON.parse(content) as TrackerState; + const state = JSON.parse(content) as TrackerState; + return { + kind: 'ok', + state: { + completed: state.completed ?? false, + completedAtVersion: state.completedAtVersion, + }, + }; } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + this.logger.debug(`Onboarding tracker state does not exist yet at ${this.trackerPath}.`); + return { + kind: 'missing', + state: { + completed: false, + completedAtVersion: undefined, + }, + }; + } + this.logger.debug( `Unable to read onboarding tracker state at ${this.trackerPath}: ${error}` ); - return undefined; + return { + kind: 'error', + error: + error instanceof Error + ? error + : new Error('Unable to read onboarding tracker state'), + }; } } diff --git a/api/src/unraid-api/graph/resolvers/customization/customization.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.spec.ts index a2faeddab1..fdf679630f 100644 --- a/api/src/unraid-api/graph/resolvers/customization/customization.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.spec.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { OnboardingTrackerService } from '@app/unraid-api/config/onboarding-tracker.module.js'; import { OnboardingStatus } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; import { CustomizationResolver } from '@app/unraid-api/graph/resolvers/customization/customization.resolver.js'; import { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js'; @@ -14,20 +13,16 @@ describe('CustomizationResolver', () => { getTheme: vi.fn(), isFreshInstall: vi.fn(), getOnboardingState: vi.fn(), + getOnboardingResponse: vi.fn(), } as unknown as OnboardingService; - const onboardingTracker = { - getState: vi.fn(), - getCurrentVersion: vi.fn(), - } as unknown as OnboardingTrackerService; const displayService = { getAvailableLanguages: vi.fn(), } as unknown as DisplayService; - const resolver = new CustomizationResolver(onboardingService, onboardingTracker, displayService); + const resolver = new CustomizationResolver(onboardingService, displayService); beforeEach(() => { vi.clearAllMocks(); - vi.mocked(onboardingTracker.getCurrentVersion).mockReturnValue('7.2.0'); vi.mocked(onboardingService.getPublicPartnerInfo).mockResolvedValue(null); vi.mocked(onboardingService.getActivationDataForPublic).mockResolvedValue(null); vi.mocked(onboardingService.getOnboardingState).mockResolvedValue({ @@ -37,12 +32,42 @@ describe('CustomizationResolver', () => { hasActivationCode: false, activationRequired: false, }); + vi.mocked(onboardingService.getOnboardingResponse).mockResolvedValue({ + status: OnboardingStatus.INCOMPLETE, + isPartnerBuild: false, + completed: false, + completedAtVersion: undefined, + onboardingState: { + registrationState: undefined, + isRegistered: false, + isFreshInstall: false, + hasActivationCode: false, + activationRequired: false, + }, + }); + }); + + it('throws when tracker state could not be read', async () => { + vi.mocked(onboardingService.getOnboardingResponse).mockRejectedValue( + new Error('permission denied') + ); + + await expect(resolver.resolveOnboarding()).rejects.toThrow(); }); it('returns INCOMPLETE status when not completed', async () => { - vi.mocked(onboardingTracker.getState).mockReturnValue({ + vi.mocked(onboardingService.getOnboardingResponse).mockResolvedValue({ + status: OnboardingStatus.INCOMPLETE, + isPartnerBuild: false, completed: false, completedAtVersion: undefined, + onboardingState: { + registrationState: undefined, + isRegistered: false, + isFreshInstall: false, + hasActivationCode: false, + activationRequired: false, + }, }); const result = await resolver.resolveOnboarding(); @@ -63,9 +88,18 @@ describe('CustomizationResolver', () => { }); it('returns COMPLETED status when completed on current version', async () => { - vi.mocked(onboardingTracker.getState).mockReturnValue({ + vi.mocked(onboardingService.getOnboardingResponse).mockResolvedValue({ + status: OnboardingStatus.COMPLETED, + isPartnerBuild: false, completed: true, completedAtVersion: '7.2.0', + onboardingState: { + registrationState: undefined, + isRegistered: false, + isFreshInstall: false, + hasActivationCode: false, + activationRequired: false, + }, }); const result = await resolver.resolveOnboarding(); @@ -86,11 +120,19 @@ describe('CustomizationResolver', () => { }); it('returns COMPLETED status when completed on a prior patch of current minor', async () => { - vi.mocked(onboardingTracker.getState).mockReturnValue({ + vi.mocked(onboardingService.getOnboardingResponse).mockResolvedValue({ + status: OnboardingStatus.COMPLETED, + isPartnerBuild: false, completed: true, completedAtVersion: '7.2.1', + onboardingState: { + registrationState: undefined, + isRegistered: false, + isFreshInstall: false, + hasActivationCode: false, + activationRequired: false, + }, }); - vi.mocked(onboardingTracker.getCurrentVersion).mockReturnValue('7.2.3'); const result = await resolver.resolveOnboarding(); @@ -110,9 +152,18 @@ describe('CustomizationResolver', () => { }); it('returns UPGRADE status when completed on older version', async () => { - vi.mocked(onboardingTracker.getState).mockReturnValue({ + vi.mocked(onboardingService.getOnboardingResponse).mockResolvedValue({ + status: OnboardingStatus.UPGRADE, + isPartnerBuild: false, completed: true, completedAtVersion: '7.1.0', + onboardingState: { + registrationState: undefined, + isRegistered: false, + isFreshInstall: false, + hasActivationCode: false, + activationRequired: false, + }, }); const result = await resolver.resolveOnboarding(); @@ -133,9 +184,18 @@ describe('CustomizationResolver', () => { }); it('returns DOWNGRADE status when completed on newer version', async () => { - vi.mocked(onboardingTracker.getState).mockReturnValue({ + vi.mocked(onboardingService.getOnboardingResponse).mockResolvedValue({ + status: OnboardingStatus.DOWNGRADE, + isPartnerBuild: false, completed: true, completedAtVersion: '7.3.0', + onboardingState: { + registrationState: undefined, + isRegistered: false, + isFreshInstall: false, + hasActivationCode: false, + activationRequired: false, + }, }); const result = await resolver.resolveOnboarding(); @@ -156,13 +216,17 @@ describe('CustomizationResolver', () => { }); it('returns isPartnerBuild true when partner info exists', async () => { - vi.mocked(onboardingTracker.getState).mockReturnValue({ + vi.mocked(onboardingService.getOnboardingResponse).mockResolvedValue({ + status: OnboardingStatus.INCOMPLETE, + isPartnerBuild: true, completed: false, completedAtVersion: undefined, - }); - vi.mocked(onboardingService.getPublicPartnerInfo).mockResolvedValue({ - partner: { - name: 'Test Partner', + onboardingState: { + registrationState: undefined, + isRegistered: false, + isFreshInstall: false, + hasActivationCode: false, + activationRequired: false, }, }); diff --git a/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts index 1f4c770495..9c6966e37c 100644 --- a/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts @@ -4,24 +4,20 @@ import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { Public } from '@app/unraid-api/auth/public.decorator.js'; -import { OnboardingTrackerService } from '@app/unraid-api/config/onboarding-tracker.module.js'; import { ActivationCode, Customization, Onboarding, - OnboardingStatus, } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; import { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js'; import { Theme } from '@app/unraid-api/graph/resolvers/customization/theme.model.js'; import { Language } from '@app/unraid-api/graph/resolvers/info/display/display.model.js'; import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js'; -import { getOnboardingVersionDirection } from '@app/unraid-api/graph/resolvers/onboarding/onboarding-status.util.js'; @Resolver(() => Customization) export class CustomizationResolver { constructor( private readonly onboardingService: OnboardingService, - private readonly onboardingTracker: OnboardingTrackerService, private readonly displayService: DisplayService ) {} @@ -59,36 +55,7 @@ export class CustomizationResolver { resource: Resource.CUSTOMIZATIONS, }) async resolveOnboarding(): Promise { - const state = this.onboardingTracker.getState(); - const currentVersion = this.onboardingTracker.getCurrentVersion() ?? 'unknown'; - const partnerInfo = await this.onboardingService.getPublicPartnerInfo(); - const activationData = await this.onboardingService.getActivationData(); - const onboardingState = await this.onboardingService.getOnboardingState(); - const versionDirection = getOnboardingVersionDirection(state.completedAtVersion, currentVersion); - - // Compute the status based on completion state and version - let status: OnboardingStatus; - if (!state.completed) { - status = OnboardingStatus.INCOMPLETE; - } else if (versionDirection === 'DOWNGRADE') { - status = OnboardingStatus.DOWNGRADE; - } else if (versionDirection === 'UPGRADE') { - status = OnboardingStatus.UPGRADE; - } else { - status = OnboardingStatus.COMPLETED; - } - - // Get the activation code string if present and non-empty - const activationCode = activationData?.code?.trim() || undefined; - - return { - status, - isPartnerBuild: partnerInfo !== null, - completed: state.completed, - completedAtVersion: state.completedAtVersion, - activationCode, - onboardingState, - }; + return this.onboardingService.getOnboardingResponse({ includeActivationCode: true }); } @ResolveField(() => [Language], { nullable: true, name: 'availableLanguages' }) diff --git a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts index 79ad738de2..aeb03e96b6 100644 --- a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts @@ -14,7 +14,10 @@ import { getters } from '@app/store/index.js'; import { OnboardingOverrideService } from '@app/unraid-api/config/onboarding-override.service.js'; import { OnboardingStateService } from '@app/unraid-api/config/onboarding-state.service.js'; import { OnboardingTrackerService } from '@app/unraid-api/config/onboarding-tracker.module.js'; -import { ActivationCode } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; +import { + ActivationCode, + OnboardingStatus, +} from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; import { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js'; vi.mock('@app/core/utils/files/file-exists.js'); @@ -115,9 +118,11 @@ vi.mock('@app/core/utils/misc/sleep.js', async () => { }); const onboardingTrackerMock = { - isCompleted: vi.fn<() => boolean>(), - getState: vi.fn<() => { completed: boolean; completedAtVersion?: string }>(), + isCompleted: vi.fn<() => Promise>(), + getStateResult: vi.fn(), + getCurrentVersion: vi.fn(), markCompleted: vi.fn<() => Promise<{ completed: boolean; completedAtVersion?: string }>>(), + reset: vi.fn<() => Promise<{ completed: boolean; completedAtVersion?: string }>>(), }; const onboardingOverridesMock = { getState: vi.fn(), @@ -182,7 +187,27 @@ describe('OnboardingService', () => { loggerWarnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {}); onboardingTrackerMock.isCompleted.mockReset(); - onboardingTrackerMock.isCompleted.mockReturnValue(false); + onboardingTrackerMock.isCompleted.mockResolvedValue(false); + onboardingTrackerMock.getStateResult.mockReset(); + onboardingTrackerMock.getStateResult.mockResolvedValue({ + kind: 'ok', + state: { + completed: false, + completedAtVersion: undefined, + }, + }); + onboardingTrackerMock.getCurrentVersion.mockReset(); + onboardingTrackerMock.getCurrentVersion.mockReturnValue('7.2.0'); + onboardingTrackerMock.markCompleted.mockReset(); + onboardingTrackerMock.markCompleted.mockResolvedValue({ + completed: true, + completedAtVersion: '7.2.0', + }); + onboardingTrackerMock.reset.mockReset(); + onboardingTrackerMock.reset.mockResolvedValue({ + completed: false, + completedAtVersion: undefined, + }); onboardingOverridesMock.getState.mockReset(); onboardingOverridesMock.getState.mockReturnValue(null); onboardingOverridesMock.setState.mockReset(); @@ -244,6 +269,93 @@ describe('OnboardingService', () => { expect(service).toBeDefined(); }); + describe('getOnboardingResponse', () => { + it('builds an onboarding response with activation code when requested', async () => { + onboardingTrackerMock.getStateResult.mockResolvedValue({ + kind: 'ok', + state: { + completed: true, + completedAtVersion: '7.2.0', + }, + }); + onboardingTrackerMock.getCurrentVersion.mockReturnValue('7.2.0'); + onboardingStateMock.getRegistrationState.mockReturnValue('PRO'); + onboardingStateMock.hasActivationCode.mockResolvedValue(true); + onboardingStateMock.isFreshInstall.mockReturnValue(false); + onboardingStateMock.isRegistered.mockReturnValue(true); + onboardingStateMock.requiresActivationStep.mockReturnValue(false); + + vi.spyOn(service, 'getPublicPartnerInfo').mockResolvedValue({ + partner: { name: 'Partner' }, + branding: undefined, + }); + vi.spyOn(service, 'getActivationData').mockResolvedValue( + plainToInstance(ActivationCode, { + code: ' ABC123 ', + }) + ); + + await expect( + service.getOnboardingResponse({ includeActivationCode: true }) + ).resolves.toEqual({ + status: OnboardingStatus.COMPLETED, + isPartnerBuild: true, + completed: true, + completedAtVersion: '7.2.0', + activationCode: 'ABC123', + onboardingState: { + registrationState: 'PRO', + isRegistered: true, + isFreshInstall: false, + hasActivationCode: true, + activationRequired: false, + }, + }); + }); + + it('omits activation code when it is not requested', async () => { + vi.spyOn(service, 'getPublicPartnerInfo').mockResolvedValue(null); + vi.spyOn(service, 'getActivationData'); + + await expect(service.getOnboardingResponse()).resolves.toMatchObject({ + status: OnboardingStatus.INCOMPLETE, + completed: false, + completedAtVersion: undefined, + }); + expect(service.getActivationData).not.toHaveBeenCalled(); + }); + + it('throws when tracker state is unavailable', async () => { + onboardingTrackerMock.getStateResult.mockResolvedValue({ + kind: 'error', + error: new Error('permission denied'), + }); + + await expect(service.getOnboardingResponse()).rejects.toThrow( + 'Onboarding tracker state is unavailable.' + ); + }); + + it('treats a missing tracker file as incomplete onboarding state', async () => { + onboardingTrackerMock.getStateResult.mockResolvedValue({ + kind: 'missing', + state: { + completed: false, + completedAtVersion: undefined, + }, + }); + vi.spyOn(service, 'getPublicPartnerInfo').mockResolvedValue(null); + vi.spyOn(service, 'getActivationData'); + + await expect(service.getOnboardingResponse()).resolves.toMatchObject({ + status: OnboardingStatus.INCOMPLETE, + completed: false, + completedAtVersion: undefined, + }); + expect(service.getActivationData).not.toHaveBeenCalled(); + }); + }); + describe('onModuleInit', () => { it('should log error if dynamix user config path is missing', async () => { const originalDynamixConfig = [...mockPaths['dynamix-config']]; @@ -289,7 +401,7 @@ describe('OnboardingService', () => { }); it('should skip customizations when first boot already completed', async () => { - onboardingTrackerMock.isCompleted.mockReturnValueOnce(true); + onboardingTrackerMock.isCompleted.mockResolvedValueOnce(true); await service.onModuleInit(); @@ -305,8 +417,8 @@ describe('OnboardingService', () => { it('should be idempotent across init calls when tracker marks setup complete', async () => { onboardingTrackerMock.isCompleted - .mockReturnValueOnce(false) // first init applies customizations - .mockReturnValueOnce(true); // second init should skip + .mockResolvedValueOnce(false) // first init applies customizations + .mockResolvedValueOnce(true); // second init should skip vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readdir).mockResolvedValue([activationJsonFile as any]); vi.mocked(fs.readFile).mockImplementation(async (p) => { @@ -1363,7 +1475,7 @@ describe('applyActivationCustomizations specific tests', () => { loggerWarnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {}); onboardingTrackerMock.isCompleted.mockReset(); - onboardingTrackerMock.isCompleted.mockReturnValue(false); + onboardingTrackerMock.isCompleted.mockResolvedValue(false); onboardingOverridesMock.getState.mockReset(); onboardingOverridesMock.getState.mockReturnValue(null); onboardingOverridesMock.setState.mockReset(); @@ -1569,7 +1681,7 @@ describe('OnboardingService - updateCfgFile', () => { loggerLogSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => {}); loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {}); onboardingTrackerMock.isCompleted.mockReset(); - onboardingTrackerMock.isCompleted.mockReturnValue(false); + onboardingTrackerMock.isCompleted.mockResolvedValue(false); onboardingOverridesMock.getState.mockReset(); onboardingOverridesMock.getState.mockReturnValue(null); onboardingOverridesMock.setState.mockReset(); diff --git a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts index 5f798d715a..6f4c8b5e6e 100644 --- a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts +++ b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts @@ -19,7 +19,9 @@ import { OnboardingTrackerService } from '@app/unraid-api/config/onboarding-trac import { ActivationCode, BrandingConfig, + Onboarding, OnboardingState, + OnboardingStatus, PublicPartnerInfo, } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; import { @@ -27,6 +29,7 @@ import { getActivationDirCandidates, } from '@app/unraid-api/graph/resolvers/customization/activation-steps.util.js'; import { Theme, ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js'; +import { getOnboardingVersionDirection } from '@app/unraid-api/graph/resolvers/onboarding/onboarding-status.util.js'; @Injectable() export class OnboardingService implements OnModuleInit { @@ -54,7 +57,7 @@ export class OnboardingService implements OnModuleInit { private async ensureFirstBootCompletion(): Promise { await fs.mkdir(this.activationDir, { recursive: true }); // Check if onboarding has already been completed - const alreadyCompleted = this.onboardingTracker.isCompleted(); + const alreadyCompleted = await this.onboardingTracker.isCompleted(); if (alreadyCompleted) { this.logger.log('Onboarding already completed, skipping first boot setup.'); return true; @@ -340,6 +343,52 @@ export class OnboardingService implements OnModuleInit { }; } + public async getOnboardingResponse(options?: { + includeActivationCode?: boolean; + }): Promise { + const trackerStateResult = await this.onboardingTracker.getStateResult(); + if (trackerStateResult.kind === 'error') { + throw new GraphQLError('Onboarding tracker state is unavailable.'); + } + + const state = trackerStateResult.state; + const currentVersion = this.onboardingTracker.getCurrentVersion() ?? 'unknown'; + const partnerInfo = await this.getPublicPartnerInfo(); + const onboardingState = await this.getOnboardingState(); + const versionDirection = getOnboardingVersionDirection(state.completedAtVersion, currentVersion); + + let status: OnboardingStatus; + if (!state.completed) { + status = OnboardingStatus.INCOMPLETE; + } else if (versionDirection === 'DOWNGRADE') { + status = OnboardingStatus.DOWNGRADE; + } else if (versionDirection === 'UPGRADE') { + status = OnboardingStatus.UPGRADE; + } else { + status = OnboardingStatus.COMPLETED; + } + + const activationData = options?.includeActivationCode ? await this.getActivationData() : null; + const activationCode = activationData?.code?.trim() || undefined; + + return { + status, + isPartnerBuild: partnerInfo !== null, + completed: state.completed, + completedAtVersion: state.completedAtVersion, + activationCode, + onboardingState, + }; + } + + public async markOnboardingCompleted(): Promise { + await this.onboardingTracker.markCompleted(); + } + + public async resetOnboarding(): Promise { + await this.onboardingTracker.reset(); + } + public isFreshInstall(): boolean { return this.onboardingState.isFreshInstall(); } diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts index 97db6ca47f..a1476d97cd 100644 --- a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts @@ -1,167 +1,122 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { OnboardingOverrideService } from '@app/unraid-api/config/onboarding-override.service.js'; +import type { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js'; +import type { OnboardingInternalBootService } from '@app/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.js'; +import type { OnboardingOverrideInput } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js'; import { OnboardingStatus } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; import { CreateInternalBootPoolInput } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js'; import { OnboardingMutationsResolver } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.mutation.js'; describe('OnboardingMutationsResolver', () => { - const onboardingTracker = { - markCompleted: vi.fn(), - reset: vi.fn(), - getState: vi.fn(), - getCurrentVersion: vi.fn(), - }; - const onboardingOverrides = { setState: vi.fn(), clearState: vi.fn(), - }; + } satisfies Pick; const onboardingService = { - getPublicPartnerInfo: vi.fn(), - getOnboardingState: vi.fn(), + markOnboardingCompleted: vi.fn(), + resetOnboarding: vi.fn(), + getOnboardingResponse: vi.fn(), clearActivationDataCache: vi.fn(), - }; + } satisfies Pick< + OnboardingService, + | 'markOnboardingCompleted' + | 'resetOnboarding' + | 'getOnboardingResponse' + | 'clearActivationDataCache' + >; const onboardingInternalBootService = { createInternalBootPool: vi.fn(), - }; - - let resolver: OnboardingMutationsResolver; - - beforeEach(() => { - vi.clearAllMocks(); - onboardingTracker.getState.mockReturnValue({ - completed: false, - completedAtVersion: undefined, - }); - onboardingTracker.getCurrentVersion.mockReturnValue('7.2.0'); - onboardingService.getPublicPartnerInfo.mockResolvedValue(null); - onboardingService.getOnboardingState.mockResolvedValue({ + } satisfies Pick; + + const defaultOnboardingResponse = { + status: OnboardingStatus.INCOMPLETE, + isPartnerBuild: false, + completed: false, + completedAtVersion: undefined, + onboardingState: { registrationState: null, isRegistered: false, isFreshInstall: false, hasActivationCode: false, activationRequired: false, - }); + }, + }; + + let resolver: OnboardingMutationsResolver; - resolver = new OnboardingMutationsResolver( - onboardingTracker as any, - onboardingOverrides as any, - onboardingService as any, - onboardingInternalBootService as any + const createResolver = () => + new OnboardingMutationsResolver( + onboardingOverrides as unknown as OnboardingOverrideService, + onboardingService as unknown as OnboardingService, + onboardingInternalBootService as unknown as OnboardingInternalBootService ); + + beforeEach(() => { + vi.clearAllMocks(); + onboardingService.markOnboardingCompleted.mockResolvedValue(undefined); + onboardingService.resetOnboarding.mockResolvedValue(undefined); + onboardingService.getOnboardingResponse.mockResolvedValue(defaultOnboardingResponse); + + resolver = createResolver(); }); - it('propagates tracker failure from completeOnboarding', async () => { + it('propagates completion failures', async () => { const error = new Error('tracker-write-failed'); - onboardingTracker.markCompleted.mockRejectedValue(error); + onboardingService.markOnboardingCompleted.mockRejectedValue(error); await expect(resolver.completeOnboarding()).rejects.toThrow('tracker-write-failed'); - expect(onboardingService.getPublicPartnerInfo).not.toHaveBeenCalled(); + expect(onboardingService.getOnboardingResponse).not.toHaveBeenCalled(); }); - it('returns completed onboarding state when markCompleted succeeds', async () => { - onboardingTracker.markCompleted.mockResolvedValue({ + it('delegates completeOnboarding through the onboarding service', async () => { + const response = { + ...defaultOnboardingResponse, + status: OnboardingStatus.COMPLETED, completed: true, completedAtVersion: '7.2.0', - }); - onboardingTracker.getState.mockReturnValue({ - completed: true, - completedAtVersion: '7.2.0', - }); - onboardingTracker.getCurrentVersion.mockReturnValue('7.2.0'); - onboardingService.getPublicPartnerInfo.mockResolvedValue(null); - - const result = await resolver.completeOnboarding(); + }; + onboardingService.getOnboardingResponse.mockResolvedValue(response); - expect(result.completed).toBe(true); - expect(result.completedAtVersion).toBe('7.2.0'); - expect(result.status).toBe(OnboardingStatus.COMPLETED); - expect(result.isPartnerBuild).toBe(false); - expect(result.onboardingState).toEqual({ - registrationState: null, - isRegistered: false, - isFreshInstall: false, - hasActivationCode: false, - activationRequired: false, - }); + await expect(resolver.completeOnboarding()).resolves.toEqual(response); + expect(onboardingService.markOnboardingCompleted).toHaveBeenCalledTimes(1); + expect(onboardingService.getOnboardingResponse).toHaveBeenCalledWith(); }); - it('returns incomplete status after resetOnboarding', async () => { - onboardingTracker.reset.mockResolvedValue(undefined); - onboardingTracker.getState.mockReturnValue({ + it('delegates resetOnboarding through the onboarding service', async () => { + const response = { + ...defaultOnboardingResponse, + status: OnboardingStatus.INCOMPLETE, completed: false, - completedAtVersion: undefined, - }); - - const result = await resolver.resetOnboarding(); - - expect(onboardingTracker.reset).toHaveBeenCalledTimes(1); - expect(result.status).toBe(OnboardingStatus.INCOMPLETE); - expect(result.completed).toBe(false); - }); - - it('returns completed status when completed version is on a prior patch of current minor', async () => { - onboardingTracker.markCompleted.mockResolvedValue(undefined); - onboardingTracker.getState.mockReturnValue({ - completed: true, - completedAtVersion: '7.2.1', - }); - onboardingTracker.getCurrentVersion.mockReturnValue('7.2.4'); - - const result = await resolver.completeOnboarding(); - - expect(result.status).toBe(OnboardingStatus.COMPLETED); - }); - - it('returns upgrade status when completed version is behind current', async () => { - onboardingTracker.markCompleted.mockResolvedValue(undefined); - onboardingTracker.getState.mockReturnValue({ - completed: true, - completedAtVersion: '7.1.0', - }); - onboardingTracker.getCurrentVersion.mockReturnValue('7.2.0'); - - const result = await resolver.completeOnboarding(); - - expect(result.status).toBe(OnboardingStatus.UPGRADE); - }); - - it('returns downgrade status when completed version is ahead of current', async () => { - onboardingTracker.markCompleted.mockResolvedValue(undefined); - onboardingTracker.getState.mockReturnValue({ - completed: true, - completedAtVersion: '7.2.0', - }); - onboardingTracker.getCurrentVersion.mockReturnValue('7.1.0'); - - const result = await resolver.completeOnboarding(); + }; + onboardingService.getOnboardingResponse.mockResolvedValue(response); - expect(result.status).toBe(OnboardingStatus.DOWNGRADE); + await expect(resolver.resetOnboarding()).resolves.toEqual(response); + expect(onboardingService.resetOnboarding).toHaveBeenCalledTimes(1); + expect(onboardingService.getOnboardingResponse).toHaveBeenCalledWith(); }); - it('setOnboardingOverride stores override, clears cache, and returns onboarding state', async () => { - onboardingTracker.getState.mockReturnValue({ + it('stores overrides, clears activation cache, and returns the shared onboarding response', async () => { + const response = { + ...defaultOnboardingResponse, + status: OnboardingStatus.COMPLETED, + isPartnerBuild: true, completed: true, completedAtVersion: '7.2.0', - }); - onboardingTracker.getCurrentVersion.mockReturnValue('7.2.0'); - onboardingService.getPublicPartnerInfo.mockResolvedValue({ - partner: { name: 'Partner' }, - branding: {}, - } as any); + }; + onboardingService.getOnboardingResponse.mockResolvedValue(response); - const input = { + const input: OnboardingOverrideInput = { onboarding: { completed: true, completedAtVersion: '7.2.0', }, registrationState: undefined, - } as any; - - const result = await resolver.setOnboardingOverride(input); + }; + await expect(resolver.setOnboardingOverride(input)).resolves.toEqual(response); expect(onboardingOverrides.setState).toHaveBeenCalledWith({ onboarding: input.onboarding, activationCode: undefined, @@ -169,21 +124,20 @@ describe('OnboardingMutationsResolver', () => { registrationState: undefined, }); expect(onboardingService.clearActivationDataCache).toHaveBeenCalledTimes(1); - expect(result.status).toBe(OnboardingStatus.COMPLETED); - expect(result.isPartnerBuild).toBe(true); + expect(onboardingService.getOnboardingResponse).toHaveBeenCalledWith(); }); - it('clearOnboardingOverride clears override and cache', async () => { - onboardingTracker.getState.mockReturnValue({ - completed: false, - completedAtVersion: undefined, - }); - - const result = await resolver.clearOnboardingOverride(); - + it('clears overrides and returns the shared onboarding response', async () => { + await expect(resolver.clearOnboardingOverride()).resolves.toEqual(defaultOnboardingResponse); expect(onboardingOverrides.clearState).toHaveBeenCalledTimes(1); expect(onboardingService.clearActivationDataCache).toHaveBeenCalledTimes(1); - expect(result.status).toBe(OnboardingStatus.INCOMPLETE); + expect(onboardingService.getOnboardingResponse).toHaveBeenCalledWith(); + }); + + it('propagates onboarding response failures after completion', async () => { + onboardingService.getOnboardingResponse.mockRejectedValue(new Error('tracker-read-failed')); + + await expect(resolver.completeOnboarding()).rejects.toThrow('tracker-read-failed'); }); it('delegates createInternalBootPool to onboarding internal boot service', async () => { @@ -199,13 +153,12 @@ describe('OnboardingMutationsResolver', () => { bootSizeMiB: 16384, updateBios: true, }; - const result = await resolver.createInternalBootPool(input); - expect(onboardingInternalBootService.createInternalBootPool).toHaveBeenCalledWith(input); - expect(result).toEqual({ + await expect(resolver.createInternalBootPool(input)).resolves.toEqual({ ok: true, code: 0, output: 'done', }); + expect(onboardingInternalBootService.createInternalBootPool).toHaveBeenCalledWith(input); }); }); diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts index 358347fdcf..1011f65b2e 100644 --- a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts @@ -5,15 +5,10 @@ import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import type { OnboardingOverrideState } from '@app/unraid-api/config/onboarding-override.model.js'; import { OnboardingOverrideService } from '@app/unraid-api/config/onboarding-override.service.js'; -import { OnboardingTrackerService } from '@app/unraid-api/config/onboarding-tracker.module.js'; -import { - Onboarding, - OnboardingStatus, -} from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; +import { Onboarding } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; import { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js'; import { OnboardingMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js'; import { OnboardingInternalBootService } from '@app/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.js'; -import { getOnboardingVersionDirection } from '@app/unraid-api/graph/resolvers/onboarding/onboarding-status.util.js'; import { CreateInternalBootPoolInput, OnboardingInternalBootResult, @@ -23,43 +18,11 @@ import { @Resolver(() => OnboardingMutations) export class OnboardingMutationsResolver { constructor( - private readonly onboardingTracker: OnboardingTrackerService, private readonly onboardingOverrides: OnboardingOverrideService, private readonly onboardingService: OnboardingService, private readonly onboardingInternalBootService: OnboardingInternalBootService ) {} - /** - * Build a full Onboarding response with computed status - */ - private async buildOnboardingResponse(): Promise { - const state = this.onboardingTracker.getState(); - const currentVersion = this.onboardingTracker.getCurrentVersion() ?? 'unknown'; - const partnerInfo = await this.onboardingService.getPublicPartnerInfo(); - const onboardingState = await this.onboardingService.getOnboardingState(); - const versionDirection = getOnboardingVersionDirection(state.completedAtVersion, currentVersion); - - // Compute the status based on completion state and version - let status: OnboardingStatus; - if (!state.completed) { - status = OnboardingStatus.INCOMPLETE; - } else if (versionDirection === 'DOWNGRADE') { - status = OnboardingStatus.DOWNGRADE; - } else if (versionDirection === 'UPGRADE') { - status = OnboardingStatus.UPGRADE; - } else { - status = OnboardingStatus.COMPLETED; - } - - return { - status, - isPartnerBuild: partnerInfo !== null, - completed: state.completed, - completedAtVersion: state.completedAtVersion, - onboardingState, - }; - } - @ResolveField(() => Onboarding, { description: 'Marks the onboarding flow as completed', }) @@ -68,8 +31,8 @@ export class OnboardingMutationsResolver { resource: Resource.WELCOME, }) async completeOnboarding(): Promise { - await this.onboardingTracker.markCompleted(); - return this.buildOnboardingResponse(); + await this.onboardingService.markOnboardingCompleted(); + return this.onboardingService.getOnboardingResponse(); } @ResolveField(() => Onboarding, { @@ -80,8 +43,8 @@ export class OnboardingMutationsResolver { resource: Resource.WELCOME, }) async resetOnboarding(): Promise { - await this.onboardingTracker.reset(); - return this.buildOnboardingResponse(); + await this.onboardingService.resetOnboarding(); + return this.onboardingService.getOnboardingResponse(); } @ResolveField(() => Onboarding, { @@ -100,7 +63,7 @@ export class OnboardingMutationsResolver { }; this.onboardingOverrides.setState(override); this.onboardingService.clearActivationDataCache(); - return this.buildOnboardingResponse(); + return this.onboardingService.getOnboardingResponse(); } @ResolveField(() => Onboarding, { @@ -113,7 +76,7 @@ export class OnboardingMutationsResolver { async clearOnboardingOverride(): Promise { this.onboardingOverrides.clearState(); this.onboardingService.clearActivationDataCache(); - return this.buildOnboardingResponse(); + return this.onboardingService.getOnboardingResponse(); } @ResolveField(() => OnboardingInternalBootResult, { diff --git a/plugin/source/dynamix.unraid.net/install/doinst.sh b/plugin/source/dynamix.unraid.net/install/doinst.sh index 710522e626..e18f5f64eb 100644 --- a/plugin/source/dynamix.unraid.net/install/doinst.sh +++ b/plugin/source/dynamix.unraid.net/install/doinst.sh @@ -31,9 +31,3 @@ cp usr/local/unraid-api/.env.production usr/local/unraid-api/.env ( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npm-cli.js npm ) ( cd usr/local/bin ; rm -rf npx ) ( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npx-cli.js npx ) -( cd usr/local/bin ; rm -rf corepack ) -( cd usr/local/bin ; ln -sf ../lib/node_modules/corepack/dist/corepack.js corepack ) -( cd usr/local/bin ; rm -rf npm ) -( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npm-cli.js npm ) -( cd usr/local/bin ; rm -rf npx ) -( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npx-cli.js npx ) diff --git a/web/__test__/components/Onboarding/OnboardingModal.test.ts b/web/__test__/components/Onboarding/OnboardingModal.test.ts index 8d7ec6a29d..b48a8287d3 100644 --- a/web/__test__/components/Onboarding/OnboardingModal.test.ts +++ b/web/__test__/components/Onboarding/OnboardingModal.test.ts @@ -2,21 +2,22 @@ import { flushPromises, mount } from '@vue/test-utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { StepId } from '~/components/Onboarding/stepRegistry.js'; + import OnboardingModal from '~/components/Onboarding/OnboardingModal.vue'; import { createTestI18n } from '../../utils/i18n'; type InternalBootVisibilityResult = { value: { - vars: { - bootedFromFlashWithInternalBootSetup: boolean | null; - enableBootTransfer: string | null; - }; - }; + bootedFromFlashWithInternalBootSetup: boolean | null; + enableBootTransfer: string | null; + } | null; }; const { mutateMock, internalBootVisibilityResult, + internalBootVisibilityLoading, onboardingModalStoreState, activationCodeDataStore, onboardingStatusStore, @@ -29,12 +30,11 @@ const { mutateMock: vi.fn().mockResolvedValue(undefined), internalBootVisibilityResult: { value: { - vars: { - bootedFromFlashWithInternalBootSetup: false, - enableBootTransfer: 'yes', - }, + bootedFromFlashWithInternalBootSetup: false, + enableBootTransfer: 'yes', }, } as InternalBootVisibilityResult, + internalBootVisibilityLoading: { value: false }, onboardingModalStoreState: { isAutoVisible: { value: true }, isForceOpened: { value: false }, @@ -64,7 +64,12 @@ const { }, onboardingDraftStore: { currentStepIndex: { value: 0 }, + currentStepId: { value: null as StepId | null }, internalBootApplySucceeded: { value: false }, + setCurrentStep: vi.fn((stepId: StepId, stepIndex: number) => { + onboardingDraftStore.currentStepId.value = stepId; + onboardingDraftStore.currentStepIndex.value = stepIndex; + }), }, purchaseStore: { generateUrl: vi.fn(() => 'https://example.com/activate'), @@ -102,11 +107,6 @@ vi.mock('@heroicons/vue/24/solid', () => ({ })); vi.mock('@vue/apollo-composable', () => ({ - useQuery: () => ({ - result: internalBootVisibilityResult, - loading: { value: false }, - error: { value: null }, - }), useMutation: () => ({ mutate: mutateMock, }), @@ -139,6 +139,13 @@ vi.mock('~/components/Onboarding/store/activationCodeData', () => ({ useActivationCodeDataStore: () => activationCodeDataStore, })); +vi.mock('~/components/Onboarding/store/onboardingContextData', () => ({ + useOnboardingContextDataStore: () => ({ + internalBootVisibility: internalBootVisibilityResult, + loading: internalBootVisibilityLoading, + }), +})); + vi.mock('~/components/Onboarding/store/onboardingStatus', () => ({ useOnboardingStore: () => onboardingStatusStore, })); @@ -187,12 +194,12 @@ describe('OnboardingModal.vue', () => { onboardingStatusStore.canDisplayOnboardingModal.value = true; onboardingStatusStore.isPartnerBuild.value = false; onboardingDraftStore.currentStepIndex.value = 0; + onboardingDraftStore.currentStepId.value = null; onboardingDraftStore.internalBootApplySucceeded.value = false; + internalBootVisibilityLoading.value = false; internalBootVisibilityResult.value = { - vars: { - bootedFromFlashWithInternalBootSetup: false, - enableBootTransfer: 'yes', - }, + bootedFromFlashWithInternalBootSetup: false, + enableBootTransfer: 'yes', }; }); @@ -298,6 +305,7 @@ describe('OnboardingModal.vue', () => { it('shows activation step for ENOKEYFILE1', () => { activationCodeDataStore.registrationState.value = 'ENOKEYFILE1'; + onboardingDraftStore.currentStepId.value = 'ACTIVATE_LICENSE'; onboardingDraftStore.currentStepIndex.value = 4; const wrapper = mountComponent(); @@ -307,6 +315,7 @@ describe('OnboardingModal.vue', () => { it('shows activation step for ENOKEYFILE2', () => { activationCodeDataStore.registrationState.value = 'ENOKEYFILE2'; + onboardingDraftStore.currentStepId.value = 'ACTIVATE_LICENSE'; onboardingDraftStore.currentStepIndex.value = 4; const wrapper = mountComponent(); @@ -316,6 +325,7 @@ describe('OnboardingModal.vue', () => { it('omits activation step for non-activation registration states', () => { activationCodeDataStore.registrationState.value = 'BASIC'; + onboardingDraftStore.currentStepId.value = 'ACTIVATE_LICENSE'; onboardingDraftStore.currentStepIndex.value = 4; const wrapper = mountComponent(); @@ -326,6 +336,7 @@ describe('OnboardingModal.vue', () => { it('shows internal boot step for regular builds', () => { onboardingDraftStore.currentStepIndex.value = 2; + onboardingDraftStore.currentStepId.value = 'CONFIGURE_BOOT'; const wrapper = mountComponent(); @@ -334,12 +345,11 @@ describe('OnboardingModal.vue', () => { it('hides internal boot step when boot transfer state is unknown', () => { internalBootVisibilityResult.value = { - vars: { - bootedFromFlashWithInternalBootSetup: null, - enableBootTransfer: null, - }, + bootedFromFlashWithInternalBootSetup: null, + enableBootTransfer: null, }; onboardingDraftStore.currentStepIndex.value = 2; + onboardingDraftStore.currentStepId.value = 'CONFIGURE_BOOT'; const wrapper = mountComponent(); @@ -349,6 +359,7 @@ describe('OnboardingModal.vue', () => { it('shows internal boot step for partner builds when boot transfer is available', () => { onboardingStatusStore.isPartnerBuild.value = true; onboardingDraftStore.currentStepIndex.value = 2; + onboardingDraftStore.currentStepId.value = 'CONFIGURE_BOOT'; const wrapper = mountComponent(); @@ -357,12 +368,11 @@ describe('OnboardingModal.vue', () => { it('hides internal boot step when already booting internally', () => { internalBootVisibilityResult.value = { - vars: { - bootedFromFlashWithInternalBootSetup: false, - enableBootTransfer: 'no', - }, + bootedFromFlashWithInternalBootSetup: false, + enableBootTransfer: 'no', }; onboardingDraftStore.currentStepIndex.value = 2; + onboardingDraftStore.currentStepId.value = 'CONFIGURE_BOOT'; const wrapper = mountComponent(); @@ -372,12 +382,11 @@ describe('OnboardingModal.vue', () => { it('hides internal boot step when still booted from flash but internal boot is already configured', () => { internalBootVisibilityResult.value = { - vars: { - bootedFromFlashWithInternalBootSetup: true, - enableBootTransfer: 'yes', - }, + bootedFromFlashWithInternalBootSetup: true, + enableBootTransfer: 'yes', }; onboardingDraftStore.currentStepIndex.value = 2; + onboardingDraftStore.currentStepId.value = 'CONFIGURE_BOOT'; const wrapper = mountComponent(); @@ -385,6 +394,18 @@ describe('OnboardingModal.vue', () => { expect(wrapper.find('[data-testid="plugins-step"]').exists()).toBe(true); }); + it('keeps the resumed internal boot step visible while boot visibility is still loading', () => { + internalBootVisibilityLoading.value = true; + internalBootVisibilityResult.value = null; + onboardingDraftStore.currentStepIndex.value = 2; + onboardingDraftStore.currentStepId.value = 'CONFIGURE_BOOT'; + + const wrapper = mountComponent(); + + expect(wrapper.find('[data-testid="internal-boot-step"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="plugins-step"]').exists()).toBe(false); + }); + it('opens exit confirmation when close button is clicked', async () => { const wrapper = mountComponent(); diff --git a/web/__test__/components/Onboarding/onboardingStorageCleanup.test.ts b/web/__test__/components/Onboarding/onboardingStorageCleanup.test.ts index 673153d381..c88ae0ef2d 100644 --- a/web/__test__/components/Onboarding/onboardingStorageCleanup.test.ts +++ b/web/__test__/components/Onboarding/onboardingStorageCleanup.test.ts @@ -1,9 +1,12 @@ +import { createPinia, setActivePinia } from 'pinia'; + import { beforeEach, describe, expect, it } from 'vitest'; import { ONBOARDING_MODAL_HIDDEN_STORAGE_KEY, ONBOARDING_TEMP_BYPASS_STORAGE_KEY, } from '~/components/Onboarding/constants'; +import { useOnboardingDraftStore } from '~/components/Onboarding/store/onboardingDraft'; import { cleanupOnboardingStorage, clearLegacyOnboardingModalHiddenSessionState, @@ -13,6 +16,7 @@ import { describe('onboardingStorageCleanup', () => { beforeEach(() => { + setActivePinia(createPinia()); window.localStorage.clear(); window.sessionStorage.clear(); }); @@ -29,6 +33,26 @@ describe('onboardingStorageCleanup', () => { expect(window.localStorage.getItem('unrelatedKey')).toBe('keep'); }); + it('resets the live onboarding draft store when clearing storage', () => { + const draftStore = useOnboardingDraftStore(); + draftStore.setCoreSettings({ + serverName: 'tower', + serverDescription: 'test', + timeZone: 'UTC', + theme: 'black', + language: 'en_US', + useSsh: true, + }); + draftStore.setCurrentStep('CONFIGURE_BOOT', 2); + + clearOnboardingDraftStorage(); + + expect(draftStore.hasResumableDraft).toBe(false); + expect(draftStore.currentStepIndex).toBe(0); + expect(draftStore.currentStepId).toBeNull(); + expect(draftStore.coreSettingsInitialized).toBe(false); + }); + it('clears temporary bypass key from sessionStorage', () => { window.sessionStorage.setItem(ONBOARDING_TEMP_BYPASS_STORAGE_KEY, '{"active":true}'); diff --git a/web/__test__/store/activationCodeData.test.ts b/web/__test__/store/activationCodeData.test.ts index 2795ac9414..52134d0b2f 100644 --- a/web/__test__/store/activationCodeData.test.ts +++ b/web/__test__/store/activationCodeData.test.ts @@ -1,251 +1,119 @@ import { ref } from 'vue'; import { createPinia, setActivePinia } from 'pinia'; -import { useQuery } from '@vue/apollo-composable'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Ref } from 'vue'; -import { ACTIVATION_CODE_QUERY } from '~/components/Onboarding/graphql/activationCode.query'; import { useActivationCodeDataStore } from '~/components/Onboarding/store/activationCodeData'; +import { useOnboardingContextDataStore } from '~/components/Onboarding/store/onboardingContextData'; import { RegistrationState } from '~/composables/gql/graphql'; -// Create a complete mock of UseQueryReturn with all required properties -const createCompleteQueryMock = (result: T | null = null, loading = false) => ({ - result: ref(result), - loading: ref(loading), - error: ref(null), - networkStatus: ref(7), - called: ref(true), - variables: ref({}), - document: ref(null), - query: ref(null), - forceDisabled: ref(false), - options: { errorPolicy: 'all' as const }, - stop: vi.fn(), - start: vi.fn(), - restart: vi.fn(), - refetch: vi.fn(), - fetchMore: vi.fn(), - onResult: vi.fn(), - onError: vi.fn(), - subscribeToMore: vi.fn(), - updateQuery: vi.fn(), -}); - -vi.mock('@vue/apollo-composable', () => ({ - useQuery: vi.fn(), +type OnboardingState = { + registrationState?: RegistrationState | null; + isRegistered?: boolean; + isFreshInstall?: boolean; + hasActivationCode?: boolean; + activationRequired?: boolean; +} | null; + +type ActivationCode = { + code?: string | null; + partner?: { name?: string | null } | null; + branding?: { hasPartnerLogo?: boolean | null } | null; +} | null; + +const { state } = vi.hoisted(() => ({ + state: { + loading: null as unknown as Ref, + onboardingState: null as unknown as Ref, + activationCode: null as unknown as Ref, + }, })); describe('ActivationCodeData Store', () => { beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - describe('Computed Properties', () => { - it('should compute loading state when activationCodeLoading is true', () => { - vi.mocked(useQuery).mockImplementation((query) => { - if (query === ACTIVATION_CODE_QUERY) { - return createCompleteQueryMock(null, true); - } - - return createCompleteQueryMock(null, false); - }); - const store = useActivationCodeDataStore(); + state.loading = ref(false); + state.onboardingState = ref(null); + state.activationCode = ref(null); - expect(store.loading).toBe(true); - }); - - it('should compute loading state when both loadings are false', () => { - vi.mocked(useQuery).mockImplementation(() => createCompleteQueryMock(null, false)); + vi.mocked(useOnboardingContextDataStore).mockReturnValue({ + loading: state.loading, + onboardingState: state.onboardingState, + activationCode: state.activationCode, + } as unknown as ReturnType); + }); - const store = useActivationCodeDataStore(); + it('exposes loading from the shared onboarding context store', () => { + state.loading.value = true; - expect(store.loading).toBe(false); - }); + const store = useActivationCodeDataStore(); - it('should compute activationCode correctly', () => { - const mockActivationCode = { code: 'TEST-CODE-123' }; - - vi.mocked(useQuery).mockImplementation((query) => { - if (query === ACTIVATION_CODE_QUERY) { - return createCompleteQueryMock( - { - customization: { activationCode: mockActivationCode }, - }, - false - ); - } - return createCompleteQueryMock(null, false); - }); + expect(store.loading).toBe(true); + }); - const store = useActivationCodeDataStore(); + it('returns activation code data from the shared onboarding context store', () => { + state.activationCode.value = { code: 'TEST-CODE-123' }; - expect(store.activationCode).toEqual(mockActivationCode); - }); + const store = useActivationCodeDataStore(); - it('should compute isFreshInstall from backend when regState is ENOKEYFILE', () => { - vi.mocked(useQuery).mockImplementation((query) => { - if (query === ACTIVATION_CODE_QUERY) { - return createCompleteQueryMock( - { - customization: { - onboarding: { - onboardingState: { - registrationState: RegistrationState.ENOKEYFILE, - isFreshInstall: true, // Backend determines this value - }, - }, - }, - }, - false - ); - } - - return createCompleteQueryMock(null, false); - }); - - const store = useActivationCodeDataStore(); - - expect(store.isFreshInstall).toBe(true); - }); - - it('should compute isFreshInstall from backend when regState is ENOKEYFILE1', () => { - vi.mocked(useQuery).mockImplementation((query) => { - if (query === ACTIVATION_CODE_QUERY) { - return createCompleteQueryMock( - { - customization: { - onboarding: { - onboardingState: { - registrationState: RegistrationState.ENOKEYFILE1, - isFreshInstall: false, // Backend determines this value - }, - }, - }, - }, - false - ); - } - - return createCompleteQueryMock(null, false); - }); - - const store = useActivationCodeDataStore(); - - expect(store.isFreshInstall).toBe(false); - }); + expect(store.activationCode).toEqual({ code: 'TEST-CODE-123' }); + }); - it('should compute isFreshInstall from backend when regState is ENOKEYFILE2', () => { - vi.mocked(useQuery).mockImplementation((query) => { - if (query === ACTIVATION_CODE_QUERY) { - return createCompleteQueryMock( - { - customization: { - onboarding: { - onboardingState: { - registrationState: RegistrationState.ENOKEYFILE2, - isFreshInstall: false, // Backend determines this value - }, - }, - }, - }, - false - ); - } - - return createCompleteQueryMock(null, false); - }); - - const store = useActivationCodeDataStore(); - - expect(store.isFreshInstall).toBe(false); - }); + it('computes registration state flags from onboarding state', () => { + state.onboardingState.value = { + registrationState: RegistrationState.ENOKEYFILE, + isRegistered: false, + isFreshInstall: true, + hasActivationCode: true, + activationRequired: true, + }; + + const store = useActivationCodeDataStore(); + + expect(store.registrationState).toBe(RegistrationState.ENOKEYFILE); + expect(store.isRegistered).toBe(false); + expect(store.isFreshInstall).toBe(true); + expect(store.hasActivationCode).toBe(true); + expect(store.activationRequired).toBe(true); + }); - it('should compute isFreshInstall from backend when regState is not ENOKEYFILE', () => { - vi.mocked(useQuery).mockImplementation((query) => { - if (query === ACTIVATION_CODE_QUERY) { - return createCompleteQueryMock( - { - customization: { - onboarding: { - onboardingState: { - registrationState: RegistrationState.PRO, - isFreshInstall: false, // Backend determines this value - }, - }, - }, - }, - false - ); - } - - return createCompleteQueryMock(null, false); - }); - - const store = useActivationCodeDataStore(); - - expect(store.isFreshInstall).toBe(false); - }); + it('returns safe defaults when onboarding state is unavailable', () => { + const store = useActivationCodeDataStore(); - it('should return false for isFreshInstall when onboardingState is null (query not loaded)', () => { - vi.mocked(useQuery).mockImplementation(() => createCompleteQueryMock(null, false)); + expect(store.registrationState).toBeNull(); + expect(store.isRegistered).toBe(false); + expect(store.isFreshInstall).toBe(false); + expect(store.hasActivationCode).toBe(false); + expect(store.activationRequired).toBe(false); + }); - const store = useActivationCodeDataStore(); + it('derives partnerInfo from activation code partner and branding', () => { + state.activationCode.value = { + partner: { name: 'Activation Partner' }, + branding: { hasPartnerLogo: true }, + }; - expect(store.isFreshInstall).toBe(false); - }); + const store = useActivationCodeDataStore(); - it('should derive partnerInfo from activationCode partner and branding', () => { - const mockPartner = { name: 'Activation Partner' }; - const mockBranding = { hasPartnerLogo: true }; - vi.mocked(useQuery).mockImplementation((query) => { - if (query === ACTIVATION_CODE_QUERY) { - return createCompleteQueryMock( - { - customization: { - activationCode: { - partner: mockPartner, - branding: mockBranding, - }, - }, - }, - false - ); - } - - return createCompleteQueryMock(null, false); - }); - - const store = useActivationCodeDataStore(); - - expect(store.partnerInfo).toEqual({ - partner: mockPartner, - branding: mockBranding, - }); + expect(store.partnerInfo).toEqual({ + partner: { name: 'Activation Partner' }, + branding: { hasPartnerLogo: true }, }); + }); - it('should return null for partnerInfo when activationCode has no partner or branding', () => { - vi.mocked(useQuery).mockImplementation((query) => { - if (query === ACTIVATION_CODE_QUERY) { - return createCompleteQueryMock( - { - customization: { activationCode: null }, - }, - false - ); - } - - return createCompleteQueryMock(null, false); - }); + it('returns null partnerInfo when activation code has no partner or branding', () => { + state.activationCode.value = null; - const store = useActivationCodeDataStore(); + const store = useActivationCodeDataStore(); - expect(store.partnerInfo).toBeNull(); - }); + expect(store.partnerInfo).toBeNull(); }); }); + +vi.mock('~/components/Onboarding/store/onboardingContextData', () => ({ + useOnboardingContextDataStore: vi.fn(), +})); diff --git a/web/__test__/store/onboardingContextData.test.ts b/web/__test__/store/onboardingContextData.test.ts new file mode 100644 index 0000000000..c9b606e6e6 --- /dev/null +++ b/web/__test__/store/onboardingContextData.test.ts @@ -0,0 +1,98 @@ +import { ref } from 'vue'; +import { createPinia, setActivePinia } from 'pinia'; +import { useQuery } from '@vue/apollo-composable'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ApolloError } from '@apollo/client/core/index.js'; + +import { ONBOARDING_BOOTSTRAP_QUERY } from '~/components/Onboarding/graphql/onboardingBootstrap.query'; +import { useOnboardingContextDataStore } from '~/components/Onboarding/store/onboardingContextData'; + +const createCompleteQueryMock = ( + result: T | null = null, + loading = false, + error: ApolloError | null = null +) => ({ + result: ref(result), + loading: ref(loading), + error: ref(error), + networkStatus: ref(7), + called: ref(true), + variables: ref({}), + document: ref(null), + query: ref(null), + forceDisabled: ref(false), + options: { errorPolicy: 'all' as const, fetchPolicy: 'cache-and-network' as const }, + stop: vi.fn(), + start: vi.fn(), + restart: vi.fn(), + refetch: vi.fn(), + fetchMore: vi.fn(), + onResult: vi.fn(), + onError: vi.fn(), + subscribeToMore: vi.fn(), + updateQuery: vi.fn(), +}); + +vi.mock('@vue/apollo-composable', () => ({ + useQuery: vi.fn(), +})); + +describe('OnboardingContextData Store', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + it('loads the shared onboarding bootstrap query', () => { + vi.mocked(useQuery).mockReturnValue( + createCompleteQueryMock( + { + customization: { + activationCode: { code: 'ABC-123' }, + onboarding: { + status: 'INCOMPLETE', + onboardingState: { isFreshInstall: true }, + }, + }, + vars: { + bootedFromFlashWithInternalBootSetup: false, + enableBootTransfer: 'yes', + }, + }, + false + ) + ); + + const store = useOnboardingContextDataStore(); + + expect(useQuery).toHaveBeenCalledWith( + ONBOARDING_BOOTSTRAP_QUERY, + {}, + expect.objectContaining({ errorPolicy: 'all', fetchPolicy: 'cache-and-network' }) + ); + expect(store.activationCode).toEqual({ code: 'ABC-123' }); + expect(store.onboarding).toMatchObject({ status: 'INCOMPLETE' }); + expect(store.onboardingState).toMatchObject({ isFreshInstall: true }); + expect(store.internalBootVisibility).toEqual({ + bootedFromFlashWithInternalBootSetup: false, + enableBootTransfer: 'yes', + }); + expect(store.loading).toBe(false); + }); + + it('exposes query loading and error state', () => { + const queryError = new Error('bootstrap failed') as ApolloError; + + vi.mocked(useQuery).mockReturnValue(createCompleteQueryMock(null, true, queryError)); + + const store = useOnboardingContextDataStore(); + + expect(store.loading).toBe(true); + expect(store.error).toBe(queryError); + expect(store.onboarding).toBeNull(); + expect(store.activationCode).toBeNull(); + expect(store.internalBootVisibility).toBeNull(); + }); +}); diff --git a/web/__test__/store/onboardingModalVisibility.test.ts b/web/__test__/store/onboardingModalVisibility.test.ts index 53385c6784..2623ea0096 100644 --- a/web/__test__/store/onboardingModalVisibility.test.ts +++ b/web/__test__/store/onboardingModalVisibility.test.ts @@ -11,14 +11,19 @@ import { ONBOARDING_TEMP_BYPASS_STORAGE_KEY, } from '~/components/Onboarding/constants'; import { useActivationCodeDataStore } from '~/components/Onboarding/store/activationCodeData'; +import { useOnboardingDraftStore } from '~/components/Onboarding/store/onboardingDraft'; import { useOnboardingModalStore } from '~/components/Onboarding/store/onboardingModalVisibility'; import { useOnboardingStore } from '~/components/Onboarding/store/onboardingStatus.js'; import { useCallbackActionsStore } from '~/store/callbackActions'; import { useServerStore } from '~/store/server'; -vi.mock('@vueuse/core', () => ({ - useSessionStorage: vi.fn(), -})); +vi.mock('@vueuse/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useSessionStorage: vi.fn(), + }; +}); vi.mock('~/components/Onboarding/store/activationCodeData', () => ({ useActivationCodeDataStore: vi.fn(), @@ -41,6 +46,7 @@ describe('OnboardingModalVisibility Store', () => { let mockTemporaryBypassState: ReturnType; let mockIsFreshInstall: ReturnType; let mockCompleted: ReturnType; + let mockCanDisplayOnboardingModal: ReturnType; let mockCallbackData: ReturnType; let mockUptime: ReturnType; let app: App | null = null; @@ -71,6 +77,7 @@ describe('OnboardingModalVisibility Store', () => { mockTemporaryBypassState = ref(null); mockIsFreshInstall = ref(false); mockCompleted = ref(false); + mockCanDisplayOnboardingModal = ref(true); mockCallbackData = ref(null); mockUptime = ref(3600); @@ -88,6 +95,7 @@ describe('OnboardingModalVisibility Store', () => { vi.mocked(useOnboardingStore).mockReturnValue({ completed: mockCompleted, + canDisplayOnboardingModal: mockCanDisplayOnboardingModal, } as unknown as ReturnType); vi.mocked(useCallbackActionsStore).mockReturnValue({ @@ -154,6 +162,14 @@ describe('OnboardingModalVisibility Store', () => { expect(store.isForceOpened).toBe(false); }); + it('does not force-open when manual onboarding open is unavailable', () => { + mockCanDisplayOnboardingModal.value = false; + + expect(store.forceOpenModal()).toBe(false); + expect(store.isForceOpened).toBe(false); + expect(store.isHidden).toBe(null); + }); + it('clears legacy hidden sessionStorage state on mount', () => { window.sessionStorage.setItem(ONBOARDING_MODAL_HIDDEN_STORAGE_KEY, 'true'); @@ -198,7 +214,20 @@ describe('OnboardingModalVisibility Store', () => { }); it('applies keyboard shortcut bypass without completing onboarding', () => { - window.localStorage.setItem('onboardingDraft', '{"currentStepIndex":2}'); + const draftStore = useOnboardingDraftStore(); + draftStore.setCoreSettings({ + serverName: 'tower', + serverDescription: 'resume me', + timeZone: 'UTC', + theme: 'black', + language: 'en_US', + useSsh: true, + }); + draftStore.setCurrentStep('CONFIGURE_BOOT', 2); + window.localStorage.setItem( + 'onboardingDraft', + '{"currentStepId":"CONFIGURE_BOOT","currentStepIndex":2}' + ); window.dispatchEvent( new KeyboardEvent('keydown', { @@ -214,6 +243,8 @@ describe('OnboardingModalVisibility Store', () => { expect(store.isHidden).toBe(true); expect(mockTemporaryBypassState.value).toMatchObject({ active: true }); expect(window.localStorage.getItem('onboardingDraft')).toBeNull(); + expect(draftStore.hasResumableDraft).toBe(false); + expect(draftStore.currentStepIndex).toBe(0); }); it('does not bypass when using 0 key with modifiers', () => { @@ -332,6 +363,17 @@ describe('OnboardingModalVisibility Store', () => { expect(replaceStateSpy).toHaveBeenCalled(); }); + it('ignores onboarding=open when manual onboarding open is unavailable', () => { + window.history.replaceState({}, '', '/Dashboard?onboarding=open'); + mockCanDisplayOnboardingModal.value = false; + + store.applyOnboardingUrlAction(); + + expect(store.isForceOpened).toBe(false); + expect(store.isHidden).toBe(null); + expect(window.location.search).not.toContain('onboarding='); + }); + it('opens when onboarding force-open event is dispatched', () => { window.dispatchEvent(new Event('unraid:onboarding:open')); @@ -339,6 +381,15 @@ describe('OnboardingModalVisibility Store', () => { expect(store.isHidden).toBe(false); }); + it('ignores onboarding force-open events when manual onboarding open is unavailable', () => { + mockCanDisplayOnboardingModal.value = false; + + window.dispatchEvent(new Event('unraid:onboarding:open')); + + expect(store.isForceOpened).toBe(false); + expect(store.isHidden).toBe(null); + }); + it('applies onboarding=resume automatically on mount', () => { if (app) { app.unmount(); diff --git a/web/__test__/store/onboardingStatus.test.ts b/web/__test__/store/onboardingStatus.test.ts new file mode 100644 index 0000000000..879287548b --- /dev/null +++ b/web/__test__/store/onboardingStatus.test.ts @@ -0,0 +1,123 @@ +import { ref } from 'vue'; +import { createPinia, setActivePinia } from 'pinia'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Ref } from 'vue'; + +import { useOnboardingContextDataStore } from '~/components/Onboarding/store/onboardingContextData'; +import { useOnboardingStore } from '~/components/Onboarding/store/onboardingStatus'; +import { useServerStore } from '~/store/server'; + +type OnboardingData = { + status?: 'INCOMPLETE' | 'UPGRADE' | 'DOWNGRADE' | 'COMPLETED'; + isPartnerBuild?: boolean; + completed?: boolean; + completedAtVersion?: string | null; +} | null; + +const { state, refetchMock } = vi.hoisted(() => ({ + state: { + onboardingData: null as unknown as Ref, + onboardingLoading: null as unknown as Ref, + onboardingError: null as unknown as Ref, + osVersionRef: null as unknown as Ref, + }, + refetchMock: vi.fn(), +})); + +const createOnboardingData = (): NonNullable => ({ + status: 'INCOMPLETE', + isPartnerBuild: false, + completed: false, + completedAtVersion: null, +}); + +describe('onboardingStatus store', () => { + beforeEach(() => { + vi.clearAllMocks(); + setActivePinia(createPinia()); + + state.onboardingData = ref(createOnboardingData()); + state.onboardingLoading = ref(false); + state.onboardingError = ref(null); + state.osVersionRef = ref('7.3.0'); + refetchMock.mockResolvedValue(undefined); + + vi.mocked(useServerStore).mockReturnValue({ + osVersion: state.osVersionRef, + } as unknown as ReturnType); + + vi.mocked(useOnboardingContextDataStore).mockReturnValue({ + onboarding: state.onboardingData, + loading: state.onboardingLoading, + error: state.onboardingError, + refetchOnboardingContext: refetchMock, + } as unknown as ReturnType); + }); + + it('blocks auto-show while the onboarding query is still loading', () => { + state.onboardingData.value = null; + state.onboardingLoading.value = true; + + const store = useOnboardingStore(); + + expect(store.canDisplayOnboardingModal).toBe(true); + expect(store.shouldShowOnboarding).toBe(false); + }); + + it('blocks onboarding modal when the onboarding query errors', () => { + state.onboardingData.value = null; + state.onboardingError.value = new Error('Network error'); + + const store = useOnboardingStore(); + + expect(store.hasOnboardingError).toBe(true); + expect(store.canDisplayOnboardingModal).toBe(false); + expect(store.shouldShowOnboarding).toBe(false); + }); + + it('allows onboarding modal when onboarding state is absent but there is no query error', () => { + state.onboardingData.value = null; + + const store = useOnboardingStore(); + + expect(store.canDisplayOnboardingModal).toBe(true); + expect(store.shouldShowOnboarding).toBe(false); + }); + + it('allows onboarding modal when the onboarding query succeeds', () => { + const store = useOnboardingStore(); + + expect(store.hasOnboardingError).toBe(false); + expect(store.canDisplayOnboardingModal).toBe(true); + expect(store.shouldShowOnboarding).toBe(true); + }); + + it('blocks onboarding modal when onboarding data exists alongside an Apollo error', () => { + state.onboardingError.value = new Error('Partial data error'); + + const store = useOnboardingStore(); + + expect(store.hasOnboardingError).toBe(true); + expect(store.canDisplayOnboardingModal).toBe(false); + expect(store.shouldShowOnboarding).toBe(false); + }); + + it('keeps onboarding modal display enabled during refetch when onboarding data already exists', () => { + state.onboardingLoading.value = true; + + const store = useOnboardingStore(); + + expect(store.canDisplayOnboardingModal).toBe(true); + expect(store.shouldShowOnboarding).toBe(true); + }); +}); + +vi.mock('~/components/Onboarding/store/onboardingContextData', () => ({ + useOnboardingContextDataStore: vi.fn(), +})); + +vi.mock('~/store/server', () => ({ + useServerStore: vi.fn(), +})); diff --git a/web/src/components/Onboarding/OnboardingModal.vue b/web/src/components/Onboarding/OnboardingModal.vue index 839a15d449..9bfd6bda41 100644 --- a/web/src/components/Onboarding/OnboardingModal.vue +++ b/web/src/components/Onboarding/OnboardingModal.vue @@ -1,26 +1,26 @@