[READ-ONLY] Mirror of https://github.com/andrioid/n8n-nodes-atproto. atproto node for n8n
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(atproto): generic blob operations (upload, download, list)

Adds three new operations to the AT Protocol node that wrap the raw PDS
blob endpoints, so users can manage blobs without going through the
lexicon-driven record flow:

- Upload Blob -> com.atproto.repo.uploadBlob
- Download Blob -> com.atproto.sync.getBlob
- List Blobs -> com.atproto.sync.listBlobs

Use case: composing records whose blobs aren't top-level lexicon fields,
e.g. multi-image Bluesky posts where the blob lives in embed.images[].image.
Workflow becomes: HTTP Request -> Upload Blob (xN) -> Create Record.

UI changes:
- Adds a Resource selector ('Record' / 'Blob') to group the now 8
operations. Silences the @n8n/community-nodes resource-operation-pattern
lint warning that kicked in at >5 operations.
- Operation *values* are unchanged (createRecord, getBlob, etc.) so
existing workflows keep working without migration.
- Download Blob resolves handles to DIDs via resolveHandle so users can
paste either form into the Repo field.

Tests: 13 new tests in tests/blobOps.test.ts, plus MSW handlers for
sync.getBlob, sync.listBlobs, and identity.resolveHandle in tests/setup.ts.
All 197 tests pass; lint clean; build clean.

