feat: prepare ARR for controlled public deployment
This commit is contained in:
567
tests/test_structured_output.py
Normal file
567
tests/test_structured_output.py
Normal file
@@ -0,0 +1,567 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SKILL_ROOT = PROJECT_ROOT / "opera-daily-channel-report"
|
||||
SCRIPTS = SKILL_ROOT / "scripts"
|
||||
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
import process_reports as core # noqa: E402
|
||||
import validate_outputs as validator # noqa: E402
|
||||
|
||||
|
||||
def reservation(
|
||||
sequence: int,
|
||||
*,
|
||||
company: str = "T / Q.B.D. TRAVEL GROUP",
|
||||
rate_code: str = "GRPA1",
|
||||
rate_amount: str = "900",
|
||||
room: str | None = None,
|
||||
confirmation: str | None = None,
|
||||
full_name: str = "SYNTHETIC GUEST",
|
||||
block_code: str = "",
|
||||
res_comment: str | None = None,
|
||||
rooms: str = "1",
|
||||
departure: str = "2026-07-28",
|
||||
) -> str:
|
||||
room = room or f"SYNTHETIC-ROOM-{sequence}"
|
||||
confirmation = confirmation or f"SYNTHETIC-CONF-{sequence}"
|
||||
comment_xml = ""
|
||||
if res_comment is not None:
|
||||
comment_xml = (
|
||||
"<LIST_G_COMMENT_RESV_NAME_ID><G_COMMENT_RESV_NAME_ID>"
|
||||
f"<RES_COMMENT>{res_comment}</RES_COMMENT>"
|
||||
"</G_COMMENT_RESV_NAME_ID></LIST_G_COMMENT_RESV_NAME_ID>"
|
||||
)
|
||||
return f"""
|
||||
<G_RESERVATION>
|
||||
<ADULTS>2</ADULTS><BLOCK_CODE>{block_code}</BLOCK_CODE><CF_CHILDREN>0</CF_CHILDREN>
|
||||
<COMPANY_NAME>{company}</COMPANY_NAME>
|
||||
<CONFIRMATION_NO>{confirmation}</CONFIRMATION_NO><DISP_ROOM_NO>{room}</DISP_ROOM_NO>
|
||||
<EFFECTIVE_RATE_AMOUNT>{rate_amount}</EFFECTIVE_RATE_AMOUNT><FULL_NAME>{full_name}</FULL_NAME>
|
||||
{comment_xml}
|
||||
<NO_OF_ROOMS>{rooms}</NO_OF_ROOMS><PRODUCTS>SYNTHETIC</PRODUCTS><RATE_CODE>{rate_code}</RATE_CODE>
|
||||
<ROOM_CATEGORY_LABEL>SYNTHETIC ROOM TYPE</ROOM_CATEGORY_LABEL>
|
||||
<TRUNC_BEGIN>2026-07-27</TRUNC_BEGIN><TRUNC_END>{departure}</TRUNC_END>
|
||||
</G_RESERVATION>
|
||||
"""
|
||||
|
||||
|
||||
def xml_document(*rows: str) -> str:
|
||||
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RES_DETAIL>
|
||||
<LIST_G_GROUP_BY1>
|
||||
<G_GROUP_BY1>
|
||||
<GROUPBY1_SORT_COL>20260727</GROUPBY1_SORT_COL>
|
||||
<GROUPBY1_COL>27-07-26</GROUPBY1_COL>
|
||||
<LIST_G_RESERVATION>{''.join(rows)}</LIST_G_RESERVATION>
|
||||
</G_GROUP_BY1>
|
||||
</LIST_G_GROUP_BY1>
|
||||
</RES_DETAIL>
|
||||
"""
|
||||
|
||||
|
||||
def run_processor(
|
||||
xml_text: str,
|
||||
root: Path,
|
||||
monthly_base: Path | None = None,
|
||||
*,
|
||||
mode: str = "daily-monthly",
|
||||
):
|
||||
xml_path = root / "synthetic.xml"
|
||||
output_dir = root / "output"
|
||||
result_path = output_dir / "result.json"
|
||||
structured_path = output_dir / "structured-result.json"
|
||||
xml_path.write_text(xml_text, encoding="utf-8")
|
||||
args = argparse.Namespace(
|
||||
xml=str(xml_path.resolve()),
|
||||
monthly_base=str(monthly_base.resolve()) if monthly_base else None,
|
||||
output_dir=str(output_dir.resolve()),
|
||||
result_json=str(result_path.resolve()),
|
||||
structured_result_json=str(structured_path.resolve()),
|
||||
mode=mode,
|
||||
)
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
exit_code = core.process(args)
|
||||
result = json.loads(result_path.read_text(encoding="utf-8"))
|
||||
structured = json.loads(structured_path.read_text(encoding="utf-8"))
|
||||
return exit_code, xml_path, output_dir, result, structured
|
||||
|
||||
|
||||
class StructuredOutputTests(unittest.TestCase):
|
||||
def success_xml(self) -> str:
|
||||
return xml_document(
|
||||
reservation(1, rate_code="NOT-ALLOWED", rate_amount="INVALID"),
|
||||
reservation(2, rate_code="grpa1", res_comment=" ab-123 ", rooms="2", departure="2026-07-30"),
|
||||
reservation(
|
||||
3,
|
||||
room="SYNTHETIC-ROOM-2",
|
||||
confirmation="SYNTHETIC-DUPLICATE",
|
||||
res_comment="AB-123",
|
||||
),
|
||||
reservation(
|
||||
4,
|
||||
company="T- Rainbow Holiday Service",
|
||||
rate_code="LBMS",
|
||||
rate_amount="98765",
|
||||
block_code="MUST-NOT-BECOME-GROUP-CODE",
|
||||
res_comment=None,
|
||||
departure="2026-07-29",
|
||||
),
|
||||
)
|
||||
|
||||
def test_success_payload_preserves_every_source_outcome_and_lineage(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
exit_code, xml_path, output_dir, result, payload = run_processor(
|
||||
self.success_xml(), Path(temp_dir)
|
||||
)
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(set(result), {
|
||||
"version", "status", "business_date", "month_key", "message", "metrics", "outputs", "errors"
|
||||
})
|
||||
self.assertNotIn("structured_result", result["outputs"])
|
||||
|
||||
self.assertEqual(payload["status"], "success")
|
||||
self.assertTrue(payload["activation_eligible"])
|
||||
self.assertEqual(payload["processor_version"], core.PROCESSOR_VERSION)
|
||||
self.assertEqual(payload["rule_set_sha256"], core.rule_set_sha256())
|
||||
self.assertEqual(payload["source_rows"], 4)
|
||||
self.assertEqual(payload["removed_by_rate_code"], 1)
|
||||
self.assertEqual(payload["removed_as_duplicates"], 1)
|
||||
self.assertEqual(payload["output_rows"], 2)
|
||||
self.assertEqual(
|
||||
payload["outcome_counts"],
|
||||
{
|
||||
"duplicate": 1,
|
||||
"excluded_rate_code": 1,
|
||||
"price_unmatched": 0,
|
||||
"retained": 2,
|
||||
"validation_failed": 0,
|
||||
},
|
||||
)
|
||||
records = payload["records"]
|
||||
self.assertEqual([row["source_sequence"] for row in records], [1, 2, 3, 4])
|
||||
self.assertEqual(
|
||||
[row["outcome"] for row in records],
|
||||
["excluded_rate_code", "retained", "duplicate", "retained"],
|
||||
)
|
||||
self.assertEqual(records[2]["duplicate_of_source_sequence"], 2)
|
||||
self.assertEqual(records[1]["rate_code"], "grpa1")
|
||||
self.assertEqual(records[1]["normalized_rate_code"], "GRPA1")
|
||||
self.assertEqual(records[1]["group_code_key"], "AB-123")
|
||||
self.assertEqual(records[1]["booking_source_match_status"], "not_checked")
|
||||
self.assertIsNone(records[3]["group_code_key"])
|
||||
self.assertEqual(records[3]["booking_source_match_status"], "missing_group_code")
|
||||
self.assertNotEqual(records[3]["block_code"], records[3]["group_code_key"])
|
||||
self.assertEqual(records[1]["real_price"], 1800)
|
||||
self.assertEqual(records[1]["total_price"], 10800)
|
||||
self.assertEqual(records[1]["pricing_method"], "price_reference_exact")
|
||||
self.assertEqual(records[3]["real_price"], 0)
|
||||
self.assertEqual(records[3]["total_price"], 0)
|
||||
self.assertEqual(records[3]["kb_amount"], 100)
|
||||
self.assertEqual(records[3]["pricing_method"], "zero_price_exception")
|
||||
self.assertTrue(all(row["source_worksheet"] is None for row in records))
|
||||
self.assertTrue(all(row["source_row_no"] is None for row in records))
|
||||
|
||||
artifact_paths = {
|
||||
"source_xml": xml_path,
|
||||
"daily_report": output_dir / result["outputs"]["daily_report"],
|
||||
"monthly_report": output_dir / result["outputs"]["monthly_report"],
|
||||
"result_json": output_dir / "result.json",
|
||||
}
|
||||
for name, path in artifact_paths.items():
|
||||
with self.subTest(artifact=name):
|
||||
artifact = payload["artifacts"][name]
|
||||
self.assertEqual(artifact["path"], str(path.resolve()))
|
||||
self.assertEqual(artifact["sha256"], core.sha256_file(path))
|
||||
self.assertEqual(artifact["byte_size"], path.stat().st_size)
|
||||
self.assertIsNone(payload["artifacts"]["exception_report"])
|
||||
core.validate_structured_completeness(payload)
|
||||
|
||||
def test_same_business_date_rerun_replaces_monthly_rows_without_duplication(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
first_root = root / "first"
|
||||
second_root = root / "second"
|
||||
first_root.mkdir()
|
||||
second_root.mkdir()
|
||||
first = run_processor(self.success_xml(), first_root)
|
||||
self.assertEqual(first[0], 0)
|
||||
first_monthly = first[2] / first[3]["outputs"]["monthly_report"]
|
||||
second = run_processor(self.success_xml(), second_root, monthly_base=first_monthly)
|
||||
self.assertEqual(second[0], 0)
|
||||
self.assertEqual(first[4]["records"], second[4]["records"])
|
||||
|
||||
second_monthly = second[2] / second[3]["outputs"]["monthly_report"]
|
||||
workbook = load_workbook(second_monthly, data_only=False)
|
||||
try:
|
||||
current_rows = 0
|
||||
for sheet in workbook.worksheets:
|
||||
for row_number in range(2, sheet.max_row + 1):
|
||||
if sheet.cell(row_number, 1).value is not None:
|
||||
current_rows += 1
|
||||
self.assertEqual(current_rows, 2)
|
||||
finally:
|
||||
workbook.close()
|
||||
|
||||
def test_daily_mode_produces_no_monthly_workbook_and_keeps_channel_lineage(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
exit_code, xml_path, output_dir, result, payload = run_processor(
|
||||
self.success_xml(), Path(temp_dir), mode="daily"
|
||||
)
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(result["version"], core.DAILY_RESULT_VERSION)
|
||||
self.assertEqual(result["processing_mode"], "daily")
|
||||
self.assertIsNone(result["outputs"]["monthly_report"])
|
||||
self.assertEqual(
|
||||
result["metrics"]["channels"],
|
||||
[{"worksheet": "QBD", "rows": 1}, {"worksheet": core.KB_SHEET, "rows": 1}],
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
payload["result_schema_version"],
|
||||
core.DAILY_STRUCTURED_RESULT_SCHEMA_VERSION,
|
||||
)
|
||||
self.assertEqual(payload["processing_mode"], "daily")
|
||||
self.assertIsNone(payload["artifacts"]["monthly_report"])
|
||||
self.assertEqual(
|
||||
[record["channel_key"] for record in payload["records"]],
|
||||
[None, "QBD", None, core.KB_SHEET],
|
||||
)
|
||||
self.assertEqual(payload["records"][3]["kb_amount"], 100)
|
||||
self.assertFalse(
|
||||
any(
|
||||
path.suffix.lower() == ".xlsx"
|
||||
and path.name != result["outputs"]["daily_report"]
|
||||
for path in output_dir.iterdir()
|
||||
)
|
||||
)
|
||||
core.validate_structured_completeness(payload)
|
||||
|
||||
errors = validator.validate(
|
||||
argparse.Namespace(
|
||||
mode="daily",
|
||||
xml=str(xml_path.resolve()),
|
||||
daily=str((output_dir / result["outputs"]["daily_report"]).resolve()),
|
||||
monthly=None,
|
||||
result_json=str((output_dir / "result.json").resolve()),
|
||||
structured_result_json=str(
|
||||
(output_dir / "structured-result.json").resolve()
|
||||
),
|
||||
price_reference=str(core.PRICE_REFERENCE.resolve()),
|
||||
)
|
||||
)
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
def test_daily_mode_rejects_monthly_base_without_creating_formal_outputs(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
monthly_base = SKILL_ROOT / "assets" / "channel-report-template.xlsx"
|
||||
exit_code, _xml_path, output_dir, result, payload = run_processor(
|
||||
self.success_xml(), root, monthly_base=monthly_base, mode="daily"
|
||||
)
|
||||
|
||||
self.assertEqual(exit_code, 3)
|
||||
self.assertEqual(result["version"], core.DAILY_RESULT_VERSION)
|
||||
self.assertEqual(result["processing_mode"], "daily")
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertIn("INPUT_MONTHLY_FORBIDDEN", {item["code"] for item in result["errors"]})
|
||||
self.assertIsNone(payload["artifacts"]["daily_report"])
|
||||
self.assertIsNone(payload["artifacts"]["monthly_report"])
|
||||
self.assertFalse(payload["activation_eligible"])
|
||||
self.assertFalse(
|
||||
any(
|
||||
path.suffix.lower() == ".xlsx" and path.name != "异常清单.xlsx"
|
||||
for path in output_dir.iterdir()
|
||||
)
|
||||
)
|
||||
|
||||
def test_daily_validator_rejects_channel_tampering(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
exit_code, xml_path, output_dir, result, payload = run_processor(
|
||||
self.success_xml(), Path(temp_dir), mode="daily"
|
||||
)
|
||||
self.assertEqual(exit_code, 0)
|
||||
structured_path = output_dir / "structured-result.json"
|
||||
payload["records"][1]["channel_key"] = "FENGRUN"
|
||||
structured_path.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
errors = validator.validate(
|
||||
argparse.Namespace(
|
||||
mode="daily",
|
||||
xml=str(xml_path.resolve()),
|
||||
daily=str((output_dir / result["outputs"]["daily_report"]).resolve()),
|
||||
monthly=None,
|
||||
result_json=str((output_dir / "result.json").resolve()),
|
||||
structured_result_json=str(structured_path.resolve()),
|
||||
price_reference=str(core.PRICE_REFERENCE.resolve()),
|
||||
)
|
||||
)
|
||||
self.assertIn(
|
||||
"OUTPUT_STRUCTURED_RECORD_MISMATCH",
|
||||
{error.code for error in errors},
|
||||
)
|
||||
|
||||
def test_price_failure_keeps_price_unmatched_and_excluded_rows_but_no_formal_outputs(self):
|
||||
failure_xml = xml_document(
|
||||
reservation(1, rate_code="GRPA4", rate_amount="1800", res_comment="GROUP-FAIL"),
|
||||
reservation(2, rate_code="NOT-ALLOWED", rate_amount="INVALID"),
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
exit_code, _xml_path, output_dir, result, payload = run_processor(
|
||||
failure_xml, Path(temp_dir)
|
||||
)
|
||||
self.assertEqual(exit_code, 2)
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertFalse(payload["activation_eligible"])
|
||||
self.assertEqual(payload["output_rows"], 0)
|
||||
self.assertEqual(
|
||||
[row["outcome"] for row in payload["records"]],
|
||||
["price_unmatched", "excluded_rate_code"],
|
||||
)
|
||||
self.assertIn("PRICE_UNMATCHED", payload["records"][0]["decision_codes"])
|
||||
self.assertIsNone(payload["artifacts"]["daily_report"])
|
||||
self.assertIsNone(payload["artifacts"]["monthly_report"])
|
||||
self.assertIsNotNone(payload["artifacts"]["exception_report"])
|
||||
self.assertFalse(any(path.suffix == ".xlsx" and path.name != "异常清单.xlsx" for path in output_dir.iterdir()))
|
||||
core.validate_structured_completeness(payload)
|
||||
|
||||
def test_row_validation_failure_marks_other_pending_rows_not_validated(self):
|
||||
failure_xml = xml_document(
|
||||
reservation(1, full_name=""),
|
||||
reservation(2, res_comment="VALID-GROUP"),
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
exit_code, _xml_path, _output_dir, _result, payload = run_processor(
|
||||
failure_xml, Path(temp_dir)
|
||||
)
|
||||
self.assertEqual(exit_code, 2)
|
||||
self.assertEqual(
|
||||
[row["outcome"] for row in payload["records"]],
|
||||
["validation_failed", "validation_failed"],
|
||||
)
|
||||
self.assertIn("XML_FULL_NAME_MISSING", payload["records"][0]["decision_codes"])
|
||||
self.assertIn("BATCH_NOT_VALIDATED", payload["records"][1]["decision_codes"])
|
||||
self.assertEqual(payload["outcome_counts"]["validation_failed"], 2)
|
||||
self.assertEqual(payload["output_rows"], 0)
|
||||
core.validate_structured_completeness(payload)
|
||||
|
||||
def test_zero_night_rows_are_retained_in_daily_monthly_and_structured_outputs(self):
|
||||
zero_night_xml = xml_document(
|
||||
reservation(1, departure="2026-07-27", res_comment="ZERO-NIGHT-QBD"),
|
||||
reservation(
|
||||
2,
|
||||
company="T- Rainbow Holiday Service",
|
||||
rate_code="LBMS",
|
||||
rate_amount="98765",
|
||||
departure="2026-07-27",
|
||||
res_comment="ZERO-NIGHT-KB",
|
||||
),
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
first_root = root / "first"
|
||||
second_root = root / "second"
|
||||
first_root.mkdir()
|
||||
second_root.mkdir()
|
||||
exit_code, _xml_path, output_dir, result, payload = run_processor(
|
||||
zero_night_xml, first_root
|
||||
)
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertTrue(payload["activation_eligible"])
|
||||
self.assertEqual(
|
||||
[record["outcome"] for record in payload["records"]],
|
||||
["retained", "retained"],
|
||||
)
|
||||
self.assertEqual([record["nights"] for record in payload["records"]], [0, 0])
|
||||
self.assertEqual([record["total_price"] for record in payload["records"]], [0, 0])
|
||||
self.assertEqual(payload["records"][0]["real_price"], 1800)
|
||||
self.assertEqual(payload["records"][1]["real_price"], 0)
|
||||
self.assertEqual(payload["records"][1]["kb_amount"], 100)
|
||||
|
||||
daily = load_workbook(output_dir / result["outputs"]["daily_report"], data_only=False)
|
||||
try:
|
||||
nights_col = core.DAILY_HEADERS.index("NIGHTS") + 1
|
||||
total_col = core.DAILY_HEADERS.index("TOTAL PRICE") + 1
|
||||
self.assertEqual(
|
||||
[daily.active.cell(row, nights_col).value for row in (2, 3)],
|
||||
[0, 0],
|
||||
)
|
||||
self.assertEqual(
|
||||
[daily.active.cell(row, total_col).value for row in (2, 3)],
|
||||
[0, 0],
|
||||
)
|
||||
finally:
|
||||
daily.close()
|
||||
|
||||
monthly_path = output_dir / result["outputs"]["monthly_report"]
|
||||
monthly = load_workbook(monthly_path, data_only=False)
|
||||
try:
|
||||
self.assertEqual(monthly["QBD"].cell(2, 3).value, 0)
|
||||
self.assertEqual(monthly["QBD"].cell(2, 19).value, 0)
|
||||
self.assertEqual(monthly[core.KB_SHEET].cell(2, 3).value, 0)
|
||||
self.assertEqual(monthly[core.KB_SHEET].cell(2, 19).value, 0)
|
||||
self.assertEqual(monthly[core.KB_SHEET].cell(2, 20).value, 100)
|
||||
finally:
|
||||
monthly.close()
|
||||
|
||||
rerun = run_processor(zero_night_xml, second_root, monthly_base=monthly_path)
|
||||
self.assertEqual(rerun[0], 0)
|
||||
self.assertTrue(rerun[4]["activation_eligible"])
|
||||
|
||||
def test_negative_night_remains_a_validation_failure(self):
|
||||
negative_night_xml = xml_document(
|
||||
reservation(1, departure="2026-07-26", res_comment="NEGATIVE-NIGHT")
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
exit_code, _xml_path, _output_dir, _result, payload = run_processor(
|
||||
negative_night_xml, Path(temp_dir)
|
||||
)
|
||||
self.assertEqual(exit_code, 2)
|
||||
self.assertFalse(payload["activation_eligible"])
|
||||
self.assertEqual(payload["records"][0]["outcome"], "validation_failed")
|
||||
self.assertEqual(payload["records"][0]["nights"], -1)
|
||||
self.assertIn("XML_NEGATIVE_NIGHTS", payload["records"][0]["decision_codes"])
|
||||
|
||||
def test_independent_validator_rejects_negative_nights(self):
|
||||
zero_night_xml = xml_document(
|
||||
reservation(1, departure="2026-07-27", res_comment="VALIDATOR-ZERO-NIGHT")
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
exit_code, xml_path, output_dir, result, _payload = run_processor(
|
||||
zero_night_xml, Path(temp_dir)
|
||||
)
|
||||
self.assertEqual(exit_code, 0)
|
||||
daily_path = output_dir / result["outputs"]["daily_report"]
|
||||
daily = load_workbook(daily_path, data_only=False)
|
||||
try:
|
||||
departure_col = core.DAILY_HEADERS.index("DEPARTURE") + 1
|
||||
nights_col = core.DAILY_HEADERS.index("NIGHTS") + 1
|
||||
daily.active.cell(2, departure_col).value = date(2026, 7, 26)
|
||||
daily.active.cell(2, nights_col).value = -1
|
||||
daily.save(daily_path)
|
||||
finally:
|
||||
daily.close()
|
||||
|
||||
errors = validator.validate(
|
||||
argparse.Namespace(
|
||||
xml=str(xml_path.resolve()),
|
||||
daily=str(daily_path.resolve()),
|
||||
monthly=str((output_dir / result["outputs"]["monthly_report"]).resolve()),
|
||||
result_json=str((output_dir / "result.json").resolve()),
|
||||
structured_result_json=str((output_dir / "structured-result.json").resolve()),
|
||||
price_reference=str(core.PRICE_REFERENCE.resolve()),
|
||||
)
|
||||
)
|
||||
self.assertIn("OUTPUT_NIGHTS_MISMATCH", {error.code for error in errors})
|
||||
|
||||
def test_independent_validator_rejects_structured_artifact_hash_tampering(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
exit_code, xml_path, output_dir, result, payload = run_processor(
|
||||
self.success_xml(), Path(temp_dir)
|
||||
)
|
||||
self.assertEqual(exit_code, 0)
|
||||
structured_path = output_dir / "structured-result.json"
|
||||
payload["artifacts"]["daily_report"]["sha256"] = "0" * 64
|
||||
structured_path.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
errors = validator.validate(
|
||||
argparse.Namespace(
|
||||
xml=str(xml_path.resolve()),
|
||||
daily=str((output_dir / result["outputs"]["daily_report"]).resolve()),
|
||||
monthly=str((output_dir / result["outputs"]["monthly_report"]).resolve()),
|
||||
result_json=str((output_dir / "result.json").resolve()),
|
||||
structured_result_json=str(structured_path.resolve()),
|
||||
price_reference=str(core.PRICE_REFERENCE.resolve()),
|
||||
)
|
||||
)
|
||||
self.assertIn(
|
||||
"OUTPUT_STRUCTURED_ARTIFACT_MISMATCH", {error.code for error in errors}
|
||||
)
|
||||
|
||||
def test_schema_is_strict_and_covers_finance_contract_fields(self):
|
||||
schema = json.loads(
|
||||
(SKILL_ROOT / "references" / "structured-result.schema.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
self.assertFalse(schema["additionalProperties"])
|
||||
self.assertEqual(
|
||||
schema["properties"]["result_schema_version"]["enum"], ["1.0", "2.0"]
|
||||
)
|
||||
self.assertEqual(schema["properties"]["processing_mode"]["const"], "daily")
|
||||
daily_condition = next(
|
||||
condition
|
||||
for condition in schema["allOf"]
|
||||
if condition.get("if", {})
|
||||
.get("properties", {})
|
||||
.get("result_schema_version", {})
|
||||
.get("const")
|
||||
== "2.0"
|
||||
)
|
||||
self.assertEqual(
|
||||
daily_condition["then"]["properties"]["artifacts"]["properties"][
|
||||
"monthly_report"
|
||||
],
|
||||
{"type": "null"},
|
||||
)
|
||||
required = set(schema["required"])
|
||||
self.assertTrue(
|
||||
{
|
||||
"business_date",
|
||||
"processor_version",
|
||||
"rule_set_sha256",
|
||||
"result_schema_version",
|
||||
"source_rows",
|
||||
"removed_by_rate_code",
|
||||
"removed_as_duplicates",
|
||||
"output_rows",
|
||||
"channels",
|
||||
"artifacts",
|
||||
"records",
|
||||
}.issubset(required)
|
||||
)
|
||||
record_schema = schema["$defs"]["record"]
|
||||
self.assertFalse(record_schema["additionalProperties"])
|
||||
retained_properties = record_schema["allOf"][1]["then"]["properties"]
|
||||
self.assertEqual(retained_properties["nights"]["minimum"], 0)
|
||||
for field in (
|
||||
"source_sequence",
|
||||
"source_location",
|
||||
"source_worksheet",
|
||||
"source_row_no",
|
||||
"outcome",
|
||||
"decision_codes",
|
||||
"duplicate_of_source_sequence",
|
||||
"effective_rate_amount",
|
||||
"room_category_label",
|
||||
"real_price",
|
||||
"total_price",
|
||||
"kb_amount",
|
||||
"channel_key",
|
||||
"pricing_method",
|
||||
"normalized_rate_code",
|
||||
"group_code_key",
|
||||
"company_key",
|
||||
):
|
||||
self.assertIn(field, record_schema["required"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user