Skip to content

Commit 2f1490c

Browse files
committed
test(mcp): parity test for cache-hit vs API-fallback variant details path
Defense against the class of divergence bug that #564 and the Copilot finding on PR #717 each exposed one facet of: cache rows and attrs ``Variant`` models have subtly divergent field shapes, and any helper that reads them with a single attribute name silently loses data on one side. The two prior fixes were per-field: - ``_dump_list``: handle attrs ``to_dict`` alongside pydantic ``model_dump`` (config_attributes / custom_fields) - ``_attr(v, "type") or _attr(v, "type_")``: read both names (the attrs ``Variant`` discriminator is renamed for Python-keyword collision) Both bugs surfaced as silent ``None`` / ``ValidationError`` only on the cold-cache call. Anything else with the same root cause would have the same failure mode and would similarly only surface in production. Enumerating every possible field-shape difference doesn't scale — but exercising both paths with the same logical variant and asserting the responses match catches the whole class at once. ``test_get_variant_details_cache_hit_and_api_fallback_paths_match``: builds the variant twice — once as a cache-shape dict (with cache- synthesized ``display_name`` / ``parent_name``), once as a real attrs ``Variant`` (without them). Runs both through ``_get_variant_details_impl`` (parent + supplier shared between runs), dumps both responses, and asserts dict equality with a clear diff on failure. Verified the test catches divergence: temporarily reverted the ``type_`` fallback locally and the test failed with:: assert {'type': 'product'} == {'type': None}
1 parent 0eb57f6 commit 2f1490c

1 file changed

Lines changed: 175 additions & 0 deletions

File tree

katana_mcp_server/tests/tools/test_items.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,181 @@ async def _get_many_by_ids(entity_type, ids, **_kw):
449449
assert result.type == "product"
450450

451451

