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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions src/composables/__tests__/useDashboardSuggestions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, ref } from 'vue';

const mocks = vi.hoisted(() => ({
execute: vi.fn(),
toastAdd: vi.fn(),
}));

vi.mock('@/composables/axios', () => ({
useDataApi: () => ({
execute: mocks.execute,
isLoading: { value: false },
error: { value: null },
}),
decamelizeKeys: vi.fn((data: unknown) => JSON.stringify(data)),
}));

vi.mock('primevue/usetoast', () => ({
useToast: () => ({ add: mocks.toastAdd }),
}));

import { useSuggestionRunPoller } from '../useDashboardSuggestions';

function response(status: string, completedCells: number, id = status) {
return {
data: {
value: {
data: {
id,
status,
plannedCalls: 2,
completedCells,
failedCells: 0,
},
},
},
};
}

describe('useSuggestionRunPoller', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});

afterEach(() => {
vi.useRealTimers();
});

it('starts polling active runs, stops on terminal status, and fires toast', async () => {
const onPoll = vi.fn();
mocks.execute
.mockResolvedValueOnce(response('running', 1, 'run-1'))
.mockResolvedValueOnce(response('completed', 2, 'run-1'));

const scope = effectScope();
const poller = scope.run(() =>
useSuggestionRunPoller(ref('ssp-1'), onPoll),
);

await poller?.pollLatest();
expect(poller?.isPolling.value).toBe(true);

await vi.advanceTimersByTimeAsync(3000);

expect(mocks.execute).toHaveBeenCalledTimes(2);
expect(onPoll).toHaveBeenCalledTimes(2);
expect(poller?.isPolling.value).toBe(false);
expect(mocks.toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'success',
summary: 'Suggestions ready',
}),
);

scope.stop();
});

it('keeps polling after a transient poll error', async () => {
mocks.execute
.mockRejectedValueOnce(new Error('temporary outage'))
.mockResolvedValueOnce(response('completed', 2, 'run-1'));

const scope = effectScope();
const poller = scope.run(() => useSuggestionRunPoller(ref('ssp-1')));

poller?.start();
await vi.advanceTimersByTimeAsync(3000);
await vi.advanceTimersByTimeAsync(3000);

expect(mocks.execute).toHaveBeenCalledTimes(2);

scope.stop();
});
});
15 changes: 14 additions & 1 deletion src/composables/axios/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ import { useAxios } from '@vueuse/integrations/useAxios';
import camelcaseKeys from 'camelcase-keys';
import { default as _decamelizeKeys } from 'decamelize-keys';

declare module 'axios' {
interface AxiosRequestConfig {
// Object paths whose child keys should be left untouched by the response
// camelcase conversion (e.g. arbitrary label maps that may use snake_case
// or `_`-prefixed keys we need to preserve verbatim).
camelcaseStopPaths?: readonly string[];
}
}

const useAuthenticatedInstance = () => {
const userStore = useUserStore();
const configStore = useConfigStore();
Expand Down Expand Up @@ -62,7 +71,11 @@ const useAuthenticatedInstance = () => {
// Brute force camelcase conversion. OSCAL apis are all kebab-case so should be converted to
// camel case, but any manually written APIs will be camel case and therefore won't change
if (response.data) {
response.data = camelcaseKeys(response.data, { deep: true });
const stopPaths = response.config?.camelcaseStopPaths;
response.data = camelcaseKeys(response.data, {
deep: true,
...(stopPaths ? { stopPaths: [...stopPaths] } : {}),
});
}
return response;
},
Expand Down
Loading
Loading