1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Redacted from "effect/Redacted";
5
6import * as RelayConfiguration from "../Config.ts";
7import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
8import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts";
9import * as HeldHooks from "./HeldHooks.ts";
10import * as HookInbox from "./HookInbox.ts";
11
12const settings = {
13 managedEndpointBaseDomain: "example.test",
14 managedEndpointNamespace: "dev",
15 cloudMintPrivateKey: Redacted.make("unused"),
16} as RelayConfiguration.RelayConfiguration["Service"];
17
18const environmentId = "env-hook";
19const ownKey = "0123456789abcdef";
20const otherKey = "fedcba9876543210";
21
22const allocationFor = (
23 userId: string,
24 key: string,
25 ready: boolean,
26): ManagedEndpointAllocations.ManagedEndpointAllocation => ({
27 userId,
28 environmentId,
29 hostname: `${key}.example.test`,
30 tunnelId: `tunnel-${key}`,
31 tunnelName: `t3coderelay-managedendpoint-dev-${key}`,
32 dnsRecordId: "dns-record-id",
33 readyAt: ready ? "2026-05-25T00:00:00.000Z" : null,
34 tunnelReleasedAt: null,
35 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
36 updatedAt: "2026-05-25T00:00:00.000Z",
37 generation: 1,
38});
39
40const linkFor = (userId: string, key: string, environmentPublicKey: string) => ({
41 userId,
42 environmentId: environmentId as never,
43 label: "Hook env",
44 endpoint: {
45 httpBaseUrl: `https://${key}.example.test/`,
46 wsBaseUrl: `wss://${key}.example.test/ws`,
47 providerKind: "cloudflare_tunnel" as const,
48 },
49 environmentPublicKey,
50 linkedAt: "2026-05-25T00:00:00.000Z",
51 holdWebhooksWhileOffline: true,
52});
53
54/** This environment's own link (user_1) and another account's link of the same environment. */
55const withService = <A, E>(
56 options: { readonly ownReady?: boolean; readonly pendingKeys?: ReadonlyArray<string> },
57 body: (input: {
58 readonly heldHooks: HeldHooks.HeldHooks["Service"];
59 readonly cleared: Array<string>;
60 readonly woken: Array<string>;
61 }) => Effect.Effect<A, E>,
62) => {
63 const cleared: Array<string> = [];
64 const woken: Array<string> = [];
65 const links = [linkFor("user_1", ownKey, "own-key"), linkFor("user_2", otherKey, "other-key")];
66 const allocations = [
67 allocationFor("user_1", ownKey, options.ownReady ?? true),
68 allocationFor("user_2", otherKey, true),
69 ];
70 const layerDependencies = Layer.mergeAll(
71 Layer.succeed(RelayConfiguration.RelayConfiguration, settings),
72 Layer.mock(EnvironmentLinks.EnvironmentLinks, {
73 findActiveManagedForEnvironment: (input) =>
74 Effect.succeed(
75 links.filter(
76 (link) =>
77 link.environmentId === input.environmentId &&
78 (input.userId === undefined || link.userId === input.userId) &&
79 (input.environmentPublicKey === undefined ||
80 link.environmentPublicKey === input.environmentPublicKey),
81 ),
82 ),
83 setHoldWebhooksWhileOffline: () => Effect.void,
84 }),
85 Layer.mock(ManagedEndpointAllocations.ManagedEndpointAllocations, {
86 get: (input) =>
87 Effect.succeed(
88 allocations.find((allocation) => allocation.userId === input.userId) ?? null,
89 ),
90 getByTunnelName: (tunnelName) =>
91 Effect.succeed(
92 allocations.find((allocation) => allocation.tunnelName === tunnelName) ?? null,
93 ),
94 }),
95 Layer.mock(HookInbox.HookInbox, {
96 clear: ({ endpointKey }) => Effect.sync(() => void cleared.push(endpointKey)),
97 wake: ({ endpointKey }) =>
98 Effect.sync(() => {
99 woken.push(endpointKey);
100 return (options.pendingKeys ?? []).includes(endpointKey);
101 }),
102 }),
103 );
104 return Effect.gen(function* () {
105 const heldHooks = yield* HeldHooks.HeldHooks;
106 return yield* body({ heldHooks, cleared, woken });
107 }).pipe(Effect.provide(HeldHooks.layer.pipe(Layer.provide(layerDependencies))));
108};
109
110describe("HeldHooks", () => {
111 it.effect("opting out clears only the caller's own endpoints", () =>
112 withService({}, ({ heldHooks, cleared }) =>
113 Effect.gen(function* () {
114 yield* heldHooks.setHoldWhileOffline({
115 environmentId,
116 environmentPublicKey: "own-key",
117 holdWebhooksWhileOffline: false,
118 });
119 expect(cleared).toEqual([ownKey]);
120 }),
121 ),
122 );
123
124 it.effect("opting in keeps what is held", () =>
125 withService({}, ({ heldHooks, cleared }) =>
126 Effect.gen(function* () {
127 yield* heldHooks.setHoldWhileOffline({
128 environmentId,
129 environmentPublicKey: "own-key",
130 holdWebhooksWhileOffline: true,
131 });
132 expect(cleared).toEqual([]);
133 }),
134 ),
135 );
136
137 it.effect("wakes the caller's ready endpoints and reports whether anything was waiting", () =>
138 withService({ pendingKeys: [ownKey] }, ({ heldHooks, woken }) =>
139 Effect.gen(function* () {
140 expect(yield* heldHooks.wake({ environmentId, environmentPublicKey: "own-key" })).toBe(
141 true,
142 );
143 expect(woken).toEqual([ownKey]);
144 }),
145 ),
146 );
147
148 it.effect("does not wake an endpoint that is not ready", () =>
149 withService({ ownReady: false, pendingKeys: [ownKey] }, ({ heldHooks, woken }) =>
150 Effect.gen(function* () {
151 expect(yield* heldHooks.wake({ environmentId, environmentPublicKey: "own-key" })).toBe(
152 false,
153 );
154 expect(woken).toEqual([]);
155 }),
156 ),
157 );
158});