@vex-chat/libvex 9.0.0 → 9.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Client.ts CHANGED
@@ -455,6 +455,8 @@ export interface Channels {
455
455
  retrieve: (serverID: string) => Promise<Channel[]>;
456
456
  /** Gets one channel by ID. */
457
457
  retrieveByID: (channelID: string) => Promise<Channel | null>;
458
+ /** Changes a channel's display name. */
459
+ update: (channelID: string, name: string) => Promise<Channel>;
458
460
  /** Lists users currently visible in a channel. */
459
461
  userList: (channelID: string) => Promise<User[]>;
460
462
  }
@@ -1129,6 +1131,7 @@ const retryRequestNotifyData = z.union([
1129
1131
  mailID: z.string(),
1130
1132
  }),
1131
1133
  ]);
1134
+ const serverChangeNotifyData = z.string().min(1).max(128);
1132
1135
 
1133
1136
  /**
1134
1137
  * Event signatures emitted by {@link Client}.
@@ -1165,6 +1168,8 @@ export interface ClientEvents {
1165
1168
  ready: () => void;
1166
1169
  /** Session healing requested a retry for a specific mail ID. */
1167
1170
  retryRequest: (retry: RetryRequest) => void;
1171
+ /** Server metadata, channels, membership, or permissions changed. */
1172
+ serverChange: (serverID: string) => void;
1168
1173
  /** A new encryption session was established with a peer device. */
1169
1174
  session: (session: Session, user: User) => void;
1170
1175
  }
@@ -1210,6 +1215,11 @@ export interface Moderation {
1210
1215
  fetchPermissionList: (serverID: string) => Promise<Permission[]>;
1211
1216
  /** Removes a user from a server by revoking their server permission(s). */
1212
1217
  kick: (userID: string, serverID: string) => Promise<void>;
1218
+ /** Changes a server member's role. */
1219
+ setRole: (
1220
+ permissionID: string,
1221
+ powerLevel: 0 | 50 | 100,
1222
+ ) => Promise<Permission>;
1213
1223
  }
1214
1224
 
1215
1225
  /**
@@ -1230,14 +1240,22 @@ export interface Servers {
1230
1240
  create: (name: string) => Promise<Server>;
1231
1241
  /** Deletes a server. */
1232
1242
  delete: (serverID: string) => Promise<void>;
1243
+ /** Returns the public URL for an immutable server icon ID. */
1244
+ iconURL: (iconID: string) => string;
1233
1245
  /** Leaves a server by removing the user's permission entry. */
1234
1246
  leave: (serverID: string) => Promise<void>;
1247
+ /** Removes a server's icon. */
1248
+ removeIcon: (serverID: string) => Promise<Server>;
1235
1249
  /** Lists servers available to the authenticated user. */
1236
1250
  retrieve: () => Promise<Server[]>;
1237
1251
  /** Gets one server by ID. */
1238
1252
  retrieveByID: (serverID: string) => Promise<null | Server>;
1239
1253
  /** Fetches servers and channels in one request for fast bootstraps. */
1240
1254
  retrieveWithChannels: () => Promise<ServerChannelBootstrap>;
1255
+ /** Uploads and sets a server icon. */
1256
+ setIcon: (serverID: string, icon: Uint8Array) => Promise<Server>;
1257
+ /** Changes a server's display name. */
1258
+ update: (serverID: string, name: string) => Promise<Server>;
1241
1259
  }
1242
1260
 
1243
1261
  /**
@@ -1458,6 +1476,8 @@ export class Client {
1458
1476
  * @returns The Channel object, or null.
1459
1477
  */
1460
1478
  retrieveByID: this.getChannelByID.bind(this),
