@htlkg/data 0.0.20 → 0.0.21

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,604 @@
1
+ /**
2
+ * Contact Mutation Tests
3
+ *
4
+ * Tests for contact CRUD operations including Zod validation,
5
+ * soft delete, restore, and merge functionality.
6
+ */
7
+
8
+ import { describe, it, expect, vi, beforeEach } from "vitest";
9
+ import type { Contact } from "@htlkg/core/types";
10
+ import {
11
+ createContact,
12
+ updateContact,
13
+ softDeleteContact,
14
+ restoreContact,
15
+ deleteContact,
16
+ mergeContacts,
17
+ ContactValidationError,
18
+ } from "./contacts";
19
+
20
+ // Mock the systemSettings query functions
21
+ vi.mock("../queries/systemSettings", () => ({
22
+ checkRestoreEligibility: vi.fn((deletedAt: string | null, retentionDays: number) => {
23
+ if (!deletedAt) {
24
+ return { canRestore: false, daysRemaining: 0, daysExpired: 0 };
25
+ }
26
+ const deletedDate = new Date(deletedAt);
27
+ const now = new Date();
28
+ const diffMs = now.getTime() - deletedDate.getTime();
29
+ const diffDays = diffMs / (1000 * 60 * 60 * 24);
30
+
31
+ if (diffDays > retentionDays) {
32
+ return {
33
+ canRestore: false,
34
+ daysRemaining: 0,
35
+ daysExpired: Math.floor(diffDays - retentionDays),
36
+ };
37
+ }
38
+
39
+ return {
40
+ canRestore: true,
41
+ daysRemaining: Math.ceil(retentionDays - diffDays),
42
+ daysExpired: 0,
43
+ };
44
+ }),
45
+ DEFAULT_SOFT_DELETE_RETENTION_DAYS: 30,
46
+ }));
47
+
48
+ // Mock the contacts query functions
49
+ vi.mock("../queries/contacts", () => ({
50
+ getContact: vi.fn(),
51
+ }));
52
+
53
+ import { getContact as mockGetContact } from "../queries/contacts";
54
+
55
+ describe("Contact Mutations", () => {
56
+ beforeEach(() => {
57
+ vi.clearAllMocks();
58
+ });
59
+
60
+ describe("createContact", () => {
61
+ const validInput = {
62
+ brandId: "brand-123",
63
+ email: "john.doe@example.com",
64
+ firstName: "John",
65
+ lastName: "Doe",
66
+ gdprConsent: true,
67
+ gdprConsentDate: "2024-01-15T10:00:00Z",
68
+ };
69
+
70
+ it("should create a contact with valid input", async () => {
71
+ const mockCreate = vi.fn().mockResolvedValue({
72
+ data: { id: "contact-001", ...validInput },
73
+ errors: null,
74
+ });
75
+
76
+ const mockClient = {
77
+ models: {
78
+ Contact: { create: mockCreate },
79
+ },
80
+ };
81
+
82
+ const result = await createContact(mockClient, validInput);
83
+
84
+ expect(result).toBeTruthy();
85
+ expect(result?.id).toBe("contact-001");
86
+ expect(mockCreate).toHaveBeenCalledTimes(1);
87
+ });
88
+
89
+ it("should throw ContactValidationError for missing required fields", async () => {
90
+ const mockClient = {
91
+ models: {
92
+ Contact: { create: vi.fn() },
93
+ },
94
+ };
95
+
96
+ const invalidInput = {
97
+ brandId: "brand-123",
98
+ email: "john.doe@example.com",
99
+ // Missing firstName, lastName, gdprConsent
100
+ } as any;
101
+
102
+ await expect(createContact(mockClient, invalidInput)).rejects.toThrow(
103
+ ContactValidationError
104
+ );
105
+ });
106
+
107
+ it("should throw ContactValidationError for invalid email", async () => {
108
+ const mockClient = {
109
+ models: {
110
+ Contact: { create: vi.fn() },
111
+ },
112
+ };
113
+
114
+ const invalidInput = {
115
+ ...validInput,
116
+ email: "invalid-email",
117
+ };
118
+
119
+ await expect(createContact(mockClient, invalidInput)).rejects.toThrow(
120
+ ContactValidationError
121
+ );
122
+ });
123
+
124
+ it("should throw ContactValidationError for empty brandId", async () => {
125
+ const mockClient = {
126
+ models: {
127
+ Contact: { create: vi.fn() },
128
+ },
129
+ };
130
+
131
+ const invalidInput = {
132
+ ...validInput,
133
+ brandId: "",
134
+ };
135
+
136
+ await expect(createContact(mockClient, invalidInput)).rejects.toThrow(
137
+ ContactValidationError
138
+ );
139
+ });
140
+
141
+ it("should return null when GraphQL returns errors", async () => {
142
+ const mockCreate = vi.fn().mockResolvedValue({
143
+ data: null,
144
+ errors: [{ message: "Database error" }],
145
+ });
146
+
147
+ const mockClient = {
148
+ models: {
149
+ Contact: { create: mockCreate },
150
+ },
151
+ };
152
+
153
+ const result = await createContact(mockClient, validInput);
154
+
155
+ expect(result).toBeNull();
156
+ });
157
+
158
+ it("should accept optional fields", async () => {
159
+ const mockCreate = vi.fn().mockResolvedValue({
160
+ data: { id: "contact-001" },
161
+ errors: null,
162
+ });
163
+
164
+ const mockClient = {
165
+ models: {
166
+ Contact: { create: mockCreate },
167
+ },
168
+ };
169
+
170
+ const inputWithOptionals = {
171
+ ...validInput,
172
+ phone: "+1234567890",
173
+ locale: "en-US",
174
+ marketingOptIn: true,
175
+ preferences: { theme: "dark" },
176
+ tags: ["vip", "returning"],
177
+ totalVisits: 5,
178
+ lastVisitDate: "2024-06-01",
179
+ firstVisitDate: "2023-01-15",
180
+ legacyId: "legacy-123",
181
+ };
182
+
183
+ await createContact(mockClient, inputWithOptionals);
184
+
185
+ expect(mockCreate).toHaveBeenCalledWith(inputWithOptionals);
186
+ });
187
+ });
188
+
189
+ describe("updateContact", () => {
190
+ it("should update a contact with valid input", async () => {
191
+ const mockUpdate = vi.fn().mockResolvedValue({
192
+ data: { id: "contact-001", firstName: "Jane" },
193
+ errors: null,
194
+ });
195
+
196
+ const mockClient = {
197
+ models: {
198
+ Contact: { update: mockUpdate },
199
+ },
200
+ };
201
+
202
+ const result = await updateContact(mockClient, {
203
+ id: "contact-001",
204
+ firstName: "Jane",
205
+ });
206
+
207
+ expect(result).toBeTruthy();
208
+ expect(mockUpdate).toHaveBeenCalledWith({ id: "contact-001", firstName: "Jane" });
209
+ });
210
+
211
+ it("should throw ContactValidationError for missing id", async () => {
212
+ const mockClient = {
213
+ models: {
214
+ Contact: { update: vi.fn() },
215
+ },
216
+ };
217
+
218
+ await expect(
219
+ updateContact(mockClient, { id: "" } as any)
220
+ ).rejects.toThrow(ContactValidationError);
221
+ });
222
+
223
+ it("should throw ContactValidationError for invalid email in update", async () => {
224
+ const mockClient = {
225
+ models: {
226
+ Contact: { update: vi.fn() },
227
+ },
228
+ };
229
+
230
+ await expect(
231
+ updateContact(mockClient, {
232
+ id: "contact-001",
233
+ email: "invalid-email",
234
+ })
235
+ ).rejects.toThrow(ContactValidationError);
236
+ });
237
+
238
+ it("should return null when GraphQL returns errors", async () => {
239
+ const mockUpdate = vi.fn().mockResolvedValue({
240
+ data: null,
241
+ errors: [{ message: "Update failed" }],
242
+ });
243
+
244
+ const mockClient = {
245
+ models: {
246
+ Contact: { update: mockUpdate },
247
+ },
248
+ };
249
+
250
+ const result = await updateContact(mockClient, {
251
+ id: "contact-001",
252
+ firstName: "Jane",
253
+ });
254
+
255
+ expect(result).toBeNull();
256
+ });
257
+ });
258
+
259
+ describe("softDeleteContact", () => {
260
+ it("should set deletedAt and deletedBy", async () => {
261
+ const mockUpdate = vi.fn().mockResolvedValue({
262
+ data: { id: "contact-001" },
263
+ errors: null,
264
+ });
265
+
266
+ const mockClient = {
267
+ models: {
268
+ Contact: { update: mockUpdate },
269
+ },
270
+ };
271
+
272
+ const result = await softDeleteContact(
273
+ mockClient,
274
+ "contact-001",
275
+ "admin@example.com"
276
+ );
277
+
278
+ expect(result).toBe(true);
279
+ const callArgs = mockUpdate.mock.calls[0][0];
280
+ expect(callArgs.id).toBe("contact-001");
281
+ expect(callArgs.deletedAt).toBeDefined();
282
+ expect(callArgs.deletedBy).toBe("admin@example.com");
283
+ });
284
+
285
+ it("should return false on GraphQL error", async () => {
286
+ const mockUpdate = vi.fn().mockResolvedValue({
287
+ data: null,
288
+ errors: [{ message: "Error" }],
289
+ });
290
+
291
+ const mockClient = {
292
+ models: {
293
+ Contact: { update: mockUpdate },
294
+ },
295
+ };
296
+
297
+ const result = await softDeleteContact(
298
+ mockClient,
299
+ "contact-001",
300
+ "admin@example.com"
301
+ );
302
+
303
+ expect(result).toBe(false);
304
+ });
305
+ });
306
+
307
+ describe("restoreContact", () => {
308
+ it("should restore a recently deleted contact", async () => {
309
+ const recentDeletedAt = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(); // 5 days ago
310
+
311
+ (mockGetContact as any).mockResolvedValue({
312
+ id: "contact-001",
313
+ deletedAt: recentDeletedAt,
314
+ });
315
+
316
+ const mockUpdate = vi.fn().mockResolvedValue({
317
+ data: { id: "contact-001" },
318
+ errors: null,
319
+ });
320
+
321
+ const mockClient = {
322
+ models: {
323
+ Contact: { get: vi.fn(), update: mockUpdate },
324
+ },
325
+ };
326
+
327
+ const result = await restoreContact(mockClient, "contact-001");
328
+
329
+ expect(result.success).toBe(true);
330
+ expect(mockUpdate).toHaveBeenCalledWith({
331
+ id: "contact-001",
332
+ deletedAt: null,
333
+ deletedBy: null,
334
+ });
335
+ });
336
+
337
+ it("should reject restoration after retention period expires", async () => {
338
+ const oldDeletedAt = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(); // 60 days ago
339
+
340
+ (mockGetContact as any).mockResolvedValue({
341
+ id: "contact-001",
342
+ deletedAt: oldDeletedAt,
343
+ });
344
+
345
+ const mockClient = {
346
+ models: {
347
+ Contact: { get: vi.fn(), update: vi.fn() },
348
+ },
349
+ };
350
+
351
+ const result = await restoreContact(mockClient, "contact-001", 30);
352
+
353
+ expect(result.success).toBe(false);
354
+ expect(result.error).toContain("Retention period");
355
+ });
356
+
357
+ it("should return error when contact not found", async () => {
358
+ (mockGetContact as any).mockResolvedValue(null);
359
+
360
+ const mockClient = {
361
+ models: {
362
+ Contact: { get: vi.fn(), update: vi.fn() },
363
+ },
364
+ };
365
+
366
+ const result = await restoreContact(mockClient, "nonexistent");
367
+
368
+ expect(result.success).toBe(false);
369
+ expect(result.error).toBe("Contact not found");
370
+ });
371
+ });
372
+
373
+ describe("deleteContact", () => {
374
+ it("should permanently delete a contact", async () => {
375
+ const mockDelete = vi.fn().mockResolvedValue({
376
+ data: { id: "contact-001" },
377
+ errors: null,
378
+ });
379
+
380
+ const mockClient = {
381
+ models: {
382
+ Contact: { delete: mockDelete },
383
+ },
384
+ };
385
+
386
+ const result = await deleteContact(mockClient, "contact-001");
387
+
388
+ expect(result).toBe(true);
389
+ expect(mockDelete).toHaveBeenCalledWith({ id: "contact-001" });
390
+ });
391
+
392
+ it("should return false on GraphQL error", async () => {
393
+ const mockDelete = vi.fn().mockResolvedValue({
394
+ data: null,
395
+ errors: [{ message: "Cannot delete" }],
396
+ });
397
+
398
+ const mockClient = {
399
+ models: {
400
+ Contact: { delete: mockDelete },
401
+ },
402
+ };
403
+
404
+ const result = await deleteContact(mockClient, "contact-001");
405
+
406
+ expect(result).toBe(false);
407
+ });
408
+ });
409
+
410
+ describe("mergeContacts", () => {
411
+ const primaryContact: Contact = {
412
+ id: "contact-primary",
413
+ brandId: "brand-123",
414
+ email: "primary@example.com",
415
+ firstName: "John",
416
+ lastName: "Doe",
417
+ gdprConsent: true,
418
+ totalVisits: 5,
419
+ firstVisitDate: "2023-06-01",
420
+ lastVisitDate: "2024-01-15",
421
+ tags: ["vip"],
422
+ };
423
+
424
+ const duplicateContact: Contact = {
425
+ id: "contact-dup-1",
426
+ brandId: "brand-123",
427
+ email: "duplicate@example.com",
428
+ firstName: "John",
429
+ lastName: "Doe",
430
+ gdprConsent: true,
431
+ totalVisits: 3,
432
+ firstVisitDate: "2023-01-15",
433
+ lastVisitDate: "2024-06-01",
434
+ tags: ["returning"],
435
+ };
436
+
437
+ it("should merge contacts and aggregate data", async () => {
438
+ // Mock getContact to return contacts
439
+ (mockGetContact as any)
440
+ .mockResolvedValueOnce(primaryContact)
441
+ .mockResolvedValueOnce(duplicateContact);
442
+
443
+ const mockUpdate = vi.fn().mockResolvedValue({
444
+ data: { id: "contact-primary" },
445
+ errors: null,
446
+ });
447
+
448
+ const mockClient = {
449
+ models: {
450
+ Contact: { update: mockUpdate },
451
+ },
452
+ };
453
+
454
+ const result = await mergeContacts(
455
+ mockClient,
456
+ {
457
+ primaryId: "contact-primary",
458
+ duplicateIds: ["contact-dup-1"],
459
+ },
460
+ "admin@example.com"
461
+ );
462
+
463
+ expect(result.success).toBe(true);
464
+ expect(result.deletedIds).toContain("contact-dup-1");
465
+
466
+ // Verify aggregated data
467
+ const updateCall = mockUpdate.mock.calls[0][0];
468
+ expect(updateCall.totalVisits).toBe(8); // 5 + 3
469
+ expect(updateCall.firstVisitDate).toBe("2023-01-15"); // Earlier date
470
+ expect(updateCall.lastVisitDate).toBe("2024-06-01"); // Later date
471
+ expect(updateCall.tags).toContain("vip");
472
+ expect(updateCall.tags).toContain("returning");
473
+ });
474
+
475
+ it("should throw ContactValidationError for missing primaryId", async () => {
476
+ const mockClient = {
477
+ models: {
478
+ Contact: { update: vi.fn() },
479
+ },
480
+ };
481
+
482
+ await expect(
483
+ mergeContacts(
484
+ mockClient,
485
+ { primaryId: "", duplicateIds: ["contact-dup-1"] },
486
+ "admin@example.com"
487
+ )
488
+ ).rejects.toThrow(ContactValidationError);
489
+ });
490
+
491
+ it("should throw ContactValidationError for empty duplicateIds", async () => {
492
+ const mockClient = {
493
+ models: {
494
+ Contact: { update: vi.fn() },
495
+ },
496
+ };
497
+
498
+ await expect(
499
+ mergeContacts(
500
+ mockClient,
501
+ { primaryId: "contact-primary", duplicateIds: [] },
502
+ "admin@example.com"
503
+ )
504
+ ).rejects.toThrow(ContactValidationError);
505
+ });
506
+
507
+ it("should return error when primary contact not found", async () => {
508
+ (mockGetContact as any).mockResolvedValueOnce(null);
509
+
510
+ const mockClient = {
511
+ models: {
512
+ Contact: { update: vi.fn() },
513
+ },
514
+ };
515
+
516
+ const result = await mergeContacts(
517
+ mockClient,
518
+ {
519
+ primaryId: "nonexistent",
520
+ duplicateIds: ["contact-dup-1"],
521
+ },
522
+ "admin@example.com"
523
+ );
524
+
525
+ expect(result.success).toBe(false);
526
+ expect(result.error).toContain("Primary contact not found");
527
+ });
528
+
529
+ it("should return error when duplicate contact not found", async () => {
530
+ (mockGetContact as any)
531
+ .mockResolvedValueOnce(primaryContact)
532
+ .mockResolvedValueOnce(null);
533
+
534
+ const mockClient = {
535
+ models: {
536
+ Contact: { update: vi.fn() },
537
+ },
538
+ };
539
+
540
+ const result = await mergeContacts(
541
+ mockClient,
542
+ {
543
+ primaryId: "contact-primary",
544
+ duplicateIds: ["nonexistent"],
545
+ },
546
+ "admin@example.com"
547
+ );
548
+
549
+ expect(result.success).toBe(false);
550
+ expect(result.error).toContain("Duplicate contact not found");
551
+ });
552
+
553
+ it("should handle multiple duplicates", async () => {
554
+ const duplicate2: Contact = {
555
+ id: "contact-dup-2",
556
+ brandId: "brand-123",
557
+ email: "duplicate2@example.com",
558
+ firstName: "John",
559
+ lastName: "Doe",
560
+ gdprConsent: true,
561
+ totalVisits: 2,
562
+ firstVisitDate: "2024-01-01",
563
+ lastVisitDate: "2024-07-01",
564
+ tags: ["newsletter"],
565
+ };
566
+
567
+ (mockGetContact as any)
568
+ .mockResolvedValueOnce(primaryContact)
569
+ .mockResolvedValueOnce(duplicateContact)
570
+ .mockResolvedValueOnce(duplicate2);
571
+
572
+ const mockUpdate = vi.fn().mockResolvedValue({
573
+ data: { id: "contact-primary" },
574
+ errors: null,
575
+ });
576
+
577
+ const mockClient = {
578
+ models: {
579
+ Contact: { update: mockUpdate },
580
+ },
581
+ };
582
+
583
+ const result = await mergeContacts(
584
+ mockClient,
585
+ {
586
+ primaryId: "contact-primary",
587
+ duplicateIds: ["contact-dup-1", "contact-dup-2"],
588
+ },
589
+ "admin@example.com"
590
+ );
591
+
592
+ expect(result.success).toBe(true);
593
+
594
+ // Verify aggregated data from all contacts
595
+ const updateCall = mockUpdate.mock.calls[0][0];
596
+ expect(updateCall.totalVisits).toBe(10); // 5 + 3 + 2
597
+ expect(updateCall.firstVisitDate).toBe("2023-01-15"); // Earliest
598
+ expect(updateCall.lastVisitDate).toBe("2024-07-01"); // Latest
599
+ expect(updateCall.tags).toContain("vip");
600
+ expect(updateCall.tags).toContain("returning");
601
+ expect(updateCall.tags).toContain("newsletter");
602
+ });
603
+ });
604
+ });