1import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay";
2import { describe, expect, it } from "@effect/vitest";
3import type { SQL } from "drizzle-orm";
4import { PgDialect } from "drizzle-orm/pg-core";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7
8import * as RelayDb from "../db.ts";
9import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts";
10import * as Devices from "./Devices.ts";
11
12const registration: RelayDeviceRegistrationRequest = {
13 deviceId: "device-1" as RelayDeviceRegistrationRequest["deviceId"],
14 label: "Julius's iPhone",
15 platform: "ios",
16 iosMajorVersion: 18,
17 appVersion: "1.0.0" as RelayDeviceRegistrationRequest["appVersion"],
18 bundleId: "com.t3tools.t3code.preview" as RelayDeviceRegistrationRequest["bundleId"],
19 apsEnvironment: "production",
20 pushToken: "apns-device-token" as RelayDeviceRegistrationRequest["pushToken"],
21 pushToStartToken: "push-to-start-token" as RelayDeviceRegistrationRequest["pushToStartToken"],
22 preferences: {
23 notificationsEnabled: true,
24 liveActivitiesEnabled: true,
25 notifyOnApproval: true,
26 notifyOnInput: true,
27 notifyOnCompletion: true,
28 notifyOnFailure: true,
29 },
30};
31
32describe("Devices", () => {
33 it.effect("claims APNs tokens globally before upserting the current user device", () => {
34 const calls: Array<string> = [];
35 const updateSets: Array<Record<string, unknown>> = [];
36 const updateConditions: Array<SQL> = [];
37 const insertedValues: Array<Record<string, unknown>> = [];
38 const dialect = new PgDialect();
39
40 const fakeDb = {
41 update: (table: unknown) => {
42 expect(table).toBe(relayMobileDevices);
43 calls.push("update");
44 return {
45 set: (values: Record<string, unknown>) => {
46 updateSets.push(values);
47 calls.push("update.set");
48 return {
49 where: (condition: SQL) => {
50 expect(condition).toBeDefined();
51 updateConditions.push(condition);
52 calls.push("update.where");
53 return Effect.void;
54 },
55 };
56 },
57 };
58 },
59 insert: (table: unknown) => {
60 expect(table).toBe(relayMobileDevices);
61 calls.push("insert");
62 return {
63 values: (values: Record<string, unknown>) => {
64 insertedValues.push(values);
65 calls.push("insert.values");
66 return {
67 onConflictDoUpdate: (config: unknown) => {
68 expect(config).toBeDefined();
69 calls.push("insert.onConflictDoUpdate");
70 return Effect.void;
71 },
72 };
73 },
74 };
75 },
76 } as unknown as RelayDb.RelayDb["Service"];
77
78 return Effect.gen(function* () {
79 const devices = yield* Devices.Devices;
80 yield* devices.register({ userId: "user-2", registration });
81
82 expect(calls).toEqual([
83 "update",
84 "update.set",
85 "update.where",
86 "update",
87 "update.set",
88 "update.where",
89 "insert",
90 "insert.values",
91 "insert.onConflictDoUpdate",
92 ]);
93 expect(updateSets).toEqual([
94 expect.objectContaining({ pushToken: null }),
95 expect.objectContaining({ pushToStartToken: null }),
96 ]);
97 expect(updateConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([
98 {
99 sql: '"relay_mobile_devices"."push_token" = $1',
100 params: ["apns-device-token"],
101 },
102 {
103 sql: '"relay_mobile_devices"."push_to_start_token" = $1',
104 params: ["push-to-start-token"],
105 },
106 ]);
107 expect(insertedValues).toEqual([
108 expect.objectContaining({
109 userId: "user-2",
110 deviceId: "device-1",
111 bundleId: "com.t3tools.t3code.preview",
112 apsEnvironment: "production",
113 pushToken: "apns-device-token",
114 pushToStartToken: "push-to-start-token",
115 }),
116 ]);
117 }).pipe(
118 Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))),
119 );
120 });
121
122 it.effect("unregisters APNs state only for the current user device", () => {
123 const calls: Array<string> = [];
124 const deleteConditions: Array<SQL> = [];
125 const dialect = new PgDialect();
126
127 const fakeDb = {
128 delete: (table: unknown) => {
129 calls.push(table === relayLiveActivities ? "delete.liveActivities" : "delete.devices");
130 return {
131 where: (condition: SQL) => {
132 expect(condition).toBeDefined();
133 deleteConditions.push(condition);
134 calls.push("delete.where");
135 return Effect.void;
136 },
137 };
138 },
139 } as unknown as RelayDb.RelayDb["Service"];
140
141 return Effect.gen(function* () {
142 const devices = yield* Devices.Devices;
143 yield* devices.unregister({ userId: "user-2", deviceId: "device-1" });
144
145 expect(calls).toEqual([
146 "delete.liveActivities",
147 "delete.where",
148 "delete.devices",
149 "delete.where",
150 ]);
151 expect(deleteConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([
152 {
153 sql:
154 '(("relay_live_activities"."user_id" = $1) and ' +
155 '("relay_live_activities"."device_id" = $2))',
156 params: ["user-2", "device-1"],
157 },
158 {
159 sql:
160 '(("relay_mobile_devices"."user_id" = $1) and ' +
161 '("relay_mobile_devices"."device_id" = $2))',
162 params: ["user-2", "device-1"],
163 },
164 ]);
165 }).pipe(
166 Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))),
167 );
168 });
169
170 it.effect("lists safe notification state without exposing APNs tokens", () => {
171 const dialect = new PgDialect();
172 let condition: SQL | null = null;
173 const fakeDb = {
174 select: () => ({
175 from: (table: unknown) => {
176 expect(table).toBe(relayMobileDevices);
177 return {
178 where: (nextCondition: SQL) => {
179 condition = nextCondition;
180 return Effect.succeed([
181 {
182 deviceId: "device-1",
183 label: "Julius's iPhone",
184 platform: "ios" as const,
185 iosMajorVersion: 18,
186 appVersion: "1.0.0",
187 preferences: registration.preferences,
188 updatedAt: "2026-06-01T00:00:00.000Z",
189 },
190 ]);
191 },
192 };
193 },
194 }),
195 } as unknown as RelayDb.RelayDb["Service"];
196
197 return Effect.gen(function* () {
198 const devices = yield* Devices.Devices;
199 const listed = yield* devices.listForUser({ userId: "user-2" });
200
201 expect(condition).not.toBeNull();
202 expect(dialect.sqlToQuery(condition!)).toEqual({
203 sql: '"relay_mobile_devices"."user_id" = $1',
204 params: ["user-2"],
205 });
206 expect(listed).toEqual([
207 {
208 deviceId: "device-1",
209 label: "Julius's iPhone",
210 platform: "ios",
211 iosMajorVersion: 18,
212 appVersion: "1.0.0",
213 notifications: {
214 enabled: true,
215 notifyOnApproval: true,
216 notifyOnInput: true,
217 notifyOnCompletion: true,
218 notifyOnFailure: true,
219 },
220 liveActivities: {
221 enabled: true,
222 },
223 updatedAt: "2026-06-01T00:00:00.000Z",
224 },
225 ]);
226 }).pipe(
227 Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))),
228 );
229 });
230
231 it.effect("identifies the failed device registration stage", () => {
232 const cause = new Error("push-token claim failed");
233 const fakeDb = {
234 update: () => ({
235 set: (values: Record<string, unknown>) => ({
236 where: () => ("pushToken" in values ? Effect.fail(cause) : Effect.void),
237 }),
238 }),
239 } as unknown as RelayDb.RelayDb["Service"];
240
241 return Effect.gen(function* () {
242 const devices = yield* Devices.Devices;
243 const error = yield* devices.register({ userId: "user-2", registration }).pipe(Effect.flip);
244
245 expect(error).toMatchObject({
246 userId: "user-2",
247 deviceId: "device-1",
248 stage: "claim-push-token",
249 });
250 expect(error.cause).toBe(cause);
251 expect(error.message).toBe(
252 "Failed to persist mobile device registration for user-2/device-1 during claim-push-token.",
253 );
254 }).pipe(
255 Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))),
256 );
257 });
258
259 it.effect("identifies the failed device unregistration stage", () => {
260 const cause = new Error("live activity delete failed");
261 const fakeDb = {
262 delete: (table: unknown) => ({
263 where: () => (table === relayLiveActivities ? Effect.fail(cause) : Effect.void),
264 }),
265 } as unknown as RelayDb.RelayDb["Service"];
266
267 return Effect.gen(function* () {
268 const devices = yield* Devices.Devices;
269 const error = yield* devices
270 .unregister({ userId: "user-2", deviceId: "device-1" })
271 .pipe(Effect.flip);
272
273 expect(error).toMatchObject({
274 userId: "user-2",
275 deviceId: "device-1",
276 stage: "delete-live-activity",
277 });
278 expect(error.cause).toBe(cause);
279 expect(error.message).toBe(
280 "Failed to unregister mobile device user-2/device-1 during delete-live-activity.",
281 );
282 }).pipe(
283 Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))),
284 );
285 });
286
287 it.effect("attaches the user to device list failures", () => {
288 const cause = new Error("device list failed");
289 const fakeDb = {
290 select: () => ({
291 from: () => ({
292 where: () => Effect.fail(cause),
293 }),
294 }),
295 } as unknown as RelayDb.RelayDb["Service"];
296
297 return Effect.gen(function* () {
298 const devices = yield* Devices.Devices;
299 const error = yield* devices.listForUser({ userId: "user-2" }).pipe(Effect.flip);
300
301 expect(error).toMatchObject({ userId: "user-2" });
302 expect(error.cause).toBe(cause);
303 expect(error.message).toBe("Failed to list mobile devices for user-2.");
304 }).pipe(
305 Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))),
306 );
307 });
308});