@nextsparkjs/theme-default 0.1.0-beta.22 → 0.1.0-beta.25

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,707 @@
1
+ /**
2
+ * Unit Tests - TasksService
3
+ *
4
+ * Tests for the TasksService class methods:
5
+ * - Input validation (required fields, empty strings)
6
+ * - Data transformation (database null → undefined)
7
+ * - Default values for optional fields
8
+ * - Error handling
9
+ *
10
+ * Focus on business logic WITHOUT actual database calls.
11
+ * Database functions are mocked to isolate the service logic.
12
+ */
13
+
14
+ // Mock the database module before importing the service
15
+ jest.mock('@nextsparkjs/core/lib/db', () => ({
16
+ queryOneWithRLS: jest.fn(),
17
+ queryWithRLS: jest.fn(),
18
+ }))
19
+
20
+ import { TasksService } from '@/themes/default/entities/tasks/tasks.service'
21
+ import type { Task, TaskStatus, TaskPriority } from '@/themes/default/entities/tasks/tasks.types'
22
+
23
+ // Get the mocked functions
24
+ const mockQueryOneWithRLS = jest.requireMock('@nextsparkjs/core/lib/db').queryOneWithRLS
25
+ const mockQueryWithRLS = jest.requireMock('@nextsparkjs/core/lib/db').queryWithRLS
26
+
27
+ // Helper to create a mock database task row
28
+ const createMockDbTask = (overrides = {}) => ({
29
+ id: 'task-123',
30
+ title: 'Test Task',
31
+ description: 'Test description',
32
+ status: 'todo' as TaskStatus,
33
+ priority: 'medium' as TaskPriority,
34
+ tags: ['tag1', 'tag2'],
35
+ dueDate: '2025-12-31',
36
+ estimatedHours: 5,
37
+ completed: false,
38
+ createdAt: '2025-01-01T00:00:00Z',
39
+ updatedAt: '2025-01-01T00:00:00Z',
40
+ ...overrides,
41
+ })
42
+
43
+ describe('TasksService', () => {
44
+ beforeEach(() => {
45
+ jest.clearAllMocks()
46
+ })
47
+
48
+ // ============================================================
49
+ // getById
50
+ // ============================================================
51
+ describe('getById', () => {
52
+ describe('Input Validation', () => {
53
+ it('should throw error when id is empty', async () => {
54
+ await expect(TasksService.getById('', 'user-123'))
55
+ .rejects.toThrow('Task ID is required')
56
+ })
57
+
58
+ it('should throw error when id is whitespace only', async () => {
59
+ await expect(TasksService.getById(' ', 'user-123'))
60
+ .rejects.toThrow('Task ID is required')
61
+ })
62
+
63
+ it('should throw error when userId is empty', async () => {
64
+ await expect(TasksService.getById('task-123', ''))
65
+ .rejects.toThrow('User ID is required for authentication')
66
+ })
67
+
68
+ it('should throw error when userId is whitespace only', async () => {
69
+ await expect(TasksService.getById('task-123', ' '))
70
+ .rejects.toThrow('User ID is required for authentication')
71
+ })
72
+ })
73
+
74
+ describe('Successful Retrieval', () => {
75
+ it('should return task when found', async () => {
76
+ const mockTask = createMockDbTask()
77
+ mockQueryOneWithRLS.mockResolvedValue(mockTask)
78
+
79
+ const result = await TasksService.getById('task-123', 'user-123')
80
+
81
+ expect(result).not.toBeNull()
82
+ expect(result?.id).toBe('task-123')
83
+ expect(result?.title).toBe('Test Task')
84
+ expect(mockQueryOneWithRLS).toHaveBeenCalledWith(
85
+ expect.stringContaining('SELECT'),
86
+ ['task-123'],
87
+ 'user-123'
88
+ )
89
+ })
90
+
91
+ it('should return null when task not found', async () => {
92
+ mockQueryOneWithRLS.mockResolvedValue(null)
93
+
94
+ const result = await TasksService.getById('non-existent', 'user-123')
95
+
96
+ expect(result).toBeNull()
97
+ })
98
+ })
99
+
100
+ describe('Data Transformation', () => {
101
+ it('should transform null description to undefined', async () => {
102
+ const mockTask = createMockDbTask({ description: null })
103
+ mockQueryOneWithRLS.mockResolvedValue(mockTask)
104
+
105
+ const result = await TasksService.getById('task-123', 'user-123')
106
+
107
+ expect(result?.description).toBeUndefined()
108
+ })
109
+
110
+ it('should transform null tags to undefined', async () => {
111
+ const mockTask = createMockDbTask({ tags: null })
112
+ mockQueryOneWithRLS.mockResolvedValue(mockTask)
113
+
114
+ const result = await TasksService.getById('task-123', 'user-123')
115
+
116
+ expect(result?.tags).toBeUndefined()
117
+ })
118
+
119
+ it('should transform null dueDate to undefined', async () => {
120
+ const mockTask = createMockDbTask({ dueDate: null })
121
+ mockQueryOneWithRLS.mockResolvedValue(mockTask)
122
+
123
+ const result = await TasksService.getById('task-123', 'user-123')
124
+
125
+ expect(result?.dueDate).toBeUndefined()
126
+ })
127
+
128
+ it('should transform null estimatedHours to undefined', async () => {
129
+ const mockTask = createMockDbTask({ estimatedHours: null })
130
+ mockQueryOneWithRLS.mockResolvedValue(mockTask)
131
+
132
+ const result = await TasksService.getById('task-123', 'user-123')
133
+
134
+ expect(result?.estimatedHours).toBeUndefined()
135
+ })
136
+
137
+ it('should transform null completed to undefined', async () => {
138
+ const mockTask = createMockDbTask({ completed: null })
139
+ mockQueryOneWithRLS.mockResolvedValue(mockTask)
140
+
141
+ const result = await TasksService.getById('task-123', 'user-123')
142
+
143
+ expect(result?.completed).toBeUndefined()
144
+ })
145
+
146
+ it('should preserve non-null values', async () => {
147
+ const mockTask = createMockDbTask()
148
+ mockQueryOneWithRLS.mockResolvedValue(mockTask)
149
+
150
+ const result = await TasksService.getById('task-123', 'user-123')
151
+
152
+ expect(result?.description).toBe('Test description')
153
+ expect(result?.tags).toEqual(['tag1', 'tag2'])
154
+ expect(result?.dueDate).toBe('2025-12-31')
155
+ expect(result?.estimatedHours).toBe(5)
156
+ expect(result?.completed).toBe(false)
157
+ })
158
+ })
159
+
160
+ describe('Error Handling', () => {
161
+ it('should wrap database errors with descriptive message', async () => {
162
+ mockQueryOneWithRLS.mockRejectedValue(new Error('Database connection failed'))
163
+
164
+ await expect(TasksService.getById('task-123', 'user-123'))
165
+ .rejects.toThrow('Database connection failed')
166
+ })
167
+ })
168
+ })
169
+
170
+ // ============================================================
171
+ // list
172
+ // ============================================================
173
+ describe('list', () => {
174
+ describe('Input Validation', () => {
175
+ it('should throw error when userId is empty', async () => {
176
+ await expect(TasksService.list(''))
177
+ .rejects.toThrow('User ID is required for authentication')
178
+ })
179
+
180
+ it('should throw error when userId is whitespace only', async () => {
181
+ await expect(TasksService.list(' '))
182
+ .rejects.toThrow('User ID is required for authentication')
183
+ })
184
+ })
185
+
186
+ describe('Default Options', () => {
187
+ it('should use default limit of 10', async () => {
188
+ mockQueryWithRLS
189
+ .mockResolvedValueOnce([{ count: '5' }]) // count query
190
+ .mockResolvedValueOnce([]) // data query
191
+
192
+ await TasksService.list('user-123')
193
+
194
+ // Second call is the data query with LIMIT
195
+ const dataQueryCall = mockQueryWithRLS.mock.calls[1]
196
+ expect(dataQueryCall[0]).toContain('LIMIT')
197
+ expect(dataQueryCall[1]).toContain(10) // default limit
198
+ })
199
+
200
+ it('should use default offset of 0', async () => {
201
+ mockQueryWithRLS
202
+ .mockResolvedValueOnce([{ count: '5' }])
203
+ .mockResolvedValueOnce([])
204
+
205
+ await TasksService.list('user-123')
206
+
207
+ const dataQueryCall = mockQueryWithRLS.mock.calls[1]
208
+ expect(dataQueryCall[1]).toContain(0) // default offset
209
+ })
210
+
211
+ it('should use default orderBy createdAt DESC', async () => {
212
+ mockQueryWithRLS
213
+ .mockResolvedValueOnce([{ count: '5' }])
214
+ .mockResolvedValueOnce([])
215
+
216
+ await TasksService.list('user-123')
217
+
218
+ const dataQueryCall = mockQueryWithRLS.mock.calls[1]
219
+ expect(dataQueryCall[0]).toContain('"createdAt"')
220
+ expect(dataQueryCall[0]).toContain('DESC')
221
+ })
222
+ })
223
+
224
+ describe('Filtering', () => {
225
+ it('should filter by status when provided', async () => {
226
+ mockQueryWithRLS
227
+ .mockResolvedValueOnce([{ count: '2' }])
228
+ .mockResolvedValueOnce([])
229
+
230
+ await TasksService.list('user-123', { status: 'todo' })
231
+
232
+ const countQueryCall = mockQueryWithRLS.mock.calls[0]
233
+ expect(countQueryCall[0]).toContain('status = $1')
234
+ expect(countQueryCall[1]).toContain('todo')
235
+ })
236
+
237
+ it('should filter by priority when provided', async () => {
238
+ mockQueryWithRLS
239
+ .mockResolvedValueOnce([{ count: '2' }])
240
+ .mockResolvedValueOnce([])
241
+
242
+ await TasksService.list('user-123', { priority: 'high' })
243
+
244
+ const countQueryCall = mockQueryWithRLS.mock.calls[0]
245
+ expect(countQueryCall[0]).toContain('priority = $1')
246
+ expect(countQueryCall[1]).toContain('high')
247
+ })
248
+
249
+ it('should filter by teamId when provided', async () => {
250
+ mockQueryWithRLS
251
+ .mockResolvedValueOnce([{ count: '2' }])
252
+ .mockResolvedValueOnce([])
253
+
254
+ await TasksService.list('user-123', { teamId: 'team-456' })
255
+
256
+ const countQueryCall = mockQueryWithRLS.mock.calls[0]
257
+ expect(countQueryCall[0]).toContain('"teamId" = $1')
258
+ expect(countQueryCall[1]).toContain('team-456')
259
+ })
260
+
261
+ it('should combine multiple filters with AND', async () => {
262
+ mockQueryWithRLS
263
+ .mockResolvedValueOnce([{ count: '1' }])
264
+ .mockResolvedValueOnce([])
265
+
266
+ await TasksService.list('user-123', { status: 'todo', priority: 'high' })
267
+
268
+ const countQueryCall = mockQueryWithRLS.mock.calls[0]
269
+ expect(countQueryCall[0]).toContain('status = $1')
270
+ expect(countQueryCall[0]).toContain('priority = $2')
271
+ expect(countQueryCall[0]).toContain('AND')
272
+ })
273
+ })
274
+
275
+ describe('Ordering', () => {
276
+ const validOrderByFields = ['title', 'status', 'priority', 'dueDate', 'createdAt']
277
+
278
+ validOrderByFields.forEach(field => {
279
+ it(`should order by ${field} when specified`, async () => {
280
+ mockQueryWithRLS
281
+ .mockResolvedValueOnce([{ count: '5' }])
282
+ .mockResolvedValueOnce([])
283
+
284
+ await TasksService.list('user-123', { orderBy: field as any })
285
+
286
+ const dataQueryCall = mockQueryWithRLS.mock.calls[1]
287
+ // dueDate and createdAt need quotes in SQL
288
+ const expectedColumn = ['dueDate', 'createdAt'].includes(field)
289
+ ? `"${field}"`
290
+ : field
291
+ expect(dataQueryCall[0]).toContain(expectedColumn)
292
+ })
293
+ })
294
+
295
+ it('should fallback to createdAt for invalid orderBy', async () => {
296
+ mockQueryWithRLS
297
+ .mockResolvedValueOnce([{ count: '5' }])
298
+ .mockResolvedValueOnce([])
299
+
300
+ await TasksService.list('user-123', { orderBy: 'invalidField' as any })
301
+
302
+ const dataQueryCall = mockQueryWithRLS.mock.calls[1]
303
+ expect(dataQueryCall[0]).toContain('"createdAt"')
304
+ })
305
+
306
+ it('should order ASC when orderDir is asc', async () => {
307
+ mockQueryWithRLS
308
+ .mockResolvedValueOnce([{ count: '5' }])
309
+ .mockResolvedValueOnce([])
310
+
311
+ await TasksService.list('user-123', { orderDir: 'asc' })
312
+
313
+ const dataQueryCall = mockQueryWithRLS.mock.calls[1]
314
+ expect(dataQueryCall[0]).toContain('ASC')
315
+ })
316
+
317
+ it('should order DESC when orderDir is desc', async () => {
318
+ mockQueryWithRLS
319
+ .mockResolvedValueOnce([{ count: '5' }])
320
+ .mockResolvedValueOnce([])
321
+
322
+ await TasksService.list('user-123', { orderDir: 'desc' })
323
+
324
+ const dataQueryCall = mockQueryWithRLS.mock.calls[1]
325
+ expect(dataQueryCall[0]).toContain('DESC')
326
+ })
327
+ })
328
+
329
+ describe('Result Structure', () => {
330
+ it('should return tasks array and total count', async () => {
331
+ const mockTasks = [createMockDbTask(), createMockDbTask({ id: 'task-456' })]
332
+ mockQueryWithRLS
333
+ .mockResolvedValueOnce([{ count: '10' }])
334
+ .mockResolvedValueOnce(mockTasks)
335
+
336
+ const result = await TasksService.list('user-123')
337
+
338
+ expect(result.tasks).toHaveLength(2)
339
+ expect(result.total).toBe(10)
340
+ })
341
+
342
+ it('should transform database rows to Task type', async () => {
343
+ const mockTask = createMockDbTask({ description: null, tags: null })
344
+ mockQueryWithRLS
345
+ .mockResolvedValueOnce([{ count: '1' }])
346
+ .mockResolvedValueOnce([mockTask])
347
+
348
+ const result = await TasksService.list('user-123')
349
+
350
+ expect(result.tasks[0].description).toBeUndefined()
351
+ expect(result.tasks[0].tags).toBeUndefined()
352
+ })
353
+
354
+ it('should return empty array when no tasks found', async () => {
355
+ mockQueryWithRLS
356
+ .mockResolvedValueOnce([{ count: '0' }])
357
+ .mockResolvedValueOnce([])
358
+
359
+ const result = await TasksService.list('user-123')
360
+
361
+ expect(result.tasks).toEqual([])
362
+ expect(result.total).toBe(0)
363
+ })
364
+ })
365
+ })
366
+
367
+ // ============================================================
368
+ // getByStatus
369
+ // ============================================================
370
+ describe('getByStatus', () => {
371
+ it('should call list with status filter', async () => {
372
+ mockQueryWithRLS
373
+ .mockResolvedValueOnce([{ count: '2' }])
374
+ .mockResolvedValueOnce([createMockDbTask(), createMockDbTask({ id: 'task-456' })])
375
+
376
+ const result = await TasksService.getByStatus('user-123', 'in-progress')
377
+
378
+ expect(result).toHaveLength(2)
379
+ const countQueryCall = mockQueryWithRLS.mock.calls[0]
380
+ expect(countQueryCall[1]).toContain('in-progress')
381
+ })
382
+
383
+ it('should order by priority DESC', async () => {
384
+ mockQueryWithRLS
385
+ .mockResolvedValueOnce([{ count: '1' }])
386
+ .mockResolvedValueOnce([createMockDbTask()])
387
+
388
+ await TasksService.getByStatus('user-123', 'todo')
389
+
390
+ const dataQueryCall = mockQueryWithRLS.mock.calls[1]
391
+ expect(dataQueryCall[0]).toContain('priority')
392
+ expect(dataQueryCall[0]).toContain('DESC')
393
+ })
394
+
395
+ it('should use large limit to get all matching tasks', async () => {
396
+ mockQueryWithRLS
397
+ .mockResolvedValueOnce([{ count: '500' }])
398
+ .mockResolvedValueOnce([])
399
+
400
+ await TasksService.getByStatus('user-123', 'todo')
401
+
402
+ const dataQueryCall = mockQueryWithRLS.mock.calls[1]
403
+ expect(dataQueryCall[1]).toContain(1000) // Large limit
404
+ })
405
+ })
406
+
407
+ // ============================================================
408
+ // getOverdue
409
+ // ============================================================
410
+ describe('getOverdue', () => {
411
+ describe('Input Validation', () => {
412
+ it('should throw error when userId is empty', async () => {
413
+ await expect(TasksService.getOverdue(''))
414
+ .rejects.toThrow('User ID is required for authentication')
415
+ })
416
+
417
+ it('should throw error when userId is whitespace only', async () => {
418
+ await expect(TasksService.getOverdue(' '))
419
+ .rejects.toThrow('User ID is required for authentication')
420
+ })
421
+ })
422
+
423
+ describe('Query', () => {
424
+ it('should query tasks with dueDate before today', async () => {
425
+ mockQueryWithRLS.mockResolvedValue([])
426
+
427
+ await TasksService.getOverdue('user-123')
428
+
429
+ const queryCall = mockQueryWithRLS.mock.calls[0]
430
+ expect(queryCall[0]).toContain('"dueDate" < CURRENT_DATE')
431
+ })
432
+
433
+ it('should exclude done tasks', async () => {
434
+ mockQueryWithRLS.mockResolvedValue([])
435
+
436
+ await TasksService.getOverdue('user-123')
437
+
438
+ const queryCall = mockQueryWithRLS.mock.calls[0]
439
+ expect(queryCall[0]).toContain("status != 'done'")
440
+ })
441
+
442
+ it('should exclude completed tasks', async () => {
443
+ mockQueryWithRLS.mockResolvedValue([])
444
+
445
+ await TasksService.getOverdue('user-123')
446
+
447
+ const queryCall = mockQueryWithRLS.mock.calls[0]
448
+ expect(queryCall[0]).toContain('completed IS NULL OR completed = false')
449
+ })
450
+
451
+ it('should order by dueDate ascending', async () => {
452
+ mockQueryWithRLS.mockResolvedValue([])
453
+
454
+ await TasksService.getOverdue('user-123')
455
+
456
+ const queryCall = mockQueryWithRLS.mock.calls[0]
457
+ expect(queryCall[0]).toContain('"dueDate" ASC')
458
+ })
459
+ })
460
+
461
+ describe('Result', () => {
462
+ it('should return array of overdue tasks', async () => {
463
+ const mockTasks = [
464
+ createMockDbTask({ dueDate: '2024-01-01' }),
465
+ createMockDbTask({ id: 'task-456', dueDate: '2024-06-01' }),
466
+ ]
467
+ mockQueryWithRLS.mockResolvedValue(mockTasks)
468
+
469
+ const result = await TasksService.getOverdue('user-123')
470
+
471
+ expect(result).toHaveLength(2)
472
+ })
473
+
474
+ it('should transform database rows correctly', async () => {
475
+ const mockTask = createMockDbTask({ description: null })
476
+ mockQueryWithRLS.mockResolvedValue([mockTask])
477
+
478
+ const result = await TasksService.getOverdue('user-123')
479
+
480
+ expect(result[0].description).toBeUndefined()
481
+ })
482
+ })
483
+ })
484
+
485
+ // ============================================================
486
+ // create
487
+ // ============================================================
488
+ describe('create', () => {
489
+ describe('Input Validation', () => {
490
+ it('should throw error when userId is missing', async () => {
491
+ await expect(TasksService.create('', { title: 'Test', teamId: 'team-123' }))
492
+ .rejects.toThrow('User ID is required')
493
+ })
494
+
495
+ it('should throw error when title is missing', async () => {
496
+ await expect(TasksService.create('user-123', { teamId: 'team-123' } as any))
497
+ .rejects.toThrow('Title is required')
498
+ })
499
+
500
+ it('should throw error when teamId is missing', async () => {
501
+ await expect(TasksService.create('user-123', { title: 'Test' } as any))
502
+ .rejects.toThrow('Team ID is required')
503
+ })
504
+ })
505
+
506
+ describe('Default Values', () => {
507
+ it('should use default status of todo', async () => {
508
+ mockQueryOneWithRLS.mockResolvedValue(createMockDbTask())
509
+
510
+ await TasksService.create('user-123', { title: 'Test', teamId: 'team-123' })
511
+
512
+ const insertCall = mockQueryOneWithRLS.mock.calls[0]
513
+ expect(insertCall[1]).toContain('todo')
514
+ })
515
+
516
+ it('should use default priority of medium', async () => {
517
+ mockQueryOneWithRLS.mockResolvedValue(createMockDbTask())
518
+
519
+ await TasksService.create('user-123', { title: 'Test', teamId: 'team-123' })
520
+
521
+ const insertCall = mockQueryOneWithRLS.mock.calls[0]
522
+ expect(insertCall[1]).toContain('medium')
523
+ })
524
+
525
+ it('should use empty array for tags by default', async () => {
526
+ mockQueryOneWithRLS.mockResolvedValue(createMockDbTask())
527
+
528
+ await TasksService.create('user-123', { title: 'Test', teamId: 'team-123' })
529
+
530
+ const insertCall = mockQueryOneWithRLS.mock.calls[0]
531
+ expect(insertCall[1]).toContainEqual([])
532
+ })
533
+
534
+ it('should use false for completed by default', async () => {
535
+ mockQueryOneWithRLS.mockResolvedValue(createMockDbTask())
536
+
537
+ await TasksService.create('user-123', { title: 'Test', teamId: 'team-123' })
538
+
539
+ const insertCall = mockQueryOneWithRLS.mock.calls[0]
540
+ expect(insertCall[1]).toContain(false)
541
+ })
542
+ })
543
+
544
+ describe('Successful Creation', () => {
545
+ it('should return created task', async () => {
546
+ const mockTask = createMockDbTask({ title: 'New Task' })
547
+ mockQueryOneWithRLS.mockResolvedValue(mockTask)
548
+
549
+ const result = await TasksService.create('user-123', {
550
+ title: 'New Task',
551
+ teamId: 'team-123',
552
+ })
553
+
554
+ expect(result.id).toBe('task-123')
555
+ expect(result.title).toBe('New Task')
556
+ })
557
+
558
+ it('should pass userId and teamId to database', async () => {
559
+ mockQueryOneWithRLS.mockResolvedValue(createMockDbTask())
560
+
561
+ await TasksService.create('user-123', {
562
+ title: 'Test',
563
+ teamId: 'team-456',
564
+ })
565
+
566
+ const insertCall = mockQueryOneWithRLS.mock.calls[0]
567
+ expect(insertCall[1]).toContain('user-123')
568
+ expect(insertCall[1]).toContain('team-456')
569
+ })
570
+
571
+ it('should include all provided fields', async () => {
572
+ mockQueryOneWithRLS.mockResolvedValue(createMockDbTask())
573
+
574
+ await TasksService.create('user-123', {
575
+ title: 'Full Task',
576
+ teamId: 'team-123',
577
+ description: 'A description',
578
+ status: 'in-progress',
579
+ priority: 'high',
580
+ tags: ['urgent', 'important'],
581
+ dueDate: '2025-12-31',
582
+ estimatedHours: 10,
583
+ })
584
+
585
+ const insertCall = mockQueryOneWithRLS.mock.calls[0]
586
+ expect(insertCall[1]).toContain('Full Task')
587
+ expect(insertCall[1]).toContain('A description')
588
+ expect(insertCall[1]).toContain('in-progress')
589
+ expect(insertCall[1]).toContain('high')
590
+ expect(insertCall[1]).toContainEqual(['urgent', 'important'])
591
+ expect(insertCall[1]).toContain('2025-12-31')
592
+ expect(insertCall[1]).toContain(10)
593
+ })
594
+ })
595
+
596
+ describe('Error Handling', () => {
597
+ it('should throw error when database insert fails', async () => {
598
+ mockQueryOneWithRLS.mockResolvedValue(null)
599
+
600
+ await expect(TasksService.create('user-123', {
601
+ title: 'Test',
602
+ teamId: 'team-123',
603
+ })).rejects.toThrow('Failed to create task')
604
+ })
605
+ })
606
+ })
607
+
608
+ // ============================================================
609
+ // update
610
+ // ============================================================
611
+ describe('update', () => {
612
+ describe('Input Validation', () => {
613
+ it('should throw error when userId is missing', async () => {
614
+ await expect(TasksService.update('', 'task-123', { title: 'Updated' }))
615
+ .rejects.toThrow('User ID is required')
616
+ })
617
+
618
+ it('should throw error when id is missing', async () => {
619
+ await expect(TasksService.update('user-123', '', { title: 'Updated' }))
620
+ .rejects.toThrow('Task ID is required')
621
+ })
622
+ })
623
+
624
+ describe('Partial Updates', () => {
625
+ it('should only update provided fields', async () => {
626
+ mockQueryOneWithRLS.mockResolvedValue(createMockDbTask({ title: 'Updated Title' }))
627
+
628
+ await TasksService.update('user-123', 'task-123', { title: 'Updated Title' })
629
+
630
+ const updateCall = mockQueryOneWithRLS.mock.calls[0]
631
+ expect(updateCall[0]).toContain('title = $2')
632
+ expect(updateCall[0]).not.toContain('description =')
633
+ expect(updateCall[0]).not.toContain('status =')
634
+ })
635
+
636
+ it('should always update updatedAt timestamp', async () => {
637
+ mockQueryOneWithRLS.mockResolvedValue(createMockDbTask())
638
+
639
+ await TasksService.update('user-123', 'task-123', { title: 'Updated' })
640
+
641
+ const updateCall = mockQueryOneWithRLS.mock.calls[0]
642
+ expect(updateCall[0]).toContain('"updatedAt" = NOW()')
643
+ })
644
+
645
+ it('should handle updating all fields', async () => {
646
+ mockQueryOneWithRLS.mockResolvedValue(createMockDbTask())
647
+
648
+ await TasksService.update('user-123', 'task-123', {
649
+ title: 'Updated Title',
650
+ description: 'Updated description',
651
+ status: 'done',
652
+ priority: 'urgent',
653
+ tags: ['new-tag'],
654
+ dueDate: '2026-01-01',
655
+ estimatedHours: 20,
656
+ completed: true,
657
+ })
658
+
659
+ const updateCall = mockQueryOneWithRLS.mock.calls[0]
660
+ expect(updateCall[0]).toContain('title = $2')
661
+ expect(updateCall[0]).toContain('description = $3')
662
+ expect(updateCall[0]).toContain('status = $4')
663
+ expect(updateCall[0]).toContain('priority = $5')
664
+ expect(updateCall[0]).toContain('tags = $6')
665
+ expect(updateCall[0]).toContain('"dueDate" = $7')
666
+ expect(updateCall[0]).toContain('"estimatedHours" = $8')
667
+ expect(updateCall[0]).toContain('completed = $9')
668
+ })
669
+ })
670
+
671
+ describe('Empty Update', () => {
672
+ it('should return current task when no fields to update', async () => {
673
+ const mockTask = createMockDbTask()
674
+ mockQueryOneWithRLS.mockResolvedValue(mockTask)
675
+
676
+ const result = await TasksService.update('user-123', 'task-123', {})
677
+
678
+ // When no fields, it should call getById instead
679
+ expect(result.id).toBe('task-123')
680
+ })
681
+ })
682
+
683
+ describe('Successful Update', () => {
684
+ it('should return updated task', async () => {
685
+ const updatedTask = createMockDbTask({ title: 'New Title', status: 'done' })
686
+ mockQueryOneWithRLS.mockResolvedValue(updatedTask)
687
+
688
+ const result = await TasksService.update('user-123', 'task-123', {
689
+ title: 'New Title',
690
+ status: 'done',
691
+ })
692
+
693
+ expect(result.title).toBe('New Title')
694
+ expect(result.status).toBe('done')
695
+ })
696
+ })
697
+
698
+ describe('Error Handling', () => {
699
+ it('should throw error when task not found', async () => {
700
+ mockQueryOneWithRLS.mockResolvedValue(null)
701
+
702
+ await expect(TasksService.update('user-123', 'non-existent', { title: 'Test' }))
703
+ .rejects.toThrow('Task not found or update failed')
704
+ })
705
+ })
706
+ })
707
+ })