@flusys/nestjs-form-builder 6.0.2 → 6.1.1

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,657 @@
1
+ import { Test } from '@nestjs/testing';
2
+ import { UtilsService } from '@flusys/nestjs-shared/modules';
3
+ import { createMockQueryBuilder, createMockRepository } from '@test-utils/mocks/repository.mock';
4
+ import { buildMockUser } from '@test-utils/mocks/logged-user.mock';
5
+ import { FormResult } from '../entities';
6
+ import { FormAccessType } from '../enums/form-access-type.enum';
7
+ import * as permissionUtils from '../utils/permission.utils';
8
+ import { FormResultService } from './form-result.service';
9
+ import { FormBuilderConfigService } from './form-builder-config.service';
10
+ import { FormBuilderDataSourceProvider } from './form-builder-datasource.provider';
11
+ jest.mock('../utils/permission.utils', ()=>({
12
+ validateUserPermissions: jest.fn()
13
+ }));
14
+ function buildForm(overrides = {}) {
15
+ return {
16
+ id: 'form-uuid-1',
17
+ name: 'Customer Survey',
18
+ description: null,
19
+ slug: 'customer-survey',
20
+ schema: {
21
+ sections: [],
22
+ settings: {}
23
+ },
24
+ schemaVersion: 1,
25
+ accessType: FormAccessType.AUTHENTICATED,
26
+ actionGroups: null,
27
+ tags: null,
28
+ isActive: true,
29
+ createdAt: new Date(),
30
+ updatedAt: new Date(),
31
+ deletedAt: null,
32
+ createdById: null,
33
+ updatedById: null,
34
+ deletedById: null,
35
+ ...overrides
36
+ };
37
+ }
38
+ function buildFormResult(overrides = {}) {
39
+ return {
40
+ id: 'result-uuid-1',
41
+ formId: 'form-uuid-1',
42
+ schemaVersionSnapshot: {
43
+ sections: []
44
+ },
45
+ schemaVersion: 1,
46
+ data: {
47
+ field1: 'value1'
48
+ },
49
+ submittedById: 'user-uuid-1',
50
+ submittedAt: new Date(),
51
+ isDraft: false,
52
+ createdAt: new Date(),
53
+ updatedAt: new Date(),
54
+ deletedAt: null,
55
+ createdById: null,
56
+ updatedById: null,
57
+ deletedById: null,
58
+ ...overrides
59
+ };
60
+ }
61
+ describe('FormResultService', ()=>{
62
+ let service;
63
+ let mockResultRepo;
64
+ let mockFormRepo;
65
+ let mockFormBuilderConfig;
66
+ async function buildService(companyFeatureEnabled = false) {
67
+ mockResultRepo = createMockRepository();
68
+ mockFormRepo = createMockRepository();
69
+ // `softRemove` isn't part of the shared repository mock's default method list — add it here.
70
+ mockResultRepo.softRemove = jest.fn();
71
+ // Extend the shared query-builder mock with the extra chain methods this service relies on
72
+ // (`innerJoin` for the company scoping join).
73
+ mockResultRepo.createQueryBuilder.mockReturnValue({
74
+ ...createMockQueryBuilder(),
75
+ innerJoin: jest.fn().mockReturnThis()
76
+ });
77
+ const mockDataSourceProvider = {
78
+ getRepository: jest.fn((entity)=>{
79
+ if (entity === FormResult) return Promise.resolve(mockResultRepo);
80
+ return Promise.resolve(mockFormRepo);
81
+ }),
82
+ getDataSource: jest.fn()
83
+ };
84
+ mockFormBuilderConfig = {
85
+ isCompanyFeatureEnabled: jest.fn().mockReturnValue(companyFeatureEnabled)
86
+ };
87
+ const module = await Test.createTestingModule({
88
+ providers: [
89
+ FormResultService,
90
+ {
91
+ provide: 'CACHE_INSTANCE',
92
+ useValue: {}
93
+ },
94
+ {
95
+ provide: UtilsService,
96
+ useValue: {}
97
+ },
98
+ {
99
+ provide: FormBuilderConfigService,
100
+ useValue: mockFormBuilderConfig
101
+ },
102
+ {
103
+ provide: FormBuilderDataSourceProvider,
104
+ useValue: mockDataSourceProvider
105
+ }
106
+ ]
107
+ }).compile();
108
+ service = await module.resolve(FormResultService);
109
+ }
110
+ afterEach(()=>{
111
+ jest.clearAllMocks();
112
+ });
113
+ // ==========================================================================
114
+ // getSelectQuery
115
+ // ==========================================================================
116
+ describe('getSelectQuery', ()=>{
117
+ it('applies the default field list, prefixed with the entity name', async ()=>{
118
+ await buildService(false);
119
+ const query = {
120
+ select: jest.fn().mockReturnThis()
121
+ };
122
+ await service.getSelectQuery(query, buildMockUser(), undefined);
123
+ expect(query.select).toHaveBeenCalledWith(expect.arrayContaining([
124
+ 'form_result.id',
125
+ 'form_result.formId',
126
+ 'form_result.data',
127
+ 'form_result.isDraft'
128
+ ]));
129
+ });
130
+ it('respects a custom select list', async ()=>{
131
+ await buildService(false);
132
+ const query = {
133
+ select: jest.fn().mockReturnThis()
134
+ };
135
+ await service.getSelectQuery(query, buildMockUser(), [
136
+ 'id',
137
+ 'data'
138
+ ]);
139
+ expect(query.select).toHaveBeenCalledWith([
140
+ 'form_result.id',
141
+ 'form_result.data'
142
+ ]);
143
+ });
144
+ });
145
+ // ==========================================================================
146
+ // getExtraManipulateQuery / applyCompanyFilterToQuery
147
+ // ==========================================================================
148
+ describe('getExtraManipulateQuery', ()=>{
149
+ it('joins the form table and filters by company when company feature is enabled', async ()=>{
150
+ await buildService(true);
151
+ const query = {
152
+ innerJoin: jest.fn().mockReturnThis(),
153
+ andWhere: jest.fn().mockReturnThis()
154
+ };
155
+ await service.getExtraManipulateQuery(query, {}, buildMockUser({
156
+ companyId: 'company-1'
157
+ }));
158
+ expect(query.innerJoin).toHaveBeenCalledWith('form', 'f', 'f.id = form_result.formId');
159
+ expect(query.andWhere).toHaveBeenCalledWith('f.company_id = :companyId', {
160
+ companyId: 'company-1'
161
+ });
162
+ });
163
+ it('does not join when company feature is disabled', async ()=>{
164
+ await buildService(false);
165
+ const query = {
166
+ innerJoin: jest.fn().mockReturnThis(),
167
+ andWhere: jest.fn().mockReturnThis()
168
+ };
169
+ await service.getExtraManipulateQuery(query, {}, buildMockUser({
170
+ companyId: 'company-1'
171
+ }));
172
+ expect(query.innerJoin).not.toHaveBeenCalled();
173
+ });
174
+ it('does not join when the user has no companyId', async ()=>{
175
+ await buildService(true);
176
+ const query = {
177
+ innerJoin: jest.fn().mockReturnThis(),
178
+ andWhere: jest.fn().mockReturnThis()
179
+ };
180
+ await service.getExtraManipulateQuery(query, {}, buildMockUser({
181
+ companyId: undefined
182
+ }));
183
+ expect(query.innerJoin).not.toHaveBeenCalled();
184
+ });
185
+ it('does not join when there is no user (public queries)', async ()=>{
186
+ await buildService(true);
187
+ const query = {
188
+ innerJoin: jest.fn().mockReturnThis(),
189
+ andWhere: jest.fn().mockReturnThis()
190
+ };
191
+ await service.getExtraManipulateQuery(query, {}, null);
192
+ expect(query.innerJoin).not.toHaveBeenCalled();
193
+ });
194
+ });
195
+ // ==========================================================================
196
+ // convertEntityToResponseDto
197
+ // ==========================================================================
198
+ describe('convertEntityToResponseDto', ()=>{
199
+ it('maps entity fields to the response shape', async ()=>{
200
+ await buildService(false);
201
+ const entity = buildFormResult();
202
+ const dto = service.convertEntityToResponseDto(entity, false);
203
+ expect(dto).toMatchObject({
204
+ id: entity.id,
205
+ formId: entity.formId,
206
+ data: entity.data,
207
+ isDraft: false
208
+ });
209
+ });
210
+ });
211
+ // ==========================================================================
212
+ // submitForm
213
+ // ==========================================================================
214
+ describe('submitForm', ()=>{
215
+ it('throws NotFoundException when the form does not exist or is inactive', async ()=>{
216
+ await buildService(false);
217
+ mockFormRepo.findOne.mockResolvedValue(null);
218
+ await expect(service.submitForm({
219
+ formId: 'missing',
220
+ data: {}
221
+ }, buildMockUser(), false)).rejects.toMatchObject({
222
+ response: expect.objectContaining({
223
+ messageKey: 'form.not.found'
224
+ })
225
+ });
226
+ });
227
+ it('rejects a public submission when the form is not marked PUBLIC', async ()=>{
228
+ await buildService(false);
229
+ mockFormRepo.findOne.mockResolvedValue(buildForm({
230
+ accessType: FormAccessType.AUTHENTICATED
231
+ }));
232
+ await expect(service.submitForm({
233
+ formId: 'form-uuid-1',
234
+ data: {}
235
+ }, null, true)).rejects.toMatchObject({
236
+ response: expect.objectContaining({
237
+ messageKey: 'form.not.public'
238
+ })
239
+ });
240
+ });
241
+ it('allows a public submission to a PUBLIC form without a user', async ()=>{
242
+ await buildService(false);
243
+ mockFormRepo.findOne.mockResolvedValue(buildForm({
244
+ accessType: FormAccessType.PUBLIC
245
+ }));
246
+ mockResultRepo.save.mockResolvedValue(buildFormResult({
247
+ submittedById: null
248
+ }));
249
+ const result = await service.submitForm({
250
+ formId: 'form-uuid-1',
251
+ data: {
252
+ field1: 'x'
253
+ }
254
+ }, null, true);
255
+ expect(mockResultRepo.findOne).not.toHaveBeenCalled();
256
+ expect(mockResultRepo.save).toHaveBeenCalledWith(expect.objectContaining({
257
+ formId: 'form-uuid-1',
258
+ submittedById: null,
259
+ isDraft: false
260
+ }));
261
+ expect(result.formId).toBe('form-uuid-1');
262
+ });
263
+ it('requires authentication for a non-public form submitted through the authenticated flow', async ()=>{
264
+ await buildService(false);
265
+ mockFormRepo.findOne.mockResolvedValue(buildForm({
266
+ accessType: FormAccessType.AUTHENTICATED
267
+ }));
268
+ await expect(service.submitForm({
269
+ formId: 'form-uuid-1',
270
+ data: {}
271
+ }, null, false)).rejects.toMatchObject({
272
+ response: expect.objectContaining({
273
+ messageKey: 'form.auth.required'
274
+ })
275
+ });
276
+ });
277
+ it('allows submission to an ACTION_GROUP form when the user has the required permission', async ()=>{
278
+ await buildService(false);
279
+ mockFormRepo.findOne.mockResolvedValue(buildForm({
280
+ accessType: FormAccessType.ACTION_GROUP,
281
+ actionGroups: [
282
+ 'hr.submit'
283
+ ]
284
+ }));
285
+ permissionUtils.validateUserPermissions.mockResolvedValue(true);
286
+ mockResultRepo.findOne.mockResolvedValue(null);
287
+ mockResultRepo.save.mockResolvedValue(buildFormResult());
288
+ const result = await service.submitForm({
289
+ formId: 'form-uuid-1',
290
+ data: {}
291
+ }, buildMockUser(), false);
292
+ expect(result.formId).toBe('form-uuid-1');
293
+ });
294
+ it('rejects submission to an ACTION_GROUP form when the user lacks the required permission', async ()=>{
295
+ await buildService(false);
296
+ mockFormRepo.findOne.mockResolvedValue(buildForm({
297
+ accessType: FormAccessType.ACTION_GROUP,
298
+ actionGroups: [
299
+ 'hr.submit'
300
+ ]
301
+ }));
302
+ permissionUtils.validateUserPermissions.mockResolvedValue(false);
303
+ await expect(service.submitForm({
304
+ formId: 'form-uuid-1',
305
+ data: {}
306
+ }, buildMockUser(), false)).rejects.toMatchObject({
307
+ response: expect.objectContaining({
308
+ messageKey: 'form.access.denied'
309
+ })
310
+ });
311
+ });
312
+ it('rejects submission for an unrecognized (non-AUTHENTICATED) access type', async ()=>{
313
+ await buildService(false);
314
+ mockFormRepo.findOne.mockResolvedValue(buildForm({
315
+ accessType: 'weird'
316
+ }));
317
+ await expect(service.submitForm({
318
+ formId: 'form-uuid-1',
319
+ data: {}
320
+ }, buildMockUser(), false)).rejects.toMatchObject({
321
+ response: expect.objectContaining({
322
+ messageKey: 'form.invalid.access.type'
323
+ })
324
+ });
325
+ });
326
+ it('saves a new final submission with a schema snapshot and computed fields', async ()=>{
327
+ await buildService(false);
328
+ const form = buildForm({
329
+ schema: {
330
+ settings: {
331
+ computedFields: [
332
+ {
333
+ id: 'cf-1',
334
+ name: 'Total',
335
+ key: 'total',
336
+ valueType: 'number',
337
+ rules: [
338
+ {
339
+ id: 'r1',
340
+ computation: {
341
+ type: 'direct',
342
+ config: {
343
+ type: 'direct',
344
+ value: 99
345
+ }
346
+ }
347
+ }
348
+ ]
349
+ }
350
+ ]
351
+ }
352
+ }
353
+ });
354
+ mockFormRepo.findOne.mockResolvedValue(form);
355
+ mockResultRepo.findOne.mockResolvedValue(null);
356
+ mockResultRepo.save.mockResolvedValue(buildFormResult());
357
+ await service.submitForm({
358
+ formId: 'form-uuid-1',
359
+ data: {
360
+ field1: 'x'
361
+ }
362
+ }, buildMockUser(), false);
363
+ expect(mockResultRepo.save).toHaveBeenCalledWith(expect.objectContaining({
364
+ schemaVersionSnapshot: form.schema,
365
+ schemaVersion: form.schemaVersion,
366
+ data: {
367
+ field1: 'x',
368
+ _computed: {
369
+ total: 99
370
+ }
371
+ },
372
+ isDraft: false
373
+ }));
374
+ });
375
+ it('does not compute fields when saving as a draft', async ()=>{
376
+ await buildService(false);
377
+ const form = buildForm({
378
+ schema: {
379
+ settings: {
380
+ computedFields: [
381
+ {
382
+ id: 'cf-1',
383
+ name: 'Total',
384
+ key: 'total',
385
+ valueType: 'number',
386
+ rules: [
387
+ {
388
+ id: 'r1',
389
+ computation: {
390
+ type: 'direct',
391
+ config: {
392
+ type: 'direct',
393
+ value: 99
394
+ }
395
+ }
396
+ }
397
+ ]
398
+ }
399
+ ]
400
+ }
401
+ }
402
+ });
403
+ mockFormRepo.findOne.mockResolvedValue(form);
404
+ mockResultRepo.findOne.mockResolvedValue(null);
405
+ mockResultRepo.save.mockResolvedValue(buildFormResult({
406
+ isDraft: true
407
+ }));
408
+ await service.submitForm({
409
+ formId: 'form-uuid-1',
410
+ data: {
411
+ field1: 'x'
412
+ },
413
+ isDraft: true
414
+ }, buildMockUser(), false);
415
+ expect(mockResultRepo.save).toHaveBeenCalledWith(expect.objectContaining({
416
+ data: {
417
+ field1: 'x'
418
+ },
419
+ isDraft: true
420
+ }));
421
+ });
422
+ it('replaces an existing draft with the final submission (soft-removes the draft, inserts a new record)', async ()=>{
423
+ await buildService(false);
424
+ mockFormRepo.findOne.mockResolvedValue(buildForm());
425
+ const existingDraft = buildFormResult({
426
+ id: 'draft-1',
427
+ isDraft: true
428
+ });
429
+ mockResultRepo.findOne.mockResolvedValue(existingDraft);
430
+ mockResultRepo.save.mockResolvedValue(buildFormResult({
431
+ id: 'final-1',
432
+ isDraft: false
433
+ }));
434
+ const result = await service.submitForm({
435
+ formId: 'form-uuid-1',
436
+ data: {
437
+ field1: 'y'
438
+ }
439
+ }, buildMockUser(), false);
440
+ expect(mockResultRepo.softRemove).toHaveBeenCalledWith(existingDraft);
441
+ expect(mockResultRepo.save).toHaveBeenCalledWith(expect.objectContaining({
442
+ formId: 'form-uuid-1',
443
+ isDraft: false
444
+ }));
445
+ expect(result.id).toBe('final-1');
446
+ });
447
+ it('updates an existing draft in place when re-submitting as a draft', async ()=>{
448
+ await buildService(false);
449
+ mockFormRepo.findOne.mockResolvedValue(buildForm());
450
+ const existingDraft = buildFormResult({
451
+ id: 'draft-1',
452
+ isDraft: true
453
+ });
454
+ mockResultRepo.findOne.mockResolvedValue(existingDraft);
455
+ mockResultRepo.save.mockResolvedValue({
456
+ ...existingDraft,
457
+ data: {
458
+ field1: 'z'
459
+ }
460
+ });
461
+ await service.submitForm({
462
+ formId: 'form-uuid-1',
463
+ data: {
464
+ field1: 'z'
465
+ },
466
+ isDraft: true
467
+ }, buildMockUser(), false);
468
+ expect(mockResultRepo.softRemove).not.toHaveBeenCalled();
469
+ expect(mockResultRepo.save).toHaveBeenCalledWith(expect.objectContaining({
470
+ id: 'draft-1',
471
+ data: {
472
+ field1: 'z'
473
+ }
474
+ }));
475
+ });
476
+ });
477
+ // ==========================================================================
478
+ // getMyDraft
479
+ // ==========================================================================
480
+ describe('getMyDraft', ()=>{
481
+ it('returns the most recent draft for the user', async ()=>{
482
+ await buildService(false);
483
+ mockResultRepo.findOne.mockResolvedValue(buildFormResult({
484
+ isDraft: true
485
+ }));
486
+ const result = await service.getMyDraft('form-uuid-1', buildMockUser());
487
+ expect(result?.isDraft).toBe(true);
488
+ expect(mockResultRepo.findOne).toHaveBeenCalledWith(expect.objectContaining({
489
+ where: expect.objectContaining({
490
+ formId: 'form-uuid-1',
491
+ isDraft: true
492
+ })
493
+ }));
494
+ });
495
+ it('returns null when the user has no draft', async ()=>{
496
+ await buildService(false);
497
+ mockResultRepo.findOne.mockResolvedValue(null);
498
+ const result = await service.getMyDraft('form-uuid-1', buildMockUser());
499
+ expect(result).toBeNull();
500
+ });
501
+ });
502
+ // ==========================================================================
503
+ // updateDraft
504
+ // ==========================================================================
505
+ describe('updateDraft', ()=>{
506
+ it('throws NotFoundException when the draft does not exist or does not belong to the user', async ()=>{
507
+ await buildService(false);
508
+ mockResultRepo.findOne.mockResolvedValue(null);
509
+ await expect(service.updateDraft('missing-draft', {
510
+ formId: 'form-uuid-1',
511
+ data: {}
512
+ }, buildMockUser())).rejects.toMatchObject({
513
+ response: expect.objectContaining({
514
+ messageKey: 'form.result.not.found'
515
+ })
516
+ });
517
+ });
518
+ it('updates the draft with the latest form schema snapshot and data', async ()=>{
519
+ await buildService(false);
520
+ const existingDraft = buildFormResult({
521
+ id: 'draft-1',
522
+ isDraft: true
523
+ });
524
+ mockResultRepo.findOne.mockResolvedValue(existingDraft);
525
+ mockFormRepo.findOne.mockResolvedValue(buildForm({
526
+ schemaVersion: 2
527
+ }));
528
+ mockResultRepo.save.mockResolvedValue({
529
+ ...existingDraft,
530
+ schemaVersion: 2
531
+ });
532
+ const result = await service.updateDraft('draft-1', {
533
+ formId: 'form-uuid-1',
534
+ data: {
535
+ field1: 'updated'
536
+ }
537
+ }, buildMockUser());
538
+ expect(mockResultRepo.save).toHaveBeenCalledWith(expect.objectContaining({
539
+ id: 'draft-1',
540
+ schemaVersion: 2
541
+ }));
542
+ expect(result).toBeDefined();
543
+ });
544
+ it('finalizes a draft (isDraft=false) and applies computed fields', async ()=>{
545
+ await buildService(false);
546
+ const existingDraft = buildFormResult({
547
+ id: 'draft-1',
548
+ isDraft: true
549
+ });
550
+ mockResultRepo.findOne.mockResolvedValue(existingDraft);
551
+ mockFormRepo.findOne.mockResolvedValue(buildForm({
552
+ schema: {
553
+ settings: {
554
+ computedFields: [
555
+ {
556
+ id: 'cf-1',
557
+ name: 'Total',
558
+ key: 'total',
559
+ valueType: 'number',
560
+ rules: [
561
+ {
562
+ id: 'r1',
563
+ computation: {
564
+ type: 'direct',
565
+ config: {
566
+ type: 'direct',
567
+ value: 7
568
+ }
569
+ }
570
+ }
571
+ ]
572
+ }
573
+ ]
574
+ }
575
+ }
576
+ }));
577
+ mockResultRepo.save.mockResolvedValue(buildFormResult({
578
+ id: 'draft-1',
579
+ isDraft: false
580
+ }));
581
+ await service.updateDraft('draft-1', {
582
+ formId: 'form-uuid-1',
583
+ data: {
584
+ field1: 'final'
585
+ },
586
+ isDraft: false
587
+ }, buildMockUser());
588
+ expect(mockResultRepo.save).toHaveBeenCalledWith(expect.objectContaining({
589
+ data: {
590
+ field1: 'final',
591
+ _computed: {
592
+ total: 7
593
+ }
594
+ },
595
+ isDraft: false
596
+ }));
597
+ });
598
+ });
599
+ // ==========================================================================
600
+ // getByFormId
601
+ // ==========================================================================
602
+ describe('getByFormId', ()=>{
603
+ it('returns paginated results scoped to the given form', async ()=>{
604
+ await buildService(false);
605
+ const qb = mockResultRepo.createQueryBuilder();
606
+ qb.getManyAndCount.mockResolvedValue([
607
+ [
608
+ buildFormResult()
609
+ ],
610
+ 1
611
+ ]);
612
+ const result = await service.getByFormId('form-uuid-1', buildMockUser(), {
613
+ page: 0,
614
+ pageSize: 10
615
+ });
616
+ expect(qb.where).toHaveBeenCalledWith('form_result.formId = :formId', {
617
+ formId: 'form-uuid-1'
618
+ });
619
+ expect(result.total).toBe(1);
620
+ expect(result.data).toHaveLength(1);
621
+ });
622
+ it('applies default pagination when none is provided', async ()=>{
623
+ await buildService(false);
624
+ const qb = mockResultRepo.createQueryBuilder();
625
+ qb.getManyAndCount.mockResolvedValue([
626
+ [],
627
+ 0
628
+ ]);
629
+ await service.getByFormId('form-uuid-1', buildMockUser());
630
+ expect(qb.skip).toHaveBeenCalledWith(0);
631
+ expect(qb.take).toHaveBeenCalledWith(10);
632
+ });
633
+ });
634
+ // ==========================================================================
635
+ // hasUserSubmitted
636
+ // ==========================================================================
637
+ describe('hasUserSubmitted', ()=>{
638
+ it('returns true when a non-draft submission exists', async ()=>{
639
+ await buildService(false);
640
+ mockResultRepo.count.mockResolvedValue(1);
641
+ const result = await service.hasUserSubmitted('form-uuid-1', buildMockUser());
642
+ expect(result).toBe(true);
643
+ expect(mockResultRepo.count).toHaveBeenCalledWith(expect.objectContaining({
644
+ where: expect.objectContaining({
645
+ formId: 'form-uuid-1',
646
+ isDraft: false
647
+ })
648
+ }));
649
+ });
650
+ it('returns false when no non-draft submission exists', async ()=>{
651
+ await buildService(false);
652
+ mockResultRepo.count.mockResolvedValue(0);
653
+ const result = await service.hasUserSubmitted('form-uuid-1', buildMockUser());
654
+ expect(result).toBe(false);
655
+ });
656
+ });
657
+ });