@htlkg/data 0.0.14 → 0.0.15

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,621 @@
1
+ /**
2
+ * Integration Tests for ProductInstance Mutations
3
+ *
4
+ * Tests the mutation functions with a more realistic GraphQL client setup
5
+ * to verify end-to-end behavior including serialization and error handling.
6
+ */
7
+
8
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
9
+ import {
10
+ createProductInstance,
11
+ updateProductInstance,
12
+ deleteProductInstance,
13
+ toggleProductInstanceEnabled,
14
+ } from './productInstances';
15
+ import { AppError } from '@htlkg/core/errors';
16
+ import * as auth from '@htlkg/core/auth';
17
+
18
+ // Mock the auth module
19
+ vi.mock('@htlkg/core/auth', () => ({
20
+ getClientUser: vi.fn(),
21
+ }));
22
+
23
+ // Mock the utils module
24
+ vi.mock('@htlkg/core/utils', () => ({
25
+ getCurrentTimestamp: vi.fn(() => '2024-01-15T12:00:00.000Z'),
26
+ }));
27
+
28
+ /**
29
+ * Create a more realistic mock GraphQL client that simulates
30
+ * AWS Amplify's behavior
31
+ */
32
+ function createMockGraphQLClient(options: {
33
+ createResponse?: any;
34
+ updateResponse?: any;
35
+ deleteResponse?: any;
36
+ shouldDelay?: boolean;
37
+ shouldFail?: boolean;
38
+ }) {
39
+ const { createResponse, updateResponse, deleteResponse, shouldDelay = false, shouldFail = false } = options;
40
+
41
+ const mockCreate = vi.fn(async (input: any) => {
42
+ if (shouldDelay) await new Promise(resolve => setTimeout(resolve, 10));
43
+ if (shouldFail) throw new Error('Network timeout');
44
+
45
+ return createResponse || {
46
+ data: {
47
+ id: `generated-${Date.now()}`,
48
+ ...input,
49
+ },
50
+ errors: null,
51
+ };
52
+ });
53
+
54
+ const mockUpdate = vi.fn(async (input: any) => {
55
+ if (shouldDelay) await new Promise(resolve => setTimeout(resolve, 10));
56
+ if (shouldFail) throw new Error('Network timeout');
57
+
58
+ return updateResponse || {
59
+ data: input,
60
+ errors: null,
61
+ };
62
+ });
63
+
64
+ const mockDelete = vi.fn(async (input: any) => {
65
+ if (shouldDelay) await new Promise(resolve => setTimeout(resolve, 10));
66
+ if (shouldFail) throw new Error('Network timeout');
67
+
68
+ return deleteResponse || {
69
+ data: { id: input.id },
70
+ errors: null,
71
+ };
72
+ });
73
+
74
+ return {
75
+ models: {
76
+ ProductInstance: {
77
+ create: mockCreate,
78
+ update: mockUpdate,
79
+ delete: mockDelete,
80
+ },
81
+ },
82
+ _mocks: {
83
+ create: mockCreate,
84
+ update: mockUpdate,
85
+ delete: mockDelete,
86
+ },
87
+ };
88
+ }
89
+
90
+ describe('ProductInstance Mutations - Integration Tests', () => {
91
+ const mockUserId = 'integration-user-789';
92
+ const mockTimestamp = '2024-01-15T12:00:00.000Z';
93
+
94
+ beforeEach(() => {
95
+ vi.clearAllMocks();
96
+ // Mock getClientUser to return a user with the expected email
97
+ vi.mocked(auth.getClientUser).mockResolvedValue({
98
+ username: 'testuser',
99
+ email: mockUserId,
100
+ brandIds: [],
101
+ accountIds: [],
102
+ isAdmin: false,
103
+ isSuperAdmin: false,
104
+ roles: [],
105
+ });
106
+ vi.useFakeTimers();
107
+ vi.setSystemTime(new Date(mockTimestamp));
108
+ });
109
+
110
+ afterEach(() => {
111
+ vi.useRealTimers();
112
+ });
113
+
114
+ describe('End-to-End Flow: Create -> Update -> Toggle -> Delete', () => {
115
+ it('should perform complete lifecycle of a product instance', async () => {
116
+ let instanceId: string;
117
+ const client = createMockGraphQLClient({});
118
+
119
+ // Step 1: Create
120
+ const created = await createProductInstance(client, {
121
+ productId: 'integration-product-1',
122
+ productName: 'Integration Test Product',
123
+ brandId: 'integration-brand-1',
124
+ accountId: 'integration-account-1',
125
+ enabled: true,
126
+ config: { testMode: true, apiKey: 'test-key' },
127
+ version: '1.0.0',
128
+ });
129
+
130
+ expect(created).toBeTruthy();
131
+ expect(created?.productId).toBe('integration-product-1');
132
+ expect(created?.updatedBy).toBe(mockUserId);
133
+ instanceId = created!.id;
134
+
135
+ // Step 2: Update configuration
136
+ const updated = await updateProductInstance(client, {
137
+ id: instanceId,
138
+ config: { testMode: false, apiKey: 'updated-key', maxRequests: 1000 },
139
+ version: '1.1.0',
140
+ });
141
+
142
+ expect(updated).toBeTruthy();
143
+ expect(updated?.id).toBe(instanceId);
144
+ expect(updated?.version).toBe('1.1.0');
145
+
146
+ // Step 3: Toggle enabled status
147
+ const toggled = await toggleProductInstanceEnabled(client, instanceId, false);
148
+
149
+ expect(toggled).toBeTruthy();
150
+ expect(toggled?.id).toBe(instanceId);
151
+ expect(toggled?.enabled).toBe(false);
152
+
153
+ // Step 4: Delete
154
+ const deleted = await deleteProductInstance(client, instanceId);
155
+
156
+ expect(deleted).toBe(true);
157
+
158
+ // Verify all operations used the correct client methods
159
+ expect(client._mocks.create).toHaveBeenCalledTimes(1);
160
+ expect(client._mocks.update).toHaveBeenCalledTimes(2); // update + toggle
161
+ expect(client._mocks.delete).toHaveBeenCalledTimes(1);
162
+ });
163
+ });
164
+
165
+ describe('Concurrent Operations', () => {
166
+ it('should handle multiple mutations in parallel', async () => {
167
+ const client = createMockGraphQLClient({});
168
+
169
+ const operations = [
170
+ createProductInstance(client, {
171
+ productId: 'concurrent-1',
172
+ productName: 'Product 1',
173
+ brandId: 'brand-1',
174
+ accountId: 'account-1',
175
+ enabled: true,
176
+ }),
177
+ createProductInstance(client, {
178
+ productId: 'concurrent-2',
179
+ productName: 'Product 2',
180
+ brandId: 'brand-1',
181
+ accountId: 'account-1',
182
+ enabled: true,
183
+ }),
184
+ createProductInstance(client, {
185
+ productId: 'concurrent-3',
186
+ productName: 'Product 3',
187
+ brandId: 'brand-1',
188
+ accountId: 'account-1',
189
+ enabled: true,
190
+ }),
191
+ ];
192
+
193
+ const results = await Promise.all(operations);
194
+
195
+ expect(results).toHaveLength(3);
196
+ results.forEach(result => {
197
+ expect(result).toBeTruthy();
198
+ expect(result?.updatedBy).toBe(mockUserId);
199
+ });
200
+ });
201
+
202
+ it('should handle mixed success and failure scenarios', async () => {
203
+ const successClient = createMockGraphQLClient({});
204
+ const failClient = createMockGraphQLClient({
205
+ createResponse: {
206
+ data: null,
207
+ errors: [{ message: 'Validation failed' }],
208
+ },
209
+ });
210
+
211
+ const results = await Promise.allSettled([
212
+ createProductInstance(successClient, {
213
+ productId: 'success-1',
214
+ productName: 'Success Product',
215
+ brandId: 'brand-1',
216
+ accountId: 'account-1',
217
+ enabled: true,
218
+ }),
219
+ createProductInstance(failClient, {
220
+ productId: 'fail-1',
221
+ productName: 'Fail Product',
222
+ brandId: 'brand-1',
223
+ accountId: 'account-1',
224
+ enabled: true,
225
+ }),
226
+ ]);
227
+
228
+ expect(results[0].status).toBe('fulfilled');
229
+ expect(results[1].status).toBe('rejected');
230
+ expect((results[1] as PromiseRejectedResult).reason).toBeInstanceOf(AppError);
231
+ });
232
+ });
233
+
234
+ describe('Complex Config Serialization', () => {
235
+ it('should handle nested objects in config', async () => {
236
+ const client = createMockGraphQLClient({});
237
+
238
+ const complexConfig = {
239
+ authentication: {
240
+ type: 'oauth',
241
+ credentials: {
242
+ clientId: 'test-client',
243
+ clientSecret: 'test-secret',
244
+ },
245
+ },
246
+ endpoints: {
247
+ production: 'https://api.example.com',
248
+ staging: 'https://staging.example.com',
249
+ },
250
+ features: ['feature1', 'feature2', 'feature3'],
251
+ metadata: {
252
+ createdAt: '2024-01-01',
253
+ tags: ['tag1', 'tag2'],
254
+ },
255
+ };
256
+
257
+ const result = await createProductInstance(client, {
258
+ productId: 'complex-config-test',
259
+ productName: 'Complex Config Product',
260
+ brandId: 'brand-1',
261
+ accountId: 'account-1',
262
+ enabled: true,
263
+ config: complexConfig,
264
+ });
265
+
266
+ // Verify the config was serialized
267
+ const createCall = client._mocks.create.mock.calls[0][0];
268
+ expect(typeof createCall.config).toBe('string');
269
+
270
+ // Verify it can be deserialized correctly
271
+ const parsedConfig = JSON.parse(createCall.config);
272
+ expect(parsedConfig).toEqual(complexConfig);
273
+ expect(parsedConfig.authentication.credentials.clientId).toBe('test-client');
274
+ expect(parsedConfig.features).toHaveLength(3);
275
+ });
276
+
277
+ it('should handle arrays in config', async () => {
278
+ const client = createMockGraphQLClient({});
279
+
280
+ const arrayConfig = {
281
+ allowedDomains: ['example.com', 'test.com', 'demo.com'],
282
+ ipWhitelist: ['192.168.1.1', '10.0.0.1'],
283
+ };
284
+
285
+ await createProductInstance(client, {
286
+ productId: 'array-config-test',
287
+ productName: 'Array Config Product',
288
+ brandId: 'brand-1',
289
+ accountId: 'account-1',
290
+ enabled: true,
291
+ config: arrayConfig,
292
+ });
293
+
294
+ const createCall = client._mocks.create.mock.calls[0][0];
295
+ const parsedConfig = JSON.parse(createCall.config);
296
+
297
+ expect(Array.isArray(parsedConfig.allowedDomains)).toBe(true);
298
+ expect(parsedConfig.allowedDomains).toHaveLength(3);
299
+ expect(parsedConfig.ipWhitelist[0]).toBe('192.168.1.1');
300
+ });
301
+
302
+ it('should handle special characters in config values', async () => {
303
+ const client = createMockGraphQLClient({});
304
+
305
+ const specialCharsConfig = {
306
+ apiKey: 'key-with-special-chars-!@#$%^&*()',
307
+ description: 'Text with "quotes" and \'apostrophes\'',
308
+ url: 'https://example.com?param=value&other=123',
309
+ };
310
+
311
+ await updateProductInstance(client, {
312
+ id: 'special-chars-test',
313
+ config: specialCharsConfig,
314
+ });
315
+
316
+ const updateCall = client._mocks.update.mock.calls[0][0];
317
+ const parsedConfig = JSON.parse(updateCall.config);
318
+
319
+ expect(parsedConfig.apiKey).toBe('key-with-special-chars-!@#$%^&*()');
320
+ expect(parsedConfig.description).toContain('quotes');
321
+ expect(parsedConfig.url).toContain('?param=value');
322
+ });
323
+ });
324
+
325
+ describe('Error Scenarios', () => {
326
+ it('should handle GraphQL errors with detailed context', async () => {
327
+ const client = createMockGraphQLClient({
328
+ createResponse: {
329
+ data: null,
330
+ errors: [
331
+ {
332
+ message: 'Product not found',
333
+ path: ['createProductInstance', 'productId'],
334
+ extensions: {
335
+ code: 'NOT_FOUND',
336
+ details: { productId: 'invalid-product-123' },
337
+ },
338
+ },
339
+ ],
340
+ },
341
+ });
342
+
343
+ try {
344
+ await createProductInstance(client, {
345
+ productId: 'invalid-product-123',
346
+ productName: 'Invalid Product',
347
+ brandId: 'brand-1',
348
+ accountId: 'account-1',
349
+ enabled: true,
350
+ });
351
+
352
+ expect.fail('Should have thrown an error');
353
+ } catch (error) {
354
+ expect(error).toBeInstanceOf(AppError);
355
+ expect((error as AppError).code).toBe('PRODUCT_INSTANCE_CREATE_ERROR');
356
+ expect((error as AppError).message).toContain('Failed to create product instance');
357
+ expect((error as AppError).details).toBeDefined();
358
+ }
359
+ });
360
+
361
+ it('should handle network timeouts', async () => {
362
+ const client = createMockGraphQLClient({
363
+ shouldFail: true,
364
+ });
365
+
366
+ try {
367
+ await updateProductInstance(client, {
368
+ id: 'timeout-test',
369
+ enabled: true,
370
+ });
371
+
372
+ expect.fail('Should have thrown an error');
373
+ } catch (error) {
374
+ expect(error).toBeInstanceOf(AppError);
375
+ expect((error as AppError).code).toBe('PRODUCT_INSTANCE_UPDATE_ERROR');
376
+ }
377
+ });
378
+
379
+ it('should handle missing required fields', async () => {
380
+ const client = createMockGraphQLClient({
381
+ createResponse: {
382
+ data: null,
383
+ errors: [
384
+ {
385
+ message: 'Variable "$input" of required type "CreateProductInstanceInput!" was not provided.',
386
+ extensions: { code: 'BAD_USER_INPUT' },
387
+ },
388
+ ],
389
+ },
390
+ });
391
+
392
+ try {
393
+ await createProductInstance(client, {
394
+ productId: '',
395
+ productName: '',
396
+ brandId: '',
397
+ accountId: '',
398
+ enabled: true,
399
+ });
400
+
401
+ expect.fail('Should have thrown an error');
402
+ } catch (error) {
403
+ expect(error).toBeInstanceOf(AppError);
404
+ }
405
+ });
406
+ });
407
+
408
+ describe('User Context and Tracking', () => {
409
+ it('should track different users across operations', async () => {
410
+ const client = createMockGraphQLClient({});
411
+
412
+ // User 1 creates
413
+ vi.mocked(auth.getClientUser).mockResolvedValueOnce({
414
+ username: 'user1',
415
+ email: 'user-001',
416
+ brandIds: [],
417
+ accountIds: [],
418
+ isAdmin: false,
419
+ isSuperAdmin: false,
420
+ roles: [],
421
+ });
422
+ await createProductInstance(client, {
423
+ productId: 'tracking-test-1',
424
+ productName: 'Tracking Product',
425
+ brandId: 'brand-1',
426
+ accountId: 'account-1',
427
+ enabled: true,
428
+ });
429
+
430
+ expect(client._mocks.create.mock.calls[0][0].updatedBy).toBe('user-001');
431
+
432
+ // User 2 updates
433
+ vi.mocked(auth.getClientUser).mockResolvedValueOnce({
434
+ username: 'user2',
435
+ email: 'user-002',
436
+ brandIds: [],
437
+ accountIds: [],
438
+ isAdmin: false,
439
+ isSuperAdmin: false,
440
+ roles: [],
441
+ });
442
+ await updateProductInstance(client, {
443
+ id: 'tracking-test-1',
444
+ enabled: false,
445
+ });
446
+
447
+ expect(client._mocks.update.mock.calls[0][0].updatedBy).toBe('user-002');
448
+
449
+ // User 3 toggles
450
+ vi.mocked(auth.getClientUser).mockResolvedValueOnce({
451
+ username: 'user3',
452
+ email: 'user-003',
453
+ brandIds: [],
454
+ accountIds: [],
455
+ isAdmin: false,
456
+ isSuperAdmin: false,
457
+ roles: [],
458
+ });
459
+ await toggleProductInstanceEnabled(client, 'tracking-test-1', true);
460
+
461
+ expect(client._mocks.update.mock.calls[1][0].updatedBy).toBe('user-003');
462
+ });
463
+
464
+ it('should handle scenarios where getClientUser returns null (unauthenticated)', async () => {
465
+ const client = createMockGraphQLClient({});
466
+
467
+ // Mock getClientUser to return null (user not authenticated)
468
+ vi.mocked(auth.getClientUser).mockResolvedValueOnce(null);
469
+
470
+ // Should still work, using "system" as fallback
471
+ const result = await createProductInstance(client, {
472
+ productId: 'auth-fail-test',
473
+ productName: 'Auth Fail Product',
474
+ brandId: 'brand-1',
475
+ accountId: 'account-1',
476
+ enabled: true,
477
+ });
478
+
479
+ expect(result).toBeTruthy();
480
+ expect(client._mocks.create.mock.calls[0][0].updatedBy).toBe('system');
481
+ });
482
+ });
483
+
484
+ describe('Performance and Edge Cases', () => {
485
+ it('should handle large config objects', async () => {
486
+ const client = createMockGraphQLClient({});
487
+
488
+ // Create a large config with 100 properties
489
+ const largeConfig: Record<string, any> = {};
490
+ for (let i = 0; i < 100; i++) {
491
+ largeConfig[`property${i}`] = {
492
+ value: `value-${i}`,
493
+ nested: {
494
+ data: Array(10).fill(i),
495
+ },
496
+ };
497
+ }
498
+
499
+ const result = await createProductInstance(client, {
500
+ productId: 'large-config-test',
501
+ productName: 'Large Config Product',
502
+ brandId: 'brand-1',
503
+ accountId: 'account-1',
504
+ enabled: true,
505
+ config: largeConfig,
506
+ });
507
+
508
+ expect(result).toBeTruthy();
509
+
510
+ const createCall = client._mocks.create.mock.calls[0][0];
511
+ const parsedConfig = JSON.parse(createCall.config);
512
+ expect(Object.keys(parsedConfig)).toHaveLength(100);
513
+ });
514
+
515
+ it('should handle rapid sequential updates', async () => {
516
+ // Use real timers for this async test
517
+ vi.useRealTimers();
518
+
519
+ const client = createMockGraphQLClient({ shouldDelay: true });
520
+
521
+ const instanceId = 'rapid-update-test';
522
+ const updates = [];
523
+
524
+ for (let i = 0; i < 5; i++) {
525
+ updates.push(
526
+ updateProductInstance(client, {
527
+ id: instanceId,
528
+ version: `1.${i}.0`,
529
+ })
530
+ );
531
+ }
532
+
533
+ const results = await Promise.all(updates);
534
+
535
+ expect(results).toHaveLength(5);
536
+ expect(client._mocks.update).toHaveBeenCalledTimes(5);
537
+
538
+ // Restore fake timers
539
+ vi.useFakeTimers();
540
+ vi.setSystemTime(new Date(mockTimestamp));
541
+ });
542
+
543
+ it('should handle empty config objects', async () => {
544
+ const client = createMockGraphQLClient({});
545
+
546
+ const result = await createProductInstance(client, {
547
+ productId: 'empty-config-test',
548
+ productName: 'Empty Config Product',
549
+ brandId: 'brand-1',
550
+ accountId: 'account-1',
551
+ enabled: true,
552
+ config: {},
553
+ });
554
+
555
+ expect(result).toBeTruthy();
556
+
557
+ const createCall = client._mocks.create.mock.calls[0][0];
558
+ expect(createCall.config).toBe('{}');
559
+ });
560
+
561
+ it('should handle undefined config', async () => {
562
+ const client = createMockGraphQLClient({});
563
+
564
+ const result = await createProductInstance(client, {
565
+ productId: 'no-config-test',
566
+ productName: 'No Config Product',
567
+ brandId: 'brand-1',
568
+ accountId: 'account-1',
569
+ enabled: true,
570
+ // No config property
571
+ });
572
+
573
+ expect(result).toBeTruthy();
574
+
575
+ const createCall = client._mocks.create.mock.calls[0][0];
576
+ expect(createCall.config).toBeUndefined();
577
+ });
578
+ });
579
+
580
+ describe('Idempotency and Retry Logic', () => {
581
+ it('should handle duplicate create requests gracefully', async () => {
582
+ const client = createMockGraphQLClient({
583
+ createResponse: {
584
+ data: null,
585
+ errors: [
586
+ {
587
+ message: 'Duplicate entry',
588
+ extensions: { code: 'DUPLICATE_ENTRY' },
589
+ },
590
+ ],
591
+ },
592
+ });
593
+
594
+ const input = {
595
+ productId: 'duplicate-test',
596
+ productName: 'Duplicate Product',
597
+ brandId: 'brand-1',
598
+ accountId: 'account-1',
599
+ enabled: true,
600
+ };
601
+
602
+ await expect(createProductInstance(client, input)).rejects.toThrow(AppError);
603
+ });
604
+
605
+ it('should support optimistic updates', async () => {
606
+ const client = createMockGraphQLClient({});
607
+
608
+ const instanceId = 'optimistic-test';
609
+
610
+ // Simulate optimistic update - fire and forget pattern
611
+ const updatePromise = toggleProductInstanceEnabled(client, instanceId, true);
612
+
613
+ // Continue with other operations without waiting
614
+ expect(updatePromise).toBeInstanceOf(Promise);
615
+
616
+ // Eventually resolve
617
+ const result = await updatePromise;
618
+ expect(result?.enabled).toBe(true);
619
+ });
620
+ });
621
+ });