Skip to content
Open
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
41 changes: 41 additions & 0 deletions api/src/unraid-api/config/onboarding-tracker.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,44 @@ describe('OnboardingTrackerService write retries', () => {
expect(mockAtomicWriteFile).toHaveBeenCalledTimes(3);
});
});

describe('OnboardingTrackerService tracker state availability', () => {
beforeEach(() => {
mockReadFile.mockReset();
mockAtomicWriteFile.mockReset();
});

it('keeps tracker state available when the tracker file does not exist yet', 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();

expect(tracker.didTrackerStateReadFail()).toBe(false);
});

it('marks tracker state unavailable when reading the tracker file fails', 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();

expect(tracker.didTrackerStateReadFail()).toBe(true);
});
});
19 changes: 19 additions & 0 deletions api/src/unraid-api/config/onboarding-tracker.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
private state: TrackerState = {};
private currentVersion?: string;
private readonly versionFilePath: string;
private trackerStateReadFailed = false;

constructor(
private readonly configService: ConfigService,
Expand Down Expand Up @@ -84,6 +85,10 @@
return this.currentVersion;
}

didTrackerStateReadFail(): boolean {
return this.trackerStateReadFailed;
}

/**
* Mark onboarding as completed for the current OS version.
*/
Expand Down Expand Up @@ -167,8 +172,22 @@
private async readTrackerState(): Promise<TrackerState | undefined> {
try {
const content = await readFile(this.trackerPath, 'utf8');
this.trackerStateReadFailed = false;
return JSON.parse(content) as TrackerState;
} catch (error) {
if (

Check failure on line 178 in api/src/unraid-api/config/onboarding-tracker.service.ts

View workflow job for this annotation

GitHub Actions / Test API

Replace `⏎················error·instanceof·Error·&&⏎················'code'·in·error·&&⏎················error.code·===·'ENOENT'⏎············` with `error·instanceof·Error·&&·'code'·in·error·&&·error.code·===·'ENOENT'`
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
this.trackerStateReadFailed = false;
this.logger.debug(

Check failure on line 184 in api/src/unraid-api/config/onboarding-tracker.service.ts

View workflow job for this annotation

GitHub Actions / Test API

Replace `⏎····················`Onboarding·tracker·state·does·not·exist·yet·at·${this.trackerPath}.`⏎················` with ``Onboarding·tracker·state·does·not·exist·yet·at·${this.trackerPath}.``
`Onboarding tracker state does not exist yet at ${this.trackerPath}.`
);
return undefined;
}
Comment on lines +178 to +188
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix Prettier formatting violations (CI failure).

The CI pipeline reports Prettier formatting issues. The multiline condition and string should be formatted on single lines.

🔧 Proposed fix for formatting
         } catch (error) {
-            if (
-                error instanceof Error &&
-                'code' in error &&
-                error.code === 'ENOENT'
-            ) {
+            if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
                 this.trackerStateReadFailed = false;
-                this.logger.debug(
-                    `Onboarding tracker state does not exist yet at ${this.trackerPath}.`
-                );
+                this.logger.debug(`Onboarding tracker state does not exist yet at ${this.trackerPath}.`);
                 return undefined;
             }
🧰 Tools
🪛 GitHub Actions: CI - Main (API)

[error] 178-178: Prettier formatting issue detected by prettier/prettier. Replace multiline condition with single-line: error instanceof Error && 'code' in error && error.code === 'ENOENT'.

🪛 GitHub Check: Test API

[failure] 184-184:
Replace ⏎····················Onboarding·tracker·state·does·not·exist·yet·at·${this.trackerPath}.⏎················ with Onboarding·tracker·state·does·not·exist·yet·at·${this.trackerPath}.


[failure] 178-178:
Replace ⏎················error·instanceof·Error·&&⏎················'code'·in·error·&&⏎················error.code·===·'ENOENT'⏎············ with error·instanceof·Error·&&·'code'·in·error·&&·error.code·===·'ENOENT'

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/src/unraid-api/config/onboarding-tracker.service.ts` around lines 178 -
188, The multi-line conditional and template string cause Prettier failures;
collapse the condition and the log call into single lines. Specifically, in the
method containing trackerStateReadFailed, change the if check to a single-line
form like: if (error instanceof Error && 'code' in error && error.code ===
'ENOENT') { ... } and make the logger.debug call a single-line statement that
references this.trackerPath. Keep the assignment this.trackerStateReadFailed =
false; and return undefined; inside the same block.


this.trackerStateReadFailed = true;
this.logger.debug(
`Unable to read onboarding tracker state at ${this.trackerPath}: ${error}`
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe('CustomizationResolver', () => {
getOnboardingState: vi.fn(),
} as unknown as OnboardingService;
const onboardingTracker = {
didTrackerStateReadFail: vi.fn(),
getState: vi.fn(),
getCurrentVersion: vi.fn(),
} as unknown as OnboardingTrackerService;
Expand All @@ -27,6 +28,7 @@ describe('CustomizationResolver', () => {

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(onboardingTracker.didTrackerStateReadFail).mockReturnValue(false);
vi.mocked(onboardingTracker.getCurrentVersion).mockReturnValue('7.2.0');
vi.mocked(onboardingService.getPublicPartnerInfo).mockResolvedValue(null);
vi.mocked(onboardingService.getActivationDataForPublic).mockResolvedValue(null);
Expand Down Expand Up @@ -62,6 +64,15 @@ describe('CustomizationResolver', () => {
});
});

it('throws when tracker state could not be read', async () => {
vi.mocked(onboardingTracker.didTrackerStateReadFail).mockReturnValue(true);

await expect(resolver.resolveOnboarding()).rejects.toThrow(
'Onboarding tracker state is unavailable.'
);
expect(onboardingTracker.getState).not.toHaveBeenCalled();
});

it('returns COMPLETED status when completed on current version', async () => {
vi.mocked(onboardingTracker.getState).mockReturnValue({
completed: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Query, ResolveField, Resolver } from '@nestjs/graphql';
import { GraphQLError } from 'graphql';

Check failure on line 2 in api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts

View workflow job for this annotation

GitHub Actions / Test API

Delete `import·{·GraphQLError·}·from·'graphql';⏎`

import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';

Check failure on line 5 in api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts

View workflow job for this annotation

GitHub Actions / Test API

Insert `';⏎import·{·GraphQLError·}·from·'graphql`

import { Public } from '@app/unraid-api/auth/public.decorator.js';
import { OnboardingTrackerService } from '@app/unraid-api/config/onboarding-tracker.module.js';
Expand Down Expand Up @@ -59,6 +60,10 @@
resource: Resource.CUSTOMIZATIONS,
})
async resolveOnboarding(): Promise<Onboarding> {
if (this.onboardingTracker.didTrackerStateReadFail()) {
throw new GraphQLError('Onboarding tracker state is unavailable.');
}

const state = this.onboardingTracker.getState();
const currentVersion = this.onboardingTracker.getCurrentVersion() ?? 'unknown';
const partnerInfo = await this.onboardingService.getPublicPartnerInfo();
Expand Down
99 changes: 99 additions & 0 deletions web/__test__/store/onboardingStatus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { ref } from 'vue';
import { createPinia, setActivePinia } from 'pinia';

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

import { useOnboardingStore } from '~/components/Onboarding/store/onboardingStatus';
import { useServerStore } from '~/store/server';

type OnboardingQueryResult = {
customization?: {
onboarding?: {
status?: 'INCOMPLETE' | 'UPGRADE' | 'DOWNGRADE' | 'COMPLETED';
isPartnerBuild?: boolean;
completed?: boolean;
completedAtVersion?: string | null;
};
};
};

const { state, refetchMock, useQueryMock } = vi.hoisted(() => ({
state: {
onboardingResult: null as unknown as ReturnType<typeof ref<OnboardingQueryResult | null>>,
onboardingLoading: null as unknown as ReturnType<typeof ref<boolean>>,
onboardingError: null as unknown as ReturnType<typeof ref<unknown>>,
osVersionRef: null as unknown as ReturnType<typeof ref<string>>,
},
refetchMock: vi.fn(),
useQueryMock: vi.fn(),
}));

const createOnboardingResult = (): OnboardingQueryResult => ({
customization: {
onboarding: {
status: 'INCOMPLETE',
isPartnerBuild: false,
completed: false,
completedAtVersion: null,
},
},
});

describe('onboardingStatus store', () => {
beforeEach(() => {
vi.clearAllMocks();
setActivePinia(createPinia());

state.onboardingResult = ref<OnboardingQueryResult | null>(createOnboardingResult());
state.onboardingLoading = ref(false);
state.onboardingError = ref(null);
state.osVersionRef = ref('7.3.0');
refetchMock.mockResolvedValue(undefined);

useQueryMock.mockReturnValue({
result: state.onboardingResult,
loading: state.onboardingLoading,
error: state.onboardingError,
refetch: refetchMock,
});

vi.mocked(useServerStore).mockReturnValue({
osVersion: state.osVersionRef,
} as unknown as ReturnType<typeof useServerStore>);
});

it('blocks onboarding modal while the onboarding query is still loading', () => {
state.onboardingLoading.value = true;

const store = useOnboardingStore();

expect(store.canDisplayOnboardingModal).toBe(false);
expect(store.shouldShowOnboarding).toBe(false);
});

it('blocks onboarding modal when the onboarding query errors', () => {
state.onboardingError.value = new Error('Network error');

const store = useOnboardingStore();

expect(store.hasOnboardingQueryError).toBe(true);
expect(store.canDisplayOnboardingModal).toBe(false);
expect(store.shouldShowOnboarding).toBe(false);
});

it('allows onboarding modal when the onboarding query succeeds', () => {
const store = useOnboardingStore();

expect(store.hasOnboardingQueryError).toBe(false);
expect(store.canDisplayOnboardingModal).toBe(true);
expect(store.shouldShowOnboarding).toBe(true);
});
});

vi.mock('@vue/apollo-composable', () => ({
useQuery: () => useQueryMock(),
}));

vi.mock('~/store/server', () => ({
useServerStore: vi.fn(),
}));
10 changes: 9 additions & 1 deletion web/src/components/Onboarding/store/onboardingStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,14 @@ export const useOnboardingStore = defineStore('onboarding', () => {
const isUnauthenticated = computed(
() => mockUnauthenticated.value || isUnauthenticatedApolloError(onboardingError.value)
);
const canDisplayOnboardingModal = computed(() => isVersionSupported.value && !isUnauthenticated.value);
const hasOnboardingQueryError = computed(() => Boolean(onboardingError.value));
const canDisplayOnboardingModal = computed(
() =>
isVersionSupported.value &&
!onboardingLoading.value &&
!hasOnboardingQueryError.value &&
!isUnauthenticated.value
);

// Automatic onboarding should only run for initial setup.
const shouldShowOnboarding = computed(() => {
Expand Down Expand Up @@ -189,6 +196,7 @@ export const useOnboardingStore = defineStore('onboarding', () => {
mockUnauthenticated,
mockOsVersion,
isUnauthenticated,
hasOnboardingQueryError,
canDisplayOnboardingModal,
shouldShowOnboarding,
// Actions
Expand Down
Loading