@vex-chat/libvex 9.0.0 → 9.1.0

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,10 @@ export class Client {
3778
3805
  return decodeHttpResponse(ServerChannelBootstrapCodec, res.data);
3779
3806
  }
3780
3807
 
3808
+ private getServerIconURL(iconID: string): string {
3809
+ return this.getHost() + "/server-icon/" + encodeURIComponent(iconID);
3810
+ }
3811
+
3781
3812
  private async getServerList(): Promise<Server[]> {
3782
3813
  const res = await this.http.get(
3783
3814
  this.getHost() + "/user/" + this.getUser().userID + "/servers",
@@ -3894,6 +3925,13 @@ export class Client {
3894
3925
  }
3895
3926
  }
3896
3927
  break;
3928
+ case "serverChange": {
3929
+ const parsed = serverChangeNotifyData.safeParse(msg.data);
3930
+ if (parsed.success) {
3931
+ this.emitter.emit("serverChange", parsed.data);
3932
+ }
3933
+ break;
3934
+ }
3897
3935
  default:
3898
3936
  break;
3899
3937
  }
@@ -5314,6 +5352,13 @@ export class Client {
5314
5352
  );
5315
5353
  }
5316
5354
 
5355
+ private async removeServerIcon(serverID: string): Promise<Server> {
5356
+ const res = await this.http.delete(
5357
+ this.getHost() + "/server-icon/" + serverID,
5358
+ );
5359
+ return decodeHttpResponse(ServerCodec, res.data);
5360
+ }
5361
+
5317
5362
  private async replaceAccountPassword(payload: {
5318
5363
  currentPassword?: string;
5319
5364
  newPassword: string;
@@ -5720,8 +5765,8 @@ export class Client {
5720
5765
  a.deviceID.localeCompare(b.deviceID, "en"),
5721
5766
  );
5722
5767
 
5723
- let failCount = 0;
5724
- let lastErr: unknown;
5768
+ const successfulOwnerIDs = new Set<string>();
5769
+ const lastErrorByOwnerID = new Map<string, unknown>();
5725
5770
  for (
5726
5771
  let index = 0;
5727
5772
  index < stableDevices.length;
@@ -5732,15 +5777,19 @@ export class Client {
5732
5777
  index + MAIL_FANOUT_CONCURRENCY,
5733
5778
  );
5734
5779
  const results = await Promise.all(
5735
- batch.map(async (device): Promise<undefined | unknown> => {
5780
+ batch.map(async (device) => {
5736
5781
  const ownerRecord =
5737
5782
  device.owner === myUserID
5738
5783
  ? this.getUser()
5739
5784
  : this.userRecords[device.owner];
5740
5785
  if (!ownerRecord) {
5741
- return new Error(
5742
- `Missing owner record for device ${device.deviceID}.`,
5743
- );
5786
+ return {
5787
+ device,
5788
+ error: new Error(
5789
+ `Missing owner record for device ${device.deviceID}.`,
5790
+ ),
5791
+ ok: false as const,
5792
+ };
5744
5793
  }
5745
5794
  try {
5746
5795
  await this.sendMailWithRecovery(
@@ -5751,35 +5800,41 @@ export class Client {
5751
5800
  mailID,
5752
5801
  false,
5753
5802
  );
5754
- return undefined;
5755
- } catch (e) {
5756
- return e;
5803
+ return { device, ok: true as const };
5804
+ } catch (error: unknown) {
5805
+ return { device, error, ok: false as const };
5757
5806
  }
5758
5807
  }),
5759
5808
  );
5760
5809
  for (const result of results) {
5761
- if (result !== undefined) {
5762
- lastErr = result;
5763
- failCount += 1;
5810
+ if (result.ok) {
5811
+ successfulOwnerIDs.add(result.device.owner);
5812
+ } else {
5813
+ lastErrorByOwnerID.set(result.device.owner, result.error);
5764
5814
  }
5765
5815
  }
5766
- if (failCount === stableDevices.length) {
5767
- break;
5768
- }
5769
5816
  }
5770
5817
 
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).`,
5818
+ const requiredOwnerIDs =
5819
+ peerUserIDs.length > 0 ? peerUserIDs : [myUserID];
5820
+ const unreachableOwnerIDs = requiredOwnerIDs.filter(
5821
+ (ownerID) => !successfulOwnerIDs.has(ownerID),
5822
+ );
5823
+ if (unreachableOwnerIDs.length > 0) {
5824
+ const lastError = lastErrorByOwnerID.get(
5825
+ unreachableOwnerIDs.at(-1) ?? "",
5826
+ );
5827
+ if (
5828
+ unreachableOwnerIDs.length === requiredOwnerIDs.length &&
5829
+ lastError instanceof Error
5830
+ ) {
5831
+ throw lastError;
5832
+ }
5833
+ const deliveryError = new Error(
5834
+ `Message could not be delivered to ${String(unreachableOwnerIDs.length)} channel member(s).`,
5780
5835
  );
5781
- partial.cause = lastErr;
5782
- throw partial;
5836
+ deliveryError.cause = lastError;
5837
+ throw deliveryError;
5783
5838
  }
5784
5839
  }
5785
5840
 
@@ -6030,6 +6085,7 @@ export class Client {
6030
6085
  }
6031
6086
  let lastErr: unknown;
6032
6087
  let failCount = 0;
6088
+ let successCount = 0;
6033
6089
  // One logical DM fan-outs to multiple recipient devices. Reuse a
6034
6090
  // single mailID so local/UI dedupe treats it as one message.
6035
6091
  const messageMailID = uuid.v4();
@@ -6093,28 +6149,20 @@ export class Client {
6093
6149
  if (result !== undefined) {
6094
6150
  lastErr = result;
6095
6151
  failCount += 1;
6152
+ } else {
6153
+ successCount += 1;
6096
6154
  }
6097
6155
  }
6098
6156
  if (failCount === deviceList.length) {
6099
6157
  break;
6100
6158
  }
6101
6159
  }
6102
- if (failCount > 0) {
6160
+ if (successCount === 0) {
6103
6161
  const base =
6104
6162
  lastErr instanceof Error
6105
6163
  ? lastErr
6106
6164
  : 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;
6165
+ throw base;
6118
6166
  }
6119
6167
  if (
6120
6168
  userID !== this.getUser().userID &&
@@ -6160,6 +6208,18 @@ export class Client {
6160
6208
  return decodeHttpResponse(AccountEntitlementsCodec, res.data);
6161
6209
  }
6162
6210
 
6211
+ private async setServerMemberRole(
6212
+ permissionID: string,
6213
+ powerLevel: 0 | 50 | 100,
6214
+ ): Promise<Permission> {
6215
+ const res = await this.http.patch(
6216
+ this.getHost() + "/permission/" + permissionID,
6217
+ msgpack.encode({ powerLevel }),
6218
+ { headers: { "Content-Type": "application/msgpack" } },
6219
+ );
6220
+ return decodeHttpResponse(PermissionCodec, res.data);
6221
+ }
6222
+
6163
6223
  private setUser(user: User): void {
6164
6224
  this.user = user;
6165
6225
  // Fresh identity / token: drop stale 404 negative-cache entries so a
@@ -6240,6 +6300,30 @@ export class Client {
6240
6300
  );
6241
6301
  }
6242
6302
 
6303
+ private async updateChannel(
6304
+ channelID: string,
6305
+ name: string,
6306
+ ): Promise<Channel> {
6307
+ const res = await this.http.patch(
6308
+ this.getHost() + "/channel/" + channelID,
6309
+ msgpack.encode({ name }),
6310
+ { headers: { "Content-Type": "application/msgpack" } },
6311
+ );
6312
+ return decodeHttpResponse(ChannelCodec, res.data);
6313
+ }
6314
+
6315
+ private async updateServer(
6316
+ serverID: string,
6317
+ name: string,
6318
+ ): Promise<Server> {
6319
+ const res = await this.http.patch(
6320
+ this.getHost() + "/server/" + serverID,
6321
+ msgpack.encode({ name }),
6322
+ { headers: { "Content-Type": "application/msgpack" } },
6323
+ );
6324
+ return decodeHttpResponse(ServerCodec, res.data);
6325
+ }
6326
+
6243
6327
  private async uploadAvatar(avatar: Uint8Array): Promise<void> {
6244
6328
  const canUseMultipart =
6245
6329
  typeof FormData !== "undefined" &&
@@ -6355,4 +6439,52 @@ export class Client {
6355
6439
  return null;
6356
6440
  }
6357
6441
  }
6442
+
6443
+ private async uploadServerIcon(
6444
+ serverID: string,
6445
+ icon: Uint8Array,
6446
+ ): Promise<Server> {
6447
+ const canUseMultipart =
6448
+ typeof FormData !== "undefined" &&
6449
+ (() => {
6450
+ try {
6451
+ void new Blob([new Uint8Array([1, 2, 3])]);
6452
+ return true;
6453
+ } catch {
6454
+ return false;
6455
+ }
6456
+ })();
6457
+
6458
+ if (canUseMultipart) {
6459
+ const payload = new FormData();
6460
+ payload.set("icon", new Blob([new Uint8Array(icon)]));
6461
+ const res = await this.http.post(
6462
+ this.getHost() + "/server-icon/" + serverID,
6463
+ payload,
6464
+ {
6465
+ headers: { "Content-Type": "multipart/form-data" },
6466
+ onUploadProgress: (progressEvent) => {
6467
+ const { loaded, total = 0 } = progressEvent;
6468
+ this.emitter.emit("fileProgress", {
6469
+ direction: "upload",
6470
+ loaded,
6471
+ progress: Math.round(
6472
+ (loaded * 100) / (progressEvent.total ?? 1),
6473
+ ),
6474
+ token: serverID,
6475
+ total,
6476
+ });
6477
+ },
6478
+ },
6479
+ );
6480
+ return decodeHttpResponse(ServerCodec, res.data);
6481
+ }
6482
+
6483
+ const res = await this.http.post(
6484
+ this.getHost() + "/server-icon/" + serverID + "/json",
6485
+ msgpack.encode({ file: XUtils.encodeBase64(icon) }),
6486
+ { headers: { "Content-Type": "application/msgpack" } },
6487
+ );
6488
+ return decodeHttpResponse(ServerCodec, res.data);
6489
+ }
6358
6490
  }
@@ -412,6 +412,25 @@ 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
+ const withoutIcon = await client.servers.removeIcon(
430
+ server.serverID,
431
+ );
432
+ expect(withoutIcon.icon).toBeUndefined();
433
+
415
434
  await client.servers.delete(server.serverID);
416
435
  const afterDelete = await client.servers.retrieve();
417
436
  expect(
@@ -429,6 +448,12 @@ export function platformSuite(
429
448
  const byID = await client.channels.retrieveByID(channel.channelID);
430
449
  expect(byID?.channelID).toBe(channel.channelID);
431
450
 
451
+ const renamed = await client.channels.update(
452
+ channel.channelID,
453
+ "Renamed Channel",
454
+ );
455
+ expect(renamed.name).toBe("Renamed Channel");
456
+
432
457
  await client.channels.delete(channel.channelID);
433
458
  const channels = await client.channels.retrieve(server.serverID);
434
459
  expect(
@@ -436,6 +461,9 @@ export function platformSuite(
436
461
  ).toBe(false);
437
462
  // Default channel still exists
438
463
  expect(channels.length).toBe(1);
464
+ await expect(
465
+ client.channels.delete(channels[0]!.channelID),
466
+ ).rejects.toThrow();
439
467
 
440
468
  await client.servers.delete(server.serverID);
441
469
  });
@@ -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 {