1479
+ /** Changes a channel's display name. */
1480
+ update: this.updateChannel.bind(this),
1461
1481
  /**
1462
1482
  * Retrieves a channel's userlist.
1463
1483
  * @param channelID - The channel to retrieve the userlist for.
@@ -1613,6 +1633,7 @@ export class Client {
1613
1633
  public moderation: Moderation = {
1614
1634
  fetchPermissionList: this.fetchPermissionList.bind(this),
1615
1635
  kick: this.kickUser.bind(this),
1636
+ setRole: this.setServerMemberRole.bind(this),
1616
1637
  };
1617
1638
 
1618
1639
  /**
@@ -1673,7 +1694,9 @@ export class Client {
1673
1694
  * @param serverID - The server to delete.
1674
1695
  */
1675
1696
  delete: this.deleteServer.bind(this),
1697
+ iconURL: this.getServerIconURL.bind(this),
1676
1698
  leave: this.leaveServer.bind(this),
1699
+ removeIcon: this.removeServerIcon.bind(this),
1677
1700
  /**
1678
1701
  * Retrieves all servers the logged in user has access to.
1679
1702
  *
@@ -1687,6 +1710,8 @@ export class Client {
1687
1710
  */
1688
1711
  retrieveByID: this.getServerByID.bind(this),
1689
1712
  retrieveWithChannels: this.getServerChannelBootstrap.bind(this),
1713
+ setIcon: this.uploadServerIcon.bind(this),
1714
+ update: this.updateServer.bind(this),
1690
1715
  };
1691
1716
 
