test_sample_adapters.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. """对象目录与样例适配守卫(任务书 P1-A / P1-F / P1-H)。"""
  2. from __future__ import annotations
  3. import re
  4. import pytest
  5. from catalog import sample_adapters as sa
  6. # 方案 §7.2 列出的 26 个 API_INBOUND 代码契约
  7. EXPECTED_INBOUND_CONTRACTS = {
  8. "MDM_ITEM", "MDM_CUSTOMER", "MDM_SUPPLIER", "MDM_LOCATION", "MDM_SOURCE_LIST",
  9. "MDM_EMPLOYEE_HEADCOUNT", "S1_SALES_ORDER_ENTRY", "S1_REQUIREMENT_EXAMINE_RESULT",
  10. "S1_REQUIREMENT_EXAMINE_DETAIL", "S2_WORK_ORDER_SCHEDULE", "S3_PURCHASE_ORDER",
  11. "S3_PURCHASE_RECEIPT", "S4_SHIPMENT", "S4_IQC", "S4_RETURN", "S4_SHORTAGE",
  12. "S5_WORK_ORDER_BOM", "S5_INVENTORY_TXN", "S5_INVENTORY_OPENING_BALANCE",
  13. "S5_INVENTORY_BALANCE_MONTHLY", "S6_WORK_ORDER_LINE", "S6_REPORT_TXN",
  14. "S7_FQC_TASK_TXN", "S7_SALES_ORDER_LINE", "S7_FINISHED_ONHAND",
  15. "S7_FINISHED_OPENING_BALANCE",
  16. }
  17. RUN_ID = "20260930120000-abc123"
  18. def test_catalog_loads_and_has_three_channels():
  19. catalog = sa.load_catalog()
  20. assert catalog["objects"], "catalog must not be empty"
  21. for obj in catalog["objects"]:
  22. for channel in ("dbSync", "apiPull", "apiInbound"):
  23. assert channel in obj, f"{obj['objectCode']} missing {channel}"
  24. assert "supported" in obj[channel]
  25. # 不支持必须写明原因,不得把“配置未开”写成“代码不支持”
  26. for channel in ("dbSync", "apiPull", "apiInbound"):
  27. if not obj[channel]["supported"]:
  28. assert obj[channel].get("reason"), f"{obj['objectCode']}.{channel} unsupported without reason"
  29. def test_catalog_covers_all_26_inbound_contracts():
  30. registered = {(o.get("apiInbound") or {}).get("entityCode")
  31. for o in sa.load_catalog()["objects"]
  32. if (o.get("apiInbound") or {}).get("supported")}
  33. assert registered == EXPECTED_INBOUND_CONTRACTS, (
  34. f"missing={sorted(EXPECTED_INBOUND_CONTRACTS - registered)}, "
  35. f"unexpected={sorted(registered - EXPECTED_INBOUND_CONTRACTS)}")
  36. def test_every_inbound_contract_has_field_spec():
  37. for code in EXPECTED_INBOUND_CONTRACTS:
  38. assert code in sa.INBOUND_SPECS, f"no field spec for {code}"
  39. assert any(required for _, required, _ in sa.INBOUND_SPECS[code]), f"{code} has no required field"
  40. def test_contract_version_matches_field_spec_semantics():
  41. """INBOUND_SPECS 按 IdentityFields(v2 中立集合)造数,目录必须统一登记 v2。
  42. C# 端 NormalizeVersion 只将显式 v2 路由到 IdentityFields;若目录写 v1 而适配表按 v2
  43. 生成,就会出现“按 v1 造数、按 v2 校验”的口径错配。
  44. """
  45. for obj in sa.load_catalog()["objects"]:
  46. inbound = obj.get("apiInbound") or {}
  47. if inbound.get("supported"):
  48. assert inbound.get("contractVersion") == "v2", \
  49. f"{obj['objectCode']} contractVersion 应为 v2,实为 {inbound.get('contractVersion')}"
  50. def test_builtin_sample_rows_satisfy_required_fields():
  51. """内置最小样例必须能填满契约必填项(否则预检会把造数缺陷当成配置问题)。"""
  52. run_id = "20260930120000-abc123"
  53. for obj in sa.load_catalog()["objects"]:
  54. if obj.get("sample") or not (obj.get("apiInbound") or {}).get("supported"):
  55. continue
  56. adapted = sa.adapt_inbound_rows(obj, run_id)
  57. assert not adapted["missing_required"], \
  58. f"{obj['objectCode']} builtin sample misses {adapted['missing_required']}"
  59. def test_sample_paths_exist():
  60. for obj in sa.load_catalog()["objects"]:
  61. if obj.get("sample"):
  62. assert (sa.REPO_ROOT / obj["sample"]).exists(), f"sample missing: {obj['sample']}"
  63. else:
  64. assert obj["objectCode"] in sa.BUILTIN_SAMPLES, \
  65. f"{obj['objectCode']} has neither sample file nor builtin rows"
  66. def test_run_id_format():
  67. assert re.match(r"^\d{14}-[0-9a-f]{6}$", sa.new_run_id())
  68. def test_db_rows_carry_sim_prefix_and_increment_column():
  69. obj = sa.find_object("ITEM")
  70. rows = sa.adapt_db_rows(obj, RUN_ID)
  71. assert rows
  72. for row in rows:
  73. assert str(row["bizKey"]).startswith(f"SIM-{RUN_ID}-")
  74. assert row.get("sourceUpdatedAt"), "increment column must be filled"
  75. assert row.get("Domain") == "SIM"
  76. def test_prefix_is_idempotent():
  77. obj = sa.find_object("ITEM")
  78. once = sa.adapt_db_rows(obj, RUN_ID)
  79. twice = sa.adapt_db_rows(obj, RUN_ID, once)
  80. assert [r["bizKey"] for r in once] == [r["bizKey"] for r in twice]
  81. def test_api_rows_sorted_by_bizkey_for_cursor():
  82. obj = sa.find_object("SALES_ORDER_ENTRY")
  83. rows = sa.adapt_api_rows(obj, RUN_ID)
  84. keys = [str(r["bizKey"]) for r in rows]
  85. assert keys == sorted(keys)
  86. @pytest.mark.parametrize("object_code,entity_code", [
  87. ("ITEM", "MDM_ITEM"),
  88. ("SALES_ORDER_ENTRY", "S1_SALES_ORDER_ENTRY"),
  89. ("WORK_ORDER", "S2_WORK_ORDER_SCHEDULE"),
  90. ("PURCHASE_ORDER", "S3_PURCHASE_ORDER"),
  91. ("IQC", "S4_IQC"),
  92. ("INVENTORY_TXN", "S5_INVENTORY_TXN"),
  93. ("REPORT_TXN", "S6_REPORT_TXN"),
  94. ("FQC_TASK", "S7_FQC_TASK_TXN"),
  95. ])
  96. def test_inbound_rows_use_contract_field_names(object_code, entity_code):
  97. obj = sa.find_object(object_code)
  98. assert (obj["apiInbound"] or {}).get("entityCode") == entity_code
  99. adapted = sa.adapt_inbound_rows(obj, RUN_ID)
  100. spec_fields = {f for f, _, _ in sa.INBOUND_SPECS[entity_code]}
  101. for row in adapted["rows"]:
  102. unknown = set(row) - spec_fields - {"sourceUpdatedAt", "sourceVersion", "op"}
  103. assert not unknown, f"{entity_code} produced non-contract fields: {sorted(unknown)}"
  104. assert str(row.get("sourceUpdatedAt")), "sourceUpdatedAt is required by common contract"
  105. def test_inbound_reports_missing_required_instead_of_faking():
  106. obj = sa.find_object("SALES_ORDER_ENTRY")
  107. adapted = sa.adapt_inbound_rows(obj, RUN_ID, rows=[{"bizKey": "X-1", "qty": 5}])
  108. # bill_no / entry_seq / seorder_id 无别名来源,必须报告缺失而不是编造
  109. assert {"bill_no", "entry_seq", "seorder_id"} <= set(adapted["missing_required"])
  110. def test_inbound_rejects_object_without_contract():
  111. obj = sa.find_object("SALES_ORDER_HEAD")
  112. assert not (obj["apiInbound"] or {}).get("supported")
  113. with pytest.raises(ValueError):
  114. sa.adapt_inbound_rows(obj, RUN_ID)
  115. def test_envelope_shape():
  116. env = sa.inbound_envelope([{"a": 1}], snapshot_id="SNAP-1", seq=2)
  117. assert env == {"data": {"list": [{"a": 1}], "snapshotId": "SNAP-1", "seq": 2}}
  118. def test_scenarios_reference_known_objects_and_mark_unsupported():
  119. scenarios = sa.load_scenarios()["scenarios"]
  120. assert len(scenarios) == 3
  121. for sc in scenarios:
  122. assert sc["nodes"]
  123. for node in sc["nodes"]:
  124. obj = sa.find_object(node["objectCode"])
  125. assert obj, f"scenario {sc['scenarioId']} references unknown object {node['objectCode']}"
  126. spec = {"DB_SYNC": obj["dbSync"], "API_PULL": obj["apiPull"],
  127. "API_INBOUND": obj["apiInbound"]}[node["channel"]]
  128. if not spec.get("supported"):
  129. assert node.get("inboundNote") or spec.get("reason"), \
  130. f"{node['objectCode']} unsupported channel without note"
  131. def test_samples_contain_no_real_looking_secrets():
  132. """样例不得含真实凭据形态字段。"""
  133. banned = re.compile(r"(password|secret|access_?key|token|jwt)", re.IGNORECASE)
  134. for obj in sa.load_catalog()["objects"]:
  135. for row in sa.load_canonical_rows(obj):
  136. for key in row:
  137. assert not banned.search(str(key)), f"{obj['objectCode']} sample has credential-like field {key}"