Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/field-contains-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@adcp/sdk": minor
---

feat(storyboard): `field_contains` validation check — closes adcontextprotocol/adcp#3803 item 2

Adds a wildcard-aware membership check to the storyboard validator. `path` may include `[*]` segments (resolved via the existing `resolvePathAll`), and the check passes when ANY resolved value matches `value` or any of `allowed_values`.

Lets storyboards write:

```yaml
- check: field_contains
path: creatives[0].errors[*].code
value: PROVENANCE_VERIFIER_NOT_ACCEPTED
```

instead of the brittle positional form `creatives[0].errors[0].code value: PROVENANCE_VERIFIER_NOT_ACCEPTED`, which breaks when a future seller emits multiple errors per creative or reorders the cascade. When the path has no wildcard segments the check reduces to scalar equality.

Symmetric to `field_value` for missing-value/missing-path errors and JSON pointer emission. 8 new test cases in `test/lib/storyboard-validations.test.js`.
10 changes: 10 additions & 0 deletions src/lib/testing/storyboard/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,16 @@ export type StoryboardValidationCheck =
| 'envelope_field_value_or_absent'
| 'field_value'
| 'field_value_or_absent'
// Wildcard-aware membership check. `path` may include `[*]` segments that
// expand to every array element via `resolvePathAll`. Passes when ANY
// resolved value matches `value` (or any of `allowed_values`). Lets
// storyboards assert presence of an expected entry inside an unordered
// array without hardcoding a positional index — e.g.,
// `creatives[0].errors[*].code = PROVENANCE_DISCLOSURE_MISSING` passes
// regardless of cascade ordering or co-emitted errors. When the path has
// no wildcard segments this reduces to scalar equality / array membership
// depending on what the path resolves to.
| 'field_contains'
| 'status_code'
| 'error_code'
// HTTP-probe checks (for raw_probe tasks)
Expand Down
72 changes: 72 additions & 0 deletions src/lib/testing/storyboard/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ function runValidation(validation: StoryboardValidation, ctx: ValidationContext)
return validateFieldValue(validation, resolveTarget(ctx));
case 'field_value_or_absent':
return validateFieldValueOrAbsent(validation, resolveTarget(ctx));
case 'field_contains':
return validateFieldContains(validation, resolveTarget(ctx));
case 'status_code':
return requireTaskResult(ctx, validation, tr => validateStatusCode(validation, tr));
case 'error_code':
Expand Down Expand Up @@ -851,6 +853,76 @@ function validateFieldValueOrAbsent(validation: StoryboardValidation, taskResult
};
}

// ────────────────────────────────────────────────────────────
// field_contains: wildcard-aware membership check
//
// Resolves `path` via `resolvePathAll` (which understands `[*]` segments)
// and passes when ANY resolved value matches `value` or any of
// `allowed_values`. Lets storyboards assert "this code appears somewhere
// in errors[]" without pinning a positional index that breaks if the
// seller's emit order shifts or co-emits additional errors.
// ────────────────────────────────────────────────────────────

function validateFieldContains(validation: StoryboardValidation, taskResult: TaskResult): ValidationResult {
const checkName = validation.check;
if (!validation.path) {
return {
check: checkName,
passed: false,
description: validation.description,
path: validation.path,
error: `No path specified for ${checkName} validation`,
json_pointer: null,
expected: 'path must be set in storyboard validation entry',
actual: null,
};
}

if (validation.value === undefined && !validation.allowed_values?.length) {
return {
check: checkName,
passed: false,
description: validation.description,
path: validation.path,
error: `${checkName} requires either \`value\` or \`allowed_values\``,
json_pointer: toJsonPointer(validation.path),
expected: '`value` or `allowed_values` must be set',
actual: null,
};
}

const resolved = resolvePathAll(taskResult.data, validation.path);
const pointer = toJsonPointer(validation.path);

const candidates = validation.allowed_values?.length ? validation.allowed_values : [validation.value];
const matched = resolved.some(actual => candidates.some(c => valuesMatch(actual, c)));

if (matched) {
return {
check: checkName,
passed: true,
description: validation.description,
path: validation.path,
json_pointer: pointer,
};
}

const expected = validation.allowed_values?.length ? validation.allowed_values : validation.value;
const errMsg = validation.allowed_values?.length
? `Expected one of ${JSON.stringify(validation.allowed_values)} to appear in path; got ${JSON.stringify(resolved)}`
: `Expected ${JSON.stringify(validation.value)} to appear in path; got ${JSON.stringify(resolved)}`;
return {
check: checkName,
passed: false,
description: validation.description,
path: validation.path,
error: errMsg,
json_pointer: pointer,
expected,
actual: resolved,
};
}

