import json from copy import deepcopy import pytest from pydantic import ValidationError from ai_platform.presentation.http.schemas import RunRequest def test_snapshot_preserves_known_omni_metadata_and_array_order(valid_run_payload): request = RunRequest.model_validate(valid_run_payload) snapshot = request.snapshot assert snapshot.version == "1.0.0" assert snapshot.scope == "linked_contact_history" assert snapshot.identity_context is not None assert ( snapshot.identity_context.model_dump() == valid_run_payload["snapshot"]["identity_context"] ) assert snapshot.trigger_metadata is not None assert ( snapshot.trigger_metadata.model_dump() == valid_run_payload["snapshot"]["trigger_metadata"] ) assert snapshot.conversations[0].channel == "webchat" assert [message.message_id for message in snapshot.conversations[0].messages] == [ "message-2", "message-1", ] def test_message_order_remains_significant_to_canonical_fingerprint(valid_run_payload): original = RunRequest.model_validate(valid_run_payload).model_dump(mode="json") reordered_payload = deepcopy(valid_run_payload) reordered_payload["snapshot"]["conversations"][0]["messages"].reverse() reordered = RunRequest.model_validate(reordered_payload).model_dump(mode="json") assert json.dumps(original, sort_keys=True, separators=(",", ":")) != json.dumps( reordered, sort_keys=True, separators=(",", ":") ) def test_conversation_order_is_preserved(valid_run_payload): conversations = valid_run_payload["snapshot"]["conversations"] conversations.append( { "conversation_id": "conversation-1", "messages": [ { "message_id": "older-message", "sender_type": "contact", "content": "Earlier in time, later in the array.", "sent_at": "2025-12-30T10:00:00Z", } ], } ) snapshot = RunRequest.model_validate(valid_run_payload).snapshot assert [conversation.conversation_id for conversation in snapshot.conversations] == [ "conversation-2", "conversation-1", ] @pytest.mark.parametrize( ("change_snapshot", "error"), [ ( lambda snapshot: snapshot.update( conversations=[ {"conversation_id": f"conversation-{index}", "messages": []} for index in range(11) ] ), "10", ), ( lambda snapshot: snapshot["conversations"][0].update( messages=[ { "message_id": f"message-{index}", "sender_type": "contact", "content": "x", "sent_at": "2025-12-31T10:00:00Z", } for index in range(51) ] ), "50", ), ( lambda snapshot: snapshot["conversations"][0]["messages"][0].update( content="x" * 20_001 ), "20000", ), ( lambda snapshot: snapshot["conversations"][0]["messages"][0].update( sent_at="2025-12-31T10:00:00" ), "timezone-aware", ), ( lambda snapshot: snapshot["conversations"][0]["messages"][0].update( sent_at="2025-09-30T12:00:00Z" ), "90-day", ), ], ) def test_snapshot_rejects_invalid_evidence(valid_run_payload, change_snapshot, error): data = deepcopy(valid_run_payload) change_snapshot(data["snapshot"]) with pytest.raises(ValidationError, match=error): RunRequest.model_validate(data) @pytest.mark.parametrize( ("target", "extra"), [ ("snapshot", "unsupported_snapshot_field"), ("lead_profile", "unsupported_profile_field"), ("conversation", "unsupported_conversation_field"), ("message", "unsupported_message_field"), ], ) def test_snapshot_rejects_unmodeled_fields(valid_run_payload, target, extra): snapshot = deepcopy(valid_run_payload)["snapshot"] if target == "snapshot": snapshot[extra] = "not silently discarded" elif target == "lead_profile": snapshot["lead_profile"][extra] = "not silently discarded" elif target == "conversation": snapshot["conversations"][0][extra] = "not silently discarded" else: snapshot["conversations"][0]["messages"][0][extra] = "not silently discarded" with pytest.raises(ValidationError, match="Extra inputs are not permitted"): RunRequest.model_validate({**valid_run_payload, "snapshot": snapshot}) def test_snapshot_rejects_naive_capture_timestamp(valid_run_payload): data = deepcopy(valid_run_payload) data["snapshot"]["captured_at"] = "2026-01-01T12:00:00" with pytest.raises(ValidationError, match="timezone-aware"): RunRequest.model_validate(data)