@chipmobilesdk/rn-notification 0.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +306 -0
  3. package/package.json +73 -0
  4. package/src/channel/migrate.ts +211 -0
  5. package/src/channel/tone-map.ts +90 -0
  6. package/src/contract/channel.ts +89 -0
  7. package/src/contract/diagnostics.ts +102 -0
  8. package/src/contract/errors.ts +123 -0
  9. package/src/contract/index.ts +10 -0
  10. package/src/contract/interaction.ts +62 -0
  11. package/src/contract/notification.ts +118 -0
  12. package/src/contract/permission.ts +60 -0
  13. package/src/contract/reconcile.ts +88 -0
  14. package/src/contract/recurrence.ts +123 -0
  15. package/src/diagnostics/burst.ts +101 -0
  16. package/src/diagnostics/events.ts +72 -0
  17. package/src/diagnostics/redact.ts +72 -0
  18. package/src/diagnostics/snapshot.ts +64 -0
  19. package/src/engine/adapter.ts +79 -0
  20. package/src/engine/notify-kit-engine.ts +535 -0
  21. package/src/index.ts +173 -0
  22. package/src/initialize.ts +174 -0
  23. package/src/interaction/index.ts +146 -0
  24. package/src/maintenance/cycle.ts +147 -0
  25. package/src/maintenance/permission-refresh.ts +41 -0
  26. package/src/maintenance/reanchor.ts +95 -0
  27. package/src/maintenance/toggle.ts +60 -0
  28. package/src/maintenance/verify.ts +83 -0
  29. package/src/maintenance/window-refresh.ts +61 -0
  30. package/src/permission/index.ts +183 -0
  31. package/src/permission/state.ts +77 -0
  32. package/src/push/envelope.ts +165 -0
  33. package/src/push/fcm-provider.ts +146 -0
  34. package/src/push/gate.ts +190 -0
  35. package/src/push/index.ts +29 -0
  36. package/src/push/instructions.ts +109 -0
  37. package/src/push/provider-adapter.ts +54 -0
  38. package/src/push/signout.ts +65 -0
  39. package/src/reconcile/chunk.ts +53 -0
  40. package/src/reconcile/diff.ts +145 -0
  41. package/src/reconcile/duplicates.ts +73 -0
  42. package/src/reconcile/index.ts +175 -0
  43. package/src/reconcile/truncate.ts +56 -0
  44. package/src/recurrence/clock-guard.ts +71 -0
  45. package/src/recurrence/expand.ts +292 -0
  46. package/src/recurrence/index.ts +177 -0
  47. package/src/schedule/anchor.ts +267 -0
  48. package/src/schedule/index.ts +173 -0
  49. package/src/schedule/partition.ts +85 -0
  50. package/src/schedule/resolve-moment.ts +80 -0
  51. package/src/schedule/validate-content.ts +111 -0
  52. package/src/schedule/validate-identifier.ts +114 -0
  53. package/src/state/caps.ts +71 -0
  54. package/src/state/dedup-ledger.ts +39 -0
  55. package/src/state/interaction-buffer.ts +73 -0
  56. package/src/state/mmkv-store.ts +71 -0
  57. package/src/state/permission-history.ts +41 -0
  58. package/src/state/record.ts +246 -0
  59. package/src/state/reset.ts +41 -0
  60. package/src/state/series-ledger.ts +84 -0
  61. package/src/state/store.ts +40 -0
  62. package/src/testing/conformance/index.ts +194 -0
  63. package/src/testing/fake-engine.ts +284 -0
  64. package/src/testing/fake-provider.ts +129 -0
  65. package/src/testing/faults.ts +72 -0
  66. package/src/testing/index.ts +26 -0
