381 lines
16 KiB
Python
381 lines
16 KiB
Python
#!/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.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())
|