Skip to content

Commit fd681f7

Browse files
committed
test: add tests for general project components
1 parent 47e053a commit fd681f7

6 files changed

Lines changed: 718 additions & 1 deletion

File tree

app/src/db/prisma/schema.prisma

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ model permit {
205205
targetDate DateTime? @map("target_date") @db.Date
206206
targetDateDescription String? @map("target_date_description")
207207
onHoldCode String? @map("on_hold_code")
208-
technical_reviewer String?
208+
technical_reviewer String? @map("technical_reviewer")
209209
activity activity @relation(fields: [activityId], references: [activityId], onDelete: Cascade, map: "permit_activity_id_foreign")
210210
piesOnHoldCode pies_on_hold_code? @relation(fields: [onHoldCode], references: [code], map: "permit_on_hold_code_foreign")
211211
permitType permit_type @relation(fields: [permitTypeId], references: [permitTypeId], map: "permit_permit_type_id_foreign")

frontend/src/components/authorization/AuthorizationListNavigator.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ onBeforeMount(async () => {
189189
>
190190
<InputIcon class="pi pi-search" />
191191
<InputText
192+
id="searchTag"
192193
v-model="searchTag"
193194
class="h-full"
194195
:placeholder="t('authorization.common.search')"
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { ref } from 'vue';
2+
3+
import AuthorizationListNavigator from '@/components/authorization/AuthorizationListNavigator.vue';
4+
import { Column, DataTable } from '@/lib/primevue';
5+
import { permitService, sourceSystemKindService } from '@/services';
6+
import { Initiative, RouteName } from '@/utils/enums/application';
7+
import { projectAuthorizationRouteNameKey } from '@/utils/keys';
8+
9+
import { mountComponent } from '../../../mountComponent';
10+
11+
import type { SearchPermitsResponse, SourceSystemKind } from '@/types';
12+
13+
// Fixtures
14+
15+
const testPermit: SearchPermitsResponse['permits'][number] = {
16+
permitId: 'permit-1',
17+
activityId: 'activity-1',
18+
permitTypeId: 26,
19+
decisionDate: '2024-01-15',
20+
stage: 'SUBMISSION',
21+
state: 'IN_PROGRESS',
22+
statusLastChanged: '2024-01-10',
23+
submittedDate: '2024-01-01',
24+
permitType: { businessDomain: 'Water', name: 'Water Licence' },
25+
project: {
26+
projectId: 'project-1',
27+
projectName: 'Test Project',
28+
companyNameRegistered: 'Test Co',
29+
streetAddress: '123 Main St',
30+
locality: 'Victoria',
31+
province: 'BC'
32+
}
33+
};
34+
35+
const testSearchResponse: SearchPermitsResponse = { permits: [testPermit], totalRecords: 1 };
36+
37+
const sampleSourceSystemKind: SourceSystemKind = {
38+
description: 'ATS Project Number',
39+
kind: undefined,
40+
sourceSystem: 'ITSM-5314',
41+
sourceSystemKindId: 2,
42+
integrated: false,
43+
createdAt: '2025-06-18T15:56:00.515Z',
44+
createdBy: 'test',
45+
permitTypeIds: [26]
46+
};
47+
48+
// Mount
49+
50+
const searchPermitsSpy = vi.spyOn(permitService, 'searchPermits');
51+
const listSourceSystemKindsSpy = vi.spyOn(sourceSystemKindService, 'listSourceSystemKinds');
52+
53+
function mountAuthorizationListNavigator(options: { initiative?: Initiative } = {}) {
54+
const { initiative } = options;
55+
56+
const { wrapper } = mountComponent(AuthorizationListNavigator, {
57+
piniaState: {
58+
...(initiative === undefined ? {} : { app: { initiative } }),
59+
code: {
60+
codes: {
61+
PermitState: [{ code: 'IN_PROGRESS', display: 'In Progress', active: true }],
62+
PermitStage: [{ code: 'SUBMISSION', display: 'Submission', active: true }],
63+
SourceSystem: [{ code: 'ITSM-5314', display: 'ATS', active: true }]
64+
}
65+
}
66+
},
67+
provide: {
68+
[projectAuthorizationRouteNameKey as symbol]: ref(RouteName.INT_HOUSING_PROJECT_AUTHORIZATION)
69+
}
70+
});
71+
72+
return { wrapper };
73+
}
74+
75+
beforeEach(() => {
76+
vi.useFakeTimers();
77+
searchPermitsSpy.mockResolvedValue(testSearchResponse);
78+
listSourceSystemKindsSpy.mockResolvedValue([sampleSourceSystemKind]);
79+
});
80+
81+
afterEach(() => {
82+
vi.useRealTimers();
83+
vi.clearAllMocks();
84+
});
85+
86+
// Tests
87+
88+
describe('AuthorizationListNavigator', () => {
89+
describe('rendering', () => {
90+
it('mounts without error', async () => {
91+
const { wrapper } = mountAuthorizationListNavigator();
92+
await vi.advanceTimersByTimeAsync(500);
93+
94+
expect(wrapper.exists()).toBe(true);
95+
});
96+
97+
it('performs an initial search with the default pagination/sort after the debounce elapses', async () => {
98+
mountAuthorizationListNavigator();
99+
100+
expect(searchPermitsSpy).not.toHaveBeenCalled();
101+
102+
await vi.advanceTimersByTimeAsync(500);
103+
104+
expect(searchPermitsSpy).toHaveBeenCalledWith({
105+
dateRange: undefined,
106+
permitTypeId: undefined,
107+
sourceSystemKindId: undefined,
108+
searchTag: undefined,
109+
skip: 0,
110+
take: 10,
111+
sortField: 'submittedDate',
112+
sortOrder: -1
113+
});
114+
});
115+
116+
it('renders a row for each permit returned by the search', async () => {
117+
const { wrapper } = mountAuthorizationListNavigator();
118+
await vi.advanceTimersByTimeAsync(500);
119+
120+
expect(wrapper.text()).toContain(testPermit.project?.projectName);
121+
expect(wrapper.text()).toContain(testPermit.permitType.businessDomain);
122+
});
123+
124+
it('shows the location column when the initiative is not electrification', async () => {
125+
const { wrapper } = mountAuthorizationListNavigator({ initiative: Initiative.HOUSING });
126+
await vi.advanceTimersByTimeAsync(500);
127+
128+
const fields = wrapper.findAllComponents(Column).map((c) => c.props('field'));
129+
130+
expect(fields).toContain('location');
131+
});
132+
133+
it('hides the location column when the initiative is electrification', async () => {
134+
const { wrapper } = mountAuthorizationListNavigator({ initiative: Initiative.ELECTRIFICATION });
135+
await vi.advanceTimersByTimeAsync(500);
136+
137+
const fields = wrapper.findAllComponents(Column).map((c) => c.props('field'));
138+
139+
expect(fields).not.toContain('location');
140+
});
141+
});
142+
143+
describe('search debounce', () => {
144+
it('debounces rapid successive searches into a single call', async () => {
145+
const { wrapper } = mountAuthorizationListNavigator();
146+
await vi.advanceTimersByTimeAsync(500);
147+
searchPermitsSpy.mockClear();
148+
149+
const searchInput = wrapper.find('#searchTag');
150+
await searchInput.setValue('a');
151+
await vi.advanceTimersByTimeAsync(250);
152+
await searchInput.setValue('ab');
153+
await vi.advanceTimersByTimeAsync(250);
154+
await searchInput.setValue('abc');
155+
156+
expect(searchPermitsSpy).not.toHaveBeenCalled();
157+
158+
await vi.advanceTimersByTimeAsync(500);
159+
160+
expect(searchPermitsSpy).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ searchTag: 'abc' }));
161+
});
162+
});
163+
164+
describe('user interaction', () => {
165+
it('resets to the first page and requests the new sort when a column is sorted', async () => {
166+
const { wrapper } = mountAuthorizationListNavigator();
167+
await vi.advanceTimersByTimeAsync(500);
168+
searchPermitsSpy.mockClear();
169+
170+
await wrapper.findComponent(DataTable).vm.$emit('sort', { sortField: 'submittedDate', sortOrder: 1 });
171+
await vi.advanceTimersByTimeAsync(500);
172+
173+
expect(searchPermitsSpy).toHaveBeenCalledWith(
174+
expect.objectContaining({ sortField: 'submittedDate', sortOrder: 1, skip: 0 })
175+
);
176+
});
177+
178+
it('requests the correct skip/take when paginating', async () => {
179+
const { wrapper } = mountAuthorizationListNavigator();
180+
await vi.advanceTimersByTimeAsync(500);
181+
searchPermitsSpy.mockClear();
182+
183+
await wrapper.findComponent(DataTable).vm.$emit('page', { page: 2, rows: 20 });
184+
await vi.advanceTimersByTimeAsync(500);
185+
186+
expect(searchPermitsSpy).toHaveBeenCalledWith(expect.objectContaining({ skip: 40, take: 20 }));
187+
});
188+
});
189+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { DOMWrapper } from '@vue/test-utils';
2+
3+
import ProjectIntakeAssistance from '@/components/general/project/ProjectIntakeAssistance.vue';
4+
import { IntakeFormCategory } from '@/utils/enums/projectCommon';
5+
import { ContactPreference, ProjectRelationship } from '@/utils/enums/projectCommon';
6+
7+
import { mountComponent } from '../../../../mountComponent';
8+
9+
// Mocks
10+
11+
const mockRequire = vi.fn();
12+
13+
vi.mock('@/lib/primevue', async (importOriginal) => {
14+
const actual = await importOriginal();
15+
return {
16+
...(actual as object),
17+
useConfirm: () => ({ require: mockRequire })
18+
};
19+
});
20+
21+
// Fixtures
22+
23+
const validContact = {
24+
contactId: '82fba7a8-9cb6-47c4-95b0-81c165e5a317',
25+
firstName: 'John',
26+
lastName: 'Doe',
27+
email: 'john.doe@example.com',
28+
phoneNumber: '123-456-7890',
29+
contactApplicantRelationship: ProjectRelationship.CONSULTANT,
30+
contactPreference: ContactPreference.EITHER
31+
};
32+
33+
// Mount
34+
35+
function mountProjectIntakeAssistance(options: { formValues?: Record<string, unknown> } = {}) {
36+
const { formValues = {} } = options;
37+
38+
const { wrapper } = mountComponent(ProjectIntakeAssistance, {
39+
props: { formValues }
40+
});
41+
42+
// The button lives inside a `<Teleport to="#app">`, so it's rendered as a
43+
// sibling of `wrapper`'s own root rather than a descendant of it --
44+
// `wrapper.find()` can't see it. Search the teleport target directly.
45+
const getAssistanceButton = () => new DOMWrapper(document.getElementById('app') as HTMLElement).find('button');
46+
47+
return { wrapper, getAssistanceButton };
48+
}
49+
50+
beforeEach(() => {
51+
vi.clearAllMocks();
52+
53+
// `ProjectIntakeAssistance` teleports its content to `#app`, which only
54+
// exists in the real app shell -- provide a matching target so the
55+
// teleported button actually mounts into the test DOM.
56+
const app = document.createElement('div');
57+
app.id = 'app';
58+
document.body.appendChild(app);
59+
});
60+
61+
afterEach(() => {
62+
document.getElementById('app')?.remove();
63+
});
64+
65+
// Tests
66+
67+
describe('ProjectIntakeAssistance', () => {
68+
describe('rendering', () => {
69+
it('mounts without error', () => {
70+
const { wrapper } = mountProjectIntakeAssistance();
71+
72+
expect(wrapper.exists()).toBe(true);
73+
});
74+
75+
it('disables the "Get assistance" button when the contact section is incomplete', () => {
76+
const { getAssistanceButton } = mountProjectIntakeAssistance({
77+
formValues: { [IntakeFormCategory.CONTACTS]: {} }
78+
});
79+
80+
expect(getAssistanceButton().attributes('disabled')).toBeDefined();
81+
});
82+
83+
it('enables the "Get assistance" button once the contact section is valid', () => {
84+
const { getAssistanceButton } = mountProjectIntakeAssistance({
85+
formValues: { [IntakeFormCategory.CONTACTS]: validContact }
86+
});
87+
88+
expect(getAssistanceButton().attributes('disabled')).toBeUndefined();
89+
});
90+
});
91+
92+
describe('user interaction', () => {
93+
it('opens a confirmation dialog when clicked', async () => {
94+
const { getAssistanceButton } = mountProjectIntakeAssistance({
95+
formValues: { [IntakeFormCategory.CONTACTS]: validContact }
96+
});
97+
98+
await getAssistanceButton().trigger('click');
99+
100+
expect(mockRequire).toHaveBeenCalledOnce();
101+
});
102+
103+
it('emits "onSubmitAssistance" once the confirmation dialog is accepted', async () => {
104+
const { wrapper, getAssistanceButton } = mountProjectIntakeAssistance({
105+
formValues: { [IntakeFormCategory.CONTACTS]: validContact }
106+
});
107+
108+
await getAssistanceButton().trigger('click');
109+
110+
const confirmArgs = mockRequire.mock.calls[0]?.[0];
111+
await confirmArgs.accept();
112+
113+
expect(wrapper.emitted('onSubmitAssistance')).toHaveLength(1);
114+
});
115+
});
116+
});

0 commit comments

Comments
 (0)