diff --git a/.changeset/brand-invariant-construct-account.md b/.changeset/brand-invariant-construct-account.md new file mode 100644 index 000000000..6c2923920 --- /dev/null +++ b/.changeset/brand-invariant-construct-account.md @@ -0,0 +1,5 @@ +--- +'@adcp/client': patch +--- + +Fix `applyBrandInvariant` scoping for tools whose schema declares `account` but not top-level `brand` (e.g. `get_media_buys`, `get_media_buy_delivery`, `list_creatives`). The helper was only injecting top-level `brand` and merging into an existing `account`; when the request-builder produced no `account`, `adaptRequestForServerVersion` would strip the unrecognized top-level `brand` and the run-scoped brand was lost on the wire. Now the helper constructs an `account` (via `resolveAccount(options)`) when the request omits one, so session scoping survives for every schema shape. Non-object `account` values (`null`, arrays) are still passed through unchanged. (adcp-client#643) diff --git a/src/lib/testing/storyboard/runner.ts b/src/lib/testing/storyboard/runner.ts index 8d86e006e..216f2f44d 100644 --- a/src/lib/testing/storyboard/runner.ts +++ b/src/lib/testing/storyboard/runner.ts @@ -20,7 +20,7 @@ import { } from './context'; import { runValidations, type ValidationContext } from './validations'; import { buildRequest, hasRequestBuilder } from './request-builder'; -import { resolveBrand } from '../client'; +import { resolveAccount, resolveBrand } from '../client'; import { isMutatingTask } from '../../utils/idempotency'; import { PROBE_TASKS, @@ -1107,15 +1107,24 @@ function evalContributesIf(expr: string | undefined, priorStepResults: Map, @@ -1127,9 +1136,19 @@ export function applyBrandInvariant( if (!options.brand && !options.brand_manifest) return request; const brand = resolveBrand(options); const result: Record = { ...request, brand }; - const existingAccount = request.account; - if (existingAccount && typeof existingAccount === 'object' && !Array.isArray(existingAccount)) { - result.account = { ...(existingAccount as Record), brand }; + if ('account' in request) { + // Caller sent an account — merge brand in when it's a plain object. + // Leave non-object values (null, array) alone so intentionally malformed + // requests aren't silently "corrected." + const existingAccount = request.account; + if (existingAccount && typeof existingAccount === 'object' && !Array.isArray(existingAccount)) { + result.account = { ...(existingAccount as Record), brand }; + } + } else { + // No account on the request — construct one so tools whose schema + // declares `account` but not top-level `brand` (e.g. get_media_buys, + // list_creatives) still carry the run-scoped brand on the wire. + result.account = resolveAccount(options); } return result; } diff --git a/test/lib/storyboard-brand-invariant.test.js b/test/lib/storyboard-brand-invariant.test.js index 864b8e105..40f633370 100644 --- a/test/lib/storyboard-brand-invariant.test.js +++ b/test/lib/storyboard-brand-invariant.test.js @@ -70,9 +70,15 @@ describe('applyBrandInvariant', () => { assert.strictEqual(result.account, account); }); - test('does not add account when the request has no account', () => { + test('constructs account when the request omits it, so tools whose schema declares account but not top-level brand still carry the run-scoped brand on the wire (get_media_buys, list_creatives, etc.)', () => { const result = applyBrandInvariant({ list_id: 'pl-1' }, { brand: BRAND }); - assert.strictEqual(result.account, undefined); + assert.deepStrictEqual(result.account, { brand: BRAND, operator: BRAND.domain, sandbox: undefined }); + assert.deepStrictEqual(result.brand, BRAND); + }); + + test('passes sandbox through into the constructed account when options.sandbox is set', () => { + const result = applyBrandInvariant({ list_id: 'pl-1' }, { brand: BRAND, sandbox: true }); + assert.deepStrictEqual(result.account, { brand: BRAND, operator: BRAND.domain, sandbox: true }); }); test('does not mutate the input request', () => {