Skip to content

fix: apply selection_type inference to inventory profile publisher_properties#1174

Open
KonstantinMirin wants to merge 7 commits intoprebid:mainfrom
KonstantinMirin:fix/selection-type-inference-inventory-profile
Open

fix: apply selection_type inference to inventory profile publisher_properties#1174
KonstantinMirin wants to merge 7 commits intoprebid:mainfrom
KonstantinMirin:fix/selection-type-inference-inventory-profile

Conversation

@KonstantinMirin
Copy link
Copy Markdown
Collaborator

@KonstantinMirin KonstantinMirin commented Mar 30, 2026

Problem

`get_products` fails with a `VALIDATION_ERROR` when a product references an inventory profile whose `publisher_properties` JSON lacks the `selection_type` discriminator required by AdCP 2.13.0+:

```
Unable to extract tag using discriminator 'selection_type'
[type=union_tag_not_found, input_value={'property_ids': ['weather.com'], ...}]
```

Any inventory profile created via the admin UI's "full JSON" mode stores arbitrary JSON with no validation, so legacy data (missing `selection_type`, containing domain-style `property_ids`, extra fields like `property_name`) passes through to Pydantic and crashes.

Closes #1162

Root Cause

`Product.effective_properties` has two code paths:

  1. Inventory profile path — returns `self.inventory_profile.publisher_properties` raw, with no normalization
  2. Legacy product path — has full inference logic that adds `selection_type` from the data

Path 1 skipped all inference. The AdCP `PublisherPropertySelector` discriminated union requires `selection_type` to be present for Pydantic to parse it.

Solution

Extract the inference logic into a shared helper `ensure_selection_type()` and apply it to both code paths (DRY). The function is non-destructive — it adds `selection_type` when missing but preserves all other fields on the dict:

  • Has valid `property_ids` (matching `^[a-z0-9_]+$`) → `selection_type: "by_id"`
  • Has valid `property_tags` → `selection_type: "by_tag"`
  • Neither → `selection_type: "all"`
  • Already has `selection_type` → passthrough unchanged

No database migration needed — normalization happens on read.

Testing Approach

BDD-first. Gherkin scenarios define the expected behavior, step definitions exercise real production code through the `ProductEnv` harness (real DB, real `_get_products_impl`, real `convert_product_model_to_schema`). Every scenario runs through all 4 transports (IMPL, A2A, MCP, REST) — 6 scenarios × 4 transports = 24 BDD test cases.

Scenarios cover:

  • `property_ids` without `selection_type` → infers `by_id`
  • `property_tags` without `selection_type` → infers `by_tag`
  • Only `publisher_domain` → infers `all`
  • `selection_type` already present → passthrough
  • Invalid `property_ids` (e.g. `weather.com`) → falls back to `all`
  • Extra metadata fields → preserved (non-destructive)

Test results (all green, merged with latest main)

Suite Passed
Unit 4,143
Integration 1,843
BDD 1,057
E2E 93
Admin 10

…n inventory profile path

TDD red phase: 6 unit tests proving Product.effective_properties returns
raw inventory profile publisher_properties without selection_type inference.
5 tests fail (KeyError: selection_type), 1 passes (passthrough when
selection_type already present). Also adds InventoryProfileFactory.
…ction_type inference

6 scenarios covering product discovery when inventory profile
publisher_properties lack the selection_type discriminator:
- by_id inference from property_ids
- by_tag inference from property_tags
- all inference when no IDs/tags
- passthrough when selection_type present
- fallback to all when property_ids invalid
- legacy field stripping
Add call_a2a, call_mcp, build_rest_body, parse_rest_response, and
REST_ENDPOINT to ProductEnv. Override call_impl with sync wrapper
(asyncio.run) so ImplDispatcher works with the async ProductMixin.
Enables multi-transport BDD dispatch for product discovery scenarios.
…enarios

6 scenarios x 4 transports = 24 test cases. 20 fail proving the bug
(Unable to extract tag using discriminator 'selection_type'), 4 pass
(passthrough when selection_type already present).

