1import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";
2import { describe, expect, it } from "@effect/vitest";
3import * as Alchemy from "alchemy";
4import * as Cloudflare from "alchemy/Cloudflare";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Redacted from "effect/Redacted";
8
9import * as RelayConfiguration from "../Config.ts";
10import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts";
11
12const config: RelayConfiguration.RelayConfiguration["Service"] = {
13 relayIssuer: "https://relay.example.com",
14 apns: {
15 teamId: "team-1",
16 keyId: "key-1",
17 privateKey: Redacted.make("apns-private-key"),
18 bundleId: "com.t3tools.test",
19 environment: "sandbox",
20 },
21 clerkSecretKey: Redacted.make("clerk-secret"),
22 clerkPublishableKey: "pk_test_test",
23 clerkJwtAudience: "t3-code-relay",
24 apnsDeliveryJobSigningSecret: Redacted.make("apns-job-secret"),
25 cloudMintPrivateKey: Redacted.make("cloud-private-key"),
26 cloudMintPublicKey: "cloud-public-key",
27 managedEndpointBaseDomain: undefined,
28 managedEndpointNamespace: undefined,
29};
30
31describe("ApnsDeliveryQueue", () => {
32 it.effect("does not require the deployment RuntimeContext when building the Worker layer", () => {
33 const sent: unknown[] = [];
34 const sender: Cloudflare.Queues.WriteQueueClient = {
35 raw: Effect.die("raw queue binding is not used"),
36 send: (body) =>
37 Effect.sync(() => {
38 sent.push(body);
39 }),
40 sendBatch: () => Effect.die("batch queue binding is not used"),
41 };
42 const runtimeContext = {} as Alchemy.BaseRuntimeContext;
43 const layer = ApnsDeliveryQueue.layerCloudflareQueues(sender, runtimeContext).pipe(
44 Layer.provide(NodeCryptoLayer.layer),
45 Layer.provide(RelayConfiguration.layer(config)),
46 );
47
48 return Effect.gen(function* () {
49 const queue = yield* ApnsDeliveryQueue.ApnsDeliveryQueue;
50 yield* queue.enqueuePushNotification({
51 userId: "user-1",
52 deviceId: "device-1",
53 token: "push-token",
54 notification: {
55 title: "Thread",
56 body: "Input: Project",
57 environmentId: "env-1",
58 threadId: "thread-1",
59 deepLink: "/threads/env-1/thread-1",
60 },
61 });
62
63 expect(sent).toHaveLength(1);
64 }).pipe(Effect.provide(layer));
65 });
66
67 it.effect("preserves job identity and the queue sender cause", () => {
68 const cause = new Error("queue unavailable");
69 const senderCause = new Cloudflare.Queues.SendError({
70 message: cause.message,
71 cause,
72 });
73 const layer = ApnsDeliveryQueue.layer.pipe(
74 Layer.provide(NodeCryptoLayer.layer),
75 Layer.provide(RelayConfiguration.layer(config)),
76 Layer.provide(
77 Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, {
78 send: () => Effect.fail(senderCause),
79 }),
80 ),
81 );
82
83 return Effect.gen(function* () {
84 const queue = yield* ApnsDeliveryQueue.ApnsDeliveryQueue;
85 const error = yield* Effect.flip(
86 queue.enqueuePushNotification({
87 userId: "user-1",
88 deviceId: "device-1",
89 token: "push-token",
90 notification: {
91 title: "Thread",
92 body: "Input: Project",
93 environmentId: "env-1",
94 threadId: "thread-1",
95 deepLink: "/threads/env-1/thread-1",
96 },
97 }),
98 );
99
100 expect(error).toMatchObject({
101 _tag: "ApnsDeliveryQueueSendError",
102 operation: "send",
103 jobId: expect.any(String),
104 kind: "push_notification",
105 userId: "user-1",
106 deviceId: "device-1",
107 cause: senderCause,
108 });
109 expect(senderCause.cause).toBe(cause);
110 expect(error.message).toBe(
111 "Failed to enqueue APNs push notification delivery during send for device device-1.",
112 );
113 }).pipe(Effect.provide(layer));
114 });
115});