@flusys/nestjs-event-manager 6.0.2 → 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,851 @@
1
+ import { NotFoundException } from '@nestjs/common';
2
+ import { ParticipantStatus, RecurrenceType } from '@flusys/nestjs-shared/enums';
3
+ import { createMockRepository } from '@test-utils/mocks/repository.mock';
4
+ import { buildMockUser } from '@test-utils/mocks/logged-user.mock';
5
+ import { EVENT_MESSAGES, EVENT_PARTICIPANT_MESSAGES } from '../config';
6
+ import { Event, EventParticipant, EventWithCompany } from '../entities';
7
+ import { EventService } from './event.service';
8
+ function buildEvent(overrides = {}) {
9
+ return {
10
+ id: 'event-uuid-1',
11
+ title: 'Sprint Planning',
12
+ description: null,
13
+ eventDate: '2026-04-03',
14
+ startTime: '09:00',
15
+ endTime: '10:00',
16
+ isAllDay: false,
17
+ recurrenceType: RecurrenceType.NONE,
18
+ recurrenceEndDate: null,
19
+ weekDays: null,
20
+ color: '#3B82F6',
21
+ meetingLink: null,
22
+ isActive: true,
23
+ createdAt: new Date('2026-01-01T00:00:00Z'),
24
+ updatedAt: new Date('2026-01-01T00:00:00Z'),
25
+ deletedAt: null,
26
+ createdById: null,
27
+ updatedById: null,
28
+ deletedById: null,
29
+ ...overrides
30
+ };
31
+ }
32
+ function buildParticipant(overrides = {}) {
33
+ return {
34
+ id: 'participant-uuid-1',
35
+ eventId: 'event-uuid-1',
36
+ userId: 'user-uuid-1',
37
+ status: ParticipantStatus.PENDING,
38
+ isOrganizer: false,
39
+ createdAt: new Date('2026-01-01T00:00:00Z'),
40
+ updatedAt: new Date('2026-01-01T00:00:00Z'),
41
+ deletedAt: null,
42
+ createdById: null,
43
+ updatedById: null,
44
+ deletedById: null,
45
+ ...overrides
46
+ };
47
+ }
48
+ describe('EventService', ()=>{
49
+ let service;
50
+ let mockEventRepo;
51
+ let mockParticipantRepo;
52
+ let mockConfigService;
53
+ let mockDataSourceProvider;
54
+ let mockEventQb;
55
+ const user = buildMockUser();
56
+ beforeEach(()=>{
57
+ mockEventRepo = createMockRepository();
58
+ mockParticipantRepo = createMockRepository();
59
+ mockEventQb = mockEventRepo.createQueryBuilder();
60
+ // `leftJoinAndMapMany` isn't part of the shared MockQueryBuilder.
61
+ mockEventQb.leftJoinAndMapMany = jest.fn().mockReturnThis();
62
+ mockConfigService = {
63
+ isCompanyFeatureEnabled: jest.fn().mockReturnValue(false),
64
+ getDefaultColor: jest.fn().mockReturnValue('#3B82F6'),
65
+ getMaxRecurrenceOccurrences: jest.fn().mockReturnValue(365)
66
+ };
67
+ mockDataSourceProvider = {
68
+ getRepository: jest.fn((entity)=>Promise.resolve(entity === EventParticipant ? mockParticipantRepo : mockEventRepo)),
69
+ getDataSource: jest.fn()
70
+ };
71
+ service = new EventService({}, {}, mockConfigService, mockDataSourceProvider);
72
+ });
73
+ describe('resolveEntity', ()=>{
74
+ it('resolves to the company-scoped entity when the company feature is enabled', ()=>{
75
+ mockConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
76
+ expect(service.resolveEntity()).toBe(EventWithCompany);
77
+ });
78
+ it('resolves to the core entity when the company feature is disabled', ()=>{
79
+ mockConfigService.isCompanyFeatureEnabled.mockReturnValue(false);
80
+ expect(service.resolveEntity()).toBe(Event);
81
+ });
82
+ });
83
+ describe('convertSingleDtoToEntity', ()=>{
84
+ it('builds a new entity with sensible defaults on create', async ()=>{
85
+ const dto = {
86
+ title: 'Sprint Planning',
87
+ eventDate: '2026-04-03',
88
+ startTime: '09:00',
89
+ endTime: '10:00'
90
+ };
91
+ const entity = await service.convertSingleDtoToEntity(dto, user);
92
+ expect(entity).toMatchObject({
93
+ title: 'Sprint Planning',
94
+ startTime: '09:00',
95
+ endTime: '10:00',
96
+ isAllDay: false,
97
+ recurrenceType: RecurrenceType.NONE,
98
+ recurrenceEndDate: null,
99
+ color: '#3B82F6',
100
+ isActive: true
101
+ });
102
+ });
103
+ it('forces 00:00 / 23:59 markers on create for all-day events', async ()=>{
104
+ const dto = {
105
+ title: 'Holiday',
106
+ eventDate: '2026-12-25',
107
+ startTime: '09:00',
108
+ endTime: '17:00',
109
+ isAllDay: true
110
+ };
111
+ const entity = await service.convertSingleDtoToEntity(dto, user);
112
+ expect(entity.startTime).toBe('00:00');
113
+ expect(entity.endTime).toBe('23:59');
114
+ });
115
+ it('attaches companyId from the current user when the company feature is enabled', async ()=>{
116
+ mockConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
117
+ const dto = {
118
+ title: 'Sprint Planning',
119
+ eventDate: '2026-04-03',
120
+ startTime: '09:00',
121
+ endTime: '10:00'
122
+ };
123
+ const entity = await service.convertSingleDtoToEntity(dto, user);
124
+ expect(entity.companyId).toBe(user.companyId);
125
+ });
126
+ it('queues participantIds for the after-insert hook when supplied', async ()=>{
127
+ const dto = {
128
+ title: 'Sprint Planning',
129
+ eventDate: '2026-04-03',
130
+ startTime: '09:00',
131
+ endTime: '10:00',
132
+ participantIds: [
133
+ 'participant-uuid-1'
134
+ ]
135
+ };
136
+ await service.convertSingleDtoToEntity(dto, user);
137
+ expect(service.pendingParticipantsList).toEqual([
138
+ [
139
+ 'participant-uuid-1'
140
+ ]
141
+ ]);
142
+ });
143
+ it('merges an update dto onto the existing entity, preserving untouched fields', async ()=>{
144
+ service.repository = mockEventRepo;
145
+ const existing = buildEvent({
146
+ title: 'Original Title',
147
+ color: '#111111'
148
+ });
149
+ mockEventRepo.findOne.mockResolvedValue(existing);
150
+ const dto = {
151
+ id: 'event-uuid-1',
152
+ title: 'Updated Title'
153
+ };
154
+ const entity = await service.convertSingleDtoToEntity(dto, user);
155
+ expect(entity.title).toBe('Updated Title');
156
+ expect(entity.color).toBe('#111111');
157
+ });
158
+ it('throws NotFoundException with the correct messageKey when updating a missing event', async ()=>{
159
+ service.repository = mockEventRepo;
160
+ mockEventRepo.findOne.mockResolvedValue(null);
161
+ const dto = {
162
+ id: 'missing-uuid',
163
+ title: 'Updated Title'
164
+ };
165
+ await expect(service.convertSingleDtoToEntity(dto, user)).rejects.toMatchObject({
166
+ response: expect.objectContaining({
167
+ messageKey: EVENT_MESSAGES.NOT_FOUND
168
+ })
169
+ });
170
+ });
171
+ it('clears an explicitly-null meetingLink but preserves the existing one when omitted', async ()=>{
172
+ service.repository = mockEventRepo;
173
+ const existing = buildEvent({
174
+ meetingLink: 'https://meet.example.com/original'
175
+ });
176
+ mockEventRepo.findOne.mockResolvedValue(existing);
177
+ const clearedDto = {
178
+ id: 'event-uuid-1',
179
+ meetingLink: null
180
+ };
181
+ const clearedEntity = await service.convertSingleDtoToEntity(clearedDto, user);
182
+ expect(clearedEntity.meetingLink).toBeNull();
183
+ const omittedDto = {
184
+ id: 'event-uuid-1'
185
+ };
186
+ const preservedEntity = await service.convertSingleDtoToEntity(omittedDto, user);
187
+ expect(preservedEntity.meetingLink).toBe('https://meet.example.com/original');
188
+ });
189
+ });
190
+ describe('getFilterQuery', ()=>{
191
+ it('filters by isActive when provided as a boolean', async ()=>{
192
+ const query = mockEventQb;
193
+ await service.getFilterQuery(query, {
194
+ isActive: true
195
+ }, user);
196
+ expect(query.andWhere).toHaveBeenCalledWith('event.isActive = :isActive', {
197
+ isActive: true
198
+ });
199
+ });
200
+ it('filters by recurrenceType', async ()=>{
201
+ const query = mockEventQb;
202
+ await service.getFilterQuery(query, {
203
+ recurrenceType: RecurrenceType.WEEKLY
204
+ }, user);
205
+ expect(query.andWhere).toHaveBeenCalledWith('event.recurrenceType = :recurrenceType', {
206
+ recurrenceType: RecurrenceType.WEEKLY
207
+ });
208
+ });
209
+ it('scopes to participant event ids when participantUserId is provided and matches exist', async ()=>{
210
+ const participantQb = mockParticipantRepo.createQueryBuilder();
211
+ participantQb.getRawMany = jest.fn().mockResolvedValue([
212
+ {
213
+ ep_event_id: 'event-uuid-1'
214
+ }
215
+ ]);
216
+ const query = mockEventQb;
217
+ await service.getFilterQuery(query, {
218
+ participantUserId: 'user-uuid-1'
219
+ }, user);
220
+ expect(query.andWhere).toHaveBeenCalledWith('event.id IN (:...participantEventIds)', {
221
+ participantEventIds: [
222
+ 'event-uuid-1'
223
+ ]
224
+ });
225
+ });
226
+ it('short-circuits to an always-false predicate when the user participates in no events', async ()=>{
227
+ const participantQb = mockParticipantRepo.createQueryBuilder();
228
+ participantQb.getRawMany = jest.fn().mockResolvedValue([]);
229
+ const query = mockEventQb;
230
+ await service.getFilterQuery(query, {
231
+ participantUserId: 'user-uuid-1'
232
+ }, user);
233
+ expect(query.andWhere).toHaveBeenCalledWith('1 = 0');
234
+ });
235
+ it('falls back to an equality filter for unrecognized keys', async ()=>{
236
+ const query = mockEventQb;
237
+ await service.getFilterQuery(query, {
238
+ color: '#000000'
239
+ }, user);
240
+ expect(query.andWhere).toHaveBeenCalledWith('event.color = :value', {
241
+ value: '#000000'
242
+ });
243
+ });
244
+ });
245
+ describe('getExtraManipulateQuery', ()=>{
246
+ it('applies the company filter and left-joins participants', async ()=>{
247
+ mockConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
248
+ const query = mockEventQb;
249
+ await service.getExtraManipulateQuery(query, {}, user);
250
+ expect(query.andWhere).toHaveBeenCalledWith('event.companyId = :companyId', {
251
+ companyId: user.companyId
252
+ });
253
+ expect(query.leftJoinAndMapMany).toHaveBeenCalledWith('event.participants', EventParticipant, 'ep', 'ep.event_id = event.id AND ep.deleted_at IS NULL');
254
+ });
255
+ it('does not apply a company filter when the company feature is disabled', async ()=>{
256
+ mockConfigService.isCompanyFeatureEnabled.mockReturnValue(false);
257
+ const query = mockEventQb;
258
+ await service.getExtraManipulateQuery(query, {}, user);
259
+ const companyCalls = query.andWhere.mock.calls.filter((call)=>call[0] === 'event.companyId = :companyId');
260
+ expect(companyCalls).toHaveLength(0);
261
+ });
262
+ });
263
+ describe('afterInsertOperation / afterUpdateOperation', ()=>{
264
+ it('adds the current user and requested participants as organizer + attendees on insert', async ()=>{
265
+ mockParticipantRepo.find.mockResolvedValue([]);
266
+ mockParticipantRepo.save.mockResolvedValue([]);
267
+ service.pendingParticipantsList = [
268
+ [
269
+ 'participant-uuid-2'
270
+ ]
271
+ ];
272
+ await service.afterInsertOperation([
273
+ buildEvent()
274
+ ], {}, user, {});
275
+ const savedParticipants = mockParticipantRepo.save.mock.calls[0][0];
276
+ expect(savedParticipants).toEqual(expect.arrayContaining([
277
+ expect.objectContaining({
278
+ userId: user.id,
279
+ isOrganizer: true
280
+ }),
281
+ expect.objectContaining({
282
+ userId: 'participant-uuid-2',
283
+ isOrganizer: false
284
+ })
285
+ ]));
286
+ expect(service.pendingParticipantsList).toEqual([]);
287
+ });
288
+ it('restores a previously soft-deleted participant instead of duplicating it', async ()=>{
289
+ const softDeleted = buildParticipant({
290
+ userId: user.id,
291
+ deletedAt: new Date()
292
+ });
293
+ mockParticipantRepo.find.mockResolvedValue([
294
+ softDeleted
295
+ ]);
296
+ mockParticipantRepo.save.mockResolvedValue([]);
297
+ service.pendingParticipantsList = [
298
+ []
299
+ ];
300
+ await service.afterInsertOperation([
301
+ buildEvent()
302
+ ], {}, user, {});
303
+ const savedParticipants = mockParticipantRepo.save.mock.calls[0][0];
304
+ expect(savedParticipants[0]).toMatchObject({
305
+ userId: user.id,
306
+ deletedAt: null,
307
+ isOrganizer: true
308
+ });
309
+ });
310
+ it('does nothing when there is no authenticated user', async ()=>{
311
+ service.pendingParticipantsList = [
312
+ [
313
+ 'participant-uuid-2'
314
+ ]
315
+ ];
316
+ await service.afterInsertOperation([
317
+ buildEvent()
318
+ ], {}, null, {});
319
+ expect(mockParticipantRepo.save).not.toHaveBeenCalled();
320
+ });
321
+ it('soft-deletes existing participants before re-adding the current set on update', async ()=>{
322
+ mockParticipantRepo.find.mockResolvedValue([]);
323
+ mockParticipantRepo.save.mockResolvedValue([]);
324
+ mockParticipantRepo.update.mockResolvedValue({});
325
+ service.pendingParticipantsList = [
326
+ [
327
+ 'participant-uuid-2'
328
+ ]
329
+ ];
330
+ await service.afterUpdateOperation([
331
+ buildEvent()
332
+ ], {}, user, {});
333
+ expect(mockParticipantRepo.update).toHaveBeenCalledWith({
334
+ eventId: 'event-uuid-1',
335
+ deletedAt: expect.anything()
336
+ }, {
337
+ deletedAt: expect.any(Date)
338
+ });
339
+ expect(mockParticipantRepo.save).toHaveBeenCalled();
340
+ expect(service.pendingParticipantsList).toEqual([]);
341
+ });
342
+ });
343
+ describe('updateParticipantStatus', ()=>{
344
+ it('updates the participant status and returns the saved entity', async ()=>{
345
+ const participant = buildParticipant({
346
+ status: ParticipantStatus.PENDING
347
+ });
348
+ mockParticipantRepo.findOne.mockResolvedValue(participant);
349
+ mockParticipantRepo.save.mockImplementation(async (p)=>p);
350
+ const dto = {
351
+ id: 'participant-uuid-1',
352
+ status: ParticipantStatus.TENTATIVE
353
+ };
354
+ const result = await service.updateParticipantStatus(dto, user);
355
+ expect(result.status).toBe(ParticipantStatus.TENTATIVE);
356
+ expect(mockParticipantRepo.save).toHaveBeenCalledWith(expect.objectContaining({
357
+ status: ParticipantStatus.TENTATIVE
358
+ }));
359
+ });
360
+ it('throws NotFoundException with the correct messageKey when the participant is missing', async ()=>{
361
+ mockParticipantRepo.findOne.mockResolvedValue(null);
362
+ const dto = {
363
+ id: 'missing-uuid',
364
+ status: ParticipantStatus.ACCEPTED
365
+ };
366
+ await expect(service.updateParticipantStatus(dto, user)).rejects.toMatchObject({
367
+ response: expect.objectContaining({
368
+ messageKey: EVENT_PARTICIPANT_MESSAGES.NOT_FOUND
369
+ })
370
+ });
371
+ await expect(service.updateParticipantStatus(dto, user)).rejects.toBeInstanceOf(NotFoundException);
372
+ });
373
+ });
374
+ describe('getEventsForCalendarRange', ()=>{
375
+ function queryDto(overrides = {}) {
376
+ return {
377
+ startDate: '2026-04-01',
378
+ endDate: '2026-04-30',
379
+ ...overrides
380
+ };
381
+ }
382
+ it('returns a single non-recurring event unchanged when it falls in range', async ()=>{
383
+ mockEventQb.getMany.mockResolvedValue([
384
+ buildEvent({
385
+ eventDate: '2026-04-10'
386
+ })
387
+ ]);
388
+ const result = await service.getEventsForCalendarRange(queryDto(), user);
389
+ expect(result).toHaveLength(1);
390
+ expect(result[0].id).toBe('event-uuid-1');
391
+ expect(result[0].isRecurrenceInstance).toBeUndefined();
392
+ });
393
+ it('defaults activeOnly to true when not specified', async ()=>{
394
+ mockEventQb.getMany.mockResolvedValue([]);
395
+ await service.getEventsForCalendarRange(queryDto(), user);
396
+ expect(mockEventQb.andWhere).toHaveBeenCalledWith('event.isActive = :isActive', {
397
+ isActive: true
398
+ });
399
+ });
400
+ it('respects activeOnly = false', async ()=>{
401
+ mockEventQb.getMany.mockResolvedValue([]);
402
+ await service.getEventsForCalendarRange(queryDto({
403
+ activeOnly: false
404
+ }), user);
405
+ expect(mockEventQb.andWhere).toHaveBeenCalledWith('event.isActive = :isActive', {
406
+ isActive: false
407
+ });
408
+ });
409
+ it('handles a zero-length range (startDate === endDate)', async ()=>{
410
+ mockEventQb.getMany.mockResolvedValue([
411
+ buildEvent({
412
+ eventDate: '2026-04-10'
413
+ })
414
+ ]);
415
+ const result = await service.getEventsForCalendarRange(queryDto({
416
+ startDate: '2026-04-10',
417
+ endDate: '2026-04-10'
418
+ }), user);
419
+ expect(mockEventQb.andWhere).toHaveBeenCalledWith(expect.stringContaining('event.eventDate >= :startDate'), {
420
+ startDate: '2026-04-10',
421
+ endDate: '2026-04-10',
422
+ none: RecurrenceType.NONE
423
+ });
424
+ expect(result).toHaveLength(1);
425
+ });
426
+ it('short-circuits to an empty array when filtering by a userId with no participant events', async ()=>{
427
+ const participantQb = mockParticipantRepo.createQueryBuilder();
428
+ participantQb.getRawMany = jest.fn().mockResolvedValue([]);
429
+ const result = await service.getEventsForCalendarRange(queryDto({
430
+ userId: 'user-uuid-1'
431
+ }), user);
432
+ expect(result).toEqual([]);
433
+ expect(mockEventQb.getMany).not.toHaveBeenCalled();
434
+ });
435
+ it('filters events to the participant event ids when userId matches events', async ()=>{
436
+ const participantQb = mockParticipantRepo.createQueryBuilder();
437
+ participantQb.getRawMany = jest.fn().mockResolvedValue([
438
+ {
439
+ ep_event_id: 'event-uuid-1'
440
+ }
441
+ ]);
442
+ mockEventQb.getMany.mockResolvedValue([
443
+ buildEvent()
444
+ ]);
445
+ await service.getEventsForCalendarRange(queryDto({
446
+ userId: 'user-uuid-1'
447
+ }), user);
448
+ expect(mockEventQb.andWhere).toHaveBeenCalledWith('event.id IN (:...eventIds)', {
449
+ eventIds: [
450
+ 'event-uuid-1'
451
+ ]
452
+ });
453
+ });
454
+ it('loads and attaches participants when includeParticipants is true', async ()=>{
455
+ mockEventQb.getMany.mockResolvedValue([
456
+ buildEvent()
457
+ ]);
458
+ mockParticipantRepo.find.mockResolvedValue([
459
+ buildParticipant({
460
+ eventId: 'event-uuid-1',
461
+ userId: 'user-uuid-1'
462
+ }),
463
+ buildParticipant({
464
+ id: 'participant-uuid-2',
465
+ eventId: 'event-uuid-1',
466
+ userId: 'user-uuid-2'
467
+ })
468
+ ]);
469
+ const result = await service.getEventsForCalendarRange(queryDto({
470
+ includeParticipants: true
471
+ }), user);
472
+ expect(result[0].participants).toHaveLength(2);
473
+ expect(result[0].participants?.[0].userId).toBe('user-uuid-1');
474
+ });
475
+ it('does not query participants when includeParticipants is falsy', async ()=>{
476
+ mockEventQb.getMany.mockResolvedValue([
477
+ buildEvent()
478
+ ]);
479
+ await service.getEventsForCalendarRange(queryDto(), user);
480
+ expect(mockParticipantRepo.find).not.toHaveBeenCalled();
481
+ });
482
+ it('does not query participants when there are no events to attach them to', async ()=>{
483
+ mockEventQb.getMany.mockResolvedValue([]);
484
+ await service.getEventsForCalendarRange(queryDto({
485
+ includeParticipants: true
486
+ }), user);
487
+ expect(mockParticipantRepo.find).not.toHaveBeenCalled();
488
+ });
489
+ // ---------------------------------------------------------------------
490
+ // Recurrence expansion — daily
491
+ // ---------------------------------------------------------------------
492
+ it('expands an infinite daily recurrence within the range, marking only the first occurrence as non-instance', async ()=>{
493
+ mockEventQb.getMany.mockResolvedValue([
494
+ buildEvent({
495
+ eventDate: '2026-01-01',
496
+ recurrenceType: RecurrenceType.DAILY
497
+ })
498
+ ]);
499
+ const result = await service.getEventsForCalendarRange(queryDto({
500
+ startDate: '2026-01-01',
501
+ endDate: '2026-01-05'
502
+ }), user);
503
+ expect(result.map((e)=>e.eventDate)).toEqual([
504
+ '2026-01-01',
505
+ '2026-01-02',
506
+ '2026-01-03',
507
+ '2026-01-04',
508
+ '2026-01-05'
509
+ ]);
510
+ expect(result[0].isRecurrenceInstance).toBe(false);
511
+ expect(result.slice(1).every((e)=>e.isRecurrenceInstance === true)).toBe(true);
512
+ expect(result.every((e)=>e.originalEventId === 'event-uuid-1')).toBe(true);
513
+ expect(result[1].id).toBe('event-uuid-1_2026-01-02');
514
+ });
515
+ it('stops a daily recurrence at the until (recurrenceEndDate) bound, before the range end', async ()=>{
516
+ mockEventQb.getMany.mockResolvedValue([
517
+ buildEvent({
518
+ eventDate: '2026-01-01',
519
+ recurrenceType: RecurrenceType.DAILY,
520
+ recurrenceEndDate: '2026-01-03'
521
+ })
522
+ ]);
523
+ const result = await service.getEventsForCalendarRange(queryDto({
524
+ startDate: '2026-01-01',
525
+ endDate: '2026-01-10'
526
+ }), user);
527
+ expect(result.map((e)=>e.eventDate)).toEqual([
528
+ '2026-01-01',
529
+ '2026-01-02',
530
+ '2026-01-03'
531
+ ]);
532
+ });
533
+ it('caps a count-based (no end date) recurrence at the configured max occurrences', async ()=>{
534
+ mockConfigService.getMaxRecurrenceOccurrences.mockReturnValue(3);
535
+ mockEventQb.getMany.mockResolvedValue([
536
+ buildEvent({
537
+ eventDate: '2026-01-01',
538
+ recurrenceType: RecurrenceType.DAILY
539
+ })
540
+ ]);
541
+ const result = await service.getEventsForCalendarRange(queryDto({
542
+ startDate: '2026-01-01',
543
+ endDate: '2026-12-31'
544
+ }), user);
545
+ expect(result).toHaveLength(3);
546
+ expect(result.map((e)=>e.eventDate)).toEqual([
547
+ '2026-01-01',
548
+ '2026-01-02',
549
+ '2026-01-03'
550
+ ]);
551
+ });
552
+ it('returns a single occurrence when the recurrence range only covers the start date', async ()=>{
553
+ mockEventQb.getMany.mockResolvedValue([
554
+ buildEvent({
555
+ eventDate: '2026-01-01',
556
+ recurrenceType: RecurrenceType.DAILY
557
+ })
558
+ ]);
559
+ const result = await service.getEventsForCalendarRange(queryDto({
560
+ startDate: '2026-01-01',
561
+ endDate: '2026-01-01'
562
+ }), user);
563
+ expect(result).toHaveLength(1);
564
+ expect(result[0].eventDate).toBe('2026-01-01');
565
+ });
566
+ it('produces no occurrences when the query range is entirely before the series start', async ()=>{
567
+ mockEventQb.getMany.mockResolvedValue([
568
+ buildEvent({
569
+ eventDate: '2026-06-01',
570
+ recurrenceType: RecurrenceType.DAILY,
571
+ recurrenceEndDate: '2026-06-10'
572
+ })
573
+ ]);
574
+ const result = await service.getEventsForCalendarRange(queryDto({
575
+ startDate: '2026-01-01',
576
+ endDate: '2026-01-31'
577
+ }), user);
578
+ expect(result).toEqual([]);
579
+ });
580
+ it('does not shift dates across a US daylight-saving boundary (spring-forward, 2026-03-08)', async ()=>{
581
+ mockEventQb.getMany.mockResolvedValue([
582
+ buildEvent({
583
+ eventDate: '2026-03-06',
584
+ recurrenceType: RecurrenceType.DAILY
585
+ })
586
+ ]);
587
+ const result = await service.getEventsForCalendarRange(queryDto({
588
+ startDate: '2026-03-06',
589
+ endDate: '2026-03-10'
590
+ }), user);
591
+ expect(result.map((e)=>e.eventDate)).toEqual([
592
+ '2026-03-06',
593
+ '2026-03-07',
594
+ '2026-03-08',
595
+ '2026-03-09',
596
+ '2026-03-10'
597
+ ]);
598
+ });
599
+ it('does not shift dates across a US daylight-saving boundary (fall-back, 2026-11-01)', async ()=>{
600
+ mockEventQb.getMany.mockResolvedValue([
601
+ buildEvent({
602
+ eventDate: '2026-10-30',
603
+ recurrenceType: RecurrenceType.DAILY
604
+ })
605
+ ]);
606
+ const result = await service.getEventsForCalendarRange(queryDto({
607
+ startDate: '2026-10-30',
608
+ endDate: '2026-11-03'
609
+ }), user);
610
+ expect(result.map((e)=>e.eventDate)).toEqual([
611
+ '2026-10-30',
612
+ '2026-10-31',
613
+ '2026-11-01',
614
+ '2026-11-02',
615
+ '2026-11-03'
616
+ ]);
617
+ });
618
+ // ---------------------------------------------------------------------
619
+ // Recurrence expansion — weekly / biweekly
620
+ // ---------------------------------------------------------------------
621
+ it('expands a weekly recurrence using the event own weekday when weekDays is not set', async ()=>{
622
+ // 2026-01-05 is a Monday
623
+ mockEventQb.getMany.mockResolvedValue([
624
+ buildEvent({
625
+ eventDate: '2026-01-05',
626
+ recurrenceType: RecurrenceType.WEEKLY,
627
+ weekDays: null
628
+ })
629
+ ]);
630
+ const result = await service.getEventsForCalendarRange(queryDto({
631
+ startDate: '2026-01-05',
632
+ endDate: '2026-01-26'
633
+ }), user);
634
+ expect(result.map((e)=>e.eventDate)).toEqual([
635
+ '2026-01-05',
636
+ '2026-01-12',
637
+ '2026-01-19',
638
+ '2026-01-26'
639
+ ]);
640
+ });
641
+ it('expands a weekly recurrence across multiple weekDays', async ()=>{
642
+ // 2026-01-05 is a Monday; weekDays MON + WED
643
+ mockEventQb.getMany.mockResolvedValue([
644
+ buildEvent({
645
+ eventDate: '2026-01-05',
646
+ recurrenceType: RecurrenceType.WEEKLY,
647
+ weekDays: [
648
+ 'MON',
649
+ 'WED'
650
+ ]
651
+ })
652
+ ]);
653
+ const result = await service.getEventsForCalendarRange(queryDto({
654
+ startDate: '2026-01-01',
655
+ endDate: '2026-01-14'
656
+ }), user);
657
+ expect(result.map((e)=>e.eventDate)).toEqual([
658
+ '2026-01-05',
659
+ '2026-01-07',
660
+ '2026-01-12',
661
+ '2026-01-14'
662
+ ]);
663
+ expect(result[0].isRecurrenceInstance).toBe(false);
664
+ expect(result.slice(1).every((e)=>e.isRecurrenceInstance === true)).toBe(true);
665
+ });
666
+ it('skips a weekday occurrence that falls before the series start date in its first week', async ()=>{
667
+ // 2026-01-07 is a Wednesday; the same week's Monday (Jan 5) is before the series
668
+ // start and must not produce an occurrence, but later weeks' Mondays should.
669
+ mockEventQb.getMany.mockResolvedValue([
670
+ buildEvent({
671
+ eventDate: '2026-01-07',
672
+ recurrenceType: RecurrenceType.WEEKLY,
673
+ weekDays: [
674
+ 'MON',
675
+ 'WED'
676
+ ]
677
+ })
678
+ ]);
679
+ const result = await service.getEventsForCalendarRange(queryDto({
680
+ startDate: '2026-01-01',
681
+ endDate: '2026-01-21'
682
+ }), user);
683
+ expect(result.map((e)=>e.eventDate)).toEqual([
684
+ '2026-01-07',
685
+ '2026-01-12',
686
+ '2026-01-14',
687
+ '2026-01-19',
688
+ '2026-01-21'
689
+ ]);
690
+ });
691
+ it('expands a biweekly recurrence with two-week spacing', async ()=>{
692
+ mockEventQb.getMany.mockResolvedValue([
693
+ buildEvent({
694
+ eventDate: '2026-01-05',
695
+ recurrenceType: RecurrenceType.BIWEEKLY,
696
+ weekDays: null
697
+ })
698
+ ]);
699
+ const result = await service.getEventsForCalendarRange(queryDto({
700
+ startDate: '2026-01-05',
701
+ endDate: '2026-02-28'
702
+ }), user);
703
+ expect(result.map((e)=>e.eventDate)).toEqual([
704
+ '2026-01-05',
705
+ '2026-01-19',
706
+ '2026-02-02',
707
+ '2026-02-16'
708
+ ]);
709
+ });
710
+ it('stops a weekly recurrence at the until bound mid-week', async ()=>{
711
+ mockEventQb.getMany.mockResolvedValue([
712
+ buildEvent({
713
+ eventDate: '2026-01-05',
714
+ recurrenceType: RecurrenceType.WEEKLY,
715
+ weekDays: null,
716
+ recurrenceEndDate: '2026-01-12'
717
+ })
718
+ ]);
719
+ const result = await service.getEventsForCalendarRange(queryDto({
720
+ startDate: '2026-01-01',
721
+ endDate: '2026-01-31'
722
+ }), user);
723
+ expect(result.map((e)=>e.eventDate)).toEqual([
724
+ '2026-01-05',
725
+ '2026-01-12'
726
+ ]);
727
+ });
728
+ // ---------------------------------------------------------------------
729
+ // Recurrence expansion — monthly
730
+ // ---------------------------------------------------------------------
731
+ it('expands a monthly recurrence, clamping to the last day of shorter months', async ()=>{
732
+ mockEventQb.getMany.mockResolvedValue([
733
+ buildEvent({
734
+ eventDate: '2026-01-31',
735
+ recurrenceType: RecurrenceType.MONTHLY
736
+ })
737
+ ]);
738
+ const result = await service.getEventsForCalendarRange(queryDto({
739
+ startDate: '2026-01-01',
740
+ endDate: '2026-04-30'
741
+ }), user);
742
+ // 2026 is not a leap year: Feb has 28 days; Mar has 31 (back to day 31); Apr has 30.
743
+ expect(result.map((e)=>e.eventDate)).toEqual([
744
+ '2026-01-31',
745
+ '2026-02-28',
746
+ '2026-03-31',
747
+ '2026-04-30'
748
+ ]);
749
+ });
750
+ it('restores the original day-of-month once a longer month is reached again', async ()=>{
751
+ mockEventQb.getMany.mockResolvedValue([
752
+ buildEvent({
753
+ eventDate: '2026-01-31',
754
+ recurrenceType: RecurrenceType.MONTHLY
755
+ })
756
+ ]);
757
+ const result = await service.getEventsForCalendarRange(queryDto({
758
+ startDate: '2026-02-01',
759
+ endDate: '2026-03-31'
760
+ }), user);
761
+ expect(result.map((e)=>e.eventDate)).toEqual([
762
+ '2026-02-28',
763
+ '2026-03-31'
764
+ ]);
765
+ });
766
+ it('clamps to Feb 29 on a leap year for a monthly recurrence starting Jan 31', async ()=>{
767
+ mockEventQb.getMany.mockResolvedValue([
768
+ buildEvent({
769
+ eventDate: '2028-01-31',
770
+ recurrenceType: RecurrenceType.MONTHLY
771
+ })
772
+ ]);
773
+ const result = await service.getEventsForCalendarRange(queryDto({
774
+ startDate: '2028-01-01',
775
+ endDate: '2028-02-29'
776
+ }), user);
777
+ expect(result.map((e)=>e.eventDate)).toEqual([
778
+ '2028-01-31',
779
+ '2028-02-29'
780
+ ]);
781
+ });
782
+ // ---------------------------------------------------------------------
783
+ // All-day events
784
+ // ---------------------------------------------------------------------
785
+ it('preserves isAllDay through non-recurring expansion', async ()=>{
786
+ mockEventQb.getMany.mockResolvedValue([
787
+ buildEvent({
788
+ eventDate: '2026-04-10',
789
+ isAllDay: true,
790
+ startTime: '00:00',
791
+ endTime: '23:59'
792
+ })
793
+ ]);
794
+ const result = await service.getEventsForCalendarRange(queryDto(), user);
795
+ expect(result[0].isAllDay).toBe(true);
796
+ expect(result[0].startTime).toBe('00:00');
797
+ expect(result[0].endTime).toBe('23:59');
798
+ });
799
+ it('preserves isAllDay through recurring expansion', async ()=>{
800
+ mockEventQb.getMany.mockResolvedValue([
801
+ buildEvent({
802
+ eventDate: '2026-01-01',
803
+ recurrenceType: RecurrenceType.DAILY,
804
+ isAllDay: true,
805
+ startTime: '00:00',
806
+ endTime: '23:59'
807
+ })
808
+ ]);
809
+ const result = await service.getEventsForCalendarRange(queryDto({
810
+ startDate: '2026-01-01',
811
+ endDate: '2026-01-02'
812
+ }), user);
813
+ expect(result.every((e)=>e.isAllDay === true)).toBe(true);
814
+ });
815
+ // ---------------------------------------------------------------------
816
+ // Sorting and multi-event ordering
817
+ // ---------------------------------------------------------------------
818
+ it('returns events sorted chronologically by eventDate, including expanded recurrence instances', async ()=>{
819
+ mockEventQb.getMany.mockResolvedValue([
820
+ buildEvent({
821
+ id: 'event-b',
822
+ eventDate: '2026-01-03',
823
+ recurrenceType: RecurrenceType.NONE
824
+ }),
825
+ buildEvent({
826
+ id: 'event-a',
827
+ eventDate: '2026-01-01',
828
+ recurrenceType: RecurrenceType.DAILY,
829
+ recurrenceEndDate: '2026-01-02'
830
+ })
831
+ ]);
832
+ const result = await service.getEventsForCalendarRange(queryDto({
833
+ startDate: '2026-01-01',
834
+ endDate: '2026-01-05'
835
+ }), user);
836
+ expect(result.map((e)=>e.eventDate)).toEqual([
837
+ '2026-01-01',
838
+ '2026-01-02',
839
+ '2026-01-03'
840
+ ]);
841
+ });
842
+ });
843
+ describe('ensureParticipantRepositoryInitialized', ()=>{
844
+ it('caches the participant repository after the first resolution', async ()=>{
845
+ await service.ensureParticipantRepositoryInitialized();
846
+ await service.ensureParticipantRepositoryInitialized();
847
+ expect(mockDataSourceProvider.getRepository).toHaveBeenCalledTimes(1);
848
+ expect(mockDataSourceProvider.getRepository).toHaveBeenCalledWith(EventParticipant);
849
+ });
850
+ });
851
+ });