@stamhoofd/backend-sgv-mock 2.122.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,329 @@
1
+ import type {
2
+ SGVFunctie,
3
+ SGVFunctieCreateRequest,
4
+ SGVGroep,
5
+ SGVLedenLijstFilterRequest,
6
+ SGVLedenLijstLid,
7
+ SGVLidPatch,
8
+ SGVProfielResponse,
9
+ SGVTokenResponse,
10
+ SGVZoekLid,
11
+ } from '@stamhoofd/sgv';
12
+ import {
13
+ createSGVLidFixture,
14
+ defaultSGVFuncties,
15
+ defaultSGVGroepen,
16
+ defaultSGVGroepNumber,
17
+ defaultSGVProfile,
18
+ SGVFunctie as SGVFunctieStruct,
19
+ SGVLedenLijstLid as SGVLedenLijstLidStruct,
20
+ SGVLedenLijstWaarden,
21
+ SGVLidGet,
22
+ SGVZoekLid as SGVZoekLidStruct,
23
+ } from '@stamhoofd/sgv';
24
+
25
+ export type SGVMockCall = {
26
+ sequence: number;
27
+ method: string;
28
+ path: string;
29
+ query: Record<string, string | string[]>;
30
+ body: unknown;
31
+ };
32
+
33
+ export type SGVMockFailure = {
34
+ method: string;
35
+ path: string;
36
+ status: number;
37
+ body?: unknown;
38
+ times?: number;
39
+ };
40
+
41
+ export type SGVMockStatePatch = {
42
+ groups?: SGVGroep[];
43
+ functions?: SGVFunctie[];
44
+ members?: SGVLidGet[];
45
+ profile?: SGVProfielResponse;
46
+ token?: Partial<SGVTokenResponse>;
47
+ nextMemberId?: number;
48
+ nextMemberNumber?: number;
49
+ currentFilter?: SGVLedenLijstFilterRequest | null;
50
+ };
51
+
52
+ type SGVMockAuthorizationCode = {
53
+ clientId: string;
54
+ redirectUri: string;
55
+ };
56
+
57
+ /** Mutable in-memory SGV data store shared by mock endpoints and reset between tests. */
58
+ export class SGVMockState {
59
+ nextMemberId = 2;
60
+ nextMemberNumber = 124;
61
+ groups: SGVGroep[] = defaultSGVGroepen.map(group => group.clone());
62
+ functions: SGVFunctie[] = defaultSGVFuncties.map(functie => functie.clone());
63
+ members: SGVLidGet[] = [createSGVLidFixture()];
64
+ currentFilter: SGVLedenLijstFilterRequest | null = null;
65
+ profileResponse: SGVProfielResponse = defaultSGVProfile.clone();
66
+ tokenResponse: SGVTokenResponse = defaultToken();
67
+ calls: SGVMockCall[] = [];
68
+ failures: SGVMockFailure[] = [];
69
+ authorizationCodes = new Map<string, SGVMockAuthorizationCode>();
70
+ private nextCallSequence = 1;
71
+ private nextAuthorizationCode = 1;
72
+
73
+ token(): SGVTokenResponse {
74
+ return { ...this.tokenResponse };
75
+ }
76
+
77
+ createAuthorizationCode(details: SGVMockAuthorizationCode): string {
78
+ const code = `sgv-mock-${this.nextAuthorizationCode++}`;
79
+ this.authorizationCodes.set(code, details);
80
+ return code;
81
+ }
82
+
83
+ consumeAuthorizationCode(code: string): SGVMockAuthorizationCode | null {
84
+ const details = this.authorizationCodes.get(code);
85
+ if (!details) {
86
+ return null;
87
+ }
88
+ this.authorizationCodes.delete(code);
89
+ return details;
90
+ }
91
+
92
+ profile() {
93
+ return this.profileResponse.clone();
94
+ }
95
+
96
+ snapshot() {
97
+ return {
98
+ groups: this.groups,
99
+ functions: this.functions,
100
+ members: this.members,
101
+ profile: this.profileResponse,
102
+ token: this.tokenResponse,
103
+ currentFilter: this.currentFilter,
104
+ calls: this.calls,
105
+ failures: this.failures,
106
+ authorizationCodes: Array.from(this.authorizationCodes.keys()),
107
+ nextMemberId: this.nextMemberId,
108
+ nextMemberNumber: this.nextMemberNumber,
109
+ };
110
+ }
111
+
112
+ setState(patch: SGVMockStatePatch): void {
113
+ if (patch.groups) {
114
+ this.groups = patch.groups.map(group => group.clone());
115
+ }
116
+ if (patch.functions) {
117
+ this.functions = patch.functions.map(functie => functie.clone());
118
+ }
119
+ if (patch.members) {
120
+ // Types need updating to support clone, but only used for playwright which is not added yet.
121
+ this.members = patch.members.map(member => member);
122
+ // this.members = patch.members.map(member => member.clone());
123
+ }
124
+ if (patch.profile) {
125
+ this.profileResponse = patch.profile.clone();
126
+ }
127
+ if (patch.token) {
128
+ this.tokenResponse = {
129
+ ...this.tokenResponse,
130
+ ...patch.token,
131
+ };
132
+ }
133
+ if (patch.nextMemberId !== undefined) {
134
+ this.nextMemberId = patch.nextMemberId;
135
+ }
136
+ if (patch.nextMemberNumber !== undefined) {
137
+ this.nextMemberNumber = patch.nextMemberNumber;
138
+ }
139
+ if ('currentFilter' in patch) {
140
+ this.currentFilter = patch.currentFilter ?? null;
141
+ }
142
+ }
143
+
144
+ addFailure(failure: SGVMockFailure): void {
145
+ this.failures.push({ ...failure, method: failure.method.toUpperCase(), times: failure.times ?? 1 });
146
+ }
147
+
148
+ recordCall(call: Omit<SGVMockCall, 'sequence'>): void {
149
+ this.calls.push({
150
+ ...call,
151
+ method: call.method.toUpperCase(),
152
+ sequence: this.nextCallSequence++,
153
+ });
154
+ }
155
+
156
+ consumeFailure(method: string, path: string): SGVMockFailure | null {
157
+ const index = this.failures.findIndex(failure => failure.method.toUpperCase() === method.toUpperCase() && failure.path === path && (failure.times ?? 1) > 0);
158
+ if (index === -1) {
159
+ return null;
160
+ }
161
+
162
+ const failure = this.failures[index];
163
+ failure.times = (failure.times ?? 1) - 1;
164
+ if (failure.times <= 0) {
165
+ this.failures.splice(index, 1);
166
+ }
167
+ return failure;
168
+ }
169
+
170
+ createFunction(body: SGVFunctieCreateRequest): SGVFunctie {
171
+ const functie = SGVFunctieStruct.create({
172
+ id: body.id ?? `functie-${this.functions.length + 1}`,
173
+ beschrijving: body.beschrijving ?? 'Nieuwe functie',
174
+ type: body.type ?? 'groepseigen',
175
+ groepen: body.groepen ?? [defaultSGVGroepNumber],
176
+ code: body.code,
177
+ });
178
+ this.functions.push(functie);
179
+ return structuredClone(functie);
180
+ }
181
+
182
+ listMembers(offset: number, aantal: number): SGVLedenLijstLid[] {
183
+ return this.members.slice(offset, offset + aantal).map(memberToSummary);
184
+ }
185
+
186
+ searchSimilar(firstName: string, lastName: string): SGVZoekLid[] {
187
+ const needle = `${firstName} ${lastName}`.toLowerCase().trim();
188
+ return this.members
189
+ .filter((member) => {
190
+ const name
191
+ = `${member.firstName} ${member.lastName}`.toLowerCase();
192
+ return (
193
+ name.includes(needle)
194
+ || needle.includes(name)
195
+ || member.firstName.toLowerCase()
196
+ === firstName.toLowerCase()
197
+ || member.lastName.toLowerCase()
198
+ === lastName.toLowerCase()
199
+ );
200
+ })
201
+ .map(memberToSearchMember);
202
+ }
203
+
204
+ getMember(id: string): SGVLidGet | undefined {
205
+ return this.members.find(member => member.id === id);
206
+ }
207
+
208
+ /** Creates a member with SGV-like generated ids and member numbers, then applies the incoming patch. */
209
+ createMember(patch: SGVLidPatch): SGVLidGet {
210
+ const member = mergeMember(
211
+ createSGVLidFixture({
212
+ id: `member-${this.nextMemberId++}`,
213
+ verbondsgegevens: {
214
+ lidnummer: String(this.nextMemberNumber++),
215
+ klantnummer: 'I00000',
216
+ lidgeldbetaald: false,
217
+ lidkaartafgedrukt: false,
218
+ },
219
+ }),
220
+ patch,
221
+ );
222
+ this.members.push(member);
223
+ return member;
224
+ }
225
+
226
+ /** Applies SGV patch semantics to an existing member while preserving omitted nested fields. */
227
+ patchMember(id: string, patch: SGVLidPatch): SGVLidGet | undefined {
228
+ const index = this.members.findIndex(member => member.id === id);
229
+ if (index === -1) {
230
+ return undefined;
231
+ }
232
+ this.members[index] = mergeMember(this.members[index], patch);
233
+ return this.members[index];
234
+ }
235
+
236
+ reset(): void {
237
+ this.nextMemberId = 2;
238
+ this.nextMemberNumber = 124;
239
+ this.groups = defaultSGVGroepen.map(group => group.clone());
240
+ this.functions = defaultSGVFuncties.map(functie => functie.clone());
241
+ this.members = [createSGVLidFixture()];
242
+ this.currentFilter = null;
243
+ this.profileResponse = defaultSGVProfile.clone();
244
+ this.tokenResponse = defaultToken();
245
+ this.calls = [];
246
+ this.failures = [];
247
+ this.authorizationCodes.clear();
248
+ this.nextCallSequence = 1;
249
+ this.nextAuthorizationCode = 1;
250
+ }
251
+ }
252
+
253
+ export const sgvMockState = new SGVMockState();
254
+
255
+ function defaultToken(): SGVTokenResponse {
256
+ return {
257
+ access_token: 'sgv-mock-access-token',
258
+ refresh_token: 'sgv-mock-refresh-token',
259
+ expires_in: 3600,
260
+ token_type: 'Bearer',
261
+ };
262
+ }
263
+
264
+ /** Merges SGV member patches shallowly at the top level and within known nested objects, mirroring how the sync client patches. */
265
+ function mergeMember(member: SGVLidGet, patch: SGVLidPatch): SGVLidGet {
266
+ const vgagegevens = {
267
+ ...requiredVgaGegevens(member),
268
+ ...patch.vgagegevens,
269
+ };
270
+ const verbondsgegevens = {
271
+ ...member.verbondsgegevens,
272
+ ...patch.verbondsgegevens,
273
+ };
274
+ const [year, month, day] = vgagegevens.geboortedatum.split('-').map(value => Number.parseInt(value, 10));
275
+
276
+ const updatedMember = new SGVLidGet({
277
+ id: member.id,
278
+ firstName: vgagegevens.voornaam,
279
+ lastName: vgagegevens.achternaam,
280
+ lidNummer: verbondsgegevens.lidnummer,
281
+ birthDay: new Date(year, month - 1, day, 12),
282
+ });
283
+ Object.assign(updatedMember, {
284
+ ...member,
285
+ ...patch,
286
+ persoonsgegevens: {
287
+ ...member.persoonsgegevens,
288
+ ...patch.persoonsgegevens,
289
+ },
290
+ vgagegevens,
291
+ verbondsgegevens,
292
+ adressen: patch.adressen ?? member.adressen,
293
+ contacten: patch.contacten ?? member.contacten,
294
+ functies: patch.functies ?? member.functies,
295
+ });
296
+ return updatedMember;
297
+ }
298
+
299
+ function memberToSearchMember(member: SGVLidGet): SGVZoekLid {
300
+ return SGVZoekLidStruct.create({
301
+ id: member.id,
302
+ firstName: member.firstName,
303
+ lastName: member.lastName,
304
+ birthDayString: member.vgagegevens?.geboortedatum ?? member.birthDay.toISOString().slice(0, 10),
305
+ });
306
+ }
307
+
308
+ function requiredVgaGegevens(member: SGVLidGet) {
309
+ if (!member.vgagegevens) {
310
+ return {
311
+ voornaam: member.firstName,
312
+ achternaam: member.lastName,
313
+ geboortedatum: member.birthDay.toISOString().slice(0, 10),
314
+ };
315
+ }
316
+ return member.vgagegevens;
317
+ }
318
+
319
+ function memberToSummary(member: SGVLidGet): SGVLedenLijstLid {
320
+ return SGVLedenLijstLidStruct.create({
321
+ id: member.id,
322
+ waarden: SGVLedenLijstWaarden.create({
323
+ lidNummer: member.lidNummer,
324
+ firstName: member.firstName,
325
+ lastName: member.lastName,
326
+ birthDay: `${member.birthDay.getDate().toString().padStart(2, '0')}/${(member.birthDay.getMonth() + 1).toString().padStart(2, '0')}/${member.birthDay.getFullYear()}`,
327
+ }),
328
+ });
329
+ }
@@ -0,0 +1,11 @@
1
+ import { Request } from "@simonbackx/simple-endpoints";
2
+
3
+ Error.stackTraceLimit = 100;
4
+
5
+ Request.defaultVersion = 1;
6
+
7
+ process.env.TZ = "UTC";
8
+
9
+ if (new Date().getTimezoneOffset() !== 0) {
10
+ throw new Error("Process should always run in UTC timezone");
11
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "outDir": "dist",
6
+ "skipLibCheck": true
7
+ },
8
+ "include": [
9
+ "./src"
10
+ ],
11
+ "exclude": [
12
+ "./src/**/*.spec.ts",
13
+ "./src/**/*.test.ts"
14
+ ],
15
+ "references": [
16
+ { "path": "../../../shared/sgv/tsconfig.build.json" }
17
+ ]
18
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "files": [],
4
+ "references": [
5
+ {
6
+ "path": "./tsconfig.build.json"
7
+ },
8
+ {
9
+ "path": "./tsconfig.test.json"
10
+ }
11
+ ]
12
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "outDir": "dist",
4
+ "rootDir": "src"
5
+ },
6
+ "extends": "../../../tsconfig.test.json",
7
+ "include": [
8
+ "../../../jest-extended.d.ts",
9
+ "./src/**/*.spec.ts",
10
+ "./src/**/*.test.ts",
11
+ "./tests"
12
+ ],
13
+ "references": [
14
+ { "path": "./tsconfig.build.json" }
15
+ ]
16
+ }
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ setupFiles: ['./tests/vitest.setup.ts'],
6
+ watch: false,
7
+ globals: true,
8
+ root: import.meta.dirname,
9
+ isolate: true,
10
+ maxWorkers: 1,
11
+ },
12
+ });