@tuturuuu/utils 0.34.0 → 0.35.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,479 @@
1
+ import { Temporal } from '@js-temporal/polyfill';
2
+ import type {
3
+ CalendarRecurrenceAnchor,
4
+ CalendarRecurrenceRule,
5
+ } from '@tuturuuu/types/primitives/calendar-recurrence';
6
+ import { describe, expect, it, vi } from 'vitest';
7
+ import {
8
+ CalendarRecurrenceRuleSchema,
9
+ expandCalendarRecurrence,
10
+ fromGoogleCalendarRecurrence,
11
+ fromGraphCalendarRecurrence,
12
+ toGoogleCalendarRecurrence,
13
+ toGraphCalendarRecurrence,
14
+ UnsupportedCalendarRecurrenceError,
15
+ } from '../calendar-recurrence';
16
+
17
+ const anchor: CalendarRecurrenceAnchor = {
18
+ startLocal: '2026-01-01T09:00:00',
19
+ endLocal: '2026-01-01T10:00:00',
20
+ allDay: false,
21
+ };
22
+ const daily: CalendarRecurrenceRule = {
23
+ version: 1,
24
+ frequency: 'daily',
25
+ interval: 1,
26
+ timeZone: 'UTC',
27
+ end: { type: 'never' },
28
+ };
29
+ const expand = (
30
+ rule: CalendarRecurrenceRule,
31
+ options: Partial<Parameters<typeof expandCalendarRecurrence>[0]> = {}
32
+ ) =>
33
+ expandCalendarRecurrence({
34
+ rule,
35
+ anchor,
36
+ from: '2026-01-01T00:00:00Z',
37
+ to: '2026-02-01T00:00:00Z',
38
+ ...options,
39
+ });
40
+ const patterns: CalendarRecurrenceRule[] = [
41
+ daily,
42
+ {
43
+ ...daily,
44
+ frequency: 'weekly',
45
+ interval: 2,
46
+ weekdays: ['MO', 'FR'],
47
+ weekStartsOn: 'SU',
48
+ },
49
+ {
50
+ ...daily,
51
+ frequency: 'monthly',
52
+ monthDay: 31,
53
+ monthDayOverflow: 'last-day',
54
+ },
55
+ { ...daily, frequency: 'monthly', weekdays: ['TH'], weekIndex: 2 },
56
+ { ...daily, frequency: 'monthly', weekdays: ['TH', 'FR'], weekIndex: -1 },
57
+ {
58
+ ...daily,
59
+ frequency: 'yearly',
60
+ month: 2,
61
+ monthDay: 29,
62
+ monthDayOverflow: 'last-day',
63
+ },
64
+ { ...daily, frequency: 'yearly', month: 11, weekdays: ['MO'], weekIndex: 1 },
65
+ ];
66
+
67
+ describe('common provider recurrence contract', () => {
68
+ for (const [index, pattern] of patterns.entries()) {
69
+ for (const end of [
70
+ { type: 'never' },
71
+ { type: 'count', count: 8 },
72
+ { type: 'until', date: '2028-12-31' },
73
+ ] as const) {
74
+ it(`round trips pattern ${index} / ${end.type} through Google and Graph`, () => {
75
+ const rule = { ...pattern, end };
76
+ const dates = [
77
+ '2026-01-01',
78
+ '2026-01-02',
79
+ '2026-01-31',
80
+ '2026-01-08',
81
+ '2026-01-29',
82
+ '2028-02-29',
83
+ '2026-11-02',
84
+ ];
85
+ const patternAnchor = {
86
+ ...anchor,
87
+ startLocal: `${dates[index]}T09:00:00`,
88
+ endLocal: `${dates[index]}T10:00:00`,
89
+ };
90
+ expect(
91
+ fromGraphCalendarRecurrence(
92
+ toGraphCalendarRecurrence(rule, patternAnchor),
93
+ rule.timeZone
94
+ )
95
+ ).toEqual(rule);
96
+ const imported = fromGoogleCalendarRecurrence(
97
+ toGoogleCalendarRecurrence(rule, patternAnchor),
98
+ patternAnchor,
99
+ rule.timeZone
100
+ );
101
+ expect(imported).toEqual(rule);
102
+ });
103
+ }
104
+ }
105
+ it('uses exclusive all-day dates and inclusive UNTIL day', () => {
106
+ const allDay = {
107
+ startLocal: '2026-01-01T00:00:00',
108
+ endLocal: '2026-01-02T00:00:00',
109
+ allDay: true,
110
+ };
111
+ const rule = {
112
+ ...daily,
113
+ end: { type: 'until', date: '2026-01-03' } as const,
114
+ };
115
+ expect(toGoogleCalendarRecurrence(rule, allDay)).toEqual([
116
+ 'RRULE:FREQ=DAILY;INTERVAL=1;UNTIL=20260103',
117
+ ]);
118
+ expect(
119
+ fromGoogleCalendarRecurrence(
120
+ toGoogleCalendarRecurrence(rule, allDay),
121
+ allDay,
122
+ 'UTC'
123
+ )
124
+ ).toEqual(rule);
125
+ expect(expand(rule, { anchor: allDay }).occurrences).toHaveLength(3);
126
+ });
127
+ it('converts Google UTC cutoff to the last eligible local day without extending it', () => {
128
+ const rule = fromGoogleCalendarRecurrence(
129
+ ['RRULE:FREQ=DAILY;UNTIL=20260103T085959Z'],
130
+ anchor,
131
+ 'UTC'
132
+ );
133
+ expect(rule.end).toEqual({ type: 'until', date: '2026-01-02' });
134
+ });
135
+ it.each([
136
+ ['RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=5'],
137
+ ['RRULE:FREQ=MONTHLY;BYDAY=1MO,1FR'],
138
+ ['RRULE:FREQ=DAILY', 'EXDATE:20260101T090000Z'],
139
+ ['RRULE:FREQ=DAILY;COUNT=2;UNTIL=20260103T000000Z'],
140
+ ['RRULE:FREQ=DAILY;INTERVAL=1;INTERVAL=2'],
141
+ ])('rejects unsupported/lossy provider recurrence %j', (...value) => {
142
+ expect(() => fromGoogleCalendarRecurrence(value, anchor, 'UTC')).toThrow(
143
+ UnsupportedCalendarRecurrenceError
144
+ );
145
+ });
146
+ it.each([
147
+ { ...daily, timeZone: '+07:00' },
148
+ { ...daily, timeZone: 'Invalid/Zone' },
149
+ { ...daily, interval: 0 },
150
+ { ...daily, weekdays: ['MO'] },
151
+ { ...daily, frequency: 'weekly' },
152
+ {
153
+ ...daily,
154
+ frequency: 'monthly',
155
+ monthDay: 1,
156
+ weekdays: ['MO'],
157
+ weekIndex: 1,
158
+ },
159
+ { ...daily, end: { type: 'until', date: '2026-02-30' } },
160
+ ])('rejects invalid rule %j', (rule) =>
161
+ expect(CalendarRecurrenceRuleSchema.safeParse(rule).success).toBe(false)
162
+ );
163
+ });
164
+
165
+ describe('bounded timezone-aware occurrence expansion', () => {
166
+ it('maintains the same wall clock through spring DST', () => {
167
+ const result = expand(
168
+ { ...daily, timeZone: 'America/New_York' },
169
+ {
170
+ anchor: {
171
+ ...anchor,
172
+ startLocal: '2026-03-07T09:00:00',
173
+ endLocal: '2026-03-07T10:00:00',
174
+ },
175
+ from: '2026-03-07T00:00:00Z',
176
+ to: '2026-03-10T00:00:00Z',
177
+ }
178
+ );
179
+ expect(result.occurrences.map((event) => event.start_at)).toEqual([
180
+ '2026-03-07T14:00:00Z',
181
+ '2026-03-08T13:00:00Z',
182
+ '2026-03-09T13:00:00Z',
183
+ ]);
184
+ });
185
+ it('skips nonexistent local times without consuming COUNT', () => {
186
+ const result = expand(
187
+ {
188
+ ...daily,
189
+ timeZone: 'America/New_York',
190
+ end: { type: 'count', count: 2 },
191
+ },
192
+ {
193
+ anchor: {
194
+ ...anchor,
195
+ startLocal: '2026-03-07T02:30:00',
196
+ endLocal: '2026-03-07T03:30:00',
197
+ },
198
+ from: '2026-03-07T00:00:00Z',
199
+ to: '2026-03-11T00:00:00Z',
200
+ }
201
+ );
202
+ expect(result.occurrences.map((event) => event.originalStartLocal)).toEqual(
203
+ ['2026-03-07T02:30:00', '2026-03-09T02:30:00']
204
+ );
205
+ });
206
+ it('uses earlier fold and preserves end wall clock in autumn', () => {
207
+ const result = expand(
208
+ {
209
+ ...daily,
210
+ timeZone: 'America/New_York',
211
+ end: { type: 'count', count: 1 },
212
+ },
213
+ {
214
+ anchor: {
215
+ ...anchor,
216
+ startLocal: '2026-11-01T01:30:00',
217
+ endLocal: '2026-11-01T02:30:00',
218
+ },
219
+ from: '2026-11-01T00:00:00Z',
220
+ to: '2026-11-02T00:00:00Z',
221
+ }
222
+ );
223
+ expect(result.occurrences[0]).toMatchObject({
224
+ start_at: '2026-11-01T05:30:00Z',
225
+ end_at: '2026-11-01T07:30:00Z',
226
+ });
227
+ });
228
+ it('counts cancellations and moved instances without creating replacement slots', () => {
229
+ const result = expand(
230
+ { ...daily, end: { type: 'count', count: 3 } },
231
+ {
232
+ exceptions: [
233
+ { originalStartLocal: '2026-01-01T09:00:00', cancelled: true },
234
+ {
235
+ originalStartLocal: '2026-01-02T09:00:00',
236
+ startLocal: '2026-01-09T13:00:00',
237
+ endLocal: '2026-01-09T14:00:00',
238
+ },
239
+ ],
240
+ }
241
+ );
242
+ expect(result.occurrences.map((event) => event.originalStartLocal)).toEqual(
243
+ ['2026-01-03T09:00:00', '2026-01-02T09:00:00']
244
+ );
245
+ expect(result.occurrences[1]?.isException).toBe(true);
246
+ });
247
+ it('includes moved instances whose original slot is outside the requested window', () => {
248
+ const result = expand(daily, {
249
+ from: '2026-01-01T12:00:00Z',
250
+ to: '2026-01-01T15:00:00Z',
251
+ exceptions: [
252
+ {
253
+ originalStartLocal: '2026-01-20T09:00:00',
254
+ startLocal: '2026-01-01T13:00:00',
255
+ endLocal: '2026-01-01T14:00:00',
256
+ },
257
+ ],
258
+ });
259
+ expect(result.occurrences.map((event) => event.originalStartLocal)).toEqual(
260
+ ['2026-01-20T09:00:00']
261
+ );
262
+ });
263
+ it('includes events overlapping the range and excludes exact edge boundaries', () => {
264
+ expect(
265
+ expand(daily, {
266
+ from: '2026-01-01T09:30:00Z',
267
+ to: '2026-01-01T09:45:00Z',
268
+ }).occurrences
269
+ ).toHaveLength(1);
270
+ expect(
271
+ expand(daily, {
272
+ from: '2026-01-01T10:00:00Z',
273
+ to: '2026-01-02T09:00:00Z',
274
+ }).occurrences
275
+ ).toHaveLength(0);
276
+ });
277
+ it('skips unavailable month days, including non-leap February', () => {
278
+ const rule = { ...daily, frequency: 'monthly', monthDay: 31 } as const;
279
+ expect(
280
+ expand(rule, {
281
+ anchor: {
282
+ ...anchor,
283
+ startLocal: '2026-01-31T09:00:00',
284
+ endLocal: '2026-01-31T10:00:00',
285
+ },
286
+ to: '2026-05-01T00:00:00Z',
287
+ }).occurrences.map((event) => event.originalStartLocal.slice(0, 10))
288
+ ).toEqual(['2026-01-31', '2026-03-31']);
289
+ expect(
290
+ expand(
291
+ { ...daily, frequency: 'yearly', month: 2, monthDay: 29 },
292
+ {
293
+ anchor: {
294
+ ...anchor,
295
+ startLocal: '2028-02-29T09:00:00',
296
+ endLocal: '2028-02-29T10:00:00',
297
+ },
298
+ to: '2030-01-01T00:00:00Z',
299
+ }
300
+ ).occurrences.map((event) => event.originalStartLocal.slice(0, 10))
301
+ ).toEqual(['2028-02-29']);
302
+ });
303
+ it('handles biweekly recurrence across year and week boundaries', () => {
304
+ expect(
305
+ expand({
306
+ ...daily,
307
+ frequency: 'weekly',
308
+ interval: 2,
309
+ weekdays: ['MO', 'TH'],
310
+ weekStartsOn: 'SU',
311
+ }).occurrences.map((event) => event.originalStartLocal.slice(0, 10))
312
+ ).toEqual([
313
+ '2026-01-01',
314
+ '2026-01-12',
315
+ '2026-01-15',
316
+ '2026-01-26',
317
+ '2026-01-29',
318
+ ]);
319
+ });
320
+ it('selects the earliest matching relative weekday', () => {
321
+ expect(
322
+ expand(
323
+ {
324
+ ...daily,
325
+ frequency: 'monthly',
326
+ weekdays: ['TH', 'FR'],
327
+ weekIndex: 2,
328
+ },
329
+ {
330
+ anchor: {
331
+ ...anchor,
332
+ startLocal: '2026-01-08T09:00:00',
333
+ endLocal: '2026-01-08T10:00:00',
334
+ },
335
+ }
336
+ ).occurrences[0]?.originalStartLocal
337
+ ).toBe('2026-01-08T09:00:00');
338
+ });
339
+ it('preserves Outlook short-month clamping through Google RRULE conversion', () => {
340
+ const rule: CalendarRecurrenceRule = {
341
+ ...daily,
342
+ frequency: 'monthly',
343
+ monthDay: 31,
344
+ monthDayOverflow: 'last-day',
345
+ };
346
+ const monthAnchor = {
347
+ ...anchor,
348
+ startLocal: '2026-01-31T09:00:00',
349
+ endLocal: '2026-01-31T10:00:00',
350
+ };
351
+ expect(toGoogleCalendarRecurrence(rule, monthAnchor)).toEqual([
352
+ 'RRULE:FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=28,29,30,31;BYSETPOS=-1',
353
+ ]);
354
+ expect(
355
+ expand(rule, {
356
+ anchor: monthAnchor,
357
+ to: '2026-05-01T00:00:00Z',
358
+ }).occurrences.map((event) => event.originalStartLocal.slice(0, 10))
359
+ ).toEqual(['2026-01-31', '2026-02-28', '2026-03-31', '2026-04-30']);
360
+ expect(() =>
361
+ toGraphCalendarRecurrence(
362
+ { ...rule, monthDayOverflow: 'skip' },
363
+ monthAnchor
364
+ )
365
+ ).toThrow(UnsupportedCalendarRecurrenceError);
366
+ });
367
+ it('rejects duplicate exception keys and incomplete moved times', () => {
368
+ expect(() =>
369
+ expand(daily, {
370
+ exceptions: [
371
+ { originalStartLocal: anchor.startLocal },
372
+ { originalStartLocal: anchor.startLocal, cancelled: true },
373
+ ],
374
+ })
375
+ ).toThrow('Duplicate');
376
+ expect(() =>
377
+ expand(daily, {
378
+ exceptions: [
379
+ {
380
+ originalStartLocal: anchor.startLocal,
381
+ startLocal: '2026-01-01T13:00:00',
382
+ },
383
+ ],
384
+ })
385
+ ).toThrow('Moved occurrences require');
386
+ });
387
+ it('does not resurrect a moved original slot beyond the counted series', () => {
388
+ const result = expand(
389
+ { ...daily, end: { type: 'count', count: 1 } },
390
+ {
391
+ exceptions: [
392
+ {
393
+ originalStartLocal: '2026-01-20T09:00:00',
394
+ startLocal: '2026-01-01T13:00:00',
395
+ endLocal: '2026-01-01T14:00:00',
396
+ },
397
+ ],
398
+ }
399
+ );
400
+ expect(result.occurrences).toHaveLength(1);
401
+ expect(result.occurrences[0]?.originalStartLocal).toBe(anchor.startLocal);
402
+ });
403
+ it('rejects a series anchor outside its pattern instead of inserting an extra first occurrence', () => {
404
+ expect(() =>
405
+ expand({ ...daily, frequency: 'weekly', weekdays: ['MO'] })
406
+ ).toThrow('anchor must match');
407
+ expect(() =>
408
+ fromGoogleCalendarRecurrence(
409
+ ['RRULE:FREQ=WEEKLY;BYDAY=1MO'],
410
+ anchor,
411
+ 'UTC'
412
+ )
413
+ ).toThrow(UnsupportedCalendarRecurrenceError);
414
+ });
415
+ it('reports truncation explicitly instead of claiming completeness', () => {
416
+ const result = expand(daily, { limit: 2 });
417
+ expect(result.occurrences).toHaveLength(2);
418
+ expect(result.truncated).toBe(true);
419
+ });
420
+ it('rejects an unbounded historical scan and invalid request limits', () => {
421
+ expect(() => expand(daily, { to: '2200-01-01T00:00:00Z' })).toThrow(
422
+ '100 years'
423
+ );
424
+ expect(() => expand(daily, { limit: 1001 })).toThrow('1–1000');
425
+ });
426
+ it('rejects oversized effective history before walking any dates', () => {
427
+ const add = vi.spyOn(Temporal.PlainDate.prototype, 'add');
428
+ try {
429
+ for (const options of [
430
+ { to: '2200-01-01T00:00:00Z' },
431
+ {
432
+ exceptions: [
433
+ { originalStartLocal: '2200-01-01T09:00:00', cancelled: true },
434
+ ],
435
+ },
436
+ ]) {
437
+ expect(() => expand(daily, options)).toThrow('100 years');
438
+ }
439
+ expect(add).not.toHaveBeenCalled();
440
+ } finally {
441
+ add.mockRestore();
442
+ }
443
+ });
444
+ it('lets COUNT and a nearer UNTIL terminate a far-future viewport', () => {
445
+ for (const end of [
446
+ { type: 'count', count: 2 },
447
+ { type: 'until', date: '2026-01-02' },
448
+ ] as const) {
449
+ const result = expand({ ...daily, end }, { to: '2200-01-01T00:00:00Z' });
450
+ expect(
451
+ result.occurrences.map((event) => event.originalStartLocal)
452
+ ).toEqual(['2026-01-01T09:00:00', '2026-01-02T09:00:00']);
453
+ expect(result.truncated).toBe(false);
454
+ }
455
+ });
456
+ it('uses 23-hour all-day duration across DST with exclusive end boundary', () => {
457
+ const result = expand(
458
+ {
459
+ ...daily,
460
+ timeZone: 'America/New_York',
461
+ end: { type: 'count', count: 1 },
462
+ },
463
+ {
464
+ anchor: {
465
+ startLocal: '2026-03-08T00:00:00',
466
+ endLocal: '2026-03-09T00:00:00',
467
+ allDay: true,
468
+ },
469
+ from: '2026-03-08T00:00:00Z',
470
+ to: '2026-03-10T00:00:00Z',
471
+ }
472
+ );
473
+ expect(result.occurrences[0]).toMatchObject({
474
+ start_at: '2026-03-08T05:00:00Z',
475
+ end_at: '2026-03-09T04:00:00Z',
476
+ is_all_day: true,
477
+ });
478
+ });
479
+ });
@@ -46,6 +46,27 @@ describe('workspace permission catalog', () => {
46
46
  expect(rootPermissionIds).toContain('manage_git_repositories');
47
47
  });
