Skip to content

Commit 18ab333

Browse files
committed
CCP-4997: Add/fix unit tests
1 parent 97f0134 commit 18ab333

9 files changed

Lines changed: 319 additions & 7 deletions

File tree

app/frontend/src/components/designer/FormViewer.vue

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
watch,
1212
} from 'vue';
1313
import { useI18n } from 'vue-i18n';
14-
import { useRoute, useRouter } from 'vue-router';
14+
import { useRouter } from 'vue-router';
1515
1616
import BaseDialog from '~/components/base/BaseDialog.vue';
1717
import FormViewerActions from '~/components/designer/FormViewerActions.vue';
@@ -35,7 +35,6 @@ import { FormPermissions, NotificationTypes } from '~/utils/constants';
3535
const { t, locale } = useI18n({ useScope: 'global' });
3636
3737
const router = useRouter();
38-
const route = useRoute();
3938
4039
const emit = defineEmits(['submission-updated', 'access-denied']);
4140

app/frontend/tests/unit/components/designer/FormViewer.spec.js

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ vi.mock('vue-router', () => ({
2222
push: () => {},
2323
replace: () => {},
2424
})),
25+
useRoute: vi.fn(() => ({ query: {} })),
2526
}));
2627

2728
const STUBS = {
@@ -333,7 +334,10 @@ describe('FormViewer.vue', () => {
333334
expect(readPublishedSpy).toBeCalledTimes(1);
334335
expect(getSubmissionSpy).toBeCalledTimes(0);
335336
expect(wrapper.vm.showModal).toBeTruthy();
336-
expect(addEventListenerSpy).toBeCalledTimes(1);
337+
expect(addEventListenerSpy).toHaveBeenCalledWith(
338+
'beforeunload',
339+
expect.any(Function)
340+
);
337341
expect(addNotificationSpy).toBeCalledTimes(0);
338342
});
339343
it('if submission id and it is not a duplicate, it should call getFormData', async () => {
@@ -357,7 +361,10 @@ describe('FormViewer.vue', () => {
357361
expect(readPublishedSpy).toBeCalledTimes(0);
358362
expect(getSubmissionSpy).toBeCalledTimes(1);
359363
expect(wrapper.vm.showModal).toBeFalsy();
360-
expect(addEventListenerSpy).toBeCalledTimes(1);
364+
expect(addEventListenerSpy).toHaveBeenCalledWith(
365+
'beforeunload',
366+
expect.any(Function)
367+
);
361368
expect(addNotificationSpy).toBeCalledTimes(0);
362369
});
363370
});
@@ -385,7 +392,10 @@ describe('FormViewer.vue', () => {
385392
expect(readPublishedSpy).toBeCalledTimes(1);
386393
expect(getSubmissionSpy).toBeCalledTimes(1);
387394
expect(wrapper.vm.showModal).toBeFalsy();
388-
expect(addEventListenerSpy).toBeCalledTimes(1);
395+
expect(addEventListenerSpy).toHaveBeenCalledWith(
396+
'beforeunload',
397+
expect.any(Function)
398+
);
389399
expect(addNotificationSpy).toBeCalledTimes(0);
390400
});
391401

@@ -409,7 +419,10 @@ describe('FormViewer.vue', () => {
409419
wrapper.unmount();
410420

411421
await flushPromises();
412-
expect(removeEventListenerSpy).toBeCalledTimes(1);
422+
expect(removeEventListenerSpy).toHaveBeenCalledWith(
423+
'beforeunload',
424+
expect.any(Function)
425+
);
413426
expect(addNotificationSpy).toBeCalledTimes(0);
414427
});
415428

app/frontend/tests/unit/components/forms/FormSubmission.spec.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ vi.mock('vue-router', () => ({
1212
useRouter: vi.fn(() => ({
1313
push: () => {},
1414
})),
15+
useRoute: vi.fn(() => ({ query: {} })),
1516
}));
1617

1718
const STUBS = {

app/frontend/tests/unit/components/forms/submission/SubmitterRevision.spec.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ vi.mock('vue-router', () => ({
1414
useRouter: vi.fn(() => ({
1515
push: vi.fn(),
1616
})),
17+
useRoute: vi.fn(() => ({ query: {} })),
1718
}));
1819

1920
// Mock formService

app/frontend/tests/unit/components/forms/submission/UserSubmission.spec.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ vi.mock('vue-router', () => ({
1313
useRouter: vi.fn(() => ({
1414
push: () => {},
1515
})),
16+
useRoute: vi.fn(() => ({ query: {} })),
1617
}));
1718

1819
const STUBS = {
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { reactive } from 'vue';
3+
4+
const store = new Map();
5+
6+
vi.mock('idb-keyval', () => ({
7+
get: vi.fn(async (key) => store.get(key)),
8+
set: vi.fn(async (key, value) => {
9+
store.set(key, value);
10+
}),
11+
}));
12+
13+
async function freshQueue() {
14+
vi.resetModules();
15+
store.clear();
16+
return await import('~/offline/queue');
17+
}
18+
19+
beforeEach(() => {
20+
// Web Locks isn't in jsdom; flush() falls back to running drain inline.
21+
delete globalThis.navigator.locks;
22+
});
23+
24+
describe('offline/queue', () => {
25+
describe('persist', () => {
26+
it('JSON-clones Vue reactive Proxies before writing to IDB', async () => {
27+
const { offlineQueue } = await freshQueue();
28+
const body = reactive({ data: { foo: 'bar' } });
29+
await expect(
30+
offlineQueue.enqueue({ formId: 'f1', versionId: 'v1', userId: 'u1', body })
31+
).resolves.toBeDefined();
32+
const stored = store.get('chefs_offline_queue');
33+
expect(Array.isArray(stored)).toBe(true);
34+
expect(stored[0].body).toEqual({ data: { foo: 'bar' } });
35+
});
36+
});
37+
38+
describe('enqueue', () => {
39+
it('assigns a unique dedupKey to each entry', async () => {
40+
const { offlineQueue } = await freshQueue();
41+
const a = await offlineQueue.enqueue({ formId: 'f1', versionId: 'v1', userId: 'u1', body: {} });
42+
const b = await offlineQueue.enqueue({ formId: 'f1', versionId: 'v1', userId: 'u1', body: {} });
43+
expect(a.dedupKey).toBeTruthy();
44+
expect(b.dedupKey).toBeTruthy();
45+
expect(a.dedupKey).not.toBe(b.dedupKey);
46+
});
47+
48+
it('throws QUEUE_CAP when the per-form soft cap is reached', async () => {
49+
const { offlineQueue, QUEUE_SOFT_CAP } = await freshQueue();
50+
for (let i = 0; i < QUEUE_SOFT_CAP; i++) {
51+
await offlineQueue.enqueue({ formId: 'f1', versionId: 'v1', userId: 'u1', body: {} });
52+
}
53+
await expect(
54+
offlineQueue.enqueue({ formId: 'f1', versionId: 'v1', userId: 'u1', body: {} })
55+
).rejects.toMatchObject({ code: 'QUEUE_CAP' });
56+
});
57+
58+
it('counts cap per-form (a different formId is unaffected)', async () => {
59+
const { offlineQueue, QUEUE_SOFT_CAP } = await freshQueue();
60+
for (let i = 0; i < QUEUE_SOFT_CAP; i++) {
61+
await offlineQueue.enqueue({ formId: 'f1', versionId: 'v1', userId: 'u1', body: {} });
62+
}
63+
await expect(
64+
offlineQueue.enqueue({ formId: 'f2', versionId: 'v1', userId: 'u1', body: {} })
65+
).resolves.toBeDefined();
66+
});
67+
});
68+
69+
describe('flush', () => {
70+
it('removes entries on 2xx and calls onProgress with sent count', async () => {
71+
const { offlineQueue } = await freshQueue();
72+
await offlineQueue.enqueue({ formId: 'f1', versionId: 'v1', userId: 'u1', body: {} });
73+
await offlineQueue.enqueue({ formId: 'f1', versionId: 'v1', userId: 'u1', body: {} });
74+
const post = vi.fn().mockResolvedValue({ data: { id: 'x' } });
75+
const onProgress = vi.fn();
76+
const result = await offlineQueue.flush(post, onProgress);
77+
expect(result).toEqual({ total: 2, sent: 2, failed: 0 });
78+
expect(post).toHaveBeenCalledTimes(2);
79+
expect(offlineQueue.entries.value).toHaveLength(0);
80+
});
81+
82+
it('pauses on 401 without firing onEntryFailed (drain stops, entry kept)', async () => {
83+
const { offlineQueue, QueueStatus } = await freshQueue();
84+
await offlineQueue.enqueue({ formId: 'f1', versionId: 'v1', userId: 'u1', body: {} });
85+
await offlineQueue.enqueue({ formId: 'f1', versionId: 'v1', userId: 'u1', body: {} });
86+
const err = Object.assign(new Error('unauth'), { response: { status: 401 } });
87+
const post = vi.fn().mockRejectedValue(err);
88+
const onEntryFailed = vi.fn();
89+
const result = await offlineQueue.flush(post, undefined, onEntryFailed);
90+
expect(result.paused).toBe(true);
91+
expect(post).toHaveBeenCalledTimes(1);
92+
expect(onEntryFailed).not.toHaveBeenCalled();
93+
expect(offlineQueue.entries.value[0].status).toBe(QueueStatus.FAILED_AUTH);
94+
});
95+
96+
it('fires onEntryFailed on permanent 4xx and continues to the next entry', async () => {
97+
const { offlineQueue, QueueStatus } = await freshQueue();
98+
await offlineQueue.enqueue({ formId: 'f1', versionId: 'v1', userId: 'u1', body: {} });
99+
await offlineQueue.enqueue({ formId: 'f1', versionId: 'v1', userId: 'u1', body: {} });
100+
const err409 = Object.assign(new Error('conflict'), { response: { status: 409 } });
101+
const post = vi.fn().mockRejectedValueOnce(err409).mockResolvedValueOnce({ data: { id: 'x' } });
102+
const onEntryFailed = vi.fn();
103+
const result = await offlineQueue.flush(post, undefined, onEntryFailed);
104+
expect(result).toEqual({ total: 2, sent: 1, failed: 1 });
105+
expect(onEntryFailed).toHaveBeenCalledTimes(1);
106+
expect(onEntryFailed.mock.calls[0][0].status).toBe(QueueStatus.FAILED_IDENTITY_MISMATCH);
107+
});
108+
109+
it('pauses without marking failed on a transient (network) error', async () => {
110+
const { offlineQueue, QueueStatus } = await freshQueue();
111+
await offlineQueue.enqueue({ formId: 'f1', versionId: 'v1', userId: 'u1', body: {} });
112+
const post = vi.fn().mockRejectedValue(new Error('network down'));
113+
const onEntryFailed = vi.fn();
114+
const result = await offlineQueue.flush(post, undefined, onEntryFailed);
115+
expect(result.paused).toBe(true);
116+
expect(onEntryFailed).not.toHaveBeenCalled();
117+
expect(offlineQueue.entries.value[0].status).toBe(QueueStatus.PENDING);
118+
});
119+
});
120+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { nextTick, ref } from 'vue';
3+
4+
const networkOnlineRef = ref(true);
5+
const routeQuery = { value: {} };
6+
7+
vi.mock('@vueuse/core', () => ({
8+
useOnline: () => networkOnlineRef,
9+
}));
10+
11+
vi.mock('vue-router', () => ({
12+
useRoute: () => ({ get query() { return routeQuery.value; } }),
13+
}));
14+
15+
import { useSimulationToggle } from '~/offline/useSimulationToggle';
16+
import { SIMULATE_OFFLINE_SS_KEY } from '~/offline/offlineQueueManager';
17+
18+
beforeEach(() => {
19+
sessionStorage.clear();
20+
networkOnlineRef.value = true;
21+
routeQuery.value = {};
22+
});
23+
24+
describe('useSimulationToggle', () => {
25+
it('canSimulateOffline tracks the ?simulateOffline=1 query gate', () => {
26+
routeQuery.value = {};
27+
const a = useSimulationToggle();
28+
expect(a.canSimulateOffline.value).toBe(false);
29+
routeQuery.value = { simulateOffline: '1' };
30+
const b = useSimulationToggle();
31+
expect(b.canSimulateOffline.value).toBe(true);
32+
});
33+
34+
it('rehydrates simulatingOffline from sessionStorage on mount', () => {
35+
sessionStorage.setItem(SIMULATE_OFFLINE_SS_KEY, '1');
36+
const { simulatingOffline } = useSimulationToggle();
37+
expect(simulatingOffline.value).toBe(true);
38+
});
39+
40+
it('mirrors simulatingOffline writes back to sessionStorage', async () => {
41+
const { simulatingOffline } = useSimulationToggle();
42+
simulatingOffline.value = true;
43+
await nextTick();
44+
expect(sessionStorage.getItem(SIMULATE_OFFLINE_SS_KEY)).toBe('1');
45+
simulatingOffline.value = false;
46+
await nextTick();
47+
expect(sessionStorage.getItem(SIMULATE_OFFLINE_SS_KEY)).toBeNull();
48+
});
49+
50+
it('online = networkOnline && !simulatingOffline', async () => {
51+
const { simulatingOffline, online } = useSimulationToggle();
52+
expect(online.value).toBe(true);
53+
simulatingOffline.value = true;
54+
await nextTick();
55+
expect(online.value).toBe(false);
56+
simulatingOffline.value = false;
57+
networkOnlineRef.value = false;
58+
await nextTick();
59+
expect(online.value).toBe(false);
60+
});
61+
});

app/tests/unit/forms/auth/authService.spec.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,16 @@ describe('formAccessToForm', () => {
4141
permissions: 12,
4242
};
4343
const result = service.formAccessToForm(form);
44-
expect(result).toEqual(form);
44+
expect(result).toMatchObject(form);
45+
});
46+
47+
// Regression: whitelist mapper silently drops new fields if not added here.
48+
it('projects enableOfflineSubmission', () => {
49+
const out = service.formAccessToForm({ enableOfflineSubmission: true });
50+
expect(out.enableOfflineSubmission).toBe(true);
51+
52+
const off = service.formAccessToForm({ enableOfflineSubmission: false });
53+
expect(off.enableOfflineSubmission).toBe(false);
4554
});
4655
});
4756

0 commit comments

Comments
 (0)