@baukit/notifications-core 0.7.2 → 0.7.3

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.
package/src/vitest.ts ADDED
@@ -0,0 +1,366 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { NotificationPlanError } from './errors.js';
4
+ import type { NotificationPlatformFaults } from './memory.js';
5
+ import { isOwnedBy, ownedNotificationIdentifier } from './ownership.js';
6
+ import type {
7
+ NotificationOwner,
8
+ OwnedNotification,
9
+ OwnedNotificationReplacementOutcome,
10
+ OwnedNotificationScheduler,
11
+ OwnedNotificationSchedulerOptions,
12
+ PendingNotification,
13
+ } from './scheduler.js';
14
+ import type { PlannedNotification } from './validation.js';
15
+
16
+ export interface OwnedNotificationSchedulerHarness<TContent> {
17
+ readonly scheduler: OwnedNotificationScheduler<TContent>;
18
+ readonly faults: NotificationPlatformFaults;
19
+ content(logicalId: string, text: string): TContent;
20
+ addUnrelated(identifier: string): Promise<void> | void;
21
+ pending(): Promise<readonly PendingNotification[]> | readonly PendingNotification[];
22
+ }
23
+
24
+ export type OwnedNotificationSchedulerHarnessFactory<TContent> = (
25
+ options: OwnedNotificationSchedulerOptions,
26
+ ) =>
27
+ | Promise<OwnedNotificationSchedulerHarness<TContent>>
28
+ | OwnedNotificationSchedulerHarness<TContent>;
29
+
30
+ type Spec = readonly [logicalId: string, hour: number, contentDigest?: string];
31
+
32
+ const BASE_INSTANT = Date.parse('2030-01-01T00:00:00Z');
33
+ const MILLISECONDS_PER_HOUR = 3_600_000;
34
+ const PRIVATE_COPY = 'private copy';
35
+ const UNRELATED = 'unrelated-request';
36
+ const OWNER: NotificationOwner = { namespace: 'reminders' };
37
+ const PREFIX_SIBLING: NotificationOwner = { namespace: 'reminders-extra' };
38
+ const LIMIT = 4;
39
+
40
+ /** Registers the owned-notification replacement contract in the current Vitest suite. */
41
+ export function describeOwnedNotificationSchedulerContract<TContent>(
42
+ makeHarness: OwnedNotificationSchedulerHarnessFactory<TContent>,
43
+ ): void {
44
+ const setup = async (options: OwnedNotificationSchedulerOptions = {}) => {
45
+ const harness = await makeHarness(options);
46
+ const build = (specs: readonly Spec[]): OwnedNotification<TContent>[] =>
47
+ specs.map(([logicalId, hour, contentDigest = 'v1']) => ({
48
+ logicalId,
49
+ epochMilliseconds: BASE_INSTANT + hour * MILLISECONDS_PER_HOUR,
50
+ contentDigest,
51
+ content: harness.content(logicalId, `${PRIVATE_COPY} ${logicalId} ${contentDigest}`),
52
+ }));
53
+ const owned = async (owner: NotificationOwner = OWNER): Promise<PlannedNotification[]> =>
54
+ (await harness.pending())
55
+ .filter((request) => isOwnedBy(owner.namespace, request.identifier, request.marker))
56
+ .map(({ marker }) => ({
57
+ logicalId: marker?.logicalId ?? '',
58
+ epochMilliseconds: marker?.epochMilliseconds ?? 0,
59
+ contentDigest: marker?.contentDigest ?? '',
60
+ }))
61
+ .sort((left, right) => left.epochMilliseconds - right.epochMilliseconds);
62
+ const identifiers = async (): Promise<string[]> =>
63
+ (await harness.pending()).map(({ identifier }) => identifier).sort();
64
+ const replace = (specs: readonly Spec[], owner = OWNER, replaceAll = false) =>
65
+ harness.scheduler.replaceOwned(owner, build(specs), { replaceAll });
66
+ const planned = (specs: readonly Spec[]): PlannedNotification[] =>
67
+ build(specs).map(({ logicalId, epochMilliseconds, contentDigest }) => ({
68
+ logicalId,
69
+ epochMilliseconds,
70
+ contentDigest,
71
+ }));
72
+ return { harness, owned, identifiers, replace, planned };
73
+ };
74
+
75
+ describe('OwnedNotificationScheduler contract', () => {
76
+ it('schedules the desired set in instant order and reports completion', async () => {
77
+ const { owned, replace, planned } = await setup();
78
+ const outcome = await replace([
79
+ ['b', 2],
80
+ ['a', 1],
81
+ ]);
82
+ expect(outcome).toEqual(complete({ scheduled: ['a', 'b'] }));
83
+ expect(await owned()).toEqual(
84
+ planned([
85
+ ['a', 1],
86
+ ['b', 2],
87
+ ]),
88
+ );
89
+ });
90
+
91
+ it('keeps an unchanged set on repeated calls', async () => {
92
+ const { owned, replace } = await setup();
93
+ const specs: Spec[] = [
94
+ ['a', 1],
95
+ ['b', 2],
96
+ ];
97
+ await replace(specs);
98
+ const before = await owned();
99
+ expect(await replace(specs)).toEqual(complete({ kept: ['a', 'b'] }));
100
+ expect(await replace([...specs].reverse())).toEqual(complete({ kept: ['a', 'b'] }));
101
+ expect(await owned()).toEqual(before);
102
+ });
103
+
104
+ it('moves, removes, and adds by logical ID and instant', async () => {
105
+ const { owned, replace, planned } = await setup();
106
+ await replace([
107
+ ['keep', 1],
108
+ ['move', 2],
109
+ ['drop', 3],
110
+ ]);
111
+ const outcome = await replace([
112
+ ['keep', 1],
113
+ ['move', 5],
114
+ ['add', 4],
115
+ ]);
116
+ expect(outcome).toEqual(
117
+ complete({ kept: ['keep'], cancelled: ['move', 'drop'], scheduled: ['add', 'move'] }),
118
+ );
119
+ expect(await owned()).toEqual(
120
+ planned([
121
+ ['keep', 1],
122
+ ['add', 4],
123
+ ['move', 5],
124
+ ]),
125
+ );
126
+ });
127
+
128
+ it('replaces content only when the digest changes or replaceAll is set', async () => {
129
+ const { owned, replace, planned } = await setup();
130
+ await replace([
131
+ ['a', 1],
132
+ ['b', 2],
133
+ ]);
134
+ expect(
135
+ await replace([
136
+ ['a', 1, 'v2'],
137
+ ['b', 2],
138
+ ]),
139
+ ).toEqual(complete({ kept: ['b'], cancelled: ['a'], scheduled: ['a'] }));
140
+ expect(
141
+ await replace(
142
+ [
143
+ ['a', 1, 'v2'],
144
+ ['b', 2],
145
+ ],
146
+ OWNER,
147
+ true,
148
+ ),
149
+ ).toEqual(complete({ cancelled: ['a', 'b'], scheduled: ['a', 'b'] }));
150
+ expect(await owned()).toEqual(
151
+ planned([
152
+ ['a', 1, 'v2'],
153
+ ['b', 2],
154
+ ]),
155
+ );
156
+ });
157
+
158
+ it('leaves other namespaces and unrelated requests alone, including on disable', async () => {
159
+ const { harness, owned, identifiers, replace, planned } = await setup();
160
+ await harness.addUnrelated(UNRELATED);
161
+ await replace([['shared-id', 1]], PREFIX_SIBLING);
162
+ await replace([['shared-id', 2]]);
163
+
164
+ expect(await replace([])).toEqual(complete({ cancelled: ['shared-id'] }));
165
+ expect(await owned()).toEqual([]);
166
+ expect(await owned(PREFIX_SIBLING)).toEqual(planned([['shared-id', 1]]));
167
+ expect(await identifiers()).toEqual(
168
+ [ownedNotificationIdentifier(PREFIX_SIBLING.namespace, 'shared-id'), UNRELATED].sort(),
169
+ );
170
+ });
171
+
172
+ it('returns an incomplete outcome when listing fails and converges on retry', async () => {
173
+ const { harness, owned, replace, planned } = await setup();
174
+ harness.faults.failNextList();
175
+ expect(await replace([['a', 1]])).toEqual(incomplete({}, [{ code: 'list_failed' }]));
176
+ expect(await owned()).toEqual([]);
177
+ expect(await replace([['a', 1]])).toEqual(complete({ scheduled: ['a'] }));
178
+ expect(await owned()).toEqual(planned([['a', 1]]));
179
+ });
180
+
181
+ it('does not reschedule an item whose cancel failed and converges on retry', async () => {
182
+ const { harness, owned, replace, planned } = await setup();
183
+ await replace([
184
+ ['a', 1],
185
+ ['b', 2],
186
+ ]);
187
+ harness.faults.failCancel(ownedNotificationIdentifier(OWNER.namespace, 'a'));
188
+ const specs: Spec[] = [
189
+ ['a', 3],
190
+ ['b', 4],
191
+ ];
192
+ expect(await replace(specs)).toEqual(
193
+ incomplete({ cancelled: ['b'], scheduled: ['b'] }, [
194
+ { code: 'cancel_failed', logicalId: 'a' },
195
+ ]),
196
+ );
197
+ expect(await owned()).toEqual(
198
+ planned([
199
+ ['a', 1],
200
+ ['b', 4],
201
+ ]),
202
+ );
203
+ expect(await replace(specs)).toEqual(
204
+ complete({ kept: ['b'], cancelled: ['a'], scheduled: ['a'] }),
205
+ );
206
+ expect(await owned()).toEqual(planned(specs));
207
+ });
208
+
209
+ it('reports one failed schedule and converges on retry', async () => {
210
+ const { harness, owned, replace, planned } = await setup();
211
+ harness.faults.failSchedule(ownedNotificationIdentifier(OWNER.namespace, 'b'));
212
+ const specs: Spec[] = [
213
+ ['a', 1],
214
+ ['b', 2],
215
+ ['c', 3],
216
+ ];
217
+ expect(await replace(specs)).toEqual(
218
+ incomplete({ scheduled: ['a', 'c'] }, [{ code: 'schedule_failed', logicalId: 'b' }]),
219
+ );
220
+ expect(await replace(specs)).toEqual(complete({ kept: ['a', 'c'], scheduled: ['b'] }));
221
+ expect(await owned()).toEqual(planned(specs));
222
+ });
223
+
224
+ it('cancels stale requests but schedules nothing without permission', async () => {
225
+ const { harness, owned, replace, planned } = await setup();
226
+ await replace([
227
+ ['a', 1],
228
+ ['b', 2],
229
+ ]);
230
+ harness.faults.setPermission('denied');
231
+ expect(
232
+ await replace([
233
+ ['a', 1],
234
+ ['c', 3],
235
+ ]),
236
+ ).toEqual(
237
+ incomplete({ kept: ['a'], cancelled: ['b'] }, [
238
+ { code: 'permission_denied', logicalId: 'c' },
239
+ ]),
240
+ );
241
+ expect(await owned()).toEqual(planned([['a', 1]]));
242
+ expect(await replace([])).toEqual(complete({ cancelled: ['a'] }));
243
+ });
244
+
245
+ it('notices permission revoked after listing', async () => {
246
+ const { harness, replace } = await setup();
247
+ harness.faults.afterNextList(() => {
248
+ harness.faults.setPermission('denied');
249
+ });
250
+ expect(await replace([['a', 1]])).toEqual(
251
+ incomplete({}, [{ code: 'permission_denied', logicalId: 'a' }]),
252
+ );
253
+ });
254
+
255
+ it('reports a failed permission check', async () => {
256
+ const { harness, replace } = await setup();
257
+ harness.faults.failNextPermission();
258
+ expect(await replace([['a', 1]])).toEqual(
259
+ incomplete({}, [{ code: 'permission_failed', logicalId: 'a' }]),
260
+ );
261
+ });
262
+
263
+ it('stops at the pending limit, counting other owners, and keeps the earliest', async () => {
264
+ const { harness, owned, replace, planned } = await setup({ pendingLimit: LIMIT });
265
+ await harness.addUnrelated(UNRELATED);
266
+ await replace([['other', 1]], PREFIX_SIBLING);
267
+ expect(
268
+ await replace([
269
+ ['c', 3],
270
+ ['a', 1],
271
+ ['b', 2],
272
+ ]),
273
+ ).toEqual(
274
+ incomplete({ scheduled: ['a', 'b'] }, [{ code: 'schedule_limit', logicalId: 'c' }]),
275
+ );
276
+ expect(await owned()).toEqual(
277
+ planned([
278
+ ['a', 1],
279
+ ['b', 2],
280
+ ]),
281
+ );
282
+ });
283
+
284
+ it('coalesces queued replacements for one owner to the newest set', async () => {
285
+ const { harness, owned, replace, planned } = await setup();
286
+ const release = harness.faults.holdNextList();
287
+ const first = replace([['first', 1]]);
288
+ const second = replace([['second', 2]]);
289
+ const third = replace([['third', 3]]);
290
+ release();
291
+
292
+ expect(await second).toEqual({
293
+ status: 'superseded',
294
+ kept: [],
295
+ cancelled: [],
296
+ scheduled: [],
297
+ failures: [],
298
+ });
299
+ expect(await first).toEqual(complete({ scheduled: ['first'] }));
300
+ expect(await third).toEqual(complete({ cancelled: ['first'], scheduled: ['third'] }));
301
+ expect(await owned()).toEqual(planned([['third', 3]]));
302
+ });
303
+
304
+ it('does not make different owners wait for each other', async () => {
305
+ const { harness, owned, replace, planned } = await setup();
306
+ const release = harness.faults.holdNextList();
307
+ const held = replace([['a', 1]]);
308
+ expect(await replace([['b', 2]], PREFIX_SIBLING)).toEqual(complete({ scheduled: ['b'] }));
309
+ release();
310
+ expect(await held).toEqual(complete({ scheduled: ['a'] }));
311
+ expect(await owned()).toEqual(planned([['a', 1]]));
312
+ expect(await owned(PREFIX_SIBLING)).toEqual(planned([['b', 2]]));
313
+ });
314
+
315
+ it('rejects invalid owners and duplicate logical IDs before touching the platform', async () => {
316
+ const { harness, identifiers, replace } = await setup();
317
+ harness.faults.failNextList();
318
+ await expect(replace([['a', 1]], { namespace: 'Bad Namespace' })).rejects.toMatchObject({
319
+ code: 'invalid_namespace',
320
+ });
321
+ await expect(
322
+ replace([
323
+ ['a', 1],
324
+ ['a', 2],
325
+ ]),
326
+ ).rejects.toBeInstanceOf(NotificationPlanError);
327
+ expect(await replace([])).toEqual(incomplete({}, [{ code: 'list_failed' }]));
328
+ expect(await identifiers()).toEqual([]);
329
+ });
330
+
331
+ it('keeps notification content out of outcomes', async () => {
332
+ const { harness, replace } = await setup({ pendingLimit: 1 });
333
+ harness.faults.failSchedule(ownedNotificationIdentifier(OWNER.namespace, 'a'));
334
+ const outcome = await replace([
335
+ ['a', 1],
336
+ ['b', 2],
337
+ ['c', 3],
338
+ ]);
339
+ expect(outcome.status).toBe('incomplete');
340
+ expect(JSON.stringify(outcome)).not.toContain(PRIVATE_COPY);
341
+ });
342
+ });
343
+ }
344
+
345
+ type OutcomeLists = Partial<
346
+ Pick<OwnedNotificationReplacementOutcome, 'kept' | 'cancelled' | 'scheduled'>
347
+ >;
348
+
349
+ function complete(lists: OutcomeLists): OwnedNotificationReplacementOutcome {
350
+ return { status: 'complete', ...emptyLists(lists), failures: [] };
351
+ }
352
+
353
+ function incomplete(
354
+ lists: OutcomeLists,
355
+ failures: OwnedNotificationReplacementOutcome['failures'],
356
+ ): OwnedNotificationReplacementOutcome {
357
+ return { status: 'incomplete', ...emptyLists(lists), failures };
358
+ }
359
+
360
+ function emptyLists(lists: OutcomeLists) {
361
+ return {
362
+ kept: lists.kept ?? [],
363
+ cancelled: lists.cancelled ?? [],
364
+ scheduled: lists.scheduled ?? [],
365
+ };
366
+ }
@@ -0,0 +1,107 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ import type { FoldPolicy, GapPolicy, LocalTimeTransition } from '@baukit/localization-core';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import { NotificationPlanError } from './errors.js';
7
+ import { resolveNotificationOccurrences } from './occurrences.js';
8
+
9
+ interface ZonedTimeOutcome {
10
+ readonly gap: GapPolicy;
11
+ readonly fold: FoldPolicy;
12
+ readonly instant?: string;
13
+ readonly error?: string;
14
+ }
15
+
16
+ interface ZonedTimeCase {
17
+ readonly name: string;
18
+ readonly civilDate: string;
19
+ readonly civilTime: string;
20
+ readonly timeZone: string;
21
+ readonly transition: LocalTimeTransition | null;
22
+ readonly outcomes: readonly ZonedTimeOutcome[];
23
+ }
24
+
25
+ interface ZonedTimeFixture {
26
+ readonly cases: readonly ZonedTimeCase[];
27
+ }
28
+
29
+ const fixtureUrl = new URL('../../../../fixtures/zoned-time/vectors-v1.json', import.meta.url);
30
+ const readUtf8File = readFileSync as unknown as (path: URL, encoding: 'utf8') => string;
31
+ const fixture = JSON.parse(readUtf8File(fixtureUrl, 'utf8')) as ZonedTimeFixture;
32
+
33
+ const MILLISECONDS_PER_DAY = 86_400_000;
34
+ const DAYS_BEFORE_OCCURRENCE = 2;
35
+ const HORIZON_DAYS = 5;
36
+ const FALLBACK_NOW = Date.parse('2026-01-01T12:00:00Z');
37
+ const LOGICAL_ID = 'occurrence';
38
+ const NONEXISTENT = 'nonexistent_local_time';
39
+
40
+ function nowBefore(civilDate: string): number {
41
+ const noon = Date.parse(`${civilDate}T12:00:00Z`);
42
+ return Number.isNaN(noon) ? FALLBACK_NOW : noon - DAYS_BEFORE_OCCURRENCE * MILLISECONDS_PER_DAY;
43
+ }
44
+
45
+ function resolve(entry: ZonedTimeCase, outcome: ZonedTimeOutcome) {
46
+ return resolveNotificationOccurrences({
47
+ occurrences: [
48
+ {
49
+ logicalId: LOGICAL_ID,
50
+ civilDate: entry.civilDate,
51
+ civilTime: entry.civilTime,
52
+ contentDigest: 'v1',
53
+ },
54
+ ],
55
+ timeZone: entry.timeZone,
56
+ gap: outcome.gap,
57
+ fold: outcome.fold,
58
+ clock: () => nowBefore(entry.civilDate),
59
+ horizonDays: HORIZON_DAYS,
60
+ });
61
+ }
62
+
63
+ function thrown(action: () => unknown): unknown {
64
+ try {
65
+ action();
66
+ } catch (error) {
67
+ return error;
68
+ }
69
+ return undefined;
70
+ }
71
+
72
+ const outcomeCases = fixture.cases.flatMap((entry) =>
73
+ entry.outcomes.map(
74
+ (outcome) => [`${entry.name} gap=${outcome.gap} fold=${outcome.fold}`, entry, outcome] as const,
75
+ ),
76
+ );
77
+ const scheduled = outcomeCases.filter(([, , outcome]) => outcome.error === undefined);
78
+ const skipped = outcomeCases.filter(([, , outcome]) => outcome.error === NONEXISTENT);
79
+ const rejected = outcomeCases.filter(
80
+ ([, , outcome]) => outcome.error !== undefined && outcome.error !== NONEXISTENT,
81
+ );
82
+
83
+ describe('zoned local time vectors through notification planning', () => {
84
+ it.each(scheduled)('%s schedules the vector instant', (_label, entry, outcome) => {
85
+ const resolution = resolve(entry, outcome);
86
+ expect(resolution.skipped).toEqual([]);
87
+ expect(resolution.desired).toMatchObject([
88
+ {
89
+ logicalId: LOGICAL_ID,
90
+ epochMilliseconds: Date.parse(outcome.instant ?? ''),
91
+ transition: entry.transition,
92
+ },
93
+ ]);
94
+ });
95
+
96
+ it.each(skipped)('%s skips the missing local time', (_label, entry, outcome) => {
97
+ const resolution = resolve(entry, outcome);
98
+ expect(resolution.desired).toEqual([]);
99
+ expect(resolution.skipped).toEqual([{ logicalId: LOGICAL_ID, reason: NONEXISTENT }]);
100
+ });
101
+
102
+ it.each(rejected)('%s rejects the input', (_label, entry, outcome) => {
103
+ const error = thrown(() => resolve(entry, outcome));
104
+ expect(error).toBeInstanceOf(NotificationPlanError);
105
+ expect(error).toMatchObject({ code: outcome.error });
106
+ });
107
+ });