import * as NodeCrypto from "node:crypto"; import { EnvironmentId, ThreadId } from "@t3tools/contracts";import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay";import { describe, expect, it } from "@effect/vitest";import * as DateTime from "effect/DateTime";import * as Deferred from "effect/Deferred";import * as Effect from "effect/Effect";import * as Fiber from "effect/Fiber";import * as Layer from "effect/Layer";import * as Redacted from "effect/Redacted";import * as Schema from "effect/Schema";import * as TestClock from "effect/testing/TestClock";import * as HttpClient from "effect/unstable/http/HttpClient";import * as HttpClientError from "effect/unstable/http/HttpClientError";import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import type { ApnsCredentials } from "../Config.ts";import * as ApnsClient from "./ApnsClient.ts";import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; const isApnsJwtSigningError = Schema.is(ApnsClient.ApnsJwtSigningError);const isApnsHttpRequestError = Schema.is(ApnsClient.ApnsHttpRequestError); const TestLayer = ApnsClient.layer.pipe( Layer.provide(ApnsProviderTokens.layer), Layer.provide( Layer.succeed( HttpClient.HttpClient, HttpClient.make(() => Effect.die("unexpected APNs HTTP request")), ), ),); describe("ApnsClient", () => { const now = DateTime.makeUnsafe(0); const state: RelayAgentActivityAggregateState = { title: "T3 Code", subtitle: "Agent work in progress", activeCount: 1, updatedAt: DateTime.formatIso(now), activities: [ { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread"), projectTitle: "Project", threadTitle: "Thread", modelTitle: "gpt-5.4", phase: "running" as const, status: "Working", updatedAt: DateTime.formatIso(now), deepLink: "/", }, ], }; it.effect("requests an update push token when remotely starting a Live Activity", () => Effect.gen(function* () { const apns = yield* ApnsClient.ApnsClient; const request = apns.makeLiveActivityRequest({ event: "start", token: "token", state, nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), nowIso: DateTime.formatIso(now), }); expect(request.priority).toBe("10"); expect(request.payload).toMatchObject({ aps: { event: "start", "attributes-type": "LiveActivityAttributes", "input-push-token": 1, "content-state": { name: "AgentActivity", }, }, }); }).pipe(Effect.provide(TestLayer)), ); it.effect("builds a low-priority update payload", () => Effect.gen(function* () { const apns = yield* ApnsClient.ApnsClient; const request = apns.makeLiveActivityRequest({ event: "update", token: "token", state, nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), nowIso: DateTime.formatIso(now), }); expect(request.priority).toBe("5"); expect(request.payload).toMatchObject({ aps: { event: "update", "content-state": { name: "AgentActivity", }, }, }); }).pipe(Effect.provide(TestLayer)), ); it.effect("builds a high-priority alerting update payload when an alert is attached", () => Effect.gen(function* () { const apns = yield* ApnsClient.ApnsClient; const request = apns.makeLiveActivityRequest({ event: "update", token: "token", state, alert: { title: "Thread", body: "Approval: Project" }, nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), nowIso: DateTime.formatIso(now), }); expect(request.priority).toBe("10"); expect(request.payload).toMatchObject({ aps: { event: "update", alert: { title: "Thread", body: "Approval: Project", sound: "default", }, }, }); }).pipe(Effect.provide(TestLayer)), ); it.effect("builds an end payload with a dismissal date", () => Effect.gen(function* () { const apns = yield* ApnsClient.ApnsClient; const request = apns.makeLiveActivityRequest({ event: "end", token: "token", state, nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), nowIso: DateTime.formatIso(now), }); expect(request.priority).toBe("10"); expect(request.payload).toMatchObject({ aps: { event: "end", "dismissal-date": 300, }, }); // Without final content the card would freeze on its previous state; // contentless ends dismiss quickly instead. const contentless = apns.makeLiveActivityRequest({ event: "end", token: "token", state: null, nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), nowIso: DateTime.formatIso(now), }); expect(contentless.payload).toMatchObject({ aps: { event: "end", "dismissal-date": 15, }, }); }).pipe(Effect.provide(TestLayer)), ); it.effect("builds a standard APNs alert payload with routing metadata", () => Effect.gen(function* () { const apns = yield* ApnsClient.ApnsClient; const request = apns.makePushNotificationRequest({ token: "push-token", notification: { title: "Thread", body: "Input: Project", environmentId: "env", threadId: "thread", deepLink: "/threads/env/thread", }, }); expect(request.priority).toBe("10"); expect(request.payload).toMatchObject({ aps: { alert: { title: "Thread", body: "Input: Project", }, sound: "default", }, environmentId: "env", threadId: "thread", deepLink: "/threads/env/thread", }); }).pipe(Effect.provide(TestLayer)), ); it.effect("preserves JWT signing context and the crypto cause", () => Effect.gen(function* () { const apns = yield* ApnsClient.ApnsClient; const request = apns.makePushNotificationRequest({ token: "push-token", notification: { title: "Thread", body: "Input: Project", environmentId: "env", threadId: "thread", deepLink: "/threads/env/thread", }, }); const error = yield* Effect.flip( apns.sendPushNotificationRequest({ credentials: { teamId: "team-1", keyId: "key-1", privateKey: Redacted.make("not-a-private-key"), bundleId: "com.t3tools.test", environment: "sandbox", }, request, issuedAtUnixSeconds: 123, }), ); expect(isApnsJwtSigningError(error)).toBe(true); if (!isApnsJwtSigningError(error)) { return yield* Effect.die("expected APNs JWT signing error"); } expect(error).toMatchObject({ teamId: "team-1", keyId: "key-1", // The provider-token service quantizes iat to the reuse window, so // the signing context carries the window start rather than the raw // request time. issuedAtUnixSeconds: 0, cause: expect.any(Error), message: "Failed to sign APNs JWT for key key-1.", }); }).pipe(Effect.provide(TestLayer)), ); it.effect("preserves APNs request context and the HTTP cause", () => { const httpCause = new Error("network unavailable"); const { privateKey } = NodeCrypto.generateKeyPairSync("ec", { namedCurve: "prime256v1", privateKeyEncoding: { type: "pkcs8", format: "pem" }, publicKeyEncoding: { type: "spki", format: "pem" }, }); const credentials = { teamId: "team-1", keyId: "key-1", privateKey: Redacted.make(privateKey), bundleId: "com.t3tools.test", environment: "sandbox", } satisfies ApnsCredentials; const failingHttpClient = HttpClient.make((request) => Effect.fail( new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({ request, cause: httpCause }), }), ), ); const layer = ApnsClient.layer.pipe( Layer.provide(ApnsProviderTokens.layer), Layer.provide(Layer.succeed(HttpClient.HttpClient, failingHttpClient)), ); return Effect.gen(function* () { const apns = yield* ApnsClient.ApnsClient; const request = apns.makePushNotificationRequest({ token: "long-push-token", notification: { title: "Thread", body: "Input: Project", environmentId: "env", threadId: "thread", deepLink: "/threads/env/thread", }, }); const error = yield* Effect.flip( apns.sendPushNotificationRequest({ credentials, request, issuedAtUnixSeconds: 123, }), ); expect(isApnsHttpRequestError(error)).toBe(true); if (!isApnsHttpRequestError(error)) { return yield* Effect.die("expected APNs HTTP request error"); } expect(error).toMatchObject({ requestKind: "push-notification", event: null, environment: "sandbox", bundleId: "com.t3tools.test", tokenSuffix: "sh-token", stage: "send", status: null, message: "APNs push-notification request failed during send in sandbox.", }); expect(error.cause).toBeInstanceOf(HttpClientError.HttpClientError); expect((error.cause as HttpClientError.HttpClientError).reason).toMatchObject({ _tag: "TransportError", cause: httpCause, }); }).pipe(Effect.provide(layer)); }); it.effect("reuses the signed provider JWT across pushes within the reuse window", () => { ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); const { privateKey } = NodeCrypto.generateKeyPairSync("ec", { namedCurve: "prime256v1", privateKeyEncoding: { type: "pkcs8", format: "pem" }, publicKeyEncoding: { type: "spki", format: "pem" }, }); const credentials = { teamId: "team-jwt-cache", keyId: "key-jwt-cache", privateKey: Redacted.make(privateKey), bundleId: "com.t3tools.test", environment: "sandbox", } satisfies ApnsCredentials; const authorizations: Array<string> = []; const capturingHttpClient = HttpClient.make((request) => { authorizations.push(request.headers.authorization ?? ""); return Effect.succeed( HttpClientResponse.fromWeb( request, new Response("", { status: 200, headers: { "apns-id": "apns-id-1" } }), ), ); }); const layer = ApnsClient.layer.pipe( Layer.provide(ApnsProviderTokens.layer), Layer.provide(Layer.succeed(HttpClient.HttpClient, capturingHttpClient)), ); return Effect.gen(function* () { const apns = yield* ApnsClient.ApnsClient; const request = apns.makePushNotificationRequest({ token: "push-token", notification: { title: "Thread", body: "Input: Project", environmentId: "env", threadId: "thread", deepLink: "/threads/env/thread", }, }); const send = (issuedAtUnixSeconds: number) => apns.sendPushNotificationRequest({ credentials, request, issuedAtUnixSeconds }); const window = ApnsProviderTokens.APNS_JWT_REUSE_SECONDS; yield* send(window + 10); yield* send(window * 2 - 1); yield* send(window * 2); expect(authorizations).toHaveLength(3); // Within the 45-minute window APNs must see the byte-identical token; // refreshing it per push trips TooManyProviderTokenUpdates. expect(authorizations[1]).toBe(authorizations[0]); expect(authorizations[2]).not.toBe(authorizations[0]); ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); }).pipe(Effect.provide(layer)); }); for (const requestKind of ["live-activity", "push-notification"] as const) { for (const stage of ["send", "read-response"] as const) { it.effect(`aborts a stalled ${requestKind} ${stage} after ten seconds`, () => Effect.gen(function* () { const started = yield* Deferred.make<void>(); const signals: AbortSignal[] = []; const stalledHttpClient = HttpClient.make((request, _url, signal) => { signals.push(signal); const stall = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)); if (stage === "send") return stall; const response = HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); Object.defineProperty(response, "text", { value: stall }); return Effect.succeed(response); }); const layer = ApnsClient.layer.pipe( Layer.provide(Layer.succeed(HttpClient.HttpClient, stalledHttpClient)), Layer.provide( Layer.succeed(ApnsProviderTokens.ApnsProviderTokens, { getJwt: () => Effect.succeed("test-jwt"), }), ), ); const apns = yield* ApnsClient.ApnsClient.pipe(Effect.provide(layer)); const credentials = { teamId: "team-timeout", keyId: "key-timeout", privateKey: Redacted.make("unused-test-key"), bundleId: "com.t3tools.test", environment: "sandbox", } satisfies ApnsCredentials; const send = requestKind === "live-activity" ? apns.sendLiveActivityRequest({ credentials, issuedAtUnixSeconds: 123, request: apns.makeLiveActivityRequest({ event: "update", token: "long-push-token", state, nowEpochSeconds: 123, nowIso: DateTime.formatIso(now), }), }) : apns.sendPushNotificationRequest({ credentials, issuedAtUnixSeconds: 123, request: apns.makePushNotificationRequest({ token: "long-push-token", notification: { title: "Thread", body: "Done", environmentId: "env", threadId: "thread", deepLink: "/", }, }), }); const fiber = yield* send.pipe(Effect.flip, Effect.forkChild); yield* Deferred.await(started); yield* TestClock.adjust("10 seconds"); expect(signals[0]?.aborted).toBe(true); const error = yield* Fiber.join(fiber); expect(error).toMatchObject({ _tag: "ApnsHttpRequestError", requestKind, event: requestKind === "live-activity" ? "update" : null, stage, status: stage === "read-response" ? 200 : null, tokenSuffix: "sh-token", cause: { _tag: "TimeoutError" }, }); }), ); } }});
import * as NodeCrypto from "node:crypto";
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay";
import { describe, expect, it } from "@effect/vitest";
import * as DateTime from "effect/DateTime";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Redacted from "effect/Redacted";
import * as Schema from "effect/Schema";
import * as TestClock from "effect/testing/TestClock";
import * as HttpClient from "effect/unstable/http/HttpClient";
import * as HttpClientError from "effect/unstable/http/HttpClientError";
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
import type { ApnsCredentials } from "../Config.ts";
import * as ApnsClient from "./ApnsClient.ts";
import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";
const isApnsJwtSigningError = Schema.is(ApnsClient.ApnsJwtSigningError);
const isApnsHttpRequestError = Schema.is(ApnsClient.ApnsHttpRequestError);
const TestLayer = ApnsClient.layer.pipe(
Layer.provide(ApnsProviderTokens.layer),
Layer.provide(
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make(() => Effect.die("unexpected APNs HTTP request")),
),
),
);
describe("ApnsClient", () => {
const now = DateTime.makeUnsafe(0);
const state: RelayAgentActivityAggregateState = {
title: "T3 Code",
subtitle: "Agent work in progress",
activeCount: 1,
updatedAt: DateTime.formatIso(now),
activities: [
{
environmentId: EnvironmentId.make("env"),
threadId: ThreadId.make("thread"),
projectTitle: "Project",
threadTitle: "Thread",
modelTitle: "gpt-5.4",
phase: "running" as const,
status: "Working",
updatedAt: DateTime.formatIso(now),
deepLink: "/",
},
],
};
it.effect("requests an update push token when remotely starting a Live Activity", () =>
Effect.gen(function* () {
const apns = yield* ApnsClient.ApnsClient;
const request = apns.makeLiveActivityRequest({
event: "start",
token: "token",
state,
nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
nowIso: DateTime.formatIso(now),
});
expect(request.priority).toBe("10");
expect(request.payload).toMatchObject({
aps: {
event: "start",
"attributes-type": "LiveActivityAttributes",
"input-push-token": 1,
"content-state": {
name: "AgentActivity",
},
},
});
}).pipe(Effect.provide(TestLayer)),
);
it.effect("builds a low-priority update payload", () =>
Effect.gen(function* () {
const apns = yield* ApnsClient.ApnsClient;
const request = apns.makeLiveActivityRequest({
event: "update",
token: "token",
state,
nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
nowIso: DateTime.formatIso(now),
});
expect(request.priority).toBe("5");
expect(request.payload).toMatchObject({
aps: {
event: "update",
"content-state": {
name: "AgentActivity",
},
},
});
}).pipe(Effect.provide(TestLayer)),
);
it.effect("builds a high-priority alerting update payload when an alert is attached", () =>
Effect.gen(function* () {
const apns = yield* ApnsClient.ApnsClient;
const request = apns.makeLiveActivityRequest({
event: "update",
token: "token",
state,
alert: { title: "Thread", body: "Approval: Project" },
nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
nowIso: DateTime.formatIso(now),
});
expect(request.priority).toBe("10");
expect(request.payload).toMatchObject({
aps: {
event: "update",
alert: {
title: "Thread",
body: "Approval: Project",
sound: "default",
},
},
});
}).pipe(Effect.provide(TestLayer)),
);
it.effect("builds an end payload with a dismissal date", () =>
Effect.gen(function* () {
const apns = yield* ApnsClient.ApnsClient;
const request = apns.makeLiveActivityRequest({
event: "end",
token: "token",
state,
nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
nowIso: DateTime.formatIso(now),
});
expect(request.priority).toBe("10");
expect(request.payload).toMatchObject({
aps: {
event: "end",
"dismissal-date": 300,
},
});
// Without final content the card would freeze on its previous state;
// contentless ends dismiss quickly instead.
const contentless = apns.makeLiveActivityRequest({
event: "end",
token: "token",
state: null,
nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
nowIso: DateTime.formatIso(now),
});
expect(contentless.payload).toMatchObject({
aps: {
event: "end",
"dismissal-date": 15,
},
});
}).pipe(Effect.provide(TestLayer)),
);
it.effect("builds a standard APNs alert payload with routing metadata", () =>
Effect.gen(function* () {
const apns = yield* ApnsClient.ApnsClient;
const request = apns.makePushNotificationRequest({
token: "push-token",
notification: {
title: "Thread",
body: "Input: Project",
environmentId: "env",
threadId: "thread",
deepLink: "/threads/env/thread",
},
});
expect(request.priority).toBe("10");
expect(request.payload).toMatchObject({
aps: {
alert: {
title: "Thread",
body: "Input: Project",
},
sound: "default",
},
environmentId: "env",
threadId: "thread",
deepLink: "/threads/env/thread",
});
}).pipe(Effect.provide(TestLayer)),
);
it.effect("preserves JWT signing context and the crypto cause", () =>
Effect.gen(function* () {
const apns = yield* ApnsClient.ApnsClient;
const request = apns.makePushNotificationRequest({
token: "push-token",
notification: {
title: "Thread",
body: "Input: Project",
environmentId: "env",
threadId: "thread",
deepLink: "/threads/env/thread",
},
});
const error = yield* Effect.flip(
apns.sendPushNotificationRequest({
credentials: {
teamId: "team-1",
keyId: "key-1",
privateKey: Redacted.make("not-a-private-key"),
bundleId: "com.t3tools.test",
environment: "sandbox",
},
request,
issuedAtUnixSeconds: 123,
}),
);
expect(isApnsJwtSigningError(error)).toBe(true);
if (!isApnsJwtSigningError(error)) {
return yield* Effect.die("expected APNs JWT signing error");
}
expect(error).toMatchObject({
teamId: "team-1",
keyId: "key-1",
// The provider-token service quantizes iat to the reuse window, so
// the signing context carries the window start rather than the raw
// request time.
issuedAtUnixSeconds: 0,
cause: expect.any(Error),
message: "Failed to sign APNs JWT for key key-1.",
});
}).pipe(Effect.provide(TestLayer)),
);
it.effect("preserves APNs request context and the HTTP cause", () => {
const httpCause = new Error("network unavailable");
const { privateKey } = NodeCrypto.generateKeyPairSync("ec", {
namedCurve: "prime256v1",
privateKeyEncoding: { type: "pkcs8", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
});
const credentials = {
teamId: "team-1",
keyId: "key-1",
privateKey: Redacted.make(privateKey),
bundleId: "com.t3tools.test",
environment: "sandbox",
} satisfies ApnsCredentials;
const failingHttpClient = HttpClient.make((request) =>
Effect.fail(
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({ request, cause: httpCause }),
}),
),
);
const layer = ApnsClient.layer.pipe(
Layer.provide(ApnsProviderTokens.layer),
Layer.provide(Layer.succeed(HttpClient.HttpClient, failingHttpClient)),
);
return Effect.gen(function* () {
const apns = yield* ApnsClient.ApnsClient;
const request = apns.makePushNotificationRequest({
token: "long-push-token",
notification: {
title: "Thread",
body: "Input: Project",
environmentId: "env",
threadId: "thread",
deepLink: "/threads/env/thread",
},
});
const error = yield* Effect.flip(
apns.sendPushNotificationRequest({
credentials,
request,
issuedAtUnixSeconds: 123,
}),
);
expect(isApnsHttpRequestError(error)).toBe(true);
if (!isApnsHttpRequestError(error)) {
return yield* Effect.die("expected APNs HTTP request error");
}
expect(error).toMatchObject({
requestKind: "push-notification",
event: null,
environment: "sandbox",
bundleId: "com.t3tools.test",
tokenSuffix: "sh-token",
stage: "send",
status: null,
message: "APNs push-notification request failed during send in sandbox.",
});
expect(error.cause).toBeInstanceOf(HttpClientError.HttpClientError);
expect((error.cause as HttpClientError.HttpClientError).reason).toMatchObject({
_tag: "TransportError",
cause: httpCause,
});
}).pipe(Effect.provide(layer));
});
it.effect("reuses the signed provider JWT across pushes within the reuse window", () => {
ApnsProviderTokens.__resetApnsProviderTokenCacheForTest();
const { privateKey } = NodeCrypto.generateKeyPairSync("ec", {
namedCurve: "prime256v1",
privateKeyEncoding: { type: "pkcs8", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
});
const credentials = {
teamId: "team-jwt-cache",
keyId: "key-jwt-cache",
privateKey: Redacted.make(privateKey),
bundleId: "com.t3tools.test",
environment: "sandbox",
} satisfies ApnsCredentials;
const authorizations: Array<string> = [];
const capturingHttpClient = HttpClient.make((request) => {
authorizations.push(request.headers.authorization ?? "");
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response("", { status: 200, headers: { "apns-id": "apns-id-1" } }),
),
);
});
const layer = ApnsClient.layer.pipe(
Layer.provide(ApnsProviderTokens.layer),
Layer.provide(Layer.succeed(HttpClient.HttpClient, capturingHttpClient)),
);
return Effect.gen(function* () {
const apns = yield* ApnsClient.ApnsClient;
const request = apns.makePushNotificationRequest({
token: "push-token",
notification: {
title: "Thread",
body: "Input: Project",
environmentId: "env",
threadId: "thread",
deepLink: "/threads/env/thread",
},
});
const send = (issuedAtUnixSeconds: number) =>
apns.sendPushNotificationRequest({ credentials, request, issuedAtUnixSeconds });
const window = ApnsProviderTokens.APNS_JWT_REUSE_SECONDS;
yield* send(window + 10);
yield* send(window * 2 - 1);
yield* send(window * 2);
expect(authorizations).toHaveLength(3);
// Within the 45-minute window APNs must see the byte-identical token;
// refreshing it per push trips TooManyProviderTokenUpdates.
expect(authorizations[1]).toBe(authorizations[0]);
expect(authorizations[2]).not.toBe(authorizations[0]);
ApnsProviderTokens.__resetApnsProviderTokenCacheForTest();
}).pipe(Effect.provide(layer));
});
for (const requestKind of ["live-activity", "push-notification"] as const) {
for (const stage of ["send", "read-response"] as const) {
it.effect(`aborts a stalled ${requestKind} ${stage} after ten seconds`, () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>();
const signals: AbortSignal[] = [];
const stalledHttpClient = HttpClient.make((request, _url, signal) => {
signals.push(signal);
const stall = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never));
if (stage === "send") return stall;
const response = HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
Object.defineProperty(response, "text", { value: stall });
return Effect.succeed(response);
});
const layer = ApnsClient.layer.pipe(
Layer.provide(Layer.succeed(HttpClient.HttpClient, stalledHttpClient)),
Layer.provide(
Layer.succeed(ApnsProviderTokens.ApnsProviderTokens, {
getJwt: () => Effect.succeed("test-jwt"),
}),
),
);
const apns = yield* ApnsClient.ApnsClient.pipe(Effect.provide(layer));
const credentials = {
teamId: "team-timeout",
keyId: "key-timeout",
privateKey: Redacted.make("unused-test-key"),
bundleId: "com.t3tools.test",
environment: "sandbox",
} satisfies ApnsCredentials;
const send =
requestKind === "live-activity"
? apns.sendLiveActivityRequest({
credentials,
issuedAtUnixSeconds: 123,
request: apns.makeLiveActivityRequest({
event: "update",
token: "long-push-token",
state,
nowEpochSeconds: 123,
nowIso: DateTime.formatIso(now),
}),
})
: apns.sendPushNotificationRequest({
credentials,
issuedAtUnixSeconds: 123,
request: apns.makePushNotificationRequest({
token: "long-push-token",
notification: {
title: "Thread",
body: "Done",
environmentId: "env",
threadId: "thread",
deepLink: "/",
},
}),
});
const fiber = yield* send.pipe(Effect.flip, Effect.forkChild);
yield* Deferred.await(started);
yield* TestClock.adjust("10 seconds");
expect(signals[0]?.aborted).toBe(true);
const error = yield* Fiber.join(fiber);
expect(error).toMatchObject({
_tag: "ApnsHttpRequestError",
requestKind,
event: requestKind === "live-activity" ? "update" : null,
stage,
status: stage === "read-response" ? 200 : null,
tokenSuffix: "sh-token",
cause: { _tag: "TimeoutError" },
});
}),
);
}
}
});