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