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