Steps use ProductEnv harness — real DB, real _get_products_impl,
real convert_product_model_to_schema, all 4 transports (IMPL/A2A/MCP/REST).
…operties (prebid#1162)

Extract ensure_selection_type() helper and apply it to both code paths
in Product.effective_properties. Inventory profile publisher_properties
that lack the selection_type discriminator now get it inferred:
- valid property_ids -> "by_id"
- valid property_tags -> "by_tag"
- neither -> "all"

Legacy extra fields (property_name, property_type, identifiers) are
stripped. Invalid property_ids (e.g. domain-style "weather.com") fall
back to "all".

BDD: 24/24 pass (6 scenarios x 4 transports).
Unit: 6 regression tests pass.
@ChrisHuie ChrisHuie self-requested a review March 30, 2026 13:20
Comment on lines +92 to +98
try:
asyncio.get_running_loop()
# Already in async context — return the coroutine for the caller to await
return coro # type: ignore[return-value]
except RuntimeError:
# No running loop — bridge to sync
return asyncio.run(coro)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See here and below there is a sync/async bridge. This is a new pattern when compared to the others. Not 100% sure the benefit or difference between having a sync/async bridge and not though? Just noting for project consistency

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — aligned to the AccountSyncEnv pattern: explicit call_impl() (sync via asyncio.run) + call_impl_async() (native async). Same for call_a2a/call_a2a_async. The get_running_loop() auto-detection was clever but inconsistent with the rest of the harness layer.

request.getfixturevalue("integration_db")
from tests.harness.product import ProductEnv

with ProductEnv(tenant_id="inv-profile-test") as env:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded tenant_id

UC-004 and UC-005 both use ProductEnv() / DeliveryPollEnv() / CreativeFormatsEnv() with no explicit tenant_id. They rely on the default "test_tenant" from the base class.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — removed the explicit tenant_id, now uses the default "test_tenant" from BaseTestEnv like all other harnesses. No isolation requirement justified the custom value.

def given_tenant(ctx: dict) -> None:
"""Create a tenant with required config for get_products."""
tenant = TenantFactory(
tenant_id="inv-profile-test",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded tenant_id

UC-004 and UC-005 both use ProductEnv() / DeliveryPollEnv() / CreativeFormatsEnv() with no explicit tenant_id. They rely on the default "test_tenant" from the base class.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — updated the TenantFactory call to use "test_tenant" to match the harness default.

Comment on lines +15 to +34
def _make_product_with_profile(publisher_properties: list[dict]) -> MagicMock:
"""Build a mock Product whose effective_properties calls the real property logic.

Uses a MagicMock with the real Product.effective_properties descriptor
to test the actual inference logic without SQLAlchemy instrumentation.
"""
profile = MagicMock()
profile.publisher_properties = publisher_properties

product = MagicMock(spec=Product)
product.inventory_profile_id = 1
product.inventory_profile = profile
product.properties = None
product.property_ids = None
product.property_tags = None
product.tenant = None

# Wire the real property descriptor so we test actual production code
type(product).effective_properties = PropertyMock(side_effect=lambda: Product.effective_properties.fget(product))
return product
Copy link
Copy Markdown
Contributor

@ChrisHuie ChrisHuie Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PropertyMock(side_effect=lambda: Product.effective_properties.fget(product)) is unique in testing of the project. Don't know if we want to make an issue to expand this pattern across other testing in the project?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the standard Python idiom for mocking `@property` — you can't override a property via instance attribute assignment (no setter), so `PropertyMock` on the class is the only option. It's the sole usage in the codebase, and I don't think it needs expanding — the integration tests for `effective_properties` use a real DB with real `InventoryProfile` relationships, which is the right approach for testing the property's own logic. This unit test just needs a surgical override to reach the error path in `convert_product_model_to_schema`.

…ference-inventory-profile

# Conflicts:
#	tests/bdd/conftest.py
- ProductEnv: replace get_running_loop() auto-detection with explicit
  call_impl/call_impl_async + call_a2a/call_a2a_async pairs, matching
  AccountSyncEnv pattern for project consistency
- Remove hardcoded "inv-profile-test" tenant_id from conftest.py and
  step definitions — use default "test_tenant" like all other harnesses
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: apply selection_type inference to inventory profile publisher_properties

2 participants