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