@axinom/mosaic-graphql-common 0.28.0-rc.15 → 0.28.0-rc.17

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,1047 @@
1
+ import { PerformItemChangeCommand } from '@axinom/mosaic-messages';
2
+ import { StoreInboxMessage } from '@axinom/mosaic-transactional-inbox-outbox';
3
+ import { DatabaseClient } from 'pg-transactional-outbox';
4
+ import { asyncResolverImplementation } from './bulk-edit-async-plugin-factory';
5
+
6
+ // Mock the dependencies
7
+ jest.mock('@axinom/mosaic-db-common', () => ({
8
+ buildPgSettings: jest.fn(() => ({
9
+ role: 'test_role',
10
+ 'mosaic.subject': 'test_user',
11
+ })),
12
+ transactionWithContext: jest.fn(
13
+ async (_pool, _isolationLevel, _pgSettings, callback) => {
14
+ // Execute the callback with a mock transaction client
15
+ const mockTxn = {} as DatabaseClient;
16
+ await callback(mockTxn);
17
+ },
18
+ ),
19
+ }));
20
+
21
+ describe('bulk-edit-async-plugin-factory', () => {
22
+ describe('asyncResolverImplementation', () => {
23
+ let capturedMessages: PerformItemChangeCommand[];
24
+ let mockStoreInboxMessage: jest.MockedFunction<StoreInboxMessage>;
25
+ let mockOwnerPool: any;
26
+ let mockEnvOwnerPool: any;
27
+ let mockConfig: any;
28
+ let mockSubject: any;
29
+
30
+ beforeEach(() => {
31
+ capturedMessages = [];
32
+
33
+ // Mock storeInboxMessage to capture messages
34
+ mockStoreInboxMessage = jest
35
+ .fn()
36
+ .mockImplementation(
37
+ async (
38
+ _aggregateId: string,
39
+ _messageSettings: any,
40
+ payload: PerformItemChangeCommand,
41
+ _client: DatabaseClient,
42
+ _options?: any,
43
+ ) => {
44
+ capturedMessages.push(payload);
45
+ },
46
+ );
47
+
48
+ mockOwnerPool = {};
49
+ mockEnvOwnerPool = {};
50
+ mockConfig = {
51
+ serviceId: 'test-service',
52
+ dbOwner: 'test_owner',
53
+ dbEnvOwner: 'test_env_owner',
54
+ };
55
+ mockSubject = {
56
+ userId: 'test-user-id',
57
+ tenantId: 'test-tenant-id',
58
+ };
59
+ });
60
+
61
+ afterEach(() => {
62
+ jest.clearAllMocks();
63
+ });
64
+
65
+ describe('SET_FIELD_VALUES action', () => {
66
+ it('should generate SET_FIELD_VALUES message with correct structure', async () => {
67
+ // Arrange
68
+ const payload = {
69
+ set: {
70
+ mainTableName: 'images',
71
+ gqlFieldNameToColumnNameMap: {
72
+ title: 'title',
73
+ description: 'description',
74
+ },
75
+ inputData: {
76
+ title: 'Updated Title',
77
+ description: 'Updated Description',
78
+ },
79
+ },
80
+ clears: {},
81
+ removals: {},
82
+ additions: {},
83
+ };
84
+
85
+ // Act
86
+ await asyncResolverImplementation(
87
+ payload,
88
+ ['image-123'],
89
+ mockConfig,
90
+ mockOwnerPool,
91
+ mockEnvOwnerPool,
92
+ 'long-lived-token',
93
+ mockSubject,
94
+ mockStoreInboxMessage,
95
+ );
96
+
97
+ // Assert
98
+ expect(capturedMessages).toHaveLength(1);
99
+ expect(capturedMessages[0]).toEqual({
100
+ table_name: 'images',
101
+ action: 'SET_FIELD_VALUES',
102
+ stringified_condition: JSON.stringify({ id: 'image-123' }),
103
+ stringified_payload: JSON.stringify({
104
+ title: 'Updated Title',
105
+ description: 'Updated Description',
106
+ }),
107
+ });
108
+ });
109
+
110
+ it('should handle NULL values in SET_FIELD_VALUES', async () => {
111
+ // Arrange
112
+ const payload = {
113
+ set: {
114
+ mainTableName: 'images',
115
+ gqlFieldNameToColumnNameMap: {
116
+ description: 'description',
117
+ },
118
+ inputData: {
119
+ description: null,
120
+ },
121
+ },
122
+ clears: {},
123
+ removals: {},
124
+ additions: {},
125
+ };
126
+
127
+ // Act
128
+ await asyncResolverImplementation(
129
+ payload,
130
+ ['image-123'],
131
+ mockConfig,
132
+ mockOwnerPool,
133
+ mockEnvOwnerPool,
134
+ 'token',
135
+ mockSubject,
136
+ mockStoreInboxMessage,
137
+ );
138
+
139
+ // Assert
140
+ expect(capturedMessages).toHaveLength(1);
141
+ expect(JSON.parse(capturedMessages[0].stringified_payload)).toEqual({
142
+ description: null,
143
+ });
144
+ });
145
+ });
146
+
147
+ describe('CLEAR_RELATED_ENTITIES action', () => {
148
+ it('should generate CLEAR_RELATED_ENTITIES message with correct structure', async () => {
149
+ // Arrange
150
+ const payload = {
151
+ set: {
152
+ mainTableName: 'images',
153
+ gqlFieldNameToColumnNameMap: {},
154
+ inputData: undefined,
155
+ },
156
+ clears: {
157
+ images_tags: {
158
+ parentKeyColumnName: 'image_id',
159
+ shouldClear: true,
160
+ },
161
+ },
162
+ removals: {},
163
+ additions: {},
164
+ };
165
+
166
+ // Act
167
+ await asyncResolverImplementation(
168
+ payload,
169
+ ['image-123'],
170
+ mockConfig,
171
+ mockOwnerPool,
172
+ mockEnvOwnerPool,
173
+ 'token',
174
+ mockSubject,
175
+ mockStoreInboxMessage,
176
+ );
177
+
178
+ // Assert
179
+ expect(capturedMessages).toHaveLength(1);
180
+ expect(capturedMessages[0]).toEqual({
181
+ table_name: 'images_tags',
182
+ action: 'CLEAR_RELATED_ENTITIES',
183
+ stringified_condition: JSON.stringify({ image_id: 'image-123' }),
184
+ stringified_payload: JSON.stringify({}),
185
+ });
186
+ });
187
+
188
+ it('should support multiple relations cleared simultaneously', async () => {
189
+ // Arrange
190
+ const payload = {
191
+ set: {
192
+ mainTableName: 'user_roles',
193
+ gqlFieldNameToColumnNameMap: {},
194
+ inputData: undefined,
195
+ },
196
+ clears: {
197
+ user_role_parent_assignments: {
198
+ parentKeyColumnName: 'user_role_id',
199
+ shouldClear: true,
200
+ },
201
+ user_role_permission_assignments: {
202
+ parentKeyColumnName: 'user_role_id',
203
+ shouldClear: true,
204
+ },
205
+ user_role_tag_assignments: {
206
+ parentKeyColumnName: 'user_role_id',
207
+ shouldClear: true,
208
+ },
209
+ },
210
+ removals: {},
211
+ additions: {},
212
+ };
213
+
214
+ // Act
215
+ await asyncResolverImplementation(
216
+ payload,
217
+ ['role-1'],
218
+ mockConfig,
219
+ mockOwnerPool,
220
+ mockEnvOwnerPool,
221
+ 'token',
222
+ mockSubject,
223
+ mockStoreInboxMessage,
224
+ );
225
+
226
+ // Assert
227
+ expect(capturedMessages).toHaveLength(3);
228
+ expect(
229
+ capturedMessages.every((m) => m.action === 'CLEAR_RELATED_ENTITIES'),
230
+ ).toBe(true);
231
+ capturedMessages.forEach((msg) => {
232
+ const condition = JSON.parse(msg.stringified_condition);
233
+ expect(condition.user_role_id).toBe('role-1');
234
+ });
235
+ });
236
+
237
+ it('should skip clear when shouldClear is false', async () => {
238
+ // Arrange
239
+ const payload = {
240
+ set: {
241
+ mainTableName: 'images',
242
+ gqlFieldNameToColumnNameMap: {},
243
+ inputData: undefined,
244
+ },
245
+ clears: {
246
+ images_tags: {
247
+ parentKeyColumnName: 'image_id',
248
+ shouldClear: false,
249
+ },
250
+ },
251
+ removals: {},
252
+ additions: {},
253
+ };
254
+
255
+ // Act
256
+ await asyncResolverImplementation(
257
+ payload,
258
+ ['image-123'],
259
+ mockConfig,
260
+ mockOwnerPool,
261
+ mockEnvOwnerPool,
262
+ 'token',
263
+ mockSubject,
264
+ mockStoreInboxMessage,
265
+ );
266
+
267
+ // Assert
268
+ expect(capturedMessages).toHaveLength(0);
269
+ });
270
+ });
271
+
272
+ describe('REMOVE_RELATED_ENTITY action', () => {
273
+ it('should generate REMOVE_RELATED_ENTITY message with correct structure', async () => {
274
+ // Arrange
275
+ const payload = {
276
+ set: {
277
+ mainTableName: 'images',
278
+ gqlFieldNameToColumnNameMap: {},
279
+ inputData: undefined,
280
+ },
281
+ clears: {},
282
+ removals: {
283
+ images_tags: {
284
+ parentKeyColumnName: 'image_id',
285
+ fkGqlFieldNameToColumnNameMap: {
286
+ name: 'name',
287
+ },
288
+ inputData: [{ name: 'tag-to-remove' }],
289
+ },
290
+ },
291
+ additions: {},
292
+ };
293
+
294
+ // Act
295
+ await asyncResolverImplementation(
296
+ payload,
297
+ ['image-123'],
298
+ mockConfig,
299
+ mockOwnerPool,
300
+ mockEnvOwnerPool,
301
+ 'token',
302
+ mockSubject,
303
+ mockStoreInboxMessage,
304
+ );
305
+
306
+ // Assert
307
+ expect(capturedMessages).toHaveLength(1);
308
+ expect(capturedMessages[0]).toEqual({
309
+ table_name: 'images_tags',
310
+ action: 'REMOVE_RELATED_ENTITY',
311
+ stringified_condition: '',
312
+ stringified_payload: JSON.stringify({
313
+ image_id: 'image-123',
314
+ name: 'tag-to-remove',
315
+ }),
316
+ });
317
+ });
318
+
319
+ it('should generate multiple REMOVE messages for multiple items', async () => {
320
+ // Arrange
321
+ const payload = {
322
+ set: {
323
+ mainTableName: 'images',
324
+ gqlFieldNameToColumnNameMap: {},
325
+ inputData: undefined,
326
+ },
327
+ clears: {},
328
+ removals: {
329
+ images_tags: {
330
+ parentKeyColumnName: 'image_id',
331
+ fkGqlFieldNameToColumnNameMap: {
332
+ name: 'name',
333
+ },
334
+ inputData: [{ name: 'tag1' }, { name: 'tag2' }, { name: 'tag3' }],
335
+ },
336
+ },
337
+ additions: {},
338
+ };
339
+
340
+ // Act
341
+ await asyncResolverImplementation(
342
+ payload,
343
+ ['image-123'],
344
+ mockConfig,
345
+ mockOwnerPool,
346
+ mockEnvOwnerPool,
347
+ 'token',
348
+ mockSubject,
349
+ mockStoreInboxMessage,
350
+ );
351
+
352
+ // Assert
353
+ expect(capturedMessages).toHaveLength(3);
354
+ expect(
355
+ capturedMessages.every((m) => m.action === 'REMOVE_RELATED_ENTITY'),
356
+ ).toBe(true);
357
+ expect(
358
+ capturedMessages.every((m) => m.table_name === 'images_tags'),
359
+ ).toBe(true);
360
+ });
361
+ });
362
+
363
+ describe('ADD_RELATED_ENTITY action', () => {
364
+ it('should generate ADD_RELATED_ENTITY message with correct structure', async () => {
365
+ // Arrange
366
+ const payload = {
367
+ set: {
368
+ mainTableName: 'images',
369
+ gqlFieldNameToColumnNameMap: {},
370
+ inputData: undefined,
371
+ },
372
+ clears: {},
373
+ removals: {},
374
+ additions: {
375
+ images_tags: {
376
+ parentKeyColumnName: 'image_id',
377
+ fkGqlFieldNameToColumnNameMap: {
378
+ name: 'name',
379
+ },
380
+ inputData: [{ name: 'new-tag' }],
381
+ },
382
+ },
383
+ };
384
+
385
+ // Act
386
+ await asyncResolverImplementation(
387
+ payload,
388
+ ['image-123'],
389
+ mockConfig,
390
+ mockOwnerPool,
391
+ mockEnvOwnerPool,
392
+ 'token',
393
+ mockSubject,
394
+ mockStoreInboxMessage,
395
+ );
396
+
397
+ // Assert
398
+ expect(capturedMessages).toHaveLength(1);
399
+ expect(capturedMessages[0]).toEqual({
400
+ table_name: 'images_tags',
401
+ action: 'ADD_RELATED_ENTITY',
402
+ stringified_condition: '',
403
+ stringified_payload: JSON.stringify({
404
+ image_id: 'image-123',
405
+ name: 'new-tag',
406
+ }),
407
+ });
408
+ });
409
+
410
+ it('should generate multiple ADD messages for multiple items', async () => {
411
+ // Arrange
412
+ const payload = {
413
+ set: {
414
+ mainTableName: 'images',
415
+ gqlFieldNameToColumnNameMap: {},
416
+ inputData: undefined,
417
+ },
418
+ clears: {},
419
+ removals: {},
420
+ additions: {
421
+ images_tags: {
422
+ parentKeyColumnName: 'image_id',
423
+ fkGqlFieldNameToColumnNameMap: {
424
+ name: 'name',
425
+ },
426
+ inputData: [{ name: 'tag1' }, { name: 'tag2' }],
427
+ },
428
+ },
429
+ };
430
+
431
+ // Act
432
+ await asyncResolverImplementation(
433
+ payload,
434
+ ['image-123'],
435
+ mockConfig,
436
+ mockOwnerPool,
437
+ mockEnvOwnerPool,
438
+ 'token',
439
+ mockSubject,
440
+ mockStoreInboxMessage,
441
+ );
442
+
443
+ // Assert
444
+ expect(capturedMessages).toHaveLength(2);
445
+ expect(
446
+ capturedMessages.every((m) => m.action === 'ADD_RELATED_ENTITY'),
447
+ ).toBe(true);
448
+ });
449
+ });
450
+
451
+ describe('Message ordering', () => {
452
+ it('should send messages in order: SET → CLEAR → REMOVE → ADD', async () => {
453
+ // Arrange
454
+ const payload = {
455
+ set: {
456
+ mainTableName: 'images',
457
+ gqlFieldNameToColumnNameMap: {
458
+ title: 'title',
459
+ },
460
+ inputData: {
461
+ title: 'Updated',
462
+ },
463
+ },
464
+ clears: {
465
+ images_tags: {
466
+ parentKeyColumnName: 'image_id',
467
+ shouldClear: true,
468
+ },
469
+ },
470
+ removals: {
471
+ images_casts: {
472
+ parentKeyColumnName: 'image_id',
473
+ fkGqlFieldNameToColumnNameMap: {
474
+ castId: 'cast_id',
475
+ },
476
+ inputData: [{ castId: 'cast-1' }],
477
+ },
478
+ },
479
+ additions: {
480
+ images_licenses: {
481
+ parentKeyColumnName: 'image_id',
482
+ fkGqlFieldNameToColumnNameMap: {
483
+ licenseId: 'license_id',
484
+ },
485
+ inputData: [{ licenseId: 'license-1' }],
486
+ },
487
+ },
488
+ };
489
+
490
+ // Act
491
+ await asyncResolverImplementation(
492
+ payload,
493
+ ['img-1'],
494
+ mockConfig,
495
+ mockOwnerPool,
496
+ mockEnvOwnerPool,
497
+ 'token',
498
+ mockSubject,
499
+ mockStoreInboxMessage,
500
+ );
501
+
502
+ // Assert
503
+ expect(capturedMessages).toHaveLength(4);
504
+ expect(capturedMessages[0].action).toBe('SET_FIELD_VALUES');
505
+ expect(capturedMessages[0].table_name).toBe('images');
506
+ expect(capturedMessages[1].action).toBe('CLEAR_RELATED_ENTITIES');
507
+ expect(capturedMessages[1].table_name).toBe('images_tags');
508
+ expect(capturedMessages[2].action).toBe('REMOVE_RELATED_ENTITY');
509
+ expect(capturedMessages[2].table_name).toBe('images_casts');
510
+ expect(capturedMessages[3].action).toBe('ADD_RELATED_ENTITY');
511
+ expect(capturedMessages[3].table_name).toBe('images_licenses');
512
+ });
513
+ });
514
+
515
+ describe('Optimization: Skip REMOVE when CLEAR is present', () => {
516
+ it('should skip REMOVE messages when CLEAR is present for same relation', async () => {
517
+ // Arrange - Both clear and remove specified for same relation
518
+ const payload = {
519
+ set: {
520
+ mainTableName: 'images',
521
+ gqlFieldNameToColumnNameMap: {},
522
+ inputData: undefined,
523
+ },
524
+ clears: {
525
+ images_tags: {
526
+ parentKeyColumnName: 'image_id',
527
+ shouldClear: true,
528
+ },
529
+ },
530
+ removals: {
531
+ images_tags: {
532
+ parentKeyColumnName: 'image_id',
533
+ fkGqlFieldNameToColumnNameMap: {
534
+ name: 'name',
535
+ },
536
+ inputData: [{ name: 'tag1' }, { name: 'tag2' }],
537
+ },
538
+ },
539
+ additions: {},
540
+ };
541
+
542
+ // Act
543
+ await asyncResolverImplementation(
544
+ payload,
545
+ ['image-123'],
546
+ mockConfig,
547
+ mockOwnerPool,
548
+ mockEnvOwnerPool,
549
+ 'token',
550
+ mockSubject,
551
+ mockStoreInboxMessage,
552
+ );
553
+
554
+ // Assert - Only CLEAR message sent, no REMOVE messages
555
+ expect(capturedMessages).toHaveLength(1);
556
+ expect(capturedMessages[0].action).toBe('CLEAR_RELATED_ENTITIES');
557
+ expect(capturedMessages[0].table_name).toBe('images_tags');
558
+ });
559
+
560
+ it('should NOT skip REMOVE when CLEAR is for different relation', async () => {
561
+ // Arrange - Clear tags, remove casts
562
+ const payload = {
563
+ set: {
564
+ mainTableName: 'images',
565
+ gqlFieldNameToColumnNameMap: {},
566
+ inputData: undefined,
567
+ },
568
+ clears: {
569
+ images_tags: {
570
+ parentKeyColumnName: 'image_id',
571
+ shouldClear: true,
572
+ },
573
+ },
574
+ removals: {
575
+ images_casts: {
576
+ parentKeyColumnName: 'image_id',
577
+ fkGqlFieldNameToColumnNameMap: {
578
+ castId: 'cast_id',
579
+ },
580
+ inputData: [{ castId: 'cast-1' }],
581
+ },
582
+ },
583
+ additions: {},
584
+ };
585
+
586
+ // Act
587
+ await asyncResolverImplementation(
588
+ payload,
589
+ ['image-123'],
590
+ mockConfig,
591
+ mockOwnerPool,
592
+ mockEnvOwnerPool,
593
+ 'token',
594
+ mockSubject,
595
+ mockStoreInboxMessage,
596
+ );
597
+
598
+ // Assert - Both CLEAR and REMOVE messages sent
599
+ expect(capturedMessages).toHaveLength(2);
600
+ expect(capturedMessages[0].action).toBe('CLEAR_RELATED_ENTITIES');
601
+ expect(capturedMessages[0].table_name).toBe('images_tags');
602
+ expect(capturedMessages[1].action).toBe('REMOVE_RELATED_ENTITY');
603
+ expect(capturedMessages[1].table_name).toBe('images_casts');
604
+ });
605
+ });
606
+
607
+ describe('Multi-entity operations', () => {
608
+ it('should process all entity IDs and send messages for each', async () => {
609
+ // Arrange
610
+ const payload = {
611
+ set: {
612
+ mainTableName: 'images',
613
+ gqlFieldNameToColumnNameMap: {
614
+ title: 'title',
615
+ },
616
+ inputData: {
617
+ title: 'Updated',
618
+ },
619
+ },
620
+ clears: {
621
+ images_tags: {
622
+ parentKeyColumnName: 'image_id',
623
+ shouldClear: true,
624
+ },
625
+ },
626
+ removals: {},
627
+ additions: {},
628
+ };
629
+
630
+ // Act - 3 entities
631
+ await asyncResolverImplementation(
632
+ payload,
633
+ ['img-1', 'img-2', 'img-3'],
634
+ mockConfig,
635
+ mockOwnerPool,
636
+ mockEnvOwnerPool,
637
+ 'token',
638
+ mockSubject,
639
+ mockStoreInboxMessage,
640
+ );
641
+
642
+ // Assert - 2 messages per entity * 3 entities = 6 messages
643
+ expect(capturedMessages).toHaveLength(6);
644
+
645
+ // Verify messages for img-1
646
+ expect(capturedMessages[0].action).toBe('SET_FIELD_VALUES');
647
+ expect(JSON.parse(capturedMessages[0].stringified_condition).id).toBe(
648
+ 'img-1',
649
+ );
650
+ expect(capturedMessages[1].action).toBe('CLEAR_RELATED_ENTITIES');
651
+ expect(
652
+ JSON.parse(capturedMessages[1].stringified_condition).image_id,
653
+ ).toBe('img-1');
654
+
655
+ // Verify messages for img-2
656
+ expect(capturedMessages[2].action).toBe('SET_FIELD_VALUES');
657
+ expect(JSON.parse(capturedMessages[2].stringified_condition).id).toBe(
658
+ 'img-2',
659
+ );
660
+ expect(capturedMessages[3].action).toBe('CLEAR_RELATED_ENTITIES');
661
+ expect(
662
+ JSON.parse(capturedMessages[3].stringified_condition).image_id,
663
+ ).toBe('img-2');
664
+
665
+ // Verify messages for img-3
666
+ expect(capturedMessages[4].action).toBe('SET_FIELD_VALUES');
667
+ expect(JSON.parse(capturedMessages[4].stringified_condition).id).toBe(
668
+ 'img-3',
669
+ );
670
+ expect(capturedMessages[5].action).toBe('CLEAR_RELATED_ENTITIES');
671
+ expect(
672
+ JSON.parse(capturedMessages[5].stringified_condition).image_id,
673
+ ).toBe('img-3');
674
+ });
675
+
676
+ it('should calculate correct message count for complex bulk operation', async () => {
677
+ // Arrange - Operation with all action types
678
+ const payload = {
679
+ set: {
680
+ mainTableName: 'images',
681
+ gqlFieldNameToColumnNameMap: {
682
+ title: 'title',
683
+ },
684
+ inputData: {
685
+ title: 'Updated',
686
+ },
687
+ },
688
+ clears: {
689
+ images_tags: {
690
+ parentKeyColumnName: 'image_id',
691
+ shouldClear: true,
692
+ },
693
+ },
694
+ removals: {
695
+ images_casts: {
696
+ parentKeyColumnName: 'image_id',
697
+ fkGqlFieldNameToColumnNameMap: {
698
+ castId: 'cast_id',
699
+ },
700
+ inputData: [{ castId: 'cast-1' }],
701
+ },
702
+ },
703
+ additions: {
704
+ images_licenses: {
705
+ parentKeyColumnName: 'image_id',
706
+ fkGqlFieldNameToColumnNameMap: {
707
+ licenseId: 'license_id',
708
+ },
709
+ inputData: [{ licenseId: 'lic-1' }, { licenseId: 'lic-2' }],
710
+ },
711
+ },
712
+ };
713
+
714
+ // Act - 2 entities
715
+ await asyncResolverImplementation(
716
+ payload,
717
+ ['img-1', 'img-2'],
718
+ mockConfig,
719
+ mockOwnerPool,
720
+ mockEnvOwnerPool,
721
+ 'token',
722
+ mockSubject,
723
+ mockStoreInboxMessage,
724
+ );
725
+
726
+ // Assert
727
+ // Per entity: 1 SET + 1 CLEAR + 1 REMOVE + 2 ADD = 5 messages
728
+ // 5 messages * 2 entities = 10 total
729
+ expect(capturedMessages).toHaveLength(10);
730
+ });
731
+ });
732
+
733
+ describe('Real-world use cases', () => {
734
+ it('should support "replace all tags" operation', async () => {
735
+ // Arrange
736
+ const payload = {
737
+ set: {
738
+ mainTableName: 'images',
739
+ gqlFieldNameToColumnNameMap: {},
740
+ inputData: undefined,
741
+ },
742
+ clears: {
743
+ images_tags: {
744
+ parentKeyColumnName: 'image_id',
745
+ shouldClear: true,
746
+ },
747
+ },
748
+ removals: {},
749
+ additions: {
750
+ images_tags: {
751
+ parentKeyColumnName: 'image_id',
752
+ fkGqlFieldNameToColumnNameMap: {
753
+ name: 'name',
754
+ },
755
+ inputData: [{ name: 'new-tag-1' }, { name: 'new-tag-2' }],
756
+ },
757
+ },
758
+ };
759
+
760
+ // Act
761
+ await asyncResolverImplementation(
762
+ payload,
763
+ ['image-123'],
764
+ mockConfig,
765
+ mockOwnerPool,
766
+ mockEnvOwnerPool,
767
+ 'token',
768
+ mockSubject,
769
+ mockStoreInboxMessage,
770
+ );
771
+
772
+ // Assert - CLEAR followed by 2 ADDs
773
+ expect(capturedMessages).toHaveLength(3);
774
+ expect(capturedMessages[0].action).toBe('CLEAR_RELATED_ENTITIES');
775
+ expect(capturedMessages[1].action).toBe('ADD_RELATED_ENTITY');
776
+ expect(capturedMessages[2].action).toBe('ADD_RELATED_ENTITY');
777
+ expect(
778
+ capturedMessages.every((m) => m.table_name === 'images_tags'),
779
+ ).toBe(true);
780
+ });
781
+
782
+ it('should support "reset permissions" operation', async () => {
783
+ // Arrange
784
+ const payload = {
785
+ set: {
786
+ mainTableName: 'user_roles',
787
+ gqlFieldNameToColumnNameMap: {},
788
+ inputData: undefined,
789
+ },
790
+ clears: {
791
+ user_role_permission_assignments: {
792
+ parentKeyColumnName: 'user_role_id',
793
+ shouldClear: true,
794
+ },
795
+ },
796
+ removals: {},
797
+ additions: {
798
+ user_role_permission_assignments: {
799
+ parentKeyColumnName: 'user_role_id',
800
+ fkGqlFieldNameToColumnNameMap: {
801
+ permissionId: 'permission_id',
802
+ },
803
+ inputData: [
804
+ { permissionId: 'perm-read' },
805
+ { permissionId: 'perm-write' },
806
+ ],
807
+ },
808
+ },
809
+ };
810
+
811
+ // Act
812
+ await asyncResolverImplementation(
813
+ payload,
814
+ ['role-admin'],
815
+ mockConfig,
816
+ mockOwnerPool,
817
+ mockEnvOwnerPool,
818
+ 'token',
819
+ mockSubject,
820
+ mockStoreInboxMessage,
821
+ );
822
+
823
+ // Assert
824
+ expect(capturedMessages).toHaveLength(3);
825
+ expect(capturedMessages[0].action).toBe('CLEAR_RELATED_ENTITIES');
826
+ expect(capturedMessages[1].action).toBe('ADD_RELATED_ENTITY');
827
+ expect(capturedMessages[2].action).toBe('ADD_RELATED_ENTITY');
828
+ });
829
+
830
+ it('should support "update and clear multiple relations" operation', async () => {
831
+ // Arrange
832
+ const payload = {
833
+ set: {
834
+ mainTableName: 'images',
835
+ gqlFieldNameToColumnNameMap: {
836
+ title: 'title',
837
+ description: 'description',
838
+ },
839
+ inputData: {
840
+ title: 'Updated Title',
841
+ description: null,
842
+ },
843
+ },
844
+ clears: {
845
+ images_tags: {
846
+ parentKeyColumnName: 'image_id',
847
+ shouldClear: true,
848
+ },
849
+ images_casts: {
850
+ parentKeyColumnName: 'image_id',
851
+ shouldClear: true,
852
+ },
853
+ },
854
+ removals: {},
855
+ additions: {},
856
+ };
857
+
858
+ // Act
859
+ await asyncResolverImplementation(
860
+ payload,
861
+ ['image-123'],
862
+ mockConfig,
863
+ mockOwnerPool,
864
+ mockEnvOwnerPool,
865
+ 'token',
866
+ mockSubject,
867
+ mockStoreInboxMessage,
868
+ );
869
+
870
+ // Assert
871
+ expect(capturedMessages).toHaveLength(3);
872
+ expect(capturedMessages[0].action).toBe('SET_FIELD_VALUES');
873
+ expect(capturedMessages[0].table_name).toBe('images');
874
+ expect(capturedMessages[1].action).toBe('CLEAR_RELATED_ENTITIES');
875
+ expect(capturedMessages[2].action).toBe('CLEAR_RELATED_ENTITIES');
876
+ expect(capturedMessages[1].table_name).not.toBe(
877
+ capturedMessages[2].table_name,
878
+ );
879
+ });
880
+ });
881
+
882
+ describe('Field name mapping', () => {
883
+ it('should map GraphQL field names to SQL column names', async () => {
884
+ // Arrange - GraphQL uses camelCase, SQL uses snake_case
885
+ const payload = {
886
+ set: {
887
+ mainTableName: 'images',
888
+ gqlFieldNameToColumnNameMap: {
889
+ originalTitle: 'original_title',
890
+ externalId: 'external_id',
891
+ },
892
+ inputData: {
893
+ originalTitle: 'Test',
894
+ externalId: 'ext-123',
895
+ },
896
+ },
897
+ clears: {},
898
+ removals: {},
899
+ additions: {},
900
+ };
901
+
902
+ // Act
903
+ await asyncResolverImplementation(
904
+ payload,
905
+ ['img-1'],
906
+ mockConfig,
907
+ mockOwnerPool,
908
+ mockEnvOwnerPool,
909
+ 'token',
910
+ mockSubject,
911
+ mockStoreInboxMessage,
912
+ );
913
+
914
+ // Assert
915
+ expect(capturedMessages).toHaveLength(1);
916
+ const payload_parsed = JSON.parse(
917
+ capturedMessages[0].stringified_payload,
918
+ );
919
+ expect(payload_parsed).toEqual({
920
+ original_title: 'Test',
921
+ external_id: 'ext-123',
922
+ });
923
+ });
924
+
925
+ it('should map GraphQL field names in FK additions', async () => {
926
+ // Arrange
927
+ const payload = {
928
+ set: {
929
+ mainTableName: 'images',
930
+ gqlFieldNameToColumnNameMap: {},
931
+ inputData: undefined,
932
+ },
933
+ clears: {},
934
+ removals: {},
935
+ additions: {
936
+ images_tags: {
937
+ parentKeyColumnName: 'image_id',
938
+ fkGqlFieldNameToColumnNameMap: {
939
+ tagName: 'name',
940
+ tagValue: 'value',
941
+ },
942
+ inputData: [{ tagName: 'genre', tagValue: 'action' }],
943
+ },
944
+ },
945
+ };
946
+
947
+ // Act
948
+ await asyncResolverImplementation(
949
+ payload,
950
+ ['img-1'],
951
+ mockConfig,
952
+ mockOwnerPool,
953
+ mockEnvOwnerPool,
954
+ 'token',
955
+ mockSubject,
956
+ mockStoreInboxMessage,
957
+ );
958
+
959
+ // Assert
960
+ const payload_parsed = JSON.parse(
961
+ capturedMessages[0].stringified_payload,
962
+ );
963
+ expect(payload_parsed).toEqual({
964
+ image_id: 'img-1',
965
+ name: 'genre',
966
+ value: 'action',
967
+ });
968
+ });
969
+ });
970
+
971
+ describe('Edge cases', () => {
972
+ it('should handle empty entity IDs array', async () => {
973
+ // Arrange
974
+ const payload = {
975
+ set: {
976
+ mainTableName: 'images',
977
+ gqlFieldNameToColumnNameMap: {
978
+ title: 'title',
979
+ },
980
+ inputData: {
981
+ title: 'Updated',
982
+ },
983
+ },
984
+ clears: {},
985
+ removals: {},
986
+ additions: {},
987
+ };
988
+
989
+ // Act
990
+ await asyncResolverImplementation(
991
+ payload,
992
+ [], // Empty array
993
+ mockConfig,
994
+ mockOwnerPool,
995
+ mockEnvOwnerPool,
996
+ 'token',
997
+ mockSubject,
998
+ mockStoreInboxMessage,
999
+ );
1000
+
1001
+ // Assert - No messages sent
1002
+ expect(capturedMessages).toHaveLength(0);
1003
+ });
1004
+
1005
+ it('should handle undefined inputData arrays', async () => {
1006
+ // Arrange
1007
+ const payload = {
1008
+ set: {
1009
+ mainTableName: 'images',
1010
+ gqlFieldNameToColumnNameMap: {},
1011
+ inputData: undefined,
1012
+ },
1013
+ clears: {},
1014
+ removals: {
1015
+ images_tags: {
1016
+ parentKeyColumnName: 'image_id',
1017
+ fkGqlFieldNameToColumnNameMap: {},
1018
+ inputData: undefined, // undefined data
1019
+ },
1020
+ },
1021
+ additions: {
1022
+ images_licenses: {
1023
+ parentKeyColumnName: 'image_id',
1024
+ fkGqlFieldNameToColumnNameMap: {},
1025
+ inputData: undefined, // undefined data
1026
+ },
1027
+ },
1028
+ };
1029
+
1030
+ // Act
1031
+ await asyncResolverImplementation(
1032
+ payload,
1033
+ ['img-1'],
1034
+ mockConfig,
1035
+ mockOwnerPool,
1036
+ mockEnvOwnerPool,
1037
+ 'token',
1038
+ mockSubject,
1039
+ mockStoreInboxMessage,
1040
+ );
1041
+
1042
+ // Assert - No messages sent since all inputData is undefined
1043
+ expect(capturedMessages).toHaveLength(0);
1044
+ });
1045
+ });
1046
+ });
1047
+ });