from datetime import UTC, datetime from unittest.mock import Mock import pytest from production_analytics.enlyze.gateway import EnlyzeApiGateway def test_get_open_production_run_returns_current_run() -> None: client = Mock() client.get.return_value.body = { "data": [ { "uuid": "closed-run", "machine": "machine-1", "product": "product-a", "production_order": "ORDER-1", "start": "2026-09-03T04:00:00Z", "end": "2026-09-03T05:00:00Z", }, { "uuid": "open-run", "machine": "machine-1", "product": "product-b", "production_order": "ORDER-2", "start": "2026-09-03T05:07:47Z", "end": None, }, ] } gateway = EnlyzeApiGateway(client) run = gateway.get_open_production_run("machine-1") assert run is not None assert run.uuid == "open-run" assert run.machine_id == "machine-1" assert run.product_id == "product-b" assert run.production_order == "ORDER-2" assert run.start == datetime(2026, 9, 3, 5, 7, 47, tzinfo=UTC) assert run.end is None client.get.assert_called_once_with( "/v2/production-runs", {"machine": "machine-1"}, ) def test_get_open_production_run_returns_none_without_open_run() -> None: client = Mock() client.get.return_value.body = { "data": [ { "uuid": "closed-run", "machine": "machine-1", "product": "product-a", "production_order": "ORDER-1", "start": "2026-09-03T04:00:00Z", "end": "2026-09-03T05:00:00Z", } ] } gateway = EnlyzeApiGateway(client) assert gateway.get_open_production_run("machine-1") is None def test_get_material_samples_requests_rate_and_gate_together() -> None: client = Mock() client.post_json.return_value.body = { "data": { "columns": ["time", "rate-variable", "gate-variable"], "records": [ ["2026-09-03T05:09:50Z", 1028.5, 2.1], ["2026-09-03T05:10:00Z", 1029.0, 2.2], ], } } gateway = EnlyzeApiGateway(client) samples = gateway.get_material_samples( machine_id="machine-1", rate_variable_id="rate-variable", gate_variable_id="gate-variable", start=datetime(2026, 9, 3, 5, 9, 50, tzinfo=UTC), end=datetime(2026, 9, 3, 5, 10, 10, tzinfo=UTC), ) assert len(samples) == 2 assert samples[0].timestamp == datetime(2026, 9, 3, 5, 9, 50, tzinfo=UTC) assert samples[0].material_rate_kg_per_hour == 1028.5 assert samples[0].gate_value == 2.1 assert samples[1].material_rate_kg_per_hour == 1029.0 assert samples[1].gate_value == 2.2 client.post_json.assert_called_once_with( "/v2/timeseries", { "machine": "machine-1", "start": "2026-09-03T05:09:50+00:00", "end": "2026-09-03T05:10:10+00:00", "variables": [ {"uuid": "rate-variable"}, {"uuid": "gate-variable"}, ], }, ) def test_get_material_samples_uses_column_names_not_fixed_positions() -> None: client = Mock() client.post_json.return_value.body = { "data": { "columns": ["time", "gate-variable", "rate-variable"], "records": [ ["2026-09-03T05:09:50Z", 2.1, 1028.5], ], } } gateway = EnlyzeApiGateway(client) samples = gateway.get_material_samples( machine_id="machine-1", rate_variable_id="rate-variable", gate_variable_id="gate-variable", start=datetime(2026, 9, 3, 5, 9, 50, tzinfo=UTC), end=datetime(2026, 9, 3, 5, 10, 0, tzinfo=UTC), ) assert samples[0].material_rate_kg_per_hour == 1028.5 assert samples[0].gate_value == 2.1 @pytest.fixture def timeseries(): client = Mock() client.post_json.return_value.body = {"data": { "columns": ["time", "rate", "gate"], "records": [], }} args = dict(machine_id="m", rate_variable_id="rate", gate_variable_id="gate", start=datetime(2026, 9, 3, tzinfo=UTC), end=datetime(2026, 9, 4, tzinfo=UTC)) return EnlyzeApiGateway(client), client, args @pytest.mark.parametrize("name", ["start", "end"]) def test_naive_window_rejected(timeseries, name) -> None: gateway, client, args = timeseries args[name] = args[name].replace(tzinfo=None) with pytest.raises(ValueError, match="timezone-aware"): gateway.get_material_samples(**args) client.post_json.assert_not_called() def test_reversed_window_rejected(timeseries) -> None: gateway, client, args = timeseries args["start"], args["end"] = args["end"], args["start"] with pytest.raises(ValueError, match="precede"): gateway.get_material_samples(**args) client.post_json.assert_not_called() def test_empty_timeseries(timeseries) -> None: gateway, client, args = timeseries assert gateway.get_material_samples(**args) == [] @pytest.mark.parametrize("column", ["time", "rate", "gate"]) def test_missing_required_column(timeseries, column) -> None: gateway, client, args = timeseries client.post_json.return_value.body["data"]["columns"].remove(column) with pytest.raises(ValueError, match="required column"): gateway.get_material_samples(**args) @pytest.mark.parametrize("record", [[], {}, ["bad", 1, 1], [None, 1, 1], ["2026-09-03T00:00:00", 1, 1], ["2026-09-03T00:00:00Z", None, 1], ["2026-09-03T00:00:00Z", 1, float("inf")], ["2026-09-03T00:00:00Z", True, 1]]) def test_malformed_records_fail_clearly(timeseries, record) -> None: gateway, client, args = timeseries client.post_json.return_value.body["data"]["records"] = [record] with pytest.raises(ValueError, match="malformed record 0"): gateway.get_material_samples(**args) def test_multiple_open_runs_rejected() -> None: client = Mock() run = dict(uuid="r", machine="m", production_order="o", start="2026-09-03T00:00:00Z", end=None) client.get.return_value.body = {"data": [run, {**run, "uuid": "r2"}]} with pytest.raises(ValueError, match="multiple open Production Runs"): EnlyzeApiGateway(client).get_open_production_run("m") @pytest.mark.parametrize("item", [{}, None, {"end": None}, dict(uuid="r", machine="m", production_order="o", start="bad", end=None)]) def test_malformed_run_fails_clearly(item) -> None: client = Mock() client.get.return_value.body = {"data": [item]} with pytest.raises(ValueError, match="Invalid production-run response"): EnlyzeApiGateway(client).get_open_production_run("m") def test_timeseries_with_null_cursor(timeseries) -> None: gateway, client, args = timeseries client.post_json.return_value.body["metadata"] = {"next_cursor": None} client.post_json.return_value.body["data"]["records"] = [ ["2026-09-03T00:00:00Z", 10, 2], ] samples = gateway.get_material_samples(**args) assert len(samples) == 1 assert samples[0].material_rate_kg_per_hour == 10 client.post_json.assert_called_once() def test_timeseries_pagination_resolves_columns_per_page(timeseries) -> None: gateway, client, args = timeseries client.post_json.side_effect = [ Mock(body={ "data": {"columns": ["time", "rate", "gate"], "records": [["2026-09-03T00:00:00Z", 10, 2]]}, "metadata": {"next_cursor": "continuation"}, }), Mock(body={ "data": {"columns": ["gate", "time", "rate"], "records": [[3, "2026-09-03T01:00:00Z", 20]]}, "metadata": {"next_cursor": None}, }), ] samples = gateway.get_material_samples(**args) assert [(s.timestamp, s.material_rate_kg_per_hour, s.gate_value) for s in samples] == [ (datetime(2026, 9, 3, tzinfo=UTC), 10, 2), (datetime(2026, 9, 3, 1, tzinfo=UTC), 20, 3), ] assert client.post_json.call_count == 2 first, second = client.post_json.call_args_list assert first.args == ("/v2/timeseries", { "machine": "m", "start": args["start"].isoformat(), "end": args["end"].isoformat(), "variables": [{"uuid": "rate"}, {"uuid": "gate"}], }) assert second.args == ("/v2/timeseries", {**first.args[1], "cursor": "continuation"}) @pytest.mark.parametrize("metadata", [None, [], "bad", {}, {"next_cursor": ""}, {"next_cursor": 1}, {"next_cursor": False}, {"next_cursor": []}, {"next_cursor": {}}]) def test_invalid_pagination_metadata(timeseries, metadata) -> None: gateway, client, args = timeseries client.post_json.return_value.body["metadata"] = metadata with pytest.raises(ValueError, match="Invalid timeseries response.*next_cursor"): gateway.get_material_samples(**args) client.post_json.assert_called_once() @pytest.mark.parametrize("cursors", [["a", "a"], ["a", "b", "a"]]) def test_repeated_pagination_cursor(timeseries, cursors) -> None: gateway, client, args = timeseries data = client.post_json.return_value.body["data"] client.post_json.side_effect = [ Mock(body={"data": data, "metadata": {"next_cursor": cursor}}) for cursor in cursors ] with pytest.raises(ValueError, match="next_cursor has already been followed"): gateway.get_material_samples(**args) assert client.post_json.call_count == len(cursors) @pytest.mark.parametrize("data, error", [ ({"columns": ["time", "rate", "gate"], "records": [[]]}, "malformed record 0"), ({"columns": ["time", "rate", "rate", "gate"], "records": []}, "required column"), ({"columns": ["time", "rate"], "records": []}, "required column"), ({"columns": None, "records": []}, "columns must be a list"), ({"columns": ["time", "rate", "gate"], "records": None}, "records must be a list"), ({"columns": ["time", "rate", "gate"], "records": [["2026-09-03T01:00:00", 10, 2]]}, "timezone-aware"), ({"columns": ["time", "rate", "gate"], "records": [["2026-09-03T01:00:00Z", float("nan"), 2]]}, "finite"), ({"columns": ["time", "rate", "gate"], "records": [["2026-09-03T01:00:00Z", 10, True]]}, "numeric"), ]) def test_malformed_later_page(timeseries, data, error) -> None: gateway, client, args = timeseries first_body = client.post_json.return_value.body first_body["metadata"] = {"next_cursor": "continuation"} client.post_json.side_effect = [ Mock(body=first_body), Mock(body={"data": data, "metadata": {"next_cursor": None}}), ] with pytest.raises(ValueError, match=f"Invalid timeseries response on page 2:.*{error}"): gateway.get_material_samples(**args) assert client.post_json.call_count == 2 def test_production_runs_follow_pages_and_find_open_run() -> None: client = Mock() closed = dict(uuid="closed", machine="m", production_order="opaque-order", start="2026-09-01T00:00:00Z", end="2026-09-01T01:00:00Z") opened = {**closed, "uuid": "open", "end": None} pages = [ Mock(body={"data": [closed], "metadata": {"next_cursor": "a"}}), Mock(body={"data": [opened], "metadata": {"next_cursor": None}}), ] client.get.side_effect = pages gateway = EnlyzeApiGateway(client) runs = gateway.get_production_runs("m") assert [run.uuid for run in runs] == ["closed", "open"] assert runs[0].end == datetime(2026, 9, 1, 1, tzinfo=UTC) assert [call.args for call in client.get.call_args_list] == [ ("/v2/production-runs", {"machine": "m"}), ("/v2/production-runs", {"machine": "m", "cursor": "a"}), ] client.get.side_effect = pages assert gateway.get_open_production_run("m") == runs[1] @pytest.mark.parametrize("metadata", [ None, [], "bad", {}, {"next_cursor": ""}, {"next_cursor": 1}, {"next_cursor": False}, {"next_cursor": []}, {"next_cursor": {}}, ]) def test_production_run_invalid_pagination(metadata) -> None: client = Mock() client.get.return_value.body = {"data": [], "metadata": metadata} with pytest.raises(ValueError, match="Invalid production-run response.*next_cursor"): EnlyzeApiGateway(client).get_production_runs("m") @pytest.mark.parametrize("cursors", [["a", "a"], ["a", "b", "a"]]) def test_production_run_cyclic_pagination(cursors) -> None: client = Mock() client.get.side_effect = [ Mock(body={"data": [], "metadata": {"next_cursor": cursor}}) for cursor in cursors ] with pytest.raises(ValueError, match="next_cursor has already been followed"): EnlyzeApiGateway(client).get_production_runs("m") assert client.get.call_count == len(cursors) @pytest.mark.parametrize("body", [ None, [], {}, {"data": {}}, {"data": [None]}, {"data": [dict(uuid="r", machine="wrong", production_order="o", start="2026-09-03T00:00:00Z", end=None)]}, {"data": [dict(uuid="r", machine="m", production_order="o", start="2026-09-03T00:00:00Z", end="2026-09-02T00:00:00Z")]}, ]) def test_production_run_malformed_later_page(body) -> None: client = Mock() client.get.side_effect = [ Mock(body={"data": [], "metadata": {"next_cursor": "a"}}), Mock(body=body), ] with pytest.raises(ValueError, match="Invalid production-run response on page 2"): EnlyzeApiGateway(client).get_production_runs("m")