1692
1717
  /**
@@ -2855,7 +2880,9 @@ export class Client {
2855
2880
 
2856
2881
  private async createServer(name: string): Promise<Server> {
2857
2882
  const res = await this.http.post(
2858
- this.getHost() + "/server/" + globalThis.btoa(name),
2883
+ this.getHost() + "/servers",
2884
+ msgpack.encode({ name }),
2885
+ { headers: { "Content-Type": "application/msgpack" } },
2859
2886
  );
2860
2887
  return decodeHttpResponse(ServerCodec, res.data);
2861
2888
  }
@@ -3255,6 +3282,7 @@ export class Client {
3255
3282
  return [null, isHttpError(err) ? err : null];
3256
3283
  }
3257
3284
  }
3285
+
3258
3286
  private async fetchUserDeviceListOnce(userID: string): Promise<Device[]> {
3259
3287
  if (this.isManualCloseInFlight()) {
3260
3288
  return [];
@@ -3334,7 +3362,6 @@ export class Client {
3334
3362
  this.http.defaults.headers.common.Authorization = `Bearer ${decoded.token}`;
3335
3363
  return decoded;
3336
3364
  }
3337
-
3338
3365
  private async finishPasskeyRegistration(args: {
3339
3366
  name: string;
3340
3367
  requestID: string;
@@ -3778,6 +3805,19 @@ export class Client {
3778
3805
  return decodeHttpResponse(ServerChannelBootstrapCodec, res.data);
3779
3806
  }
3780
3807
 
3808
+ private getServerIconURL(iconID: string): string {
3809
+ const normalizedIconID = iconID.trim();
3810
+ if (!normalizedIconID) {
3811
+ throw new Error("Server icon ID cannot be empty.");
3812
+ }
3813
+
3814
+ return (
3815
+ this.getHost() +
3816
+ "/server-icon/" +
3817
+ encodeURIComponent(normalizedIconID)
3818
+ );
3819
+ }
3820
+
3781
3821
  private async getServerList(): Promise<Server[]> {
3782
3822
  const res = await this.http.get(
3783
3823
  this.getHost() + "/user/" + this.getUser().userID + "/servers",
@@ -3894,6 +3934,13 @@ export class Client {
3894
3934
  }
3895
3935
  }
3896
3936
  break;
3937
+ case "serverChange": {
3938
+ const parsed = serverChangeNotifyData.safeParse(msg.data);
3939
+ if (parsed.success) {
3940
+ this.emitter.emit("serverChange", parsed.data);
3941
+ }
3942
+ break;
3943
+ }
3897
3944
  default:
3898
3945
  break;
3899
3946
  }
@@ -5314,6 +5361,13 @@ export class Client {
5314
5361
  );
5315
5362
  }
5316
5363
 
5364
+ private async removeServerIcon(serverID: string): Promise<Server> {
5365
+ const res = await this.http.delete(
5366
+ this.getHost() + "/server-icon/" + serverID,
5367
+ );
5368
+ return decodeHttpResponse(ServerCodec, res.data);
5369
+ }
5370
+
5317
5371
  private async replaceAccountPassword(payload: {
5318
5372
  currentPassword?: string;
5319
5373
  newPassword: string;
@@ -5720,8 +5774,8 @@ export class Client {
5720
5774
  a.deviceID.localeCompare(b.deviceID, "en"),
5721
5775
  );
5722
5776
 
5723
- let failCount = 0;
5724
- let lastErr: unknown;
5777
+ const successfulOwnerIDs = new Set<string>();
5778
+ const lastErrorByOwnerID = new Map<string, unknown>();
5725
5779
  for (
5726
5780
  let index = 0;
5727
5781
  index < stableDevices.length;
@@ -5732,15 +5786,19 @@ export class Client {
5732
5786
  index + MAIL_FANOUT_CONCURRENCY,
5733
5787
  );
5734
5788
  const results = await Promise.all(
5735
- batch.map(async (device): Promise<undefined | unknown> => {
5789
+ batch.map(async (device) => {
5736
5790
  const ownerRecord =
5737
5791
  device.owner === myUserID
5738
5792
  ? this.getUser()
5739
5793
  : this.userRecords[device.owner];
5740
5794
  if (!ownerRecord) {
5741
- return new Error(
5742
- `Missing owner record for device ${device.deviceID}.`,
5743
- );
5795
+ return {
5796
+ device,
5797
+ error: new Error(
5798
+ `Missing owner record for device ${device.deviceID}.`,
5799
+ ),
5800
+ ok: false as const,
5801
+ };
5744
5802
  }
5745
5803
  try {
5746
5804
  await this.sendMailWithRecovery(
@@ -5751,35 +5809,41 @@ export class Client {
5751
5809
  mailID,
5752
5810
  false,
5753
5811
  );
5754
- return undefined;
5755
- } catch (e) {
5756
- return e;
5812
+ return { device, ok: true as const };
5813
+ } catch (error: unknown) {
5814
+ return { device, error, ok: false as const };
5757
5815
  }
5758
5816
  }),
5759
5817
  );
5760
5818
  for (const result of results) {
5761
- if (result !== undefined) {
5762
- lastErr = result;
5763
- failCount += 1;
5819
+ if (result.ok) {
5820
+ successfulOwnerIDs.add(result.device.owner);
5821
+ } else {
5822
+ lastErrorByOwnerID.set(result.device.owner, result.error);
5764
5823
  }
5765
5824
  }
5766
- if (failCount === stableDevices.length) {
5767
- break;
5768
- }
5769
5825
  }
5770
5826
 
5771
- if (failCount === stableDevices.length) {
5772
- throw lastErr instanceof Error
5773
- ? lastErr
5774
- : new Error(String(lastErr));
5775
- }
5776
- if (failCount > 0) {
5777
- const partial = new Error(
5778
- `Group message failed to reach ${String(failCount)} of ` +
5779
- `${String(stableDevices.length)} peer device(s).`,
5827
+ const requiredOwnerIDs =
5828
+ peerUserIDs.length > 0 ? peerUserIDs : [myUserID];
5829
+ const unreachableOwnerIDs = requiredOwnerIDs.filter(
5830
+ (ownerID) => !successfulOwnerIDs.has(ownerID),
5831
+ );
5832
+ if (unreachableOwnerIDs.length > 0) {
5833
+ const lastError = lastErrorByOwnerID.get(
5834
+ unreachableOwnerIDs.at(-1) ?? "",
5780
5835
  );
5781
- partial.cause = lastErr;
5782
- throw partial;
5836
+ if (
5837
+ unreachableOwnerIDs.length === requiredOwnerIDs.length &&
5838
+ lastError instanceof Error
5839
+ ) {
5840
+ throw lastError;
5841
+ }
5842
+ const deliveryError = new Error(
5843
+ `Message could not be delivered to ${String(unreachableOwnerIDs.length)} channel member(s).`,
5844
+ );
5845
+ deliveryError.cause = lastError;
5846
+ throw deliveryError;
5783
5847
  }
5784
5848
  }
5785
5849
 
@@ -6030,6 +6094,7 @@ export class Client {
6030
6094
  }
6031
6095
  let lastErr: unknown;
6032
6096
  let failCount = 0;
6097
+ let successCount = 0;
6033
6098
  // One logical DM fan-outs to multiple recipient devices. Reuse a
6034
6099
  // single mailID so local/UI dedupe treats it as one message.
6035
6100
  const messageMailID = uuid.v4();
@@ -6093,28 +6158,20 @@ export class Client {
6093
6158
  if (result !== undefined) {
6094
6159
  lastErr = result;
6095
6160
  failCount += 1;
6161
+ } else {
6162
+ successCount += 1;
6096
6163
  }
6097
6164
  }
6098
6165
  if (failCount === deviceList.length) {
6099
6166
  break;
6100
6167
  }
6101
6168
  }
6102
- if (failCount > 0) {
6169
+ if (successCount === 0) {
6103
6170
  const base =
6104
6171
  lastErr instanceof Error
6105
6172
  ? lastErr
6106
6173
  : new Error(String(lastErr));
6107
- if (failCount === deviceList.length) {
6108
- throw base;
6109
- }
6110
- // Multi-device: do not “succeed” when only one device of several got mail —
6111
- // callers and tests have no per-device result and the other copy times out.
6112
- const partial = new Error(
6113
- `Direct message failed to reach ${String(failCount)} of ` +
6114
- `${String(deviceList.length)} peer device(s) (X3DH/post).`,
6115
- );
6116
- partial.cause = base;
6117
- throw partial;
6174
+ throw base;
6118
6175
  }
6119
6176
  if (
6120
6177
  userID !== this.getUser().userID &&
@@ -6160,6 +6217,18 @@ export class Client {
6160
6217
  return decodeHttpResponse(AccountEntitlementsCodec, res.data);
6161
6218
  }
6162
6219
 
6220
+ private async setServerMemberRole(
6221
+ permissionID: string,
6222
+ powerLevel: 0 | 50 | 100,
6223
+ ): Promise<Permission> {
6224
+ const res = await this.http.patch(
6225
+ this.getHost() + "/permission/" + permissionID,
6226
+ msgpack.encode({ powerLevel }),
6227
+ { headers: { "Content-Type": "application/msgpack" } },
6228
+ );
6229
+ return decodeHttpResponse(PermissionCodec, res.data);
6230
+ }
6231
+
6163
6232
  private setUser(user: User): void {
6164
6233
  this.user = user;
6165
6234
  // Fresh identity / token: drop stale 404 negative-cache entries so a
@@ -6240,6 +6309,30 @@ export class Client {
6240
6309
  );
6241
6310
  }
6242
6311
 
6312
+ private async updateChannel(
6313
+ channelID: string,
6314
+ name: string,
6315
+ ): Promise<Channel> {
6316
+ const res = await this.http.patch(
6317
+ this.getHost() + "/channel/" + channelID,
6318
+ msgpack.encode({ name }),
6319
+ { headers: { "Content-Type": "application/msgpack" } },
6320
+ );
6321
+ return decodeHttpResponse(ChannelCodec, res.data);
6322
+ }
6323
+
6324
+ private async updateServer(
6325
+ serverID: string,
6326
+ name: string,
6327
+ ): Promise<Server> {
6328
+ const res = await this.http.patch(
6329
+ this.getHost() + "/server/" + serverID,
6330
+ msgpack.encode({ name }),
6331
+ { headers: { "Content-Type": "application/msgpack" } },
6332
+ );
6333
+ return decodeHttpResponse(ServerCodec, res.data);
6334
+ }
6335
+
6243
6336
  private async uploadAvatar(avatar: Uint8Array): Promise<void> {
6244
6337
  const canUseMultipart =
6245
6338
  typeof FormData !== "undefined" &&
@@ -6355,4 +6448,52 @@ export class Client {
6355
6448
  return null;
6356
6449
  }
6357
6450
  }
6451
+
6452
+ private async uploadServerIcon(
6453
+ serverID: string,
6454
+ icon: Uint8Array,
6455
+ ): Promise<Server> {
6456
+ const canUseMultipart =
6457
+ typeof FormData !== "undefined" &&
6458
+ (() => {
6459
+ try {
6460
+ void new Blob([new Uint8Array([1, 2, 3])]);
6461
+ return true;
6462
+ } catch {
6463
+ return false;
6464
+ }
6465
+ })();
6466
+
6467
+ if (canUseMultipart) {
6468
+ const payload = new FormData();
6469
+ payload.set("icon", new Blob([new Uint8Array(icon)]));
6470
+ const res = await this.http.post(
6471
+ this.getHost() + "/server-icon/" + serverID,
6472
+ payload,
6473
+ {
6474
+ headers: { "Content-Type": "multipart/form-data" },
6475
+ onUploadProgress: (progressEvent) => {
6476
+ const { loaded, total = 0 } = progressEvent;
6477
+ this.emitter.emit("fileProgress", {
6478
+ direction: "upload",
6479
+ loaded,
6480
+ progress: Math.round(
6481
+ (loaded * 100) / (progressEvent.total ?? 1),
6482
+ ),
6483
+ token: serverID,
6484
+ total,
6485
+ });
6486
+ },
6487
+ },
6488
+ );
6489
+ return decodeHttpResponse(ServerCodec, res.data);
6490
+ }
6491
+
6492
+ const res = await this.http.post(
6493
+ this.getHost() + "/server-icon/" + serverID + "/json",
6494
+ msgpack.encode({ file: XUtils.encodeBase64(icon) }),
6495
+ { headers: { "Content-Type": "application/msgpack" } },
6496
+ );
6497
+ return decodeHttpResponse(ServerCodec, res.data);
6498
+ }
6358
6499
  }
@@ -412,6 +412,31 @@ export function platformSuite(
412
412
  const byID = await client.servers.retrieveByID(server.serverID);
413
413
  expect(byID?.serverID).toBe(server.serverID);
414
414
 
415
+ const renamed = await client.servers.update(
416
+ server.serverID,
417
+ "Renamed Server",
418
+ );
419
+ expect(renamed.name).toBe("Renamed Server");
420
+
421
+ const withIcon = await client.servers.setIcon(
422
+ server.serverID,
423
+ testImage,
424
+ );
425
+ expect(withIcon.icon).toEqual(expect.any(String));
426
+ expect(client.servers.iconURL(withIcon.icon!)).toContain(
427
+ `/server-icon/${withIcon.icon!}`,
428
+ );
429
+ expect(client.servers.iconURL(` ${withIcon.icon!} `)).toBe(
430
+ client.servers.iconURL(withIcon.icon!),
431
+ );
432
+ expect(() => client.servers.iconURL(" ")).toThrow(
433
+ "Server icon ID cannot be empty.",
434
+ );
435
+ const withoutIcon = await client.servers.removeIcon(
436
+ server.serverID,
437
+ );
438
+ expect(withoutIcon.icon).toBeUndefined();
439
+
415
440
  await client.servers.delete(server.serverID);
416
441
  const afterDelete = await client.servers.retrieve();
417
442
  expect(
@@ -429,6 +454,12 @@ export function platformSuite(
429
454
  const byID = await client.channels.retrieveByID(channel.channelID);
430
455
  expect(byID?.channelID).toBe(channel.channelID);
431
456
 
457
+ const renamed = await client.channels.update(
458
+ channel.channelID,
459
+ "Renamed Channel",
460
+ );
461
+ expect(renamed.name).toBe("Renamed Channel");
462
+
432
463
  await client.channels.delete(channel.channelID);
433
464
  const channels = await client.channels.retrieve(server.serverID);
434
465
  expect(
@@ -436,6 +467,9 @@ export function platformSuite(
436
467
  ).toBe(false);
437
468
  // Default channel still exists
438
469
  expect(channels.length).toBe(1);
470
+ await expect(
471
+ client.channels.delete(channels[0]!.channelID),
472
+ ).rejects.toThrow();
439
473
 
440
474
  await client.servers.delete(server.serverID);
441
475
  });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+
7
+ import type { Device, User } from "@vex-chat/types";
8
+
9
+ import { describe, expect, it, vi } from "vitest";
10
+
11
+ import { Client } from "../Client.js";
12
+
13
+ type SendGroupMessage = (
14
+ this: unknown,
15
+ channelID: string,
16
+ message: string,
17
+ ) => Promise<void>;
18
+
19
+ type SendMessage = (
20
+ this: unknown,
21
+ userID: string,
22
+ message: string,
23
+ ) => Promise<void>;
24
+
25
+ const now = "2026-07-14T00:00:00.000Z";
26
+
27
+ function device(deviceID: string, owner: string): Device {
28
+ return {
29
+ deleted: false,
30
+ deviceID,
31
+ lastLogin: now,
32
+ name: deviceID,
33
+ owner,
34
+ signKey: `${deviceID}-sign-key`,
35
+ };
36
+ }
37
+
38
+ function user(userID: string, username: string): User {
39
+ return { lastSeen: now, userID, username };
40
+ }
41
+
42
+ describe("multi-device delivery", () => {
43
+ it("succeeds when one direct-message recipient device is unavailable", async () => {
44
+ const sender = user("user-a", "alice");
45
+ const recipient = user("user-b", "bob");
46
+ const unavailable = device("device-b-1", recipient.userID);
47
+ const available = device("device-b-2", recipient.userID);
48
+ const sendMailWithRecovery = vi.fn((target: Device) =>
49
+ target === unavailable
50
+ ? Promise.reject(new Error("Device is unavailable."))
51
+ : Promise.resolve(null),
52
+ );
53
+ const fakeClient = {
54
+ fetchUser: vi.fn(() => Promise.resolve([recipient, null])),
55
+ fetchUserDeviceListOnce: vi.fn(() =>
56
+ Promise.resolve([unavailable, available]),
57
+ ),
58
+ fetchUserDeviceListWithBackoff: vi.fn(() =>
59
+ Promise.resolve([unavailable, available]),
60
+ ),
61
+ getUser: () => sender,
62
+ sendMailWithRecovery,
63
+ };
64
+ const sendMessage = Reflect.get(
65
+ Client.prototype,
66
+ "sendMessage",
67
+ ) as SendMessage;
68
+
69
+ await expect(
70
+ sendMessage.call(fakeClient, recipient.userID, "hello"),
71
+ ).resolves.toBeUndefined();
72
+ expect(sendMailWithRecovery).toHaveBeenCalledTimes(2);
73
+ });
74
+
75
+ it("succeeds when another device for each group recipient receives the message", async () => {
76
+ const sender = user("user-a", "alice");
77
+ const recipient = user("user-b", "bob");
78
+ const ownDevice = device("device-a-1", sender.userID);
79
+ const unavailable = device("device-b-1", recipient.userID);
80
+ const available = device("device-b-2", recipient.userID);
81
+ const sendMailWithRecovery = vi.fn((target: Device) =>
82
+ target === unavailable
83
+ ? Promise.reject(new Error("Device is unavailable."))
84
+ : Promise.resolve(null),
85
+ );
86
+ const fakeClient = {
87
+ fetchUserDeviceListWithBackoff: vi.fn(() =>
88
+ Promise.resolve([ownDevice]),
89
+ ),
90
+ getMultiUserDeviceList: vi.fn(() =>
91
+ Promise.resolve([unavailable, available]),
92
+ ),
93
+ getUser: () => sender,
94
+ getUserList: vi.fn(() => Promise.resolve([sender, recipient])),
95
+ sendMailWithRecovery,
96
+ userRecords: {} as Record<string, User>,
97
+ };
98
+ const sendGroupMessage = Reflect.get(
99
+ Client.prototype,
100
+ "sendGroupMessage",
101
+ ) as SendGroupMessage;
102
+
103
+ await expect(
104
+ sendGroupMessage.call(
105
+ fakeClient,
106
+ "1b0a66e2-8275-4f8b-84d5-cb7309d41410",
107
+ "hello channel",
108
+ ),
109
+ ).resolves.toBeUndefined();
110
+ expect(sendMailWithRecovery).toHaveBeenCalledTimes(3);
111
+ });
112
+
113
+ it("still fails a group send when a recipient has no successful device delivery", async () => {
114
+ const sender = user("user-a", "alice");
115
+ const recipient = user("user-b", "bob");
116
+ const ownDevice = device("device-a-1", sender.userID);
117
+ const recipientDevice = device("device-b-1", recipient.userID);
118
+ const fakeClient = {
119
+ fetchUserDeviceListWithBackoff: vi.fn(() =>
120
+ Promise.resolve([ownDevice]),
121
+ ),
122
+ getMultiUserDeviceList: vi.fn(() =>
123
+ Promise.resolve([recipientDevice]),
124
+ ),
125
+ getUser: () => sender,
126
+ getUserList: vi.fn(() => Promise.resolve([sender, recipient])),
127
+ sendMailWithRecovery: vi.fn((target: Device) =>
128
+ target === recipientDevice
129
+ ? Promise.reject(new Error("Recipient is unavailable."))
130
+ : Promise.resolve(null),
131
+ ),
132
+ userRecords: {} as Record<string, User>,
133
+ };
134
+ const sendGroupMessage = Reflect.get(
135
+ Client.prototype,
136
+ "sendGroupMessage",
137
+ ) as SendGroupMessage;
138
+
139
+ await expect(
140
+ sendGroupMessage.call(
141
+ fakeClient,
142
+ "1b0a66e2-8275-4f8b-84d5-cb7309d41410",
143
+ "hello channel",
144
+ ),
145
+ ).rejects.toThrow("Recipient is unavailable.");
146
+ });
147
+ });
@@ -138,6 +138,33 @@ describe("SqliteStorage message at-rest encryption", () => {
138
138
  await storage.close();
139
139
  }
140
140
  });
141
+
142
+ it("atomically updates an existing Double Ratchet session", async () => {
143
+ const { storage } = makeStorage();
144
+ try {
145
+ await storage.init();
146
+ const session = makeSession({ Ns: 1 });
147
+ await storage.saveSession(session);
148
+
149
+ await storage.saveSession({
150
+ ...session,
151
+ CKs: "77".repeat(32),
152
+ lastUsed: "2026-06-02T00:00:00.000Z",
153
+ Ns: 2,
154
+ });
155
+
156
+ const rows = await storage.getAllSessions();
157
+ expect(rows).toHaveLength(1);
158
+ expect(rows[0]).toMatchObject({
159
+ CKs: "77".repeat(32),
160
+ lastUsed: "2026-06-02T00:00:00.000Z",
161
+ Ns: 2,
162
+ sessionID: session.sessionID,
163
+ });
164
+ } finally {
165
+ await storage.close();
166
+ }
167
+ });
141
168
  });
142
169
 
143
170
  function makeMessage(overrides: Partial<Message>): Message {