48
48
 
49
+ it('exposes both deployment vault permissions only in root or full catalogs', () => {
50
+ for (const id of [
51
+ 'manage_mobile_deployment_vault',
52
+ 'manage_desktop_deployment_vault',
53
+ ]) {
54
+ expect(
55
+ permissions({ wsId: workspaceId, user: null }).map((entry) => entry.id)
56
+ ).not.toContain(id);
57
+ expect(
58
+ permissions({ wsId: ROOT_WORKSPACE_ID, user: null }).map(
59
+ (entry) => entry.id
60
+ )
61
+ ).toContain(id);
62
+ expect(
63
+ permissions({ wsId: workspaceId, user: null, catalog: 'full' }).map(
64
+ (entry) => entry.id
65
+ )
66
+ ).toContain(id);
67
+ }
68
+ });
69
+
49
70
  it('keeps internal account permission translations in shared role editors', () => {
50
71
  for (const app of ['cms', 'infrastructure', 'inventory', 'web']) {
51
72
  for (const locale of ['en', 'vi']) {
@@ -0,0 +1,63 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const { read, entries } = vi.hoisted(() => ({
4
+ read: vi.fn(),
5
+ entries: new Map<string, unknown>(),
6
+ }));
7
+ vi.mock('../workspace-helper', () => ({ getWorkspace: read }));
8
+ // One request's cache tests primitive identity keys. React owns its lifetime.
9
+ vi.mock('react', () => ({
10
+ cache:
11
+ (fn: (...args: unknown[]) => unknown) =>
12
+ (...args: unknown[]) => {
13
+ const key = JSON.stringify(args);
14
+ if (!entries.has(key)) entries.set(key, fn(...args));
15
+ return entries.get(key);
16
+ },
17
+ }));
18
+
19
+ import { getRequestWorkspace } from '../request-workspace';
20
+
21
+ describe('request workspace identity', () => {
22
+ beforeEach(() => {
23
+ entries.clear();
24
+ read.mockReset();
25
+ read.mockResolvedValue({ id: 'workspace' });
26
+ });
27
+ it('coalesces equal principals supplied as separate objects', async () => {
28
+ await Promise.all([
29
+ getRequestWorkspace('personal', {
30
+ useAdmin: true,
31
+ user: { id: 'actor' },
32
+ }),
33
+ getRequestWorkspace('personal', {
34
+ useAdmin: true,
35
+ user: { id: 'actor' },
36
+ }),
37
+ ]);
38
+ expect(read).toHaveBeenCalledTimes(1);
39
+ expect(read).toHaveBeenCalledWith('personal', {
40
+ useAdmin: true,
41
+ user: { id: 'actor', email: null },
42
+ });
43
+ });
44
+ it('separates actors, emails, workspace identifiers and access modes', async () => {
45
+ const user = { id: 'actor', email: 'actor@example.test' };
46
+ await getRequestWorkspace('personal', { user });
47
+ await getRequestWorkspace('personal', { user: { ...user, id: 'other' } });
48
+ await getRequestWorkspace('personal', { user: { ...user, email: null } });
49
+ await getRequestWorkspace('other', { user });
50
+ await getRequestWorkspace('personal', { useAdmin: true, user });
51
+ expect(read).toHaveBeenCalledTimes(5);
52
+ });
53
+ it('leaves authentication to the existing reader without a principal', async () => {
54
+ await getRequestWorkspace('personal');
55
+ expect(read).toHaveBeenCalledWith('personal', { useAdmin: false });
56
+ });
57
+ it('does not retain results in the next simulated request', async () => {
58
+ await getRequestWorkspace('personal');
59
+ entries.clear();
60
+ await getRequestWorkspace('personal');
61
+ expect(read).toHaveBeenCalledTimes(2);
62
+ });
63
+ });