1import type {
2 RelayAgentActivityAggregateState,
3 RelayAgentActivityState,
4} from "@t3tools/contracts/relay";
5import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";
6import { describe, expect, it } from "@effect/vitest";
7// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
8import * as NodeCrypto from "node:crypto";
9import * as Deferred from "effect/Deferred";
10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import * as Layer from "effect/Layer";
13import * as Logger from "effect/Logger";
14import * as Redacted from "effect/Redacted";
15import * as References from "effect/References";
16import * as Stream from "effect/Stream";
17import * as TestClock from "effect/testing/TestClock";
18import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
19
20import {
21 makeApnsDeliveryJobPayload,
22 signApnsDeliveryJob,
23 type SignedApnsDeliveryJob,
24} from "./apnsDeliveryJobs.ts";
25import * as DeliveryAttempts from "./DeliveryAttempts.ts";
26import * as LiveActivities from "./LiveActivities.ts";
27import * as RelayConfiguration from "../Config.ts";
28import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts";
29import * as AgentActivityRows from "./AgentActivityRows.ts";
30import * as ApnsDeliveries from "./ApnsDeliveries.ts";
31import * as ApnsClient from "./ApnsClient.ts";
32import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";
33
34const config = RelayConfiguration.RelayConfiguration.of({
35 relayIssuer: "https://relay.example.test",
36 apns: {
37 environment: "sandbox",
38 teamId: "team-id",
39 keyId: "key-id",
40 privateKey: Redacted.make("not-a-private-key"),
41 bundleId: "com.t3tools.t3code.dev",
42 },
43 apnsDeliveryJobSigningSecret: Redacted.make("job-signing-secret"),
44 clerkSecretKey: Redacted.make("clerk-secret"),
45 clerkPublishableKey: "pk_test_test",
46 clerkJwtAudience: "t3-code-relay",
47 cloudMintPrivateKey: Redacted.make("cloud-private-key"),
48 cloudMintPublicKey: "cloud-public-key",
49 managedEndpointBaseDomain: undefined,
50 managedEndpointNamespace: undefined,
51});
52
53const apnsSigningKeyPair = NodeCrypto.generateKeyPairSync("ec", {
54 namedCurve: "P-256",
55 privateKeyEncoding: { format: "pem", type: "pkcs8" },
56 publicKeyEncoding: { format: "pem", type: "spki" },
57});
58
59const signingConfig = RelayConfiguration.RelayConfiguration.of({
60 ...config,
61 apns: {
62 ...config.apns!,
63 privateKey: Redacted.make(apnsSigningKeyPair.privateKey),
64 },
65});
66
67const state: RelayAgentActivityState = {
68 environmentId: "env" as RelayAgentActivityState["environmentId"],
69 threadId: "thread" as RelayAgentActivityState["threadId"],
70 projectTitle: "Project",
71 threadTitle: "Thread",
72 modelTitle: "gpt-5.4",
73 phase: "running",
74 headline: "Running",
75 updatedAt: "1970-01-01T00:00:00.000Z",
76 deepLink: "/",
77};
78
79const aggregate: RelayAgentActivityAggregateState = {
80 title: "T3 Code",
81 subtitle: "Agent work in progress",
82 activeCount: 1,
83 updatedAt: state.updatedAt,
84 activities: [
85 {
86 environmentId: state.environmentId,
87 threadId: state.threadId,
88 projectTitle: state.projectTitle,
89 threadTitle: state.threadTitle,
90 modelTitle: state.modelTitle,
91 phase: state.phase,
92 status: "Working",
93 updatedAt: state.updatedAt,
94 deepLink: state.deepLink,
95 },
96 ],
97};
98
99const enabledPreferences = JSON.stringify({
100 liveActivitiesEnabled: true,
101 notificationsEnabled: true,
102 notifyOnApproval: true,
103 notifyOnInput: true,
104 notifyOnCompletion: true,
105 notifyOnFailure: true,
106});
107
108const disabledPreferences = JSON.stringify({
109 liveActivitiesEnabled: false,
110 notificationsEnabled: true,
111 notifyOnApproval: true,
112 notifyOnInput: true,
113 notifyOnCompletion: true,
114 notifyOnFailure: true,
115});
116
117const notificationsDisabledPreferences = JSON.stringify({
118 liveActivitiesEnabled: false,
119 notificationsEnabled: false,
120 notifyOnApproval: true,
121 notifyOnInput: true,
122 notifyOnCompletion: true,
123 notifyOnFailure: true,
124});
125
126const target: LiveActivities.TargetRow = {
127 user_id: "dev:julius",
128 device_id: "device-1",
129 platform: "ios",
130 ios_major_version: 18,
131 app_version: "1.0.0",
132 bundle_id: null,
133 aps_environment: null,
134 push_token: null,
135 push_to_start_token: "start-token",
136 preferences_json: enabledPreferences,
137 activity_push_token: "activity-token",
138 remote_start_queued_at: null,
139 remote_started_at: "1970-01-01T00:00:00.000Z",
140 ended_at: null,
141 last_aggregate_json: null,
142 last_live_activity_delivery_at: null,
143};
144
145function layerFor(input: {
146 readonly attempts: Array<DeliveryAttempts.DeliveryAttemptInput>;
147 readonly sourceJobClaims?: ReadonlyMap<string, DeliveryAttempts.DeliverySourceJobClaimResult>;
148 readonly queuedJobs?: Array<SignedApnsDeliveryJob>;
149 readonly queuedStarts?: Array<
150 Parameters<LiveActivities.LiveActivities["Service"]["markStartQueued"]>[0]
151 >;
152 readonly clearedStarts?: Array<
153 Parameters<LiveActivities.LiveActivities["Service"]["clearStartQueued"]>[0]
154 >;
155 readonly markedDeliveries?: Array<
156 Parameters<LiveActivities.LiveActivities["Service"]["markDelivery"]>[0]
157 >;
158 readonly invalidatedTokens?: Array<
159 Parameters<LiveActivities.LiveActivities["Service"]["invalidateDeliveryToken"]>[0]
160 >;
161 readonly currentTargets?: ReadonlyArray<LiveActivities.TargetRow>;
162 readonly config?: RelayConfiguration.RelayConfiguration["Service"];
163 // Live agent-activity rows returned by delivery-time state rechecks.
164 // Defaults to the fixture row so queued updates match unless a test is
165 // explicitly exercising stale-state behavior.
166 readonly activityStates?: ReadonlyArray<RelayAgentActivityState>;
167 // Current per-thread rows used to reject queued updates/notifications that
168 // have been superseded before APNs delivery.
169 readonly currentActivityStates?: ReadonlyArray<RelayAgentActivityState>;
170 readonly execute?: (
171 request: HttpClientRequest.HttpClientRequest,
172 ) => Effect.Effect<HttpClientResponse.HttpClientResponse>;
173}) {
174 return ApnsDeliveries.layer.pipe(
175 Layer.provide(ApnsClient.layer),
176 Layer.provide(ApnsProviderTokens.layer),
177 Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))),
178 Layer.provide(
179 Layer.mergeAll(
180 Layer.succeed(AgentActivityRows.AgentActivityRows, {
181 upsert: () => Effect.void,
182 remove: () => Effect.void,
183 pruneTerminal: () => Effect.void,
184 listForUser: () =>
185 input.activityStates !== undefined
186 ? Effect.succeed([...input.activityStates])
187 : Effect.succeed([state]),
188 getForUserThread: ({ environmentId, threadId }) =>
189 Effect.succeed(
190 (input.currentActivityStates ?? input.activityStates ?? [state]).find(
191 (current) =>
192 current.environmentId === environmentId && current.threadId === threadId,
193 ) ?? null,
194 ),
195 } satisfies AgentActivityRows.AgentActivityRows["Service"]),
196 Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, {
197 send: (body) =>
198 Effect.sync(() => {
199 input.queuedJobs?.push(body);
200 }),
201 }),
202 Layer.succeed(DeliveryAttempts.DeliveryAttempts, {
203 record: (attempt) =>
204 Effect.sync(() => {
205 input.attempts.push(attempt);
206 }),
207 claimSourceJob: (attempt) =>
208 Effect.sync(() => {
209 const claim = input.sourceJobClaims?.get(attempt.sourceJobId);
210 if (claim) {
211 return claim;
212 }
213 input.attempts.push(attempt);
214 return "claimed";
215 }),
216 completeSourceJob: (completion) =>
217 Effect.sync(() => {
218 const attempt = input.attempts.find(
219 (row) => row.sourceJobId === completion.sourceJobId,
220 );
221 if (attempt) {
222 Object.assign(attempt, completion);
223 }
224 }),
225 }),
226 Layer.succeed(LiveActivities.LiveActivities, {
227 register: () => Effect.void,
228 listTargets: () => Effect.succeed(input.currentTargets ?? [target]),
229 markStartQueued: (queued) =>
230 Effect.sync(() => {
231 input.queuedStarts?.push(queued);
232 }),
233 clearStartQueued: (cleared) =>
234 Effect.sync(() => {
235 input.clearedStarts?.push(cleared);
236 }),
237 markDelivery: (delivery) =>
238 Effect.sync(() => {
239 input.markedDeliveries?.push(delivery);
240 }),
241 invalidateDeliveryToken: (invalidated) =>
242 Effect.sync(() => {
243 input.invalidatedTokens?.push(invalidated);
244 }),
245 }),
246 RelayConfiguration.layer(input.config ?? config),
247 input.execute
248 ? Layer.succeed(HttpClient.HttpClient, HttpClient.make(input.execute))
249 : FetchHttpClient.layer,
250 ),
251 ),
252 );
253}
254
255describe("ApnsDeliveries", () => {
256 it.effect("skips Apple delivery when an Android-only relay disables APNs", () => {
257 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
258 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
259 return Effect.gen(function* () {
260 const service = yield* ApnsDeliveries.ApnsDeliveries;
261 expect(yield* service.sendForTarget({ target, aggregate, nowMs: 0 })).toBeNull();
262 expect(yield* service.sendPushNotificationForTarget({ target, aggregate })).toBeNull();
263 expect(
264 yield* service.sendLiveActivity({
265 target,
266 token: "activity-token",
267 kind: "live_activity_update",
268 aggregate,
269 }),
270 ).toMatchObject({ ok: false, apnsReason: "APNs is disabled for this relay." });
271 expect(
272 yield* service.sendPushNotification({
273 target,
274 token: "push-token",
275 notification: {
276 title: "Finished",
277 body: "Thread",
278 environmentId: state.environmentId,
279 threadId: state.threadId,
280 deepLink: state.deepLink,
281 },
282 }),
283 ).toMatchObject({ ok: false, apnsReason: "APNs is disabled for this relay." });
284 expect(queuedJobs).toHaveLength(0);
285 expect(attempts).toHaveLength(0);
286 }).pipe(
287 Effect.provide(
288 layerFor({
289 attempts,
290 queuedJobs,
291 config: { ...config, apns: null },
292 execute: () => Effect.die("Disabled APNs must not make HTTP requests"),
293 }),
294 ),
295 );
296 });
297
298 it.effect("never starts an activity remotely when no update token is registered", () => {
299 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
300 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
301 const queuedStarts: Array<
302 Parameters<LiveActivities.LiveActivities["Service"]["markStartQueued"]>[0]
303 > = [];
304
305 return Effect.gen(function* () {
306 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
307 const result = yield* deliveries.sendForTarget({
308 target: {
309 ...target,
310 activity_push_token: null,
311 remote_started_at: null,
312 ended_at: "1970-01-01T00:00:05.000Z",
313 },
314 aggregate,
315 nowMs: 10_000,
316 });
317
318 // Activities are armed by the app in the foreground; the relay never
319 // uses the push-to-start token, so the only fallback is the push
320 // notification channel (none here: the aggregate is not an attention
321 // phase).
322 expect(result).toBeNull();
323 expect(queuedJobs).toEqual([]);
324 expect(queuedStarts).toEqual([]);
325 expect(attempts).toEqual([]);
326 }).pipe(Effect.provide(layerFor({ attempts, queuedJobs, queuedStarts })));
327 });
328
329 it.effect("ends the armed card when nothing remains to show", () => {
330 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
331 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
332
333 return Effect.gen(function* () {
334 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
335 // Within the freshly-armed grace window an empty aggregate delivers
336 // nothing: the environment's first publish may still be in flight.
337 const graced = yield* deliveries.sendForTarget({
338 target,
339 aggregate: null,
340 nowMs: 5_000,
341 });
342 expect(graced).toBeNull();
343
344 const result = yield* deliveries.sendForTarget({
345 target,
346 aggregate: null,
347 nowMs: 5_000 + 3 * 60 * 1_000,
348 });
349
350 // An armed card always shows content (live or recently finished work);
351 // once the aggregate is empty the card ends rather than rendering an
352 // empty state. The app re-arms on the next open with content.
353 expect(result?.kind).toBe("live_activity_end");
354 expect(result?.ok).toBe(true);
355 expect(queuedJobs).toMatchObject([
356 {
357 payload: {
358 kind: "live_activity_end",
359 target: {
360 token: "activity-token",
361 },
362 },
363 },
364 ]);
365 expect(attempts).toEqual([]);
366 }).pipe(Effect.provide(layerFor({ attempts, queuedJobs })));
367 });
368
369 it.effect("does not queue a remote start when Live Activities are disabled", () => {
370 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
371 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
372
373 return Effect.gen(function* () {
374 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
375 const result = yield* deliveries.sendForTarget({
376 target: {
377 ...target,
378 activity_push_token: null,
379 remote_started_at: null,
380 ended_at: null,
381 preferences_json: disabledPreferences,
382 },
383 aggregate,
384 nowMs: 5_000,
385 });
386
387 expect(result).toBeNull();
388 expect(queuedJobs).toEqual([]);
389 expect(attempts).toEqual([]);
390 }).pipe(Effect.provide(layerFor({ attempts, queuedJobs })));
391 });
392
393 it.effect("does not queue a duplicate remote start while a start is already queued", () => {
394 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
395 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
396
397 return Effect.gen(function* () {
398 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
399 const result = yield* deliveries.sendForTarget({
400 target: {
401 ...target,
402 activity_push_token: null,
403 remote_start_queued_at: "1970-01-01T00:00:03.000Z",
404 remote_started_at: null,
405 ended_at: null,
406 },
407 aggregate,
408 nowMs: 5_000,
409 });
410
411 expect(result).toBeNull();
412 expect(queuedJobs).toEqual([]);
413 expect(attempts).toEqual([]);
414 }).pipe(Effect.provide(layerFor({ attempts, queuedJobs })));
415 });
416
417 it.effect("queues bounded Live Activity aggregate payloads", () => {
418 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
419 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
420 const longTitle = "x".repeat(300);
421 const inputAggregate: RelayAgentActivityAggregateState = {
422 ...aggregate,
423 title: longTitle,
424 subtitle: longTitle,
425 activities: [0, 1, 2, 3, 4, 5].map((index) =>
426 Object.assign({}, aggregate.activities[0]!, {
427 projectTitle: longTitle,
428 threadTitle: longTitle,
429 modelTitle: longTitle,
430 status: longTitle,
431 threadId: `thread-${index}` as RelayAgentActivityState["threadId"],
432 deepLink: "https://example.test/not-an-app-link",
433 }),
434 ),
435 };
436
437 return Effect.gen(function* () {
438 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
439 yield* deliveries.sendForTarget({
440 target,
441 aggregate: inputAggregate,
442 nowMs: 10_000,
443 });
444
445 const payloadAggregate = queuedJobs[0]?.payload.aggregate;
446 expect(payloadAggregate?.title.length).toBeLessThanOrEqual(120);
447 expect(payloadAggregate?.subtitle.length).toBeLessThanOrEqual(120);
448 expect(payloadAggregate?.activities).toHaveLength(5);
449 expect(payloadAggregate?.activities[0]?.projectTitle.length).toBeLessThanOrEqual(120);
450 expect(payloadAggregate?.activities[0]?.status.length).toBeLessThanOrEqual(40);
451 expect(payloadAggregate?.activities[0]?.deepLink).toBe("/");
452 expect(attempts).toEqual([]);
453 }).pipe(Effect.provide(layerFor({ attempts, queuedJobs })));
454 });
455
456 it.effect("queues Live Activity jobs with the device's APNs routing", () => {
457 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
458 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
459
460 return Effect.gen(function* () {
461 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
462 yield* deliveries.sendForTarget({
463 target: {
464 ...target,
465 bundle_id: "com.t3tools.t3code.preview",
466 aps_environment: "production",
467 ended_at: "1970-01-01T00:00:05.000Z",
468 },
469 aggregate,
470 nowMs: 10_000,
471 });
472
473 expect(queuedJobs).toMatchObject([
474 {
475 payload: {
476 kind: "live_activity_update",
477 target: {
478 token: "activity-token",
479 bundleId: "com.t3tools.t3code.preview",
480 apsEnvironment: "production",
481 },
482 },
483 },
484 ]);
485 }).pipe(Effect.provide(layerFor({ attempts, queuedJobs })));
486 });
487
488 it.effect("sends signed jobs to the device's APNs environment and bundle topic", () => {
489 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
490 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
491 const payload = makeApnsDeliveryJobPayload({
492 kind: "live_activity_update",
493 userId: target.user_id,
494 deviceId: target.device_id,
495 token: "activity-token",
496 bundleId: "com.t3tools.t3code.preview",
497 apsEnvironment: "sandbox",
498 aggregate,
499 createdAt: "1970-01-01T00:00:00.000Z",
500 expiresAt: "1970-01-01T00:10:00.000Z",
501 jobId: "job-routing-1",
502 });
503 const signed = signApnsDeliveryJob({
504 secret: config.apnsDeliveryJobSigningSecret,
505 payload,
506 });
507 const execute = (request: HttpClientRequest.HttpClientRequest) =>
508 Effect.sync(() => {
509 requests.push(request);
510 return HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
511 });
512
513 return Effect.gen(function* () {
514 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
515 const result = yield* deliveries.processSignedJob(signed);
516
517 expect(result.ok).toBe(true);
518 expect(requests).toHaveLength(1);
519 expect(requests[0]?.url).toBe("https://api.sandbox.push.apple.com/3/device/activity-token");
520 expect(requests[0]?.headers["apns-topic"]).toBe(
521 "com.t3tools.t3code.preview.push-type.liveactivity",
522 );
523 }).pipe(
524 Effect.provide(
525 layerFor({
526 attempts,
527 currentTargets: [
528 { ...target, bundle_id: "com.t3tools.t3code.preview", aps_environment: "sandbox" },
529 ],
530 config: signingConfig,
531 execute,
532 }),
533 ),
534 );
535 });
536
537 it.effect(
538 "suppresses all deliveries when the aggregate is unchanged while a row awaits input",
539 () => {
540 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
541 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
542 const waitingAggregate: RelayAgentActivityAggregateState = {
543 ...aggregate,
544 activities: [
545 {
546 ...aggregate.activities[0]!,
547 phase: "waiting_for_input",
548 status: "Input",
549 },
550 ],
551 };
552 const waitingAggregateJson = JSON.stringify(waitingAggregate);
553
554 return Effect.gen(function* () {
555 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
556 const result = yield* deliveries.sendForTarget({
557 target: {
558 ...target,
559 // A registered alert token must not turn the suppressed Live
560 // Activity update into an alert push on every republish.
561 push_token: "apns-device-token",
562 last_aggregate_json: waitingAggregateJson,
563 last_live_activity_delivery_at: "1970-01-01T00:00:04.000Z",
564 },
565 aggregate: waitingAggregate,
566 nowMs: 5_000,
567 });
568
569 expect(result).toBeNull();
570 expect(queuedJobs).toEqual([]);
571 expect(attempts).toEqual([]);
572 }).pipe(Effect.provide(layerFor({ attempts, queuedJobs })));
573 },
574 );
575
576 it.effect(
577 "queues an update inside the throttle window when a changed aggregate awaits input",
578 () => {
579 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
580 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
581 const waitingAggregate: RelayAgentActivityAggregateState = {
582 ...aggregate,
583 activities: [
584 {
585 ...aggregate.activities[0]!,
586 phase: "waiting_for_input",
587 status: "Input",
588 },
589 ],
590 };
591 const previousAggregateJson = JSON.stringify(aggregate);
592
593 return Effect.gen(function* () {
594 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
595 const result = yield* deliveries.sendForTarget({
596 target: {
597 ...target,
598 last_aggregate_json: previousAggregateJson,
599 last_live_activity_delivery_at: "1970-01-01T00:00:04.000Z",
600 },
601 aggregate: waitingAggregate,
602 nowMs: 5_000,
603 });
604
605 expect(result?.kind).toBe("live_activity_update");
606 expect(queuedJobs).toMatchObject([
607 {
608 payload: {
609 kind: "live_activity_update",
610 target: {
611 token: "activity-token",
612 },
613 },
614 },
615 ]);
616 }).pipe(Effect.provide(layerFor({ attempts, queuedJobs })));
617 },
618 );
619
620 it.effect(
621 "throttles updates for changed aggregates with stable counts and no pending attention",
622 () => {
623 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
624 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
625 const changedAggregate: RelayAgentActivityAggregateState = {
626 ...aggregate,
627 updatedAt: "1970-01-01T00:00:04.000Z",
628 };
629 const previousAggregateJson = JSON.stringify(aggregate);
630
631 return Effect.gen(function* () {
632 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
633 const result = yield* deliveries.sendForTarget({
634 target: {
635 ...target,
636 last_aggregate_json: previousAggregateJson,
637 last_live_activity_delivery_at: "1970-01-01T00:00:04.000Z",
638 },
639 aggregate: changedAggregate,
640 nowMs: 5_000,
641 });
642
643 expect(result).toBeNull();
644 expect(queuedJobs).toEqual([]);
645 }).pipe(Effect.provide(layerFor({ attempts, queuedJobs })));
646 },
647 );
648
649 it.effect("queues an end for an active Live Activity when Live Activities are disabled", () => {
650 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
651 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
652
653 return Effect.gen(function* () {
654 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
655 const result = yield* deliveries.sendForTarget({
656 target: {
657 ...target,
658 preferences_json: disabledPreferences,
659 },
660 aggregate,
661 nowMs: 5_000,
662 });
663
664 expect(result?.kind).toBe("live_activity_end");
665 expect(queuedJobs).toMatchObject([
666 {
667 payload: {
668 kind: "live_activity_end",
669 target: {
670 token: "activity-token",
671 },
672 },
673 },
674 ]);
675 expect(attempts).toEqual([]);
676 }).pipe(Effect.provide(layerFor({ attempts, queuedJobs })));
677 });
678
679 it.effect(
680 "queues an alert while ending an active Live Activity when only Live Activities are disabled",
681 () => {
682 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
683 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
684 const inputAggregate: RelayAgentActivityAggregateState = {
685 ...aggregate,
686 activities: [
687 {
688 ...aggregate.activities[0]!,
689 phase: "waiting_for_input",
690 status: "Input",
691 },
692 ],
693 };
694
695 return Effect.gen(function* () {
696 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
697 const result = yield* deliveries.sendForTarget({
698 target: {
699 ...target,
700 push_token: "apns-device-token",
701 preferences_json: disabledPreferences,
702 },
703 aggregate: inputAggregate,
704 nowMs: 5_000,
705 });
706
707 expect(result?.kind).toBe("live_activity_end");
708 expect(queuedJobs).toMatchObject([
709 {
710 payload: {
711 kind: "live_activity_end",
712 target: {
713 token: "activity-token",
714 },
715 },
716 },
717 {
718 payload: {
719 kind: "push_notification",
720 target: {
721 token: "apns-device-token",
722 },
723 notification: {
724 title: "Thread",
725 body: "Input: Project",
726 environmentId: "env",
727 threadId: "thread",
728 deepLink: "/",
729 phase: "waiting_for_input",
730 updatedAt: state.updatedAt,
731 },
732 },
733 },
734 ]);
735 expect(attempts).toEqual([]);
736 }).pipe(Effect.provide(layerFor({ attempts, queuedJobs })));
737 },
738 );
739
740 it.effect("does not queue alert pushes when notification permission is disabled", () => {
741 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
742 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
743 const inputAggregate: RelayAgentActivityAggregateState = {
744 ...aggregate,
745 activities: [
746 {
747 ...aggregate.activities[0]!,
748 phase: "waiting_for_input",
749 status: "Input",
750 },
751 ],
752 };
753
754 return Effect.gen(function* () {
755 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
756 const result = yield* deliveries.sendForTarget({
757 target: {
758 ...target,
759 push_token: "apns-device-token",
760 preferences_json: notificationsDisabledPreferences,
761 },
762 aggregate: inputAggregate,
763 nowMs: 5_000,
764 });
765
766 expect(result?.kind).toBe("live_activity_end");
767 expect(queuedJobs).toMatchObject([
768 {
769 payload: {
770 kind: "live_activity_end",
771 target: {
772 token: "activity-token",
773 },
774 },
775 },
776 ]);
777 expect(attempts).toEqual([]);
778 }).pipe(Effect.provide(layerFor({ attempts, queuedJobs })));
779 });
780
781 it.effect(
782 "queues a push notification for approval and input states when no Live Activity delivery is available",
783 () => {
784 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
785 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
786 const inputAggregate: RelayAgentActivityAggregateState = {
787 ...aggregate,
788 activities: [
789 {
790 ...aggregate.activities[0]!,
791 phase: "waiting_for_input",
792 status: "Input",
793 },
794 ],
795 };
796
797 return Effect.gen(function* () {
798 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
799 const result = yield* deliveries.sendForTarget({
800 target: {
801 ...target,
802 push_token: "apns-device-token",
803 push_to_start_token: null,
804 activity_push_token: null,
805 remote_started_at: null,
806 },
807 aggregate: inputAggregate,
808 nowMs: 5_000,
809 });
810
811 expect(result?.kind).toBe("push_notification");
812 expect(result?.ok).toBe(true);
813 expect(queuedJobs).toMatchObject([
814 {
815 payload: {
816 kind: "push_notification",
817 target: {
818 token: "apns-device-token",
819 },
820 notification: {
821 title: "Thread",
822 body: "Input: Project",
823 environmentId: "env",
824 threadId: "thread",
825 deepLink: "/",
826 },
827 },
828 },
829 ]);
830 expect(attempts).toEqual([]);
831 }).pipe(Effect.provide(layerFor({ attempts, queuedJobs })));
832 },
833 );
834
835 it.effect("does not queue a push notification when a thread starts working", () => {
836 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
837 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
838
839 return Effect.gen(function* () {
840 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
841 const result = yield* deliveries.sendForTarget({
842 target: {
843 ...target,
844 push_token: "apns-device-token",
845 push_to_start_token: null,
846 activity_push_token: null,
847 remote_started_at: null,
848 },
849 aggregate,
850 nowMs: 5_000,
851 });
852
853 expect(result).toBeNull();
854 expect(queuedJobs).toEqual([]);
855 expect(attempts).toEqual([]);
856 }).pipe(Effect.provide(layerFor({ attempts, queuedJobs })));
857 });
858
859 it.effect("queues bounded alert notification payloads", () => {
860 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
861 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
862 const longTitle = "x".repeat(300);
863 const inputAggregate: RelayAgentActivityAggregateState = {
864 ...aggregate,
865 activities: [
866 {
867 ...aggregate.activities[0]!,
868 projectTitle: longTitle,
869 threadTitle: longTitle,
870 phase: "waiting_for_input",
871 status: "Input",
872 deepLink: "https://example.test/not-an-app-link",
873 },
874 ],
875 };
876
877 return Effect.gen(function* () {
878 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
879 yield* deliveries.sendForTarget({
880 target: {
881 ...target,
882 push_token: "apns-device-token",
883 push_to_start_token: null,
884 activity_push_token: null,
885 remote_started_at: null,
886 },
887 aggregate: inputAggregate,
888 nowMs: 5_000,
889 });
890
891 const notification = queuedJobs[0]?.payload.notification;
892 expect(notification?.title.length).toBeLessThanOrEqual(120);
893 expect(notification?.body.length).toBeLessThanOrEqual(120);
894 expect(notification?.deepLink).toBe("/");
895 expect(attempts).toEqual([]);
896 }).pipe(Effect.provide(layerFor({ attempts, queuedJobs })));
897 });
898
899 it.effect("preserves the schema cause for invalid queue payloads", () => {
900 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
901
902 return Effect.gen(function* () {
903 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
904 const error = yield* Effect.flip(deliveries.processSignedJob({ invalid: true }));
905
906 expect(error).toMatchObject({
907 _tag: "ApnsDeliveryJobQueuePayloadInvalid",
908 receivedType: "object",
909 message: "Invalid APNs delivery queue job with object payload.",
910 });
911 expect(error.cause).toMatchObject({ _tag: "SchemaError" });
912 }).pipe(Effect.provide(layerFor({ attempts })));
913 });
914
915 it.effect("skips a queued start when the user no longer has live work", () => {
916 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
917 const clearedStarts: Array<
918 Parameters<LiveActivities.LiveActivities["Service"]["clearStartQueued"]>[0]
919 > = [];
920 const payload = makeApnsDeliveryJobPayload({
921 kind: "live_activity_start",
922 userId: target.user_id,
923 deviceId: target.device_id,
924 token: target.push_to_start_token ?? "start-token",
925 aggregate,
926 createdAt: "1970-01-01T00:00:00.000Z",
927 expiresAt: "1970-01-01T00:10:00.000Z",
928 jobId: "job-start-1",
929 });
930 const signed = signApnsDeliveryJob({
931 secret: config.apnsDeliveryJobSigningSecret,
932 payload,
933 });
934
935 return Effect.gen(function* () {
936 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
937 const result = yield* deliveries.processSignedJob(signed);
938
939 // The start was decided from an aggregate that a newer terminal publish
940 // has since invalidated; delivering it would birth an orphan activity.
941 expect(result).toMatchObject({
942 kind: "live_activity_start",
943 ok: true,
944 apnsStatus: null,
945 });
946 expect(attempts).toMatchObject([
947 {
948 kind: "live_activity_start",
949 sourceJobId: "job-start-1",
950 apnsReason: "Stale APNs start job skipped.",
951 },
952 ]);
953 expect(clearedStarts).toMatchObject([{ userId: target.user_id, deviceId: target.device_id }]);
954 }).pipe(Effect.provide(layerFor({ attempts, clearedStarts, activityStates: [] })));
955 });
956
957 it.effect("processes signed jobs through APNs and records attempts", () => {
958 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
959 const transportErrors: Array<ApnsDeliveries.ApnsDeliveryTransportError> = [];
960 const logger = Logger.make(({ fiber }) => {
961 const annotation = fiber.getRef(References.CurrentLogAnnotations).error;
962 if (!Redacted.isRedacted(annotation)) {
963 return;
964 }
965 const error = Redacted.value(annotation);
966 if (ApnsDeliveries.isApnsDeliveryTransportError(error)) {
967 transportErrors.push(error);
968 }
969 });
970 const payload = makeApnsDeliveryJobPayload({
971 kind: "live_activity_update",
972 userId: target.user_id,
973 deviceId: target.device_id,
974 token: target.activity_push_token ?? "activity-token",
975 aggregate,
976 createdAt: "1970-01-01T00:00:00.000Z",
977 expiresAt: "1970-01-01T00:10:00.000Z",
978 jobId: "job-1",
979 });
980 const signed = signApnsDeliveryJob({
981 secret: config.apnsDeliveryJobSigningSecret,
982 payload,
983 });
984
985 return Effect.gen(function* () {
986 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
987 const result = yield* deliveries.processSignedJob(signed);
988
989 expect(result.kind).toBe("live_activity_update");
990 expect(result.ok).toBe(false);
991 expect(attempts).toMatchObject([
992 {
993 kind: "live_activity_update",
994 sourceJobId: "job-1",
995 token: "activity-token",
996 },
997 ]);
998 expect(transportErrors).toHaveLength(1);
999 const error = transportErrors[0]!;
1000 expect(error).toMatchObject({
1001 deviceId: target.device_id,
1002 kind: "live_activity_update",
1003 sourceJobId: "job-1",
1004 apnsErrorTag: "ApnsJwtSigningError",
1005 requestStage: null,
1006 });
1007 expect(error.cause).toBeInstanceOf(ApnsClient.ApnsJwtSigningError);
1008 expect(error.cause).toMatchObject({
1009 teamId: "team-id",
1010 keyId: "key-id",
1011 });
1012 expect((error.cause as ApnsClient.ApnsJwtSigningError).cause).toBeDefined();
1013 }).pipe(
1014 Effect.provide(
1015 Layer.mergeAll(
1016 layerFor({ attempts }),
1017 Logger.layer([logger], { mergeWithExisting: false }),
1018 ),
1019 ),
1020 );
1021 });
1022
1023 it.effect("continues a signed delivery batch after an APNs timeout", () =>
1024 Effect.gen(function* () {
1025 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
1026 const markedDeliveries: Array<
1027 Parameters<LiveActivities.LiveActivities["Service"]["markDelivery"]>[0]
1028 > = [];
1029 const secondTarget = {
1030 ...target,
1031 device_id: "device-2",
1032 activity_push_token: "second-token",
1033 };
1034 const jobs = [target, secondTarget].map((device) =>
1035 signApnsDeliveryJob({
1036 secret: signingConfig.apnsDeliveryJobSigningSecret,
1037 payload: makeApnsDeliveryJobPayload({
1038 kind: "live_activity_update",
1039 userId: device.user_id,
1040 deviceId: device.device_id,
1041 token: device.activity_push_token!,
1042 aggregate,
1043 createdAt: "1970-01-01T00:00:00.000Z",
1044 expiresAt: "1970-01-01T00:10:00.000Z",
1045 jobId: `job-timeout-${device.device_id}`,
1046 }),
1047 }),
1048 );
1049 const started = yield* Deferred.make<void>();
1050 const results: Array<{ deviceId: string; ok: boolean }> = [];
1051 const layer = layerFor({
1052 attempts,
1053 markedDeliveries,
1054 currentTargets: [target, secondTarget],
1055 config: signingConfig,
1056 execute: (request) =>
1057 request.url.endsWith("/activity-token")
1058 ? Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never))
1059 : Effect.succeed(
1060 HttpClientResponse.fromWeb(request, new Response("", { status: 200 })),
1061 ),
1062 });
1063 const batch = yield* Effect.gen(function* () {
1064 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
1065 yield* Stream.fromIterable(jobs).pipe(
1066 Stream.runForEach((job) =>
1067 deliveries.processSignedJob(job).pipe(
1068 Effect.tap((result) =>
1069 Effect.sync(() => {
1070 results.push(result);
1071 }),
1072 ),
1073 ),
1074 ),
1075 );
1076 }).pipe(Effect.provide(layer), Effect.forkChild);
1077
1078 yield* Deferred.await(started);
1079 yield* TestClock.adjust("10 seconds");
1080 yield* Fiber.join(batch);
1081 expect(results).toMatchObject([
1082 { deviceId: "device-1", ok: false },
1083 { deviceId: "device-2", ok: true },
1084 ]);
1085 expect(attempts).toMatchObject([
1086 {
1087 sourceJobId: "job-timeout-device-1",
1088 apnsReason: expect.stringContaining("request failed"),
1089 },
1090 { sourceJobId: "job-timeout-device-2", apnsStatus: 200 },
1091 ]);
1092 expect(markedDeliveries).toMatchObject([{ deviceId: "device-2" }]);
1093 }),
1094 );
1095
1096 it.effect("processes signed push notification jobs through APNs and records attempts", () => {
1097 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
1098 const payload = makeApnsDeliveryJobPayload({
1099 kind: "push_notification",
1100 userId: target.user_id,
1101 deviceId: target.device_id,
1102 token: "apns-device-token",
1103 aggregate: null,
1104 notification: {
1105 title: "Thread",
1106 body: "Input: Project",
1107 environmentId: "env",
1108 threadId: "thread",
1109 deepLink: "/",
1110 },
1111 createdAt: "1970-01-01T00:00:00.000Z",
1112 expiresAt: "1970-01-01T00:10:00.000Z",
1113 jobId: "job-push-1",
1114 });
1115 const signed = signApnsDeliveryJob({
1116 secret: config.apnsDeliveryJobSigningSecret,
1117 payload,
1118 });
1119 const execute = (request: HttpClientRequest.HttpClientRequest) =>
1120 Effect.succeed(HttpClientResponse.fromWeb(request, new Response("", { status: 200 })));
1121
1122 return Effect.gen(function* () {
1123 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
1124 const result = yield* deliveries.processSignedJob(signed);
1125
1126 expect(result.kind).toBe("push_notification");
1127 expect(result.ok).toBe(true);
1128 expect(result.apnsStatus).toBe(200);
1129 expect(attempts).toMatchObject([
1130 {
1131 kind: "push_notification",
1132 sourceJobId: "job-push-1",
1133 token: "apns-device-token",
1134 environmentId: "env",
1135 threadId: "thread",
1136 deviceId: target.device_id,
1137 apnsStatus: 200,
1138 },
1139 ]);
1140 }).pipe(
1141 Effect.provide(
1142 layerFor({
1143 attempts,
1144 currentTargets: [
1145 {
1146 ...target,
1147 push_token: "apns-device-token",
1148 },
1149 ],
1150 config: signingConfig,
1151 execute,
1152 }),
1153 ),
1154 );
1155 });
1156
1157 it.effect("skips duplicate signed queue jobs before calling APNs", () => {
1158 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
1159 let executeCount = 0;
1160 const payload = makeApnsDeliveryJobPayload({
1161 kind: "push_notification",
1162 userId: target.user_id,
1163 deviceId: target.device_id,
1164 token: "apns-device-token",
1165 aggregate: null,
1166 notification: {
1167 title: "Thread",
1168 body: "Input: Project",
1169 environmentId: "env",
1170 threadId: "thread",
1171 deepLink: "/",
1172 },
1173 createdAt: "1970-01-01T00:00:00.000Z",
1174 expiresAt: "1970-01-01T00:10:00.000Z",
1175 jobId: "job-push-duplicate",
1176 });
1177 const signed = signApnsDeliveryJob({
1178 secret: config.apnsDeliveryJobSigningSecret,
1179 payload,
1180 });
1181 const execute = (request: HttpClientRequest.HttpClientRequest) =>
1182 Effect.sync(() => {
1183 executeCount += 1;
1184 return HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
1185 });
1186
1187 return Effect.gen(function* () {
1188 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
1189 const result = yield* deliveries.processSignedJob(signed);
1190
1191 expect(result).toMatchObject({
1192 kind: "push_notification",
1193 ok: true,
1194 apnsStatus: null,
1195 apnsReason: "Duplicate APNs delivery job skipped.",
1196 });
1197 expect(executeCount).toBe(0);
1198 expect(attempts).toEqual([]);
1199 }).pipe(
1200 Effect.provide(
1201 layerFor({
1202 attempts,
1203 sourceJobClaims: new Map([["job-push-duplicate", "completed"]]),
1204 config: signingConfig,
1205 execute,
1206 }),
1207 ),
1208 );
1209 });
1210
1211 it.effect("skips stale signed Live Activity jobs when the registered token changed", () => {
1212 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
1213 let executeCount = 0;
1214 const payload = makeApnsDeliveryJobPayload({
1215 kind: "live_activity_update",
1216 userId: target.user_id,
1217 deviceId: target.device_id,
1218 token: "stale-activity-token",
1219 aggregate,
1220 createdAt: "1970-01-01T00:00:00.000Z",
1221 expiresAt: "1970-01-01T00:10:00.000Z",
1222 jobId: "job-update-stale-token",
1223 });
1224 const signed = signApnsDeliveryJob({
1225 secret: config.apnsDeliveryJobSigningSecret,
1226 payload,
1227 });
1228 const execute = (request: HttpClientRequest.HttpClientRequest) =>
1229 Effect.sync(() => {
1230 executeCount += 1;
1231 return HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
1232 });
1233
1234 return Effect.gen(function* () {
1235 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
1236 const result = yield* deliveries.processSignedJob(signed);
1237
1238 expect(result).toMatchObject({
1239 kind: "live_activity_update",
1240 ok: true,
1241 apnsStatus: null,
1242 apnsReason: "Stale APNs delivery job skipped.",
1243 });
1244 expect(executeCount).toBe(0);
1245 expect(attempts).toMatchObject([
1246 {
1247 kind: "live_activity_update",
1248 sourceJobId: "job-update-stale-token",
1249 token: "stale-activity-token",
1250 apnsReason: "Stale APNs delivery job skipped.",
1251 },
1252 ]);
1253 }).pipe(
1254 Effect.provide(
1255 layerFor({
1256 attempts,
1257 config: signingConfig,
1258 execute,
1259 }),
1260 ),
1261 );
1262 });
1263
1264 it.effect("skips stale signed push notification jobs when the device token changed", () => {
1265 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
1266 let executeCount = 0;
1267 const payload = makeApnsDeliveryJobPayload({
1268 kind: "push_notification",
1269 userId: target.user_id,
1270 deviceId: target.device_id,
1271 token: "stale-device-token",
1272 aggregate: null,
1273 notification: {
1274 title: "Thread",
1275 body: "Input: Project",
1276 environmentId: "env",
1277 threadId: "thread",
1278 deepLink: "/",
1279 },
1280 createdAt: "1970-01-01T00:00:00.000Z",
1281 expiresAt: "1970-01-01T00:10:00.000Z",
1282 jobId: "job-push-stale-token",
1283 });
1284 const signed = signApnsDeliveryJob({
1285 secret: config.apnsDeliveryJobSigningSecret,
1286 payload,
1287 });
1288 const execute = (request: HttpClientRequest.HttpClientRequest) =>
1289 Effect.sync(() => {
1290 executeCount += 1;
1291 return HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
1292 });
1293
1294 return Effect.gen(function* () {
1295 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
1296 const result = yield* deliveries.processSignedJob(signed);
1297
1298 expect(result).toMatchObject({
1299 kind: "push_notification",
1300 ok: true,
1301 apnsStatus: null,
1302 apnsReason: "Stale APNs delivery job skipped.",
1303 });
1304 expect(executeCount).toBe(0);
1305 expect(attempts).toMatchObject([
1306 {
1307 kind: "push_notification",
1308 sourceJobId: "job-push-stale-token",
1309 token: "stale-device-token",
1310 apnsReason: "Stale APNs delivery job skipped.",
1311 },
1312 ]);
1313 }).pipe(
1314 Effect.provide(
1315 layerFor({
1316 attempts,
1317 currentTargets: [
1318 {
1319 ...target,
1320 push_token: "current-device-token",
1321 },
1322 ],
1323 config: signingConfig,
1324 execute,
1325 }),
1326 ),
1327 );
1328 });
1329
1330 it.effect("skips a queued Done Live Activity update after the thread resumes working", () => {
1331 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
1332 let executeCount = 0;
1333 const completedAt = "1970-01-01T00:00:01.000Z";
1334 const completedAggregate: RelayAgentActivityAggregateState = {
1335 ...aggregate,
1336 activeCount: 0,
1337 updatedAt: completedAt,
1338 activities: [
1339 {
1340 ...aggregate.activities[0]!,
1341 phase: "completed",
1342 status: "Done",
1343 updatedAt: completedAt,
1344 },
1345 ],
1346 };
1347 const payload = makeApnsDeliveryJobPayload({
1348 kind: "live_activity_update",
1349 userId: target.user_id,
1350 deviceId: target.device_id,
1351 token: target.activity_push_token ?? "activity-token",
1352 aggregate: completedAggregate,
1353 alert: { title: "Thread", body: "Done: Project" },
1354 createdAt: completedAt,
1355 expiresAt: "1970-01-01T00:10:00.000Z",
1356 jobId: "job-update-superseded-by-running",
1357 });
1358 const signed = signApnsDeliveryJob({
1359 secret: config.apnsDeliveryJobSigningSecret,
1360 payload,
1361 });
1362 const execute = (request: HttpClientRequest.HttpClientRequest) =>
1363 Effect.sync(() => {
1364 executeCount += 1;
1365 return HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
1366 });
1367
1368 return Effect.gen(function* () {
1369 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
1370 const result = yield* deliveries.processSignedJob(signed);
1371
1372 expect(result).toMatchObject({
1373 kind: "live_activity_update",
1374 ok: true,
1375 apnsStatus: null,
1376 apnsReason: "Stale APNs delivery job skipped.",
1377 });
1378 expect(executeCount).toBe(0);
1379 expect(attempts).toMatchObject([
1380 {
1381 sourceJobId: "job-update-superseded-by-running",
1382 apnsReason: "Stale agent activity state skipped.",
1383 },
1384 ]);
1385 }).pipe(
1386 Effect.provide(
1387 layerFor({
1388 attempts,
1389 activityStates: [
1390 {
1391 ...state,
1392 phase: "running",
1393 headline: "Running",
1394 updatedAt: "1970-01-01T00:00:02.000Z",
1395 },
1396 ],
1397 config: signingConfig,
1398 execute,
1399 }),
1400 ),
1401 );
1402 });
1403
1404 it.effect("skips a queued Done notification after the thread resumes working", () => {
1405 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
1406 let executeCount = 0;
1407 const completedAt = "1970-01-01T00:00:01.000Z";
1408 const payload = makeApnsDeliveryJobPayload({
1409 kind: "push_notification",
1410 userId: target.user_id,
1411 deviceId: target.device_id,
1412 token: "apns-device-token",
1413 aggregate: null,
1414 notification: {
1415 title: "Thread",
1416 body: "Done: Project",
1417 environmentId: "env",
1418 threadId: "thread",
1419 deepLink: "/",
1420 phase: "completed",
1421 updatedAt: completedAt,
1422 },
1423 createdAt: completedAt,
1424 expiresAt: "1970-01-01T00:10:00.000Z",
1425 jobId: "job-push-superseded-by-running",
1426 });
1427 const signed = signApnsDeliveryJob({
1428 secret: config.apnsDeliveryJobSigningSecret,
1429 payload,
1430 });
1431 const execute = (request: HttpClientRequest.HttpClientRequest) =>
1432 Effect.sync(() => {
1433 executeCount += 1;
1434 return HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
1435 });
1436
1437 return Effect.gen(function* () {
1438 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
1439 const result = yield* deliveries.processSignedJob(signed);
1440
1441 expect(result).toMatchObject({
1442 kind: "push_notification",
1443 ok: true,
1444 apnsStatus: null,
1445 apnsReason: "Stale APNs delivery job skipped.",
1446 });
1447 expect(executeCount).toBe(0);
1448 expect(attempts).toMatchObject([
1449 {
1450 sourceJobId: "job-push-superseded-by-running",
1451 apnsReason: "Stale agent activity state skipped.",
1452 },
1453 ]);
1454 }).pipe(
1455 Effect.provide(
1456 layerFor({
1457 attempts,
1458 currentTargets: [{ ...target, push_token: "apns-device-token" }],
1459 currentActivityStates: [
1460 {
1461 ...state,
1462 phase: "running",
1463 headline: "Running",
1464 updatedAt: "1970-01-01T00:00:02.000Z",
1465 },
1466 ],
1467 config: signingConfig,
1468 execute,
1469 }),
1470 ),
1471 );
1472 });
1473
1474 it.effect("retries signed queue jobs that are already claimed but not completed", () => {
1475 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
1476 let executeCount = 0;
1477 const payload = makeApnsDeliveryJobPayload({
1478 kind: "push_notification",
1479 userId: target.user_id,
1480 deviceId: target.device_id,
1481 token: "apns-device-token",
1482 aggregate: null,
1483 notification: {
1484 title: "Thread",
1485 body: "Input: Project",
1486 environmentId: "env",
1487 threadId: "thread",
1488 deepLink: "/",
1489 },
1490 createdAt: "1970-01-01T00:00:00.000Z",
1491 expiresAt: "1970-01-01T00:10:00.000Z",
1492 jobId: "job-push-in-flight",
1493 });
1494 const signed = signApnsDeliveryJob({
1495 secret: config.apnsDeliveryJobSigningSecret,
1496 payload,
1497 });
1498 const execute = (request: HttpClientRequest.HttpClientRequest) =>
1499 Effect.sync(() => {
1500 executeCount += 1;
1501 return HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
1502 });
1503
1504 return Effect.gen(function* () {
1505 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
1506 const result = yield* Effect.exit(deliveries.processSignedJob(signed));
1507
1508 expect(result._tag).toBe("Failure");
1509 if (result._tag === "Failure") {
1510 expect(result.cause.toString()).toContain("ApnsDeliveryJobClaimInFlight");
1511 }
1512 expect(executeCount).toBe(0);
1513 expect(attempts).toEqual([]);
1514 }).pipe(
1515 Effect.provide(
1516 layerFor({
1517 attempts,
1518 sourceJobClaims: new Map([["job-push-in-flight", "in_flight"]]),
1519 config: signingConfig,
1520 execute,
1521 }),
1522 ),
1523 );
1524 });
1525
1526 it.effect("invalidates dead device push tokens after permanent APNs alert failures", () => {
1527 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
1528 const invalidatedTokens: Array<
1529 Parameters<LiveActivities.LiveActivities["Service"]["invalidateDeliveryToken"]>[0]
1530 > = [];
1531 const payload = makeApnsDeliveryJobPayload({
1532 kind: "push_notification",
1533 userId: target.user_id,
1534 deviceId: target.device_id,
1535 token: "apns-device-token",
1536 aggregate: null,
1537 notification: {
1538 title: "Thread",
1539 body: "Failed: Project",
1540 environmentId: "env",
1541 threadId: "thread",
1542 deepLink: "/",
1543 },
1544 createdAt: "1970-01-01T00:00:00.000Z",
1545 expiresAt: "1970-01-01T00:10:00.000Z",
1546 jobId: "job-push-bad-token",
1547 });
1548 const signed = signApnsDeliveryJob({
1549 secret: config.apnsDeliveryJobSigningSecret,
1550 payload,
1551 });
1552 const execute = (request: HttpClientRequest.HttpClientRequest) =>
1553 Effect.succeed(
1554 HttpClientResponse.fromWeb(
1555 request,
1556 Response.json({ reason: "BadDeviceToken" }, { status: 400 }),
1557 ),
1558 );
1559
1560 return Effect.gen(function* () {
1561 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
1562 const result = yield* deliveries.processSignedJob(signed);
1563
1564 expect(result.kind).toBe("push_notification");
1565 expect(result.ok).toBe(false);
1566 expect(result.apnsStatus).toBe(400);
1567 expect(result.apnsReason).toBe("BadDeviceToken");
1568 expect(invalidatedTokens).toMatchObject([
1569 {
1570 userId: target.user_id,
1571 deviceId: target.device_id,
1572 kind: "push_notification",
1573 },
1574 ]);
1575 }).pipe(
1576 Effect.provide(
1577 layerFor({
1578 attempts,
1579 invalidatedTokens,
1580 currentTargets: [
1581 {
1582 ...target,
1583 push_token: "apns-device-token",
1584 },
1585 ],
1586 config: signingConfig,
1587 execute,
1588 }),
1589 ),
1590 );
1591 });
1592
1593 it.effect("clears queued start state when a start job fails in APNs", () => {
1594 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
1595 const clearedStarts: Array<
1596 Parameters<LiveActivities.LiveActivities["Service"]["clearStartQueued"]>[0]
1597 > = [];
1598 const payload = makeApnsDeliveryJobPayload({
1599 kind: "live_activity_start",
1600 userId: target.user_id,
1601 deviceId: target.device_id,
1602 token: target.push_to_start_token ?? "start-token",
1603 aggregate,
1604 createdAt: "1970-01-01T00:00:00.000Z",
1605 expiresAt: "1970-01-01T00:10:00.000Z",
1606 jobId: "job-start-1",
1607 });
1608 const signed = signApnsDeliveryJob({
1609 secret: config.apnsDeliveryJobSigningSecret,
1610 payload,
1611 });
1612
1613 return Effect.gen(function* () {
1614 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
1615 const result = yield* deliveries.processSignedJob(signed);
1616
1617 expect(result.kind).toBe("live_activity_start");
1618 expect(result.ok).toBe(false);
1619 expect(clearedStarts).toEqual([
1620 {
1621 userId: target.user_id,
1622 deviceId: target.device_id,
1623 },
1624 ]);
1625 }).pipe(
1626 Effect.provide(
1627 layerFor({
1628 attempts,
1629 clearedStarts,
1630 activityStates: [{ ...state, updatedAt: "9999-01-01T00:00:00.000Z" }],
1631 }),
1632 ),
1633 );
1634 });
1635
1636 it.effect("invalidates dead push-to-start tokens after permanent APNs start failures", () => {
1637 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
1638 const invalidatedTokens: Array<
1639 Parameters<LiveActivities.LiveActivities["Service"]["invalidateDeliveryToken"]>[0]
1640 > = [];
1641 const payload = makeApnsDeliveryJobPayload({
1642 kind: "live_activity_start",
1643 userId: target.user_id,
1644 deviceId: target.device_id,
1645 token: target.push_to_start_token ?? "start-token",
1646 aggregate,
1647 createdAt: "1970-01-01T00:00:00.000Z",
1648 expiresAt: "1970-01-01T00:10:00.000Z",
1649 jobId: "job-start-bad-token",
1650 });
1651 const signed = signApnsDeliveryJob({
1652 secret: config.apnsDeliveryJobSigningSecret,
1653 payload,
1654 });
1655 const execute = (request: HttpClientRequest.HttpClientRequest) =>
1656 Effect.succeed(
1657 HttpClientResponse.fromWeb(
1658 request,
1659 Response.json({ reason: "BadDeviceToken" }, { status: 400 }),
1660 ),
1661 );
1662
1663 return Effect.gen(function* () {
1664 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
1665 const result = yield* deliveries.processSignedJob(signed);
1666
1667 expect(result.kind).toBe("live_activity_start");
1668 expect(result.ok).toBe(false);
1669 expect(result.apnsStatus).toBe(400);
1670 expect(result.apnsReason).toBe("BadDeviceToken");
1671 expect(invalidatedTokens).toMatchObject([
1672 {
1673 userId: target.user_id,
1674 deviceId: target.device_id,
1675 kind: "live_activity_start",
1676 },
1677 ]);
1678 }).pipe(
1679 Effect.provide(
1680 layerFor({
1681 attempts,
1682 invalidatedTokens,
1683 activityStates: [{ ...state, updatedAt: "9999-01-01T00:00:00.000Z" }],
1684 config: signingConfig,
1685 execute,
1686 }),
1687 ),
1688 );
1689 });
1690
1691 it.effect("invalidates dead Live Activity tokens after APNs unregisters them", () => {
1692 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
1693 const invalidatedTokens: Array<
1694 Parameters<LiveActivities.LiveActivities["Service"]["invalidateDeliveryToken"]>[0]
1695 > = [];
1696 const payload = makeApnsDeliveryJobPayload({
1697 kind: "live_activity_update",
1698 userId: target.user_id,
1699 deviceId: target.device_id,
1700 token: target.activity_push_token ?? "activity-token",
1701 aggregate,
1702 createdAt: "1970-01-01T00:00:00.000Z",
1703 expiresAt: "1970-01-01T00:10:00.000Z",
1704 jobId: "job-update-unregistered",
1705 });
1706 const signed = signApnsDeliveryJob({
1707 secret: config.apnsDeliveryJobSigningSecret,
1708 payload,
1709 });
1710 const execute = (request: HttpClientRequest.HttpClientRequest) =>
1711 Effect.succeed(
1712 HttpClientResponse.fromWeb(
1713 request,
1714 Response.json({ reason: "Unregistered" }, { status: 410 }),
1715 ),
1716 );
1717
1718 return Effect.gen(function* () {
1719 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
1720 const result = yield* deliveries.processSignedJob(signed);
1721
1722 expect(result.kind).toBe("live_activity_update");
1723 expect(result.ok).toBe(false);
1724 expect(result.apnsStatus).toBe(410);
1725 expect(result.apnsReason).toBe("Unregistered");
1726 expect(invalidatedTokens).toMatchObject([
1727 {
1728 userId: target.user_id,
1729 deviceId: target.device_id,
1730 kind: "live_activity_update",
1731 },
1732 ]);
1733 }).pipe(
1734 Effect.provide(layerFor({ attempts, invalidatedTokens, config: signingConfig, execute })),
1735 );
1736 });
1737});
1738
1739describe("live activity alert decisions", () => {
1740 const preferences = {
1741 liveActivitiesEnabled: true,
1742 notificationsEnabled: true,
1743 notifyOnApproval: true,
1744 notifyOnInput: true,
1745 notifyOnCompletion: true,
1746 notifyOnFailure: true,
1747 };
1748
1749 const attentionRow = {
1750 ...aggregate.activities[0]!,
1751 threadId: "thread-2" as RelayAgentActivityState["threadId"],
1752 threadTitle: "Blocked thread",
1753 phase: "waiting_for_approval" as const,
1754 status: "Approval",
1755 };
1756
1757 it("alerts when a thread newly enters an attention phase", () => {
1758 const alert = ApnsDeliveries.alertForAttentionTransition({
1759 previousAggregate: aggregate,
1760 nextAggregate: {
1761 ...aggregate,
1762 activeCount: 2,
1763 activities: [...aggregate.activities, attentionRow],
1764 },
1765 preferences,
1766 });
1767 expect(alert).toEqual({ title: "Blocked thread", body: "Approval: Project" });
1768 });
1769
1770 it("stays silent when the attention phase was already delivered", () => {
1771 const withAttention = {
1772 ...aggregate,
1773 activeCount: 2,
1774 activities: [...aggregate.activities, attentionRow],
1775 };
1776 expect(
1777 ApnsDeliveries.alertForAttentionTransition({
1778 previousAggregate: withAttention,
1779 nextAggregate: withAttention,
1780 preferences,
1781 }),
1782 ).toBeNull();
1783 });
1784
1785 it("stays silent without a delivered baseline so replays cannot buzz", () => {
1786 expect(
1787 ApnsDeliveries.alertForAttentionTransition({
1788 previousAggregate: null,
1789 nextAggregate: { ...aggregate, activities: [attentionRow] },
1790 preferences,
1791 }),
1792 ).toBeNull();
1793 });
1794
1795 it("honors the per-event notification switch for attention alerts", () => {
1796 expect(
1797 ApnsDeliveries.alertForAttentionTransition({
1798 previousAggregate: aggregate,
1799 nextAggregate: {
1800 ...aggregate,
1801 activeCount: 2,
1802 activities: [...aggregate.activities, attentionRow],
1803 },
1804 preferences: { ...preferences, notifyOnApproval: false },
1805 }),
1806 ).toBeNull();
1807 });
1808
1809 it("summarizes multiple newly blocked threads in one alert", () => {
1810 const secondAttentionRow = {
1811 ...attentionRow,
1812 threadId: "thread-3" as RelayAgentActivityState["threadId"],
1813 threadTitle: "Other blocked thread",
1814 phase: "waiting_for_input" as const,
1815 status: "Input",
1816 };
1817 const alert = ApnsDeliveries.alertForAttentionTransition({
1818 previousAggregate: aggregate,
1819 nextAggregate: {
1820 ...aggregate,
1821 activeCount: 3,
1822 activities: [...aggregate.activities, attentionRow, secondAttentionRow],
1823 },
1824 preferences,
1825 });
1826 expect(alert).toEqual({
1827 title: "2 agents need attention",
1828 body: "Blocked thread, Other blocked thread",
1829 });
1830 });
1831
1832 it("alerts for a terminal aggregate and honors the completion switch", () => {
1833 const terminalAggregate = {
1834 ...aggregate,
1835 activeCount: 0,
1836 activities: [{ ...aggregate.activities[0]!, phase: "completed" as const, status: "Done" }],
1837 };
1838 expect(
1839 ApnsDeliveries.alertForTerminalAggregate({ aggregate: terminalAggregate, preferences }),
1840 ).toEqual({ title: "Thread", body: "Done: Project" });
1841 expect(
1842 ApnsDeliveries.alertForTerminalAggregate({
1843 aggregate: terminalAggregate,
1844 preferences: { ...preferences, notifyOnCompletion: false },
1845 }),
1846 ).toBeNull();
1847 expect(ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences })).toBeNull();
1848 });
1849
1850 it("alerts when a previously active thread finishes mid-flight", () => {
1851 const doneRow = {
1852 ...aggregate.activities[0]!,
1853 phase: "completed" as const,
1854 status: "Done",
1855 };
1856 const next = {
1857 ...aggregate,
1858 activeCount: 0,
1859 activities: [attentionRow, doneRow],
1860 };
1861 expect(
1862 ApnsDeliveries.alertForNewlyTerminal({
1863 previousAggregate: aggregate,
1864 nextAggregate: next,
1865 preferences,
1866 nowMs: 0,
1867 }),
1868 ).toEqual({ title: "Thread", body: "Done: Project" });
1869 // The completion switch mutes it.
1870 expect(
1871 ApnsDeliveries.alertForNewlyTerminal({
1872 previousAggregate: aggregate,
1873 nextAggregate: next,
1874 preferences: { ...preferences, notifyOnCompletion: false },
1875 nowMs: 0,
1876 }),
1877 ).toBeNull();
1878 // No baseline means no transition to ring on.
1879 expect(
1880 ApnsDeliveries.alertForNewlyTerminal({
1881 previousAggregate: null,
1882 nextAggregate: next,
1883 preferences,
1884 nowMs: 0,
1885 }),
1886 ).toBeNull();
1887 // A Done row that was already terminal (or absent) before stays silent.
1888 expect(
1889 ApnsDeliveries.alertForNewlyTerminal({
1890 previousAggregate: next,
1891 nextAggregate: next,
1892 preferences,
1893 nowMs: 0,
1894 }),
1895 ).toBeNull();
1896 expect(
1897 ApnsDeliveries.alertForNewlyTerminal({
1898 previousAggregate: { ...aggregate, activities: [attentionRow] },
1899 nextAggregate: next,
1900 preferences,
1901 nowMs: 0,
1902 }),
1903 ).toBeNull();
1904 });
1905});
1906
1907describe("queued iOS alert policy", () => {
1908 it.effect.each(["enabled", "muted", "late"] as const)(
1909 "checks the current policy for a %s completion",
1910 (scenario) => {
1911 let sent = 0;
1912 const completed = { ...state, phase: "completed" as const };
1913 const prefs = JSON.parse(enabledPreferences);
1914 if (scenario === "muted") prefs.notifyOnCompletion = false;
1915 const payload = makeApnsDeliveryJobPayload({
1916 kind: "push_notification",
1917 userId: target.user_id,
1918 deviceId: target.device_id,
1919 token: "push",
1920 aggregate: null,
1921 notification: {
1922 title: "Thread",
1923 body: "Done: Project",
1924 environmentId: "env",
1925 threadId: "thread",
1926 deepLink: "/",
1927 phase: "completed",
1928 updatedAt: completed.updatedAt,
1929 },
1930 createdAt: completed.updatedAt,
1931 expiresAt: "1970-01-01T00:10:00.000Z",
1932 jobId: `delivery-policy-${scenario}`,
1933 });
1934 const signed = signApnsDeliveryJob({ secret: config.apnsDeliveryJobSigningSecret, payload });
1935 return Effect.gen(function* () {
1936 if (scenario === "late") yield* TestClock.adjust("3 minutes");
1937 const d = yield* ApnsDeliveries.ApnsDeliveries;
1938 yield* d.processSignedJob(signed);
1939 expect(sent).toBe(scenario === "enabled" ? 1 : 0);
1940 }).pipe(
1941 Effect.provide(
1942 layerFor({
1943 attempts: [],
1944 config: signingConfig,
1945 currentTargets: [
1946 { ...target, push_token: "push", preferences_json: JSON.stringify(prefs) },
1947 ],
1948 currentActivityStates: [completed],
1949 execute: (request) =>
1950 Effect.sync(() => {
1951 sent++;
1952 return HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
1953 }),
1954 }),
1955 ),
1956 );
1957 },
1958 );
1959});
1960
1961describe("fast completion delivery", () => {
1962 it.effect("keeps a completion alert when work finishes before running delivery", () => {
1963 const queuedJobs: SignedApnsDeliveryJob[] = [];
1964 const old = {
1965 ...aggregate,
1966 activeCount: 0,
1967 activities: [
1968 {
1969 ...aggregate.activities[0]!,
1970 threadId: "old" as RelayAgentActivityState["threadId"],
1971 phase: "completed" as const,
1972 },
1973 ],
1974 };
1975 const device = { ...target, last_aggregate_json: JSON.stringify(old) };
1976 const done = {
1977 ...aggregate,
1978 activeCount: 0,
1979 activities: [{ ...aggregate.activities[0]!, phase: "completed" as const }],
1980 };
1981 return Effect.gen(function* () {
1982 const d = yield* ApnsDeliveries.ApnsDeliveries;
1983 yield* d.sendForTarget({ target: device, aggregate, nowMs: 0 });
1984 yield* d.sendForTarget({ target: device, aggregate: done, nowMs: 0 });
1985 expect(
1986 queuedJobs.some((x) => x.payload.alert !== null && x.payload.alert !== undefined),
1987 ).toBe(true);
1988 }).pipe(Effect.provide(layerFor({ attempts: [], queuedJobs, currentTargets: [device] })));
1989 });
1990 it.effect("replays a newly visible completion without alerting", () => {
1991 const queuedJobs: SignedApnsDeliveryJob[] = [];
1992 const done = {
1993 ...aggregate,
1994 activeCount: 0,
1995 activities: [{ ...aggregate.activities[0]!, phase: "completed" as const }],
1996 };
1997 const previous = {
1998 ...aggregate,
1999 activities: [
2000 { ...aggregate.activities[0]!, threadId: "other" as RelayAgentActivityState["threadId"] },
2001 ],
2002 };
2003 const device = { ...target, last_aggregate_json: JSON.stringify(previous) };
2004 return Effect.gen(function* () {
2005 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
2006 yield* deliveries.sendForTarget({
2007 target: device,
2008 aggregate: done,
2009 nowMs: 0,
2010 replay: true,
2011 });
2012 expect(queuedJobs).toHaveLength(1);
2013 expect(queuedJobs[0]?.payload.alert).toBeUndefined();
2014 }).pipe(Effect.provide(layerFor({ attempts: [], queuedJobs })));
2015 });
2016});
2017
2018describe("signed APNs registration metadata", () => {
2019 it.effect.each(
2020 (["live_activity_update", "push_notification"] as const).flatMap((kind) =>
2021 (["bundle", "environment", "legacy"] as const).map((changed) => ({ kind, changed })),
2022 ),
2023 )("routes $kind using current registration with $changed job metadata", ({ kind, changed }) => {
2024 const attempts: DeliveryAttempts.DeliveryAttemptInput[] = [];
2025 const requests: HttpClientRequest.HttpClientRequest[] = [];
2026 const payload = makeApnsDeliveryJobPayload({
2027 kind,
2028 userId: target.user_id,
2029 deviceId: target.device_id,
2030 token: "unchanged-token",
2031 ...(changed === "legacy"
2032 ? {}
2033 : { bundleId: "com.t3tools.t3code.dev", apsEnvironment: "sandbox" as const }),
2034 aggregate: kind === "live_activity_update" ? aggregate : null,
2035 ...(kind === "push_notification"
2036 ? {
2037 notification: {
2038 title: "Thread",
2039 body: "Input: Project",
2040 environmentId: "env",
2041 threadId: "thread",
2042 deepLink: "/",
2043 },
2044 }
2045 : {}),
2046 createdAt: "1970-01-01T00:00:00.000Z",
2047 expiresAt: "1970-01-01T00:10:00.000Z",
2048 jobId: `metadata-${kind}-${changed}`,
2049 });
2050 const signed = signApnsDeliveryJob({
2051 secret: config.apnsDeliveryJobSigningSecret,
2052 payload,
2053 });
2054 return Effect.gen(function* () {
2055 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
2056 const result = yield* deliveries.processSignedJob(signed);
2057 expect(result.ok).toBe(true);
2058 expect(requests).toHaveLength(1);
2059 expect(requests[0]?.url).toBe(
2060 `${changed === "environment" ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com"}/3/device/unchanged-token`,
2061 );
2062 expect(requests[0]?.headers["apns-topic"]).toBe(
2063 `${changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev"}${kind === "live_activity_update" ? ".push-type.liveactivity" : ""}`,
2064 );
2065 }).pipe(
2066 Effect.provide(
2067 layerFor({
2068 attempts,
2069 config: signingConfig,
2070 currentTargets: [
2071 {
2072 ...target,
2073 push_token: "unchanged-token",
2074 activity_push_token: "unchanged-token",
2075 bundle_id:
2076 changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev",
2077 aps_environment: changed === "environment" ? "production" : "sandbox",
2078 },
2079 ],
2080 execute: (request) =>
2081 Effect.sync(() => {
2082 requests.push(request);
2083 return HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
2084 }),
2085 }),
2086 ),
2087 );
2088 });
2089});