import { expect } from "@std/expect"; import { Consumer } from "./mod.ts"; import { encryptText, generateKeys } from "@cistern/crypto"; import type { ConsumerParams } from "./types.ts"; import type { Client, CredentialManager } from "@atcute/client"; import type { Did, Handle, ResourceUri } from "@atcute/lexicons"; import { now } from "@atcute/tid"; import type { AppCisternMemo } from "@cistern/lexicon"; import type { XRPCProcedures, XRPCQueries } from "@cistern/shared"; // Helper to create a mock Consumer instance function createMockConsumer( overrides?: Partial, ): Consumer { const mockParams: ConsumerParams = { miniDoc: { did: "did:plc:test123" as Did, handle: "test.bsky.social" as Handle, pds: "https://test.pds.example", signing_key: "test-key", }, manager: {} as CredentialManager, rpc: createMockRpcClient(), options: { handle: "test.bsky.social" as Handle, appPassword: "test-password", }, ...overrides, }; return new Consumer(mockParams); } // Helper to create a mock RPC client function createMockRpcClient(): Client { return { get: () => { throw new Error("Mock RPC get not implemented"); }, post: () => { throw new Error("Mock RPC post not implemented"); }, } as unknown as Client; } Deno.test({ name: "Consumer constructor initializes with provided params", fn() { const consumer = createMockConsumer(); expect(consumer.did).toEqual("did:plc:test123"); expect(consumer.keypair).toBeUndefined(); expect(consumer.rpc).toBeDefined(); }, }); Deno.test({ name: "Consumer constructor initializes with existing keypair", fn() { const mockKeypair = { privateKey: new Uint8Array(32).toBase64(), publicKey: "at://did:plc:test/app.cistern.pubkey/abc123" as ResourceUri, }; const consumer = createMockConsumer({ options: { handle: "test.bsky.social" as Handle, appPassword: "test-password", keypair: mockKeypair, }, }); expect(consumer.keypair).toBeDefined(); expect(consumer.keypair?.publicKey).toEqual(mockKeypair.publicKey); expect(consumer.keypair?.privateKey).toBeInstanceOf(Uint8Array); }, }); Deno.test({ name: "generateKeyPair creates and uploads a new keypair", async fn() { let capturedRecord: unknown; let capturedCollection: string | undefined; const mockRpc = { post: (endpoint: string, params: { input: unknown }) => { if (endpoint === "com.atproto.repo.createRecord") { const input = params.input as { collection: string; record: unknown; }; capturedCollection = input.collection; capturedRecord = input.record; return Promise.resolve({ ok: true, data: { uri: "at://did:plc:test/app.cistern.pubkey/generated123", }, }); } return Promise.resolve({ ok: false, status: 500, data: {} }); }, } as unknown as Client; const consumer = createMockConsumer({ rpc: mockRpc }); const keypair = await consumer.generateKeyPair(); expect(keypair).toBeDefined(); expect(keypair.privateKey).toBeInstanceOf(Uint8Array); expect(keypair.publicKey).toEqual( "at://did:plc:test/app.cistern.pubkey/generated123", ); expect(consumer.keypair).toEqual(keypair); expect(capturedCollection).toEqual("app.cistern.pubkey"); expect(capturedRecord).toMatchObject({ $type: "app.cistern.pubkey", algorithm: "x_wing", }); }, }); Deno.test({ name: "generateKeyPair throws when consumer already has a keypair", async fn() { const consumer = createMockConsumer({ options: { handle: "test.bsky.social" as Handle, appPassword: "test-password", keypair: { privateKey: new Uint8Array(32).toBase64(), publicKey: "at://did:plc:test/app.cistern.pubkey/existing" as ResourceUri, }, }, }); await expect(consumer.generateKeyPair()).rejects.toThrow( "client already has a key pair", ); }, }); Deno.test({ name: "generateKeyPair throws when upload fails", async fn() { const mockRpc = { post: () => Promise.resolve({ ok: false, status: 500, data: { error: "Internal Server Error" }, }), } as unknown as Client; const consumer = createMockConsumer({ rpc: mockRpc }); await expect(consumer.generateKeyPair()).rejects.toThrow( "failed to save public key", ); }, }); Deno.test({ name: "listMemos throws when no keypair is set", async fn() { const consumer = createMockConsumer(); const iterator = consumer.listMemos(); await expect(iterator.next()).rejects.toThrow( "no key pair set; generate a key before listing memos", ); }, }); Deno.test({ name: "listMemos decrypts and yields memos", async fn() { const keys = generateKeys(); const testText = "Test memo content"; const encrypted = encryptText(keys.publicKey, testText); const testTid = now(); const mockRpc = { get: (endpoint: string) => { if (endpoint === "com.atproto.repo.listRecords") { return Promise.resolve({ ok: true, data: { records: [ { uri: "at://did:plc:test/app.cistern.memo/memo1", value: { $type: "app.cistern.memo", tid: testTid, ciphertext: { $bytes: encrypted.cipherText }, nonce: { $bytes: encrypted.nonce }, algorithm: "x_wing-xchacha20_poly1305-sha3_512", pubkey: "at://did:plc:test/app.cistern.pubkey/key1", payload: { $bytes: encrypted.content }, contentLength: encrypted.length, contentHash: { $bytes: encrypted.hash }, } as AppCisternMemo.Main, }, ], cursor: undefined, }, }); } return Promise.resolve({ ok: false, status: 500, data: {} }); }, } as unknown as Client; const consumer = createMockConsumer({ rpc: mockRpc, options: { handle: "test.bsky.social" as Handle, appPassword: "test-password", keypair: { privateKey: keys.secretKey.toBase64(), publicKey: "at://did:plc:test/app.cistern.pubkey/key1" as ResourceUri, }, }, }); const memos = []; for await (const memo of consumer.listMemos()) { memos.push(memo); } expect(memos).toHaveLength(1); expect(memos[0].text).toEqual(testText); expect(memos[0].tid).toEqual(testTid); }, }); Deno.test({ name: "listmemos skips memos with mismatched public key", async fn() { const keys = generateKeys(); const testText = "Test memo content"; const encrypted = encryptText(keys.publicKey, testText); const testTid = now(); const mockRpc = { get: (endpoint: string) => { if (endpoint === "com.atproto.repo.listRecords") { return Promise.resolve({ ok: true, data: { records: [ { uri: "at://did:plc:test/app.cistern.memo/memo1", value: { $type: "app.cistern.memo", tid: testTid, ciphertext: { $bytes: encrypted.cipherText }, nonce: { $bytes: encrypted.nonce }, algorithm: "x_wing-xchacha20_poly1305-sha3_512", pubkey: "at://did:plc:test/app.cistern.pubkey/different-key", payload: { $bytes: encrypted.content }, contentLength: encrypted.length, contentHash: { $bytes: encrypted.hash }, } as AppCisternMemo.Main, }, ], cursor: undefined, }, }); } return Promise.resolve({ ok: false, status: 500, data: {} }); }, } as unknown as Client; const consumer = createMockConsumer({ rpc: mockRpc, options: { handle: "test.bsky.social" as Handle, appPassword: "test-password", keypair: { privateKey: keys.secretKey.toBase64(), publicKey: "at://did:plc:test/app.cistern.pubkey/my-key" as ResourceUri, }, }, }); const memos = []; for await (const memo of consumer.listMemos()) { memos.push(memo); } expect(memos).toHaveLength(0); }, }); Deno.test({ name: "listMemos handles pagination", async fn() { const keys = generateKeys(); const text1 = "First memo"; const text2 = "Second memo"; const encrypted1 = encryptText(keys.publicKey, text1); const encrypted2 = encryptText(keys.publicKey, text2); const tid1 = now(); const tid2 = now(); let callCount = 0; const mockRpc = { get: (endpoint: string, _params?: { params?: { cursor?: string } }) => { if (endpoint === "com.atproto.repo.listRecords") { callCount++; if (callCount === 1) { return Promise.resolve({ ok: true, data: { records: [ { uri: "at://did:plc:test/app.cistern.memo/memo1", value: { $type: "app.cistern.memo", tid: tid1, ciphertext: { $bytes: encrypted1.cipherText }, nonce: { $bytes: encrypted1.nonce }, algorithm: "x_wing-xchacha20_poly1305-sha3_512", pubkey: "at://did:plc:test/app.cistern.pubkey/key1", payload: { $bytes: encrypted1.content }, contentLength: encrypted1.length, contentHash: { $bytes: encrypted1.hash }, } as AppCisternMemo.Main, }, ], cursor: "next-page", }, }); } else { return Promise.resolve({ ok: true, data: { records: [ { uri: "at://did:plc:test/app.cistern.memo/memo2", value: { $type: "app.cistern.memo", tid: tid2, ciphertext: { $bytes: encrypted2.cipherText }, nonce: { $bytes: encrypted2.nonce }, algorithm: "x_wing-xchacha20_poly1305-sha3_512", pubkey: "at://did:plc:test/app.cistern.pubkey/key1", payload: { $bytes: encrypted2.content }, contentLength: encrypted2.length, contentHash: { $bytes: encrypted2.hash }, } as AppCisternMemo.Main, }, ], cursor: undefined, }, }); } } return Promise.resolve({ ok: false, status: 500, data: {} }); }, } as unknown as Client; const consumer = createMockConsumer({ rpc: mockRpc, options: { handle: "test.bsky.social" as Handle, appPassword: "test-password", keypair: { privateKey: keys.secretKey.toBase64(), publicKey: "at://did:plc:test/app.cistern.pubkey/key1" as ResourceUri, }, }, }); const memos = []; for await (const memo of consumer.listMemos()) { memos.push(memo); } expect(memos).toHaveLength(2); expect(memos[0].text).toEqual(text1); expect(memos[1].text).toEqual(text2); expect(callCount).toEqual(2); }, }); Deno.test({ name: "listMemos throws when list request fails", async fn() { const mockRpc = { get: () => Promise.resolve({ ok: false, status: 401, data: { error: "Unauthorized" }, }), } as unknown as Client; const consumer = createMockConsumer({ rpc: mockRpc, options: { handle: "test.bsky.social" as Handle, appPassword: "test-password", keypair: { privateKey: new Uint8Array(32).toBase64(), publicKey: "at://did:plc:test/app.cistern.pubkey/key1", }, }, }); const iterator = consumer.listMemos(); await expect(iterator.next()).rejects.toThrow("failed to list memos"); }, }); Deno.test({ name: "subscribeToMemos throws when no keypair is set", async fn() { const consumer = createMockConsumer(); const iterator = consumer.subscribeToMemos(); await expect(iterator.next()).rejects.toThrow( "no key pair set; generate a key before subscribing", ); }, }); Deno.test({ name: "deleteMemo successfully deletes a memo", async fn() { let deletedRkey: string | undefined; const mockRpc = { post: (endpoint: string, params: { input: unknown }) => { if (endpoint === "com.atproto.repo.deleteRecord") { const input = params.input as { rkey: string }; deletedRkey = input.rkey; return Promise.resolve({ ok: true, data: {}, }); } return Promise.resolve({ ok: false, status: 500, data: {} }); }, } as unknown as Client; const consumer = createMockConsumer({ rpc: mockRpc }); await consumer.deleteMemo("memo123"); expect(deletedRkey).toEqual("memo123"); }, }); Deno.test({ name: "deleteMemo throws when delete request fails", async fn() { const mockRpc = { post: () => Promise.resolve({ ok: false, status: 404, data: { error: "Not Found" }, }), } as unknown as Client; const consumer = createMockConsumer({ rpc: mockRpc }); await expect(consumer.deleteMemo("memo123")).rejects.toThrow( "failed to delete memo memo123", ); }, });