@marktiderman/genesis-supabase 0.2.0 → 0.3.1

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 (39) hide show
  1. package/README.md +377 -23
  2. package/dist/blocks.d.ts +19 -0
  3. package/dist/blocks.js +3 -0
  4. package/dist/blocks.js.map +1 -0
  5. package/dist/chunk-FAA2FTF4.js +110 -0
  6. package/dist/chunk-FAA2FTF4.js.map +1 -0
  7. package/dist/chunk-T2HGFC4C.js +37 -0
  8. package/dist/chunk-T2HGFC4C.js.map +1 -0
  9. package/dist/index-BSGz3RGk.d.ts +317 -0
  10. package/dist/index.d.ts +4 -103
  11. package/dist/index.js +60 -3
  12. package/dist/index.js.map +1 -1
  13. package/dist/native.d.ts +90 -6
  14. package/dist/native.js +70 -2
  15. package/dist/native.js.map +1 -1
  16. package/functions/genesis-apple-revoke/apple.ts +237 -0
  17. package/functions/genesis-apple-revoke/handler.ts +227 -0
  18. package/functions/genesis-apple-revoke/index.ts +55 -0
  19. package/functions/genesis-push-send/expo.ts +117 -0
  20. package/functions/genesis-push-send/handler.ts +462 -0
  21. package/functions/genesis-push-send/index.ts +54 -0
  22. package/functions/genesis-support-cleanup/handler.ts +80 -0
  23. package/functions/genesis-support-cleanup/index.ts +39 -0
  24. package/functions/genesis-support-intake/contract.ts +193 -0
  25. package/functions/genesis-support-intake/handler.ts +170 -0
  26. package/functions/genesis-support-intake/index.ts +47 -0
  27. package/functions/genesis-support-notify/handler.ts +167 -0
  28. package/functions/genesis-support-notify/index.ts +15 -0
  29. package/functions/tests/genesis-apple-revoke_test.ts +81 -0
  30. package/functions/tests/genesis-push-send_test.ts +53 -0
  31. package/migrations/0003_genesis_apple_token_revocation.sql +205 -0
  32. package/migrations/0004_genesis_tenant_features.sql +333 -0
  33. package/migrations/0005_genesis_push.sql +524 -0
  34. package/migrations/0006_genesis_support.sql +846 -0
  35. package/migrations/tests/0003_genesis_apple_token_revocation.test.sql +230 -0
  36. package/migrations/tests/0004_genesis_tenant_features.test.sql +418 -0
  37. package/migrations/tests/0005_genesis_push.test.sql +407 -0
  38. package/migrations/tests/0006_genesis_support.test.sql +552 -0
  39. package/package.json +91 -4
