+3
-1
deno.lock
+3
-1
deno.lock
···
215
215
"packages/consumer": {
216
216
"dependencies": [
217
217
"jsr:@puregarlic/randimal@^1.0.1",
218
+
"jsr:@std/expect@^1.0.17",
218
219
"npm:@atcute/atproto@^3.1.9",
219
220
"npm:@atcute/client@^4.0.5",
220
221
"npm:@atcute/jetstream@^1.1.2",
221
-
"npm:@atcute/lexicons@^1.2.2"
222
+
"npm:@atcute/lexicons@^1.2.2",
223
+
"npm:@atcute/tid@^1.0.3"
222
224
]
223
225
},
224
226
"packages/crypto": {
+3
-1
packages/consumer/deno.jsonc
+3
-1
packages/consumer/deno.jsonc
···
8
8
"@atcute/client": "npm:@atcute/client@^4.0.5",
9
9
"@atcute/jetstream": "npm:@atcute/jetstream@^1.1.2",
10
10
"@atcute/lexicons": "npm:@atcute/lexicons@^1.2.2",
11
-
"@puregarlic/randimal": "jsr:@puregarlic/randimal@^1.0.1"
11
+
"@atcute/tid": "npm:@atcute/tid@^1.0.3",
12
+
"@puregarlic/randimal": "jsr:@puregarlic/randimal@^1.0.1",
13
+
"@std/expect": "jsr:@std/expect@^1.0.17"
12
14
}
13
15
}
+483
packages/consumer/mod.test.ts
+483
packages/consumer/mod.test.ts
···
1
+
import { expect } from "@std/expect";
2
+
import { Consumer } from "./mod.ts";
3
+
import { encryptText, generateKeys } from "@cistern/crypto";
4
+
import type { ConsumerParams } from "./types.ts";
5
+
import type { Client, CredentialManager } from "@atcute/client";
6
+
import type { Did, Handle, ResourceUri } from "@atcute/lexicons";
7
+
import { now } from "@atcute/tid";
8
+
9
+
// Helper to create a mock Consumer instance
10
+
function createMockConsumer(
11
+
overrides?: Partial<ConsumerParams>,
12
+
): Consumer {
13
+
const mockParams: ConsumerParams = {
14
+
miniDoc: {
15
+
did: "did:plc:test123" as Did,
16
+
handle: "test.bsky.social" as Handle,
17
+
pds: "https://test.pds.example",
18
+
signing_key: "test-key",
19
+
},
20
+
manager: {} as CredentialManager,
21
+
rpc: createMockRpcClient(),
22
+
options: {
23
+
handle: "test.bsky.social" as Handle,
24
+
appPassword: "test-password",
25
+
},
26
+
...overrides,
27
+
};
28
+
29
+
return new Consumer(mockParams);
30
+
}
31
+
32
+
// Helper to create a mock RPC client
33
+
function createMockRpcClient(): Client {
34
+
return {
35
+
get: () => {
36
+
throw new Error("Mock RPC get not implemented");
37
+
},
38
+
post: () => {
39
+
throw new Error("Mock RPC post not implemented");
40
+
},
41
+
} as unknown as Client;
42
+
}
43
+
44
+
Deno.test({
45
+
name: "Consumer constructor initializes with provided params",
46
+
fn() {
47
+
const consumer = createMockConsumer();
48
+
49
+
expect(consumer.did).toEqual("did:plc:test123");
50
+
expect(consumer.keypair).toBeUndefined();
51
+
expect(consumer.rpc).toBeDefined();
52
+
expect(consumer.manager).toBeDefined();
53
+
},
54
+
});
55
+
56
+
Deno.test({
57
+
name: "Consumer constructor initializes with existing keypair",
58
+
fn() {
59
+
const mockKeypair = {
60
+
privateKey: new Uint8Array(32).toBase64(),
61
+
publicKey:
62
+
"at://did:plc:test/app.cistern.lexicon.pubkey/abc123" as ResourceUri,
63
+
};
64
+
65
+
const consumer = createMockConsumer({
66
+
options: {
67
+
handle: "test.bsky.social" as Handle,
68
+
appPassword: "test-password",
69
+
keypair: mockKeypair,
70
+
},
71
+
});
72
+
73
+
expect(consumer.keypair).toBeDefined();
74
+
expect(consumer.keypair?.publicKey).toEqual(mockKeypair.publicKey);
75
+
expect(consumer.keypair?.privateKey).toBeInstanceOf(Uint8Array);
76
+
},
77
+
});
78
+
79
+
Deno.test({
80
+
name: "generateKeyPair creates and uploads a new keypair",
81
+
async fn() {
82
+
let capturedRecord: unknown;
83
+
let capturedCollection: string | undefined;
84
+
85
+
const mockRpc = {
86
+
post: (endpoint: string, params: { input: unknown }) => {
87
+
if (endpoint === "com.atproto.repo.createRecord") {
88
+
const input = params.input as {
89
+
collection: string;
90
+
record: unknown;
91
+
};
92
+
capturedCollection = input.collection;
93
+
capturedRecord = input.record;
94
+
95
+
return Promise.resolve({
96
+
ok: true,
97
+
data: {
98
+
uri: "at://did:plc:test/app.cistern.lexicon.pubkey/generated123",
99
+
},
100
+
});
101
+
}
102
+
return Promise.resolve({ ok: false, status: 500, data: {} });
103
+
},
104
+
} as unknown as Client;
105
+
106
+
const consumer = createMockConsumer({ rpc: mockRpc });
107
+
const keypair = await consumer.generateKeyPair();
108
+
109
+
expect(keypair).toBeDefined();
110
+
expect(keypair.privateKey).toBeInstanceOf(Uint8Array);
111
+
expect(keypair.publicKey).toEqual(
112
+
"at://did:plc:test/app.cistern.lexicon.pubkey/generated123",
113
+
);
114
+
expect(consumer.keypair).toEqual(keypair);
115
+
116
+
expect(capturedCollection).toEqual("app.cistern.lexicon.pubkey");
117
+
expect(capturedRecord).toMatchObject({
118
+
$type: "app.cistern.lexicon.pubkey",
119
+
algorithm: "x_wing",
120
+
});
121
+
},
122
+
});
123
+
124
+
Deno.test({
125
+
name: "generateKeyPair throws when consumer already has a keypair",
126
+
async fn() {
127
+
const consumer = createMockConsumer({
128
+
options: {
129
+
handle: "test.bsky.social" as Handle,
130
+
appPassword: "test-password",
131
+
keypair: {
132
+
privateKey: new Uint8Array(32).toBase64(),
133
+
publicKey:
134
+
"at://did:plc:test/app.cistern.lexicon.pubkey/existing" as ResourceUri,
135
+
},
136
+
},
137
+
});
138
+
139
+
await expect(consumer.generateKeyPair()).rejects.toThrow(
140
+
"client already has a key pair",
141
+
);
142
+
},
143
+
});
144
+
145
+
Deno.test({
146
+
name: "generateKeyPair throws when upload fails",
147
+
async fn() {
148
+
const mockRpc = {
149
+
post: () =>
150
+
Promise.resolve({
151
+
ok: false,
152
+
status: 500,
153
+
data: { error: "Internal Server Error" },
154
+
}),
155
+
} as unknown as Client;
156
+
157
+
const consumer = createMockConsumer({ rpc: mockRpc });
158
+
159
+
await expect(consumer.generateKeyPair()).rejects.toThrow(
160
+
"failed to save public key",
161
+
);
162
+
},
163
+
});
164
+
165
+
Deno.test({
166
+
name: "listItems throws when no keypair is set",
167
+
async fn() {
168
+
const consumer = createMockConsumer();
169
+
170
+
const iterator = consumer.listItems();
171
+
await expect(iterator.next()).rejects.toThrow(
172
+
"no key pair set; generate a key before listing items",
173
+
);
174
+
},
175
+
});
176
+
177
+
Deno.test({
178
+
name: "listItems decrypts and yields items",
179
+
async fn() {
180
+
const keys = generateKeys();
181
+
const testText = "Test item content";
182
+
const encrypted = encryptText(keys.publicKey, testText);
183
+
const testTid = now();
184
+
185
+
const mockRpc = {
186
+
get: (endpoint: string) => {
187
+
if (endpoint === "com.atproto.repo.listRecords") {
188
+
return Promise.resolve({
189
+
ok: true,
190
+
data: {
191
+
records: [
192
+
{
193
+
uri: "at://did:plc:test/app.cistern.lexicon.item/item1",
194
+
value: {
195
+
$type: "app.cistern.lexicon.item",
196
+
tid: testTid,
197
+
ciphertext: encrypted.cipherText,
198
+
nonce: encrypted.nonce,
199
+
algorithm: "x_wing-xchacha20_poly1305-sha3_512",
200
+
pubkey: "at://did:plc:test/app.cistern.lexicon.pubkey/key1",
201
+
payload: encrypted.content,
202
+
contentLength: encrypted.length,
203
+
contentHash: encrypted.hash,
204
+
},
205
+
},
206
+
],
207
+
cursor: undefined,
208
+
},
209
+
});
210
+
}
211
+
return Promise.resolve({ ok: false, status: 500, data: {} });
212
+
},
213
+
} as unknown as Client;
214
+
215
+
const consumer = createMockConsumer({
216
+
rpc: mockRpc,
217
+
options: {
218
+
handle: "test.bsky.social" as Handle,
219
+
appPassword: "test-password",
220
+
keypair: {
221
+
privateKey: keys.secretKey.toBase64(),
222
+
publicKey:
223
+
"at://did:plc:test/app.cistern.lexicon.pubkey/key1" as ResourceUri,
224
+
},
225
+
},
226
+
});
227
+
228
+
const items = [];
229
+
for await (const item of consumer.listItems()) {
230
+
items.push(item);
231
+
}
232
+
233
+
expect(items).toHaveLength(1);
234
+
expect(items[0].text).toEqual(testText);
235
+
expect(items[0].tid).toEqual(testTid);
236
+
},
237
+
});
238
+
239
+
Deno.test({
240
+
name: "listItems skips items with mismatched public key",
241
+
async fn() {
242
+
const keys = generateKeys();
243
+
const testText = "Test item content";
244
+
const encrypted = encryptText(keys.publicKey, testText);
245
+
const testTid = now();
246
+
247
+
const mockRpc = {
248
+
get: (endpoint: string) => {
249
+
if (endpoint === "com.atproto.repo.listRecords") {
250
+
return Promise.resolve({
251
+
ok: true,
252
+
data: {
253
+
records: [
254
+
{
255
+
uri: "at://did:plc:test/app.cistern.lexicon.item/item1",
256
+
value: {
257
+
$type: "app.cistern.lexicon.item",
258
+
tid: testTid,
259
+
ciphertext: encrypted.cipherText,
260
+
nonce: encrypted.nonce,
261
+
algorithm: "x_wing-xchacha20_poly1305-sha3_512",
262
+
pubkey:
263
+
"at://did:plc:test/app.cistern.lexicon.pubkey/different-key",
264
+
payload: encrypted.content,
265
+
contentLength: encrypted.length,
266
+
contentHash: encrypted.hash,
267
+
},
268
+
},
269
+
],
270
+
cursor: undefined,
271
+
},
272
+
});
273
+
}
274
+
return Promise.resolve({ ok: false, status: 500, data: {} });
275
+
},
276
+
} as unknown as Client;
277
+
278
+
const consumer = createMockConsumer({
279
+
rpc: mockRpc,
280
+
options: {
281
+
handle: "test.bsky.social" as Handle,
282
+
appPassword: "test-password",
283
+
keypair: {
284
+
privateKey: keys.secretKey.toBase64(),
285
+
publicKey:
286
+
"at://did:plc:test/app.cistern.lexicon.pubkey/my-key" as ResourceUri,
287
+
},
288
+
},
289
+
});
290
+
291
+
const items = [];
292
+
for await (const item of consumer.listItems()) {
293
+
items.push(item);
294
+
}
295
+
296
+
expect(items).toHaveLength(0);
297
+
},
298
+
});
299
+
300
+
Deno.test({
301
+
name: "listItems handles pagination",
302
+
async fn() {
303
+
const keys = generateKeys();
304
+
const text1 = "First item";
305
+
const text2 = "Second item";
306
+
const encrypted1 = encryptText(keys.publicKey, text1);
307
+
const encrypted2 = encryptText(keys.publicKey, text2);
308
+
const tid1 = now();
309
+
const tid2 = now();
310
+
311
+
let callCount = 0;
312
+
const mockRpc = {
313
+
get: (endpoint: string, _params?: { params?: { cursor?: string } }) => {
314
+
if (endpoint === "com.atproto.repo.listRecords") {
315
+
callCount++;
316
+
317
+
if (callCount === 1) {
318
+
return Promise.resolve({
319
+
ok: true,
320
+
data: {
321
+
records: [
322
+
{
323
+
uri: "at://did:plc:test/app.cistern.lexicon.item/item1",
324
+
value: {
325
+
$type: "app.cistern.lexicon.item",
326
+
tid: tid1,
327
+
ciphertext: encrypted1.cipherText,
328
+
nonce: encrypted1.nonce,
329
+
algorithm: "x_wing-xchacha20_poly1305-sha3_512",
330
+
pubkey:
331
+
"at://did:plc:test/app.cistern.lexicon.pubkey/key1",
332
+
payload: encrypted1.content,
333
+
contentLength: encrypted1.length,
334
+
contentHash: encrypted1.hash,
335
+
},
336
+
},
337
+
],
338
+
cursor: "next-page",
339
+
},
340
+
});
341
+
} else {
342
+
return Promise.resolve({
343
+
ok: true,
344
+
data: {
345
+
records: [
346
+
{
347
+
uri: "at://did:plc:test/app.cistern.lexicon.item/item2",
348
+
value: {
349
+
$type: "app.cistern.lexicon.item",
350
+
tid: tid2,
351
+
ciphertext: encrypted2.cipherText,
352
+
nonce: encrypted2.nonce,
353
+
algorithm: "x_wing-xchacha20_poly1305-sha3_512",
354
+
pubkey:
355
+
"at://did:plc:test/app.cistern.lexicon.pubkey/key1",
356
+
payload: encrypted2.content,
357
+
contentLength: encrypted2.length,
358
+
contentHash: encrypted2.hash,
359
+
},
360
+
},
361
+
],
362
+
cursor: undefined,
363
+
},
364
+
});
365
+
}
366
+
}
367
+
return Promise.resolve({ ok: false, status: 500, data: {} });
368
+
},
369
+
} as unknown as Client;
370
+
371
+
const consumer = createMockConsumer({
372
+
rpc: mockRpc,
373
+
options: {
374
+
handle: "test.bsky.social" as Handle,
375
+
appPassword: "test-password",
376
+
keypair: {
377
+
privateKey: keys.secretKey.toBase64(),
378
+
publicKey:
379
+
"at://did:plc:test/app.cistern.lexicon.pubkey/key1" as ResourceUri,
380
+
},
381
+
},
382
+
});
383
+
384
+
const items = [];
385
+
for await (const item of consumer.listItems()) {
386
+
items.push(item);
387
+
}
388
+
389
+
expect(items).toHaveLength(2);
390
+
expect(items[0].text).toEqual(text1);
391
+
expect(items[1].text).toEqual(text2);
392
+
expect(callCount).toEqual(2);
393
+
},
394
+
});
395
+
396
+
Deno.test({
397
+
name: "listItems throws when list request fails",
398
+
async fn() {
399
+
const mockRpc = {
400
+
get: () =>
401
+
Promise.resolve({
402
+
ok: false,
403
+
status: 401,
404
+
data: { error: "Unauthorized" },
405
+
}),
406
+
} as unknown as Client;
407
+
408
+
const consumer = createMockConsumer({
409
+
rpc: mockRpc,
410
+
options: {
411
+
handle: "test.bsky.social" as Handle,
412
+
appPassword: "test-password",
413
+
keypair: {
414
+
privateKey: new Uint8Array(32).toBase64(),
415
+
publicKey: "at://did:plc:test/app.cistern.lexicon.pubkey/key1",
416
+
},
417
+
},
418
+
});
419
+
420
+
const iterator = consumer.listItems();
421
+
await expect(iterator.next()).rejects.toThrow("failed to list items");
422
+
},
423
+
});
424
+
425
+
Deno.test({
426
+
name: "subscribeToItems throws when no keypair is set",
427
+
async fn() {
428
+
const consumer = createMockConsumer();
429
+
430
+
const iterator = consumer.subscribeToItems();
431
+
await expect(iterator.next()).rejects.toThrow(
432
+
"no key pair set; generate a key before subscribing",
433
+
);
434
+
},
435
+
});
436
+
437
+
Deno.test({
438
+
name: "deleteItem successfully deletes an item",
439
+
async fn() {
440
+
let deletedRkey: string | undefined;
441
+
442
+
const mockRpc = {
443
+
post: (endpoint: string, params: { input: unknown }) => {
444
+
if (endpoint === "com.atproto.repo.deleteRecord") {
445
+
const input = params.input as { rkey: string };
446
+
deletedRkey = input.rkey;
447
+
448
+
return Promise.resolve({
449
+
ok: true,
450
+
data: {},
451
+
});
452
+
}
453
+
return Promise.resolve({ ok: false, status: 500, data: {} });
454
+
},
455
+
} as unknown as Client;
456
+
457
+
const consumer = createMockConsumer({ rpc: mockRpc });
458
+
459
+
await consumer.deleteItem("item123");
460
+
461
+
expect(deletedRkey).toEqual("item123");
462
+
},
463
+
});
464
+
465
+
Deno.test({
466
+
name: "deleteItem throws when delete request fails",
467
+
async fn() {
468
+
const mockRpc = {
469
+
post: () =>
470
+
Promise.resolve({
471
+
ok: false,
472
+
status: 404,
473
+
data: { error: "Not Found" },
474
+
}),
475
+
} as unknown as Client;
476
+
477
+
const consumer = createMockConsumer({ rpc: mockRpc });
478
+
479
+
await expect(consumer.deleteItem("item123")).rejects.toThrow(
480
+
"failed to delete item item123",
481
+
);
482
+
},
483
+
});