452+
@pytest.mark.asyncio
453+
async def test_get_variant_details_cache_hit_and_api_fallback_paths_match():
454+
"""The same logical variant must produce structurally identical
455+
``VariantDetailsResponse`` whether resolved via the cache-hit path
456+
(``CachedVariant`` shape) or the API-fallback path (attrs ``Variant``
457+
shape). Every field surfaced through ``_dict_to_variant_details``
458+
needs to read off both shapes consistently.
459+
460+
This is the divergence-protection contract that #564 and the Copilot
461+
review on PR #717 exposed two facets of:
462+
463+
- ``_dump_list`` only handled ``model_dump`` (pydantic), not
464+
``to_dict`` (attrs) — API-fallback variants with non-empty
465+
``config_attributes`` would fail pydantic validation
466+
- ``_attr(v, "type")`` returned None on attrs because the field is
467+
named ``type_`` (Python-keyword rename), dropping the type badge
468+
469+
Both bugs share the same root: cache rows and attrs models have
470+
subtly divergent field shapes, and any helper that reads them with
471+
a single attribute name will silently lose data on one side. The
472+
only practical defense is exercising both paths with the same
473+
logical input and asserting the responses match — catching a
474+
broader class of latent divergence without enumerating every
475+
possible field-shape difference.
476+
477+
Adding new fields to ``VariantDetailsResponse`` that read from the
478+
variant: if the field's source differs between cache and attrs
479+
shapes (different name, different type), this test will fail and
480+
point at the divergence.
481+
"""
482+
from katana_public_api_client.models.variant import Variant
483+
from katana_public_api_client.models.variant_config_attributes_item import (
484+
VariantConfigAttributesItem,
485+
)
486+
from katana_public_api_client.models.variant_custom_fields_item import (
487+
VariantCustomFieldsItem,
488+
)
489+
from katana_public_api_client.models.variant_type import VariantType
490+
491+
# Logical variant + parent — same data, used to build both shapes.
492+
# Pinned values rather than the existing ``_FULL_VARIANT_DICT`` so
493+
# the test stays self-contained and the assertions read top-to-bottom.
494+
parent_product = {
495+
"id": 101,
496+
"name": "Professional Kitchen Knife Set",
497+
"uom": "set",
498+
"default_supplier_id": 555,
499+
"batch_tracked": False,
500+
"purchase_uom": None,
501+
"purchase_uom_conversion_rate": None,
502+
}
503+
supplier = {"id": 555, "name": "Acme Cutlery Co"}
504+
505+
# CachedVariant shape: dict with cache-only synthesized fields
506+
# (``display_name``, ``parent_name``) precomputed at sync time.
507+
cache_row = {
508+
"id": 9001,
509+
"sku": "KNF-PRO-8PC-STL",
510+
"product_id": 101,
511+
"material_id": None,
512+
"sales_price": 299.99,
513+
"purchase_price": 150.0,
514+
"type": "product",
515+
"internal_barcode": "INT-KNF-001",
516+
"registered_barcode": "789123456789",
517+
"supplier_item_codes": ["SUP-KNF-8PC-001"],
518+
"lead_time": 7,
519+
"minimum_order_quantity": 1,
520+
"config_attributes": [
521+
{"config_name": "Piece Count", "config_value": "8-piece"},
522+
{"config_name": "Handle Material", "config_value": "Steel"},
523+
],
524+
"custom_fields": [
525+
{"field_name": "Warranty Period", "field_value": "5 years"},
526+
],
527+
"display_name": ("Professional Kitchen Knife Set / 8-piece / Steel"),
528+
"parent_name": "Professional Kitchen Knife Set",
529+
"created_at": None,
530+
"updated_at": None,
531+
"deleted_at": None,
532+
}
533+
534+
# attrs Variant shape: same logical data, no cache-only fields,
535+
# ``type_`` trailing-underscore rename, attrs items for configs /
536+
# custom fields. The fields ``Variant`` doesn't carry
537+
# (display_name, parent_name) get recomputed at read time by
538+
# ``_dict_to_variant_details`` from the enriched parent.
539+
api_variant = Variant(
540+
id=9001,
541+
sku="KNF-PRO-8PC-STL",
542+
product_id=101,
543+
material_id=None,
544+
sales_price=299.99,
545+
purchase_price=150.0,
546+
type_=VariantType.PRODUCT,
547+
internal_barcode="INT-KNF-001",
548+
registered_barcode="789123456789",
549+
supplier_item_codes=["SUP-KNF-8PC-001"],
550+
lead_time=7,
551+
minimum_order_quantity=1,
552+
config_attributes=[
553+
VariantConfigAttributesItem(
554+
config_name="Piece Count", config_value="8-piece"
555+
),
556+
VariantConfigAttributesItem(
557+
config_name="Handle Material", config_value="Steel"
558+
),
559+
],
560+
custom_fields=[
561+
VariantCustomFieldsItem(
562+
field_name="Warranty Period", field_value="5 years"
563+
),
564+
],
565+
)
566+
# Sanity: the attrs shape genuinely lacks cache-only fields. If
567+
# this changes the test stops proving what it claims to prove.
568+
assert api_variant.lead_time == 7 # actually-set field surfaces
569+
assert not hasattr(api_variant, "display_name")
570+
assert not hasattr(api_variant, "parent_name")
571+
572+
async def _get_many_by_ids(entity_type, ids, **_kw):
573+
from katana_public_api_client.models_pydantic._generated import (
574+
CachedProduct,
575+
CachedSupplier,
576+
)
577+
578+
if entity_type == CachedProduct:
579+
return {101: parent_product} if 101 in set(ids) else {}
580+
if entity_type == CachedSupplier:
581+
return {555: supplier} if 555 in set(ids) else {}
582+
return {}
583+
584+
async def _run_path(variant_lookup_returns: object) -> dict:
585+
"""Drive the impl with a single mocked variant return value; return
586+
the response as a plain dict for cross-path comparison."""
587+
context, lifespan_ctx = create_mock_context()
588+
lifespan_ctx.typed_cache.catalog.get_by_id = AsyncMock(
589+
return_value=variant_lookup_returns
590+
)
591+
lifespan_ctx.typed_cache.catalog.get_many_by_ids = AsyncMock(
592+
side_effect=_get_many_by_ids
593+
)
594+
# API path: cache_by_id returns None, _fetch_variant_by_id falls
595+
# through to its API call. Patch the inner call to return the
596+
# attrs Variant directly without round-tripping through httpx.
597+
if variant_lookup_returns is None:
598+
with patch(
599+
"katana_mcp.tools.foundation.items._fetch_variant_by_id",
600+
new_callable=AsyncMock,
601+
return_value=api_variant,
602+
):
603+
request = GetVariantDetailsRequest(variant_id=9001)
604+
response = (await _get_variant_details_impl(request, context)).found[0]
605+
else:
606+
request = GetVariantDetailsRequest(variant_id=9001)
607+
response = (await _get_variant_details_impl(request, context)).found[0]
608+
return response.model_dump()
609+
610+
cache_response = await _run_path(cache_row)
611+
api_response = await _run_path(None)
612+
613+
# Path equivalence — every user-facing field on
614+
# ``VariantDetailsResponse`` must produce the same value from either
615+
# path. A diff here is a divergence bug.
616+
assert cache_response == api_response, (
617+
"Cache-hit and API-fallback paths produced different responses. "
618+
"Diff (cache - api):\n"
619+
+ "\n".join(
620+
f" {k}: cache={cache_response.get(k)!r} api={api_response.get(k)!r}"
621+
for k in sorted(set(cache_response) | set(api_response))
622+
if cache_response.get(k) != api_response.get(k)
623+
)
624+
)
625+
626+
452627
@pytest.mark.asyncio
453628
async def test_get_variant_details_no_purchase_uom_when_parent_omits_it():
454629
"""The common case (purchase_uom == stock uom) — parent omits the fields,

0 commit comments

Comments
 (0)