@@ -0,0 +1,407 @@
1
+ -- pgTAP test for 0005_genesis_push.sql.
2
+ -- Copy into your app's `supabase/tests/database/` and run `supabase test db`.
3
+ -- It reads only its own users and rows and rolls everything back. (The
4
+ -- receipt cases push any other due receipt out of reach first, inside this
5
+ -- transaction.)
6
+ begin;
7
+ create extension if not exists pgtap with schema extensions;
8
+ select plan(79);
9
+
10
+ insert into auth.users (id, aud, role, email, instance_id)
11
+ select ('00000000-0000-4000-8000-0000000005' || n)::uuid, 'authenticated', 'authenticated', 'genesis-push-' || n || '@example.test', '00000000-0000-0000-0000-000000000000'
12
+ from unnest(array['a1', 'b2', 'c3']) n;
13
+
14
+ create function pg_temp.u(n text) returns uuid language sql as $$ select ('00000000-0000-4000-8000-0000000005' || n)::uuid $$;
15
+ create function pg_temp.tok(n text) returns text language sql as $$ select 'ExponentPushToken[genesis-test-' || n || ']' $$;
16
+ -- Signs in as one of the test users (both claim settings, as 0001's test explains), or as nobody.
17
+ create function pg_temp.as_user(n text) returns void language plpgsql as $$
18
+ begin
19
+ perform set_config('request.jwt.claims', case when n is null then '{"role":"authenticated"}' else json_build_object('sub', pg_temp.u(n), 'role', 'authenticated')::text end, true);
20
+ perform set_config('request.jwt.claim.sub', coalesce(pg_temp.u(n)::text, ''), true);
21
+ end;
22
+ $$;
23
+
24
+ -- Tables: row level security on, only what each role needs ----------------------
25
+ select ok((select relrowsecurity from pg_class where oid = 'public.push_devices'::regclass), 'push_devices has row level security on');
26
+ select ok((select relrowsecurity from pg_class where oid = 'public.notification_preferences'::regclass), 'notification_preferences has row level security on');
27
+ select ok((select relrowsecurity from pg_class where oid = 'public.push_receipts'::regclass), 'push_receipts has row level security on');
28
+ select ok(
29
+ not exists (
30
+ select 1
31
+ from unnest(array['public.push_devices', 'public.notification_preferences', 'public.push_receipts']) t(tbl),
32
+ unnest(array['select', 'insert', 'update', 'delete', 'truncate', 'references', 'trigger']) p(priv)
33
+ where has_table_privilege('anon', t.tbl, p.priv)
34
+ ),
35
+ 'anon has no privilege on any of the three tables'
36
+ );
37
+ select ok(
38
+ has_table_privilege('authenticated', 'public.push_devices', 'select')
39
+ and has_table_privilege('authenticated', 'public.push_devices', 'delete')
40
+ and not has_table_privilege('authenticated', 'public.push_devices', 'insert')
41
+ and not has_table_privilege('authenticated', 'public.push_devices', 'update')
42
+ and not has_table_privilege('authenticated', 'public.push_devices', 'truncate'),
43
+ 'authenticated may only read and delete push_devices (writes go through genesis_push_register)'
44
+ );
45
+ select ok(
46
+ has_table_privilege('authenticated', 'public.push_receipts', 'select')
47
+ and not has_table_privilege('authenticated', 'public.push_receipts', 'insert')
48
+ and not has_table_privilege('authenticated', 'public.push_receipts', 'update')
49
+ and not has_table_privilege('authenticated', 'public.push_receipts', 'delete'),
50
+ 'authenticated may only read push_receipts'
51
+ );
52
+ select ok(
53
+ not exists (
54
+ select 1 from pg_class c, aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) a
55
+ where c.oid in ('public.push_devices'::regclass, 'public.notification_preferences'::regclass, 'public.push_receipts'::regclass) and a.grantee = 0
56
+ ),
57
+ 'public has no grant on any of the three tables'
58
+ );
59
+
60
+ -- Functions: security definer, empty search_path, executable by name -------------
61
+ create temp table genesis_fns (sig regprocedure, who text);
62
+ insert into genesis_fns values
63
+ ('public.genesis_push_register(text, text, text)'::regprocedure, 'authenticated'),
64
+ ('public.genesis_push_forget(text)'::regprocedure, 'anyone'),
65
+ ('public.genesis_push_status(uuid, text, boolean, boolean, timestamptz)'::regprocedure, 'service'),
66
+ ('public.genesis_push_targets(uuid, text, boolean, boolean, timestamptz)'::regprocedure, 'service'),
67
+ ('public.genesis_push_tickets_record(jsonb)'::regprocedure, 'service'),
68
+ ('public.genesis_push_receipts_claim(integer)'::regprocedure, 'service'),
69
+ ('public.genesis_push_receipts_settle(jsonb)'::regprocedure, 'service'),
70
+ ('public.genesis_push_devices_forget(uuid[])'::regprocedure, 'service');
71
+
72
+ select ok(
73
+ (select bool_and(p.prosecdef and p.proconfig @> array['search_path=""']) from genesis_fns f join pg_proc p on p.oid = f.sig),
74
+ 'every function is security definer with an empty search_path'
75
+ );
76
+ select ok(
77
+ (select bool_and(p.proowner::regrole::text = 'postgres') from genesis_fns f join pg_proc p on p.oid = f.sig),
78
+ 'every function is owned by postgres, as hosted migrations run'
79
+ );
80
+ select ok(
81
+ (select bool_and(has_function_privilege('anon', f.sig, 'execute') = (f.who = 'anyone')) from genesis_fns f),
82
+ 'anon can execute genesis_push_forget and nothing else'
83
+ );
84
+ select ok(
85
+ (select bool_and(has_function_privilege('authenticated', f.sig, 'execute') = (f.who in ('anyone', 'authenticated'))) from genesis_fns f),
86
+ 'authenticated can execute genesis_push_register and genesis_push_forget and nothing else'
87
+ );
88
+ select ok(
89
+ (select bool_and(has_function_privilege('service_role', f.sig, 'execute')) from genesis_fns f where f.who = 'service'),
90
+ 'service_role can execute the six server functions'
91
+ );
92
+ select ok(
93
+ not exists (
94
+ select 1 from genesis_fns f join pg_proc p on p.oid = f.sig, aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) a
95
+ where a.grantee = 0 and a.privilege_type = 'EXECUTE'
96
+ ),
97
+ 'public has no execute grant on any of them'
98
+ );
99
+ select ok(
100
+ not has_function_privilege('authenticated', 'private.genesis_notification_preferences_check()', 'execute')
101
+ and not has_function_privilege('anon', 'private.genesis_notification_preferences_check()', 'execute')
102
+ and not has_function_privilege('authenticated', 'private.genesis_push_local_instant(timestamp, text)', 'execute')
103
+ and not has_function_privilege('anon', 'private.genesis_push_local_instant(timestamp, text)', 'execute'),
104
+ 'nobody but the owner can call the private helpers directly'
105
+ );
106
+
107
+ -- Registering a device ----------------------------------------------------------
108
+ select pg_temp.as_user(null);
109
+ set local role authenticated;
110
+ select throws_ok($$ select public.genesis_push_register(pg_temp.tok('x'), 'ios') $$, '42501', null, 'registering without a signed-in person is refused');
111
+ reset role;
112
+
113
+ set local role anon;
114
+ select throws_ok($$ select public.genesis_push_register(pg_temp.tok('x'), 'ios') $$, '42501', null, 'anon cannot register a device');
115
+ reset role;
116
+
117
+ select pg_temp.as_user('a1');
118
+ set local role authenticated;
119
+ select isnt(public.genesis_push_register(pg_temp.tok('a1'), 'ios', '1.0.0'), null, 'a signed-in person registers this device and gets its id');
120
+ select is(
121
+ public.genesis_push_register(pg_temp.tok('a1'), 'ios', '1.0.1'),
122
+ (select id from public.push_devices where token = pg_temp.tok('a1')),
123
+ 'registering the same token again keeps one row'
124
+ );
125
+ select is((select app_version from public.push_devices where token = pg_temp.tok('a1')), '1.0.1', '... and updates its app version');
126
+ select throws_ok($$ select public.genesis_push_register('not-a-token', 'ios') $$, '23514', 'genesis_push_register: malformed token, platform or app version', 'a malformed token is refused without echoing it');
127
+ select throws_ok($$ select public.genesis_push_register(pg_temp.tok('a9'), 'windows') $$, '23514', null, 'an unknown platform is refused');
128
+ select throws_ok($$ select public.genesis_push_register(pg_temp.tok('a9'), null) $$, '23514', 'genesis_push_register: malformed token, platform or app version', 'a missing platform is refused without quoting the row (and its token)');
129
+ select throws_ok($$ insert into public.push_devices (user_id, token, platform) values (pg_temp.u('a1'), pg_temp.tok('a8'), 'ios') $$, '42501', null, 'a person cannot insert a device row directly');
130
+ select throws_ok($$ update public.push_devices set user_id = pg_temp.u('b2') $$, '42501', null, 'a person cannot update a device row');
131
+ reset role;
132
+
133
+ select pg_temp.as_user('b2');
134
+ set local role authenticated;
135
+ select lives_ok($$ select public.genesis_push_register(pg_temp.tok('b2'), 'android') $$, 'a second person registers their own device');
136
+ select is((select count(*) from public.push_devices where token = pg_temp.tok('a1')), 0::bigint, 'a person cannot see another person''s device');
137
+ select is((select count(*) from public.push_devices), 1::bigint, '... and sees exactly their own');
138
+ delete from public.push_devices where token = pg_temp.tok('a1');
139
+ reset role;
140
+ select is((select count(*) from public.push_devices where token = pg_temp.tok('a1')), 1::bigint, 'a person cannot delete another person''s device');
141
+
142
+ -- The phone changes hands: b2 signs in on a1's phone.
143
+ select pg_temp.as_user('b2');
144
+ set local role authenticated;
145
+ do $$ begin perform public.genesis_push_register(pg_temp.tok('a1'), 'ios'); end $$;
146
+ reset role;
147
+ select is((select user_id from public.push_devices where token = pg_temp.tok('a1')), pg_temp.u('b2'), 'a token registered by another login moves to it');
148
+
149
+ -- At most ten devices per person, always keeping the one just registered.
150
+ select pg_temp.as_user('c3');
151
+ set local role authenticated;
152
+ do $$ begin perform public.genesis_push_register(pg_temp.tok('c3-' || n), 'ios') from generate_series(1, 11) n; end $$;
153
+ reset role;
154
+ select is((select count(*) from public.push_devices where user_id = pg_temp.u('c3')), 10::bigint, 'a person keeps at most ten devices');
155
+ select is((select count(*) from public.push_devices where token = pg_temp.tok('c3-11')), 1::bigint, '... and the one just registered is one of them');
156
+
157
+ -- Forgetting a token ------------------------------------------------------------
158
+ set local role anon;
159
+ select ok(public.genesis_push_forget(pg_temp.tok('c3-11')), 'anyone holding a token can forget it (a phone whose session already ended)');
160
+ select ok(not public.genesis_push_forget(pg_temp.tok('nope')), '... and an unknown token forgets nothing');
161
+ reset role;
162
+ select pg_temp.as_user('c3');
163
+ set local role authenticated;
164
+ delete from public.push_devices where id = (select id from public.push_devices order by id limit 1);
165
+ reset role;
166
+ select is((select count(*) from public.push_devices where user_id = pg_temp.u('c3')), 8::bigint, 'a person can delete their own device');
167
+
168
+ -- Preferences ---------------------------------------------------------------------
169
+ select pg_temp.as_user('a1');
170
+ set local role authenticated;
171
+ select lives_ok(
172
+ $$ insert into public.notification_preferences (categories, hide_previews) values ('{"marketing": false}', true) $$,
173
+ 'a person saves their own preferences (user_id defaults to theirs)'
174
+ );
175
+ select throws_ok(
176
+ $$ insert into public.notification_preferences (user_id) values (pg_temp.u('b2')) $$,
177
+ '42501', null, 'a person cannot write another person''s preferences'
178
+ );
179
+ select throws_ok(
180
+ $$ update public.notification_preferences set time_zone = 'Mars/Olympus_Mons' $$,
181
+ '22023', null, 'an unknown time zone is refused'
182
+ );
183
+ select throws_ok(
184
+ $$ update public.notification_preferences set categories = '{"Bad Key": true}' $$,
185
+ '22023', null, 'a malformed category id is refused'
186
+ );
187
+ select throws_ok(
188
+ $$ update public.notification_preferences set categories = '{"messages": "yes"}' $$,
189
+ '23514', null, 'a category value that is not true or false is refused'
190
+ );
191
+ select throws_ok(
192
+ $$ update public.notification_preferences set quiet_hours_enabled = true, time_zone = null $$,
193
+ '23514', null, 'quiet hours without a time zone are refused'
194
+ );
195
+ reset role;
196
+ select pg_temp.as_user('b2');
197
+ set local role authenticated;
198
+ select is((select count(*) from public.notification_preferences), 0::bigint, 'a person cannot read another person''s preferences');
199
+ update public.notification_preferences set hide_previews = false;
200
+ reset role;
201
+ select ok((select hide_previews from public.notification_preferences where user_id = pg_temp.u('a1')), '... nor change them');
202
+
203
+ -- Who to push to: categories, quiet hours, privacy -----------------------------------
204
+ set local role service_role;
205
+ select is(
206
+ (select row(deliver, reason, hide_previews)::text from public.genesis_push_status(pg_temp.u('c3'), 'messages')),
207
+ '(t,ok,f)', 'without preferences, every category is delivered with its preview'
208
+ );
209
+ select is(
210
+ (select row(deliver, reason)::text from public.genesis_push_status(pg_temp.u('a1'), 'marketing')),
211
+ '(f,category_off)', 'a category the person turned off is not delivered'
212
+ );
213
+ select is((select count(*) from public.genesis_push_targets(pg_temp.u('a1'), 'marketing')), 0::bigint, '... and has no targets');
214
+ select is(
215
+ (select deliver from public.genesis_push_status(pg_temp.u('c3'), 'digest', p_default_on => false)),
216
+ false, 'a category that is off by default is not delivered until the person turns it on'
217
+ );
218
+ select is(
219
+ (select count(*) from public.genesis_push_targets(pg_temp.u('c3'), 'messages')), 8::bigint,
220
+ 'targets are every device of that person, and only theirs'
221
+ );
222
+ select throws_ok($$ select * from public.genesis_push_status(pg_temp.u('c3'), 'Bad Category') $$, '22023', null, 'a malformed category is refused');
223
+ reset role;
224
+
225
+ set local role anon;
226
+ select throws_ok($$ select * from public.genesis_push_targets(pg_temp.u('c3'), 'messages') $$, '42501', null, 'anon cannot ask for anyone''s tokens');
227
+ reset role;
228
+ select pg_temp.as_user('c3');
229
+ set local role authenticated;
230
+ select throws_ok($$ select * from public.genesis_push_targets(pg_temp.u('c3'), 'messages') $$, '42501', null, 'a signed-in person cannot ask for tokens, not even their own');
231
+ reset role;
232
+
233
+ -- Quiet hours 22:00 to 07:00 in New York (EST is UTC-5, EDT UTC-4).
234
+ update public.notification_preferences set quiet_hours_enabled = true, quiet_hours_start = '22:00', quiet_hours_end = '07:00', time_zone = 'America/New_York', categories = '{}'
235
+ where user_id = pg_temp.u('a1');
236
+ set local role service_role;
237
+ select is(
238
+ (select row(deliver, reason, quiet_until)::text from public.genesis_push_status(pg_temp.u('a1'), 'messages', p_at => '2026-01-15 04:00Z')),
239
+ (select row(false, 'quiet_hours', '2026-01-15 12:00Z'::timestamptz)::text),
240
+ '23:00 in New York is quiet, until 07:00 there the next morning'
241
+ );
242
+ select is(
243
+ (select row(deliver, quiet_until)::text from public.genesis_push_status(pg_temp.u('a1'), 'messages', p_at => '2026-01-15 11:59Z')),
244
+ (select row(false, '2026-01-15 12:00Z'::timestamptz)::text),
245
+ '06:59 in New York is still quiet, until 07:00 the same morning'
246
+ );
247
+ select is(
248
+ (select deliver from public.genesis_push_status(pg_temp.u('a1'), 'messages', p_at => '2026-01-15 12:00Z')),
249
+ true, '07:00 in New York is not quiet (the end is exclusive)'
250
+ );
251
+ select is(
252
+ (select deliver from public.genesis_push_status(pg_temp.u('a1'), 'messages', p_at => '2026-01-15 03:00Z')),
253
+ false, '22:00 in New York is quiet (the start is inclusive), although it is 03:00 in UTC'
254
+ );
255
+ select is(
256
+ (select deliver from public.genesis_push_status(pg_temp.u('a1'), 'messages', p_at => '2026-01-15 20:00Z')),
257
+ true, '15:00 in New York is not quiet'
258
+ );
259
+ select is(
260
+ (select quiet_until from public.genesis_push_status(pg_temp.u('a1'), 'messages', p_at => '2026-03-08 06:00Z')),
261
+ '2026-03-08 11:00Z'::timestamptz,
262
+ 'across the spring daylight-saving change, quiet hours still end at 07:00 local (EDT, so 11:00 UTC)'
263
+ );
264
+ select is(
265
+ (select deliver from public.genesis_push_status(pg_temp.u('a1'), 'messages', p_bypass_quiet_hours => true, p_at => '2026-01-15 04:00Z')),
266
+ true, 'a push the sender marks urgent goes through quiet hours'
267
+ );
268
+ select is(
269
+ (select count(*) from public.genesis_push_targets(pg_temp.u('a1'), 'messages', p_at => '2026-01-15 04:00Z')), 0::bigint,
270
+ 'during quiet hours there are no targets'
271
+ );
272
+ reset role;
273
+
274
+ -- Clock changes in New York: spring forward 2027-03-14 02:00 EST -> 03:00 EDT, fall back 2027-11-07 02:00 EDT -> 01:00 EST.
275
+ reset role;
276
+ update public.notification_preferences set quiet_hours_start = '01:00', quiet_hours_end = '02:30' where user_id = pg_temp.u('a1');
277
+ set local role service_role;
278
+ select is(
279
+ (select row(deliver, quiet_until)::text from public.genesis_push_status(pg_temp.u('a1'), 'messages', p_at => '2027-03-14 06:30Z')),
280
+ (select row(false, '2027-03-14 07:00Z'::timestamptz)::text),
281
+ 'an end time the spring change skips (02:30) ends quiet hours at the change (03:00 EDT)'
282
+ );
283
+ select is(
284
+ (select deliver from public.genesis_push_status(pg_temp.u('a1'), 'messages', p_at => '2027-03-14 07:00Z')),
285
+ true, '... and at the change it is no longer quiet'
286
+ );
287
+ reset role;
288
+ update public.notification_preferences set quiet_hours_start = '22:00', quiet_hours_end = '01:30' where user_id = pg_temp.u('a1');
289
+ set local role service_role;
290
+ select is(
291
+ (select row(deliver, quiet_until)::text from public.genesis_push_status(pg_temp.u('a1'), 'messages', p_at => '2027-11-07 05:15Z')),
292
+ (select row(false, '2027-11-07 05:30Z'::timestamptz)::text),
293
+ 'on the fall-back night, 01:15 EDT is quiet until the first 01:30'
294
+ );
295
+ select is(
296
+ (select deliver from public.genesis_push_status(pg_temp.u('a1'), 'messages', p_at => '2027-11-07 06:15Z')),
297
+ true, '... and the repeated 01:15 (EST) is not quiet again'
298
+ );
299
+ reset role;
300
+ update public.notification_preferences set quiet_hours_start = '22:00', quiet_hours_end = '07:00' where user_id = pg_temp.u('a1');
301
+ set local role service_role;
302
+ select is(
303
+ (select quiet_until from public.genesis_push_status(pg_temp.u('a1'), 'messages', p_at => '2027-11-07 06:15Z')),
304
+ '2027-11-07 12:00Z'::timestamptz,
305
+ 'a night that spans the fall-back change still ends at 07:00 EST'
306
+ );
307
+ reset role;
308
+
309
+ -- A window that doesn't cross midnight, in Tokyo (UTC+9, no daylight saving).
310
+ update public.notification_preferences set quiet_hours_start = '09:00', quiet_hours_end = '17:00', time_zone = 'Asia/Tokyo'
311
+ where user_id = pg_temp.u('a1');
312
+ set local role service_role;
313
+ select is(
314
+ (select row(deliver, quiet_until)::text from public.genesis_push_status(pg_temp.u('a1'), 'messages', p_at => '2026-01-15 01:00Z')),
315
+ (select row(false, '2026-01-15 08:00Z'::timestamptz)::text),
316
+ '10:00 in Tokyo is inside 09:00 to 17:00, quiet until 17:00 there'
317
+ );
318
+ select is(
319
+ (select deliver from public.genesis_push_status(pg_temp.u('a1'), 'messages', p_at => '2026-01-15 09:00Z')),
320
+ true, '18:00 in Tokyo is outside 09:00 to 17:00'
321
+ );
322
+ reset role;
323
+ update public.notification_preferences set quiet_hours_end = '09:00' where user_id = pg_temp.u('a1');
324
+ set local role service_role;
325
+ select is(
326
+ (select deliver from public.genesis_push_status(pg_temp.u('a1'), 'messages', p_at => '2026-01-15 00:30Z')),
327
+ true, 'a start equal to the end means no quiet hours'
328
+ );
329
+ reset role;
330
+
331
+ -- Privacy previews travel with each target.
332
+ update public.notification_preferences set quiet_hours_enabled = false where user_id = pg_temp.u('a1');
333
+ select pg_temp.as_user('a1');
334
+ set local role authenticated;
335
+ do $$ begin perform public.genesis_push_register(pg_temp.tok('a1-new'), 'ios'); end $$;
336
+ reset role;
337
+ set local role service_role;
338
+ select ok(
339
+ (select bool_and(hide_previews) and count(*) = 1 from public.genesis_push_targets(pg_temp.u('a1'), 'messages')),
340
+ 'a person who hides previews gets targets marked hide_previews'
341
+ );
342
+ reset role;
343
+
344
+ -- Tickets and receipts --------------------------------------------------------------
345
+ update public.push_receipts set check_after = now() + interval '1 year' where status = 'sent';
346
+ set local role service_role;
347
+ select is(
348
+ public.genesis_push_tickets_record(jsonb_build_array(
349
+ jsonb_build_object('user_id', pg_temp.u('a1'), 'device_id', (select id from public.push_devices where token = pg_temp.tok('a1-new')), 'category', 'messages', 'ticket_id', 'ticket-1', 'status', 'sent'),
350
+ jsonb_build_object('user_id', pg_temp.u('a1'), 'device_id', null, 'category', 'messages', 'status', 'failed', 'error', 'MessageTooBig'),
351
+ jsonb_build_object('user_id', '00000000-0000-4000-8000-0000000005ff', 'category', 'messages', 'ticket_id', 'ticket-x', 'status', 'sent')
352
+ )),
353
+ 2, 'tickets are recorded, skipping a login that no longer exists'
354
+ );
355
+ select is((select count(*) from public.genesis_push_receipts_claim(100)), 0::bigint, 'a receipt is not polled before Expo has it (15 minutes)');
356
+ reset role;
357
+ update public.push_receipts set check_after = now() - interval '1 second' where ticket_id = 'ticket-1';
358
+ set local role service_role;
359
+ select is((select array_agg(ticket_id) from public.genesis_push_receipts_claim(100)), array['ticket-1'], 'a due receipt is handed out once ...');
360
+ select is((select count(*) from public.genesis_push_receipts_claim(100)), 0::bigint, '... and leased, so a second poller doesn''t take it');
361
+ select is(
362
+ public.genesis_push_receipts_settle(jsonb_build_array(jsonb_build_object('id', (select id from public.push_receipts where ticket_id = 'ticket-1'), 'status', 'failed', 'error', 'DeviceNotRegistered'))),
363
+ 1, 'a receipt is settled'
364
+ );
365
+ reset role;
366
+ select ok(
367
+ (select status = 'failed' and error = 'DeviceNotRegistered' and device_id is null from public.push_receipts where ticket_id = 'ticket-1'),
368
+ 'DeviceNotRegistered fails the receipt ...'
369
+ );
370
+ select is((select count(*) from public.push_devices where token = pg_temp.tok('a1-new')), 0::bigint, '... and forgets the dead device');
371
+
372
+ select pg_temp.as_user('b2');
373
+ set local role authenticated;
374
+ select is((select count(*) from public.push_receipts), 0::bigint, 'a person cannot read another person''s receipts');
375
+ reset role;
376
+ select pg_temp.as_user('a1');
377
+ set local role authenticated;
378
+ select is((select count(*) from public.push_receipts), 2::bigint, '... and reads their own');
379
+ reset role;
380
+
381
+ -- A late DeviceNotRegistered for a device registered again since the push leaves it alone.
382
+ select pg_temp.as_user('b2');
383
+ set local role authenticated;
384
+ do $$ begin perform public.genesis_push_register(pg_temp.tok('b2-late'), 'ios'); end $$;
385
+ reset role;
386
+ insert into public.push_receipts (user_id, device_id, category, ticket_id, status, created_at, check_after)
387
+ select pg_temp.u('b2'), id, 'messages', 'ticket-late', 'sent', now() - interval '1 hour', now() - interval '1 second'
388
+ from public.push_devices where token = pg_temp.tok('b2-late');
389
+ set local role service_role;
390
+ select is(
391
+ public.genesis_push_receipts_settle(jsonb_build_array(jsonb_build_object('id', (select id from public.push_receipts where ticket_id = 'ticket-late'), 'status', 'failed', 'error', 'DeviceNotRegistered'))),
392
+ 1, 'a late receipt is settled ...'
393
+ );
394
+ reset role;
395
+ select is((select count(*) from public.push_devices where token = pg_temp.tok('b2-late')), 1::bigint, '... without deleting a device registered again after the push');
396
+
397
+ -- Deleting the login takes everything with it ----------------------------------------
398
+ delete from auth.users where id = pg_temp.u('a1');
399
+ select ok(
400
+ not exists (select 1 from public.push_devices where user_id = pg_temp.u('a1'))
401
+ and not exists (select 1 from public.notification_preferences where user_id = pg_temp.u('a1'))
402
+ and not exists (select 1 from public.push_receipts where user_id = pg_temp.u('a1')),
403
+ 'deleting a login deletes its devices, preferences and receipts'
404
+ );
405
+
406
+ select * from finish();
407
+ rollback;