+750 -18
+289 -18
src/nodes/Atproto/Atproto.node.ts
··· 14 14 createRecord, 15 15 deleteRecord, 16 16 getRecord, 17 + getBlob, 18 + listBlobs, 17 19 listRecords, 18 20 putRecord, 21 + resolveActorToDid, 22 + uploadBlob, 19 23 applyConstValues, 20 24 } from './operations'; 21 25 import { createAgent, extractCollectionNsid, searchCollections } from './shared'; ··· 111 115 icon: 'file:atproto.svg', 112 116 group: ['transform'], 113 117 version: 1, 114 - subtitle: '={{ $parameter["operation"] }}', 115 - description: 'CRUD records in any AT Protocol collection', 118 + subtitle: 119 + '={{ $parameter["operation"] + ": " + $parameter["resource"] }}', 120 + description: 'CRUD records and manage blobs in any AT Protocol repo', 116 121 defaults: { 117 122 name: 'AT Protocol', 118 123 }, ··· 127 132 ], 128 133 properties: [ 129 134 // ------------------------------------------------------------------ 130 - // Operation 135 + // Resource 136 + // ------------------------------------------------------------------ 137 + { 138 + displayName: 'Resource', 139 + name: 'resource', 140 + type: 'options', 141 + noDataExpression: true, 142 + // Sorted alphabetically per @n8n/community-nodes lint rule. 143 + options: [ 144 + { name: 'Blob', value: 'blob' }, 145 + { name: 'Record', value: 'record' }, 146 + ], 147 + default: 'record', 148 + }, 149 + 150 + // ------------------------------------------------------------------ 151 + // Operation — Record 131 152 // ------------------------------------------------------------------ 132 153 { 133 154 displayName: 'Operation', 134 155 name: 'operation', 135 156 type: 'options', 136 157 noDataExpression: true, 158 + displayOptions: { 159 + show: { resource: ['record'] }, 160 + }, 161 + // Sorted alphabetically per @n8n/community-nodes lint rule. 137 162 options: [ 138 163 { 139 - name: 'Create Record', 164 + name: 'Create', 140 165 value: 'createRecord', 141 166 description: 'Create a new record in a collection', 142 167 action: 'Create a record', 143 168 }, 144 169 { 145 - name: 'Delete Record', 170 + name: 'Delete', 146 171 value: 'deleteRecord', 147 172 description: 'Delete a record by collection and record key', 148 173 action: 'Delete a record', 149 174 }, 150 175 { 151 - name: 'Get Record', 176 + name: 'Get', 152 177 value: 'getRecord', 153 178 description: 'Get a record by collection and record key', 154 179 action: 'Get a record', 155 180 }, 156 181 { 157 - name: 'List Records', 182 + name: 'List', 158 183 value: 'listRecords', 159 184 description: 'List records in a collection with pagination', 160 185 action: 'List records', 161 186 }, 162 187 { 163 - name: 'Put Record', 188 + name: 'Put', 164 189 value: 'putRecord', 165 190 description: 'Full-replace a record', 166 191 action: 'Update a record', 167 192 }, 168 193 ], 169 194 default: 'createRecord', 195 + }, 196 + 197 + // ------------------------------------------------------------------ 198 + // Operation — Blob 199 + // ------------------------------------------------------------------ 200 + { 201 + displayName: 'Operation', 202 + name: 'operation', 203 + type: 'options', 204 + noDataExpression: true, 205 + displayOptions: { 206 + show: { resource: ['blob'] }, 207 + }, 208 + // Sorted alphabetically per @n8n/community-nodes lint rule. 209 + options: [ 210 + { 211 + name: 'Download', 212 + value: 'getBlob', 213 + description: 'Download a blob by CID from a repo', 214 + action: 'Download a blob', 215 + }, 216 + { 217 + name: 'List', 218 + value: 'listBlobs', 219 + description: 'List blob CIDs in a repo with pagination', 220 + action: 'List blobs', 221 + }, 222 + { 223 + name: 'Upload', 224 + value: 'uploadBlob', 225 + description: 'Upload a binary as a blob to the PDS', 226 + action: 'Upload a blob', 227 + }, 228 + ], 229 + default: 'uploadBlob', 170 230 }, 171 231 172 232 // ------------------------------------------------------------------ ··· 220 280 }, 221 281 222 282 // ------------------------------------------------------------------ 223 - // Repo (for Get/List — optional, defaults to self) 283 + // Repo (for Get/List Record + List Blobs — optional, defaults to self) 224 284 // ------------------------------------------------------------------ 225 285 { 226 286 displayName: 'Repo (DID or handle)', ··· 229 289 required: false, 230 290 placeholder: 'did:plc:... or user.bsky.social', 231 291 description: 232 - 'Optional. The DID or handle of the repo. Defaults to the authenticated user. Useful for reading other users\' public records.', 292 + 'Optional. The DID or handle of the repo. Defaults to the authenticated user. Useful for reading other users\' public records or blobs.', 293 + displayOptions: { 294 + show: { 295 + operation: ['getRecord', 'listRecords', 'listBlobs'], 296 + }, 297 + }, 298 + default: '', 299 + }, 300 + 301 + // ------------------------------------------------------------------ 302 + // Repo (for Download Blob — required, can be DID or handle) 303 + // ------------------------------------------------------------------ 304 + { 305 + displayName: 'Repo (DID or handle)', 306 + name: 'repo', 307 + type: 'string', 308 + required: true, 309 + placeholder: 'did:plc:... or user.bsky.social', 310 + description: 'The DID or handle of the repo that owns the blob', 311 + displayOptions: { 312 + show: { 313 + operation: ['getBlob'], 314 + }, 315 + }, 316 + default: '', 317 + }, 318 + 319 + // ------------------------------------------------------------------ 320 + // CID (Download Blob only) 321 + // ------------------------------------------------------------------ 322 + { 323 + displayName: 'CID', 324 + name: 'cid', 325 + type: 'string', 326 + required: true, 327 + placeholder: 'bafkreig...', 328 + description: 'The Content Identifier (CID) of the blob to download', 233 329 displayOptions: { 234 330 show: { 235 - operation: ['getRecord', 'listRecords'], 331 + operation: ['getBlob'], 236 332 }, 237 333 }, 238 334 default: '', 239 335 }, 240 336 241 337 // ------------------------------------------------------------------ 338 + // Binary Property (Upload Blob — input, Download Blob — output) 339 + // ------------------------------------------------------------------ 340 + { 341 + displayName: 'Input Binary Property', 342 + name: 'binaryPropertyName', 343 + type: 'string', 344 + required: true, 345 + default: 'data', 346 + placeholder: 'data', 347 + description: 348 + 'Name of the binary property on the incoming item containing the data to upload', 349 + displayOptions: { 350 + show: { 351 + operation: ['uploadBlob'], 352 + }, 353 + }, 354 + }, 355 + { 356 + displayName: 'Output Binary Property', 357 + name: 'binaryPropertyName', 358 + type: 'string', 359 + required: true, 360 + default: 'data', 361 + placeholder: 'data', 362 + description: 363 + 'Name of the binary property to write the downloaded blob to on the output item', 364 + displayOptions: { 365 + show: { 366 + operation: ['getBlob'], 367 + }, 368 + }, 369 + }, 370 + 371 + // ------------------------------------------------------------------ 372 + // Upload Blob — advanced options 373 + // ------------------------------------------------------------------ 374 + { 375 + displayName: 'Options', 376 + name: 'options', 377 + type: 'collection', 378 + placeholder: 'Add Option', 379 + default: {}, 380 + displayOptions: { 381 + show: { 382 + operation: ['uploadBlob'], 383 + }, 384 + }, 385 + options: [ 386 + { 387 + displayName: 'MIME Type Override', 388 + name: 'mimeTypeOverride', 389 + type: 'string', 390 + default: '', 391 + placeholder: 'image/jpeg', 392 + description: 393 + 'Override the MIME type sent to the PDS. Defaults to the binary metadata\'s mimeType, or application/octet-stream if unset.', 394 + }, 395 + ], 396 + }, 397 + 398 + // ------------------------------------------------------------------ 399 + // List Blobs — advanced options 400 + // ------------------------------------------------------------------ 401 + { 402 + displayName: 'Options', 403 + name: 'options', 404 + type: 'collection', 405 + placeholder: 'Add Option', 406 + default: {}, 407 + displayOptions: { 408 + show: { 409 + operation: ['listBlobs'], 410 + }, 411 + }, 412 + options: [ 413 + { 414 + displayName: 'Since (Repo Revision)', 415 + name: 'since', 416 + type: 'string', 417 + default: '', 418 + placeholder: '3jzfc...', 419 + description: 420 + 'Only list blobs added after this repo revision. Useful for incremental sync.', 421 + }, 422 + ], 423 + }, 424 + 425 + // ------------------------------------------------------------------ 242 426 // Record Key — shown for Get/Put/Delete 243 427 // ------------------------------------------------------------------ 244 428 { ··· 361 545 }, 362 546 363 547 // ------------------------------------------------------------------ 364 - // Limit (List only) 548 + // Limit (List Records / List Blobs) 365 549 // ------------------------------------------------------------------ 366 550 { 367 551 displayName: 'Limit', ··· 369 553 type: 'number', 370 554 typeOptions: { 371 555 minValue: 1, 372 - maxValue: 100, 556 + maxValue: 1000, 373 557 }, 374 558 displayOptions: { 375 559 show: { 376 - operation: ['listRecords'], 560 + operation: ['listRecords', 'listBlobs'], 377 561 }, 378 562 }, 379 563 default: 50, 380 - description: 'Maximum number of records to return per page', 564 + description: 'Maximum number of items to return per page', 381 565 }, 382 566 383 567 // ------------------------------------------------------------------ 384 - // Cursor (List only — optional) 568 + // Cursor (List Records / List Blobs — optional) 385 569 // ------------------------------------------------------------------ 386 570 { 387 571 displayName: 'Cursor', ··· 390 574 required: false, 391 575 placeholder: '...', 392 576 description: 393 - 'Optional. Cursor for pagination. Pass the cursor from a previous List Records response to get the next page.', 577 + 'Optional. Cursor for pagination. Pass the cursor from a previous response to get the next page.', 394 578 displayOptions: { 395 579 show: { 396 - operation: ['listRecords'], 580 + operation: ['listRecords', 'listBlobs'], 397 581 }, 398 582 }, 399 583 default: '', ··· 628 812 limit, 629 813 ...(cursor ? { cursor } : {}), 630 814 ...(repo ? { repo } : {}), 815 + }); 816 + result = res as unknown as IDataObject; 817 + break; 818 + } 819 + 820 + case 'uploadBlob': { 821 + const binaryPropertyName = this.getNodeParameter( 822 + 'binaryPropertyName', 823 + i, 824 + ) as string; 825 + const opts = this.getNodeParameter('options', i, {}) as { 826 + mimeTypeOverride?: string; 827 + }; 828 + 829 + const buffer = await this.helpers.getBinaryDataBuffer( 830 + i, 831 + binaryPropertyName, 832 + ); 833 + if (!buffer) { 834 + throw new NodeOperationError( 835 + this.getNode(), 836 + `Binary property "${binaryPropertyName}" not found on input item`, 837 + { itemIndex: i }, 838 + ); 839 + } 840 + 841 + const binaryMeta = items[i].binary?.[binaryPropertyName]; 842 + const mimeType = 843 + opts.mimeTypeOverride?.trim() || 844 + binaryMeta?.mimeType || 845 + 'application/octet-stream'; 846 + 847 + const res = await uploadBlob(agent, { 848 + data: buffer, 849 + mimeType, 850 + }); 851 + result = res as unknown as IDataObject; 852 + break; 853 + } 854 + 855 + case 'getBlob': { 856 + const repoInput = this.getNodeParameter('repo', i) as string; 857 + const cid = this.getNodeParameter('cid', i) as string; 858 + const binaryPropertyName = this.getNodeParameter( 859 + 'binaryPropertyName', 860 + i, 861 + ) as string; 862 + 863 + const did = await resolveActorToDid(agent, repoInput); 864 + const res = await getBlob(agent, { did, cid }); 865 + 866 + const binaryData = await this.helpers.prepareBinaryData( 867 + res.data, 868 + cid, 869 + res.mimeType || undefined, 870 + ); 871 + 872 + returnData.push({ 873 + json: { 874 + cid, 875 + did, 876 + mimeType: res.mimeType, 877 + size: res.size, 878 + }, 879 + binary: { [binaryPropertyName]: binaryData }, 880 + pairedItem: { item: i }, 881 + }); 882 + continue; 883 + } 884 + 885 + case 'listBlobs': { 886 + const limit = this.getNodeParameter('limit', i) as number; 887 + const cursor = this.getNodeParameter('cursor', i) as string; 888 + const repoInput = this.getNodeParameter('repo', i) as string; 889 + const listOpts = this.getNodeParameter('options', i, {}) as { 890 + since?: string; 891 + }; 892 + 893 + const did = repoInput 894 + ? await resolveActorToDid(agent, repoInput) 895 + : undefined; 896 + 897 + const res = await listBlobs(agent, { 898 + ...(did ? { did } : {}), 899 + limit, 900 + ...(cursor ? { cursor } : {}), 901 + ...(listOpts.since ? { since: listOpts.since } : {}), 631 902 }); 632 903 result = res as unknown as IDataObject; 633 904 break;
+156
src/nodes/Atproto/operations.ts
··· 88 88 } 89 89 90 90 // --------------------------------------------------------------------------- 91 + // Blob operation params / results 92 + // --------------------------------------------------------------------------- 93 + 94 + export interface UploadBlobParams { 95 + data: Buffer; 96 + mimeType: string; 97 + } 98 + 99 + export interface UploadBlobResult { 100 + blob: { 101 + $type: 'blob'; 102 + ref: { $link: string }; 103 + mimeType: string; 104 + size: number; 105 + }; 106 + } 107 + 108 + export interface GetBlobParams { 109 + /** DID of the account that owns the blob. */ 110 + did: string; 111 + /** CID of the blob to fetch. */ 112 + cid: string; 113 + } 114 + 115 + export interface GetBlobResult { 116 + /** Raw blob bytes. */ 117 + data: Buffer; 118 + /** MIME type reported by the server (from Content-Type), or empty string. */ 119 + mimeType: string; 120 + /** Size of the returned buffer in bytes. */ 121 + size: number; 122 + } 123 + 124 + export interface ListBlobsParams { 125 + /** DID of the repo to list blobs for. Defaults to authenticated user. */ 126 + did?: string; 127 + cursor?: string; 128 + limit?: number; 129 + /** Optional repo revision — list only blobs added since this rev. */ 130 + since?: string; 131 + } 132 + 133 + export interface ListBlobsResult { 134 + cids: string[]; 135 + cursor?: string; 136 + } 137 + 138 + // --------------------------------------------------------------------------- 91 139 // Helpers 92 140 // --------------------------------------------------------------------------- 93 141 ··· 119 167 throw new Error('Not authenticated — no DID available'); 120 168 } 121 169 return did; 170 + } 171 + 172 + /** 173 + * Resolve a handle to a DID if needed. Returns DIDs unchanged. 174 + * Strips a leading `@` from handles. Trims whitespace. 175 + */ 176 + async function resolveActorToDid( 177 + agent: Agent, 178 + actor: string, 179 + ): Promise<string> { 180 + const trimmed = actor.trim(); 181 + if (trimmed.startsWith('did:')) return trimmed; 182 + const handle = trimmed.startsWith('@') ? trimmed.slice(1) : trimmed; 183 + const res = await agent.com.atproto.identity.resolveHandle({ handle }); 184 + return res.data.did; 122 185 } 123 186 124 187 // --------------------------------------------------------------------------- ··· 277 340 cursor: data.cursor, 278 341 }; 279 342 } 343 + 344 + // --------------------------------------------------------------------------- 345 + // Blob operations 346 + // --------------------------------------------------------------------------- 347 + 348 + /** 349 + * Upload a blob to the authenticated user's PDS. 350 + * 351 + * Returns the blob reference in the canonical AT Protocol shape, ready to 352 + * embed in a record: 353 + * 354 + * { "$type": "blob", "ref": { "$link": "bafkrei..." }, "mimeType": "image/jpeg", "size": 12345 } 355 + * 356 + * The PDS may reject blobs over a service-defined size limit (commonly 357 + * ~1 MB on bsky.social). The error is propagated unchanged so the caller 358 + * can surface it to the user. 359 + */ 360 + export async function uploadBlob( 361 + agent: Agent, 362 + params: UploadBlobParams, 363 + ): Promise<UploadBlobResult> { 364 + const response = await agent.com.atproto.repo.uploadBlob(params.data, { 365 + encoding: params.mimeType, 366 + }); 367 + 368 + // BlobRef serializes to the on-the-wire JSON shape via toJSON(); 369 + // calling it explicitly gives us a plain object that's safe to return 370 + // through n8n's data pipeline. 371 + const ref = response.data.blob as unknown as { toJSON?: () => unknown }; 372 + const serialized = 373 + typeof ref.toJSON === 'function' 374 + ? (ref.toJSON() as UploadBlobResult['blob']) 375 + : (response.data.blob as unknown as UploadBlobResult['blob']); 376 + 377 + return { blob: serialized }; 378 + } 379 + 380 + /** 381 + * Download a blob by CID from a given repo. 382 + * 383 + * `did` is required by the XRPC method and must be a DID (callers should 384 + * resolve handles upfront). Returns the raw bytes plus the server-reported 385 + * MIME type (read from the response headers). 386 + */ 387 + export async function getBlob( 388 + agent: Agent, 389 + params: GetBlobParams, 390 + ): Promise<GetBlobResult> { 391 + const response = await agent.com.atproto.sync.getBlob({ 392 + did: params.did, 393 + cid: params.cid, 394 + }); 395 + 396 + // response.data is a Uint8Array — convert to Buffer for n8n's binary helpers. 397 + const buffer = Buffer.from(response.data); 398 + const mimeType = 399 + (response.headers as Record<string, string> | undefined)?.['content-type'] ?? 400 + ''; 401 + 402 + return { 403 + data: buffer, 404 + mimeType, 405 + size: buffer.length, 406 + }; 407 + } 408 + 409 + /** 410 + * List blob CIDs in a repo. Paginated; pass `cursor` from a previous response 411 + * to fetch the next page. `since` filters to blobs added after a given repo 412 + * revision (rev) — useful for incremental sync. 413 + * 414 + * `did` defaults to the authenticated user. 415 + */ 416 + export async function listBlobs( 417 + agent: Agent, 418 + params: ListBlobsParams = {}, 419 + ): Promise<ListBlobsResult> { 420 + const did = params.did ?? getOwnDid(agent); 421 + 422 + const response = await agent.com.atproto.sync.listBlobs({ 423 + did, 424 + limit: params.limit, 425 + cursor: params.cursor, 426 + since: params.since, 427 + }); 428 + 429 + return { 430 + cids: response.data.cids, 431 + cursor: response.data.cursor, 432 + }; 433 + } 434 + 435 + export { resolveActorToDid };
+253
tests/blobOps.test.ts
··· 1 + /** 2 + * Tests for generic blob operations on the AT Protocol node. 3 + * 4 + * Covers: 5 + * - uploadBlob: takes binary + mimeType, returns a serialized blob ref 6 + * - getBlob: downloads bytes by (did, cid), surfaces Content-Type 7 + * - listBlobs: lists blob CIDs for a repo with cursor + since 8 + * - resolveActorToDid: handle → DID resolution helper used by node UI 9 + */ 10 + 11 + import { 12 + describe, 13 + it, 14 + expect, 15 + beforeAll, 16 + afterAll, 17 + beforeEach, 18 + } from 'vitest'; 19 + import { Agent, CredentialSession } from '@atproto/api'; 20 + 21 + import { 22 + uploadBlob, 23 + getBlob, 24 + listBlobs, 25 + resolveActorToDid, 26 + } from '../src/nodes/Atproto/operations'; 27 + 28 + import { 29 + server, 30 + PDS_URL, 31 + FAKE_DID, 32 + CID_1, 33 + CID_2, 34 + CID_3, 35 + clearInterceptedRequests, 36 + clearMockResponses, 37 + clearUploadedBlobs, 38 + interceptedRequests, 39 + uploadedBlobs, 40 + setMockResponse, 41 + } from './setup'; 42 + 43 + let agent: Agent; 44 + 45 + beforeAll(async () => { 46 + server.listen({ onUnhandledRequest: 'error' }); 47 + 48 + const session = new CredentialSession(new URL(PDS_URL)); 49 + await session.login({ 50 + identifier: 'test.bsky.social', 51 + password: 'xxxx-xxxx-xxxx-xxxx', 52 + }); 53 + agent = new Agent(session); 54 + }); 55 + 56 + afterAll(() => { 57 + server.close(); 58 + }); 59 + 60 + beforeEach(() => { 61 + clearInterceptedRequests(); 62 + clearMockResponses(); 63 + clearUploadedBlobs(); 64 + }); 65 + 66 + // --------------------------------------------------------------------------- 67 + // uploadBlob 68 + // --------------------------------------------------------------------------- 69 + 70 + describe('uploadBlob', () => { 71 + it('uploads a buffer and returns a serialized blob ref', async () => { 72 + const data = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); 73 + 74 + const result = await uploadBlob(agent, { 75 + data, 76 + mimeType: 'image/jpeg', 77 + }); 78 + 79 + // The shape callers can drop straight into a record embed. 80 + expect(result.blob.$type).toBe('blob'); 81 + expect(result.blob.mimeType).toBe('image/jpeg'); 82 + expect(result.blob.size).toBe(data.byteLength); 83 + expect(result.blob.ref.$link).toBe(CID_3); 84 + 85 + // The PDS received the actual bytes with the correct Content-Type. 86 + expect(uploadedBlobs).toHaveLength(1); 87 + expect(uploadedBlobs[0].mimeType).toBe('image/jpeg'); 88 + expect(new Uint8Array(uploadedBlobs[0].data)).toEqual(new Uint8Array(data)); 89 + }); 90 + 91 + it('forwards arbitrary MIME types verbatim', async () => { 92 + await uploadBlob(agent, { 93 + data: Buffer.from('hello'), 94 + mimeType: 'application/x-custom', 95 + }); 96 + 97 + expect(uploadedBlobs[0].mimeType).toBe('application/x-custom'); 98 + }); 99 + 100 + it('propagates PDS errors unchanged', async () => { 101 + setMockResponse('com.atproto.repo.uploadBlob', () => { 102 + throw new Error('PayloadTooLarge'); 103 + }); 104 + 105 + await expect( 106 + uploadBlob(agent, { 107 + data: Buffer.from('too big'), 108 + mimeType: 'text/plain', 109 + }), 110 + ).rejects.toThrow(); 111 + }); 112 + }); 113 + 114 + // --------------------------------------------------------------------------- 115 + // getBlob 116 + // --------------------------------------------------------------------------- 117 + 118 + describe('getBlob', () => { 119 + it('downloads raw bytes and surfaces the MIME type', async () => { 120 + const fakeBody = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); 121 + setMockResponse('com.atproto.sync.getBlob', () => ({ 122 + body: fakeBody, 123 + contentType: 'image/png', 124 + })); 125 + 126 + const result = await getBlob(agent, { 127 + did: FAKE_DID, 128 + cid: CID_1, 129 + }); 130 + 131 + expect(result.data).toBeInstanceOf(Buffer); 132 + expect(new Uint8Array(result.data)).toEqual(fakeBody); 133 + expect(result.mimeType).toBe('image/png'); 134 + expect(result.size).toBe(fakeBody.byteLength); 135 + }); 136 + 137 + it('falls back to empty mimeType when server omits Content-Type', async () => { 138 + // Default mock returns application/octet-stream. Override with an 139 + // explicit empty content-type to assert the read path doesn't crash. 140 + setMockResponse('com.atproto.sync.getBlob', () => ({ 141 + body: new Uint8Array([0]), 142 + contentType: '', 143 + })); 144 + 145 + const result = await getBlob(agent, { 146 + did: FAKE_DID, 147 + cid: CID_2, 148 + }); 149 + 150 + // The XRPC layer may inject a default Content-Type when the header 151 + // is empty; we just assert the call succeeds and exposes whatever 152 + // the headers carried. 153 + expect(typeof result.mimeType).toBe('string'); 154 + expect(result.size).toBe(1); 155 + }); 156 + 157 + it('sends did + cid as query parameters', async () => { 158 + await getBlob(agent, { 159 + did: FAKE_DID, 160 + cid: CID_1, 161 + }); 162 + 163 + const req = interceptedRequests.find((r) => 164 + r.url.includes('com.atproto.sync.getBlob'), 165 + ); 166 + expect(req).toBeDefined(); 167 + expect(req!.url).toContain(`did=${encodeURIComponent(FAKE_DID)}`); 168 + expect(req!.url).toContain(`cid=${CID_1}`); 169 + }); 170 + }); 171 + 172 + // --------------------------------------------------------------------------- 173 + // listBlobs 174 + // --------------------------------------------------------------------------- 175 + 176 + describe('listBlobs', () => { 177 + it('lists CIDs for the authenticated user when no did is provided', async () => { 178 + const result = await listBlobs(agent); 179 + 180 + expect(result.cids).toEqual([CID_1, CID_2, CID_3]); 181 + expect(result.cursor).toBe('next-blob-cursor'); 182 + 183 + const req = interceptedRequests.find((r) => 184 + r.url.includes('com.atproto.sync.listBlobs'), 185 + ); 186 + expect(req).toBeDefined(); 187 + expect(req!.url).toContain(`did=${encodeURIComponent(FAKE_DID)}`); 188 + }); 189 + 190 + it('passes through did / limit / cursor / since', async () => { 191 + await listBlobs(agent, { 192 + did: 'did:plc:other', 193 + limit: 25, 194 + cursor: 'page-2', 195 + since: 'rev-abc', 196 + }); 197 + 198 + const req = interceptedRequests.find((r) => 199 + r.url.includes('com.atproto.sync.listBlobs'), 200 + ); 201 + expect(req!.url).toContain('did=did%3Aplc%3Aother'); 202 + expect(req!.url).toContain('limit=25'); 203 + expect(req!.url).toContain('cursor=page-2'); 204 + expect(req!.url).toContain('since=rev-abc'); 205 + }); 206 + 207 + it('omits the cursor field when the page has none', async () => { 208 + setMockResponse('com.atproto.sync.listBlobs', () => ({ 209 + cids: [CID_1], 210 + })); 211 + 212 + const result = await listBlobs(agent); 213 + expect(result.cids).toEqual([CID_1]); 214 + expect(result.cursor).toBeUndefined(); 215 + }); 216 + }); 217 + 218 + // --------------------------------------------------------------------------- 219 + // resolveActorToDid 220 + // --------------------------------------------------------------------------- 221 + 222 + describe('resolveActorToDid', () => { 223 + it('returns DIDs unchanged', async () => { 224 + const did = await resolveActorToDid(agent, 'did:plc:abc123'); 225 + expect(did).toBe('did:plc:abc123'); 226 + }); 227 + 228 + it('trims whitespace before checking for did: prefix', async () => { 229 + const did = await resolveActorToDid(agent, ' did:plc:xyz789\n'); 230 + expect(did).toBe('did:plc:xyz789'); 231 + }); 232 + 233 + it('resolves handles via com.atproto.identity.resolveHandle', async () => { 234 + const did = await resolveActorToDid(agent, 'alice.bsky.social'); 235 + 236 + expect(did).toBe(FAKE_DID); 237 + const req = interceptedRequests.find((r) => 238 + r.url.includes('com.atproto.identity.resolveHandle'), 239 + ); 240 + expect(req).toBeDefined(); 241 + expect(req!.url).toContain('handle=alice.bsky.social'); 242 + }); 243 + 244 + it('strips a leading @ from handles before resolving', async () => { 245 + await resolveActorToDid(agent, '@alice.bsky.social'); 246 + 247 + const req = interceptedRequests.find((r) => 248 + r.url.includes('com.atproto.identity.resolveHandle'), 249 + ); 250 + expect(req!.url).toContain('handle=alice.bsky.social'); 251 + expect(req!.url).not.toContain('%40'); 252 + }); 253 + });
+52
tests/setup.ts
··· 64 64 cid: CID_2, 65 65 }), 66 66 'com.atproto.repo.deleteRecord': () => ({}), 67 + 'com.atproto.sync.listBlobs': () => ({ 68 + cids: [CID_1, CID_2, CID_3], 69 + cursor: 'next-blob-cursor', 70 + }), 71 + 'com.atproto.identity.resolveHandle': () => ({ 72 + did: FAKE_DID, 73 + }), 67 74 'com.atproto.repo.listRecords': () => ({ 68 75 records: [ 69 76 { ··· 199 206 http.get(xrpcRegex('com.atproto.repo.listRecords'), async ({ request }) => { 200 207 await captureRequest(request); 201 208 return xrpcHandler('com.atproto.repo.listRecords'); 209 + }), 210 + 211 + http.get(xrpcRegex('com.atproto.identity.resolveHandle'), async ({ request }) => { 212 + await captureRequest(request); 213 + return xrpcHandler('com.atproto.identity.resolveHandle'); 214 + }), 215 + 216 + // Blob download — returns raw binary, not JSON. Tests can override the 217 + // body/Content-Type via setMockResponse('com.atproto.sync.getBlob', ...). 218 + http.get(xrpcRegex('com.atproto.sync.getBlob'), async ({ request }) => { 219 + await captureRequest(request); 220 + const handler = responseOverrides['com.atproto.sync.getBlob']; 221 + if (handler) { 222 + const fake = handler() as { 223 + body?: ArrayBuffer | Buffer | Uint8Array; 224 + contentType?: string; 225 + status?: number; 226 + }; 227 + if (fake.status && fake.status !== 200) { 228 + return HttpResponse.json( 229 + { error: 'BlobNotFound', message: 'mock error' }, 230 + { status: fake.status }, 231 + ); 232 + } 233 + const body = 234 + fake.body instanceof ArrayBuffer 235 + ? new Uint8Array(fake.body) 236 + : fake.body ?? new Uint8Array(); 237 + return new HttpResponse(body, { 238 + status: 200, 239 + headers: { 240 + 'content-type': fake.contentType ?? 'application/octet-stream', 241 + }, 242 + }); 243 + } 244 + // Default: return a tiny fake blob 245 + return new HttpResponse(new Uint8Array([0xde, 0xad, 0xbe, 0xef]), { 246 + status: 200, 247 + headers: { 'content-type': 'application/octet-stream' }, 248 + }); 249 + }), 250 + 251 + http.get(xrpcRegex('com.atproto.sync.listBlobs'), async ({ request }) => { 252 + await captureRequest(request); 253 + return xrpcHandler('com.atproto.sync.listBlobs'); 202 254 }), 203 255 204 256 // Phase 2: Lexicon resolution