From 2beb6467e5173e90bc63b7cc45e3e80982d7a713 Mon Sep 17 00:00:00 2001 From: jgjesdal Date: Tue, 29 Sep 2026 10:10:18 +0200 Subject: [PATCH] test(python): pin what timeseries.create refuses, replacing the dead draft The invalid-input tests in test_timeseries_sync.py and _async.py sat inside a string literal since the Python SDK landed, so pytest never collected them, and they could not have run as written. They are gone. test_timeseries_rejections.py covers what is actually refused, and where. Wrongly typed input (unit=0, a non-string metadata value) is a TypeError from the constructor and an unknown value_type a ValueError; neither reaches the api. The api answers the rest with a 400 constraint-violation naming each field: unit absent, None, empty or blank; a unit_external_id without a unit, which REST does not fill in from the catalogue the way the MCP tool does; unit over 64 characters; unit_external_id, name and external_id under their minimum lengths. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: jgjesdal --- python_tests/test_timeseries_async.py | 60 ---------- python_tests/test_timeseries_rejections.py | 130 +++++++++++++++++++++ python_tests/test_timeseries_sync.py | 56 --------- 3 files changed, 130 insertions(+), 116 deletions(-) create mode 100644 python_tests/test_timeseries_rejections.py diff --git a/python_tests/test_timeseries_async.py b/python_tests/test_timeseries_async.py index 529a328..b8028bb 100644 --- a/python_tests/test_timeseries_async.py +++ b/python_tests/test_timeseries_async.py @@ -132,63 +132,3 @@ async def test_insert_datapoints_missing_timeseries_returns_not_found(async_clie ), f"`missing` should name the absent series: {error.problem}" - -""" -TODO! determine what is invalid input and codify in tests -Bellow are draft tests for invalid input - -@pytest.mark.asyncio -@pytest.mark.parametrize("metadata", [{"vec": [0,1,2]},{"value_params": {"nested": {}}},{"nonstringable": print}]) -async def test_reject_invalid_timeseries_metadata(async_client,metadata): - with pytest.raises(ValueError): - test_insert_ts = intellistream_datahub_sdk.TimeSeries( - name="valid name", - metadata=metadata, - ) - await async_client.timeseries.delete([test_insert_ts]) - -@pytest.mark.asyncio -@pytest.mark.parametrize("name", ["valid name"]) -async def test_reject_invalid_timeseries_name(async_client,name): - with pytest.raises(ValueError): - test_insert_ts = intellistream_datahub_sdk.TimeSeries( - name=name, - ) - await async_client.timeseries.delete([test_insert_ts]) - -@pytest.mark.asyncio -@pytest.mark.parametrize("external_id", [1,12,"a","ab"]) -async def test_reject_invalid_timeseries_metadata(async_client,external_id): - with pytest.raises(ValueError): - test_insert_ts = intellistream_datahub_sdk.TimeSeries( - name="valid name", - external_id=external_id, - ) - await async_client.timeseries.delete([test_insert_ts]) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("value_type", ["",None,"big_int","strings","hex"]) -async def test_reject_invalid_timeseries_metadata(async_client,value_type): - with pytest.raises(ValueError): - test_insert_ts = intellistream_datahub_sdk.TimeSeries( - name="valid name", - value_type=value_type, - ) - await async_client.timeseries.delete([test_insert_ts]) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("units", ["",None,0],marks=pytest.mark.xfail(reason="TBD what are invalid units")) -@pytest.mark.parametrize("unit_external_id", ["",None,0],marks=pytest.mark.xfail(reason="TBD what are invalid units")) -async def test_reject_invalid_timeseries_unit(async_client,units,unit_external_id): - with pytest.raises(ValueError): - test_insert_ts = intellistream_datahub_sdk.TimeSeries( - name="valid name", - units=units, - unit_external_id=unit_external_id, - ) - await async_client.timeseries.delete([test_insert_ts]) - - -""" \ No newline at end of file diff --git a/python_tests/test_timeseries_rejections.py b/python_tests/test_timeseries_rejections.py new file mode 100644 index 0000000..e3ae819 --- /dev/null +++ b/python_tests/test_timeseries_rejections.py @@ -0,0 +1,130 @@ +"""What ``timeseries.create`` refuses, and where. + +Wrongly *typed* input never leaves Python: the binding declares ``str`` fields, so ``unit=0`` is a +``TypeError`` from the constructor, and ``value_type`` is checked against the SDK's catalogue there +too. Everything else is the api's call, answered with a 400 ``constraint-violation`` whose +``fields`` name each rejected property. + +Two things the api accepts that a caller might expect it to refuse, and which are therefore not +pinned here: a ``unit_external_id`` that names no catalogue entry, and one that disagrees with +``unit`` (``unit="Celsius"`` on ``pressure_bar``). REST does not look the id up at all. +""" +import pytest + +from intellistream_datahub_sdk import DataHubException, TimeSeries + +from fixtures import async_client, make_ts, sync_client, unique_id # noqa: F401 (fixtures) + +ABSENT = object() + + +def valid(**overrides): + kwargs = { + "external_id": unique_id("reject_ts"), + "name": "rejection probe", + "value_type": "float", + "unit": "a.u", + } + kwargs.update(overrides) + return {k: v for k, v in kwargs.items() if v is not ABSENT} + + +def rejected_fields(sync_client, ts): + """Create ``ts`` expecting a 400, and return ``{field: code}`` from the problem.""" + try: + with pytest.raises(DataHubException) as excinfo: + sync_client.timeseries.create([ts]) + finally: + # Only reached with something to delete if the api regressed and accepted it. + sync_client.timeseries.delete([ts.external_id]) + error = excinfo.value + assert error.status_code == 400, error.message + assert error.problem_slug == "constraint-violation", error.message + return {f["field"]: f["code"] for f in error.problem["fields"]} + + +# --------------------------------------------------------------------------- # +# refused by the constructor +# --------------------------------------------------------------------------- # + +@pytest.mark.parametrize("overrides", [ + {"unit": 0}, + {"unit_external_id": 0}, + {"external_id": 12}, + {"name": 5}, + {"value_type": None}, + {"metadata": {"count": 1}}, + {"metadata": {"vec": [0, 1, 2]}}, +], ids=repr) +def test_a_wrongly_typed_field_is_a_type_error(overrides): + with pytest.raises(TypeError): + TimeSeries(**valid(**overrides)) + + +@pytest.mark.parametrize("value_type", ["", "big_int", "strings", "hex"]) +def test_an_unknown_value_type_is_a_value_error(value_type): + with pytest.raises(ValueError, match="ValueType"): + TimeSeries(**valid(value_type=value_type)) + + +# --------------------------------------------------------------------------- # +# refused by the api +# --------------------------------------------------------------------------- # + +@pytest.mark.parametrize("unit", [ABSENT, None, "", " "], ids=["absent", "None", "empty", "blank"]) +def test_unit_is_required(sync_client, unit): + fields = rejected_fields(sync_client, TimeSeries(**valid(unit=unit))) + assert fields == {"unit": "timeseries.unit.not.blank"} + + +def test_a_unit_external_id_does_not_stand_in_for_unit(sync_client): + """Unlike the MCP ``timeseries_create`` tool, REST does not fill ``unit`` in from the catalogue.""" + fields = rejected_fields( + sync_client, TimeSeries(**valid(unit=ABSENT, unit_external_id="pressure_bar")) + ) + assert fields == {"unit": "timeseries.unit.not.blank"} + + +def test_unit_is_at_most_64_characters(sync_client, make_ts): + assert make_ts(**valid(unit="x" * 64)).unit == "x" * 64 + + fields = rejected_fields(sync_client, TimeSeries(**valid(unit="x" * 65))) + assert set(fields) == {"unit"} + + +@pytest.mark.parametrize("unit_external_id", ["", "ab"]) +def test_unit_external_id_is_at_least_3_characters(sync_client, unit_external_id): + fields = rejected_fields( + sync_client, TimeSeries(**valid(unit_external_id=unit_external_id)) + ) + assert set(fields) == {"unitExternalId"} + + +@pytest.mark.parametrize("name", ["", "ab", "x" * 513], ids=["empty", "2 chars", "513 chars"]) +def test_name_is_3_to_512_characters(sync_client, name): + fields = rejected_fields(sync_client, TimeSeries(**valid(name=name))) + assert set(fields) == {"name"} + + +def test_external_id_is_at_least_3_characters(sync_client): + fields = rejected_fields(sync_client, TimeSeries(**valid(external_id="ab"))) + assert set(fields) == {"externalId"} + + +def test_every_rejected_field_is_named_at_once(sync_client): + fields = rejected_fields( + sync_client, TimeSeries(**valid(name="", unit="", unit_external_id="ab")) + ) + assert {"name", "unit", "unitExternalId"} <= set(fields) + + +@pytest.mark.asyncio +async def test_the_async_client_surfaces_the_same_rejection(async_client, sync_client): + ts = TimeSeries(**valid(unit="")) + try: + with pytest.raises(DataHubException) as excinfo: + await async_client.timeseries.create([ts]) + finally: + sync_client.timeseries.delete([ts.external_id]) + assert excinfo.value.status_code == 400 + assert excinfo.value.problem_slug == "constraint-violation" diff --git a/python_tests/test_timeseries_sync.py b/python_tests/test_timeseries_sync.py index 833eb03..e2ed9fc 100644 --- a/python_tests/test_timeseries_sync.py +++ b/python_tests/test_timeseries_sync.py @@ -158,62 +158,6 @@ def test_invalid_retrieve_latest_datapoint(sync_client): nonexistant_ts = intellistream_datahub_sdk.TimeSeries(external_id="nonexistent_ts",value_type="bigint",unit="a.u") sync_client.timeseries.retrieve_latest_datapoints(input=[nonexistant_ts])[0] -""" -TODO! determine what is invalid input and codify in tests -Bellow are draft tests for invalid input - -@pytest.mark.parametrize("metadata", [{"vec": [0,1,2]},{"value_params": {"nested": {}}},{"nonstringable": print}]) -def test_reject_invalid_timeseries_metadata(sync_client,metadata): - with pytest.raises(ValueError): - test_insert_ts = intellistream_datahub_sdk.TimeSeries( - name="valid name", - metadata=metadata, - ) - sync_client.timeseries.delete([test_insert_ts]) - -@pytest.mark.parametrize("name", ["valid name"]) -def test_reject_invalid_timeseries_name(sync_client,name): - with pytest.raises(ValueError): - test_insert_ts = intellistream_datahub_sdk.TimeSeries( - name=name, - ) - sync_client.timeseries.delete([test_insert_ts]) - -@pytest.mark.parametrize("external_id", [1,12,"a","ab"]) -def test_reject_invalid_timeseries_metadata(sync_client,external_id): - with pytest.raises(ValueError): - test_insert_ts = intellistream_datahub_sdk.TimeSeries( - name="valid name", - external_id=external_id, - ) - sync_client.timeseries.delete([test_insert_ts]) - - -@pytest.mark.parametrize("value_type", ["",None,"big_int","strings","hex"]) -def test_reject_invalid_timeseries_metadata(sync_client,value_type): - with pytest.raises(ValueError): - test_insert_ts = intellistream_datahub_sdk.TimeSeries( - name="valid name", - value_type=value_type, - ) - sync_client.timeseries.delete([test_insert_ts]) - - -@pytest.mark.parametrize("units", ["",None,0],marks=pytest.mark.xfail(reason="TBD what are invalid units")) -@pytest.mark.parametrize("unit_external_id", ["",None,0],marks=pytest.mark.xfail(reason="TBD what are invalid units")) -def test_reject_invalid_timeseries_unit(sync_client,units,unit_external_id): - with pytest.raises(ValueError): - test_insert_ts = intellistream_datahub_sdk.TimeSeries( - name="valid name", - units=units, - unit_external_id=unit_external_id, - ) - sync_client.timeseries.delete([test_insert_ts]) - - -""" - - def test_timeseries_update_with_fields(sync_client, make_ts): # 1. Create a new unique TS for updating (make_ts deletes it at teardown)