@@ -0,0 +1,246 @@
1
+ /**
2
+ * The persisted state record.
3
+ *
4
+ * Six collections, holding only what the platform will not tell us. No
5
+ * notification content, no push token, no desired set. The interaction buffer
6
+ * is the sole content-bearing exception, and it evicts on delivery.
7
+ *
8
+ * Corrupt or incompatible state is discarded and rebuilt rather than blocking:
9
+ * everything already scheduled lives in the OS and is unaffected, which is what
10
+ * makes discarding safe rather than destructive.
11
+ */
12
+
13
+ import type { StateStore } from './store';
14
+ import { evict, COLLECTION_CAPS, type CollectionName, type Timestamped } from './caps';
15
+ import type { AnchoringMode, RoutingPayload } from '../contract/notification';
16
+ import type { CollectionHealth, StateHealth } from '../contract/diagnostics';
17
+ import { DIAGNOSTIC_CODES, type DiagnosticCode } from '../contract/errors';
18
+
19
+ const STATE_KEY = 'chipmobilesdk.notification.state';
20
+
21
+ /**
22
+ * Bumped whenever the shape below changes. A mismatch discards and rebuilds —
23
+ * never a migration, because none of this data is worth migrating and all of it
24
+ * is reconstructible.
25
+ */
26
+ export const STATE_SCHEMA_VERSION = 1;
27
+
28
+ export interface DedupEntry extends Timestamped {
29
+ readonly dedupId: string;
30
+ readonly ownerTag?: string;
31
+ }
32
+
33
+ export interface BufferedInteraction extends Timestamped {
34
+ readonly id: string;
35
+ readonly kind: 'tap' | 'action' | 'dismiss';
36
+ readonly notificationId: string;
37
+ readonly actionId?: string;
38
+ readonly inputText?: string;
39
+ /** The one content-bearing thing persisted. Evicted the moment it is delivered. */
40
+ readonly routing?: RoutingPayload;
41
+ readonly ownerTag?: string;
42
+ }
43
+
44
+ export interface MigrationEntry extends Timestamped {
45
+ readonly toneId: string;
46
+ readonly appliedVersion: number;
47
+ readonly channelId: string;
48
+ readonly retired: boolean;
49
+ }
50
+
51
+ export interface SeriesEntry extends Timestamped {
52
+ readonly seriesId: string;
53
+ readonly anchoring: AnchoringMode;
54
+ readonly expandedThrough: number;
55
+ readonly emittedCount: number;
56
+ readonly exhausted: boolean;
57
+ }
58
+
59
+ export interface PermissionHistoryEntry extends Timestamped {
60
+ readonly key: string;
61
+ readonly requested: boolean;
62
+ }
63
+
64
+ export interface StateRecord {
65
+ readonly schemaVersion: number;
66
+ readonly dedupLedger: readonly DedupEntry[];
67
+ readonly interactionBuffer: readonly BufferedInteraction[];
68
+ readonly migrationLedger: readonly MigrationEntry[];
69
+ readonly seriesLedger: readonly SeriesEntry[];
70
+ readonly permissionHistory: readonly PermissionHistoryEntry[];
71
+ readonly lastReconcileSummary?: Readonly<Record<string, unknown>>;
72
+ readonly lastMaintenanceSummary?: Readonly<Record<string, unknown>>;
73
+ }
74
+
75
+ export function emptyRecord(): StateRecord {
76
+ return {
77
+ schemaVersion: STATE_SCHEMA_VERSION,
78
+ dedupLedger: [],
79
+ interactionBuffer: [],
80
+ migrationLedger: [],
81
+ seriesLedger: [],
82
+ permissionHistory: [],
83
+ };
84
+ }
85
+
86
+ export interface StateEvent {
87
+ readonly code: DiagnosticCode;
88
+ readonly context?: Readonly<Record<string, unknown>>;
89
+ }
90
+
91
+ export interface StateManagerOptions {
92
+ readonly store: StateStore;
93
+ readonly now: () => number;
94
+ readonly emit?: (event: StateEvent) => void;
95
+ }
96
+
97
+ /**
98
+ * Owns the record's lifecycle. Reads are synchronous and cheap; writes are
99
+ * best-effort and never propagate a failure to the caller's operation.
100
+ */
101
+ export class StateManager {
102
+ private record: StateRecord;
103
+ private rebuilt = false;
104
+ private readonly store: StateStore;
105
+ private readonly now: () => number;
106
+ private readonly emit: (event: StateEvent) => void;
107
+
108
+ constructor(options: StateManagerOptions) {
109
+ this.store = options.store;
110
+ this.now = options.now;
111
+ this.emit = options.emit ?? (() => {});
112
+ this.record = this.load();
113
+ }
114
+
115
+ private load(): StateRecord {
116
+ const raw = this.store.getString(STATE_KEY);
117
+ if (raw === undefined) {
118
+ return emptyRecord();
119
+ }
120
+
121
+ try {
122
+ const parsed = JSON.parse(raw) as Partial<StateRecord>;
123
+ if (parsed.schemaVersion !== STATE_SCHEMA_VERSION) {
124
+ return this.discardAndRebuild('schema-version-mismatch', {
125
+ found: parsed.schemaVersion,
126
+ expected: STATE_SCHEMA_VERSION,
127
+ });
128
+ }
129
+ return {
130
+ schemaVersion: STATE_SCHEMA_VERSION,
131
+ dedupLedger: parsed.dedupLedger ?? [],
132
+ interactionBuffer: parsed.interactionBuffer ?? [],
133
+ migrationLedger: parsed.migrationLedger ?? [],
134
+ seriesLedger: parsed.seriesLedger ?? [],
135
+ permissionHistory: parsed.permissionHistory ?? [],
136
+ lastReconcileSummary: parsed.lastReconcileSummary,
137
+ lastMaintenanceSummary: parsed.lastMaintenanceSummary,
138
+ };
139
+ } catch {
140
+ return this.discardAndRebuild('unparseable');
141
+ }
142
+ }
143
+
144
+ private discardAndRebuild(
145
+ reason: string,
146
+ extra?: Readonly<Record<string, unknown>>,
147
+ ): StateRecord {
148
+ this.rebuilt = true;
149
+ this.store.delete(STATE_KEY);
150
+ this.emit({
151
+ code: DIAGNOSTIC_CODES.STATE_CORRUPT_REBUILT,
152
+ // Note what is actually lost, so the consequence is stated rather than implied.
153
+ context: { reason, lost: ['dedupLedger', 'interactionBuffer'], ...extra },
154
+ });
155
+ return emptyRecord();
156
+ }
157
+
158
+ /** Never throws. A storage failure degrades the affected guarantee, nothing more. */
159
+ private persist(): void {
160
+ try {
161
+ this.store.setString(STATE_KEY, JSON.stringify(this.record));
162
+ } catch (error) {
163
+ this.emit({
164
+ code: DIAGNOSTIC_CODES.STATE_WRITE_FAILED,
165
+ context: { reason: error instanceof Error ? error.name : 'unknown' },
166
+ });
167
+ }
168
+ }
169
+
170
+ read(): StateRecord {
171
+ return this.record;
172
+ }
173
+
174
+ /** Applies a change, evicts the touched collection, and persists. */
175
+ update<K extends CollectionName>(
176
+ collection: K,
177
+ mutate: (current: StateRecord[K]) => StateRecord[K],
178
+ ): void {
179
+ const next = mutate(this.record[collection]);
180
+ const { kept, evicted, reason } = evict(next as readonly Timestamped[], collection, this.now());
181
+
182
+ if (evicted.length > 0) {
183
+ this.emit({
184
+ code:
185
+ collection === 'interactionBuffer'
186
+ ? DIAGNOSTIC_CODES.INTERACTION_BUFFER_EVICTED
187
+ : DIAGNOSTIC_CODES.STATE_CAP_EVICTED,
188
+ context: { collection, evicted: evicted.length, reason },
189
+ });
190
+ }
191
+
192
+ this.record = { ...this.record, [collection]: kept } as StateRecord;
193
+ this.persist();
194
+ }
195
+
196
+ setSummary(key: 'lastReconcileSummary' | 'lastMaintenanceSummary', value: Readonly<Record<string, unknown>>): void {
197
+ this.record = { ...this.record, [key]: value };
198
+ this.persist();
199
+ }
200
+
201
+ /** Clears everything. Public through `resetState()`. */
202
+ reset(): void {
203
+ this.record = emptyRecord();
204
+ this.rebuilt = false;
205
+ this.store.delete(STATE_KEY);
206
+ }
207
+
208
+ /**
209
+ * Clears one owner tag's entries, leaving every other tag untouched.
210
+ *
211
+ * Collections without an owner tag are left alone: a migration ledger and a
212
+ * series ledger describe the device, not an account.
213
+ */
214
+ clearOwner(ownerTag: string): void {
215
+ this.record = {
216
+ ...this.record,
217
+ dedupLedger: this.record.dedupLedger.filter(e => e.ownerTag !== ownerTag),
218
+ interactionBuffer: this.record.interactionBuffer.filter(e => e.ownerTag !== ownerTag),
219
+ permissionHistory: this.record.permissionHistory.filter(
220
+ e => !e.key.startsWith(`${ownerTag}:`),
221
+ ),
222
+ };
223
+ this.persist();
224
+ }
225
+
226
+ health(): StateHealth {
227
+ const collections: CollectionHealth[] = (
228
+ Object.keys(COLLECTION_CAPS) as CollectionName[]
229
+ ).map(name => {
230
+ const entries = this.record[name] as readonly Timestamped[];
231
+ return {
232
+ name,
233
+ size: entries.length,
234
+ cap: COLLECTION_CAPS[name].maxEntries,
235
+ oldestEntryAt: entries.length > 0 ? Math.min(...entries.map(e => e.at)) : undefined,
236
+ };
237
+ });
238
+
239
+ return {
240
+ readable: true,
241
+ rebuilt: this.rebuilt,
242
+ collections,
243
+ approximateBytes: this.store.approximateBytes(),
244
+ };
245
+ }
246
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Public state reset and per-owner clearing.
3
+ *
4
+ * These are the only two ways a consumer may touch the persisted record. The
5
+ * record itself is unreachable — a consumer can discard state, but can never
6
+ * read or write it.
7
+ */
8
+
9
+ import type { StateManager } from './record';
10
+
11
+ export interface ResetOutcome {
12
+ readonly clearedCollections: readonly string[];
13
+ }
14
+
15
+ /** Discards the entire record. Nothing already scheduled with the OS is affected. */
16
+ export function resetState(state: StateManager): ResetOutcome {
17
+ state.reset();
18
+ return {
19
+ clearedCollections: [
20
+ 'dedupLedger',
21
+ 'interactionBuffer',
22
+ 'migrationLedger',
23
+ 'seriesLedger',
24
+ 'permissionHistory',
25
+ ],
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Clears one owner tag's entries on sign-out.
31
+ *
32
+ * Deliberately narrower than a full reset: the migration and series ledgers
33
+ * describe the device rather than an account, so signing one account out must
34
+ * not discard channel migration history that every account depends on.
35
+ */
36
+ export function clearOwner(state: StateManager, ownerTag: string): ResetOutcome {
37
+ state.clearOwner(ownerTag);
38
+ return {
39
+ clearedCollections: ['dedupLedger', 'interactionBuffer', 'permissionHistory'],
40
+ };
41
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The recurrence series ledger.
3
+ *
4
+ * What makes automatic maintenance possible without a desired set from the
5
+ * consumer: the package remembers which series it owns, how far each has been
6
+ * expanded, and how many occurrences a count-limited series has already
7
+ * emitted. None of that is derivable from the platform's pending set, which
8
+ * knows only about individual notifications.
9
+ *
10
+ * It also defines what maintenance is allowed to touch. An occurrence belongs
11
+ * to a series in this ledger, so cancelling it is within scope; anything else
12
+ * is a directly-scheduled notification the package does not own.
13
+ */
14
+
15
+ import type { StateManager, SeriesEntry } from './record';
16
+ import type { RecurrenceRule } from '../contract/recurrence';
17
+ import type { AnchoringMode } from '../contract/notification';
18
+
19
+ export interface SeriesRecord {
20
+ readonly seriesId: string;
21
+ readonly anchoring: AnchoringMode;
22
+ readonly expandedThrough: number;
23
+ readonly emittedCount: number;
24
+ readonly exhausted: boolean;
25
+ readonly at: number;
26
+ }
27
+
28
+ export function readSeries(state: StateManager, seriesId: string): SeriesRecord | undefined {
29
+ return state.read().seriesLedger.find(entry => entry.seriesId === seriesId);
30
+ }
31
+
32
+ export function listSeries(state: StateManager): readonly SeriesRecord[] {
33
+ return state.read().seriesLedger;
34
+ }
35
+
36
+ /** Records or updates a series. Replaces in place so a re-registration cannot duplicate. */
37
+ export function upsertSeries(
38
+ state: StateManager,
39
+ rule: RecurrenceRule,
40
+ update: { readonly expandedThrough: number; readonly emittedCount: number; readonly exhausted: boolean },
41
+ now: number,
42
+ ): void {
43
+ const entry: SeriesEntry = {
44
+ at: now,
45
+ seriesId: rule.id,
46
+ anchoring: rule.anchoring ?? 'wallClock',
47
+ expandedThrough: update.expandedThrough,
48
+ emittedCount: update.emittedCount,
49
+ exhausted: update.exhausted,
50
+ };
51
+
52
+ state.update('seriesLedger', current => [
53
+ ...current.filter(existing => existing.seriesId !== rule.id),
54
+ entry,
55
+ ]);
56
+ }
57
+
58
+ export function removeSeries(state: StateManager, seriesId: string): void {
59
+ state.update('seriesLedger', current => current.filter(entry => entry.seriesId !== seriesId));
60
+ }
61
+
62
+ /**
63
+ * Identifiers the package owns.
64
+ *
65
+ * An occurrence identifier is `<seriesId>#<instant>`, so ownership is decided
66
+ * by prefix rather than by an enumeration the ledger would have to keep in sync
67
+ * with the device. That keeps the check correct even for occurrences scheduled
68
+ * before the app last restarted.
69
+ */
70
+ export function ownsIdentifier(state: StateManager, id: string): boolean {
71
+ const separator = id.lastIndexOf('#');
72
+ if (separator === -1) return false;
73
+
74
+ const seriesId = id.slice(0, separator);
75
+ return state.read().seriesLedger.some(entry => entry.seriesId === seriesId);
76
+ }
77
+
78
+ /** Every occurrence identifier currently attributable to a tracked series. */
79
+ export function ownedIdentifiers(
80
+ state: StateManager,
81
+ pendingIds: readonly string[],
82
+ ): ReadonlySet<string> {
83
+ return new Set(pendingIds.filter(id => ownsIdentifier(state, id)));
84
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The storage port behind the persisted state record.
3
+ *
4
+ * Kept as a port so the pure state logic is testable with no native module,
5
+ * and so MMKV stays confined to one file. This is internal — a consumer can
6
+ * reset or clear state, but can never read or write the record directly.
7
+ */
8
+
9
+ export interface StateStore {
10
+ getString(key: string): string | undefined;
11
+ setString(key: string, value: string): void;
12
+ delete(key: string): void;
13
+ clearAll(): void;
14
+ /** Approximate on-disk size, for the health report. */
15
+ approximateBytes(): number;
16
+ }
17
+
18
+ /** In-memory store, used by tests and as the fallback when MMKV is unavailable. */
19
+ export function createMemoryStore(): StateStore {
20
+ const map = new Map<string, string>();
21
+ return {
22
+ getString: key => map.get(key),
23
+ setString: (key, value) => {
24
+ map.set(key, value);
25
+ },
26
+ delete: key => {
27
+ map.delete(key);
28
+ },
29
+ clearAll: () => {
30
+ map.clear();
31
+ },
32
+ approximateBytes: () => {
33
+ let total = 0;
34
+ for (const [key, value] of map) {
35
+ total += key.length + value.length;
36
+ }
37
+ return total;
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The shared conformance suite.
3
+ *
4
+ * The executable definition of what an engine adapter must do. Run against the
5
+ * real engine and against the fake, it is what backs the claim that testing
6
+ * with fakes proves something about production — if the two ever diverge, this
7
+ * is where it shows up rather than on a user's device.
8
+ *
9
+ * Written as a plain async function rather than a Jest suite so it can be
10
+ * driven from the demo app on a real device as well as from continuous
11
+ * integration.
12
+ */
13
+
14
+ import type { NotificationEngineAdapter } from '../../engine/adapter';
15
+ import type { ResolvedTone } from '../../contract/channel';
16
+
17
+ const TONE: ResolvedTone = {
18
+ toneId: 'conformance',
19
+ version: 1,
20
+ android: {
21
+ channelId: 'conformance.v1',
22
+ importance: 'default',
23
+ vibration: false,
24
+ badge: false,
25
+ bypassDnd: false,
26
+ },
27
+ ios: { categoryId: 'conformance', interruptionLevel: 'active', badge: false },
28
+ };
29
+
30
+ export interface ConformanceCheck {
31
+ readonly name: string;
32
+ readonly passed: boolean;
33
+ readonly detail?: string;
34
+ }
35
+
36
+ export interface ConformanceReport {
37
+ readonly adapter: string;
38
+ readonly checks: readonly ConformanceCheck[];
39
+ readonly passed: number;
40
+ readonly failed: number;
41
+ }
42
+
43
+ export interface ConformanceOptions {
44
+ /** Injected so the suite never depends on wall-clock time. */
45
+ readonly now?: () => number;
46
+ /** Identifier prefix, so a device run cannot collide with real notifications. */
47
+ readonly prefix?: string;
48
+ }
49
+
50
+ export async function runConformanceSuite(
51
+ adapter: NotificationEngineAdapter,
52
+ options: ConformanceOptions = {},
53
+ ): Promise<ConformanceReport> {
54
+ const now = options.now ?? (() => Date.now());
55
+ const prefix = options.prefix ?? 'conformance';
56
+ const checks: ConformanceCheck[] = [];
57
+
58
+ const check = async (name: string, run: () => Promise<boolean | string>): Promise<void> => {
59
+ try {
60
+ const outcome = await run();
61
+ checks.push(
62
+ outcome === true
63
+ ? { name, passed: true }
64
+ : { name, passed: false, detail: typeof outcome === 'string' ? outcome : 'returned false' },
65
+ );
66
+ } catch (error) {
67
+ checks.push({
68
+ name,
69
+ passed: false,
70
+ detail: error instanceof Error ? error.message : String(error),
71
+ });
72
+ }
73
+ };
74
+
75
+ const id = (suffix: string) => `${prefix}:${suffix}`;
76
+ const at = now() + 3_600_000;
77
+
78
+ const schedule = (suffix: string, overrides: Record<string, unknown> = {}) =>
79
+ adapter.schedule({
80
+ request: {
81
+ id: id(suffix),
82
+ content: { title: `Conformance ${suffix}`, body: 'Suite entry' },
83
+ tone: 'conformance',
84
+ moment: { at },
85
+ ...overrides,
86
+ },
87
+ resolvedAt: at,
88
+ tone: TONE,
89
+ approximate: false,
90
+ });
91
+
92
+ // Start clean so a previous run cannot mask a failure.
93
+ await adapter.cancelAll();
94
+
95
+ await check('reports its capabilities', async () => {
96
+ const capabilities = await adapter.getCapabilities();
97
+ return (
98
+ capabilities.pendingCeiling > 0 ||
99
+ `pendingCeiling must be positive, got ${capabilities.pendingCeiling}`
100
+ );
101
+ });
102
+
103
+ await check('schedules and reads back an entry', async () => {
104
+ await schedule('a');
105
+ const pending = await adapter.getPending();
106
+ return pending.some(entry => entry.id === id('a')) || 'scheduled entry was not returned';
107
+ });
108
+
109
+ await check('returns enough detail to diff, not merely to count', async () => {
110
+ const [entry] = (await adapter.getPending()).filter(e => e.id === id('a'));
111
+ if (entry === undefined) return 'entry missing';
112
+
113
+ return (
114
+ (entry.content.title.length > 0 && entry.resolvedAt > 0 && entry.anchoring !== undefined) ||
115
+ 'entry lacked content, instant, or anchoring mode'
116
+ );
117
+ });
118
+
119
+ await check('replaces rather than duplicates on the same identifier', async () => {
120
+ await schedule('a', { content: { title: 'Replaced', body: 'Newer' } });
121
+ const matching = (await adapter.getPending()).filter(entry => entry.id === id('a'));
122
+ return matching.length === 1 || `expected exactly 1 entry, found ${matching.length}`;
123
+ });
124
+
125
+ await check('round-trips a routing payload unchanged', async () => {
126
+ await schedule('routing', { routing: { screen: 'task', nested: { deep: true } } });
127
+ const entry = (await adapter.getPending()).find(e => e.id === id('routing'));
128
+ return (
129
+ JSON.stringify(entry?.routing) === JSON.stringify({ screen: 'task', nested: { deep: true } }) ||
130
+ `routing payload changed: ${JSON.stringify(entry?.routing)}`
131
+ );
132
+ });
133
+
134
+ await check('preserves the anchoring mode it was given', async () => {
135
+ await schedule('absolute', { moment: { at, anchoring: 'absolute' } });
136
+ const entry = (await adapter.getPending()).find(e => e.id === id('absolute'));
137
+ return entry?.anchoring === 'absolute' || `expected absolute, got ${entry?.anchoring}`;
138
+ });
139
+
140
+ await check('cancels one entry without touching the others', async () => {
141
+ const before = (await adapter.getPending()).length;
142
+ await adapter.cancel(id('a'));
143
+ const after = await adapter.getPending();
144
+
145
+ return (
146
+ (after.length === before - 1 && !after.some(entry => entry.id === id('a'))) ||
147
+ 'cancel removed the wrong number of entries'
148
+ );
149
+ });
150
+
151
+ await check('cancelling something absent is harmless', async () => {
152
+ await adapter.cancel(id('never-existed'));
153
+ return true;
154
+ });
155
+
156
+ await check('reports permission state without throwing', async () => {
157
+ const state = await adapter.getPermission('display');
158
+ return (
159
+ ['notAsked', 'granted', 'denied', 'permanentlyDenied', 'provisional', 'notRequired'].includes(
160
+ state,
161
+ ) || `unexpected permission state: ${state}`
162
+ );
163
+ });
164
+
165
+ await check('reports background restrictions, admitting unknown', async () => {
166
+ const report = await adapter.getRestrictions();
167
+ return (
168
+ ['unrestricted', 'restricted', 'unknown'].includes(report.batteryOptimization) ||
169
+ `unexpected restriction status: ${report.batteryOptimization}`
170
+ );
171
+ });
172
+
173
+ await check('returns pending entries in ascending delivery order', async () => {
174
+ const pending = await adapter.getPending();
175
+ const instants = pending.map(entry => entry.resolvedAt);
176
+ const sorted = [...instants].sort((x, y) => x - y);
177
+ return JSON.stringify(instants) === JSON.stringify(sorted) || 'entries were not time-ordered';
178
+ });
179
+
180
+ await check('cancelAll leaves nothing behind', async () => {
181
+ await adapter.cancelAll();
182
+ const pending = await adapter.getPending();
183
+ return pending.length === 0 || `${pending.length} entries survived cancelAll`;
184
+ });
185
+
186
+ const passed = checks.filter(entry => entry.passed).length;
187
+
188
+ return {
189
+ adapter: adapter.name,
190
+ checks,
191
+ passed,
192
+ failed: checks.length - passed,
193
+ };
194
+ }