// ────────────────────────────────────────────────────────────
// status_code: check TaskResult status
// ────────────────────────────────────────────────────────────
Expand Down
152 changes: 152 additions & 0 deletions test/lib/storyboard-validations.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -303,3 +303,155 @@ describe('field_absent / envelope_field_absent (adcp#3429)', () => {
assert.match(result.error, /No path specified for envelope_field_absent/);
});
});

describe('field_contains (adcp#3803 item 2)', () => {
it('passes when value matches any element via [*] wildcard', () => {
const taskResult = {
success: true,
data: {
creatives: [
{
errors: [
{ code: 'PROVENANCE_DISCLOSURE_MISSING', message: 'no disclosure' },
{ code: 'PROVENANCE_VERIFIER_NOT_ACCEPTED', message: 'verifier off-list' },
],
},
],
},
};
const [result] = runOne(
[
{
check: 'field_contains',
path: 'creatives[0].errors[*].code',
value: 'PROVENANCE_VERIFIER_NOT_ACCEPTED',
description: 'verifier-not-accepted appears regardless of cascade order',
},
],
'sync_creatives',
taskResult
);
assert.strictEqual(result.passed, true, result.error);
assert.strictEqual(result.check, 'field_contains');
});

it('passes when any allowed_values entry matches', () => {
const taskResult = {
success: true,
data: {
creatives: [{ errors: [{ code: 'PROVENANCE_DISCLOSURE_MISSING' }] }],
},
};
const [result] = runOne(
[
{
check: 'field_contains',
path: 'creatives[0].errors[*].code',
allowed_values: ['PROVENANCE_DIGITAL_SOURCE_TYPE_MISSING', 'PROVENANCE_DISCLOSURE_MISSING'],
description: 'either disclosure code is acceptable',
},
],
'sync_creatives',
taskResult
);
assert.strictEqual(result.passed, true, result.error);
});

it('fails when no resolved value matches', () => {
const taskResult = {
success: true,
data: {
creatives: [{ errors: [{ code: 'PROVENANCE_DISCLOSURE_MISSING' }] }],
},
};
const [result] = runOne(
[
{
check: 'field_contains',
path: 'creatives[0].errors[*].code',
value: 'PROVENANCE_VERIFIER_NOT_ACCEPTED',
description: 'expected verifier-not-accepted',
},
],
'sync_creatives',
taskResult
);
assert.strictEqual(result.passed, false);
assert.match(result.error, /PROVENANCE_VERIFIER_NOT_ACCEPTED/);
assert.deepStrictEqual(result.actual, ['PROVENANCE_DISCLOSURE_MISSING']);
});

it('fails when path resolves to empty (no array elements)', () => {
const taskResult = { success: true, data: { creatives: [{ errors: [] }] } };
const [result] = runOne(
[
{
check: 'field_contains',
path: 'creatives[0].errors[*].code',
value: 'PROVENANCE_DISCLOSURE_MISSING',
description: 'expected disclosure error',
},
],
'sync_creatives',
taskResult
);
assert.strictEqual(result.passed, false);
assert.deepStrictEqual(result.actual, []);
});

it('reduces to scalar equality when path has no wildcard', () => {
const taskResult = { success: true, data: { status: 'completed' } };
const [hit] = runOne(
[{ check: 'field_contains', path: 'status', value: 'completed', description: 'status matches' }],
'create_media_buy',
taskResult
);
assert.strictEqual(hit.passed, true, hit.error);

const [miss] = runOne(
[{ check: 'field_contains', path: 'status', value: 'submitted', description: 'status mismatch' }],
'create_media_buy',
taskResult
);
assert.strictEqual(miss.passed, false);
});

it('reports an error when path is missing', () => {
const [result] = runOne(
[{ check: 'field_contains', value: 'X', description: 'no path given' }],
'create_media_buy',
{ success: true, data: {} }
);
assert.strictEqual(result.passed, false);
assert.match(result.error, /No path specified for field_contains/);
});

it('reports an error when neither value nor allowed_values is set', () => {
const [result] = runOne(
[{ check: 'field_contains', path: 'errors[*].code', description: 'no expectations' }],
'create_media_buy',
{ success: true, data: { errors: [] } }
);
assert.strictEqual(result.passed, false);
assert.match(result.error, /requires either `value` or `allowed_values`/);
});

it('emits the canonical JSON pointer for the path', () => {
const [result] = runOne(
[
{
check: 'field_contains',
path: 'creatives[0].errors[*].code',
value: 'X',
description: 'pointer test',
},
],
'sync_creatives',
{ success: true, data: { creatives: [{ errors: [{ code: 'X' }] }] } }
);
assert.strictEqual(result.passed, true);
// toJsonPointer renders [*] as /* (literal asterisk) — `*` isn't a
// forbidden character in RFC 6901, so it round-trips unescaped.
assert.strictEqual(result.json_pointer, '/creatives/0/errors/*/code');
});
});
Loading