1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
2import * as NodeCrypto from "node:crypto";
3
4import { EnvironmentId, ThreadId } from "@t3tools/contracts";
5import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay";
6import { describe, expect, it } from "@effect/vitest";
7import * as DateTime from "effect/DateTime";
8import * as Deferred from "effect/Deferred";
9import * as Effect from "effect/Effect";
10import * as Fiber from "effect/Fiber";
11import * as Layer from "effect/Layer";
12import * as Redacted from "effect/Redacted";
13import * as Schema from "effect/Schema";
14import * as TestClock from "effect/testing/TestClock";
15import * as HttpClient from "effect/http/HttpClient";
16import * as HttpClientError from "effect/http/HttpClientError";
17import * as HttpClientResponse from "effect/http/HttpClientResponse";
18
19import type { ApnsCredentials } from "../Config.ts";
20import * as ApnsClient from "./ApnsClient.ts";
21import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";
22
23const isApnsJwtSigningError = Schema.is(ApnsClient.ApnsJwtSigningError);
24const isApnsHttpRequestError = Schema.is(ApnsClient.ApnsHttpRequestError);
25
26const layerTest = ApnsClient.layer.pipe(
27 Layer.provide(ApnsProviderTokens.layer),
28 Layer.provide(
29 Layer.succeed(
30 HttpClient.HttpClient,
31 HttpClient.make(() => Effect.die("unexpected APNs HTTP request")),
32 ),
33 ),
34);
35
36describe("ApnsClient", () => {
37 const now = DateTime.makeUnsafe(0);
38 const state: RelayAgentActivityAggregateState = {
39 title: "T3 Code",
40 subtitle: "Agent work in progress",
41 activeCount: 1,
42 updatedAt: DateTime.formatIso(now),
43 activities: [
44 {
45 environmentId: EnvironmentId.make("env"),
46 threadId: ThreadId.make("thread"),
47 projectTitle: "Project",
48 threadTitle: "Thread",
49 modelTitle: "gpt-5.4",
50 phase: "running" as const,
51 status: "Working",
52 updatedAt: DateTime.formatIso(now),
53 deepLink: "/",
54 },
55 ],
56 };
57
58 it.effect("requests an update push token when remotely starting a Live Activity", () =>
59 Effect.gen(function* () {
60 const apns = yield* ApnsClient.ApnsClient;
61 const request = apns.makeLiveActivityRequest({
62 event: "start",
63 token: "token",
64 state,
65 nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
66 nowIso: DateTime.formatIso(now),
67 });
68
69 expect(request.priority).toBe("10");
70 expect(request.payload).toMatchObject({
71 aps: {
72 event: "start",
73 "attributes-type": "LiveActivityAttributes",
74 "input-push-token": 1,
75 "content-state": {
76 name: "AgentActivity",
77 },
78 },
79 });
80 }).pipe(Effect.provide(layerTest)),
81 );
82
83 it.effect("builds a low-priority update payload", () =>
84 Effect.gen(function* () {
85 const apns = yield* ApnsClient.ApnsClient;
86 const request = apns.makeLiveActivityRequest({
87 event: "update",
88 token: "token",
89 state,
90 nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
91 nowIso: DateTime.formatIso(now),
92 });
93
94 expect(request.priority).toBe("5");
95 expect(request.payload).toMatchObject({
96 aps: {
97 event: "update",
98 "content-state": {
99 name: "AgentActivity",
100 },
101 },
102 });
103 }).pipe(Effect.provide(layerTest)),
104 );
105
106 it.effect("builds a high-priority alerting update payload when an alert is attached", () =>
107 Effect.gen(function* () {
108 const apns = yield* ApnsClient.ApnsClient;
109 const request = apns.makeLiveActivityRequest({
110 event: "update",
111 token: "token",
112 state,
113 alert: { title: "Thread", body: "Approval: Project" },
114 nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
115 nowIso: DateTime.formatIso(now),
116 });
117
118 expect(request.priority).toBe("10");
119 expect(request.payload).toMatchObject({
120 aps: {
121 event: "update",
122 alert: {
123 title: "Thread",
124 body: "Approval: Project",
125 sound: "default",
126 },
127 },
128 });
129 }).pipe(Effect.provide(layerTest)),
130 );
131
132 it.effect("builds an end payload with a dismissal date", () =>
133 Effect.gen(function* () {
134 const apns = yield* ApnsClient.ApnsClient;
135 const request = apns.makeLiveActivityRequest({
136 event: "end",
137 token: "token",
138 state,
139 nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
140 nowIso: DateTime.formatIso(now),
141 });
142
143 expect(request.priority).toBe("10");
144 expect(request.payload).toMatchObject({
145 aps: {
146 event: "end",
147 "dismissal-date": 300,
148 },
149 });
150
151 // Without final content the card would freeze on its previous state;
152 // contentless ends dismiss quickly instead.
153 const contentless = apns.makeLiveActivityRequest({
154 event: "end",
155 token: "token",
156 state: null,
157 nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
158 nowIso: DateTime.formatIso(now),
159 });
160 expect(contentless.payload).toMatchObject({
161 aps: {
162 event: "end",
163 "dismissal-date": 15,
164 },
165 });
166 }).pipe(Effect.provide(layerTest)),
167 );
168
169 it.effect("builds a standard APNs alert payload with routing metadata", () =>
170 Effect.gen(function* () {
171 const apns = yield* ApnsClient.ApnsClient;
172 const request = apns.makePushNotificationRequest({
173 token: "push-token",
174 notification: {
175 title: "Thread",
176 body: "Input: Project",
177 environmentId: "env",
178 threadId: "thread",
179 deepLink: "/threads/env/thread",
180 },
181 });
182
183 expect(request.priority).toBe("10");
184 expect(request.payload).toMatchObject({
185 aps: {
186 alert: {
187 title: "Thread",
188 body: "Input: Project",
189 },
190 sound: "default",
191 "thread-id": "env/thread",
192 },
193 environmentId: "env",
194 threadId: "thread",
195 deepLink: "/threads/env/thread",
196 });
197 }).pipe(Effect.provide(layerTest)),
198 );
199
200 it.effect("preserves JWT signing context and the crypto cause", () =>
201 Effect.gen(function* () {
202 const apns = yield* ApnsClient.ApnsClient;
203 const request = apns.makePushNotificationRequest({
204 token: "push-token",
205 notification: {
206 title: "Thread",
207 body: "Input: Project",
208 environmentId: "env",
209 threadId: "thread",
210 deepLink: "/threads/env/thread",
211 },
212 });
213 const error = yield* Effect.flip(
214 apns.sendPushNotificationRequest({
215 credentials: {
216 teamId: "team-1",
217 keyId: "key-1",
218 privateKey: Redacted.make("not-a-private-key"),
219 bundleId: "com.t3tools.test",
220 environment: "sandbox",
221 },
222 request,
223 issuedAtUnixSeconds: 123,
224 }),
225 );
226
227 expect(isApnsJwtSigningError(error)).toBe(true);
228 if (!isApnsJwtSigningError(error)) {
229 return yield* Effect.die("expected APNs JWT signing error");
230 }
231 expect(error).toMatchObject({
232 teamId: "team-1",
233 keyId: "key-1",
234 // The provider-token service quantizes iat to the reuse window, so
235 // the signing context carries the window start rather than the raw
236 // request time.
237 issuedAtUnixSeconds: 0,
238 cause: expect.any(Error),
239 message: "Failed to sign APNs JWT for key key-1.",
240 });
241 }).pipe(Effect.provide(layerTest)),
242 );
243
244 it.effect("preserves APNs request context and the HTTP cause", () => {
245 const httpCause = new Error("network unavailable");
246 const { privateKey } = NodeCrypto.generateKeyPairSync("ec", {
247 namedCurve: "prime256v1",
248 privateKeyEncoding: { type: "pkcs8", format: "pem" },
249 publicKeyEncoding: { type: "spki", format: "pem" },
250 });
251 const credentials = {
252 teamId: "team-1",
253 keyId: "key-1",
254 privateKey: Redacted.make(privateKey),
255 bundleId: "com.t3tools.test",
256 environment: "sandbox",
257 } satisfies ApnsCredentials;
258 const failingHttpClient = HttpClient.make((request) =>
259 Effect.fail(
260 new HttpClientError.HttpClientError({
261 reason: new HttpClientError.TransportError({ request, cause: httpCause }),
262 }),
263 ),
264 );
265 const layer = ApnsClient.layer.pipe(
266 Layer.provide(ApnsProviderTokens.layer),
267 Layer.provide(Layer.succeed(HttpClient.HttpClient, failingHttpClient)),
268 );
269
270 return Effect.gen(function* () {
271 const apns = yield* ApnsClient.ApnsClient;
272 const request = apns.makePushNotificationRequest({
273 token: "long-push-token",
274 notification: {
275 title: "Thread",
276 body: "Input: Project",
277 environmentId: "env",
278 threadId: "thread",
279 deepLink: "/threads/env/thread",
280 },
281 });
282 const error = yield* Effect.flip(
283 apns.sendPushNotificationRequest({
284 credentials,
285 request,
286 issuedAtUnixSeconds: 123,
287 }),
288 );
289
290 expect(isApnsHttpRequestError(error)).toBe(true);
291 if (!isApnsHttpRequestError(error)) {
292 return yield* Effect.die("expected APNs HTTP request error");
293 }
294 expect(error).toMatchObject({
295 requestKind: "push-notification",
296 event: null,
297 environment: "sandbox",
298 bundleId: "com.t3tools.test",
299 tokenSuffix: "sh-token",
300 stage: "send",
301 status: null,
302 message: "APNs push-notification request failed during send in sandbox.",
303 });
304 expect(error.cause).toBeInstanceOf(HttpClientError.HttpClientError);
305 expect((error.cause as HttpClientError.HttpClientError).reason).toMatchObject({
306 _tag: "TransportError",
307 cause: httpCause,
308 });
309 }).pipe(Effect.provide(layer));
310 });
311
312 it.effect("reuses the signed provider JWT across pushes within the reuse window", () => {
313 ApnsProviderTokens.__resetApnsProviderTokenCacheForTest();
314 const { privateKey } = NodeCrypto.generateKeyPairSync("ec", {
315 namedCurve: "prime256v1",
316 privateKeyEncoding: { type: "pkcs8", format: "pem" },
317 publicKeyEncoding: { type: "spki", format: "pem" },
318 });
319 const credentials = {
320 teamId: "team-jwt-cache",
321 keyId: "key-jwt-cache",
322 privateKey: Redacted.make(privateKey),
323 bundleId: "com.t3tools.test",
324 environment: "sandbox",
325 } satisfies ApnsCredentials;
326 const authorizations: Array<string> = [];
327 const capturingHttpClient = HttpClient.make((request) => {
328 authorizations.push(request.headers.authorization ?? "");
329 return Effect.succeed(
330 HttpClientResponse.fromWeb(
331 request,
332 new Response("", { status: 200, headers: { "apns-id": "apns-id-1" } }),
333 ),
334 );
335 });
336 const layer = ApnsClient.layer.pipe(
337 Layer.provide(ApnsProviderTokens.layer),
338 Layer.provide(Layer.succeed(HttpClient.HttpClient, capturingHttpClient)),
339 );
340
341 return Effect.gen(function* () {
342 const apns = yield* ApnsClient.ApnsClient;
343 const request = apns.makePushNotificationRequest({
344 token: "push-token",
345 notification: {
346 title: "Thread",
347 body: "Input: Project",
348 environmentId: "env",
349 threadId: "thread",
350 deepLink: "/threads/env/thread",
351 },
352 });
353 const send = (issuedAtUnixSeconds: number) =>
354 apns.sendPushNotificationRequest({ credentials, request, issuedAtUnixSeconds });
355
356 const window = ApnsProviderTokens.APNS_JWT_REUSE_SECONDS;
357 yield* send(window + 10);
358 yield* send(window * 2 - 1);
359 yield* send(window * 2);
360
361 expect(authorizations).toHaveLength(3);
362 // Within the 45-minute window APNs must see the byte-identical token;
363 // refreshing it per push trips TooManyProviderTokenUpdates.
364 expect(authorizations[1]).toBe(authorizations[0]);
365 expect(authorizations[2]).not.toBe(authorizations[0]);
366 ApnsProviderTokens.__resetApnsProviderTokenCacheForTest();
367 }).pipe(Effect.provide(layer));
368 });
369
370 it.effect.each(
371 (["live-activity", "push-notification"] as const).flatMap((requestKind) =>
372 (["send", "read-response"] as const).map((stage) => ({ requestKind, stage })),
373 ),
374 )("aborts a stalled $requestKind $stage after ten seconds", ({ requestKind, stage }) =>
375 Effect.gen(function* () {
376 const started = yield* Deferred.make<void>();
377 const signals: AbortSignal[] = [];
378 const stalledHttpClient = HttpClient.make((request, _url, signal) => {
379 signals.push(signal);
380 const stall = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never));
381 if (stage === "send") return stall;
382 const response = HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
383 Object.defineProperty(response, "text", { value: stall });
384 return Effect.succeed(response);
385 });
386 const layer = ApnsClient.layer.pipe(
387 Layer.provide(Layer.succeed(HttpClient.HttpClient, stalledHttpClient)),
388 Layer.provide(
389 Layer.succeed(ApnsProviderTokens.ApnsProviderTokens, {
390 getJwt: () => Effect.succeed("test-jwt"),
391 }),
392 ),
393 );
394 const apns = yield* ApnsClient.ApnsClient.pipe(Effect.provide(layer));
395 const credentials = {
396 teamId: "team-timeout",
397 keyId: "key-timeout",
398 privateKey: Redacted.make("unused-test-key"),
399 bundleId: "com.t3tools.test",
400 environment: "sandbox",
401 } satisfies ApnsCredentials;
402 const send =
403 requestKind === "live-activity"
404 ? apns.sendLiveActivityRequest({
405 credentials,
406 issuedAtUnixSeconds: 123,
407 request: apns.makeLiveActivityRequest({
408 event: "update",
409 token: "long-push-token",
410 state,
411 nowEpochSeconds: 123,
412 nowIso: DateTime.formatIso(now),
413 }),
414 })
415 : apns.sendPushNotificationRequest({
416 credentials,
417 issuedAtUnixSeconds: 123,
418 request: apns.makePushNotificationRequest({
419 token: "long-push-token",
420 notification: {
421 title: "Thread",
422 body: "Done",
423 environmentId: "env",
424 threadId: "thread",
425 deepLink: "/",
426 },
427 }),
428 });
429 const fiber = yield* send.pipe(Effect.flip, Effect.forkChild);
430 yield* Deferred.await(started);
431 yield* TestClock.adjust("10 seconds");
432 expect(signals[0]?.aborted).toBe(true);
433 const error = yield* Fiber.join(fiber);
434 expect(error).toMatchObject({
435 _tag: "ApnsHttpRequestError",
436 requestKind,
437 event: requestKind === "live-activity" ? "update" : null,
438 stage,
439 status: stage === "read-response" ? 200 : null,
440 tokenSuffix: "sh-token",
441 cause: { _tag: "TimeoutError" },
442 });
443 }),
444 );
445});