diff --git a/deno.lock b/deno.lock index 7356890..82079ff 100644 --- a/deno.lock +++ b/deno.lock @@ -240,6 +240,7 @@ }, "packages/producer": { "dependencies": [ + "jsr:@std/expect@^1.0.17", "npm:@atcute/atproto@^3.1.9", "npm:@atcute/client@^4.0.5", "npm:@atcute/lexicons@^1.2.2", diff --git a/packages/producer/deno.jsonc b/packages/producer/deno.jsonc index 9ce47da..634b2a1 100644 --- a/packages/producer/deno.jsonc +++ b/packages/producer/deno.jsonc @@ -7,6 +7,7 @@ "@atcute/atproto": "npm:@atcute/atproto@^3.1.9", "@atcute/client": "npm:@atcute/client@^4.0.5", "@atcute/lexicons": "npm:@atcute/lexicons@^1.2.2", - "@atcute/tid": "npm:@atcute/tid@^1.0.3" + "@atcute/tid": "npm:@atcute/tid@^1.0.3", + "@std/expect": "jsr:@std/expect@^1.0.17" } } diff --git a/packages/producer/mod.test.ts b/packages/producer/mod.test.ts new file mode 100644 index 0000000..f88127a --- /dev/null +++ b/packages/producer/mod.test.ts @@ -0,0 +1,344 @@ +import { expect } from "@std/expect"; +import { Producer } from "./mod.ts"; +import { generateKeys } from "@cistern/crypto"; +import type { ProducerParams, PublicKeyOption } from "./types.ts"; +import type { Client, CredentialManager } from "@atcute/client"; +import type { Did, Handle, ResourceUri } from "@atcute/lexicons"; + +// Helper to create a mock Producer instance +function createMockProducer( + overrides?: Partial, +): Producer { + const mockParams: ProducerParams = { + 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 Producer(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: "Producer constructor initializes with provided params", + fn() { + const producer = createMockProducer(); + + expect(producer.did).toEqual("did:plc:test123"); + expect(producer.publicKey).toBeUndefined(); + expect(producer.rpc).toBeDefined(); + expect(producer.manager).toBeDefined(); + }, +}); + +Deno.test({ + name: "Producer constructor initializes with existing public key", + fn() { + const mockPublicKey: PublicKeyOption = { + uri: "at://did:plc:test/app.cistern.lexicon.pubkey/key1" as ResourceUri, + name: "Test Key", + content: new Uint8Array(32).toBase64(), + }; + + const producer = createMockProducer({ + publicKey: mockPublicKey, + }); + + expect(producer.publicKey).toBeDefined(); + expect(producer.publicKey?.uri).toEqual(mockPublicKey.uri); + expect(producer.publicKey?.name).toEqual("Test Key"); + }, +}); + +Deno.test({ + name: "createItem successfully creates and uploads an encrypted item", + async fn() { + const keys = generateKeys(); + 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.lexicon.item/item123" as ResourceUri, + }, + }); + } + return Promise.resolve({ ok: false, status: 500, data: {} }); + }, + } as unknown as Client; + + const producer = createMockProducer({ + rpc: mockRpc, + publicKey: { + uri: "at://did:plc:test/app.cistern.lexicon.pubkey/key1" as ResourceUri, + name: "Test Key", + content: keys.publicKey.toBase64(), + }, + }); + + const uri = await producer.createItem("Test message"); + + expect(uri).toEqual("at://did:plc:test/app.cistern.lexicon.item/item123"); + expect(capturedCollection).toEqual("app.cistern.lexicon.item"); + expect(capturedRecord).toMatchObject({ + $type: "app.cistern.lexicon.item", + algorithm: "x_wing-xchacha20_poly1305-sha3_512", + }); + }, +}); + +Deno.test({ + name: "createItem throws when no public key is set", + async fn() { + const producer = createMockProducer(); + + await expect(producer.createItem("Test message")).rejects.toThrow( + "no public key set; select a public key before creating an item", + ); + }, +}); + +Deno.test({ + name: "createItem throws when upload fails", + async fn() { + const keys = generateKeys(); + const mockRpc = { + post: () => + Promise.resolve({ + ok: false, + status: 500, + data: { error: "Internal Server Error" }, + }), + } as unknown as Client; + + const producer = createMockProducer({ + rpc: mockRpc, + publicKey: { + uri: "at://did:plc:test/app.cistern.lexicon.pubkey/key1" as ResourceUri, + name: "Test Key", + content: keys.publicKey.toBase64(), + }, + }); + + await expect(producer.createItem("Test message")).rejects.toThrow( + "failed to create new item", + ); + }, +}); + +Deno.test({ + name: "listPublicKeys yields public keys from PDS", + async fn() { + 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.lexicon.pubkey/key1", + value: { + $type: "app.cistern.lexicon.pubkey", + name: "Key 1", + algorithm: "x_wing", + content: new Uint8Array(32).toBase64(), + createdAt: new Date().toISOString(), + }, + }, + { + uri: "at://did:plc:test/app.cistern.lexicon.pubkey/key2", + value: { + $type: "app.cistern.lexicon.pubkey", + name: "Key 2", + algorithm: "x_wing", + content: new Uint8Array(32).toBase64(), + createdAt: new Date().toISOString(), + }, + }, + ], + cursor: undefined, + }, + }); + } + return Promise.resolve({ ok: false, status: 500, data: {} }); + }, + } as unknown as Client; + + const producer = createMockProducer({ rpc: mockRpc }); + + const keys = []; + for await (const key of producer.listPublicKeys()) { + keys.push(key); + } + + expect(keys).toHaveLength(2); + expect(keys[0].name).toEqual("Key 1"); + expect(keys[1].name).toEqual("Key 2"); + }, +}); + +Deno.test({ + name: "listPublicKeys handles pagination", + async fn() { + 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.lexicon.pubkey/key1", + value: { + $type: "app.cistern.lexicon.pubkey", + name: "Key 1", + algorithm: "x_wing", + content: new Uint8Array(32).toBase64(), + createdAt: new Date().toISOString(), + }, + }, + ], + cursor: "next-page", + }, + }); + } else { + return Promise.resolve({ + ok: true, + data: { + records: [ + { + uri: "at://did:plc:test/app.cistern.lexicon.pubkey/key2", + value: { + $type: "app.cistern.lexicon.pubkey", + name: "Key 2", + algorithm: "x_wing", + content: new Uint8Array(32).toBase64(), + createdAt: new Date().toISOString(), + }, + }, + ], + cursor: undefined, + }, + }); + } + } + return Promise.resolve({ ok: false, status: 500, data: {} }); + }, + } as unknown as Client; + + const producer = createMockProducer({ rpc: mockRpc }); + + const keys = []; + for await (const key of producer.listPublicKeys()) { + keys.push(key); + } + + expect(keys).toHaveLength(2); + expect(keys[0].name).toEqual("Key 1"); + expect(keys[1].name).toEqual("Key 2"); + expect(callCount).toEqual(2); + }, +}); + +Deno.test({ + name: "listPublicKeys throws when request fails", + async fn() { + const mockRpc = { + get: () => + Promise.resolve({ + ok: false, + status: 401, + data: { error: "Unauthorized" }, + }), + } as unknown as Client; + + const producer = createMockProducer({ rpc: mockRpc }); + + const iterator = producer.listPublicKeys(); + await expect(iterator.next()).rejects.toThrow("failed to list public keys"); + }, +}); + +Deno.test({ + name: "selectPublicKey sets the active public key", + fn() { + const producer = createMockProducer(); + + const mockPublicKey: PublicKeyOption = { + uri: "at://did:plc:test/app.cistern.lexicon.pubkey/key1" as ResourceUri, + name: "Selected Key", + content: new Uint8Array(32).toBase64(), + }; + + expect(producer.publicKey).toBeUndefined(); + + producer.selectPublicKey(mockPublicKey); + + expect(producer.publicKey).toBeDefined(); + expect(producer.publicKey?.uri).toEqual(mockPublicKey.uri); + expect(producer.publicKey?.name).toEqual("Selected Key"); + }, +}); + +Deno.test({ + name: "selectPublicKey can change the active key", + fn() { + const producer = createMockProducer({ + publicKey: { + uri: "at://did:plc:test/app.cistern.lexicon.pubkey/old" as ResourceUri, + name: "Old Key", + content: new Uint8Array(32).toBase64(), + }, + }); + + expect(producer.publicKey?.name).toEqual("Old Key"); + + const newKey: PublicKeyOption = { + uri: "at://did:plc:test/app.cistern.lexicon.pubkey/new" as ResourceUri, + name: "New Key", + content: new Uint8Array(32).toBase64(), + }; + + producer.selectPublicKey(newKey); + + expect(producer.publicKey?.name).toEqual("New Key"); + expect(producer.publicKey?.uri).toEqual(newKey.uri); + }, +});