[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.

Phase 2 self-review fixes

Critical bugs fixed:

1. Dotted keys produced by ref/object field flattening (e.g. reply.root,
reply.parent) were sent to the PDS as literal flat keys, producing
records that would always fail lexicon validation. Added
unflattenDottedKeys() to convert them back to nested objects before
the XRPC call. Also drops empty optional values so they don't get sent
as empty strings.

2. resolveLocalDef extracted the required-fields array but discarded it,
so the required status of ref-resolved sub-fields was lost. Changed
resolveRefProperties to return { properties, required } (new ResolvedRef
type) and updated flattenRefProperties to AND the schema's required
list with the parent's required status — so optional ref sub-fields
stay optional even when the schema would require them if the ref were
present.

Resource mapper UX improvements:

- Added loadOptionsDependsOn: ['collection'] so getRecordFields is only
called when the NSID changes, not on every editor keystroke.
- Added supportAutoMap: true so users can auto-map input data to fields.
- Added noFieldsError with a helpful message when lexicon resolution fails.

Cleanup:

- Removed unused INodePropertyOptions and ResourceMapperField imports.
- Removed unused 'collection' parameter from buildRecordFromNodeParams.
- Exported buildRecordFromNodeParams + unflattenDottedKeys for testing.

New tests (21 added, 92 total):

- tests/buildRecord.test.ts — 20 tests covering raw JSON, resourceMapper
values, dotted key un-flattening, empty value handling, deep nesting,
and plain object input (from expressions).
- tests/fieldMapping.test.ts — 1 test for the optional-ref-sub-field
required propagation fix.

+394 -85
+70 -20
src/nodes/Atproto/Atproto.node.ts
··· 3 3 IExecuteFunctions, 4 4 ILoadOptionsFunctions, 5 5 INodeExecutionData, 6 - INodePropertyOptions, 7 6 INodeType, 8 7 INodeTypeDescription, 9 - ResourceMapperField, 10 8 ResourceMapperFields, 11 9 } from 'n8n-workflow'; 12 10 import { NodeOperationError } from 'n8n-workflow'; ··· 274 272 singular: 'field', 275 273 plural: 'fields', 276 274 }, 275 + supportAutoMap: true, 276 + noFieldsError: 277 + 'Could not resolve lexicon for this NSID. Enter record data as JSON using an expression, or check the collection NSID.', 277 278 }, 279 + loadOptionsDependsOn: ['collection'], 278 280 }, 279 281 displayOptions: { 280 282 show: { ··· 411 413 ? (this.getNodeParameter('rkey', i) as string) 412 414 : generateTid(); 413 415 const recordData = this.getNodeParameter('recordData', i); 414 - const record = buildRecordFromNodeParams(recordData, collection); 416 + const record = buildRecordFromNodeParams(recordData); 415 417 const swapCommit = this.getNodeParameter('swapCommit', i) as string; 416 418 417 419 const res = await createRecord(agent, { ··· 440 442 case 'putRecord': { 441 443 const rkey = this.getNodeParameter('rkey', i) as string; 442 444 const recordData = this.getNodeParameter('recordData', i); 443 - const record = buildRecordFromNodeParams(recordData, collection); 445 + const record = buildRecordFromNodeParams(recordData); 444 446 const swapCommit = this.getNodeParameter('swapCommit', i) as string; 445 447 446 448 const res = await putRecord(agent, { ··· 523 525 /** 524 526 * Build a record object from node parameters. 525 527 * 526 - * Phase 2: When `recordData` is a `resourceMapper` value (object with 527 - * `mappingMode` and `value`), read the mapped fields and construct the 528 - * record. When it's a raw JSON string (Phase 1 fallback), parse it. 528 + * Handles three input formats: 529 + * 1. Raw JSON string (legacy / fallback) 530 + * 2. `resourceMapper` value object `{ mappingMode, value, ... }` 531 + * 3. Plain object (when called from an expression) 532 + * 533 + * For resourceMapper values, dotted keys produced by ref/object flattening 534 + * (e.g. `reply.root`, `reply.parent`) are un-flattened into nested objects 535 + * (`{ reply: { root, parent } }`) so the resulting record matches the 536 + * lexicon's expected shape. 529 537 * 530 - * In both cases, `$type` and `createdAt` are handled by the operations 531 - * layer (auto-injected in `operations.ts`). 538 + * `$type` and `createdAt` are NOT handled here — they're auto-injected in 539 + * `operations.ts`. 532 540 */ 533 - function buildRecordFromNodeParams( 541 + export function buildRecordFromNodeParams( 534 542 recordData: unknown, 535 - collection: string, 536 543 ): Record<string, unknown> { 537 - // Phase 1 fallback: raw JSON string 544 + // Format 1: raw JSON string 538 545 if (typeof recordData === 'string') { 539 546 try { 540 547 return JSON.parse(recordData) as Record<string, unknown>; ··· 543 550 } 544 551 } 545 552 546 - // Phase 2: resourceMapper value 553 + // Format 2: resourceMapper value 547 554 if ( 548 555 recordData && 549 556 typeof recordData === 'object' && ··· 555 562 value: Record<string, unknown> | null; 556 563 }; 557 564 558 - if (rm.mappingMode === 'defineBelow' && rm.value) { 559 - return rm.value; 565 + if (!rm.value) return {}; 566 + 567 + // Both defineBelow and autoMapInputData produce a flat key/value object. 568 + // Un-flatten dotted keys back into nested objects. 569 + return unflattenDottedKeys(rm.value); 570 + } 571 + 572 + // Format 3: plain object (e.g. from an expression) 573 + return (recordData ?? {}) as Record<string, unknown>; 574 + } 575 + 576 + /** 577 + * Convert a flat object with dotted keys into a nested object. 578 + * 579 + * `{ "reply.root": "x", "reply.parent": "y", text: "hi" }` 580 + * becomes 581 + * `{ reply: { root: "x", parent: "y" }, text: "hi" }`. 582 + * 583 + * Conflicts (a dotted key whose prefix is also a leaf) prefer the more 584 + * specific (dotted) value and discard the conflicting leaf. 585 + */ 586 + export function unflattenDottedKeys( 587 + flat: Record<string, unknown>, 588 + ): Record<string, unknown> { 589 + const result: Record<string, unknown> = {}; 590 + 591 + for (const [key, value] of Object.entries(flat)) { 592 + if (value === null || value === undefined || value === '') { 593 + // Skip empty values — the user didn't fill in this field, and we 594 + // don't want to send `null`/`""` to the PDS for optional fields. 595 + continue; 560 596 } 561 597 562 - if (rm.mappingMode === 'autoMapInputData' && rm.value) { 563 - return rm.value; 598 + if (!key.includes('.')) { 599 + result[key] = value; 600 + continue; 564 601 } 565 602 566 - return {}; 603 + const parts = key.split('.'); 604 + let cursor: Record<string, unknown> = result; 605 + for (let i = 0; i < parts.length - 1; i++) { 606 + const part = parts[i]; 607 + const existing = cursor[part]; 608 + if ( 609 + existing === undefined || 610 + existing === null || 611 + typeof existing !== 'object' 612 + ) { 613 + cursor[part] = {}; 614 + } 615 + cursor = cursor[part] as Record<string, unknown>; 616 + } 617 + cursor[parts[parts.length - 1]] = value; 567 618 } 568 619 569 - // Fallback: return as-is 570 - return recordData as Record<string, unknown>; 620 + return result; 571 621 }
+26 -8
src/nodes/Atproto/fieldMapping.ts
··· 125 125 (nsid: string) => resolveLexiconSchema(agent, nsid), 126 126 ); 127 127 128 - if (resolved !== null && Object.keys(resolved).length > 0) { 129 - // Flatten the resolved properties with a dotted prefix 130 - return flattenRefProperties(name, resolved, required, parentSchema, agent, depth + 1); 128 + if (resolved !== null && Object.keys(resolved.properties).length > 0) { 129 + // Flatten the resolved properties with a dotted prefix. 130 + // A sub-field is required iff the parent ref is required AND the 131 + // resolved schema lists it as required. 132 + return flattenRefProperties( 133 + name, 134 + resolved.properties, 135 + resolved.required, 136 + required, 137 + parentSchema, 138 + agent, 139 + depth + 1, 140 + ); 131 141 } 132 142 133 143 // Resolution failed — fall back to object field ··· 229 239 * Flatten a resolved ref's properties into dotted-path fields. 230 240 * 231 241 * E.g. `reply.root.uri`, `reply.root.cid` instead of a single `reply` json field. 242 + * 243 + * A sub-field is marked `required` iff the parent ref itself is required 244 + * (`parentRequired`) AND the resolved schema lists the sub-field as required 245 + * (`schemaRequired`). This avoids marking optional ref's sub-fields as required 246 + * just because the schema requires them when the ref is present. 232 247 */ 233 248 async function flattenRefProperties( 234 249 prefix: string, 235 250 properties: Record<string, LexiconProperty>, 236 - required: boolean, 251 + schemaRequired: string[], 252 + parentRequired: boolean, 237 253 parentSchema: LexiconSchema, 238 254 agent: Agent | null, 239 255 depth: number, ··· 242 258 243 259 for (const [name, prop] of Object.entries(properties)) { 244 260 const dotted = `${prefix}.${name}`; 261 + const isRequired = parentRequired && schemaRequired.includes(name); 245 262 246 263 // Recursively handle nested refs 247 264 if (prop.type === 'ref' && prop.ref && depth < MAX_REF_DEPTH) { ··· 250 267 parentSchema, 251 268 (nsid: string) => resolveLexiconSchema(agent, nsid), 252 269 ); 253 - if (resolved !== null && Object.keys(resolved).length > 0) { 270 + if (resolved !== null && Object.keys(resolved.properties).length > 0) { 254 271 const nested = await flattenRefProperties( 255 272 dotted, 256 - resolved, 257 - required, 273 + resolved.properties, 274 + resolved.required, 275 + isRequired, 258 276 parentSchema, 259 277 agent, 260 278 depth + 1, ··· 269 287 fields.push({ 270 288 id: dotted, 271 289 displayName: dotted, 272 - required, 290 + required: isRequired, 273 291 defaultMatch: false, 274 292 display: true, 275 293 type: (fieldType === 'json' ? 'object' : fieldType) as ResourceMapperField['type'],
+40 -29
src/nodes/Atproto/lexicon.ts
··· 316 316 // Ref resolution 317 317 // --------------------------------------------------------------------------- 318 318 319 + /** Resolved ref: properties plus the names of fields required by the 320 + * referenced definition. */ 321 + export interface ResolvedRef { 322 + properties: Record<string, LexiconProperty>; 323 + required: string[]; 324 + } 325 + 319 326 /** 320 327 * Resolve a local ref (`#someName`) or cross-document ref 321 328 * (`app.bsky.richtext.facet` or `app.bsky.feed.post#replyRef`) to ··· 324 331 * For local refs, looks in the `rawDefs` of the given schema. 325 332 * For cross-document refs, attempts to resolve the target NSID. 326 333 * 327 - * @returns The resolved properties map, or `null` if resolution fails. 334 + * @returns The resolved properties + required-field names, or `null` if 335 + * resolution fails. 328 336 */ 329 337 export async function resolveRefProperties( 330 338 ref: string, 331 339 currentSchema: LexiconSchema, 332 340 resolveExternal: (nsid: string) => Promise<LexiconSchema | null>, 333 - ): Promise<Record<string, LexiconProperty> | null> { 341 + ): Promise<ResolvedRef | null> { 334 342 if (ref.startsWith('#')) { 335 343 // Local ref — look up in defs 336 344 const localName = ref.slice(1); ··· 350 358 return resolveLocalDef(fragment, resolved.rawDefs); 351 359 } 352 360 353 - // No fragment — return the record's top-level properties 354 - return resolved.properties; 361 + // No fragment — return the record's top-level properties + required 362 + return { 363 + properties: resolved.properties, 364 + required: resolved.required, 365 + }; 355 366 } 356 367 357 368 /** 358 - * Look up a def by name in the raw defs map and extract its properties. 369 + * Look up a def by name in the raw defs map and extract its properties 370 + * along with the names of required fields. 359 371 */ 360 372 function resolveLocalDef( 361 373 name: string, 362 374 rawDefs?: Record<string, unknown>, 363 - ): Record<string, LexiconProperty> | null { 375 + ): ResolvedRef | null { 364 376 if (!rawDefs) return null; 365 377 366 378 const def = rawDefs[name] as Record<string, unknown> | undefined; ··· 368 380 369 381 // Direct object definition 370 382 if (def.type === 'object') { 371 - const properties = def.properties as Record<string, unknown> | undefined; 372 - if (!properties) return null; 373 - const required = (def.required as string[]) ?? []; 374 - const nullable = (def.nullable as string[]) ?? []; 375 - const result: Record<string, LexiconProperty> = {}; 376 - for (const [k, v] of Object.entries(properties)) { 377 - const p = parseInlineProperty(v as Record<string, unknown>); 378 - p.nullable = nullable.includes(k); 379 - result[k] = p; 380 - } 381 - return result; 383 + return extractObjectDef(def); 382 384 } 383 385 384 386 // Token — no properties 385 387 if (def.type === 'token') { 386 - return {}; 388 + return { properties: {}, required: [] }; 387 389 } 388 390 389 391 // Record — unwrap to get the inner object 390 392 if (def.type === 'record') { 391 393 const record = def.record as Record<string, unknown> | undefined; 392 394 if (!record || record.type !== 'object') return null; 393 - const properties = record.properties as Record<string, unknown> | undefined; 394 - if (!properties) return null; 395 - const required = (record.required as string[]) ?? []; 396 - const nullable = (record.nullable as string[]) ?? []; 397 - const result: Record<string, LexiconProperty> = {}; 398 - for (const [k, v] of Object.entries(properties)) { 399 - const p = parseInlineProperty(v as Record<string, unknown>); 400 - p.nullable = nullable.includes(k); 401 - result[k] = p; 402 - } 403 - return result; 395 + return extractObjectDef(record); 404 396 } 405 397 406 398 return null; 407 399 } 400 + 401 + /** Extract a `{ properties, required }` pair from an `object`-type def. */ 402 + function extractObjectDef( 403 + objDef: Record<string, unknown>, 404 + ): ResolvedRef | null { 405 + const properties = objDef.properties as 406 + | Record<string, unknown> 407 + | undefined; 408 + if (!properties) return null; 409 + const required = (objDef.required as string[]) ?? []; 410 + const nullable = (objDef.nullable as string[]) ?? []; 411 + const result: Record<string, LexiconProperty> = {}; 412 + for (const [k, v] of Object.entries(properties)) { 413 + const p = parseInlineProperty(v as Record<string, unknown>); 414 + p.nullable = nullable.includes(k); 415 + result[k] = p; 416 + } 417 + return { properties: result, required }; 418 + }
+211
tests/buildRecord.test.ts
··· 1 + /** 2 + * Tests for `buildRecordFromNodeParams` and `unflattenDottedKeys` from 3 + * the node execute path (Phase 2). 4 + * 5 + * These exercise the critical path from resourceMapper values back to 6 + * AT Protocol record shapes, including un-flattening dotted keys produced 7 + * by ref/object field flattening. 8 + */ 9 + 10 + import { describe, it, expect } from 'vitest'; 11 + 12 + import { 13 + buildRecordFromNodeParams, 14 + unflattenDottedKeys, 15 + } from '../src/nodes/Atproto/Atproto.node'; 16 + 17 + describe('unflattenDottedKeys', () => { 18 + it('passes through flat keys unchanged', () => { 19 + const flat = { text: 'hello', count: 42 }; 20 + expect(unflattenDottedKeys(flat)).toEqual({ text: 'hello', count: 42 }); 21 + }); 22 + 23 + it('nests a single dotted key', () => { 24 + const flat = { 'reply.root': 'at://x' }; 25 + expect(unflattenDottedKeys(flat)).toEqual({ reply: { root: 'at://x' } }); 26 + }); 27 + 28 + it('groups multiple dotted keys with the same prefix', () => { 29 + const flat = { 30 + 'reply.root': 'at://root', 31 + 'reply.parent': 'at://parent', 32 + }; 33 + expect(unflattenDottedKeys(flat)).toEqual({ 34 + reply: { root: 'at://root', parent: 'at://parent' }, 35 + }); 36 + }); 37 + 38 + it('handles a mix of flat and dotted keys', () => { 39 + const flat = { 40 + text: 'hi', 41 + 'reply.root': 'at://root', 42 + 'reply.parent': 'at://parent', 43 + createdAt: '2024-01-01T00:00:00Z', 44 + }; 45 + expect(unflattenDottedKeys(flat)).toEqual({ 46 + text: 'hi', 47 + reply: { root: 'at://root', parent: 'at://parent' }, 48 + createdAt: '2024-01-01T00:00:00Z', 49 + }); 50 + }); 51 + 52 + it('nests through multiple levels', () => { 53 + const flat = { 54 + 'a.b.c': 1, 55 + 'a.b.d': 2, 56 + 'a.e': 3, 57 + }; 58 + expect(unflattenDottedKeys(flat)).toEqual({ 59 + a: { b: { c: 1, d: 2 }, e: 3 }, 60 + }); 61 + }); 62 + 63 + it('skips empty values (null, undefined, empty string)', () => { 64 + const flat = { 65 + text: 'hi', 66 + 'reply.root': '', 67 + 'reply.parent': null, 68 + empty: undefined, 69 + }; 70 + expect(unflattenDottedKeys(flat)).toEqual({ text: 'hi' }); 71 + }); 72 + 73 + it('preserves falsy non-empty values (0, false)', () => { 74 + const flat = { 75 + count: 0, 76 + enabled: false, 77 + }; 78 + expect(unflattenDottedKeys(flat)).toEqual({ count: 0, enabled: false }); 79 + }); 80 + 81 + it('preserves nested object/array values at leaves', () => { 82 + const flat = { 83 + tags: ['a', 'b', 'c'], 84 + 'meta.tags': ['x'], 85 + }; 86 + expect(unflattenDottedKeys(flat)).toEqual({ 87 + tags: ['a', 'b', 'c'], 88 + meta: { tags: ['x'] }, 89 + }); 90 + }); 91 + 92 + it('handles empty input', () => { 93 + expect(unflattenDottedKeys({})).toEqual({}); 94 + }); 95 + }); 96 + 97 + describe('buildRecordFromNodeParams', () => { 98 + describe('raw JSON string input (legacy fallback)', () => { 99 + it('parses a valid JSON string', () => { 100 + const json = '{"text":"hello","createdAt":"2024-01-01T00:00:00Z"}'; 101 + expect(buildRecordFromNodeParams(json)).toEqual({ 102 + text: 'hello', 103 + createdAt: '2024-01-01T00:00:00Z', 104 + }); 105 + }); 106 + 107 + it('returns empty object for invalid JSON', () => { 108 + expect(buildRecordFromNodeParams('{not json}')).toEqual({}); 109 + }); 110 + 111 + it('returns empty object for empty string', () => { 112 + expect(buildRecordFromNodeParams('')).toEqual({}); 113 + }); 114 + }); 115 + 116 + describe('resourceMapper value input', () => { 117 + it('extracts value from defineBelow mode', () => { 118 + const input = { 119 + mappingMode: 'defineBelow', 120 + value: { text: 'hello', createdAt: '2024-01-01' }, 121 + }; 122 + expect(buildRecordFromNodeParams(input)).toEqual({ 123 + text: 'hello', 124 + createdAt: '2024-01-01', 125 + }); 126 + }); 127 + 128 + it('extracts value from autoMapInputData mode', () => { 129 + const input = { 130 + mappingMode: 'autoMapInputData', 131 + value: { text: 'auto' }, 132 + }; 133 + expect(buildRecordFromNodeParams(input)).toEqual({ text: 'auto' }); 134 + }); 135 + 136 + it('returns empty object when value is null', () => { 137 + const input = { mappingMode: 'defineBelow', value: null }; 138 + expect(buildRecordFromNodeParams(input)).toEqual({}); 139 + }); 140 + 141 + it('un-flattens dotted keys from ref-flattened fields', () => { 142 + // Mimics what resourceMapper returns when the user fills in 143 + // `reply.root` and `reply.parent` fields produced by ref flattening. 144 + const input = { 145 + mappingMode: 'defineBelow', 146 + value: { 147 + text: 'a reply', 148 + 'reply.root': 'at://did:plc:abc/app.bsky.feed.post/x', 149 + 'reply.parent': 'at://did:plc:abc/app.bsky.feed.post/y', 150 + createdAt: '2024-01-01T00:00:00Z', 151 + }, 152 + }; 153 + expect(buildRecordFromNodeParams(input)).toEqual({ 154 + text: 'a reply', 155 + reply: { 156 + root: 'at://did:plc:abc/app.bsky.feed.post/x', 157 + parent: 'at://did:plc:abc/app.bsky.feed.post/y', 158 + }, 159 + createdAt: '2024-01-01T00:00:00Z', 160 + }); 161 + }); 162 + 163 + it('un-flattens deeply nested inline object keys', () => { 164 + const input = { 165 + mappingMode: 'defineBelow', 166 + value: { 167 + name: 'test', 168 + 'metadata.source': 'api', 169 + 'metadata.version': 2, 170 + 'metadata.nested.deep': 'value', 171 + }, 172 + }; 173 + expect(buildRecordFromNodeParams(input)).toEqual({ 174 + name: 'test', 175 + metadata: { 176 + source: 'api', 177 + version: 2, 178 + nested: { deep: 'value' }, 179 + }, 180 + }); 181 + }); 182 + 183 + it('drops empty optional fields', () => { 184 + // The user left several fields blank; we shouldn't send them as 185 + // empty strings to the PDS (which would fail lexicon validation 186 + // for optional fields with format constraints). 187 + const input = { 188 + mappingMode: 'defineBelow', 189 + value: { 190 + text: 'hi', 191 + 'reply.root': '', 192 + 'reply.parent': '', 193 + embed: null, 194 + }, 195 + }; 196 + expect(buildRecordFromNodeParams(input)).toEqual({ text: 'hi' }); 197 + }); 198 + }); 199 + 200 + describe('plain object input (from expression)', () => { 201 + it('returns the object as-is', () => { 202 + const obj = { text: 'from expression' }; 203 + expect(buildRecordFromNodeParams(obj)).toEqual({ text: 'from expression' }); 204 + }); 205 + 206 + it('returns empty object for null/undefined', () => { 207 + expect(buildRecordFromNodeParams(null)).toEqual({}); 208 + expect(buildRecordFromNodeParams(undefined)).toEqual({}); 209 + }); 210 + }); 211 + });
+16
tests/fieldMapping.test.ts
··· 177 177 const tagsField = fields.find((f) => f.id === 'tags'); 178 178 expect(tagsField!.required).toBe(false); 179 179 }); 180 + 181 + it('does not mark ref sub-fields as required when the parent ref is optional', async () => { 182 + // `reply` is optional in app.bsky.feed.post, even though replyRef 183 + // declares root and parent as required. The flattened sub-fields 184 + // should be optional because the user might omit `reply` entirely. 185 + const schema = await resolveLexiconSchema(agent, 'app.bsky.feed.post'); 186 + const fields = await lexiconToResourceMapperFields(schema!, agent); 187 + 188 + const replyRoot = fields.find((f) => f.id === 'reply.root'); 189 + expect(replyRoot).toBeDefined(); 190 + expect(replyRoot!.required).toBe(false); 191 + 192 + const replyParent = fields.find((f) => f.id === 'reply.parent'); 193 + expect(replyParent).toBeDefined(); 194 + expect(replyParent!.required).toBe(false); 195 + }); 180 196 }); 181 197 182 198 describe('createdAt auto-default', () => {
+31 -28
tests/recursiveRef.test.ts
··· 51 51 expect(schema!.rawDefs).toBeDefined(); 52 52 53 53 // resolveRefProperties for a local ref 54 - const properties = await resolveRefProperties( 54 + const resolved = await resolveRefProperties( 55 55 '#replyRef', 56 56 schema!, 57 57 (nsid: string) => resolveLexiconSchema(agent, nsid), 58 58 ); 59 59 60 - expect(properties).not.toBeNull(); 61 - expect(properties).toHaveProperty('root'); 62 - expect(properties).toHaveProperty('parent'); 63 - expect(properties!.root.type).toBe('ref'); 64 - expect(properties!.root.ref).toBe('com.atproto.repo.strongRef'); 60 + expect(resolved).not.toBeNull(); 61 + expect(resolved!.properties).toHaveProperty('root'); 62 + expect(resolved!.properties).toHaveProperty('parent'); 63 + expect(resolved!.properties.root.type).toBe('ref'); 64 + expect(resolved!.properties.root.ref).toBe('com.atproto.repo.strongRef'); 65 + // replyRef declares both root and parent as required 66 + expect(resolved!.required).toEqual(['root', 'parent']); 65 67 }); 66 68 67 69 it('resolves a local ref with full dotted notation', async () => { 68 70 const schema = await resolveLexiconSchema(agent, 'app.bsky.feed.post'); 69 71 expect(schema).not.toBeNull(); 70 72 71 - const properties = await resolveRefProperties( 73 + const resolved = await resolveRefProperties( 72 74 'app.bsky.feed.post#replyRef', 73 75 schema!, 74 76 (nsid: string) => resolveLexiconSchema(agent, nsid), ··· 76 78 77 79 // This is a cross-document ref to app.bsky.feed.post with fragment #replyRef 78 80 // Since app.bsky.feed.post resolves (via mock), it should find the fragment 79 - expect(properties).not.toBeNull(); 80 - expect(properties).toHaveProperty('root'); 81 - expect(properties).toHaveProperty('parent'); 81 + expect(resolved).not.toBeNull(); 82 + expect(resolved!.properties).toHaveProperty('root'); 83 + expect(resolved!.properties).toHaveProperty('parent'); 82 84 }); 83 85 84 86 it('returns null for nonexistent local ref', async () => { 85 87 const schema = await resolveLexiconSchema(agent, 'app.bsky.feed.post'); 86 88 expect(schema).not.toBeNull(); 87 89 88 - const properties = await resolveRefProperties( 90 + const resolved = await resolveRefProperties( 89 91 '#nonexistent', 90 92 schema!, 91 93 (nsid: string) => resolveLexiconSchema(agent, nsid), 92 94 ); 93 95 94 - expect(properties).toBeNull(); 96 + expect(resolved).toBeNull(); 95 97 }); 96 98 }); 97 99 ··· 103 105 104 106 // Try to resolve a cross-document ref 105 107 // app.bsky.richtext.facet doesn't have a mock, so this should fail 106 - const properties = await resolveRefProperties( 108 + const resolved = await resolveRefProperties( 107 109 'app.bsky.richtext.facet', 108 110 schema!, 109 111 (nsid: string) => resolveLexiconSchema(agent, nsid), 110 112 ); 111 113 112 114 // We don't have a mock for richtext.facet, so it returns null 113 - expect(properties).toBeNull(); 115 + expect(resolved).toBeNull(); 114 116 }); 115 117 }); 116 118 ··· 126 128 expect(schema).not.toBeNull(); 127 129 128 130 // Resolve level1Ref (depth 1) 129 - const level1Props = await resolveRefProperties( 131 + const level1 = await resolveRefProperties( 130 132 '#level1Ref', 131 133 schema!, 132 134 (nsid: string) => resolveLexiconSchema(agent, nsid), 133 135 ); 134 - expect(level1Props).not.toBeNull(); 135 - expect(level1Props).toHaveProperty('name'); 136 - expect(level1Props).toHaveProperty('level2'); 136 + expect(level1).not.toBeNull(); 137 + expect(level1!.properties).toHaveProperty('name'); 138 + expect(level1!.properties).toHaveProperty('level2'); 137 139 138 140 // level2 is itself a ref to #level2Ref — but we're just checking 139 141 // that local ref resolution works at one level, not recursion 140 - expect(level1Props!.level2.type).toBe('ref'); 141 - expect(level1Props!.level2.ref).toBe('io.example.deep#level2Ref'); 142 + expect(level1!.properties.level2.type).toBe('ref'); 143 + expect(level1!.properties.level2.ref).toBe('io.example.deep#level2Ref'); 142 144 }); 143 145 }); 144 146 ··· 147 149 const schema = await resolveLexiconSchema(agent, 'app.bsky.feed.post'); 148 150 expect(schema).not.toBeNull(); 149 151 150 - const properties = await resolveRefProperties( 152 + const resolved = await resolveRefProperties( 151 153 'io.nonexistent.schema', 152 154 schema!, 153 155 (nsid: string) => resolveLexiconSchema(agent, nsid), 154 156 ); 155 157 156 - expect(properties).toBeNull(); 158 + expect(resolved).toBeNull(); 157 159 }); 158 160 159 161 it('handles empty ref string', async () => { 160 162 const schema = await resolveLexiconSchema(agent, 'app.bsky.feed.post'); 161 163 expect(schema).not.toBeNull(); 162 164 163 - const properties = await resolveRefProperties( 165 + const resolved = await resolveRefProperties( 164 166 '', 165 167 schema!, 166 168 (nsid: string) => resolveLexiconSchema(agent, nsid), ··· 168 170 169 171 // Empty string doesn't start with # and doesn't have a dot 170 172 // It's a cross-document ref with empty NSID 171 - expect(properties).toBeNull(); 173 + expect(resolved).toBeNull(); 172 174 }); 173 175 174 176 it('handles ref with only fragment (no NSID)', async () => { ··· 177 179 178 180 // Cross-document ref with fragment but no NSID prefix 179 181 // # is in the string so it would be treated differently 180 - const properties = await resolveRefProperties( 182 + const resolved = await resolveRefProperties( 181 183 '#replyRef', 182 184 schema!, 183 185 (nsid: string) => resolveLexiconSchema(agent, nsid), 184 186 ); 185 187 186 - expect(properties).not.toBeNull(); 188 + expect(resolved).not.toBeNull(); 189 + expect(resolved!.properties).toHaveProperty('root'); 187 190 }); 188 191 189 192 it('ref resolution with no rawDefs returns null for local refs', async () => { ··· 193 196 required: [], 194 197 }; 195 198 196 - const properties = await resolveRefProperties( 199 + const resolved = await resolveRefProperties( 197 200 '#something', 198 201 schemaWithoutDefs, 199 202 (nsid: string) => resolveLexiconSchema(agent, nsid), 200 203 ); 201 204 202 - expect(properties).toBeNull(); 205 + expect(resolved).toBeNull(); 203 206 }); 204 207 });