"""Single-profile, read-only summary probe; not an accepted ARR name mapping. The caller owns the authorized sample budget. This module performs exactly one HTTP attempt, with no broad search, pagination, retry or profile-detail fallback. Raw names/IDs and the returned assessment are private data, not log messages. """ from __future__ import annotations from dataclasses import dataclass, field import re from urllib.parse import urlencode from . import collect_arr_source as source OPERATION = "searchProfiles" PATH = "/api/v1/profiles/searches" GET_OPERATION = "getProfiles" GET_PATH = "/api/v1/profiles" VERSION = "arr-profile-summary-probe/v1" COMPONENTS = {"surname": "name", "givenName": "givenName", "middleName": "middleName", "nameTitle": "nameTitle"} require = source.require @dataclass(frozen=True) class PrimaryGuest: profile_id: str = field(repr=False) name: dict = field(repr=False) def _profile_id(identifiers): require(type(identifiers) is list and all(type(item) is dict for item in identifiers), "profile_invalid_identity_list") require(all(type(item.get("type")) is str for item in identifiers), "profile_invalid_identity_type") matches = [item.get("id") for item in identifiers if item["type"] == "Profile"] require(len(matches) == 1 and type(matches[0]) is str and bool(re.fullmatch(r"[A-Za-z0-9_-]{1,128}", matches[0])), "profile_ambiguous_identity") return matches[0] def primary_guest(detail: dict, *, include_prefix: bool = False) -> PrimaryGuest: source.reservation_id(detail) guests = detail.get("reservationGuests") require("reservationGuests" in detail, "profile_missing_guest_list") require(type(guests) is list and all(type(item) is dict for item in guests), "profile_invalid_guest_list") require(all("primary" not in guest or type(guest["primary"]) is bool for guest in guests), "profile_invalid_primary_flag") primary = [guest for guest in guests if guest.get("primary") is True] require(len(primary) == 1, "profile_ambiguous_primary_guest") info = primary[0].get("profileInfo") require("profileInfo" in primary[0], "profile_missing_primary_info") require(type(info) is dict, "profile_invalid_primary_info") identity = _profile_id(info.get("profileIdList")) profile = info.get("profile") require("profile" in info, "profile_missing_person_names") require(type(profile) is dict, "profile_invalid_primary_profile") require("customer" in profile, "profile_missing_person_names") customer = profile["customer"] require(type(customer) is dict, "profile_invalid_primary_customer") require("personName" in customer, "profile_missing_person_names") names = customer["personName"] require(type(names) is list and all(type(item) is dict for item in names), "profile_invalid_person_names") require(all("nameType" not in name or type(name["nameType"]) is str for name in names), "profile_invalid_primary_name_type") selected = [name for name in names if name.get("nameType") == "Primary"] require(len(selected) == 1, "profile_ambiguous_primary_name") name = selected[0] components = {**COMPONENTS, **({"namePrefix": "namePrefix"} if include_prefix else {})} require(all(key not in name or type(name[key]) is str for key in components), "profile_invalid_name_component") return PrimaryGuest(identity, {key: name[key] for key in components if key in name}) def request(profile_id: str) -> tuple[str, str, bytes]: _profile_id([{"type": "Profile", "id": profile_id}]) return "POST", PATH, source.json_bytes({"profileIds": [profile_id], "summaryInfo": True, "limit": 1, "offset": 0}) def get_request(profile_id: str) -> tuple[str, str, None]: """Exact-ID summary read supported by the production GET contract.""" _profile_id([{"type": "Profile", "id": profile_id}]) query = urlencode({"profileIds": profile_id, "summaryInfo": "true", "limit": 1, "offset": 0}) return "GET", GET_PATH + "?" + query, None def summary_row(raw: bytes, profile_id: str, hotel_id: str, *, operation: str = OPERATION) -> dict: """Validate response identity/coverage independently of available name fields.""" require(operation in (OPERATION, GET_OPERATION), "profile_invalid_operation") _profile_id([{"type": "Profile", "id": profile_id}]) require(type(raw) is bytes and len(raw) <= source.MAX_RESPONSE_BYTES, "profile_response_too_large") document = source.strict_json(raw) require(document.get("operation_id") == operation and document.get("hotel_id") == hotel_id, "profile_response_context_mismatch") require(type(document.get("oracle_request_id")) is str and bool(document["oracle_request_id"].strip()), "profile_missing_oracle_request_id") source.check_warnings(document) data = document.get("data") summaries = data.get("profileSummaries") if type(data) is dict else None require(type(summaries) is dict, "profile_missing_summary") if "hasMore" in summaries: require(summaries["hasMore"] is False, "profile_unexpected_more_results") for field in ("limit", "count", "totalResults", "totalPages"): if field in summaries: require(type(summaries[field]) is int and summaries[field] == 1, "profile_inconsistent_pagination") if "offset" in summaries: # Oracle's schema describes an initial offset; the real single-row # samples return1. Both supported values are bounded to this one row; # no broader pagination convention is inferred from these samples. require(type(summaries["offset"]) is int and summaries["offset"] in (0, 1), "profile_inconsistent_pagination") rows = summaries.get("profileInfo") require(type(rows) is list and len(rows) == 1 and type(rows[0]) is dict, "profile_ambiguous_summary_count") row = rows[0] require(_profile_id(row.get("profileIdList")) == profile_id, "profile_identity_mismatch") if "profile" in row: require(type(row["profile"]) is dict, "profile_invalid_summary_profile") if "formerName" in row["profile"]: require(type(row["profile"]["formerName"]) is dict, "profile_invalid_former_name") return row def inspect_summary(raw: bytes, guest: PrimaryGuest, hotel_id: str, *, include_prefix: bool = False, operation: str = OPERATION) -> dict: require(type(guest) is PrimaryGuest, "profile_primary_guest_required") row = summary_row(raw, guest.profile_id, hotel_id, operation=operation) profile = row.get("profile") require(type(profile) is dict, "profile_missing_summary_profile") name = profile.get("formerName") require(type(name) is dict, "profile_missing_former_name") components = {**COMPONENTS, **({"namePrefix": "namePrefix"} if include_prefix else {})} require(all(key not in name or type(name[key]) is str for key in (*components.values(), "fullName", "nameType")), "profile_invalid_summary_name") comparison = {} for detail_key, summary_key in components.items(): if detail_key not in guest.name: comparison[detail_key] = "both_absent" if summary_key not in name else "summary_only" elif summary_key not in name: comparison[detail_key] = "summary_missing" else: comparison[detail_key] = "equal" if guest.name[detail_key] == name[summary_key] else "different" full_name = name.get("fullName") return {"version": VERSION, "identity_matched": True, "profile_id": guest.profile_id, "name_type": name.get("nameType"), "component_comparisons": comparison, "full_name_state": "missing" if full_name is None else "present" if full_name.strip() else "explicit_blank", "full_name": full_name, "alt_name_returned": "altName" in profile, "report_equivalence_verified": False, "finance_ready": False} def read_once(guest: PrimaryGuest, hotel_id: str, transport, archive: source.Archive, index: int, *, key: str = "") -> dict: """One exact-ID read; capture the original response before interpreting it.""" require(type(index) is int and 1 <= index <= 3, "profile_probe_index_out_of_bounds") source.Options("2000-01-01", hotel_id).validate() require(type(guest) is PrimaryGuest, "profile_primary_guest_required") method, path, payload = request(guest.profile_id) prefix = f"profile-{index:02d}" archive.write(prefix + ".request.json", {"operation_id": OPERATION, "method": method, "path": path, "hotel_id": hotel_id, "body": source.strict_json(payload), "started_at": source.utc_now()}) try: status, headers, raw = transport(method, path, payload) except (OSError, TimeoutError, ConnectionError): archive.write(prefix + ".meta.json", {"error": "transport_failure", "ended_at": source.utc_now()}) raise source.CollectionError("profile_transport_failure") from None require(not key or key.encode() not in raw, "secret_in_response") archive.write(prefix + ".response.bin", raw) headers = {key.lower(): value for key, value in headers.items()} archive.write(prefix + ".meta.json", {"http_status": status, "edge_request_id": headers.get("x-request-id"), "ended_at": source.utc_now(), "oversized": len(raw) > source.MAX_RESPONSE_BYTES}) require(status == 200, "profile_http_failure") result = inspect_summary(raw, guest, hotel_id) archive.write(prefix + ".assessment.json", result) return result