|
4 | 4 | from datetime import datetime, timedelta, timezone |
5 | 5 | from pathlib import Path |
6 | 6 | from typing import ClassVar |
7 | | -from unittest.mock import MagicMock |
| 7 | +from unittest.mock import AsyncMock, MagicMock, patch |
8 | 8 |
|
9 | 9 | import grpc |
10 | 10 | import pytest |
| 11 | +import pytest_asyncio |
11 | 12 | from grpc import aio as aiogrpc |
12 | 13 |
|
13 | 14 | from sift_client._internal.low_level_wrappers.test_results import TestResultsLowLevelClient |
@@ -770,3 +771,342 @@ def updater() -> None: |
770 | 771 | assert sidecar.exists() |
771 | 772 | reloaded = LogTracking.load(log_file) |
772 | 773 | assert reloaded.last_uploaded_line >= 1 |
| 774 | + |
| 775 | + |
| 776 | +T0 = datetime(2026, 1, 1, tzinfo=timezone.utc) |
| 777 | + |
| 778 | + |
| 779 | +def _make_report(id_: str = "sim-report") -> TestReport: |
| 780 | + return TestReport( |
| 781 | + id_=id_, |
| 782 | + status=TestStatus.IN_PROGRESS, |
| 783 | + name="n", |
| 784 | + test_system_name="s", |
| 785 | + test_case="c", |
| 786 | + start_time=T0, |
| 787 | + end_time=T0, |
| 788 | + metadata={}, |
| 789 | + is_archived=False, |
| 790 | + ) |
| 791 | + |
| 792 | + |
| 793 | +def _make_step(id_: str = "sim-step") -> TestStep: |
| 794 | + return TestStep( |
| 795 | + id_=id_, |
| 796 | + test_report_id="sim-report", |
| 797 | + name="step", |
| 798 | + step_type=TestStepType.ACTION, |
| 799 | + step_path="1", |
| 800 | + status=TestStatus.IN_PROGRESS, |
| 801 | + start_time=T0, |
| 802 | + end_time=T0, |
| 803 | + ) |
| 804 | + |
| 805 | + |
| 806 | +def _make_measurement(id_: str = "sim-meas") -> TestMeasurement: |
| 807 | + return TestMeasurement( |
| 808 | + id_=id_, |
| 809 | + measurement_type=TestMeasurementType.BOOLEAN, |
| 810 | + name="m", |
| 811 | + test_step_id="sim-step", |
| 812 | + boolean_value=True, |
| 813 | + passed=True, |
| 814 | + timestamp=T0, |
| 815 | + ) |
| 816 | + |
| 817 | + |
| 818 | +@pytest.fixture |
| 819 | +def mock_client(): |
| 820 | + client = MagicMock() |
| 821 | + client.grpc_client = MagicMock() |
| 822 | + client.rest_client = MagicMock() |
| 823 | + return client |
| 824 | + |
| 825 | + |
| 826 | +@pytest_asyncio.fixture |
| 827 | +def api(mock_client): |
| 828 | + """Build a TestResultsAPIAsync with mocked low-level + upload clients.""" |
| 829 | + with patch( |
| 830 | + "sift_client.resources.test_results.TestResultsLowLevelClient", |
| 831 | + autospec=True, |
| 832 | + ), patch( |
| 833 | + "sift_client.resources.test_results.UploadLowLevelClient", |
| 834 | + autospec=True, |
| 835 | + ): |
| 836 | + return TestResultsAPIAsync(mock_client) |
| 837 | + |
| 838 | + |
| 839 | +LOG = "/tmp/log.jsonl" |
| 840 | + |
| 841 | + |
| 842 | +class TestCreateStamping: |
| 843 | + @pytest.mark.asyncio |
| 844 | + async def test_create_stamps_log_file(self, api): |
| 845 | + api._low_level_client.create_test_report = AsyncMock(return_value=_make_report()) |
| 846 | + report_data = { |
| 847 | + "status": TestStatus.IN_PROGRESS, |
| 848 | + "name": "n", |
| 849 | + "test_system_name": "s", |
| 850 | + "test_case": "c", |
| 851 | + "start_time": T0, |
| 852 | + "end_time": T0, |
| 853 | + } |
| 854 | + result = await api.create(report_data, log_file=LOG) |
| 855 | + assert result._log_file == LOG |
| 856 | + assert api._low_level_client.create_test_report.call_args.kwargs["log_file"] == LOG |
| 857 | + |
| 858 | + @pytest.mark.asyncio |
| 859 | + async def test_create_step_stamps_log_file(self, api): |
| 860 | + api._low_level_client.create_test_step = AsyncMock(return_value=_make_step()) |
| 861 | + step_data = { |
| 862 | + "test_report_id": "sim-report", |
| 863 | + "name": "step", |
| 864 | + "step_type": TestStepType.ACTION, |
| 865 | + "step_path": "1", |
| 866 | + "status": TestStatus.IN_PROGRESS, |
| 867 | + "start_time": T0, |
| 868 | + "end_time": T0, |
| 869 | + } |
| 870 | + result = await api.create_step(step_data, log_file=LOG) |
| 871 | + assert result._log_file == LOG |
| 872 | + |
| 873 | + @pytest.mark.asyncio |
| 874 | + async def test_create_measurement_stamps_log_file(self, api): |
| 875 | + api._low_level_client.create_test_measurement = AsyncMock(return_value=_make_measurement()) |
| 876 | + meas_data = { |
| 877 | + "test_step_id": "sim-step", |
| 878 | + "name": "m", |
| 879 | + "measurement_type": TestMeasurementType.BOOLEAN, |
| 880 | + "boolean_value": True, |
| 881 | + "passed": True, |
| 882 | + "timestamp": T0, |
| 883 | + } |
| 884 | + result = await api.create_measurement(meas_data, log_file=LOG) |
| 885 | + assert result._log_file == LOG |
| 886 | + |
| 887 | + |
| 888 | +class TestUpdateStamping: |
| 889 | + @pytest.mark.asyncio |
| 890 | + async def test_update_stamps_log_file(self, api): |
| 891 | + existing = _make_report() |
| 892 | + api._low_level_client.update_test_report = AsyncMock(return_value=existing) |
| 893 | + result = await api.update( |
| 894 | + test_report=existing, update={"status": TestStatus.FAILED}, log_file=LOG |
| 895 | + ) |
| 896 | + assert result._log_file == LOG |
| 897 | + assert api._low_level_client.update_test_report.call_args.kwargs["log_file"] == LOG |
| 898 | + |
| 899 | + @pytest.mark.asyncio |
| 900 | + async def test_update_step_stamps_log_file(self, api): |
| 901 | + existing = _make_step() |
| 902 | + api._low_level_client.update_test_step = AsyncMock(return_value=existing) |
| 903 | + result = await api.update_step( |
| 904 | + test_step=existing, update={"description": "x"}, log_file=LOG |
| 905 | + ) |
| 906 | + assert result._log_file == LOG |
| 907 | + assert api._low_level_client.update_test_step.call_args.kwargs["log_file"] == LOG |
| 908 | + |
| 909 | + @pytest.mark.asyncio |
| 910 | + async def test_update_measurement_stamps_log_file(self, api): |
| 911 | + existing = _make_measurement() |
| 912 | + api._low_level_client.update_test_measurement = AsyncMock(return_value=existing) |
| 913 | + result = await api.update_measurement( |
| 914 | + test_measurement=existing, update={"passed": False}, log_file=LOG |
| 915 | + ) |
| 916 | + assert result._log_file == LOG |
| 917 | + assert api._low_level_client.update_test_measurement.call_args.kwargs["log_file"] == LOG |
| 918 | + |
| 919 | + |
| 920 | +CACHED = "/tmp/cached.jsonl" |
| 921 | +KWARG = "/tmp/kwarg.jsonl" |
| 922 | + |
| 923 | + |
| 924 | +class TestResourceMethodReadsStampedEntity: |
| 925 | + """Resource-level fallback: when no log_file kwarg is passed, read it off |
| 926 | + the entity. Symmetric with the entity-level convenience method's behavior. |
| 927 | + """ |
| 928 | + |
| 929 | + @pytest.mark.parametrize( |
| 930 | + ("cached", "kwarg", "expected"), |
| 931 | + [ |
| 932 | + (None, None, None), |
| 933 | + (CACHED, None, CACHED), # the fallback |
| 934 | + (CACHED, KWARG, KWARG), # kwarg wins |
| 935 | + ], |
| 936 | + ) |
| 937 | + @pytest.mark.asyncio |
| 938 | + async def test_update_reads_log_file_from_test_report(self, api, cached, kwarg, expected): |
| 939 | + entity = _make_report() |
| 940 | + if cached is not None: |
| 941 | + entity.__dict__["_log_file"] = cached |
| 942 | + api._low_level_client.update_test_report = AsyncMock(return_value=entity) |
| 943 | + |
| 944 | + await api.update(test_report=entity, update={"status": TestStatus.FAILED}, log_file=kwarg) |
| 945 | + |
| 946 | + assert api._low_level_client.update_test_report.call_args.kwargs["log_file"] == expected |
| 947 | + |
| 948 | + @pytest.mark.parametrize( |
| 949 | + ("cached", "kwarg", "expected"), |
| 950 | + [ |
| 951 | + (None, None, None), |
| 952 | + (CACHED, None, CACHED), |
| 953 | + (CACHED, KWARG, KWARG), |
| 954 | + ], |
| 955 | + ) |
| 956 | + @pytest.mark.asyncio |
| 957 | + async def test_update_step_reads_log_file_from_test_step(self, api, cached, kwarg, expected): |
| 958 | + entity = _make_step() |
| 959 | + if cached is not None: |
| 960 | + entity.__dict__["_log_file"] = cached |
| 961 | + api._low_level_client.update_test_step = AsyncMock(return_value=entity) |
| 962 | + |
| 963 | + await api.update_step(test_step=entity, update={"description": "x"}, log_file=kwarg) |
| 964 | + |
| 965 | + assert api._low_level_client.update_test_step.call_args.kwargs["log_file"] == expected |
| 966 | + |
| 967 | + @pytest.mark.parametrize( |
| 968 | + ("cached", "kwarg", "expected"), |
| 969 | + [ |
| 970 | + (None, None, None), |
| 971 | + (CACHED, None, CACHED), |
| 972 | + (CACHED, KWARG, KWARG), |
| 973 | + ], |
| 974 | + ) |
| 975 | + @pytest.mark.asyncio |
| 976 | + async def test_update_measurement_reads_log_file_from_test_measurement( |
| 977 | + self, api, cached, kwarg, expected |
| 978 | + ): |
| 979 | + entity = _make_measurement() |
| 980 | + if cached is not None: |
| 981 | + entity.__dict__["_log_file"] = cached |
| 982 | + api._low_level_client.update_test_measurement = AsyncMock(return_value=entity) |
| 983 | + |
| 984 | + await api.update_measurement( |
| 985 | + test_measurement=entity, update={"passed": False}, log_file=kwarg |
| 986 | + ) |
| 987 | + |
| 988 | + assert ( |
| 989 | + api._low_level_client.update_test_measurement.call_args.kwargs["log_file"] == expected |
| 990 | + ) |
| 991 | + |
| 992 | + @pytest.mark.asyncio |
| 993 | + async def test_update_with_string_id_has_no_fallback(self, api): |
| 994 | + """Passing a bare ID (no entity) means no _log_file to read; the resource |
| 995 | + forwards None to the low-level wrapper. |
| 996 | + """ |
| 997 | + api._low_level_client.update_test_report = AsyncMock(return_value=_make_report()) |
| 998 | + await api.update(test_report="some-id", update={"status": TestStatus.FAILED}) |
| 999 | + assert api._low_level_client.update_test_report.call_args.kwargs["log_file"] is None |
| 1000 | + |
| 1001 | + @pytest.mark.asyncio |
| 1002 | + async def test_update_step_with_string_id_has_no_fallback(self, api): |
| 1003 | + api._low_level_client.update_test_step = AsyncMock(return_value=_make_step()) |
| 1004 | + await api.update_step(test_step="some-id", update={"description": "x"}) |
| 1005 | + assert api._low_level_client.update_test_step.call_args.kwargs["log_file"] is None |
| 1006 | + |
| 1007 | + |
| 1008 | +class TestReadPathsDoNotStamp: |
| 1009 | + """get/list_/find/import_log_file return real entities; they must not carry _log_file.""" |
| 1010 | + |
| 1011 | + @pytest.mark.asyncio |
| 1012 | + async def test_get_does_not_stamp(self, api): |
| 1013 | + api._low_level_client.get_test_report = AsyncMock(return_value=_make_report("real-id")) |
| 1014 | + result = await api.get(test_report_id="real-id") |
| 1015 | + assert result._log_file is None |
| 1016 | + |
| 1017 | + @pytest.mark.asyncio |
| 1018 | + async def test_list_does_not_stamp(self, api): |
| 1019 | + api._low_level_client.list_all_test_reports = AsyncMock( |
| 1020 | + return_value=[_make_report("a"), _make_report("b")] |
| 1021 | + ) |
| 1022 | + results = await api.list_() |
| 1023 | + assert all(r._log_file is None for r in results) |
| 1024 | + |
| 1025 | + @pytest.mark.asyncio |
| 1026 | + async def test_import_log_file_does_not_stamp(self, api, tmp_path): |
| 1027 | + from sift_client._internal.low_level_wrappers.test_results import ReplayResult |
| 1028 | + |
| 1029 | + log_path = tmp_path / "log.jsonl" |
| 1030 | + log_path.touch() |
| 1031 | + replay_result = ReplayResult( |
| 1032 | + report=_make_report("real-report"), |
| 1033 | + steps=[_make_step("real-step")], |
| 1034 | + measurements=[_make_measurement("real-meas")], |
| 1035 | + ) |
| 1036 | + api._low_level_client.import_log_file = AsyncMock(return_value=replay_result) |
| 1037 | + |
| 1038 | + result = await api.import_log_file(log_path) |
| 1039 | + |
| 1040 | + assert result.report._log_file is None |
| 1041 | + assert all(s._log_file is None for s in result.steps) |
| 1042 | + assert all(m._log_file is None for m in result.measurements) |
| 1043 | + |
| 1044 | + |
| 1045 | +class TestEndToEndLogFileRouting: |
| 1046 | + """Full pipeline: resource -> real low-level client -> actual file write. |
| 1047 | +
|
| 1048 | + No mocking of the low-level client; the GrpcClient stub is mocked but is |
| 1049 | + never invoked because the file-write branch in the low-level wrapper |
| 1050 | + short-circuits before any gRPC call when log_file is set. Proves the |
| 1051 | + cached-_log_file plumbing reaches the file on disk. |
| 1052 | + """ |
| 1053 | + |
| 1054 | + @pytest.fixture |
| 1055 | + def real_api(self, mock_client): |
| 1056 | + """TestResultsAPIAsync wired through a real TestResultsLowLevelClient.""" |
| 1057 | + return TestResultsAPIAsync(mock_client) |
| 1058 | + |
| 1059 | + @pytest.mark.asyncio |
| 1060 | + async def test_metadata_update_round_trips_through_log_file(self, real_api, tmp_path): |
| 1061 | + """Update with metadata via cached |
| 1062 | + _log_file, then read the JSONL line back through the same parser the |
| 1063 | + replay path uses and verify every key/value round-trips. Proves the |
| 1064 | + user-visible payload (not just an opaque entry) lands on disk. |
| 1065 | + """ |
| 1066 | + from google.protobuf import json_format |
| 1067 | + from sift.test_reports.v1.test_reports_pb2 import UpdateTestReportRequest |
| 1068 | + |
| 1069 | + from sift_client._internal.low_level_wrappers._test_results_log import ( |
| 1070 | + iter_log_data_lines, |
| 1071 | + ) |
| 1072 | + from sift_client.util.metadata import metadata_proto_to_dict |
| 1073 | + |
| 1074 | + log_file = tmp_path / "metadata.jsonl" |
| 1075 | + report_data = { |
| 1076 | + "status": TestStatus.IN_PROGRESS, |
| 1077 | + "name": "n", |
| 1078 | + "test_system_name": "s", |
| 1079 | + "test_case": "c", |
| 1080 | + "start_time": T0, |
| 1081 | + "end_time": T0, |
| 1082 | + } |
| 1083 | + report = await real_api.create(report_data, log_file=log_file) |
| 1084 | + assert report._log_file == log_file |
| 1085 | + |
| 1086 | + # Mix of string, number, and boolean to cover all three MetadataValue arms. |
| 1087 | + metadata = { |
| 1088 | + "run_id": "run-abc-123", |
| 1089 | + "operator": "test-user", |
| 1090 | + "trial_number": 42.5, |
| 1091 | + "is_dry_run": True, |
| 1092 | + } |
| 1093 | + # No log_file kwarg — the resource layer must read it off the entity. |
| 1094 | + await real_api.update(test_report=report, update={"metadata": metadata}) |
| 1095 | + |
| 1096 | + # Find the UpdateTestReport line and decode it the same way replay does. |
| 1097 | + update_entries = [ |
| 1098 | + (rt, rid, js) |
| 1099 | + for rt, rid, js in iter_log_data_lines(log_file) |
| 1100 | + if rt == "UpdateTestReport" |
| 1101 | + ] |
| 1102 | + assert len(update_entries) == 1 |
| 1103 | + _, _, json_str = update_entries[0] |
| 1104 | + |
| 1105 | + request = UpdateTestReportRequest() |
| 1106 | + json_format.Parse(json_str, request) |
| 1107 | + |
| 1108 | + assert "metadata" in request.update_mask.paths |
| 1109 | + round_tripped = metadata_proto_to_dict(list(request.test_report.metadata)) |
| 1110 | + assert round_tripped == metadata |
| 1111 | + # And confirm we never reached the gRPC stub. |
| 1112 | + real_api._low_level_client._grpc_client.get_stub.assert_not_called() |
0 commit comments