Skip to content
Source
Sign inSign up
Open History to see the last commit

ApnsClient.test.ts

305 lines10.2 KB

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 Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Redacted from "effect/Redacted";
11import * as HttpClient from "effect/http/HttpClient";
12import * as HttpClientResponse from "effect/http/HttpClientResponse";
13​
14import type { ApnsCredentials } from "../Config.ts";
15import * as ApnsClient from "./ApnsClient.ts";
16import {
17 sanitizeAgentActivityAggregateState,
18 sanitizeApnsNotificationPayload,
19} from "./agentActivityPayloads.ts";
20import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";
21​
22const layerTest = ApnsClient.layer.pipe(
23 Layer.provide(ApnsProviderTokens.layer),
24 Layer.provide(
25 Layer.succeed(
26 HttpClient.HttpClient,
27 HttpClient.make(() => Effect.die("unexpected APNs HTTP request")),
28 ),
29 ),
30);
31​
32describe("ApnsClient", () => {
33 const now = DateTime.makeUnsafe(0);
34 const state: RelayAgentActivityAggregateState = {
35 title: "T3 Code",
36 subtitle: "Agent work in progress",
37 activeCount: 1,
38 updatedAt: DateTime.formatIso(now),
39 activities: [
40 {
41 environmentId: EnvironmentId.make("env"),
42 threadId: ThreadId.make("thread"),
43 projectTitle: "Project",
44 threadTitle: "Thread",
45 modelTitle: "gpt-5.4",
46 phase: "running" as const,
47 status: "Working",
48 updatedAt: DateTime.formatIso(now),
49 deepLink: "/",
50 },
51 ],
52 };
53​
54 it.effect("builds a high-priority alerting update payload when an alert is attached", () =>
55 Effect.gen(function* () {
56 const apns = yield* ApnsClient.ApnsClient;
57 const request = apns.makeLiveActivityRequest({
58 event: "update",
59 token: "token",
60 state,
61 alert: { title: "Thread", body: "Approval: Project" },
62 nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
63 nowIso: DateTime.formatIso(now),
64 });
65​
66 expect(request.priority).toBe("10");
67 expect(request.payload).toMatchObject({
68 aps: {
69 event: "update",
70 alert: {
71 title: "Thread",
72 body: "Approval: Project",
73 sound: "default",
74 },
75 },
76 });
77 }).pipe(Effect.provide(layerTest)),
78 );
79​
80 it.effect("builds an end payload with a dismissal date", () =>
81 Effect.gen(function* () {
82 const apns = yield* ApnsClient.ApnsClient;
83 const request = apns.makeLiveActivityRequest({
84 event: "end",
85 token: "token",
86 state,
87 nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
88 nowIso: DateTime.formatIso(now),
89 });
90​
91 expect(request.priority).toBe("10");
92 expect(request.payload).toMatchObject({
93 aps: {
94 event: "end",
95 "dismissal-date": 300,
96 },
97 });
98​
99 const contentless = apns.makeLiveActivityRequest({
100 event: "end",
101 token: "token",
102 state: null,
103 nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
104 nowIso: DateTime.formatIso(now),
105 });
106 expect(contentless.payload).toMatchObject({
107 aps: {
108 event: "end",
109 "dismissal-date": 15,
110 },
111 });
112 }).pipe(Effect.provide(layerTest)),
113 );
114​
115 it.effect("puts remote routing metadata only in Expo's body field", () =>
116 Effect.gen(function* () {
117 const apns = yield* ApnsClient.ApnsClient;
118 const request = apns.makePushNotificationRequest({
119 token: "push-token",
120 notification: {
121 title: "Thread",
122 body: "Input: Project",
123 environmentId: "env",
124 threadId: "thread",
125 deepLink: "/threads/env/thread",
126 },
127 });
128​
129 expect(request.priority).toBe("10");
130 expect(request.payload).toEqual({
131 aps: {
132 alert: {
133 title: "Thread",
134 body: "Input: Project",
135 },
136 sound: "default",
137 "thread-id": "env/thread",
138 },
139 body: {
140 environmentId: "env",
141 threadId: "thread",
142 deepLink: "/threads/env/thread",
143 },
144 });
145 }).pipe(Effect.provide(layerTest)),
146 );
147​
148 it.effect("fits long routing metadata and multibyte alerts within the APNs 4096-byte limit", () =>
149 Effect.gen(function* () {
150 const apns = yield* ApnsClient.ApnsClient;
151 const environmentId = "é".repeat(191);
152 const threadId = "t".repeat(512);
153 const notification = sanitizeApnsNotificationPayload({
154 title: "界".repeat(120),
155 body: "界".repeat(120),
156 environmentId,
157 threadId,
158 deepLink: `/threads/${encodeURIComponent(environmentId)}/${threadId}`,
159 });
160 const request = apns.makePushNotificationRequest({ token: "push-token", notification });
161​
162 expect(
163 new TextEncoder().encode(JSON.stringify(request.payload)).byteLength,
164 ).toBeLessThanOrEqual(4096);
165 expect(request.payload).toMatchObject({
166 body: {
167 environmentId,
168 threadId,
169 deepLink: notification.deepLink,
170 },
171 });
172 }).pipe(Effect.provide(layerTest)),
173 );
174​
175 it.effect("shortens multibyte row titles so Live Activity updates fit within 4096 bytes", () =>
176 Effect.gen(function* () {
177 const apns = yield* ApnsClient.ApnsClient;
178 const longState = sanitizeAgentActivityAggregateState({
179 ...state,
180 activeCount: 5,
181 activities: ["a", "b", "c", "d", "e"].map((id) => ({
182 ...state.activities[0]!,
183 threadId: ThreadId.make(id),
184 projectTitle: "界".repeat(200),
185 threadTitle: "界".repeat(200),
186 })),
187 });
188 const request = apns.makeLiveActivityRequest({
189 event: "update",
190 token: "token",
191 state: longState,
192 alert: { title: "界".repeat(120), body: "界".repeat(120) },
193 nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
194 nowIso: DateTime.formatIso(now),
195 });
196​
197 expect(
198 new TextEncoder().encode(JSON.stringify(request.payload)).byteLength,
199 ).toBeLessThanOrEqual(4096);
200 const payload = request.payload as { aps: { "content-state": { props: string } } };
201 const sent = JSON.parse(payload.aps["content-state"].props) as typeof longState;
202 expect(sent.activities.map((row) => row.threadId)).toEqual(["a", "b", "c", "d", "e"]);
203 expect(sent.activities[0]?.threadTitle).toMatch(/^界+\.\.\.$/);
204 }).pipe(Effect.provide(layerTest)),
205 );
206​
207 it.effect("fits Live Activity updates at every sanitizer cap within 4096 bytes", () =>
208 Effect.gen(function* () {
209 const apns = yield* ApnsClient.ApnsClient;
210 const ids = ["a", "b", "c", "d", "e"];
211 const cappedState = sanitizeAgentActivityAggregateState({
212 ...state,
213 activeCount: 5,
214 activities: ids.map((id) => ({
215 ...state.activities[0]!,
216 threadId: ThreadId.make(id),
217 projectTitle: "界".repeat(120),
218 threadTitle: "界".repeat(120),
219 deepLink: `/${"d".repeat(511)}`,
220 })),
221 });
222 const alert = {
223 title: "界".repeat(120),
224 body: `Working: ${"界".repeat(120)}`,
225 };
226​
227 for (const event of ["update"] as const) {
228 const request = apns.makeLiveActivityRequest({
229 event,
230 token: "token",
231 state: cappedState,
232 alert,
233 nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
234 nowIso: DateTime.formatIso(now),
235 });
236​
237 expect(
238 new TextEncoder().encode(JSON.stringify(request.payload)).byteLength,
239 ).toBeLessThanOrEqual(4096);
240 const payload = request.payload as { aps: { "content-state": { props: string } } };
241 const sent = JSON.parse(payload.aps["content-state"].props) as typeof cappedState;
242 expect(sent.activities.length).toBeGreaterThan(0);
243 expect(sent.activities.map((row) => row.threadId)).toEqual(
244 ids.slice(0, sent.activities.length),
245 );
246 }
247 }).pipe(Effect.provide(layerTest)),
248 );
249​
250 it.effect("reuses the signed provider JWT across pushes within the reuse window", () => {
251 ApnsProviderTokens.__resetApnsProviderTokenCacheForTest();
252 const { privateKey } = NodeCrypto.generateKeyPairSync("ec", {
253 namedCurve: "prime256v1",
254 privateKeyEncoding: { type: "pkcs8", format: "pem" },
255 publicKeyEncoding: { type: "spki", format: "pem" },
256 });
257 const credentials = {
258 teamId: "team-jwt-cache",
259 keyId: "key-jwt-cache",
260 privateKey: Redacted.make(privateKey),
261 bundleId: "com.t3tools.test",
262 environment: "sandbox",
263 } satisfies ApnsCredentials;
264 const authorizations: Array<string> = [];
265 const capturingHttpClient = HttpClient.make((request) => {
266 authorizations.push(request.headers.authorization ?? "");
267 return Effect.succeed(
268 HttpClientResponse.fromWeb(
269 request,
270 new Response("", { status: 200, headers: { "apns-id": "apns-id-1" } }),
271 ),
272 );
273 });
274 const layer = ApnsClient.layer.pipe(
275 Layer.provide(ApnsProviderTokens.layer),
276 Layer.provide(Layer.succeed(HttpClient.HttpClient, capturingHttpClient)),
277 );
278​
279 return Effect.gen(function* () {
280 const apns = yield* ApnsClient.ApnsClient;
281 const request = apns.makePushNotificationRequest({
282 token: "push-token",
283 notification: {
284 title: "Thread",
285 body: "Input: Project",
286 environmentId: "env",
287 threadId: "thread",
288 deepLink: "/threads/env/thread",
289 },
290 });
291 const send = (issuedAtUnixSeconds: number) =>
292 apns.sendPushNotificationRequest({ credentials, request, issuedAtUnixSeconds });
293​
294 const window = ApnsProviderTokens.APNS_JWT_REUSE_SECONDS;
295 yield* send(window + 10);
296 yield* send(window * 2 - 1);
297 yield* send(window * 2);
298​
299 expect(authorizations).toHaveLength(3);
300 expect(authorizations[1]).toBe(authorizations[0]);
301 expect(authorizations[2]).not.toBe(authorizations[0]);
302 ApnsProviderTokens.__resetApnsProviderTokenCacheForTest();
303 }).pipe(Effect.provide(layer));
304 });
305});