Skip to content

Commit 5e05bd6

Browse files
committed
comment and fix a few dev script items
1 parent dfa27b3 commit 5e05bd6

3 files changed

Lines changed: 345 additions & 359 deletions

File tree

python/lib/sift_client/_tests/resources/test_test_results.py

Lines changed: 341 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
from datetime import datetime, timedelta, timezone
55
from pathlib import Path
66
from typing import ClassVar
7-
from unittest.mock import MagicMock
7+
from unittest.mock import AsyncMock, MagicMock, patch
88

99
import grpc
1010
import pytest
11+
import pytest_asyncio
1112
from grpc import aio as aiogrpc
1213

1314
from sift_client._internal.low_level_wrappers.test_results import TestResultsLowLevelClient
@@ -770,3 +771,342 @@ def updater() -> None:
770771
assert sidecar.exists()
771772
reloaded = LogTracking.load(log_file)
772773
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

Comments
 (0)