#!/usr/bin/env python3 from __future__ import annotations import argparse import json import sys from pathlib import Path from typing import Any from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen DEFAULT_FORM_FILE = "" ROUTES: list[dict[str, Any]] = [ { "name": "form_attribute_title", "selector": {"element": "А"}, "value": "SMOKE_A_TITLE", "expected_target_section": "items", "expected_effective_section": "attributes", "expected_source_kind": "data_path_form_attribute_title", "expected_write_path": "3.3.4.2.1", }, { "name": "tabular_attribute_field_title", "selector": {"element": "ТЗК1"}, "value": "SMOKE_K1_TITLE", "expected_target_section": "items", "expected_effective_section": "attribute_fields", "expected_source_kind": "data_path_form_attribute_field_title", "expected_write_path": "3.6.14.4.2.1", }, { "name": "command_title", "selector": {"command": "КомандаПример1"}, "value": "SMOKE_COMMAND_TITLE", "expected_target_section": "commands", "expected_effective_section": "commands", "expected_source_kind": "local", "expected_write_path": "5.3.3.2.1", }, ] def request_json( method: str, base_url: str, path: str, *, payload: dict[str, Any] | None = None, timeout: float = 30.0, ) -> tuple[int, dict[str, Any] | str, bool]: body = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None request = Request( f"{base_url.rstrip('/')}{path}", data=body, method=method, headers={"Content-Type": "application/json"}, ) try: with urlopen(request, timeout=timeout) as response: raw = response.read().decode("utf-8") return response.status, json.loads(raw) if raw else {}, False except HTTPError as exc: raw = exc.read().decode("utf-8") return exc.code, json.loads(raw) if raw else {}, True except URLError as exc: return 0, str(exc), True def rpc(base_url: str, method: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]: status, response, is_error = request_json( "POST", base_url, "/rpc", payload={"method": method, "payload": payload}, timeout=timeout, ) if is_error or status != 200 or not isinstance(response, dict): raise AssertionError(f"{method} failed: status={status}, response={response}") return response def assert_equal(actual: Any, expected: Any, label: str) -> None: if actual != expected: raise AssertionError(f"{label}: expected {expected!r}, got {actual!r}") def check_route( *, base_url: str, base_id: str, table: str, file_name: str, route: dict[str, Any], timeout: float, ) -> dict[str, Any]: property_name = str(route.get("property") or "Заголовок") common = { "base_id": base_id, "table": table, "file_name": file_name, "property": property_name, "include_storage": True, "timeout_seconds": int(timeout), } resolve_payload = { **common, **route["selector"], "value": route["value"], } resolved = rpc(base_url, "metadata.form.write_target.resolve", resolve_payload, timeout) assert_equal(resolved.get("status"), "ok", f"{route['name']} resolve status") if route.get("expected_target_section"): assert_equal((resolved.get("target") or {}).get("section"), route["expected_target_section"], f"{route['name']} target section") if route.get("expected_effective_section"): assert_equal((resolved.get("effective_target") or {}).get("section"), route["expected_effective_section"], f"{route['name']} effective section") source_kind = (resolved.get("effective_source") or {}).get("kind") or (resolved.get("display") or {}).get("source_kind") if route.get("expected_source_kind"): assert_equal(source_kind, route["expected_source_kind"], f"{route['name']} source kind") if route.get("expected_write_path"): assert_equal((resolved.get("write_target") or {}).get("path"), route["expected_write_path"], f"{route['name']} write path") write_payload = { "base_id": base_id, "target": { "kind": "form", "table": table, "file_name": file_name, **route["selector"], }, "mode": "apply_and_rollback", "edits": [{"property": property_name, "value": route["value"]}], "allow_sql_saved_state_apply": True, "allow_sql_saved_state_rollback": True, "timeout_seconds": int(timeout), } written = rpc(base_url, "metadata.write", write_payload, timeout) assert_equal(written.get("status"), "verified_and_rolled_back", f"{route['name']} write status") result = written.get("result") if isinstance(written.get("result"), dict) else {} write_plan = result.get("write_plan") if isinstance(result.get("write_plan"), dict) else {} assert_equal(write_plan.get("allowed"), True, f"{route['name']} write plan allowed") assert_equal((write_plan.get("route") or {}).get("apply_method"), "metadata.form.element.write_apply", f"{route['name']} write plan apply method") assert_equal(result.get("rolled_back"), True, f"{route['name']} rolled_back") verification = ((result.get("apply_result") or {}).get("semantic_verification") or {}) assert_equal(verification.get("status"), "ok", f"{route['name']} semantic verification") for check in verification.get("checks") or []: assert_equal(check.get("ok"), True, f"{route['name']} semantic check") after = rpc(base_url, "metadata.form.write_target.resolve", common | route["selector"], timeout) assert_equal(after.get("status"), "ok", f"{route['name']} post-rollback resolve status") if route.get("expected_write_path"): assert_equal((after.get("write_target") or {}).get("path"), route["expected_write_path"], f"{route['name']} post-rollback write path") return { "name": route["name"], "status": "ok", "target_section": (resolved.get("target") or {}).get("section"), "effective_section": (resolved.get("effective_target") or {}).get("section"), "source_kind": source_kind, "write_path": (resolved.get("write_target") or {}).get("path"), "old": (resolved.get("write_target") or {}).get("old"), "test_value": route["value"], "post_rollback_old": (after.get("write_target") or {}).get("old"), "property": property_name, "write_plan": { "status": write_plan.get("status"), "allowed": write_plan.get("allowed"), "apply_method": (write_plan.get("route") or {}).get("apply_method"), "target_kind": (write_plan.get("target") or {}).get("target_kind"), }, } def route_from_matrix_entry(entry: dict[str, Any], index: int) -> dict[str, Any] | None: prop = entry.get("property") if isinstance(entry.get("property"), dict) else {} selector = entry.get("selector") if isinstance(entry.get("selector"), dict) else {} test_value = prop.get("test_value") if not selector or test_value is None: return None property_name = str(prop.get("presentation") or prop.get("property") or prop.get("canonical_property") or "").strip() if not property_name: return None return { "name": f"matrix_safe_{index}", "selector": selector, "property": property_name, "value": test_value, "expected_target_section": ((entry.get("requested_target") or {}) if isinstance(entry.get("requested_target"), dict) else {}).get("section"), "expected_effective_section": ((entry.get("effective_target") or {}) if isinstance(entry.get("effective_target"), dict) else {}).get("section"), "expected_source_kind": ((entry.get("effective_source") or {}) if isinstance(entry.get("effective_source"), dict) else {}).get("kind"), "expected_write_path": prop.get("write_path"), } def discover_matrix_smoke_routes( base_url: str, base_id: str, table: str, file_name: str, timeout: float, *, limit: int = 1, ) -> tuple[list[dict[str, Any]], dict[str, Any]]: matrix = rpc( base_url, "metadata.form.write_matrix.build", { "base_id": base_id, "table": table, "file_name": file_name, "timeout_seconds": int(timeout), }, timeout, ) if matrix.get("status") != "ok": raise AssertionError(f"write matrix build failed: {matrix.get('status')}") routes: list[dict[str, Any]] = [] for entry in matrix.get("entries") or []: if not isinstance(entry, dict) or not entry.get("can_smoke"): continue route = route_from_matrix_entry(entry, len(routes) + 1) if route is None: continue routes.append(route) if len(routes) >= limit: break if not routes: raise AssertionError("no write-matrix smoke-safe route candidates found") return routes, {"status": matrix.get("status"), "counts": matrix.get("counts"), "source": "metadata.form.write_matrix.build"} def discover_smoke_routes(base_url: str, base_id: str, table: str, timeout: float) -> tuple[str, list[dict[str, Any]], dict[str, Any]]: saved_state = rpc( base_url, "metadata.saved_state.forms.search", { "base_id": base_id, "tables": [table], "limit": 20, "scan_limit": 100, "include_storage": True, "timeout_seconds": int(timeout), }, timeout, ) if saved_state.get("status") != "ok": raise AssertionError(f"saved-state forms search failed: {saved_state.get('status')}") routes: list[dict[str, Any]] = [] selected_file_name = "" for form in saved_state.get("forms") or []: source = form.get("source") if isinstance(form.get("source"), dict) else {} file_name = str(source.get("file_name") or form.get("file_name") or "").strip() if not file_name: continue for match in form.get("matches") or []: if not isinstance(match, dict): continue element_name = str(match.get("name") or match.get("title") or "").strip() if not element_name: continue for prop in match.get("writable_properties") or []: if not isinstance(prop, dict): continue prop_name = str(prop.get("property") or "").lower() presentation = str(prop.get("presentation") or "").lower() if prop_name != "title" and presentation != "заголовок": continue selected_file_name = file_name routes.append( { "name": f"auto_title_{len(routes) + 1}", "selector": {"element": element_name}, "value": f"SMOKE_AUTO_TITLE_{len(routes) + 1}", "expected_write_path": prop.get("path"), } ) break if routes: break if routes: break if not routes or not selected_file_name: raise AssertionError("no saved-state form title write route candidates found") return selected_file_name, routes, {"status": saved_state.get("status"), "counts": saved_state.get("counts")} def main() -> int: parser = argparse.ArgumentParser(description="Smoke test saved-state form write routing with apply_and_rollback.") parser.add_argument("--base-url", default="http://docker-gpu.cin.su:8011", help="1C REST adapter URL.") parser.add_argument("--base-id", default="upo_test", help="Configured adapter base id.") parser.add_argument("--table", default="ConfigCASSave", help="Saved-state SQL table.") parser.add_argument("--file-name", default=DEFAULT_FORM_FILE, help="Saved-state form file name. Omit to auto-select a safe saved-state form title route.") parser.add_argument("--timeout", type=float, default=30.0, help="Request timeout in seconds.") parser.add_argument("--report", type=Path, help="Optional JSON report path.") parser.add_argument("--allow-empty-saved-state", action="store_true", help="Exit successfully with skipped status when the saved-state table has no forms.") args = parser.parse_args() report: dict[str, Any] = { "schema": "onec_saved_state_write_routes_smoke.v1", "base_url": args.base_url, "base_id": args.base_id, "table": args.table, "file_name": args.file_name, "routes": [], "passed": False, "status": "pending", } try: status, health, is_error = request_json("GET", args.base_url, f"/health?base_id={args.base_id}", timeout=args.timeout) if is_error or status != 200 or not isinstance(health, dict) or health.get("status") != "ok": raise AssertionError(f"health failed: status={status}, response={health}") report["health"] = {"status": health.get("status"), "live_sql": health.get("live_sql")} if args.allow_empty_saved_state: saved_state = rpc( args.base_url, "metadata.saved_state.forms.search", {"base_id": args.base_id, "tables": [args.table], "limit": 1, "timeout_seconds": int(args.timeout)}, args.timeout, ) report["saved_state_preflight"] = { "status": saved_state.get("status"), "counts": saved_state.get("counts"), } if saved_state.get("status") == "ok" and ((saved_state.get("counts") or {}).get("forms") or 0) == 0: report["status"] = "skipped_no_saved_state" report["skipped"] = True report["passed"] = True if args.report: args.report.parent.mkdir(parents=True, exist_ok=True) args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(json.dumps(report, ensure_ascii=False, indent=2)) return 0 smoke_file_name = args.file_name smoke_routes = ROUTES if not smoke_file_name: smoke_file_name, smoke_routes, discovery = discover_smoke_routes(args.base_url, args.base_id, args.table, args.timeout) report["file_name"] = smoke_file_name report["route_discovery"] = discovery report["saved_state_preflight"] = discovery else: smoke_routes, discovery = discover_matrix_smoke_routes(args.base_url, args.base_id, args.table, smoke_file_name, args.timeout) report["route_discovery"] = discovery for route in smoke_routes: report["routes"].append( check_route( base_url=args.base_url, base_id=args.base_id, table=args.table, file_name=smoke_file_name, route=route, timeout=args.timeout, ) ) report["passed"] = True report["status"] = "verified_and_rolled_back" except Exception as exc: report["error"] = str(exc) if args.report: args.report.parent.mkdir(parents=True, exist_ok=True) args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(json.dumps(report, ensure_ascii=False, indent=2), file=sys.stderr) return 1 if args.report: args.report.parent.mkdir(parents=True, exist_ok=True) args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(json.dumps(report, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())