diff --git a/.changeset/fix-get-rights-request-builder.md b/.changeset/fix-get-rights-request-builder.md new file mode 100644 index 000000000..6d7049518 --- /dev/null +++ b/.changeset/fix-get-rights-request-builder.md @@ -0,0 +1,24 @@ +--- +'@adcp/client': patch +--- + +Storyboard runner: honor `step.sample_request` in `get_rights` +request builder. + +Prior behavior hardcoded `query: 'available rights for advertising'` +and `uses: ['ai_generated_image']`, and injected `brand_id` from the +caller's `brand.domain`. Storyboards declaring scenario-specific +query text, uses, or a `buyer_brand` hit the wire with the generic +fallback instead, and rights-holder rosters rejected the +caller-domain `brand_id` as unknown — so `rights[0]` was undefined, +`$context.rights_id` didn't resolve, and downstream `acquire_rights` +steps failed with `rights_not_found` instead of the error the +storyboard was actually asserting (e.g., `GOVERNANCE_DENIED` in +`brand_rights/governance_denied`). + +Mirrors the pattern used by peer builders (`sync_plans`, +`check_governance`, `list_creative_formats`, +`create_content_standards`, etc.). The generic fallback still runs +when no `sample_request` is authored. + +Closes adcp#2846. diff --git a/src/lib/testing/storyboard/request-builder.ts b/src/lib/testing/storyboard/request-builder.ts index c497d68f0..3dab2b394 100644 --- a/src/lib/testing/storyboard/request-builder.ts +++ b/src/lib/testing/storyboard/request-builder.ts @@ -76,11 +76,25 @@ const REQUEST_BUILDERS: Record = { }, get_rights(step, context, options) { + // Honor hand-authored sample_request so storyboards can specify + // scenario-specific query text, uses, countries, or buyer_brand. + // Peer builders (sync_plans, check_governance, list_creative_formats, + // create_content_standards, etc.) follow the same pattern. + // + // Without this, any get_rights step hits the wire with the generic + // fallback and a brand_id derived from the caller's domain — which + // rights-holder rosters reject as unknown, so rights[0] is undefined, + // $context.rights_id doesn't resolve, and downstream acquire_rights + // steps fail with rights_not_found instead of the error the + // storyboard is actually asserting (e.g., GOVERNANCE_DENIED). + if (step.sample_request) { + return injectContext({ ...step.sample_request }, context); + } const brand = resolveBrand(options); return { query: 'available rights for advertising', uses: ['ai_generated_image'], - brand_id: context.brand_id ?? (step.sample_request?.brand_id as string) ?? brand.brand_id ?? brand.domain, + brand_id: context.brand_id ?? brand.brand_id ?? brand.domain, }; }, diff --git a/test/lib/request-builder.test.js b/test/lib/request-builder.test.js index 008a5cb7d..db6efe886 100644 --- a/test/lib/request-builder.test.js +++ b/test/lib/request-builder.test.js @@ -75,6 +75,29 @@ describe('Request Builder', () => { assert.ok(Array.isArray(result.uses), 'should have uses array'); assert.strictEqual(result.brand_id, 'acmeoutdoor.example'); }); + + test('honors step.sample_request when present', () => { + // Regression: the builder previously ignored sample_request for + // everything except brand_id, so a storyboard declaring specific + // query text / uses / countries hit the wire with the generic + // fallback. That silently broke scenarios like + // brand_rights/governance_denied where the buyer query matters + // for the rights-holder roster to return a non-empty list. + const fixture = { + buyer: { domain: 'pinnacle-agency.example' }, + query: 'licensed commercial rights for a regional outdoor retail campaign', + uses: ['commercial', 'endorsement'], + }; + const result = buildRequest(step('get_rights', { sample_request: fixture }), {}, DEFAULT_OPTIONS); + assert.strictEqual(result.query, fixture.query); + assert.deepStrictEqual(result.uses, fixture.uses); + assert.deepStrictEqual(result.buyer, fixture.buyer); + assert.strictEqual( + result.brand_id, + undefined, + 'brand_id from caller domain must not leak when sample_request omits it' + ); + }); }); describe('sync_catalogs', () => {