@flusys/nestjs-form-builder 6.0.1 → 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,701 @@
1
+ import { Test } from '@nestjs/testing';
2
+ import { envConfig } from '@flusys/nestjs-core';
3
+ import { UtilsService } from '@flusys/nestjs-shared/modules';
4
+ import { createMockDataSourceProvider, createMockRepository } from '@test-utils/mocks/repository.mock';
5
+ import { buildMockUser } from '@test-utils/mocks/logged-user.mock';
6
+ import { Form, FormWithCompany } from '../entities';
7
+ import { FormAccessType } from '../enums/form-access-type.enum';
8
+ import * as permissionUtils from '../utils/permission.utils';
9
+ import { FormService } from './form.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
+ describe('FormService', ()=>{
40
+ let service;
41
+ let mockRepo;
42
+ let mockFormBuilderConfig;
43
+ async function buildService(companyFeatureEnabled = false) {
44
+ mockRepo = createMockRepository();
45
+ const mockDataSourceProvider = createMockDataSourceProvider(mockRepo);
46
+ mockFormBuilderConfig = {
47
+ isCompanyFeatureEnabled: jest.fn().mockReturnValue(companyFeatureEnabled)
48
+ };
49
+ const module = await Test.createTestingModule({
50
+ providers: [
51
+ FormService,
52
+ {
53
+ provide: 'CACHE_INSTANCE',
54
+ useValue: {}
55
+ },
56
+ {
57
+ provide: UtilsService,
58
+ useValue: {}
59
+ },
60
+ {
61
+ provide: FormBuilderConfigService,
62
+ useValue: mockFormBuilderConfig
63
+ },
64
+ {
65
+ provide: FormBuilderDataSourceProvider,
66
+ useValue: mockDataSourceProvider
67
+ }
68
+ ]
69
+ }).compile();
70
+ service = await module.resolve(FormService);
71
+ // Pre-warm the lazily-initialized repository so protected/public helper methods
72
+ // that read `this.repository` directly (outside the insert/update pipeline) work in tests.
73
+ await service.ensureDataSourceRepository();
74
+ }
75
+ afterEach(()=>{
76
+ jest.clearAllMocks();
77
+ });
78
+ describe('resolveEntity', ()=>{
79
+ it('resolves to Form when company feature disabled', async ()=>{
80
+ await buildService(false);
81
+ expect(service.resolveEntity()).toBe(Form);
82
+ });
83
+ it('resolves to FormWithCompany when company feature enabled', async ()=>{
84
+ await buildService(true);
85
+ expect(service.resolveEntity()).toBe(FormWithCompany);
86
+ });
87
+ });
88
+ // ==========================================================================
89
+ // convertSingleDtoToEntity — schema versioning + company scoping
90
+ // ==========================================================================
91
+ describe('convertSingleDtoToEntity', ()=>{
92
+ describe('create (no id)', ()=>{
93
+ it('merges the DTO fields into a fresh entity without touching the repository', async ()=>{
94
+ await buildService(false);
95
+ const result = await service.convertSingleDtoToEntity({
96
+ name: 'New Form',
97
+ schema: {
98
+ sections: []
99
+ }
100
+ }, buildMockUser());
101
+ expect(result.name).toBe('New Form');
102
+ expect(mockRepo.findOne).not.toHaveBeenCalled();
103
+ });
104
+ it('sets companyId from the DTO when company feature enabled and DTO provides it', async ()=>{
105
+ await buildService(true);
106
+ const result = await service.convertSingleDtoToEntity({
107
+ name: 'New Form',
108
+ schema: {},
109
+ companyId: 'company-explicit'
110
+ }, buildMockUser({
111
+ companyId: 'company-from-user'
112
+ }));
113
+ expect(result.companyId).toBe('company-explicit');
114
+ });
115
+ it('falls back to the user companyId when company feature enabled and DTO omits it', async ()=>{
116
+ await buildService(true);
117
+ const result = await service.convertSingleDtoToEntity({
118
+ name: 'New Form',
119
+ schema: {}
120
+ }, buildMockUser({
121
+ companyId: 'company-from-user'
122
+ }));
123
+ expect(result.companyId).toBe('company-from-user');
124
+ });
125
+ it('sets companyId to null when company feature enabled, no DTO value, and no user', async ()=>{
126
+ await buildService(true);
127
+ const result = await service.convertSingleDtoToEntity({
128
+ name: 'New Form',
129
+ schema: {}
130
+ }, null);
131
+ expect(result.companyId).toBeNull();
132
+ });
133
+ it('does not set companyId when company feature is disabled', async ()=>{
134
+ await buildService(false);
135
+ const result = await service.convertSingleDtoToEntity({
136
+ name: 'New Form',
137
+ schema: {}
138
+ }, buildMockUser({
139
+ companyId: 'company-from-user'
140
+ }));
141
+ expect(result.companyId).toBeUndefined();
142
+ });
143
+ });
144
+ describe('update (with id)', ()=>{
145
+ it('throws NotFoundException when the form does not exist', async ()=>{
146
+ await buildService(false);
147
+ mockRepo.findOne.mockResolvedValue(null);
148
+ await expect(service.convertSingleDtoToEntity({
149
+ id: 'missing-id',
150
+ schema: {}
151
+ }, buildMockUser())).rejects.toMatchObject({
152
+ response: expect.objectContaining({
153
+ messageKey: 'form.not.found'
154
+ })
155
+ });
156
+ });
157
+ it('bumps schemaVersion when the schema payload differs from the stored schema', async ()=>{
158
+ await buildService(false);
159
+ const existing = buildForm({
160
+ id: 'form-1',
161
+ schemaVersion: 3,
162
+ schema: {
163
+ a: 1
164
+ }
165
+ });
166
+ mockRepo.findOne.mockResolvedValue(existing);
167
+ const result = await service.convertSingleDtoToEntity({
168
+ id: 'form-1',
169
+ schema: {
170
+ a: 2
171
+ }
172
+ }, buildMockUser());
173
+ expect(result.schemaVersion).toBe(4);
174
+ });
175
+ it('does not bump schemaVersion when the schema payload is unchanged', async ()=>{
176
+ await buildService(false);
177
+ const existing = buildForm({
178
+ id: 'form-1',
179
+ schemaVersion: 3,
180
+ schema: {
181
+ a: 1
182
+ }
183
+ });
184
+ mockRepo.findOne.mockResolvedValue(existing);
185
+ const result = await service.convertSingleDtoToEntity({
186
+ id: 'form-1',
187
+ schema: {
188
+ a: 1
189
+ }
190
+ }, buildMockUser());
191
+ expect(result.schemaVersion).toBe(3);
192
+ });
193
+ it('does not bump schemaVersion when the DTO omits schema entirely', async ()=>{
194
+ await buildService(false);
195
+ const existing = buildForm({
196
+ id: 'form-1',
197
+ schemaVersion: 3,
198
+ schema: {
199
+ a: 1
200
+ }
201
+ });
202
+ mockRepo.findOne.mockResolvedValue(existing);
203
+ const result = await service.convertSingleDtoToEntity({
204
+ id: 'form-1',
205
+ name: 'Renamed'
206
+ }, buildMockUser());
207
+ expect(result.schemaVersion).toBe(3);
208
+ expect(result.name).toBe('Renamed');
209
+ });
210
+ it('uses the DTO companyId when explicitly provided on update (company feature enabled)', async ()=>{
211
+ await buildService(true);
212
+ const existing = buildForm({
213
+ id: 'form-1'
214
+ });
215
+ existing.companyId = 'company-original';
216
+ mockRepo.findOne.mockResolvedValue(existing);
217
+ const result = await service.convertSingleDtoToEntity({
218
+ id: 'form-1',
219
+ companyId: 'company-new'
220
+ }, buildMockUser({
221
+ companyId: 'company-from-user'
222
+ }));
223
+ expect(result.companyId).toBe('company-new');
224
+ });
225
+ it('preserves the existing companyId when the update DTO omits it', async ()=>{
226
+ await buildService(true);
227
+ const existing = buildForm({
228
+ id: 'form-1'
229
+ });
230
+ existing.companyId = 'company-original';
231
+ mockRepo.findOne.mockResolvedValue(existing);
232
+ const result = await service.convertSingleDtoToEntity({
233
+ id: 'form-1',
234
+ name: 'Renamed'
235
+ }, buildMockUser({
236
+ companyId: 'company-from-user'
237
+ }));
238
+ expect(result.companyId).toBe('company-original');
239
+ });
240
+ it('falls back to the user companyId when the existing form has no companyId set', async ()=>{
241
+ await buildService(true);
242
+ // Simulate a stored Form (not FormWithCompany) — companyId is undefined on the entity.
243
+ const existing = buildForm({
244
+ id: 'form-1'
245
+ });
246
+ mockRepo.findOne.mockResolvedValue(existing);
247
+ const result = await service.convertSingleDtoToEntity({
248
+ id: 'form-1',
249
+ name: 'Renamed'
250
+ }, buildMockUser({
251
+ companyId: 'company-from-user'
252
+ }));
253
+ expect(result.companyId).toBe('company-from-user');
254
+ });
255
+ });
256
+ });
257
+ // ==========================================================================
258
+ // getSelectQuery
259
+ // ==========================================================================
260
+ describe('getSelectQuery', ()=>{
261
+ it('applies the default field list, prefixed with the entity name', async ()=>{
262
+ await buildService(false);
263
+ const query = {
264
+ select: jest.fn().mockReturnThis()
265
+ };
266
+ await service.getSelectQuery(query, buildMockUser(), undefined);
267
+ expect(query.select).toHaveBeenCalledWith(expect.arrayContaining([
268
+ 'form.id',
269
+ 'form.name',
270
+ 'form.schema',
271
+ 'form.schemaVersion'
272
+ ]));
273
+ });
274
+ it('appends form.companyId to the default fields when company feature is enabled', async ()=>{
275
+ await buildService(true);
276
+ const query = {
277
+ select: jest.fn().mockReturnThis()
278
+ };
279
+ await service.getSelectQuery(query, buildMockUser(), undefined);
280
+ const selectedFields = query.select.mock.calls[0][0];
281
+ expect(selectedFields).toContain('form.companyId');
282
+ });
283
+ it('appends form.companyId even when a custom select list is provided', async ()=>{
284
+ await buildService(true);
285
+ const query = {
286
+ select: jest.fn().mockReturnThis()
287
+ };
288
+ await service.getSelectQuery(query, buildMockUser(), [
289
+ 'name'
290
+ ]);
291
+ expect(query.select).toHaveBeenCalledWith([
292
+ 'form.name',
293
+ 'form.companyId'
294
+ ]);
295
+ });
296
+ it('does not append companyId when company feature is disabled with a custom select', async ()=>{
297
+ await buildService(false);
298
+ const query = {
299
+ select: jest.fn().mockReturnThis()
300
+ };
301
+ await service.getSelectQuery(query, buildMockUser(), [
302
+ 'name'
303
+ ]);
304
+ expect(query.select).toHaveBeenCalledWith([
305
+ 'form.name'
306
+ ]);
307
+ });
308
+ });
309
+ // ==========================================================================
310
+ // getFilterQuery — tag filtering across DB drivers
311
+ // ==========================================================================
312
+ describe('getFilterQuery', ()=>{
313
+ function buildQueryStub() {
314
+ return {
315
+ andWhere: jest.fn().mockReturnThis()
316
+ };
317
+ }
318
+ it('delegates directly to the base filter query when no tags filter is present', async ()=>{
319
+ await buildService(false);
320
+ const query = buildQueryStub();
321
+ await service.getFilterQuery(query, {
322
+ isActive: true
323
+ }, buildMockUser());
324
+ expect(query.andWhere).toHaveBeenCalledWith('form.isActive = :value', {
325
+ value: true
326
+ });
327
+ });
328
+ it('ignores an empty tags array (no tag filter applied)', async ()=>{
329
+ await buildService(false);
330
+ const query = buildQueryStub();
331
+ await service.getFilterQuery(query, {
332
+ tags: []
333
+ }, buildMockUser());
334
+ expect(query.andWhere).not.toHaveBeenCalled();
335
+ });
336
+ it('coerces a single non-array tag value into a one-item list', async ()=>{
337
+ await buildService(false);
338
+ jest.spyOn(envConfig, 'getTypeOrmConfig').mockReturnValue({
339
+ type: 'mysql'
340
+ });
341
+ const query = buildQueryStub();
342
+ await service.getFilterQuery(query, {
343
+ tags: 'survey'
344
+ }, buildMockUser());
345
+ expect(query.andWhere).toHaveBeenCalledWith('JSON_CONTAINS(form.tags, :tagValues)', {
346
+ tagValues: JSON.stringify([
347
+ 'survey'
348
+ ])
349
+ });
350
+ });
351
+ it('uses jsonb containment for postgres', async ()=>{
352
+ await buildService(false);
353
+ jest.spyOn(envConfig, 'getTypeOrmConfig').mockReturnValue({
354
+ type: 'postgres'
355
+ });
356
+ const query = buildQueryStub();
357
+ await service.getFilterQuery(query, {
358
+ tags: [
359
+ 'survey',
360
+ 'review'
361
+ ]
362
+ }, buildMockUser());
363
+ expect(query.andWhere).toHaveBeenCalledWith('form.tags @> :tagValues::jsonb', {
364
+ tagValues: JSON.stringify([
365
+ 'survey',
366
+ 'review'
367
+ ])
368
+ });
369
+ });
370
+ it('uses JSON_CONTAINS for mysql', async ()=>{
371
+ await buildService(false);
372
+ jest.spyOn(envConfig, 'getTypeOrmConfig').mockReturnValue({
373
+ type: 'mysql'
374
+ });
375
+ const query = buildQueryStub();
376
+ await service.getFilterQuery(query, {
377
+ tags: [
378
+ 'survey',
379
+ 'review'
380
+ ]
381
+ }, buildMockUser());
382
+ expect(query.andWhere).toHaveBeenCalledWith('JSON_CONTAINS(form.tags, :tagValues)', {
383
+ tagValues: JSON.stringify([
384
+ 'survey',
385
+ 'review'
386
+ ])
387
+ });
388
+ });
389
+ it('falls back to per-tag LIKE matching for other drivers (e.g. sqlite)', async ()=>{
390
+ await buildService(false);
391
+ jest.spyOn(envConfig, 'getTypeOrmConfig').mockReturnValue({
392
+ type: 'sqlite'
393
+ });
394
+ const query = buildQueryStub();
395
+ await service.getFilterQuery(query, {
396
+ tags: [
397
+ 'survey',
398
+ 'review'
399
+ ]
400
+ }, buildMockUser());
401
+ expect(query.andWhere).toHaveBeenCalledWith('form.tags LIKE :tagValue0', {
402
+ tagValue0: '%"survey"%'
403
+ });
404
+ expect(query.andWhere).toHaveBeenCalledWith('form.tags LIKE :tagValue1', {
405
+ tagValue1: '%"review"%'
406
+ });
407
+ });
408
+ it('still applies remaining (non-tag) filters alongside the tag filter', async ()=>{
409
+ await buildService(false);
410
+ jest.spyOn(envConfig, 'getTypeOrmConfig').mockReturnValue({
411
+ type: 'mysql'
412
+ });
413
+ const query = buildQueryStub();
414
+ await service.getFilterQuery(query, {
415
+ tags: [
416
+ 'survey'
417
+ ],
418
+ isActive: true
419
+ }, buildMockUser());
420
+ expect(query.andWhere).toHaveBeenCalledWith('form.isActive = :value', {
421
+ value: true
422
+ });
423
+ expect(query.andWhere).toHaveBeenCalledWith('JSON_CONTAINS(form.tags, :tagValues)', {
424
+ tagValues: JSON.stringify([
425
+ 'survey'
426
+ ])
427
+ });
428
+ });
429
+ });
430
+ // ==========================================================================
431
+ // getExtraManipulateQuery — company scoping
432
+ // ==========================================================================
433
+ describe('getExtraManipulateQuery', ()=>{
434
+ it('applies a company filter when company feature is enabled and user has a companyId', async ()=>{
435
+ await buildService(true);
436
+ const query = {
437
+ andWhere: jest.fn().mockReturnThis()
438
+ };
439
+ await service.getExtraManipulateQuery(query, {}, buildMockUser({
440
+ companyId: 'company-1'
441
+ }));
442
+ expect(query.andWhere).toHaveBeenCalledWith('form.companyId = :companyId', {
443
+ companyId: 'company-1'
444
+ });
445
+ });
446
+ it('does not filter by company when company feature is disabled', async ()=>{
447
+ await buildService(false);
448
+ const query = {
449
+ andWhere: jest.fn().mockReturnThis()
450
+ };
451
+ await service.getExtraManipulateQuery(query, {}, buildMockUser({
452
+ companyId: 'company-1'
453
+ }));
454
+ expect(query.andWhere).not.toHaveBeenCalled();
455
+ });
456
+ it('does not filter by company when the user has no companyId', async ()=>{
457
+ await buildService(true);
458
+ const query = {
459
+ andWhere: jest.fn().mockReturnThis()
460
+ };
461
+ await service.getExtraManipulateQuery(query, {}, buildMockUser({
462
+ companyId: undefined
463
+ }));
464
+ expect(query.andWhere).not.toHaveBeenCalled();
465
+ });
466
+ });
467
+ // ==========================================================================
468
+ // convertEntityToResponseDto
469
+ // ==========================================================================
470
+ describe('convertEntityToResponseDto', ()=>{
471
+ it('maps entity fields to the response shape and defaults companyId to null', async ()=>{
472
+ await buildService(false);
473
+ const entity = buildForm();
474
+ const dto = service.convertEntityToResponseDto(entity, false);
475
+ expect(dto).toMatchObject({
476
+ id: entity.id,
477
+ name: entity.name,
478
+ companyId: null
479
+ });
480
+ });
481
+ it('carries through companyId when present on the entity', async ()=>{
482
+ await buildService(true);
483
+ const entity = buildForm();
484
+ entity.companyId = 'company-1';
485
+ const dto = service.convertEntityToResponseDto(entity, false);
486
+ expect(dto.companyId).toBe('company-1');
487
+ });
488
+ });
489
+ // ==========================================================================
490
+ // Public form access
491
+ // ==========================================================================
492
+ describe('getPublicForm', ()=>{
493
+ it('returns the public projection of an active public form', async ()=>{
494
+ await buildService(false);
495
+ mockRepo.findOne.mockResolvedValue(buildForm({
496
+ accessType: FormAccessType.PUBLIC
497
+ }));
498
+ const result = await service.getPublicForm('form-uuid-1');
499
+ expect(result).toEqual({
500
+ id: 'form-uuid-1',
501
+ name: 'Customer Survey',
502
+ description: null,
503
+ schema: {
504
+ sections: [],
505
+ settings: {}
506
+ },
507
+ schemaVersion: 1
508
+ });
509
+ });
510
+ it('throws NotFoundException when the form is missing, inactive, or not public', async ()=>{
511
+ await buildService(false);
512
+ mockRepo.findOne.mockResolvedValue(null);
513
+ await expect(service.getPublicForm('missing')).rejects.toMatchObject({
514
+ response: expect.objectContaining({
515
+ messageKey: 'form.not.public'
516
+ })
517
+ });
518
+ });
519
+ });
520
+ describe('getFormForSubmission', ()=>{
521
+ it('throws NotFoundException when the form does not exist or is inactive', async ()=>{
522
+ await buildService(false);
523
+ mockRepo.findOne.mockResolvedValue(null);
524
+ await expect(service.getFormForSubmission('missing', null)).rejects.toMatchObject({
525
+ response: expect.objectContaining({
526
+ messageKey: 'form.not.found'
527
+ })
528
+ });
529
+ });
530
+ it('allows anonymous access to PUBLIC forms', async ()=>{
531
+ await buildService(false);
532
+ const form = buildForm({
533
+ accessType: FormAccessType.PUBLIC
534
+ });
535
+ mockRepo.findOne.mockResolvedValue(form);
536
+ const result = await service.getFormForSubmission('form-uuid-1', null);
537
+ expect(result).toBe(form);
538
+ });
539
+ it('requires authentication for non-public forms', async ()=>{
540
+ await buildService(false);
541
+ mockRepo.findOne.mockResolvedValue(buildForm({
542
+ accessType: FormAccessType.AUTHENTICATED
543
+ }));
544
+ await expect(service.getFormForSubmission('form-uuid-1', null)).rejects.toMatchObject({
545
+ response: expect.objectContaining({
546
+ messageKey: 'form.auth.required'
547
+ })
548
+ });
549
+ });
550
+ it('allows an authenticated user to access an AUTHENTICATED form', async ()=>{
551
+ await buildService(false);
552
+ const form = buildForm({
553
+ accessType: FormAccessType.AUTHENTICATED
554
+ });
555
+ mockRepo.findOne.mockResolvedValue(form);
556
+ const result = await service.getFormForSubmission('form-uuid-1', buildMockUser());
557
+ expect(result).toBe(form);
558
+ });
559
+ it('allows an authenticated user to access an ACTION_GROUP form (permission check deferred)', async ()=>{
560
+ await buildService(false);
561
+ const form = buildForm({
562
+ accessType: FormAccessType.ACTION_GROUP,
563
+ actionGroups: [
564
+ 'hr.submit'
565
+ ]
566
+ });
567
+ mockRepo.findOne.mockResolvedValue(form);
568
+ const result = await service.getFormForSubmission('form-uuid-1', buildMockUser());
569
+ expect(result).toBe(form);
570
+ });
571
+ it('throws BadRequestException for an unrecognized access type', async ()=>{
572
+ await buildService(false);
573
+ mockRepo.findOne.mockResolvedValue(buildForm({
574
+ accessType: 'weird'
575
+ }));
576
+ await expect(service.getFormForSubmission('form-uuid-1', buildMockUser())).rejects.toMatchObject({
577
+ response: expect.objectContaining({
578
+ messageKey: 'form.invalid.access.type'
579
+ })
580
+ });
581
+ });
582
+ });
583
+ describe('getBySlug', ()=>{
584
+ it('returns the form response DTO when found', async ()=>{
585
+ await buildService(false);
586
+ mockRepo.findOne.mockResolvedValue(buildForm({
587
+ slug: 'customer-survey'
588
+ }));
589
+ const result = await service.getBySlug('customer-survey');
590
+ expect(result?.slug).toBe('customer-survey');
591
+ });
592
+ it('returns null when the slug does not match any form', async ()=>{
593
+ await buildService(false);
594
+ mockRepo.findOne.mockResolvedValue(null);
595
+ const result = await service.getBySlug('missing-slug');
596
+ expect(result).toBeNull();
597
+ });
598
+ });
599
+ describe('getPublicFormBySlug', ()=>{
600
+ it('returns the public projection when found', async ()=>{
601
+ await buildService(false);
602
+ mockRepo.findOne.mockResolvedValue(buildForm({
603
+ accessType: FormAccessType.PUBLIC,
604
+ slug: 'public-slug'
605
+ }));
606
+ const result = await service.getPublicFormBySlug('public-slug');
607
+ expect(result).toEqual(expect.objectContaining({
608
+ id: 'form-uuid-1',
609
+ name: 'Customer Survey'
610
+ }));
611
+ });
612
+ it('returns null when no public active form matches the slug', async ()=>{
613
+ await buildService(false);
614
+ mockRepo.findOne.mockResolvedValue(null);
615
+ const result = await service.getPublicFormBySlug('missing-slug');
616
+ expect(result).toBeNull();
617
+ });
618
+ });
619
+ describe('getFormAccessInfo', ()=>{
620
+ it('returns access metadata for an existing form', async ()=>{
621
+ await buildService(false);
622
+ mockRepo.findOne.mockResolvedValue(buildForm({
623
+ accessType: FormAccessType.ACTION_GROUP,
624
+ actionGroups: [
625
+ 'hr.submit'
626
+ ]
627
+ }));
628
+ const result = await service.getFormAccessInfo('form-uuid-1');
629
+ expect(result).toEqual({
630
+ id: 'form-uuid-1',
631
+ name: 'Customer Survey',
632
+ description: null,
633
+ accessType: FormAccessType.ACTION_GROUP,
634
+ actionGroups: [
635
+ 'hr.submit'
636
+ ],
637
+ isActive: true
638
+ });
639
+ });
640
+ it('throws NotFoundException when the form does not exist', async ()=>{
641
+ await buildService(false);
642
+ mockRepo.findOne.mockResolvedValue(null);
643
+ await expect(service.getFormAccessInfo('missing')).rejects.toMatchObject({
644
+ response: expect.objectContaining({
645
+ messageKey: 'form.not.found'
646
+ })
647
+ });
648
+ });
649
+ });
650
+ describe('getAuthenticatedForm', ()=>{
651
+ it('returns the public projection for an AUTHENTICATED form without checking permissions', async ()=>{
652
+ await buildService(false);
653
+ mockRepo.findOne.mockResolvedValue(buildForm({
654
+ accessType: FormAccessType.AUTHENTICATED
655
+ }));
656
+ const result = await service.getAuthenticatedForm('form-uuid-1', buildMockUser());
657
+ expect(result.id).toBe('form-uuid-1');
658
+ expect(permissionUtils.validateUserPermissions).not.toHaveBeenCalled();
659
+ });
660
+ it('returns the form when the user has one of the required action-group permissions', async ()=>{
661
+ await buildService(false);
662
+ mockRepo.findOne.mockResolvedValue(buildForm({
663
+ accessType: FormAccessType.ACTION_GROUP,
664
+ actionGroups: [
665
+ 'hr.submit'
666
+ ]
667
+ }));
668
+ permissionUtils.validateUserPermissions.mockResolvedValue(true);
669
+ const result = await service.getAuthenticatedForm('form-uuid-1', buildMockUser());
670
+ expect(result.id).toBe('form-uuid-1');
671
+ expect(permissionUtils.validateUserPermissions).toHaveBeenCalledWith(expect.any(Object), [
672
+ 'hr.submit'
673
+ ], expect.anything(), false);
674
+ });
675
+ it('throws ForbiddenException when the user lacks all action-group permissions', async ()=>{
676
+ await buildService(false);
677
+ mockRepo.findOne.mockResolvedValue(buildForm({
678
+ accessType: FormAccessType.ACTION_GROUP,
679
+ actionGroups: [
680
+ 'hr.submit'
681
+ ]
682
+ }));
683
+ permissionUtils.validateUserPermissions.mockResolvedValue(false);
684
+ await expect(service.getAuthenticatedForm('form-uuid-1', buildMockUser())).rejects.toMatchObject({
685
+ response: expect.objectContaining({
686
+ messageKey: 'form.access.denied'
687
+ })
688
+ });
689
+ });
690
+ it('skips the permission check when an ACTION_GROUP form has no configured action groups', async ()=>{
691
+ await buildService(false);
692
+ mockRepo.findOne.mockResolvedValue(buildForm({
693
+ accessType: FormAccessType.ACTION_GROUP,
694
+ actionGroups: []
695
+ }));
696
+ const result = await service.getAuthenticatedForm('form-uuid-1', buildMockUser());
697
+ expect(result.id).toBe('form-uuid-1');
698
+ expect(permissionUtils.validateUserPermissions).not.toHaveBeenCalled();
699
+ });
700
+ });
701
+ });