From 2b7c8815e111b79399cf895492ed377c3c444def Mon Sep 17 00:00:00 2001 From: Katherine Bargar Date: Wed, 24 Sep 2025 13:08:01 +0000 Subject: [PATCH 01/17] Add new dataclass for call information --- tests/test_cases/base.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_cases/base.py b/tests/test_cases/base.py index 2977256..3d3548f 100644 --- a/tests/test_cases/base.py +++ b/tests/test_cases/base.py @@ -5,10 +5,24 @@ from unittest.mock import MagicMock from uuid import uuid4 +from ldlite._jsonx import Json + if TYPE_CHECKING: import ldlite +@dataclass(frozen=True) +class Call: + prefix: str + returns: list[Json] + + # duplicate of LDLite.query default params + query: str | dict[str, str] | None = None + json_depth: int = 3 + limit: int | None = None + keep_raw: bool = True + + @dataclass(frozen=True) class EndToEndTestCase: values: dict[str, list[dict[str, Any]] | list[list[dict[str, Any]]]] From 9ba2e43e24dce8c2ea41d70219e58248bb3a2076 Mon Sep 17 00:00:00 2001 From: Katherine Bargar Date: Wed, 24 Sep 2025 13:15:37 +0000 Subject: [PATCH 02/17] WIP: Refactor base test case to use calls object --- tests/test_cases/base.py | 38 ++++++++++++++------------------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/tests/test_cases/base.py b/tests/test_cases/base.py index 3d3548f..9e078d2 100644 --- a/tests/test_cases/base.py +++ b/tests/test_cases/base.py @@ -1,12 +1,11 @@ import json +from collections.abc import Sequence from dataclasses import dataclass from functools import cached_property -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING from unittest.mock import MagicMock from uuid import uuid4 -from ldlite._jsonx import Json - if TYPE_CHECKING: import ldlite @@ -14,7 +13,7 @@ @dataclass(frozen=True) class Call: prefix: str - returns: list[Json] + returns: list["ldlite._jsonx.Json"] # duplicate of LDLite.query default params query: str | dict[str, str] | None = None @@ -25,7 +24,7 @@ class Call: @dataclass(frozen=True) class EndToEndTestCase: - values: dict[str, list[dict[str, Any]] | list[list[dict[str, Any]]]] + calls: Sequence[Call] @cached_property def db(self) -> str: @@ -39,35 +38,26 @@ def patch_request_get( httpx_post_mock: MagicMock, client_get_mock: MagicMock, ) -> None: - # iteration hack - ld.page_size = 1 # leave tqdm out of it ld.quiet(enable=True) httpx_post_mock.return_value.cookies.__getitem__.return_value = "token" side_effects = [] - for vsource in self.values.values(): - list_values = ( - [cast("list[dict[str, Any]]", vsource)] - if isinstance(vsource[0], dict) - else cast("list[list[dict[str, Any]]]", vsource) - ) - - key = next(iter(list_values[0][0].keys())) + for call in self.calls: + key = next(iter(call.returns[0].keys())) total_mock = MagicMock() total_mock.text = f'{{"{key}": [{{"id": ""}}], "totalRecords": 100000}}' - for values in list_values: - value_mocks = [] - for v in values: - value_mock = MagicMock() - value_mock.text = json.dumps(v) - value_mocks.append(value_mock) + value_mocks = [] + for v in call.returns: + value_mock = MagicMock() + value_mock.text = json.dumps(v) + value_mocks.append(value_mock) - end_mock = MagicMock() - end_mock.text = f'{{"{key}": [] }}' + end_mock = MagicMock() + end_mock.text = f'{{"{key}": [] }}' - side_effects.extend([total_mock, *value_mocks, end_mock]) + side_effects.extend([total_mock, *value_mocks, end_mock]) client_get_mock.side_effect = side_effects From 5416ea3fcbcf5374da8fdb68d5ba2aaa0cd573b1 Mon Sep 17 00:00:00 2001 From: Katherine Bargar Date: Wed, 24 Sep 2025 13:48:58 +0000 Subject: [PATCH 03/17] WIP: Refactor drop_tables test cases --- tests/test_cases/base.py | 25 +++++++++---- tests/test_cases/drop_tables_cases.py | 52 +++++++++++++++------------ tests/test_duckdb.py | 6 ++-- tests/test_postgres.py | 6 ++-- 4 files changed, 55 insertions(+), 34 deletions(-) diff --git a/tests/test_cases/base.py b/tests/test_cases/base.py index 9e078d2..4abe662 100644 --- a/tests/test_cases/base.py +++ b/tests/test_cases/base.py @@ -1,5 +1,4 @@ import json -from collections.abc import Sequence from dataclasses import dataclass from functools import cached_property from typing import TYPE_CHECKING @@ -13,7 +12,7 @@ @dataclass(frozen=True) class Call: prefix: str - returns: list["ldlite._jsonx.Json"] + returns: "ldlite._jsonx.Json | list[ldlite._jsonx.Json]" # duplicate of LDLite.query default params query: str | dict[str, str] | None = None @@ -21,10 +20,24 @@ class Call: limit: int | None = None keep_raw: bool = True + @property + def returns_list(self) -> list["ldlite._jsonx.Json"]: + if isinstance(self.returns, list): + return self.returns + + return [self.returns] + @dataclass(frozen=True) class EndToEndTestCase: - calls: Sequence[Call] + calls: Call | list[Call] + + @property + def calls_list(self) -> list[Call]: + if isinstance(self.calls, list): + return self.calls + + return [self.calls] @cached_property def db(self) -> str: @@ -44,13 +57,13 @@ def patch_request_get( httpx_post_mock.return_value.cookies.__getitem__.return_value = "token" side_effects = [] - for call in self.calls: - key = next(iter(call.returns[0].keys())) + for call in self.calls_list: + key = next(iter(call.returns_list[0].keys())) total_mock = MagicMock() total_mock.text = f'{{"{key}": [{{"id": ""}}], "totalRecords": 100000}}' value_mocks = [] - for v in call.returns: + for v in call.returns_list: value_mock = MagicMock() value_mock.text = json.dumps(v) value_mocks.append(value_mock) diff --git a/tests/test_cases/drop_tables_cases.py b/tests/test_cases/drop_tables_cases.py index e67c3b0..995feb6 100644 --- a/tests/test_cases/drop_tables_cases.py +++ b/tests/test_cases/drop_tables_cases.py @@ -2,44 +2,45 @@ from pytest_cases import parametrize -from .base import EndToEndTestCase +from .base import Call, EndToEndTestCase @dataclass(frozen=True) class DropTablesCase(EndToEndTestCase): drop: str expected_tables: list[str] - keep_raw: bool class DropTablesCases: @parametrize(keep_raw=[True, False]) def case_one_table(self, keep_raw: bool) -> DropTablesCase: return DropTablesCase( + calls=Call( + "prefix", + returns={"purchaseOrders": [{"id": "1"}]}, + keep_raw=keep_raw, + ), drop="prefix", - values={"prefix": [{"purchaseOrders": [{"id": "1"}]}]}, expected_tables=[], - keep_raw=keep_raw, ) @parametrize(keep_raw=[True, False]) def case_two_tables(self, keep_raw: bool) -> DropTablesCase: return DropTablesCase( + calls=Call( + "prefix", + returns={ + "purchaseOrders": [ + { + "id": "1", + "subObjects": [{"id": "2"}, {"id": "3"}], + }, + ], + }, + keep_raw=keep_raw, + ), drop="prefix", - values={ - "prefix": [ - { - "purchaseOrders": [ - { - "id": "1", - "subObjects": [{"id": "2"}, {"id": "3"}], - }, - ], - }, - ], - }, expected_tables=[], - keep_raw=keep_raw, ) @parametrize(keep_raw=[True, False]) @@ -52,11 +53,18 @@ def case_separate_table(self, keep_raw: bool) -> DropTablesCase: expected_tables = ["notdropped", *expected_tables] return DropTablesCase( + calls=[ + Call( + "prefix", + returns={"purchaseOrders": [{"id": "1"}]}, + keep_raw=keep_raw, + ), + Call( + "notdropped", + returns={"purchaseOrders": [{"id": "1"}]}, + keep_raw=keep_raw, + ), + ], drop="prefix", - values={ - "prefix": [{"purchaseOrders": [{"id": "1"}]}], - "notdropped": [{"purchaseOrders": [{"id": "1"}]}], - }, expected_tables=expected_tables, - keep_raw=keep_raw, ) diff --git a/tests/test_duckdb.py b/tests/test_duckdb.py index 1c3f8ea..f89baca 100644 --- a/tests/test_duckdb.py +++ b/tests/test_duckdb.py @@ -31,8 +31,8 @@ def test_drop_tables( ld.connect_db(dsn) ld.drop_tables(tc.drop) - for prefix in tc.values: - ld.query(table=prefix, path="/patched", keep_raw=tc.keep_raw) + for call in tc.calls_list: + ld.query(table=call.prefix, path="/patched", keep_raw=call.keep_raw) ld.drop_tables(tc.drop) with duckdb.connect(dsn) as res: @@ -47,7 +47,7 @@ def test_drop_tables( res.execute('SELECT COUNT(*) FROM "ldlite_system"."load_history"') assert (ud := res.fetchone()) is not None - assert ud[0] == len(tc.values) - 1 + assert ud[0] == len(tc.calls_list) - 1 res.execute( 'SELECT COUNT(*) FROM "ldlite_system"."load_history" ' 'WHERE "table_name" = $1', diff --git a/tests/test_postgres.py b/tests/test_postgres.py index 1489869..3aedd05 100644 --- a/tests/test_postgres.py +++ b/tests/test_postgres.py @@ -58,8 +58,8 @@ def test_drop_tables( ld.connect_db_postgresql(dsn) ld.drop_tables(tc.drop) - for prefix in tc.values: - ld.query(table=prefix, path="/patched", keep_raw=tc.keep_raw) + for call in tc.calls_list: + ld.query(table=call.prefix, path="/patched", keep_raw=call.keep_raw) ld.drop_tables(tc.drop) with psycopg.connect(dsn) as conn, conn.cursor() as res: @@ -74,7 +74,7 @@ def test_drop_tables( res.execute('SELECT COUNT(*) FROM "ldlite_system"."load_history"') assert (ud := res.fetchone()) is not None - assert ud[0] == len(tc.values) - 1 + assert ud[0] == len(tc.calls) - 1 res.execute( 'SELECT COUNT(*) FROM "ldlite_system"."load_history"' 'WHERE "table_name" = %s', From e79a02ca958a5d7d0fae839227eda85215d1beb6 Mon Sep 17 00:00:00 2001 From: Katherine Bargar Date: Wed, 24 Sep 2025 13:58:43 +0000 Subject: [PATCH 04/17] WIP: Refactor load_history to use calls object --- tests/test_cases/drop_tables_cases.py | 6 +- tests/test_cases/load_history_cases.py | 118 +++++++++++-------------- tests/test_duckdb.py | 12 +-- tests/test_postgres.py | 12 +-- 4 files changed, 61 insertions(+), 87 deletions(-) diff --git a/tests/test_cases/drop_tables_cases.py b/tests/test_cases/drop_tables_cases.py index 995feb6..f7c7d1e 100644 --- a/tests/test_cases/drop_tables_cases.py +++ b/tests/test_cases/drop_tables_cases.py @@ -15,7 +15,7 @@ class DropTablesCases: @parametrize(keep_raw=[True, False]) def case_one_table(self, keep_raw: bool) -> DropTablesCase: return DropTablesCase( - calls=Call( + Call( "prefix", returns={"purchaseOrders": [{"id": "1"}]}, keep_raw=keep_raw, @@ -27,7 +27,7 @@ def case_one_table(self, keep_raw: bool) -> DropTablesCase: @parametrize(keep_raw=[True, False]) def case_two_tables(self, keep_raw: bool) -> DropTablesCase: return DropTablesCase( - calls=Call( + Call( "prefix", returns={ "purchaseOrders": [ @@ -53,7 +53,7 @@ def case_separate_table(self, keep_raw: bool) -> DropTablesCase: expected_tables = ["notdropped", *expected_tables] return DropTablesCase( - calls=[ + [ Call( "prefix", returns={"purchaseOrders": [{"id": "1"}]}, diff --git a/tests/test_cases/load_history_cases.py b/tests/test_cases/load_history_cases.py index ed1727b..e2f49d7 100644 --- a/tests/test_cases/load_history_cases.py +++ b/tests/test_cases/load_history_cases.py @@ -2,12 +2,11 @@ from pytest_cases import parametrize -from .base import EndToEndTestCase +from .base import Call, EndToEndTestCase @dataclass(frozen=True) class LoadHistoryCase(EndToEndTestCase): - queries: dict[str, list[str | None | dict[str, str]]] expected_loads: dict[str, tuple[str | None, int]] @@ -15,33 +14,63 @@ class LoadHistoryTestCases: @parametrize(query=[None, "poline.id=*A"]) def case_one_load(self, query: str | None) -> LoadHistoryCase: return LoadHistoryCase( - values={ - "prefix": [ - { + Call( + "prefix", + query=query, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + }, + { + "id": "b096504a-9999-4664-9bf5-1b872466fd66", + "value": "value-2", + }, + ], + }, + ), + expected_loads={"prefix": (query, 2)}, + ) + + def case_schema_load(self) -> LoadHistoryCase: + return LoadHistoryCase( + Call( + "schema.prefix", + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + }, + { + "id": "b096504a-9999-4664-9bf5-1b872466fd66", + "value": "value-2", + }, + ], + }, + ), + expected_loads={"schema.prefix": (None, 2)}, + ) + + def case_two_loads(self) -> LoadHistoryCase: + return LoadHistoryCase( + [ + Call( + "prefix", + returns={ "purchaseOrders": [ { "id": "b096504a-3d54-4664-9bf5-1b872466fd66", "value": "value", }, - { - "id": "b096504a-9999-4664-9bf5-1b872466fd66", - "value": "value-2", - }, ], }, - ], - }, - queries={"prefix": [query]}, - expected_loads={ - "prefix": (query, 2), - }, - ) - - def case_schema_load(self) -> LoadHistoryCase: - return LoadHistoryCase( - values={ - "schema.prefix": [ - { + ), + Call( + "prefix", + query="a query", + returns={ "purchaseOrders": [ { "id": "b096504a-3d54-4664-9bf5-1b872466fd66", @@ -53,46 +82,7 @@ def case_schema_load(self) -> LoadHistoryCase: }, ], }, - ], - }, - queries={"schema.prefix": [None]}, - expected_loads={ - "schema.prefix": (None, 2), - }, - ) - - def case_two_loads(self) -> LoadHistoryCase: - return LoadHistoryCase( - values={ - "prefix": [ - [ - { - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - }, - ], - }, - ], - [ - { - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - }, - { - "id": "b096504a-9999-4664-9bf5-1b872466fd66", - "value": "value-2", - }, - ], - }, - ], - ], - }, - queries={"prefix": [None, "a query"]}, - expected_loads={ - "prefix": ("a query", 2), - }, + ), + ], + expected_loads={"prefix": ("a query", 2)}, ) diff --git a/tests/test_duckdb.py b/tests/test_duckdb.py index f89baca..3e7ab4c 100644 --- a/tests/test_duckdb.py +++ b/tests/test_duckdb.py @@ -151,16 +151,8 @@ def test_history( ld.connect_folio("https://doesnt.matter", "", "", "") ld.connect_db(dsn) - for prefix, calls in cast( - "dict[str, list[list[dict[str, Any]]]]", - tc.values, - ).items(): - for i in range(len(calls)): - ld.query( - table=prefix, - path="/patched", - query=tc.queries[prefix][i], - ) + for call in tc.calls_list: + ld.query(table=call.prefix, path="/patched", query=call.query) with duckdb.connect(dsn) as res: res.execute('SELECT COUNT(*) FROM "ldlite_system"."load_history"') diff --git a/tests/test_postgres.py b/tests/test_postgres.py index 3aedd05..ff7d4a4 100644 --- a/tests/test_postgres.py +++ b/tests/test_postgres.py @@ -213,16 +213,8 @@ def test_history( ld.connect_folio("https://doesnt.matter", "", "", "") ld.connect_db_postgresql(dsn) - for prefix, calls in cast( - "dict[str, list[list[dict[str, Any]]]]", - tc.values, - ).items(): - for i in range(len(calls)): - ld.query( - table=prefix, - path="/patched", - query=tc.queries[prefix][i], - ) + for call in tc.calls_list: + ld.query(table=call.prefix, path="/patched", query=call.query) with psycopg.connect(dsn) as conn, conn.cursor() as res: res.execute('SELECT COUNT(*) FROM "ldlite_system"."load_history"') From 8cf8a892f639b7cf76ce168d60ec83a6fa27509e Mon Sep 17 00:00:00 2001 From: Katherine Bargar Date: Wed, 24 Sep 2025 14:01:54 +0000 Subject: [PATCH 05/17] WIP: Refactor to_csv to use calls object --- tests/test_cases/to_csv_cases.py | 114 +++++++++++++------------------ tests/test_duckdb.py | 4 +- tests/test_postgres.py | 4 +- 3 files changed, 52 insertions(+), 70 deletions(-) diff --git a/tests/test_cases/to_csv_cases.py b/tests/test_cases/to_csv_cases.py index baf2d9c..b15aad8 100644 --- a/tests/test_cases/to_csv_cases.py +++ b/tests/test_cases/to_csv_cases.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from pathlib import Path -from .base import EndToEndTestCase +from .base import Call, EndToEndTestCase _SAMPLE_PATH = Path() / "tests" / "test_cases" / "to_csv_samples" @@ -14,87 +14,69 @@ class ToCsvCase(EndToEndTestCase): class ToCsvCases: def case_basic(self) -> ToCsvCase: return ToCsvCase( - values={"prefix": [{"purchaseOrders": [{"id": "id", "val": "value"}]}]}, + Call("prefix", returns={"purchaseOrders": [{"id": "id", "val": "value"}]}), expected_csvs=[("prefix__t", _SAMPLE_PATH / "basic.csv")], ) def case_datatypes(self) -> ToCsvCase: return ToCsvCase( - values={ - "prefix": [ - { - "purchaseOrders": [ - { - "id": "id", - "string": "string", - "integer": 1, - "numeric": 1.1, - "boolean": True, - "uuid": "6a31a12a-9570-405c-af20-6abf2992859c", - }, - ], - }, - ], - }, + Call( + "prefix", + returns={ + "purchaseOrders": [ + { + "id": "id", + "string": "string", + "integer": 1, + "numeric": 1.1, + "boolean": True, + "uuid": "6a31a12a-9570-405c-af20-6abf2992859c", + }, + ], + }, + ), expected_csvs=[("prefix__t", _SAMPLE_PATH / "datatypes.csv")], ) def case_escaped_chars(self) -> ToCsvCase: return ToCsvCase( - values={ - "prefix": [ - { - "purchaseOrders": [ - { - "id": "id", - "comma": "Double, double toil and trouble", - "doubleQuote": 'Cry "Havoc!" a horse', - "newLine": """To be + Call( + "prefix", + returns={ + "purchaseOrders": [ + { + "id": "id", + "comma": "Double, double toil and trouble", + "doubleQuote": 'Cry "Havoc!" a horse', + "newLine": """To be or not to be""", - "singleQuote": "Cry 'Havoc!' a horse", - }, - { - "id": "id", - "comma": "Z", - "doubleQuote": "Z", - "newLine": "Z", - "singleQuote": "Z", - }, - ], - }, - ], - }, + "singleQuote": "Cry 'Havoc!' a horse", + }, + { + "id": "id", + "comma": "Z", + "doubleQuote": "Z", + "newLine": "Z", + "singleQuote": "Z", + }, + ], + }, + ), expected_csvs=[("prefix__t", _SAMPLE_PATH / "escaped_chars.csv")], ) def case_sorting(self) -> ToCsvCase: return ToCsvCase( - values={ - "prefix": [ - { - "purchaseOrders": [ - { - "id": "id", - "C": "YY", - "B": "XX", - "A": "ZZ", - }, - { - "id": "id", - "C": "Y", - "B": "XX", - "A": "ZZ", - }, - { - "id": "id", - "C": "Y", - "B": "X", - "A": "Z", - }, - ], - }, - ], - }, + Call( + "prefix", + returns={ + "purchaseOrders": [ + {"id": "id", "C": "YY", "B": "XX", "A": "ZZ"}, + {"id": "id", "C": "Y", "B": "XX", "A": "ZZ"}, + {"id": "id", "C": "Y", "B": "X", "A": "Z"}, + ], + }, + ), expected_csvs=[("prefix__t", _SAMPLE_PATH / "sorting.csv")], ) diff --git a/tests/test_duckdb.py b/tests/test_duckdb.py index 3e7ab4c..fec70e5 100644 --- a/tests/test_duckdb.py +++ b/tests/test_duckdb.py @@ -117,8 +117,8 @@ def test_to_csv( ld.connect_folio("https://doesnt.matter", "", "", "") ld.connect_db(dsn) - for prefix in tc.values: - ld.query(table=prefix, path="/patched") + for call in tc.calls_list: + ld.query(table=call.prefix, path="/patched") for table, expected in tc.expected_csvs: actual = (Path(tmpdir) / table).with_suffix(".csv") diff --git a/tests/test_postgres.py b/tests/test_postgres.py index ff7d4a4..bbd8574 100644 --- a/tests/test_postgres.py +++ b/tests/test_postgres.py @@ -175,8 +175,8 @@ def test_to_csv( ld.connect_folio("https://doesnt.matter", "", "", "") ld.connect_db_postgresql(dsn) - for prefix in tc.values: - ld.query(table=prefix, path="/patched") + for call in tc.calls_list: + ld.query(table=call.prefix, path="/patched") for table, expected in tc.expected_csvs: actual = (Path(tmpdir) / table).with_suffix(".csv") From 42babffa371af42d5962dfb4fe64f5572cb2f6f6 Mon Sep 17 00:00:00 2001 From: Katherine Bargar Date: Wed, 24 Sep 2025 14:11:53 +0000 Subject: [PATCH 06/17] Finish refactoring test cases to use calls object --- tests/test_cases/query_cases.py | 484 ++++++++++++++++---------------- tests/test_duckdb.py | 9 +- tests/test_postgres.py | 12 +- 3 files changed, 245 insertions(+), 260 deletions(-) diff --git a/tests/test_cases/query_cases.py b/tests/test_cases/query_cases.py index 9e3725e..721f6bf 100644 --- a/tests/test_cases/query_cases.py +++ b/tests/test_cases/query_cases.py @@ -4,35 +4,32 @@ from pytest_cases import parametrize -from .base import EndToEndTestCase +from .base import Call, EndToEndTestCase @dataclass(frozen=True) class QueryCase(EndToEndTestCase): - json_depth: int expected_tables: list[str] expected_values: dict[str, tuple[list[str], list[tuple[Any, ...]]]] expected_indexes: list[tuple[str, str]] | None = None - keep_raw: bool = True class QueryTestCases: @parametrize(json_depth=range(1, 2)) def case_one_table(self, json_depth: int) -> QueryCase: return QueryCase( - json_depth=json_depth, - values={ - "prefix": [ - { - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - }, - ], - }, - ], - }, + Call( + "prefix", + json_depth=json_depth, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + }, + ], + }, + ), expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], expected_values={ "prefix__t": ( @@ -51,29 +48,28 @@ def case_one_table(self, json_depth: int) -> QueryCase: @parametrize(json_depth=range(2, 3)) def case_two_tables(self, json_depth: int) -> QueryCase: return QueryCase( - json_depth=json_depth, - values={ - "prefix": [ - { - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - "subObjects": [ - { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-value-1", - }, - { - "id": "f5bda109-a719-4f72-b797-b9c22f45e4e1", - "value": "sub-value-2", - }, - ], - }, - ], - }, - ], - }, + Call( + "prefix", + json_depth=json_depth, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + "subObjects": [ + { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-value-1", + }, + { + "id": "f5bda109-a719-4f72-b797-b9c22f45e4e1", + "value": "sub-value-2", + }, + ], + }, + ], + }, + ), expected_tables=[ "prefix", "prefix__t", @@ -119,57 +115,55 @@ def case_two_tables(self, json_depth: int) -> QueryCase: @parametrize(json_depth=range(1)) def case_table_no_expansion(self, json_depth: int) -> QueryCase: return QueryCase( - json_depth=json_depth, - values={ - "prefix": [ - { - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - "subObjects": [ - { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-value", - }, - ], - }, - ], - }, - ], - }, + Call( + "prefix", + json_depth=json_depth, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + "subObjects": [ + { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-value", + }, + ], + }, + ], + }, + ), expected_tables=["prefix"], expected_values={}, ) def case_table_underexpansion(self) -> QueryCase: return QueryCase( - json_depth=2, - values={ - "prefix": [ - { - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "subObjects": [ - { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-value", - "subSubObjects": [ - { - "id": ( - "2b94c631-fca9-4892-a730-03ee529ffe2a" - ), - "value": "sub-sub-value", - }, - ], - }, - ], - }, - ], - }, - ], - }, + Call( + "prefix", + json_depth=2, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "subObjects": [ + { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-value", + "subSubObjects": [ + { + "id": ( + "2b94c631-fca9-4892-a730-03ee529ffe2a" + ), + "value": "sub-sub-value", + }, + ], + }, + ], + }, + ], + }, + ), expected_tables=[ "prefix", "prefix__t", @@ -201,33 +195,32 @@ def case_table_underexpansion(self) -> QueryCase: @parametrize(json_depth=range(3, 4)) def case_three_tables(self, json_depth: int) -> QueryCase: return QueryCase( - json_depth=json_depth, - values={ - "prefix": [ - { - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - "subObjects": [ - { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-value", - "subSubObjects": [ - { - "id": ( - "2b94c631-fca9-4892-a730-03ee529ffe2a" - ), - "value": "sub-sub-value", - }, - ], - }, - ], - }, - ], - }, - ], - }, + Call( + "prefix", + json_depth=json_depth, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + "subObjects": [ + { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-value", + "subSubObjects": [ + { + "id": ( + "2b94c631-fca9-4892-a730-03ee529ffe2a" + ), + "value": "sub-sub-value", + }, + ], + }, + ], + }, + ], + }, + ), expected_tables=[ "prefix", "prefix__t", @@ -286,23 +279,22 @@ def case_three_tables(self, json_depth: int) -> QueryCase: def case_nested_object(self) -> QueryCase: return QueryCase( - json_depth=2, - values={ - "prefix": [ - { - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - "subObject": { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-value", - }, + Call( + "prefix", + json_depth=2, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + "subObject": { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-value", }, - ], - }, - ], - }, + }, + ], + }, + ), expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], expected_values={ "prefix__t": ( @@ -331,27 +323,26 @@ def case_nested_object(self) -> QueryCase: def case_doubly_nested_object(self) -> QueryCase: return QueryCase( - json_depth=3, - values={ - "prefix": [ - { - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - "subObject": { + Call( + "prefix", + json_depth=3, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + "subObject": { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-value", + "subSubObject": { "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-value", - "subSubObject": { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-sub-value", - }, + "value": "sub-sub-value", }, }, - ], - }, - ], - }, + }, + ], + }, + ), expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], expected_values={ "prefix__t": ( @@ -388,23 +379,22 @@ def case_doubly_nested_object(self) -> QueryCase: def case_nested_object_underexpansion(self) -> QueryCase: return QueryCase( - json_depth=1, - values={ - "prefix": [ - { - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - "subObject": { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-value", - }, + Call( + "prefix", + json_depth=1, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + "subObject": { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-value", }, - ], - }, - ], - }, + }, + ], + }, + ), expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], expected_values={ "prefix__t": ( @@ -432,50 +422,49 @@ def case_nested_object_underexpansion(self) -> QueryCase: def case_id_generation(self) -> QueryCase: return QueryCase( - json_depth=4, - values={ - "prefix": [ - { - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "subObjects": [ - { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "subSubObjects": [ - { - "id": ( - "2b94c631-fca9-4892-a730-03ee529ffe2a" - ), - }, - { - "id": ( - "8516a913-8bf7-55a4-ab71-417aba9171c9" - ), - }, - ], - }, - { - "id": "b5d8cdc4-9441-487c-90cf-0c7ec97728eb", - "subSubObjects": [ - { - "id": ( - "13a24cc8-a15c-4158-abbd-4abf25c8815a" - ), - }, - { - "id": ( - "37344879-09ce-4cd8-976f-bf1a57c0cfa6" - ), - }, - ], - }, - ], - }, - ], - }, - ], - }, + Call( + "prefix", + json_depth=4, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "subObjects": [ + { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "subSubObjects": [ + { + "id": ( + "2b94c631-fca9-4892-a730-03ee529ffe2a" + ), + }, + { + "id": ( + "8516a913-8bf7-55a4-ab71-417aba9171c9" + ), + }, + ], + }, + { + "id": "b5d8cdc4-9441-487c-90cf-0c7ec97728eb", + "subSubObjects": [ + { + "id": ( + "13a24cc8-a15c-4158-abbd-4abf25c8815a" + ), + }, + { + "id": ( + "37344879-09ce-4cd8-976f-bf1a57c0cfa6" + ), + }, + ], + }, + ], + }, + ], + }, + ), expected_tables=[ "prefix", "prefix__t", @@ -515,22 +504,21 @@ def case_id_generation(self) -> QueryCase: def case_indexing_id_like(self) -> QueryCase: return QueryCase( - json_depth=4, - values={ - "prefix": [ - { - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "otherId": "b096504a-3d54-4664-9bf5-1b872466fd66", - "anIdButWithADifferentEnding": ( - "b096504a-3d54-4664-9bf5-1b872466fd66" - ), - }, - ], - }, - ], - }, + Call( + "prefix", + json_depth=4, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "otherId": "b096504a-3d54-4664-9bf5-1b872466fd66", + "anIdButWithADifferentEnding": ( + "b096504a-3d54-4664-9bf5-1b872466fd66" + ), + }, + ], + }, + ), expected_tables=[ "prefix", "prefix__t", @@ -549,19 +537,19 @@ def case_indexing_id_like(self) -> QueryCase: @parametrize(json_depth=range(1, 2)) def case_drop_raw(self, json_depth: int) -> QueryCase: return QueryCase( - json_depth=json_depth, - values={ - "prefix": [ - { - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - }, - ], - }, - ], - }, + Call( + "prefix", + json_depth=json_depth, + keep_raw=False, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + }, + ], + }, + ), expected_tables=["prefix__t", "prefix__tcatalog"], expected_values={ "prefix__t": ( @@ -574,27 +562,25 @@ def case_drop_raw(self, json_depth: int) -> QueryCase: ("prefix__t", "__id"), ("prefix__t", "id"), ], - keep_raw=False, ) # this case should be testing the FolioClient class # but it isn't setup to mock the data properly right now def case_null_records(self) -> QueryCase: return QueryCase( - json_depth=1, - values={ - "prefix": [ - { - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - }, - None, - ], - }, - ], - }, + Call( + "prefix", + json_depth=1, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + }, + None, + ], + }, + ), expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], expected_values={}, expected_indexes=[ diff --git a/tests/test_duckdb.py b/tests/test_duckdb.py index fec70e5..7bb0cf3 100644 --- a/tests/test_duckdb.py +++ b/tests/test_duckdb.py @@ -1,6 +1,5 @@ from difflib import unified_diff from pathlib import Path -from typing import Any, cast from unittest import mock from unittest.mock import MagicMock @@ -73,12 +72,12 @@ def test_query( ld.connect_folio("https://doesnt.matter", "", "", "") ld.connect_db(dsn) - for prefix in tc.values: + for call in tc.calls_list: ld.query( - table=prefix, + table=call.prefix, path="/patched", - json_depth=tc.json_depth, - keep_raw=tc.keep_raw, + json_depth=call.json_depth, + keep_raw=call.keep_raw, ) with duckdb.connect(dsn) as res: diff --git a/tests/test_postgres.py b/tests/test_postgres.py index bbd8574..2cf4e91 100644 --- a/tests/test_postgres.py +++ b/tests/test_postgres.py @@ -2,7 +2,7 @@ from collections.abc import Callable from difflib import unified_diff from pathlib import Path -from typing import Any, cast +from typing import cast from unittest import mock from unittest.mock import MagicMock @@ -74,7 +74,7 @@ def test_drop_tables( res.execute('SELECT COUNT(*) FROM "ldlite_system"."load_history"') assert (ud := res.fetchone()) is not None - assert ud[0] == len(tc.calls) - 1 + assert ud[0] == len(tc.calls_list) - 1 res.execute( 'SELECT COUNT(*) FROM "ldlite_system"."load_history"' 'WHERE "table_name" = %s', @@ -104,12 +104,12 @@ def test_query( ld.connect_folio("https://doesnt.matter", "", "", "") ld.connect_db_postgresql(dsn) - for prefix in tc.values: + for call in tc.calls_list: ld.query( - table=prefix, + table=call.prefix, path="/patched", - json_depth=tc.json_depth, - keep_raw=tc.keep_raw, + json_depth=call.json_depth, + keep_raw=call.keep_raw, ) with psycopg.connect(dsn) as conn: From ccd74c00c77a00eff86eb2bb30c2ef9555e449ac Mon Sep 17 00:00:00 2001 From: Katherine Bargar Date: Wed, 24 Sep 2025 14:16:34 +0000 Subject: [PATCH 07/17] Rename base test case --- tests/test_cases/base.py | 2 +- tests/test_cases/drop_tables_cases.py | 4 ++-- tests/test_cases/load_history_cases.py | 4 ++-- tests/test_cases/query_cases.py | 4 ++-- tests/test_cases/to_csv_cases.py | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/test_cases/base.py b/tests/test_cases/base.py index 4abe662..353be9e 100644 --- a/tests/test_cases/base.py +++ b/tests/test_cases/base.py @@ -29,7 +29,7 @@ def returns_list(self) -> list["ldlite._jsonx.Json"]: @dataclass(frozen=True) -class EndToEndTestCase: +class MockedResponseTestCase: calls: Call | list[Call] @property diff --git a/tests/test_cases/drop_tables_cases.py b/tests/test_cases/drop_tables_cases.py index f7c7d1e..181291e 100644 --- a/tests/test_cases/drop_tables_cases.py +++ b/tests/test_cases/drop_tables_cases.py @@ -2,11 +2,11 @@ from pytest_cases import parametrize -from .base import Call, EndToEndTestCase +from .base import Call, MockedResponseTestCase @dataclass(frozen=True) -class DropTablesCase(EndToEndTestCase): +class DropTablesCase(MockedResponseTestCase): drop: str expected_tables: list[str] diff --git a/tests/test_cases/load_history_cases.py b/tests/test_cases/load_history_cases.py index e2f49d7..542a301 100644 --- a/tests/test_cases/load_history_cases.py +++ b/tests/test_cases/load_history_cases.py @@ -2,11 +2,11 @@ from pytest_cases import parametrize -from .base import Call, EndToEndTestCase +from .base import Call, MockedResponseTestCase @dataclass(frozen=True) -class LoadHistoryCase(EndToEndTestCase): +class LoadHistoryCase(MockedResponseTestCase): expected_loads: dict[str, tuple[str | None, int]] diff --git a/tests/test_cases/query_cases.py b/tests/test_cases/query_cases.py index 721f6bf..3af1fb7 100644 --- a/tests/test_cases/query_cases.py +++ b/tests/test_cases/query_cases.py @@ -4,11 +4,11 @@ from pytest_cases import parametrize -from .base import Call, EndToEndTestCase +from .base import Call, MockedResponseTestCase @dataclass(frozen=True) -class QueryCase(EndToEndTestCase): +class QueryCase(MockedResponseTestCase): expected_tables: list[str] expected_values: dict[str, tuple[list[str], list[tuple[Any, ...]]]] expected_indexes: list[tuple[str, str]] | None = None diff --git a/tests/test_cases/to_csv_cases.py b/tests/test_cases/to_csv_cases.py index b15aad8..bb553a9 100644 --- a/tests/test_cases/to_csv_cases.py +++ b/tests/test_cases/to_csv_cases.py @@ -1,13 +1,13 @@ from dataclasses import dataclass from pathlib import Path -from .base import Call, EndToEndTestCase +from .base import Call, MockedResponseTestCase _SAMPLE_PATH = Path() / "tests" / "test_cases" / "to_csv_samples" @dataclass(frozen=True) -class ToCsvCase(EndToEndTestCase): +class ToCsvCase(MockedResponseTestCase): expected_csvs: list[tuple[str, Path]] From 775c04505fd0f98f0c2c98e984cc5a8f326f6fdd Mon Sep 17 00:00:00 2001 From: Katherine Bargar Date: Wed, 24 Sep 2025 14:51:34 +0000 Subject: [PATCH 08/17] Refactor drop tables cases to a shared file/logic --- tests/conftest.py | 25 ++++ tests/test_cases/drop_tables_cases.py | 70 ----------- tests/test_drop_tables.py | 171 ++++++++++++++++++++++++++ tests/test_duckdb.py | 44 ------- tests/test_postgres.py | 69 ----------- 5 files changed, 196 insertions(+), 183 deletions(-) delete mode 100644 tests/test_cases/drop_tables_cases.py create mode 100644 tests/test_drop_tables.py diff --git a/tests/conftest.py b/tests/conftest.py index b9ccef2..38a7e5d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,10 @@ +import contextlib +from collections.abc import Callable + +import psycopg import pytest from httpx_folio.auth import FolioParams +from psycopg import sql def pytest_addoption(parser: pytest.Parser) -> None: @@ -24,3 +29,23 @@ def folio_params(pytestconfig: pytest.Config) -> tuple[bool, FolioParams]: pytestconfig.getoption("folio_password") or "admin", ), ) + + +@pytest.fixture(scope="session") +def pg_dsn(pytestconfig: pytest.Config) -> None | Callable[[str], str]: + host = pytestconfig.getoption("pg_host") + if host is None: + return None + + def setup(db: str) -> str: + base_dsn = f"host={host} user=ldlite password=ldlite" + with contextlib.closing(psycopg.connect(base_dsn)) as base_conn: + base_conn.autocommit = True + with base_conn.cursor() as curr: + curr.execute( + sql.SQL("CREATE DATABASE {db};").format(db=sql.Identifier(db)), + ) + + return base_dsn + f" dbname={db}" + + return setup diff --git a/tests/test_cases/drop_tables_cases.py b/tests/test_cases/drop_tables_cases.py deleted file mode 100644 index 181291e..0000000 --- a/tests/test_cases/drop_tables_cases.py +++ /dev/null @@ -1,70 +0,0 @@ -from dataclasses import dataclass - -from pytest_cases import parametrize - -from .base import Call, MockedResponseTestCase - - -@dataclass(frozen=True) -class DropTablesCase(MockedResponseTestCase): - drop: str - expected_tables: list[str] - - -class DropTablesCases: - @parametrize(keep_raw=[True, False]) - def case_one_table(self, keep_raw: bool) -> DropTablesCase: - return DropTablesCase( - Call( - "prefix", - returns={"purchaseOrders": [{"id": "1"}]}, - keep_raw=keep_raw, - ), - drop="prefix", - expected_tables=[], - ) - - @parametrize(keep_raw=[True, False]) - def case_two_tables(self, keep_raw: bool) -> DropTablesCase: - return DropTablesCase( - Call( - "prefix", - returns={ - "purchaseOrders": [ - { - "id": "1", - "subObjects": [{"id": "2"}, {"id": "3"}], - }, - ], - }, - keep_raw=keep_raw, - ), - drop="prefix", - expected_tables=[], - ) - - @parametrize(keep_raw=[True, False]) - def case_separate_table(self, keep_raw: bool) -> DropTablesCase: - expected_tables = [ - "notdropped__t", - "notdropped__tcatalog", - ] - if keep_raw: - expected_tables = ["notdropped", *expected_tables] - - return DropTablesCase( - [ - Call( - "prefix", - returns={"purchaseOrders": [{"id": "1"}]}, - keep_raw=keep_raw, - ), - Call( - "notdropped", - returns={"purchaseOrders": [{"id": "1"}]}, - keep_raw=keep_raw, - ), - ], - drop="prefix", - expected_tables=expected_tables, - ) diff --git a/tests/test_drop_tables.py b/tests/test_drop_tables.py new file mode 100644 index 0000000..7933d90 --- /dev/null +++ b/tests/test_drop_tables.py @@ -0,0 +1,171 @@ +from collections.abc import Callable +from contextlib import closing +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast +from unittest import mock +from unittest.mock import MagicMock + +import duckdb +import psycopg +import pytest +from pytest_cases import parametrize, parametrize_with_cases + +from .test_cases.base import Call, MockedResponseTestCase + +if TYPE_CHECKING: + from _typeshed import dbapi + + import ldlite + + +@dataclass(frozen=True) +class DropTablesCase(MockedResponseTestCase): + drop: str + expected_tables: list[str] + + +class DropTablesCases: + @parametrize(keep_raw=[True, False]) + def case_one_table(self, keep_raw: bool) -> DropTablesCase: + return DropTablesCase( + Call( + "prefix", + returns={"purchaseOrders": [{"id": "1"}]}, + keep_raw=keep_raw, + ), + drop="prefix", + expected_tables=[], + ) + + @parametrize(keep_raw=[True, False]) + def case_two_tables(self, keep_raw: bool) -> DropTablesCase: + return DropTablesCase( + Call( + "prefix", + returns={ + "purchaseOrders": [ + { + "id": "1", + "subObjects": [{"id": "2"}, {"id": "3"}], + }, + ], + }, + keep_raw=keep_raw, + ), + drop="prefix", + expected_tables=[], + ) + + @parametrize(keep_raw=[True, False]) + def case_separate_table(self, keep_raw: bool) -> DropTablesCase: + expected_tables = [ + "notdropped__t", + "notdropped__tcatalog", + ] + if keep_raw: + expected_tables = ["notdropped", *expected_tables] + + return DropTablesCase( + [ + Call( + "prefix", + returns={"purchaseOrders": [{"id": "1"}]}, + keep_raw=keep_raw, + ), + Call( + "notdropped", + returns={"purchaseOrders": [{"id": "1"}]}, + keep_raw=keep_raw, + ), + ], + drop="prefix", + expected_tables=expected_tables, + ) + + +def _arrange( + client_get_mock: MagicMock, + httpx_post_mock: MagicMock, + tc: DropTablesCase, +) -> "ldlite.LDLite": + from ldlite import LDLite + + uut = LDLite() + tc.patch_request_get(uut, httpx_post_mock, client_get_mock) + uut.connect_folio("https://doesnt.matter", "", "", "") + return uut + + +def _act(uut: "ldlite.LDLite", tc: DropTablesCase) -> None: + uut.drop_tables(tc.drop) + for call in tc.calls_list: + uut.query(table=call.prefix, path="/patched", keep_raw=call.keep_raw) + uut.drop_tables(tc.drop) + + +def _assert( + conn: "dbapi.DBAPIConnection", + res_schema: str, # TODO: have schema be part of tc + tc: DropTablesCase, +) -> None: + with closing(conn.cursor()) as cur: + cur.execute( + """ + SELECT table_name + FROM information_schema.tables + WHERE table_schema=$1 + """, + (res_schema,), + ) + assert sorted([r[0] for r in cur.fetchall()]) == sorted(tc.expected_tables) + + cur.execute('SELECT COUNT(*) FROM "ldlite_system"."load_history"') + assert (ud := cur.fetchone()) is not None + assert ud[0] == len(tc.calls_list) - 1 + cur.execute( + 'SELECT COUNT(*) FROM "ldlite_system"."load_history" ' + 'WHERE "table_name" = $1', + (tc.drop,), + ) + assert (d := cur.fetchone()) is not None + assert d[0] == 0 + + +@mock.patch("httpx_folio.auth.httpx.post") +@mock.patch("httpx_folio.factories.httpx.Client.get") +@parametrize_with_cases("tc", cases=DropTablesCases) +def test_duckdb( + client_get_mock: MagicMock, + httpx_post_mock: MagicMock, + tc: DropTablesCase, +) -> None: + uut = _arrange(client_get_mock, httpx_post_mock, tc) + dsn = f":memory:{tc.db}" + uut.connect_db(dsn) + + _act(uut, tc) + + with duckdb.connect(dsn) as conn: + _assert(cast("dbapi.DBAPIConnection", conn), "main", tc) + + +@mock.patch("httpx_folio.auth.httpx.post") +@mock.patch("httpx_folio.factories.httpx.Client.get") +@parametrize_with_cases("tc", cases=DropTablesCases) +def test_postgres( + client_get_mock: MagicMock, + httpx_post_mock: MagicMock, + pg_dsn: None | Callable[[str], str], + tc: DropTablesCase, +) -> None: + if pg_dsn is None: + pytest.skip("Specify the pg host using --pg-host to run") + + uut = _arrange(client_get_mock, httpx_post_mock, tc) + dsn = pg_dsn(tc.db) + uut.connect_db_postgresql(dsn) + + _act(uut, tc) + + with psycopg.connect(dsn, cursor_factory=psycopg.RawCursor) as conn: + _assert(cast("dbapi.DBAPIConnection", conn), "public", tc) diff --git a/tests/test_duckdb.py b/tests/test_duckdb.py index 7bb0cf3..85ac0f6 100644 --- a/tests/test_duckdb.py +++ b/tests/test_duckdb.py @@ -7,55 +7,11 @@ import pytest from pytest_cases import parametrize_with_cases -from tests.test_cases import drop_tables_cases as dtc from tests.test_cases import load_history_cases as lhc from tests.test_cases import query_cases as qc from tests.test_cases import to_csv_cases as csvc -@mock.patch("httpx_folio.auth.httpx.post") -@mock.patch("httpx_folio.factories.httpx.Client.get") -@parametrize_with_cases("tc", cases=dtc.DropTablesCases) -def test_drop_tables( - client_get_mock: MagicMock, - httpx_post_mock: MagicMock, - tc: dtc.DropTablesCase, -) -> None: - from ldlite import LDLite as uut - - ld = uut() - tc.patch_request_get(ld, httpx_post_mock, client_get_mock) - dsn = f":memory:{tc.db}" - ld.connect_folio("https://doesnt.matter", "", "", "") - ld.connect_db(dsn) - ld.drop_tables(tc.drop) - - for call in tc.calls_list: - ld.query(table=call.prefix, path="/patched", keep_raw=call.keep_raw) - ld.drop_tables(tc.drop) - - with duckdb.connect(dsn) as res: - res.execute( - """ - SELECT table_name - FROM information_schema.tables - WHERE table_schema='main' - """, - ) - assert sorted([r[0] for r in res.fetchall()]) == sorted(tc.expected_tables) - - res.execute('SELECT COUNT(*) FROM "ldlite_system"."load_history"') - assert (ud := res.fetchone()) is not None - assert ud[0] == len(tc.calls_list) - 1 - res.execute( - 'SELECT COUNT(*) FROM "ldlite_system"."load_history" ' - 'WHERE "table_name" = $1', - (tc.drop,), - ) - assert (d := res.fetchone()) is not None - assert d[0] == 0 - - @mock.patch("httpx_folio.auth.httpx.post") @mock.patch("httpx_folio.factories.httpx.Client.get") @parametrize_with_cases("tc", cases=qc.QueryTestCases) diff --git a/tests/test_postgres.py b/tests/test_postgres.py index 2cf4e91..c764416 100644 --- a/tests/test_postgres.py +++ b/tests/test_postgres.py @@ -1,4 +1,3 @@ -import contextlib from collections.abc import Callable from difflib import unified_diff from pathlib import Path @@ -11,79 +10,11 @@ from psycopg import sql from pytest_cases import parametrize_with_cases -from tests.test_cases import drop_tables_cases as dtc from tests.test_cases import load_history_cases as lhc from tests.test_cases import query_cases as qc from tests.test_cases import to_csv_cases as csvc -@pytest.fixture(scope="session") -def pg_dsn(pytestconfig: pytest.Config) -> None | Callable[[str], str]: - host = pytestconfig.getoption("pg_host") - if host is None: - return None - - def setup(db: str) -> str: - base_dsn = f"host={host} user=ldlite password=ldlite" - with contextlib.closing(psycopg.connect(base_dsn)) as base_conn: - base_conn.autocommit = True - with base_conn.cursor() as curr: - curr.execute( - sql.SQL("CREATE DATABASE {db};").format(db=sql.Identifier(db)), - ) - - return base_dsn + f" dbname={db}" - - return setup - - -@mock.patch("httpx_folio.auth.httpx.post") -@mock.patch("httpx_folio.factories.httpx.Client.get") -@parametrize_with_cases("tc", cases=dtc.DropTablesCases) -def test_drop_tables( - client_get_mock: MagicMock, - httpx_post_mock: MagicMock, - pg_dsn: None | Callable[[str], str], - tc: dtc.DropTablesCase, -) -> None: - if pg_dsn is None: - pytest.skip("Specify the pg host using --pg-host to run") - - from ldlite import LDLite as uut - - ld = uut() - tc.patch_request_get(ld, httpx_post_mock, client_get_mock) - dsn = pg_dsn(tc.db) - ld.connect_folio("https://doesnt.matter", "", "", "") - ld.connect_db_postgresql(dsn) - ld.drop_tables(tc.drop) - - for call in tc.calls_list: - ld.query(table=call.prefix, path="/patched", keep_raw=call.keep_raw) - ld.drop_tables(tc.drop) - - with psycopg.connect(dsn) as conn, conn.cursor() as res: - res.execute( - """ - SELECT table_name - FROM information_schema.tables - WHERE table_schema='public' - """, - ) - assert sorted([r[0] for r in res.fetchall()]) == sorted(tc.expected_tables) - - res.execute('SELECT COUNT(*) FROM "ldlite_system"."load_history"') - assert (ud := res.fetchone()) is not None - assert ud[0] == len(tc.calls_list) - 1 - res.execute( - 'SELECT COUNT(*) FROM "ldlite_system"."load_history"' - 'WHERE "table_name" = %s', - (tc.drop,), - ) - assert (d := res.fetchone()) is not None - assert d[0] == 0 - - @mock.patch("httpx_folio.auth.httpx.post") @mock.patch("httpx_folio.factories.httpx.Client.get") @parametrize_with_cases("tc", cases=qc.QueryTestCases) From f53020b002cca03ec12b0a69c80466f000655d2c Mon Sep 17 00:00:00 2001 From: Katherine Bargar Date: Wed, 24 Sep 2025 15:02:14 +0000 Subject: [PATCH 09/17] Refactor load history tests --- tests/test_duckdb.py | 36 -------- tests/test_load_history.py | 178 +++++++++++++++++++++++++++++++++++++ tests/test_postgres.py | 40 --------- 3 files changed, 178 insertions(+), 76 deletions(-) create mode 100644 tests/test_load_history.py diff --git a/tests/test_duckdb.py b/tests/test_duckdb.py index 85ac0f6..bf83c30 100644 --- a/tests/test_duckdb.py +++ b/tests/test_duckdb.py @@ -7,7 +7,6 @@ import pytest from pytest_cases import parametrize_with_cases -from tests.test_cases import load_history_cases as lhc from tests.test_cases import query_cases as qc from tests.test_cases import to_csv_cases as csvc @@ -88,38 +87,3 @@ def test_to_csv( diff = list(unified_diff(expected_lines, actual_lines)) if len(diff) > 0: pytest.fail("".join(diff)) - - -@mock.patch("httpx_folio.auth.httpx.post") -@mock.patch("httpx_folio.factories.httpx.Client.get") -@parametrize_with_cases("tc", cases=lhc.LoadHistoryTestCases) -def test_history( - client_get_mock: MagicMock, - httpx_post_mock: MagicMock, - tc: lhc.LoadHistoryCase, -) -> None: - from ldlite import LDLite as uut - - ld = uut() - tc.patch_request_get(ld, httpx_post_mock, client_get_mock) - dsn = f":memory:{tc.db}" - ld.connect_folio("https://doesnt.matter", "", "", "") - ld.connect_db(dsn) - - for call in tc.calls_list: - ld.query(table=call.prefix, path="/patched", query=call.query) - - with duckdb.connect(dsn) as res: - res.execute('SELECT COUNT(*) FROM "ldlite_system"."load_history"') - assert (ud := res.fetchone()) is not None - assert ud[0] == len(tc.expected_loads) - - for tn, (q, t) in tc.expected_loads.items(): - res.execute( - 'SELECT * FROM "ldlite_system"."load_history" WHERE "table_name" = $1', - (tn,), - ) - assert (d := res.fetchone()) is not None - assert d[1] == q - assert d[7] == t - assert d[6] > d[5] > d[4] > d[3] > d[2] diff --git a/tests/test_load_history.py b/tests/test_load_history.py new file mode 100644 index 0000000..674dde9 --- /dev/null +++ b/tests/test_load_history.py @@ -0,0 +1,178 @@ +from collections.abc import Callable +from contextlib import closing +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast +from unittest import mock +from unittest.mock import MagicMock + +import duckdb +import psycopg +import pytest +from pytest_cases import parametrize, parametrize_with_cases + +from .test_cases.base import Call, MockedResponseTestCase + +if TYPE_CHECKING: + from _typeshed import dbapi + + import ldlite + + +@dataclass(frozen=True) +class LoadHistoryCase(MockedResponseTestCase): + expected_loads: dict[str, tuple[str | None, int]] + + +class LoadHistoryTestCases: + @parametrize(query=[None, "poline.id=*A"]) + def case_one_load(self, query: str | None) -> LoadHistoryCase: + return LoadHistoryCase( + Call( + "prefix", + query=query, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + }, + { + "id": "b096504a-9999-4664-9bf5-1b872466fd66", + "value": "value-2", + }, + ], + }, + ), + expected_loads={"prefix": (query, 2)}, + ) + + def case_schema_load(self) -> LoadHistoryCase: + return LoadHistoryCase( + Call( + "schema.prefix", + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + }, + { + "id": "b096504a-9999-4664-9bf5-1b872466fd66", + "value": "value-2", + }, + ], + }, + ), + expected_loads={"schema.prefix": (None, 2)}, + ) + + def case_two_loads(self) -> LoadHistoryCase: + return LoadHistoryCase( + [ + Call( + "prefix", + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + }, + ], + }, + ), + Call( + "prefix", + query="a query", + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + }, + { + "id": "b096504a-9999-4664-9bf5-1b872466fd66", + "value": "value-2", + }, + ], + }, + ), + ], + expected_loads={"prefix": ("a query", 2)}, + ) + + +def _arrange( + client_get_mock: MagicMock, + httpx_post_mock: MagicMock, + tc: LoadHistoryCase, +) -> "ldlite.LDLite": + from ldlite import LDLite + + uut = LDLite() + tc.patch_request_get(uut, httpx_post_mock, client_get_mock) + uut.connect_folio("https://doesnt.matter", "", "", "") + return uut + + +def _act(uut: "ldlite.LDLite", tc: LoadHistoryCase) -> None: + for call in tc.calls_list: + uut.query(table=call.prefix, path="/patched", query=call.query) + + +def _assert( + conn: "dbapi.DBAPIConnection", + tc: LoadHistoryCase, +) -> None: + with closing(conn.cursor()) as cur: + cur.execute('SELECT COUNT(*) FROM "ldlite_system"."load_history"') + assert (ud := cur.fetchone()) is not None + assert ud[0] == len(tc.expected_loads) + + for tn, (q, t) in tc.expected_loads.items(): + cur.execute( + 'SELECT * FROM "ldlite_system"."load_history" WHERE "table_name" = $1', + (tn,), + ) + assert (d := cur.fetchone()) is not None + assert d[1] == q + assert d[7] == t + assert d[6] > d[5] > d[4] > d[3] > d[2] + + +@mock.patch("httpx_folio.auth.httpx.post") +@mock.patch("httpx_folio.factories.httpx.Client.get") +@parametrize_with_cases("tc", cases=LoadHistoryTestCases) +def test_duckdb( + client_get_mock: MagicMock, + httpx_post_mock: MagicMock, + tc: LoadHistoryCase, +) -> None: + uut = _arrange(client_get_mock, httpx_post_mock, tc) + dsn = f":memory:{tc.db}" + uut.connect_db(dsn) + + _act(uut, tc) + with duckdb.connect(dsn) as conn: + _assert(cast("dbapi.DBAPIConnection", conn), tc) + + +@mock.patch("httpx_folio.auth.httpx.post") +@mock.patch("httpx_folio.factories.httpx.Client.get") +@parametrize_with_cases("tc", cases=LoadHistoryTestCases) +def test_postgres( + client_get_mock: MagicMock, + httpx_post_mock: MagicMock, + pg_dsn: None | Callable[[str], str], + tc: LoadHistoryCase, +) -> None: + if pg_dsn is None: + pytest.skip("Specify the pg host using --pg-host to run") + + uut = _arrange(client_get_mock, httpx_post_mock, tc) + dsn = pg_dsn(tc.db) + uut.connect_db_postgresql(dsn) + + _act(uut, tc) + + with psycopg.connect(dsn, cursor_factory=psycopg.RawCursor) as conn: + _assert(cast("dbapi.DBAPIConnection", conn), tc) diff --git a/tests/test_postgres.py b/tests/test_postgres.py index c764416..e822101 100644 --- a/tests/test_postgres.py +++ b/tests/test_postgres.py @@ -10,7 +10,6 @@ from psycopg import sql from pytest_cases import parametrize_with_cases -from tests.test_cases import load_history_cases as lhc from tests.test_cases import query_cases as qc from tests.test_cases import to_csv_cases as csvc @@ -122,42 +121,3 @@ def test_to_csv( diff = list(unified_diff(expected_lines, actual_lines)) if len(diff) > 0: pytest.fail("".join(diff)) - - -@mock.patch("httpx_folio.auth.httpx.post") -@mock.patch("httpx_folio.factories.httpx.Client.get") -@parametrize_with_cases("tc", cases=lhc.LoadHistoryTestCases) -def test_history( - client_get_mock: MagicMock, - httpx_post_mock: MagicMock, - pg_dsn: None | Callable[[str], str], - tc: lhc.LoadHistoryCase, -) -> None: - if pg_dsn is None: - pytest.skip("Specify the pg host using --pg-host to run") - - from ldlite import LDLite as uut - - ld = uut() - tc.patch_request_get(ld, httpx_post_mock, client_get_mock) - dsn = pg_dsn(tc.db) - ld.connect_folio("https://doesnt.matter", "", "", "") - ld.connect_db_postgresql(dsn) - - for call in tc.calls_list: - ld.query(table=call.prefix, path="/patched", query=call.query) - - with psycopg.connect(dsn) as conn, conn.cursor() as res: - res.execute('SELECT COUNT(*) FROM "ldlite_system"."load_history"') - assert (ud := res.fetchone()) is not None - assert ud[0] == len(tc.expected_loads) - - for tn, (q, t) in tc.expected_loads.items(): - res.execute( - 'SELECT * FROM "ldlite_system"."load_history" WHERE "table_name" = %s', - (tn,), - ) - assert (d := res.fetchone()) is not None - assert d[1] == q - assert d[7] == t - assert d[6] > d[5] > d[4] > d[3] > d[2] From 3d7524a943665375067d9851caabfd884168d6b4 Mon Sep 17 00:00:00 2001 From: Katherine Bargar Date: Wed, 24 Sep 2025 15:12:31 +0000 Subject: [PATCH 10/17] Refactor query test cases to new format --- tests/test_duckdb.py | 45 --- tests/test_postgres.py | 74 ----- tests/test_query.py | 700 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 700 insertions(+), 119 deletions(-) create mode 100644 tests/test_query.py diff --git a/tests/test_duckdb.py b/tests/test_duckdb.py index bf83c30..ce51100 100644 --- a/tests/test_duckdb.py +++ b/tests/test_duckdb.py @@ -3,57 +3,12 @@ from unittest import mock from unittest.mock import MagicMock -import duckdb import pytest from pytest_cases import parametrize_with_cases -from tests.test_cases import query_cases as qc from tests.test_cases import to_csv_cases as csvc -@mock.patch("httpx_folio.auth.httpx.post") -@mock.patch("httpx_folio.factories.httpx.Client.get") -@parametrize_with_cases("tc", cases=qc.QueryTestCases) -def test_query( - client_get_mock: MagicMock, - httpx_post_mock: MagicMock, - tc: qc.QueryCase, -) -> None: - from ldlite import LDLite as uut - - ld = uut() - tc.patch_request_get(ld, httpx_post_mock, client_get_mock) - dsn = f":memory:{tc.db}" - ld.connect_folio("https://doesnt.matter", "", "", "") - ld.connect_db(dsn) - - for call in tc.calls_list: - ld.query( - table=call.prefix, - path="/patched", - json_depth=call.json_depth, - keep_raw=call.keep_raw, - ) - - with duckdb.connect(dsn) as res: - res.execute( - """ - SELECT table_name - FROM information_schema.tables - WHERE table_schema='main' - """, - ) - assert sorted([r[0] for r in res.fetchall()]) == sorted(tc.expected_tables) - - for table, (cols, values) in tc.expected_values.items(): - with duckdb.connect(dsn) as res: - res.execute(f"SELECT {'::text,'.join(cols)}::text FROM {table};") - for v in values: - assert res.fetchone() == v - - assert res.fetchone() is None - - @mock.patch("httpx_folio.auth.httpx.post") @mock.patch("httpx_folio.factories.httpx.Client.get") @parametrize_with_cases("tc", cases=csvc.ToCsvCases) diff --git a/tests/test_postgres.py b/tests/test_postgres.py index e822101..90ddc69 100644 --- a/tests/test_postgres.py +++ b/tests/test_postgres.py @@ -1,89 +1,15 @@ from collections.abc import Callable from difflib import unified_diff from pathlib import Path -from typing import cast from unittest import mock from unittest.mock import MagicMock -import psycopg import pytest -from psycopg import sql from pytest_cases import parametrize_with_cases -from tests.test_cases import query_cases as qc from tests.test_cases import to_csv_cases as csvc -@mock.patch("httpx_folio.auth.httpx.post") -@mock.patch("httpx_folio.factories.httpx.Client.get") -@parametrize_with_cases("tc", cases=qc.QueryTestCases) -def test_query( - client_get_mock: MagicMock, - httpx_post_mock: MagicMock, - pg_dsn: None | Callable[[str], str], - tc: qc.QueryCase, -) -> None: - if pg_dsn is None: - pytest.skip("Specify the pg host using --pg-host to run") - - from ldlite import LDLite as uut - - ld = uut() - tc.patch_request_get(ld, httpx_post_mock, client_get_mock) - dsn = pg_dsn(tc.db) - ld.connect_folio("https://doesnt.matter", "", "", "") - ld.connect_db_postgresql(dsn) - - for call in tc.calls_list: - ld.query( - table=call.prefix, - path="/patched", - json_depth=call.json_depth, - keep_raw=call.keep_raw, - ) - - with psycopg.connect(dsn) as conn: - with conn.cursor() as res: - res.execute( - """ - SELECT table_name - FROM information_schema.tables - WHERE table_schema='public' - """, - ) - assert sorted([r[0] for r in res.fetchall()]) == sorted(tc.expected_tables) - - for table, (cols, values) in tc.expected_values.items(): - with conn.cursor() as res: - res.execute( - sql.SQL("SELECT {cols}::text FROM {table};").format( - cols=sql.SQL("::text, ").join( - [sql.Identifier(c) for c in cols], - ), - table=sql.Identifier(table), - ), - ) - for v in values: - assert res.fetchone() == v - - assert res.fetchone() is None - - if tc.expected_indexes is not None: - with conn.cursor() as res: - res.execute( - "SELECT COUNT(*) FROM pg_indexes WHERE schemaname = 'public';", - ) - assert cast("tuple[int]", res.fetchone())[0] == len(tc.expected_indexes) - - for t, c in tc.expected_indexes: - # this requires specific formatting to match the postgres strings - res.execute(f""" -SELECT COUNT(*) FROM pg_indexes -WHERE indexdef LIKE 'CREATE INDEX % ON public.{t} USING btree ({c})'; - """) - assert cast("tuple[int]", res.fetchone())[0] == 1, f"{t}, {c}" - - @mock.patch("httpx_folio.auth.httpx.post") @mock.patch("httpx_folio.factories.httpx.Client.get") @parametrize_with_cases("tc", cases=csvc.ToCsvCases) diff --git a/tests/test_query.py b/tests/test_query.py new file mode 100644 index 0000000..7318b91 --- /dev/null +++ b/tests/test_query.py @@ -0,0 +1,700 @@ +import json +from collections.abc import Callable +from contextlib import closing +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, cast +from unittest import mock +from unittest.mock import MagicMock + +import duckdb +import psycopg +import pytest +from psycopg import sql +from pytest_cases import parametrize, parametrize_with_cases + +from .test_cases.base import Call, MockedResponseTestCase + +if TYPE_CHECKING: + from _typeshed import dbapi + + import ldlite + + +@dataclass(frozen=True) +class QueryCase(MockedResponseTestCase): + expected_tables: list[str] + expected_values: dict[str, tuple[list[str], list[tuple[Any, ...]]]] + expected_indexes: list[tuple[str, str]] | None = None + + +class QueryTestCases: + @parametrize(json_depth=range(1, 2)) + def case_one_table(self, json_depth: int) -> QueryCase: + return QueryCase( + Call( + "prefix", + json_depth=json_depth, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + }, + ], + }, + ), + expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], + expected_values={ + "prefix__t": ( + ["id", "value"], + [("b096504a-3d54-4664-9bf5-1b872466fd66", "value")], + ), + "prefix__tcatalog": (["table_name"], [("prefix__t",)]), + }, + expected_indexes=[ + ("prefix", "__id"), + ("prefix__t", "__id"), + ("prefix__t", "id"), + ], + ) + + @parametrize(json_depth=range(2, 3)) + def case_two_tables(self, json_depth: int) -> QueryCase: + return QueryCase( + Call( + "prefix", + json_depth=json_depth, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + "subObjects": [ + { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-value-1", + }, + { + "id": "f5bda109-a719-4f72-b797-b9c22f45e4e1", + "value": "sub-value-2", + }, + ], + }, + ], + }, + ), + expected_tables=[ + "prefix", + "prefix__t", + "prefix__t__sub_objects", + "prefix__tcatalog", + ], + expected_values={ + "prefix__t": ( + ["id", "value"], + [("b096504a-3d54-4664-9bf5-1b872466fd66", "value")], + ), + "prefix__t__sub_objects": ( + ["id", "sub_objects__id", "sub_objects__value"], + [ + ( + "b096504a-3d54-4664-9bf5-1b872466fd66", + "2b94c631-fca9-4892-a730-03ee529ffe2a", + "sub-value-1", + ), + ( + "b096504a-3d54-4664-9bf5-1b872466fd66", + "f5bda109-a719-4f72-b797-b9c22f45e4e1", + "sub-value-2", + ), + ], + ), + "prefix__tcatalog": ( + ["table_name"], + [("prefix__t",), ("prefix__t__sub_objects",)], + ), + }, + expected_indexes=[ + ("prefix", "__id"), + ("prefix__t", "__id"), + ("prefix__t", "id"), + ("prefix__t__sub_objects", "__id"), + ("prefix__t__sub_objects", "id"), + ("prefix__t__sub_objects", "sub_objects__o"), + ("prefix__t__sub_objects", "sub_objects__id"), + ], + ) + + @parametrize(json_depth=range(1)) + def case_table_no_expansion(self, json_depth: int) -> QueryCase: + return QueryCase( + Call( + "prefix", + json_depth=json_depth, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + "subObjects": [ + { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-value", + }, + ], + }, + ], + }, + ), + expected_tables=["prefix"], + expected_values={}, + ) + + def case_table_underexpansion(self) -> QueryCase: + return QueryCase( + Call( + "prefix", + json_depth=2, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "subObjects": [ + { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-value", + "subSubObjects": [ + { + "id": ( + "2b94c631-fca9-4892-a730-03ee529ffe2a" + ), + "value": "sub-sub-value", + }, + ], + }, + ], + }, + ], + }, + ), + expected_tables=[ + "prefix", + "prefix__t", + "prefix__t__sub_objects", + "prefix__tcatalog", + ], + expected_values={ + "prefix__t__sub_objects": ( + [ + "id", + "sub_objects__id", + "sub_objects__value", + ], + [ + ( + "b096504a-3d54-4664-9bf5-1b872466fd66", + "2b94c631-fca9-4892-a730-03ee529ffe2a", + "sub-value", + ), + ], + ), + "prefix__tcatalog": ( + ["table_name"], + [("prefix__t",), ("prefix__t__sub_objects",)], + ), + }, + ) + + @parametrize(json_depth=range(3, 4)) + def case_three_tables(self, json_depth: int) -> QueryCase: + return QueryCase( + Call( + "prefix", + json_depth=json_depth, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + "subObjects": [ + { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-value", + "subSubObjects": [ + { + "id": ( + "2b94c631-fca9-4892-a730-03ee529ffe2a" + ), + "value": "sub-sub-value", + }, + ], + }, + ], + }, + ], + }, + ), + expected_tables=[ + "prefix", + "prefix__t", + "prefix__t__sub_objects", + "prefix__t__sub_objects__sub_sub_objects", + "prefix__tcatalog", + ], + expected_values={ + "prefix__t__sub_objects__sub_sub_objects": ( + [ + "id", + "sub_objects__id", + "sub_objects__sub_sub_objects__id", + "sub_objects__sub_sub_objects__value", + ], + [ + ( + "b096504a-3d54-4664-9bf5-1b872466fd66", + "2b94c631-fca9-4892-a730-03ee529ffe2a", + "2b94c631-fca9-4892-a730-03ee529ffe2a", + "sub-sub-value", + ), + ], + ), + "prefix__tcatalog": ( + ["table_name"], + [ + ("prefix__t",), + ("prefix__t__sub_objects",), + ("prefix__t__sub_objects__sub_sub_objects",), + ], + ), + }, + expected_indexes=[ + ("prefix", "__id"), + ("prefix__t", "__id"), + ("prefix__t", "id"), + ("prefix__t__sub_objects", "__id"), + ("prefix__t__sub_objects", "id"), + ("prefix__t__sub_objects", "sub_objects__o"), + ("prefix__t__sub_objects", "sub_objects__id"), + ("prefix__t__sub_objects__sub_sub_objects", "__id"), + ("prefix__t__sub_objects__sub_sub_objects", "id"), + ("prefix__t__sub_objects__sub_sub_objects", "sub_objects__o"), + ("prefix__t__sub_objects__sub_sub_objects", "sub_objects__id"), + ( + "prefix__t__sub_objects__sub_sub_objects", + "sub_objects__sub_sub_objects__o", + ), + ( + "prefix__t__sub_objects__sub_sub_objects", + "sub_objects__sub_sub_objects__id", + ), + ], + ) + + def case_nested_object(self) -> QueryCase: + return QueryCase( + Call( + "prefix", + json_depth=2, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + "subObject": { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-value", + }, + }, + ], + }, + ), + expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], + expected_values={ + "prefix__t": ( + ["id", "value", "sub_object__id", "sub_object__value"], + [ + ( + "b096504a-3d54-4664-9bf5-1b872466fd66", + "value", + "2b94c631-fca9-4892-a730-03ee529ffe2a", + "sub-value", + ), + ], + ), + "prefix__tcatalog": ( + ["table_name"], + [("prefix__t",)], + ), + }, + expected_indexes=[ + ("prefix", "__id"), + ("prefix__t", "__id"), + ("prefix__t", "id"), + ("prefix__t", "sub_object__id"), + ], + ) + + def case_doubly_nested_object(self) -> QueryCase: + return QueryCase( + Call( + "prefix", + json_depth=3, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + "subObject": { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-value", + "subSubObject": { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-sub-value", + }, + }, + }, + ], + }, + ), + expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], + expected_values={ + "prefix__t": ( + [ + "id", + "value", + "sub_object__id", + "sub_object__sub_sub_object__id", + "sub_object__sub_sub_object__value", + ], + [ + ( + "b096504a-3d54-4664-9bf5-1b872466fd66", + "value", + "2b94c631-fca9-4892-a730-03ee529ffe2a", + "2b94c631-fca9-4892-a730-03ee529ffe2a", + "sub-sub-value", + ), + ], + ), + "prefix__tcatalog": ( + ["table_name"], + [("prefix__t",)], + ), + }, + expected_indexes=[ + ("prefix", "__id"), + ("prefix__t", "__id"), + ("prefix__t", "id"), + ("prefix__t", "sub_object__id"), + ("prefix__t", "sub_object__sub_sub_object__id"), + ], + ) + + def case_nested_object_underexpansion(self) -> QueryCase: + return QueryCase( + Call( + "prefix", + json_depth=1, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + "subObject": { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-value", + }, + }, + ], + }, + ), + expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], + expected_values={ + "prefix__t": ( + ["id", "value", "sub_object"], + [ + ( + "b096504a-3d54-4664-9bf5-1b872466fd66", + "value", + json.dumps( + { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-value", + }, + indent=4, + ), + ), + ], + ), + "prefix__tcatalog": ( + ["table_name"], + [("prefix__t",)], + ), + }, + ) + + def case_id_generation(self) -> QueryCase: + return QueryCase( + Call( + "prefix", + json_depth=4, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "subObjects": [ + { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "subSubObjects": [ + { + "id": ( + "2b94c631-fca9-4892-a730-03ee529ffe2a" + ), + }, + { + "id": ( + "8516a913-8bf7-55a4-ab71-417aba9171c9" + ), + }, + ], + }, + { + "id": "b5d8cdc4-9441-487c-90cf-0c7ec97728eb", + "subSubObjects": [ + { + "id": ( + "13a24cc8-a15c-4158-abbd-4abf25c8815a" + ), + }, + { + "id": ( + "37344879-09ce-4cd8-976f-bf1a57c0cfa6" + ), + }, + ], + }, + ], + }, + ], + }, + ), + expected_tables=[ + "prefix", + "prefix__t", + "prefix__t__sub_objects", + "prefix__t__sub_objects__sub_sub_objects", + "prefix__tcatalog", + ], + expected_values={ + "prefix__t__sub_objects": ( + ["__id", "id", "sub_objects__o", "sub_objects__id"], + [ + ( + "1", + "b096504a-3d54-4664-9bf5-1b872466fd66", + "1", + "2b94c631-fca9-4892-a730-03ee529ffe2a", + ), + ( + "2", + "b096504a-3d54-4664-9bf5-1b872466fd66", + "2", + "b5d8cdc4-9441-487c-90cf-0c7ec97728eb", + ), + ], + ), + "prefix__t__sub_objects__sub_sub_objects": ( + ["__id", "sub_objects__o", "sub_objects__sub_sub_objects__o"], + [ + ("1", "1", "1"), + ("2", "1", "2"), + ("3", "2", "1"), + ("4", "2", "2"), + ], + ), + }, + ) + + def case_indexing_id_like(self) -> QueryCase: + return QueryCase( + Call( + "prefix", + json_depth=4, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "otherId": "b096504a-3d54-4664-9bf5-1b872466fd66", + "anIdButWithADifferentEnding": ( + "b096504a-3d54-4664-9bf5-1b872466fd66" + ), + }, + ], + }, + ), + expected_tables=[ + "prefix", + "prefix__t", + "prefix__tcatalog", + ], + expected_values={}, + expected_indexes=[ + ("prefix", "__id"), + ("prefix__t", "__id"), + ("prefix__t", "id"), + ("prefix__t", "other_id"), + ("prefix__t", "an_id_but_with_a_different_ending"), + ], + ) + + @parametrize(json_depth=range(1, 2)) + def case_drop_raw(self, json_depth: int) -> QueryCase: + return QueryCase( + Call( + "prefix", + json_depth=json_depth, + keep_raw=False, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + }, + ], + }, + ), + expected_tables=["prefix__t", "prefix__tcatalog"], + expected_values={ + "prefix__t": ( + ["id", "value"], + [("b096504a-3d54-4664-9bf5-1b872466fd66", "value")], + ), + "prefix__tcatalog": (["table_name"], [("prefix__t",)]), + }, + expected_indexes=[ + ("prefix__t", "__id"), + ("prefix__t", "id"), + ], + ) + + # this case should be testing the FolioClient class + # but it isn't setup to mock the data properly right now + def case_null_records(self) -> QueryCase: + return QueryCase( + Call( + "prefix", + json_depth=1, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + }, + None, + ], + }, + ), + expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], + expected_values={}, + expected_indexes=[ + ("prefix", "__id"), + ("prefix__t", "__id"), + ("prefix__t", "id"), + ], + ) + + +def _arrange( + client_get_mock: MagicMock, + httpx_post_mock: MagicMock, + tc: QueryCase, +) -> "ldlite.LDLite": + from ldlite import LDLite + + uut = LDLite() + tc.patch_request_get(uut, httpx_post_mock, client_get_mock) + uut.connect_folio("https://doesnt.matter", "", "", "") + return uut + + +def _act(uut: "ldlite.LDLite", tc: QueryCase) -> None: + for call in tc.calls_list: + uut.query( + table=call.prefix, + path="/patched", + json_depth=call.json_depth, + keep_raw=call.keep_raw, + ) + + +def _assert( + conn: "dbapi.DBAPIConnection", + res_schema: str, # TODO: have schema be part of tc + tc: QueryCase, +) -> None: + with closing(conn.cursor()) as cur: + cur.execute( + """ + SELECT table_name + FROM information_schema.tables + WHERE table_schema=$1 + """, + (res_schema,), + ) + assert sorted([r[0] for r in cur.fetchall()]) == sorted(tc.expected_tables) + + for table, (cols, values) in tc.expected_values.items(): + cur.execute( + sql.SQL("SELECT {cols}::text FROM {table};") + .format( + cols=sql.SQL("::text, ").join( + [sql.Identifier(c) for c in cols], + ), + table=sql.Identifier(table), + ) + .as_string(), + ) + for v in values: + assert cur.fetchone() == v + + assert cur.fetchone() is None + + +@mock.patch("httpx_folio.auth.httpx.post") +@mock.patch("httpx_folio.factories.httpx.Client.get") +@parametrize_with_cases("tc", cases=QueryTestCases) +def test_duckdb( + client_get_mock: MagicMock, + httpx_post_mock: MagicMock, + tc: QueryCase, +) -> None: + uut = _arrange(client_get_mock, httpx_post_mock, tc) + dsn = f":memory:{tc.db}" + uut.connect_db(dsn) + + _act(uut, tc) + + with duckdb.connect(dsn) as conn: + _assert(cast("dbapi.DBAPIConnection", conn), "main", tc) + + +@mock.patch("httpx_folio.auth.httpx.post") +@mock.patch("httpx_folio.factories.httpx.Client.get") +@parametrize_with_cases("tc", cases=QueryTestCases) +def test_postgres( + client_get_mock: MagicMock, + httpx_post_mock: MagicMock, + pg_dsn: None | Callable[[str], str], + tc: QueryCase, +) -> None: + if pg_dsn is None: + pytest.skip("Specify the pg host using --pg-host to run") + + uut = _arrange(client_get_mock, httpx_post_mock, tc) + dsn = pg_dsn(tc.db) + uut.connect_db_postgresql(dsn) + + _act(uut, tc) + + with psycopg.connect(dsn, cursor_factory=psycopg.RawCursor) as conn: + _assert(cast("dbapi.DBAPIConnection", conn), "public", tc) From 3b9de68bae4b6d2acc7beecd6b4c13c4550b7359 Mon Sep 17 00:00:00 2001 From: Katherine Bargar Date: Wed, 24 Sep 2025 15:23:51 +0000 Subject: [PATCH 11/17] Refactor csv test cases to new format --- .../basic.csv | 0 .../datatypes.csv | 0 .../escaped_chars.csv | 0 .../sorting.csv | 0 tests/test_export_csv.py | 169 ++++++++++++++++++ 5 files changed, 169 insertions(+) rename tests/{test_cases/to_csv_samples => export_csv_samples}/basic.csv (100%) rename tests/{test_cases/to_csv_samples => export_csv_samples}/datatypes.csv (100%) rename tests/{test_cases/to_csv_samples => export_csv_samples}/escaped_chars.csv (100%) rename tests/{test_cases/to_csv_samples => export_csv_samples}/sorting.csv (100%) create mode 100644 tests/test_export_csv.py diff --git a/tests/test_cases/to_csv_samples/basic.csv b/tests/export_csv_samples/basic.csv similarity index 100% rename from tests/test_cases/to_csv_samples/basic.csv rename to tests/export_csv_samples/basic.csv diff --git a/tests/test_cases/to_csv_samples/datatypes.csv b/tests/export_csv_samples/datatypes.csv similarity index 100% rename from tests/test_cases/to_csv_samples/datatypes.csv rename to tests/export_csv_samples/datatypes.csv diff --git a/tests/test_cases/to_csv_samples/escaped_chars.csv b/tests/export_csv_samples/escaped_chars.csv similarity index 100% rename from tests/test_cases/to_csv_samples/escaped_chars.csv rename to tests/export_csv_samples/escaped_chars.csv diff --git a/tests/test_cases/to_csv_samples/sorting.csv b/tests/export_csv_samples/sorting.csv similarity index 100% rename from tests/test_cases/to_csv_samples/sorting.csv rename to tests/export_csv_samples/sorting.csv diff --git a/tests/test_export_csv.py b/tests/test_export_csv.py new file mode 100644 index 0000000..2837eae --- /dev/null +++ b/tests/test_export_csv.py @@ -0,0 +1,169 @@ +from collections.abc import Callable +from dataclasses import dataclass +from difflib import unified_diff +from pathlib import Path +from typing import TYPE_CHECKING +from unittest import mock +from unittest.mock import MagicMock + +import pytest +from pytest_cases import parametrize_with_cases + +from .test_cases.base import Call, MockedResponseTestCase + +if TYPE_CHECKING: + import ldlite + +_SAMPLE_PATH = Path() / "tests" / "export_csv_samples" + + +@dataclass(frozen=True) +class ToCsvCase(MockedResponseTestCase): + expected_csvs: list[tuple[str, Path]] + + +class ToCsvCases: + def case_basic(self) -> ToCsvCase: + return ToCsvCase( + Call("prefix", returns={"purchaseOrders": [{"id": "id", "val": "value"}]}), + expected_csvs=[("prefix__t", _SAMPLE_PATH / "basic.csv")], + ) + + def case_datatypes(self) -> ToCsvCase: + return ToCsvCase( + Call( + "prefix", + returns={ + "purchaseOrders": [ + { + "id": "id", + "string": "string", + "integer": 1, + "numeric": 1.1, + "boolean": True, + "uuid": "6a31a12a-9570-405c-af20-6abf2992859c", + }, + ], + }, + ), + expected_csvs=[("prefix__t", _SAMPLE_PATH / "datatypes.csv")], + ) + + def case_escaped_chars(self) -> ToCsvCase: + return ToCsvCase( + Call( + "prefix", + returns={ + "purchaseOrders": [ + { + "id": "id", + "comma": "Double, double toil and trouble", + "doubleQuote": 'Cry "Havoc!" a horse', + "newLine": """To be +or not +to be""", + "singleQuote": "Cry 'Havoc!' a horse", + }, + { + "id": "id", + "comma": "Z", + "doubleQuote": "Z", + "newLine": "Z", + "singleQuote": "Z", + }, + ], + }, + ), + expected_csvs=[("prefix__t", _SAMPLE_PATH / "escaped_chars.csv")], + ) + + def case_sorting(self) -> ToCsvCase: + return ToCsvCase( + Call( + "prefix", + returns={ + "purchaseOrders": [ + {"id": "id", "C": "YY", "B": "XX", "A": "ZZ"}, + {"id": "id", "C": "Y", "B": "XX", "A": "ZZ"}, + {"id": "id", "C": "Y", "B": "X", "A": "Z"}, + ], + }, + ), + expected_csvs=[("prefix__t", _SAMPLE_PATH / "sorting.csv")], + ) + + +def _arrange( + client_get_mock: MagicMock, + httpx_post_mock: MagicMock, + tc: ToCsvCase, +) -> "ldlite.LDLite": + from ldlite import LDLite + + uut = LDLite() + tc.patch_request_get(uut, httpx_post_mock, client_get_mock) + uut.connect_folio("https://doesnt.matter", "", "", "") + return uut + + +def _act(uut: "ldlite.LDLite", tc: ToCsvCase) -> None: + for call in tc.calls_list: + uut.query(table=call.prefix, path="/patched") + + +def _assert( + uut: "ldlite.LDLite", + tc: ToCsvCase, + tmpdir: str, +) -> None: + for table, expected in tc.expected_csvs: + actual = (Path(tmpdir) / table).with_suffix(".csv") + + uut.export_csv(str(actual), table) + + with expected.open("r") as f: + expected_lines = f.readlines() + with actual.open("r") as f: + actual_lines = f.readlines() + + diff = list(unified_diff(expected_lines, actual_lines)) + if len(diff) > 0: + pytest.fail("".join(diff)) + + +@mock.patch("httpx_folio.auth.httpx.post") +@mock.patch("httpx_folio.factories.httpx.Client.get") +@parametrize_with_cases("tc", cases=ToCsvCases) +def test_duckdb( + client_get_mock: MagicMock, + httpx_post_mock: MagicMock, + tc: ToCsvCase, + tmpdir: str, +) -> None: + uut = _arrange(client_get_mock, httpx_post_mock, tc) + dsn = f":memory:{tc.db}" + uut.connect_db(dsn) + + _act(uut, tc) + _assert(uut, tc, tmpdir) + + +@mock.patch("httpx_folio.auth.httpx.post") +@mock.patch("httpx_folio.factories.httpx.Client.get") +@parametrize_with_cases("tc", cases=ToCsvCases) +def test_postgres( + client_get_mock: MagicMock, + httpx_post_mock: MagicMock, + pg_dsn: None | Callable[[str], str], + tc: ToCsvCase, + tmpdir: str, +) -> None: + if pg_dsn is None: + pytest.skip("Specify the pg host using --pg-host to run") + + uut = _arrange(client_get_mock, httpx_post_mock, tc) + dsn = pg_dsn(tc.db) + uut.connect_db_postgresql(dsn) + + _act(uut, tc) + _assert(uut, tc, tmpdir) From 3fc9dd81b0aacbccfe236c35522e73bf3e80be22 Mon Sep 17 00:00:00 2001 From: Katherine Bargar Date: Wed, 24 Sep 2025 15:29:14 +0000 Subject: [PATCH 12/17] Move base case and remove old cases --- .../base.py => mock_response_case.py} | 0 tests/test_cases/load_history_cases.py | 88 --- tests/test_cases/query_cases.py | 591 ------------------ tests/test_cases/to_csv_cases.py | 82 --- tests/test_drop_tables.py | 2 +- tests/test_duckdb.py | 44 -- tests/test_export_csv.py | 2 +- tests/test_load_history.py | 2 +- tests/test_postgres.py | 49 -- tests/test_query.py | 2 +- 10 files changed, 4 insertions(+), 858 deletions(-) rename tests/{test_cases/base.py => mock_response_case.py} (100%) delete mode 100644 tests/test_cases/load_history_cases.py delete mode 100644 tests/test_cases/query_cases.py delete mode 100644 tests/test_cases/to_csv_cases.py delete mode 100644 tests/test_duckdb.py delete mode 100644 tests/test_postgres.py diff --git a/tests/test_cases/base.py b/tests/mock_response_case.py similarity index 100% rename from tests/test_cases/base.py rename to tests/mock_response_case.py diff --git a/tests/test_cases/load_history_cases.py b/tests/test_cases/load_history_cases.py deleted file mode 100644 index 542a301..0000000 --- a/tests/test_cases/load_history_cases.py +++ /dev/null @@ -1,88 +0,0 @@ -from dataclasses import dataclass - -from pytest_cases import parametrize - -from .base import Call, MockedResponseTestCase - - -@dataclass(frozen=True) -class LoadHistoryCase(MockedResponseTestCase): - expected_loads: dict[str, tuple[str | None, int]] - - -class LoadHistoryTestCases: - @parametrize(query=[None, "poline.id=*A"]) - def case_one_load(self, query: str | None) -> LoadHistoryCase: - return LoadHistoryCase( - Call( - "prefix", - query=query, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - }, - { - "id": "b096504a-9999-4664-9bf5-1b872466fd66", - "value": "value-2", - }, - ], - }, - ), - expected_loads={"prefix": (query, 2)}, - ) - - def case_schema_load(self) -> LoadHistoryCase: - return LoadHistoryCase( - Call( - "schema.prefix", - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - }, - { - "id": "b096504a-9999-4664-9bf5-1b872466fd66", - "value": "value-2", - }, - ], - }, - ), - expected_loads={"schema.prefix": (None, 2)}, - ) - - def case_two_loads(self) -> LoadHistoryCase: - return LoadHistoryCase( - [ - Call( - "prefix", - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - }, - ], - }, - ), - Call( - "prefix", - query="a query", - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - }, - { - "id": "b096504a-9999-4664-9bf5-1b872466fd66", - "value": "value-2", - }, - ], - }, - ), - ], - expected_loads={"prefix": ("a query", 2)}, - ) diff --git a/tests/test_cases/query_cases.py b/tests/test_cases/query_cases.py deleted file mode 100644 index 3af1fb7..0000000 --- a/tests/test_cases/query_cases.py +++ /dev/null @@ -1,591 +0,0 @@ -import json -from dataclasses import dataclass -from typing import Any - -from pytest_cases import parametrize - -from .base import Call, MockedResponseTestCase - - -@dataclass(frozen=True) -class QueryCase(MockedResponseTestCase): - expected_tables: list[str] - expected_values: dict[str, tuple[list[str], list[tuple[Any, ...]]]] - expected_indexes: list[tuple[str, str]] | None = None - - -class QueryTestCases: - @parametrize(json_depth=range(1, 2)) - def case_one_table(self, json_depth: int) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=json_depth, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - }, - ], - }, - ), - expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], - expected_values={ - "prefix__t": ( - ["id", "value"], - [("b096504a-3d54-4664-9bf5-1b872466fd66", "value")], - ), - "prefix__tcatalog": (["table_name"], [("prefix__t",)]), - }, - expected_indexes=[ - ("prefix", "__id"), - ("prefix__t", "__id"), - ("prefix__t", "id"), - ], - ) - - @parametrize(json_depth=range(2, 3)) - def case_two_tables(self, json_depth: int) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=json_depth, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - "subObjects": [ - { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-value-1", - }, - { - "id": "f5bda109-a719-4f72-b797-b9c22f45e4e1", - "value": "sub-value-2", - }, - ], - }, - ], - }, - ), - expected_tables=[ - "prefix", - "prefix__t", - "prefix__t__sub_objects", - "prefix__tcatalog", - ], - expected_values={ - "prefix__t": ( - ["id", "value"], - [("b096504a-3d54-4664-9bf5-1b872466fd66", "value")], - ), - "prefix__t__sub_objects": ( - ["id", "sub_objects__id", "sub_objects__value"], - [ - ( - "b096504a-3d54-4664-9bf5-1b872466fd66", - "2b94c631-fca9-4892-a730-03ee529ffe2a", - "sub-value-1", - ), - ( - "b096504a-3d54-4664-9bf5-1b872466fd66", - "f5bda109-a719-4f72-b797-b9c22f45e4e1", - "sub-value-2", - ), - ], - ), - "prefix__tcatalog": ( - ["table_name"], - [("prefix__t",), ("prefix__t__sub_objects",)], - ), - }, - expected_indexes=[ - ("prefix", "__id"), - ("prefix__t", "__id"), - ("prefix__t", "id"), - ("prefix__t__sub_objects", "__id"), - ("prefix__t__sub_objects", "id"), - ("prefix__t__sub_objects", "sub_objects__o"), - ("prefix__t__sub_objects", "sub_objects__id"), - ], - ) - - @parametrize(json_depth=range(1)) - def case_table_no_expansion(self, json_depth: int) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=json_depth, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - "subObjects": [ - { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-value", - }, - ], - }, - ], - }, - ), - expected_tables=["prefix"], - expected_values={}, - ) - - def case_table_underexpansion(self) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=2, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "subObjects": [ - { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-value", - "subSubObjects": [ - { - "id": ( - "2b94c631-fca9-4892-a730-03ee529ffe2a" - ), - "value": "sub-sub-value", - }, - ], - }, - ], - }, - ], - }, - ), - expected_tables=[ - "prefix", - "prefix__t", - "prefix__t__sub_objects", - "prefix__tcatalog", - ], - expected_values={ - "prefix__t__sub_objects": ( - [ - "id", - "sub_objects__id", - "sub_objects__value", - ], - [ - ( - "b096504a-3d54-4664-9bf5-1b872466fd66", - "2b94c631-fca9-4892-a730-03ee529ffe2a", - "sub-value", - ), - ], - ), - "prefix__tcatalog": ( - ["table_name"], - [("prefix__t",), ("prefix__t__sub_objects",)], - ), - }, - ) - - @parametrize(json_depth=range(3, 4)) - def case_three_tables(self, json_depth: int) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=json_depth, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - "subObjects": [ - { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-value", - "subSubObjects": [ - { - "id": ( - "2b94c631-fca9-4892-a730-03ee529ffe2a" - ), - "value": "sub-sub-value", - }, - ], - }, - ], - }, - ], - }, - ), - expected_tables=[ - "prefix", - "prefix__t", - "prefix__t__sub_objects", - "prefix__t__sub_objects__sub_sub_objects", - "prefix__tcatalog", - ], - expected_values={ - "prefix__t__sub_objects__sub_sub_objects": ( - [ - "id", - "sub_objects__id", - "sub_objects__sub_sub_objects__id", - "sub_objects__sub_sub_objects__value", - ], - [ - ( - "b096504a-3d54-4664-9bf5-1b872466fd66", - "2b94c631-fca9-4892-a730-03ee529ffe2a", - "2b94c631-fca9-4892-a730-03ee529ffe2a", - "sub-sub-value", - ), - ], - ), - "prefix__tcatalog": ( - ["table_name"], - [ - ("prefix__t",), - ("prefix__t__sub_objects",), - ("prefix__t__sub_objects__sub_sub_objects",), - ], - ), - }, - expected_indexes=[ - ("prefix", "__id"), - ("prefix__t", "__id"), - ("prefix__t", "id"), - ("prefix__t__sub_objects", "__id"), - ("prefix__t__sub_objects", "id"), - ("prefix__t__sub_objects", "sub_objects__o"), - ("prefix__t__sub_objects", "sub_objects__id"), - ("prefix__t__sub_objects__sub_sub_objects", "__id"), - ("prefix__t__sub_objects__sub_sub_objects", "id"), - ("prefix__t__sub_objects__sub_sub_objects", "sub_objects__o"), - ("prefix__t__sub_objects__sub_sub_objects", "sub_objects__id"), - ( - "prefix__t__sub_objects__sub_sub_objects", - "sub_objects__sub_sub_objects__o", - ), - ( - "prefix__t__sub_objects__sub_sub_objects", - "sub_objects__sub_sub_objects__id", - ), - ], - ) - - def case_nested_object(self) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=2, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - "subObject": { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-value", - }, - }, - ], - }, - ), - expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], - expected_values={ - "prefix__t": ( - ["id", "value", "sub_object__id", "sub_object__value"], - [ - ( - "b096504a-3d54-4664-9bf5-1b872466fd66", - "value", - "2b94c631-fca9-4892-a730-03ee529ffe2a", - "sub-value", - ), - ], - ), - "prefix__tcatalog": ( - ["table_name"], - [("prefix__t",)], - ), - }, - expected_indexes=[ - ("prefix", "__id"), - ("prefix__t", "__id"), - ("prefix__t", "id"), - ("prefix__t", "sub_object__id"), - ], - ) - - def case_doubly_nested_object(self) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=3, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - "subObject": { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-value", - "subSubObject": { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-sub-value", - }, - }, - }, - ], - }, - ), - expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], - expected_values={ - "prefix__t": ( - [ - "id", - "value", - "sub_object__id", - "sub_object__sub_sub_object__id", - "sub_object__sub_sub_object__value", - ], - [ - ( - "b096504a-3d54-4664-9bf5-1b872466fd66", - "value", - "2b94c631-fca9-4892-a730-03ee529ffe2a", - "2b94c631-fca9-4892-a730-03ee529ffe2a", - "sub-sub-value", - ), - ], - ), - "prefix__tcatalog": ( - ["table_name"], - [("prefix__t",)], - ), - }, - expected_indexes=[ - ("prefix", "__id"), - ("prefix__t", "__id"), - ("prefix__t", "id"), - ("prefix__t", "sub_object__id"), - ("prefix__t", "sub_object__sub_sub_object__id"), - ], - ) - - def case_nested_object_underexpansion(self) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=1, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - "subObject": { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-value", - }, - }, - ], - }, - ), - expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], - expected_values={ - "prefix__t": ( - ["id", "value", "sub_object"], - [ - ( - "b096504a-3d54-4664-9bf5-1b872466fd66", - "value", - json.dumps( - { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-value", - }, - indent=4, - ), - ), - ], - ), - "prefix__tcatalog": ( - ["table_name"], - [("prefix__t",)], - ), - }, - ) - - def case_id_generation(self) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=4, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "subObjects": [ - { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "subSubObjects": [ - { - "id": ( - "2b94c631-fca9-4892-a730-03ee529ffe2a" - ), - }, - { - "id": ( - "8516a913-8bf7-55a4-ab71-417aba9171c9" - ), - }, - ], - }, - { - "id": "b5d8cdc4-9441-487c-90cf-0c7ec97728eb", - "subSubObjects": [ - { - "id": ( - "13a24cc8-a15c-4158-abbd-4abf25c8815a" - ), - }, - { - "id": ( - "37344879-09ce-4cd8-976f-bf1a57c0cfa6" - ), - }, - ], - }, - ], - }, - ], - }, - ), - expected_tables=[ - "prefix", - "prefix__t", - "prefix__t__sub_objects", - "prefix__t__sub_objects__sub_sub_objects", - "prefix__tcatalog", - ], - expected_values={ - "prefix__t__sub_objects": ( - ["__id", "id", "sub_objects__o", "sub_objects__id"], - [ - ( - "1", - "b096504a-3d54-4664-9bf5-1b872466fd66", - "1", - "2b94c631-fca9-4892-a730-03ee529ffe2a", - ), - ( - "2", - "b096504a-3d54-4664-9bf5-1b872466fd66", - "2", - "b5d8cdc4-9441-487c-90cf-0c7ec97728eb", - ), - ], - ), - "prefix__t__sub_objects__sub_sub_objects": ( - ["__id", "sub_objects__o", "sub_objects__sub_sub_objects__o"], - [ - ("1", "1", "1"), - ("2", "1", "2"), - ("3", "2", "1"), - ("4", "2", "2"), - ], - ), - }, - ) - - def case_indexing_id_like(self) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=4, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "otherId": "b096504a-3d54-4664-9bf5-1b872466fd66", - "anIdButWithADifferentEnding": ( - "b096504a-3d54-4664-9bf5-1b872466fd66" - ), - }, - ], - }, - ), - expected_tables=[ - "prefix", - "prefix__t", - "prefix__tcatalog", - ], - expected_values={}, - expected_indexes=[ - ("prefix", "__id"), - ("prefix__t", "__id"), - ("prefix__t", "id"), - ("prefix__t", "other_id"), - ("prefix__t", "an_id_but_with_a_different_ending"), - ], - ) - - @parametrize(json_depth=range(1, 2)) - def case_drop_raw(self, json_depth: int) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=json_depth, - keep_raw=False, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - }, - ], - }, - ), - expected_tables=["prefix__t", "prefix__tcatalog"], - expected_values={ - "prefix__t": ( - ["id", "value"], - [("b096504a-3d54-4664-9bf5-1b872466fd66", "value")], - ), - "prefix__tcatalog": (["table_name"], [("prefix__t",)]), - }, - expected_indexes=[ - ("prefix__t", "__id"), - ("prefix__t", "id"), - ], - ) - - # this case should be testing the FolioClient class - # but it isn't setup to mock the data properly right now - def case_null_records(self) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=1, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - }, - None, - ], - }, - ), - expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], - expected_values={}, - expected_indexes=[ - ("prefix", "__id"), - ("prefix__t", "__id"), - ("prefix__t", "id"), - ], - ) diff --git a/tests/test_cases/to_csv_cases.py b/tests/test_cases/to_csv_cases.py deleted file mode 100644 index bb553a9..0000000 --- a/tests/test_cases/to_csv_cases.py +++ /dev/null @@ -1,82 +0,0 @@ -from dataclasses import dataclass -from pathlib import Path - -from .base import Call, MockedResponseTestCase - -_SAMPLE_PATH = Path() / "tests" / "test_cases" / "to_csv_samples" - - -@dataclass(frozen=True) -class ToCsvCase(MockedResponseTestCase): - expected_csvs: list[tuple[str, Path]] - - -class ToCsvCases: - def case_basic(self) -> ToCsvCase: - return ToCsvCase( - Call("prefix", returns={"purchaseOrders": [{"id": "id", "val": "value"}]}), - expected_csvs=[("prefix__t", _SAMPLE_PATH / "basic.csv")], - ) - - def case_datatypes(self) -> ToCsvCase: - return ToCsvCase( - Call( - "prefix", - returns={ - "purchaseOrders": [ - { - "id": "id", - "string": "string", - "integer": 1, - "numeric": 1.1, - "boolean": True, - "uuid": "6a31a12a-9570-405c-af20-6abf2992859c", - }, - ], - }, - ), - expected_csvs=[("prefix__t", _SAMPLE_PATH / "datatypes.csv")], - ) - - def case_escaped_chars(self) -> ToCsvCase: - return ToCsvCase( - Call( - "prefix", - returns={ - "purchaseOrders": [ - { - "id": "id", - "comma": "Double, double toil and trouble", - "doubleQuote": 'Cry "Havoc!" a horse', - "newLine": """To be -or not -to be""", - "singleQuote": "Cry 'Havoc!' a horse", - }, - { - "id": "id", - "comma": "Z", - "doubleQuote": "Z", - "newLine": "Z", - "singleQuote": "Z", - }, - ], - }, - ), - expected_csvs=[("prefix__t", _SAMPLE_PATH / "escaped_chars.csv")], - ) - - def case_sorting(self) -> ToCsvCase: - return ToCsvCase( - Call( - "prefix", - returns={ - "purchaseOrders": [ - {"id": "id", "C": "YY", "B": "XX", "A": "ZZ"}, - {"id": "id", "C": "Y", "B": "XX", "A": "ZZ"}, - {"id": "id", "C": "Y", "B": "X", "A": "Z"}, - ], - }, - ), - expected_csvs=[("prefix__t", _SAMPLE_PATH / "sorting.csv")], - ) diff --git a/tests/test_drop_tables.py b/tests/test_drop_tables.py index 7933d90..2c77488 100644 --- a/tests/test_drop_tables.py +++ b/tests/test_drop_tables.py @@ -10,7 +10,7 @@ import pytest from pytest_cases import parametrize, parametrize_with_cases -from .test_cases.base import Call, MockedResponseTestCase +from .mock_response_case import Call, MockedResponseTestCase if TYPE_CHECKING: from _typeshed import dbapi diff --git a/tests/test_duckdb.py b/tests/test_duckdb.py deleted file mode 100644 index ce51100..0000000 --- a/tests/test_duckdb.py +++ /dev/null @@ -1,44 +0,0 @@ -from difflib import unified_diff -from pathlib import Path -from unittest import mock -from unittest.mock import MagicMock - -import pytest -from pytest_cases import parametrize_with_cases - -from tests.test_cases import to_csv_cases as csvc - - -@mock.patch("httpx_folio.auth.httpx.post") -@mock.patch("httpx_folio.factories.httpx.Client.get") -@parametrize_with_cases("tc", cases=csvc.ToCsvCases) -def test_to_csv( - client_get_mock: MagicMock, - httpx_post_mock: MagicMock, - tc: csvc.ToCsvCase, - tmpdir: str, -) -> None: - from ldlite import LDLite as uut - - ld = uut() - tc.patch_request_get(ld, httpx_post_mock, client_get_mock) - dsn = f":memory:{tc.db}" - ld.connect_folio("https://doesnt.matter", "", "", "") - ld.connect_db(dsn) - - for call in tc.calls_list: - ld.query(table=call.prefix, path="/patched") - - for table, expected in tc.expected_csvs: - actual = (Path(tmpdir) / table).with_suffix(".csv") - - ld.export_csv(str(actual), table) - - with expected.open("r") as f: - expected_lines = f.readlines() - with actual.open("r") as f: - actual_lines = f.readlines() - - diff = list(unified_diff(expected_lines, actual_lines)) - if len(diff) > 0: - pytest.fail("".join(diff)) diff --git a/tests/test_export_csv.py b/tests/test_export_csv.py index 2837eae..379d8a6 100644 --- a/tests/test_export_csv.py +++ b/tests/test_export_csv.py @@ -9,7 +9,7 @@ import pytest from pytest_cases import parametrize_with_cases -from .test_cases.base import Call, MockedResponseTestCase +from .mock_response_case import Call, MockedResponseTestCase if TYPE_CHECKING: import ldlite diff --git a/tests/test_load_history.py b/tests/test_load_history.py index 674dde9..7c1ca37 100644 --- a/tests/test_load_history.py +++ b/tests/test_load_history.py @@ -10,7 +10,7 @@ import pytest from pytest_cases import parametrize, parametrize_with_cases -from .test_cases.base import Call, MockedResponseTestCase +from .mock_response_case import Call, MockedResponseTestCase if TYPE_CHECKING: from _typeshed import dbapi diff --git a/tests/test_postgres.py b/tests/test_postgres.py deleted file mode 100644 index 90ddc69..0000000 --- a/tests/test_postgres.py +++ /dev/null @@ -1,49 +0,0 @@ -from collections.abc import Callable -from difflib import unified_diff -from pathlib import Path -from unittest import mock -from unittest.mock import MagicMock - -import pytest -from pytest_cases import parametrize_with_cases - -from tests.test_cases import to_csv_cases as csvc - - -@mock.patch("httpx_folio.auth.httpx.post") -@mock.patch("httpx_folio.factories.httpx.Client.get") -@parametrize_with_cases("tc", cases=csvc.ToCsvCases) -def test_to_csv( - client_get_mock: MagicMock, - httpx_post_mock: MagicMock, - pg_dsn: None | Callable[[str], str], - tc: csvc.ToCsvCase, - tmpdir: str, -) -> None: - if pg_dsn is None: - pytest.skip("Specify the pg host using --pg-host to run") - - from ldlite import LDLite as uut - - ld = uut() - tc.patch_request_get(ld, httpx_post_mock, client_get_mock) - dsn = pg_dsn(tc.db) - ld.connect_folio("https://doesnt.matter", "", "", "") - ld.connect_db_postgresql(dsn) - - for call in tc.calls_list: - ld.query(table=call.prefix, path="/patched") - - for table, expected in tc.expected_csvs: - actual = (Path(tmpdir) / table).with_suffix(".csv") - - ld.export_csv(str(actual), table) - - with expected.open("r") as f: - expected_lines = f.readlines() - with actual.open("r") as f: - actual_lines = f.readlines() - - diff = list(unified_diff(expected_lines, actual_lines)) - if len(diff) > 0: - pytest.fail("".join(diff)) diff --git a/tests/test_query.py b/tests/test_query.py index 7318b91..dec2fdc 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -12,7 +12,7 @@ from psycopg import sql from pytest_cases import parametrize, parametrize_with_cases -from .test_cases.base import Call, MockedResponseTestCase +from .mock_response_case import Call, MockedResponseTestCase if TYPE_CHECKING: from _typeshed import dbapi From 436c255073bd629921a61a3e996976a2e645e43c Mon Sep 17 00:00:00 2001 From: Katherine Bargar Date: Wed, 24 Sep 2025 15:33:19 +0000 Subject: [PATCH 13/17] Refactor __init__ tests to be integration filterable --- tests/test___init__.py | 160 ++++++++++++++++++++--------------------- 1 file changed, 78 insertions(+), 82 deletions(-) diff --git a/tests/test___init__.py b/tests/test___init__.py index e880ebf..e363ed4 100644 --- a/tests/test___init__.py +++ b/tests/test___init__.py @@ -7,78 +7,6 @@ from pytest_cases import parametrize_with_cases -def test_ok_legacy(folio_params: tuple[bool, FolioParams]) -> None: - from ldlite import LDLite as uut - - ld = uut() - ld.connect_folio(*astuple(folio_params[1])) - ld.connect_db() - ld.query(table="g", path="/groups", query="cql.allRecords=1 sortby id") - ld.select(table="g__t") - - -def test_ok_limit(folio_params: tuple[bool, FolioParams]) -> None: - from ldlite import LDLite as uut - - ld = uut() - db = ld.connect_db() - - ld.connect_folio(*astuple(folio_params[1])) - ld.page_size = 2 - ld.query(table="g", path="/groups", query="cql.allRecords=1 sortby id", limit=5) - - db.execute("SELECT COUNT(DISTINCT COLUMNS(*)) FROM g__t;") - actual = cast("tuple[int]", db.fetchone())[0] - assert actual == 5 - - -def test_ok_trailing_slash(folio_params: tuple[bool, FolioParams]) -> None: - if folio_params[0]: - pytest.skip("Specify an okapi environment with --folio-base-url to run") - - from ldlite import LDLite as uut - - ld = uut() - params = astuple(folio_params[1]) - ld.connect_folio(*[params[0] + "/", *params[1:]]) - ld.connect_db() - ld.query(table="g", path="/groups") - ld.select(table="g__t") - - -def test_ok(folio_params: tuple[bool, FolioParams]) -> None: - from ldlite import LDLite as uut - - ld = uut() - ld.connect_folio(*astuple(folio_params[1])) - ld.connect_db() - ld.query(table="g", path="/groups") - ld.select(table="g__t") - - -def test_no_connect_folio() -> None: - from ldlite import LDLite as uut - - ld = uut() - ld.connect_db() - with pytest.raises(RuntimeError): - ld.query(table="g", path="/groups") - - -def test_no_connect_db() -> None: - from ldlite import LDLite as uut - - ld = uut() - ld.connect_folio( - url="https://folio-etesting-snapshot-kong.ci.folio.org", - tenant="diku", - user="diku_admin", - password="admin", - ) - with pytest.raises(RuntimeError): - ld.query(table="g", path="/groups") - - @dataclass(frozen=True) class FolioConnectionCase: expected: type[Exception] @@ -116,14 +44,82 @@ def case_password(self) -> FolioConnectionCase: ) -@parametrize_with_cases("tc", cases=FolioConnectionCases) -def test_bad_folio_connection( - folio_params: tuple[bool, FolioParams], - tc: FolioConnectionCase, -) -> None: - from ldlite import LDLite as uut +class TestIntegration: + def test_ok_legacy(self, folio_params: tuple[bool, FolioParams]) -> None: + from ldlite import LDLite as uut + + ld = uut() + ld.connect_folio(*astuple(folio_params[1])) + ld.connect_db() + ld.query(table="g", path="/groups", query="cql.allRecords=1 sortby id") + ld.select(table="g__t") + + def test_ok_limit(self, folio_params: tuple[bool, FolioParams]) -> None: + from ldlite import LDLite as uut + + ld = uut() + db = ld.connect_db() + + ld.connect_folio(*astuple(folio_params[1])) + ld.page_size = 2 + ld.query(table="g", path="/groups", query="cql.allRecords=1 sortby id", limit=5) + + db.execute("SELECT COUNT(DISTINCT COLUMNS(*)) FROM g__t;") + actual = cast("tuple[int]", db.fetchone())[0] + assert actual == 5 + + def test_ok_trailing_slash(self, folio_params: tuple[bool, FolioParams]) -> None: + if folio_params[0]: + pytest.skip("Specify an okapi environment with --folio-base-url to run") + + from ldlite import LDLite as uut - ld = uut() - params = astuple(folio_params[1]) - with pytest.raises(tc.expected): - ld.connect_folio(*[*params[: tc.index], tc.value, *params[tc.index + 1 :]]) + ld = uut() + params = astuple(folio_params[1]) + ld.connect_folio(*[params[0] + "/", *params[1:]]) + ld.connect_db() + ld.query(table="g", path="/groups") + ld.select(table="g__t") + + def test_ok(self, folio_params: tuple[bool, FolioParams]) -> None: + from ldlite import LDLite as uut + + ld = uut() + ld.connect_folio(*astuple(folio_params[1])) + ld.connect_db() + ld.query(table="g", path="/groups") + ld.select(table="g__t") + + def test_no_connect_folio(self) -> None: + from ldlite import LDLite as uut + + ld = uut() + ld.connect_db() + with pytest.raises(RuntimeError): + ld.query(table="g", path="/groups") + + def test_no_connect_db(self) -> None: + from ldlite import LDLite as uut + + ld = uut() + ld.connect_folio( + url="https://folio-etesting-snapshot-kong.ci.folio.org", + tenant="diku", + user="diku_admin", + password="admin", + ) + with pytest.raises(RuntimeError): + ld.query(table="g", path="/groups") + + @parametrize_with_cases("tc", cases=FolioConnectionCases) + def test_bad_folio_connection( + self, + folio_params: tuple[bool, FolioParams], + tc: FolioConnectionCase, + ) -> None: + from ldlite import LDLite as uut + + ld = uut() + params = astuple(folio_params[1]) + with pytest.raises(tc.expected): + ld.connect_folio(*[*params[: tc.index], tc.value, *params[tc.index + 1 :]]) From 163a3ca9604b8b257a33a139027f5f2fa2ea597f Mon Sep 17 00:00:00 2001 From: Katherine Bargar Date: Wed, 24 Sep 2025 16:01:03 +0000 Subject: [PATCH 14/17] Refactor non srs tests to integration + both db types --- tests/test_endtoend.py | 214 ++++++++++++++++++++++++++--------------- 1 file changed, 136 insertions(+), 78 deletions(-) diff --git a/tests/test_endtoend.py b/tests/test_endtoend.py index d8aada2..c0c58a6 100644 --- a/tests/test_endtoend.py +++ b/tests/test_endtoend.py @@ -1,82 +1,140 @@ -from dataclasses import astuple -from typing import cast +from collections.abc import Callable +from contextlib import closing +from dataclasses import astuple, dataclass +from typing import TYPE_CHECKING, cast +from uuid import uuid4 import pytest from httpx_folio.factories import FolioParams, default_client_factory from httpx_folio.query import QueryParams, QueryType -from pytest_cases import parametrize - - -@parametrize( - tc=[ - # no id column - (True, "/finance/ledger-rollovers-logs", None), - # finicky about sorting - (True, "/notes", "title=Key Permissions"), - # id descending - (False, "/invoice/invoices", "vendorId==e0* sortBy id desc"), - # non id sort - (False, "/groups", "cql.allRecords=1 sortBy group desc"), - ], -) -def test_endtoend( - folio_params: tuple[bool, FolioParams], - tc: tuple[bool, str, QueryType], -) -> None: - (non_snapshot_data, endpoint, query) = tc - if non_snapshot_data and folio_params[0]: - pytest.skip( - "Specify an environment having data with --folio-base-url to run", - ) - - from ldlite import LDLite as uut - - ld = uut() - db = ld.connect_db() - - ld.page_size = 3 - ld.connect_folio(*astuple(folio_params[1])) - ld.query(table="test", path=endpoint, query=query) # type:ignore[arg-type] - - with default_client_factory(folio_params[1])() as client: - res = client.get( - endpoint, - params=QueryParams(query).stats(), - ) - res.raise_for_status() - - expected = res.json()["totalRecords"] - assert expected > 3 - - db.execute("SELECT COUNT(DISTINCT COLUMNS(*)) FROM test__t;") - actual = cast("tuple[int]", db.fetchone())[0] - - assert actual == expected - - -@parametrize( - srs=[ - "/source-storage/records", - "/source-storage/stream/records", - "/source-storage/source-records", - "/source-storage/stream/source-records", - ], -) -def test_endtoend_srs(folio_params: tuple[bool, FolioParams], srs: str) -> None: - from ldlite import LDLite as uut - - ld = uut() - db = ld.connect_db() - - ld.connect_folio(*astuple(folio_params[1])) - ld.query(table="test", path=srs, limit=10) - - db.execute("SELECT COUNT(DISTINCT COLUMNS(*)) FROM test__t;") - actual = cast("tuple[int]", db.fetchone())[0] - - # snapshot a variable number of records - assert actual >= 1 - if folio_params[0]: - assert actual <= 10 - else: - assert actual == 10 +from pytest_cases import parametrize, parametrize_with_cases + +if TYPE_CHECKING: + import ldlite + + +@dataclass(frozen=True) +class NonSrsCase: + snapshot_ok: bool + path: str + query: str | dict[str, str] | None + + +class NonSrsCases: + def case_no_id_col(self) -> NonSrsCase: + return NonSrsCase(False, "/finance/ledger-rollovers-logs", None) + + def case_finicky_sorting(self) -> NonSrsCase: + return NonSrsCase(False, "/notes", "title=Key Permissions") + + def case_id_descending(self) -> NonSrsCase: + return NonSrsCase(True, "/invoice/invoices", "vendorId==e0* sortBy id desc") + + def case_non_id_sort(self) -> NonSrsCase: + return NonSrsCase(True, "/groups", "cql.allRecords=1 sortBy group desc") + + +class TestIntegration: + def _nonsrs_arrange( + self, + folio_params: tuple[bool, FolioParams], + tc: NonSrsCase, + ) -> "ldlite.LDLite": + if not tc.snapshot_ok and folio_params[0]: + pytest.skip( + "Specify an environment having data with --folio-base-url to run", + ) + + from ldlite import LDLite + + uut = LDLite() + uut.page_size = 3 + uut.connect_folio(*astuple(folio_params[1])) + return uut + + def _nonsrs_assert( + self, + uut: "ldlite.LDLite", + folio_params: tuple[bool, FolioParams], + tc: NonSrsCase, + ) -> None: + with default_client_factory(folio_params[1])() as client: + res = client.get( + tc.path, + params=QueryParams(cast("QueryType", tc.query)).stats(), + ) + res.raise_for_status() + + expected = res.json()["totalRecords"] + assert expected > 3 + + if uut.db is None: + pytest.fail("No active database connection.") + + with closing(uut.db.cursor()) as cur: + cur.execute("SELECT COUNT(*) FROM (SELECT DISTINCT * FROM test__t) t;") + actual = cast("tuple[int]", cur.fetchone())[0] + + assert actual == expected + + @parametrize_with_cases("tc", cases=NonSrsCases) + def test_nonsrs_duckdb( + self, + folio_params: tuple[bool, FolioParams], + tc: NonSrsCase, + ) -> None: + uut = self._nonsrs_arrange(folio_params, tc) + uut.connect_db() + + uut.query(table="test", path=tc.path, query=tc.query) + self._nonsrs_assert(uut, folio_params, tc) + + @parametrize_with_cases("tc", cases=NonSrsCases) + def test_nonsrs_postgres( + self, + folio_params: tuple[bool, FolioParams], + pg_dsn: None | Callable[[str], str], + tc: NonSrsCase, + ) -> None: + if pg_dsn is None: + pytest.skip("Specify the pg host using --pg-host to run") + + uut = self._nonsrs_arrange(folio_params, tc) + db = "db" + str(uuid4()).split("-")[0] + print(db) # noqa: T201 + dsn = pg_dsn(db) + uut.connect_db_postgresql(dsn) + + uut.query(table="test", path=tc.path, query=tc.query) + self._nonsrs_assert(uut, folio_params, tc) + + @parametrize( + srs=[ + "/source-storage/records", + "/source-storage/stream/records", + "/source-storage/source-records", + "/source-storage/stream/source-records", + ], + ) + def test_endtoend_srs( + self, + folio_params: tuple[bool, FolioParams], + srs: str, + ) -> None: + from ldlite import LDLite as uut + + ld = uut() + db = ld.connect_db() + + ld.connect_folio(*astuple(folio_params[1])) + ld.query(table="test", path=srs, limit=10) + + db.execute("SELECT COUNT(DISTINCT COLUMNS(*)) FROM test__t;") + actual = cast("tuple[int]", db.fetchone())[0] + + # snapshot a variable number of records + assert actual >= 1 + if folio_params[0]: + assert actual <= 10 + else: + assert actual == 10 From 5d892aa64e58a9a7ce60aab2d670cb8e298cc6c4 Mon Sep 17 00:00:00 2001 From: Katherine Bargar Date: Wed, 24 Sep 2025 16:19:23 +0000 Subject: [PATCH 15/17] Refactor srs test cases to work with duckdb and postgres --- tests/test_endtoend.py | 82 +++++++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 28 deletions(-) diff --git a/tests/test_endtoend.py b/tests/test_endtoend.py index c0c58a6..6768101 100644 --- a/tests/test_endtoend.py +++ b/tests/test_endtoend.py @@ -34,13 +34,21 @@ def case_non_id_sort(self) -> NonSrsCase: return NonSrsCase(True, "/groups", "cql.allRecords=1 sortBy group desc") +SrsCases = [ + "/source-storage/records", + "/source-storage/stream/records", + "/source-storage/source-records", + "/source-storage/stream/source-records", +] + + class TestIntegration: - def _nonsrs_arrange( + def _arrange( self, folio_params: tuple[bool, FolioParams], - tc: NonSrsCase, + snapshot_ok: bool = True, ) -> "ldlite.LDLite": - if not tc.snapshot_ok and folio_params[0]: + if not snapshot_ok and folio_params[0]: pytest.skip( "Specify an environment having data with --folio-base-url to run", ) @@ -83,7 +91,7 @@ def test_nonsrs_duckdb( folio_params: tuple[bool, FolioParams], tc: NonSrsCase, ) -> None: - uut = self._nonsrs_arrange(folio_params, tc) + uut = self._arrange(folio_params, tc.snapshot_ok) uut.connect_db() uut.query(table="test", path=tc.path, query=tc.query) @@ -99,42 +107,60 @@ def test_nonsrs_postgres( if pg_dsn is None: pytest.skip("Specify the pg host using --pg-host to run") - uut = self._nonsrs_arrange(folio_params, tc) + uut = self._arrange(folio_params, tc.snapshot_ok) db = "db" + str(uuid4()).split("-")[0] print(db) # noqa: T201 dsn = pg_dsn(db) uut.connect_db_postgresql(dsn) uut.query(table="test", path=tc.path, query=tc.query) + self._nonsrs_assert(uut, folio_params, tc) - @parametrize( - srs=[ - "/source-storage/records", - "/source-storage/stream/records", - "/source-storage/source-records", - "/source-storage/stream/source-records", - ], - ) - def test_endtoend_srs( + def _srs_assert(self, uut: "ldlite.LDLite", is_snapshot: bool) -> None: + if uut.db is None: + pytest.fail("No active database connection.") + + with closing(uut.db.cursor()) as cur: + cur.execute("SELECT COUNT(*) FROM (SELECT DISTINCT * FROM test__t) t;") + actual = cast("tuple[int]", cur.fetchone())[0] + + # snapshot has a variable number of records + assert actual >= 1 + if is_snapshot: + assert actual <= 10 + else: + assert actual == 10 + + @parametrize(path=SrsCases) + def test_srs_duckdb( self, folio_params: tuple[bool, FolioParams], - srs: str, + path: str, ) -> None: - from ldlite import LDLite as uut + uut = self._arrange(folio_params) + uut.connect_db() - ld = uut() - db = ld.connect_db() + uut.query(table="test", path=path, limit=10) - ld.connect_folio(*astuple(folio_params[1])) - ld.query(table="test", path=srs, limit=10) + self._srs_assert(uut, folio_params[0]) + + @parametrize(path=SrsCases) + def test_srs_postgres( + self, + folio_params: tuple[bool, FolioParams], + pg_dsn: None | Callable[[str], str], + path: str, + ) -> None: + if pg_dsn is None: + pytest.skip("Specify the pg host using --pg-host to run") + + uut = self._arrange(folio_params) + db = "db" + str(uuid4()).split("-")[0] + print(db) # noqa: T201 + dsn = pg_dsn(db) + uut.connect_db_postgresql(dsn) - db.execute("SELECT COUNT(DISTINCT COLUMNS(*)) FROM test__t;") - actual = cast("tuple[int]", db.fetchone())[0] + uut.query(table="test", path=path, limit=10) - # snapshot a variable number of records - assert actual >= 1 - if folio_params[0]: - assert actual <= 10 - else: - assert actual == 10 + self._srs_assert(uut, folio_params[0]) From 179fc59386982bfbf8120158597e42524ae733b9 Mon Sep 17 00:00:00 2001 From: Katherine Bargar Date: Wed, 24 Sep 2025 16:32:18 +0000 Subject: [PATCH 16/17] Add to readme --- tests/README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/README.md b/tests/README.md index 85b5758..8d31d04 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,4 +1,18 @@ -# Test Setup +# Tests + +## Running tests + +There is a pdm script which runs tests with code coverage `pdm run test`. Any additional parameters will be passed through to pytest. + +The -k parameter filters tests. Some useful examples: +``` +pdm run test -k "Integration" +pdm run test --pg-host 172.17.0.3 -k "postgres and not Integration" +pdm run test -k "drop_tables and not postgres" +pdm run test -k "duckdb" +``` + +## Test Setup Duckdb tests "just work". Postgres tests will be skipped unless you set them up first. From ee260ef6a4d3878336175aeab28bfca7a5bd9e98 Mon Sep 17 00:00:00 2001 From: Katherine Bargar Date: Wed, 24 Sep 2025 16:42:52 +0000 Subject: [PATCH 17/17] Remove a layer of test case nesting --- tests/test_drop_tables.py | 115 ++-- tests/test_export_csv.py | 152 ++--- tests/test_load_history.py | 119 ++-- tests/test_query.py | 1108 ++++++++++++++++++------------------ 4 files changed, 748 insertions(+), 746 deletions(-) diff --git a/tests/test_drop_tables.py b/tests/test_drop_tables.py index 2c77488..a0d1d42 100644 --- a/tests/test_drop_tables.py +++ b/tests/test_drop_tables.py @@ -19,74 +19,75 @@ @dataclass(frozen=True) -class DropTablesCase(MockedResponseTestCase): +class DropTablesTC(MockedResponseTestCase): drop: str expected_tables: list[str] -class DropTablesCases: - @parametrize(keep_raw=[True, False]) - def case_one_table(self, keep_raw: bool) -> DropTablesCase: - return DropTablesCase( +@parametrize(keep_raw=[True, False]) +def case_one_table(keep_raw: bool) -> DropTablesTC: + return DropTablesTC( + Call( + "prefix", + returns={"purchaseOrders": [{"id": "1"}]}, + keep_raw=keep_raw, + ), + drop="prefix", + expected_tables=[], + ) + + +@parametrize(keep_raw=[True, False]) +def case_two_tables(keep_raw: bool) -> DropTablesTC: + return DropTablesTC( + Call( + "prefix", + returns={ + "purchaseOrders": [ + { + "id": "1", + "subObjects": [{"id": "2"}, {"id": "3"}], + }, + ], + }, + keep_raw=keep_raw, + ), + drop="prefix", + expected_tables=[], + ) + + +@parametrize(keep_raw=[True, False]) +def case_separate_table(keep_raw: bool) -> DropTablesTC: + expected_tables = [ + "notdropped__t", + "notdropped__tcatalog", + ] + if keep_raw: + expected_tables = ["notdropped", *expected_tables] + + return DropTablesTC( + [ Call( "prefix", returns={"purchaseOrders": [{"id": "1"}]}, keep_raw=keep_raw, ), - drop="prefix", - expected_tables=[], - ) - - @parametrize(keep_raw=[True, False]) - def case_two_tables(self, keep_raw: bool) -> DropTablesCase: - return DropTablesCase( Call( - "prefix", - returns={ - "purchaseOrders": [ - { - "id": "1", - "subObjects": [{"id": "2"}, {"id": "3"}], - }, - ], - }, + "notdropped", + returns={"purchaseOrders": [{"id": "1"}]}, keep_raw=keep_raw, ), - drop="prefix", - expected_tables=[], - ) - - @parametrize(keep_raw=[True, False]) - def case_separate_table(self, keep_raw: bool) -> DropTablesCase: - expected_tables = [ - "notdropped__t", - "notdropped__tcatalog", - ] - if keep_raw: - expected_tables = ["notdropped", *expected_tables] - - return DropTablesCase( - [ - Call( - "prefix", - returns={"purchaseOrders": [{"id": "1"}]}, - keep_raw=keep_raw, - ), - Call( - "notdropped", - returns={"purchaseOrders": [{"id": "1"}]}, - keep_raw=keep_raw, - ), - ], - drop="prefix", - expected_tables=expected_tables, - ) + ], + drop="prefix", + expected_tables=expected_tables, + ) def _arrange( client_get_mock: MagicMock, httpx_post_mock: MagicMock, - tc: DropTablesCase, + tc: DropTablesTC, ) -> "ldlite.LDLite": from ldlite import LDLite @@ -96,7 +97,7 @@ def _arrange( return uut -def _act(uut: "ldlite.LDLite", tc: DropTablesCase) -> None: +def _act(uut: "ldlite.LDLite", tc: DropTablesTC) -> None: uut.drop_tables(tc.drop) for call in tc.calls_list: uut.query(table=call.prefix, path="/patched", keep_raw=call.keep_raw) @@ -106,7 +107,7 @@ def _act(uut: "ldlite.LDLite", tc: DropTablesCase) -> None: def _assert( conn: "dbapi.DBAPIConnection", res_schema: str, # TODO: have schema be part of tc - tc: DropTablesCase, + tc: DropTablesTC, ) -> None: with closing(conn.cursor()) as cur: cur.execute( @@ -133,11 +134,11 @@ def _assert( @mock.patch("httpx_folio.auth.httpx.post") @mock.patch("httpx_folio.factories.httpx.Client.get") -@parametrize_with_cases("tc", cases=DropTablesCases) +@parametrize_with_cases("tc", cases=".") def test_duckdb( client_get_mock: MagicMock, httpx_post_mock: MagicMock, - tc: DropTablesCase, + tc: DropTablesTC, ) -> None: uut = _arrange(client_get_mock, httpx_post_mock, tc) dsn = f":memory:{tc.db}" @@ -151,12 +152,12 @@ def test_duckdb( @mock.patch("httpx_folio.auth.httpx.post") @mock.patch("httpx_folio.factories.httpx.Client.get") -@parametrize_with_cases("tc", cases=DropTablesCases) +@parametrize_with_cases("tc", cases=".") def test_postgres( client_get_mock: MagicMock, httpx_post_mock: MagicMock, pg_dsn: None | Callable[[str], str], - tc: DropTablesCase, + tc: DropTablesTC, ) -> None: if pg_dsn is None: pytest.skip("Specify the pg host using --pg-host to run") diff --git a/tests/test_export_csv.py b/tests/test_export_csv.py index 379d8a6..55aaae2 100644 --- a/tests/test_export_csv.py +++ b/tests/test_export_csv.py @@ -18,85 +18,87 @@ @dataclass(frozen=True) -class ToCsvCase(MockedResponseTestCase): +class ExportCsvTC(MockedResponseTestCase): expected_csvs: list[tuple[str, Path]] -class ToCsvCases: - def case_basic(self) -> ToCsvCase: - return ToCsvCase( - Call("prefix", returns={"purchaseOrders": [{"id": "id", "val": "value"}]}), - expected_csvs=[("prefix__t", _SAMPLE_PATH / "basic.csv")], - ) - - def case_datatypes(self) -> ToCsvCase: - return ToCsvCase( - Call( - "prefix", - returns={ - "purchaseOrders": [ - { - "id": "id", - "string": "string", - "integer": 1, - "numeric": 1.1, - "boolean": True, - "uuid": "6a31a12a-9570-405c-af20-6abf2992859c", - }, - ], - }, - ), - expected_csvs=[("prefix__t", _SAMPLE_PATH / "datatypes.csv")], - ) - - def case_escaped_chars(self) -> ToCsvCase: - return ToCsvCase( - Call( - "prefix", - returns={ - "purchaseOrders": [ - { - "id": "id", - "comma": "Double, double toil and trouble", - "doubleQuote": 'Cry "Havoc!" a horse', - "newLine": """To be +def case_basic() -> ExportCsvTC: + return ExportCsvTC( + Call("prefix", returns={"purchaseOrders": [{"id": "id", "val": "value"}]}), + expected_csvs=[("prefix__t", _SAMPLE_PATH / "basic.csv")], + ) + + +def case_datatypes() -> ExportCsvTC: + return ExportCsvTC( + Call( + "prefix", + returns={ + "purchaseOrders": [ + { + "id": "id", + "string": "string", + "integer": 1, + "numeric": 1.1, + "boolean": True, + "uuid": "6a31a12a-9570-405c-af20-6abf2992859c", + }, + ], + }, + ), + expected_csvs=[("prefix__t", _SAMPLE_PATH / "datatypes.csv")], + ) + + +def case_escaped_chars() -> ExportCsvTC: + return ExportCsvTC( + Call( + "prefix", + returns={ + "purchaseOrders": [ + { + "id": "id", + "comma": "Double, double toil and trouble", + "doubleQuote": 'Cry "Havoc!" a horse', + "newLine": """To be or not to be""", - "singleQuote": "Cry 'Havoc!' a horse", - }, - { - "id": "id", - "comma": "Z", - "doubleQuote": "Z", - "newLine": "Z", - "singleQuote": "Z", - }, - ], - }, - ), - expected_csvs=[("prefix__t", _SAMPLE_PATH / "escaped_chars.csv")], - ) - - def case_sorting(self) -> ToCsvCase: - return ToCsvCase( - Call( - "prefix", - returns={ - "purchaseOrders": [ - {"id": "id", "C": "YY", "B": "XX", "A": "ZZ"}, - {"id": "id", "C": "Y", "B": "XX", "A": "ZZ"}, - {"id": "id", "C": "Y", "B": "X", "A": "Z"}, - ], - }, - ), - expected_csvs=[("prefix__t", _SAMPLE_PATH / "sorting.csv")], - ) + "singleQuote": "Cry 'Havoc!' a horse", + }, + { + "id": "id", + "comma": "Z", + "doubleQuote": "Z", + "newLine": "Z", + "singleQuote": "Z", + }, + ], + }, + ), + expected_csvs=[("prefix__t", _SAMPLE_PATH / "escaped_chars.csv")], + ) + + +def case_sorting() -> ExportCsvTC: + return ExportCsvTC( + Call( + "prefix", + returns={ + "purchaseOrders": [ + {"id": "id", "C": "YY", "B": "XX", "A": "ZZ"}, + {"id": "id", "C": "Y", "B": "XX", "A": "ZZ"}, + {"id": "id", "C": "Y", "B": "X", "A": "Z"}, + ], + }, + ), + expected_csvs=[("prefix__t", _SAMPLE_PATH / "sorting.csv")], + ) def _arrange( client_get_mock: MagicMock, httpx_post_mock: MagicMock, - tc: ToCsvCase, + tc: ExportCsvTC, ) -> "ldlite.LDLite": from ldlite import LDLite @@ -106,14 +108,14 @@ def _arrange( return uut -def _act(uut: "ldlite.LDLite", tc: ToCsvCase) -> None: +def _act(uut: "ldlite.LDLite", tc: ExportCsvTC) -> None: for call in tc.calls_list: uut.query(table=call.prefix, path="/patched") def _assert( uut: "ldlite.LDLite", - tc: ToCsvCase, + tc: ExportCsvTC, tmpdir: str, ) -> None: for table, expected in tc.expected_csvs: @@ -133,11 +135,11 @@ def _assert( @mock.patch("httpx_folio.auth.httpx.post") @mock.patch("httpx_folio.factories.httpx.Client.get") -@parametrize_with_cases("tc", cases=ToCsvCases) +@parametrize_with_cases("tc", cases=".") def test_duckdb( client_get_mock: MagicMock, httpx_post_mock: MagicMock, - tc: ToCsvCase, + tc: ExportCsvTC, tmpdir: str, ) -> None: uut = _arrange(client_get_mock, httpx_post_mock, tc) @@ -150,12 +152,12 @@ def test_duckdb( @mock.patch("httpx_folio.auth.httpx.post") @mock.patch("httpx_folio.factories.httpx.Client.get") -@parametrize_with_cases("tc", cases=ToCsvCases) +@parametrize_with_cases("tc", cases=".") def test_postgres( client_get_mock: MagicMock, httpx_post_mock: MagicMock, pg_dsn: None | Callable[[str], str], - tc: ToCsvCase, + tc: ExportCsvTC, tmpdir: str, ) -> None: if pg_dsn is None: diff --git a/tests/test_load_history.py b/tests/test_load_history.py index 7c1ca37..1db5f34 100644 --- a/tests/test_load_history.py +++ b/tests/test_load_history.py @@ -19,37 +19,71 @@ @dataclass(frozen=True) -class LoadHistoryCase(MockedResponseTestCase): +class LoadHistoryTC(MockedResponseTestCase): expected_loads: dict[str, tuple[str | None, int]] -class LoadHistoryTestCases: - @parametrize(query=[None, "poline.id=*A"]) - def case_one_load(self, query: str | None) -> LoadHistoryCase: - return LoadHistoryCase( +@parametrize(query=[None, "poline.id=*A"]) +def case_one_load(query: str | None) -> LoadHistoryTC: + return LoadHistoryTC( + Call( + "prefix", + query=query, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + }, + { + "id": "b096504a-9999-4664-9bf5-1b872466fd66", + "value": "value-2", + }, + ], + }, + ), + expected_loads={"prefix": (query, 2)}, + ) + + +def case_schema_load() -> LoadHistoryTC: + return LoadHistoryTC( + Call( + "schema.prefix", + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + }, + { + "id": "b096504a-9999-4664-9bf5-1b872466fd66", + "value": "value-2", + }, + ], + }, + ), + expected_loads={"schema.prefix": (None, 2)}, + ) + + +def case_two_loads() -> LoadHistoryTC: + return LoadHistoryTC( + [ Call( "prefix", - query=query, returns={ "purchaseOrders": [ { "id": "b096504a-3d54-4664-9bf5-1b872466fd66", "value": "value", }, - { - "id": "b096504a-9999-4664-9bf5-1b872466fd66", - "value": "value-2", - }, ], }, ), - expected_loads={"prefix": (query, 2)}, - ) - - def case_schema_load(self) -> LoadHistoryCase: - return LoadHistoryCase( Call( - "schema.prefix", + "prefix", + query="a query", returns={ "purchaseOrders": [ { @@ -63,48 +97,15 @@ def case_schema_load(self) -> LoadHistoryCase: ], }, ), - expected_loads={"schema.prefix": (None, 2)}, - ) - - def case_two_loads(self) -> LoadHistoryCase: - return LoadHistoryCase( - [ - Call( - "prefix", - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - }, - ], - }, - ), - Call( - "prefix", - query="a query", - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - }, - { - "id": "b096504a-9999-4664-9bf5-1b872466fd66", - "value": "value-2", - }, - ], - }, - ), - ], - expected_loads={"prefix": ("a query", 2)}, - ) + ], + expected_loads={"prefix": ("a query", 2)}, + ) def _arrange( client_get_mock: MagicMock, httpx_post_mock: MagicMock, - tc: LoadHistoryCase, + tc: LoadHistoryTC, ) -> "ldlite.LDLite": from ldlite import LDLite @@ -114,14 +115,14 @@ def _arrange( return uut -def _act(uut: "ldlite.LDLite", tc: LoadHistoryCase) -> None: +def _act(uut: "ldlite.LDLite", tc: LoadHistoryTC) -> None: for call in tc.calls_list: uut.query(table=call.prefix, path="/patched", query=call.query) def _assert( conn: "dbapi.DBAPIConnection", - tc: LoadHistoryCase, + tc: LoadHistoryTC, ) -> None: with closing(conn.cursor()) as cur: cur.execute('SELECT COUNT(*) FROM "ldlite_system"."load_history"') @@ -141,11 +142,11 @@ def _assert( @mock.patch("httpx_folio.auth.httpx.post") @mock.patch("httpx_folio.factories.httpx.Client.get") -@parametrize_with_cases("tc", cases=LoadHistoryTestCases) +@parametrize_with_cases("tc", cases=".") def test_duckdb( client_get_mock: MagicMock, httpx_post_mock: MagicMock, - tc: LoadHistoryCase, + tc: LoadHistoryTC, ) -> None: uut = _arrange(client_get_mock, httpx_post_mock, tc) dsn = f":memory:{tc.db}" @@ -158,12 +159,12 @@ def test_duckdb( @mock.patch("httpx_folio.auth.httpx.post") @mock.patch("httpx_folio.factories.httpx.Client.get") -@parametrize_with_cases("tc", cases=LoadHistoryTestCases) +@parametrize_with_cases("tc", cases=".") def test_postgres( client_get_mock: MagicMock, httpx_post_mock: MagicMock, pg_dsn: None | Callable[[str], str], - tc: LoadHistoryCase, + tc: LoadHistoryTC, ) -> None: if pg_dsn is None: pytest.skip("Specify the pg host using --pg-host to run") diff --git a/tests/test_query.py b/tests/test_query.py index dec2fdc..fbc8ceb 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -21,593 +21,591 @@ @dataclass(frozen=True) -class QueryCase(MockedResponseTestCase): +class QueryTC(MockedResponseTestCase): expected_tables: list[str] expected_values: dict[str, tuple[list[str], list[tuple[Any, ...]]]] expected_indexes: list[tuple[str, str]] | None = None -class QueryTestCases: - @parametrize(json_depth=range(1, 2)) - def case_one_table(self, json_depth: int) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=json_depth, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - }, - ], - }, - ), - expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], - expected_values={ - "prefix__t": ( - ["id", "value"], - [("b096504a-3d54-4664-9bf5-1b872466fd66", "value")], - ), - "prefix__tcatalog": (["table_name"], [("prefix__t",)]), +@parametrize(json_depth=range(1, 2)) +def case_one_table(json_depth: int) -> QueryTC: + return QueryTC( + Call( + "prefix", + json_depth=json_depth, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + }, + ], }, - expected_indexes=[ - ("prefix", "__id"), - ("prefix__t", "__id"), - ("prefix__t", "id"), - ], - ) - - @parametrize(json_depth=range(2, 3)) - def case_two_tables(self, json_depth: int) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=json_depth, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - "subObjects": [ - { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-value-1", - }, - { - "id": "f5bda109-a719-4f72-b797-b9c22f45e4e1", - "value": "sub-value-2", - }, - ], - }, - ], - }, + ), + expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], + expected_values={ + "prefix__t": ( + ["id", "value"], + [("b096504a-3d54-4664-9bf5-1b872466fd66", "value")], ), - expected_tables=[ - "prefix", - "prefix__t", - "prefix__t__sub_objects", - "prefix__tcatalog", - ], - expected_values={ - "prefix__t": ( - ["id", "value"], - [("b096504a-3d54-4664-9bf5-1b872466fd66", "value")], - ), - "prefix__t__sub_objects": ( - ["id", "sub_objects__id", "sub_objects__value"], - [ - ( - "b096504a-3d54-4664-9bf5-1b872466fd66", - "2b94c631-fca9-4892-a730-03ee529ffe2a", - "sub-value-1", - ), - ( - "b096504a-3d54-4664-9bf5-1b872466fd66", - "f5bda109-a719-4f72-b797-b9c22f45e4e1", - "sub-value-2", - ), - ], - ), - "prefix__tcatalog": ( - ["table_name"], - [("prefix__t",), ("prefix__t__sub_objects",)], - ), + "prefix__tcatalog": (["table_name"], [("prefix__t",)]), + }, + expected_indexes=[ + ("prefix", "__id"), + ("prefix__t", "__id"), + ("prefix__t", "id"), + ], + ) + + +@parametrize(json_depth=range(2, 3)) +def case_two_tables(json_depth: int) -> QueryTC: + return QueryTC( + Call( + "prefix", + json_depth=json_depth, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + "subObjects": [ + { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-value-1", + }, + { + "id": "f5bda109-a719-4f72-b797-b9c22f45e4e1", + "value": "sub-value-2", + }, + ], + }, + ], }, - expected_indexes=[ - ("prefix", "__id"), - ("prefix__t", "__id"), - ("prefix__t", "id"), - ("prefix__t__sub_objects", "__id"), - ("prefix__t__sub_objects", "id"), - ("prefix__t__sub_objects", "sub_objects__o"), - ("prefix__t__sub_objects", "sub_objects__id"), - ], - ) - - @parametrize(json_depth=range(1)) - def case_table_no_expansion(self, json_depth: int) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=json_depth, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - "subObjects": [ - { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-value", - }, - ], - }, - ], - }, + ), + expected_tables=[ + "prefix", + "prefix__t", + "prefix__t__sub_objects", + "prefix__tcatalog", + ], + expected_values={ + "prefix__t": ( + ["id", "value"], + [("b096504a-3d54-4664-9bf5-1b872466fd66", "value")], ), - expected_tables=["prefix"], - expected_values={}, - ) - - def case_table_underexpansion(self) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=2, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "subObjects": [ - { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-value", - "subSubObjects": [ - { - "id": ( - "2b94c631-fca9-4892-a730-03ee529ffe2a" - ), - "value": "sub-sub-value", - }, - ], - }, - ], - }, - ], - }, + "prefix__t__sub_objects": ( + ["id", "sub_objects__id", "sub_objects__value"], + [ + ( + "b096504a-3d54-4664-9bf5-1b872466fd66", + "2b94c631-fca9-4892-a730-03ee529ffe2a", + "sub-value-1", + ), + ( + "b096504a-3d54-4664-9bf5-1b872466fd66", + "f5bda109-a719-4f72-b797-b9c22f45e4e1", + "sub-value-2", + ), + ], ), - expected_tables=[ - "prefix", - "prefix__t", - "prefix__t__sub_objects", - "prefix__tcatalog", - ], - expected_values={ - "prefix__t__sub_objects": ( - [ - "id", - "sub_objects__id", - "sub_objects__value", - ], - [ - ( - "b096504a-3d54-4664-9bf5-1b872466fd66", - "2b94c631-fca9-4892-a730-03ee529ffe2a", - "sub-value", - ), - ], - ), - "prefix__tcatalog": ( - ["table_name"], - [("prefix__t",), ("prefix__t__sub_objects",)], - ), - }, - ) - - @parametrize(json_depth=range(3, 4)) - def case_three_tables(self, json_depth: int) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=json_depth, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - "subObjects": [ - { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-value", - "subSubObjects": [ - { - "id": ( - "2b94c631-fca9-4892-a730-03ee529ffe2a" - ), - "value": "sub-sub-value", - }, - ], - }, - ], - }, - ], - }, + "prefix__tcatalog": ( + ["table_name"], + [("prefix__t",), ("prefix__t__sub_objects",)], ), - expected_tables=[ - "prefix", - "prefix__t", - "prefix__t__sub_objects", - "prefix__t__sub_objects__sub_sub_objects", - "prefix__tcatalog", - ], - expected_values={ - "prefix__t__sub_objects__sub_sub_objects": ( - [ - "id", - "sub_objects__id", - "sub_objects__sub_sub_objects__id", - "sub_objects__sub_sub_objects__value", - ], - [ - ( - "b096504a-3d54-4664-9bf5-1b872466fd66", - "2b94c631-fca9-4892-a730-03ee529ffe2a", - "2b94c631-fca9-4892-a730-03ee529ffe2a", - "sub-sub-value", - ), - ], - ), - "prefix__tcatalog": ( - ["table_name"], - [ - ("prefix__t",), - ("prefix__t__sub_objects",), - ("prefix__t__sub_objects__sub_sub_objects",), - ], - ), + }, + expected_indexes=[ + ("prefix", "__id"), + ("prefix__t", "__id"), + ("prefix__t", "id"), + ("prefix__t__sub_objects", "__id"), + ("prefix__t__sub_objects", "id"), + ("prefix__t__sub_objects", "sub_objects__o"), + ("prefix__t__sub_objects", "sub_objects__id"), + ], + ) + + +@parametrize(json_depth=range(1)) +def case_table_no_expansion(json_depth: int) -> QueryTC: + return QueryTC( + Call( + "prefix", + json_depth=json_depth, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + "subObjects": [ + { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-value", + }, + ], + }, + ], }, - expected_indexes=[ - ("prefix", "__id"), - ("prefix__t", "__id"), - ("prefix__t", "id"), - ("prefix__t__sub_objects", "__id"), - ("prefix__t__sub_objects", "id"), - ("prefix__t__sub_objects", "sub_objects__o"), - ("prefix__t__sub_objects", "sub_objects__id"), - ("prefix__t__sub_objects__sub_sub_objects", "__id"), - ("prefix__t__sub_objects__sub_sub_objects", "id"), - ("prefix__t__sub_objects__sub_sub_objects", "sub_objects__o"), - ("prefix__t__sub_objects__sub_sub_objects", "sub_objects__id"), - ( - "prefix__t__sub_objects__sub_sub_objects", - "sub_objects__sub_sub_objects__o", - ), - ( - "prefix__t__sub_objects__sub_sub_objects", - "sub_objects__sub_sub_objects__id", - ), - ], - ) - - def case_nested_object(self) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=2, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - "subObject": { + ), + expected_tables=["prefix"], + expected_values={}, + ) + + +def case_table_underexpansion() -> QueryTC: + return QueryTC( + Call( + "prefix", + json_depth=2, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "subObjects": [ + { "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", "value": "sub-value", + "subSubObjects": [ + { + "id": ("2b94c631-fca9-4892-a730-03ee529ffe2a"), + "value": "sub-sub-value", + }, + ], }, - }, - ], - }, - ), - expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], - expected_values={ - "prefix__t": ( - ["id", "value", "sub_object__id", "sub_object__value"], - [ - ( - "b096504a-3d54-4664-9bf5-1b872466fd66", - "value", - "2b94c631-fca9-4892-a730-03ee529ffe2a", - "sub-value", - ), - ], - ), - "prefix__tcatalog": ( - ["table_name"], - [("prefix__t",)], - ), + ], + }, + ], }, - expected_indexes=[ - ("prefix", "__id"), - ("prefix__t", "__id"), - ("prefix__t", "id"), - ("prefix__t", "sub_object__id"), - ], - ) - - def case_doubly_nested_object(self) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=3, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - "subObject": { + ), + expected_tables=[ + "prefix", + "prefix__t", + "prefix__t__sub_objects", + "prefix__tcatalog", + ], + expected_values={ + "prefix__t__sub_objects": ( + [ + "id", + "sub_objects__id", + "sub_objects__value", + ], + [ + ( + "b096504a-3d54-4664-9bf5-1b872466fd66", + "2b94c631-fca9-4892-a730-03ee529ffe2a", + "sub-value", + ), + ], + ), + "prefix__tcatalog": ( + ["table_name"], + [("prefix__t",), ("prefix__t__sub_objects",)], + ), + }, + ) + + +@parametrize(json_depth=range(3, 4)) +def case_three_tables(json_depth: int) -> QueryTC: + return QueryTC( + Call( + "prefix", + json_depth=json_depth, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + "subObjects": [ + { "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", "value": "sub-value", - "subSubObject": { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-sub-value", - }, + "subSubObjects": [ + { + "id": ("2b94c631-fca9-4892-a730-03ee529ffe2a"), + "value": "sub-sub-value", + }, + ], }, + ], + }, + ], + }, + ), + expected_tables=[ + "prefix", + "prefix__t", + "prefix__t__sub_objects", + "prefix__t__sub_objects__sub_sub_objects", + "prefix__tcatalog", + ], + expected_values={ + "prefix__t__sub_objects__sub_sub_objects": ( + [ + "id", + "sub_objects__id", + "sub_objects__sub_sub_objects__id", + "sub_objects__sub_sub_objects__value", + ], + [ + ( + "b096504a-3d54-4664-9bf5-1b872466fd66", + "2b94c631-fca9-4892-a730-03ee529ffe2a", + "2b94c631-fca9-4892-a730-03ee529ffe2a", + "sub-sub-value", + ), + ], + ), + "prefix__tcatalog": ( + ["table_name"], + [ + ("prefix__t",), + ("prefix__t__sub_objects",), + ("prefix__t__sub_objects__sub_sub_objects",), + ], + ), + }, + expected_indexes=[ + ("prefix", "__id"), + ("prefix__t", "__id"), + ("prefix__t", "id"), + ("prefix__t__sub_objects", "__id"), + ("prefix__t__sub_objects", "id"), + ("prefix__t__sub_objects", "sub_objects__o"), + ("prefix__t__sub_objects", "sub_objects__id"), + ("prefix__t__sub_objects__sub_sub_objects", "__id"), + ("prefix__t__sub_objects__sub_sub_objects", "id"), + ("prefix__t__sub_objects__sub_sub_objects", "sub_objects__o"), + ("prefix__t__sub_objects__sub_sub_objects", "sub_objects__id"), + ( + "prefix__t__sub_objects__sub_sub_objects", + "sub_objects__sub_sub_objects__o", + ), + ( + "prefix__t__sub_objects__sub_sub_objects", + "sub_objects__sub_sub_objects__id", + ), + ], + ) + + +def case_nested_object() -> QueryTC: + return QueryTC( + Call( + "prefix", + json_depth=2, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + "subObject": { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-value", }, - ], - }, + }, + ], + }, + ), + expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], + expected_values={ + "prefix__t": ( + ["id", "value", "sub_object__id", "sub_object__value"], + [ + ( + "b096504a-3d54-4664-9bf5-1b872466fd66", + "value", + "2b94c631-fca9-4892-a730-03ee529ffe2a", + "sub-value", + ), + ], + ), + "prefix__tcatalog": ( + ["table_name"], + [("prefix__t",)], ), - expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], - expected_values={ - "prefix__t": ( - [ - "id", + }, + expected_indexes=[ + ("prefix", "__id"), + ("prefix__t", "__id"), + ("prefix__t", "id"), + ("prefix__t", "sub_object__id"), + ], + ) + + +def case_doubly_nested_object() -> QueryTC: + return QueryTC( + Call( + "prefix", + json_depth=3, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + "subObject": { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-value", + "subSubObject": { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-sub-value", + }, + }, + }, + ], + }, + ), + expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], + expected_values={ + "prefix__t": ( + [ + "id", + "value", + "sub_object__id", + "sub_object__sub_sub_object__id", + "sub_object__sub_sub_object__value", + ], + [ + ( + "b096504a-3d54-4664-9bf5-1b872466fd66", "value", - "sub_object__id", - "sub_object__sub_sub_object__id", - "sub_object__sub_sub_object__value", - ], - [ - ( - "b096504a-3d54-4664-9bf5-1b872466fd66", - "value", - "2b94c631-fca9-4892-a730-03ee529ffe2a", - "2b94c631-fca9-4892-a730-03ee529ffe2a", - "sub-sub-value", - ), - ], - ), - "prefix__tcatalog": ( - ["table_name"], - [("prefix__t",)], - ), + "2b94c631-fca9-4892-a730-03ee529ffe2a", + "2b94c631-fca9-4892-a730-03ee529ffe2a", + "sub-sub-value", + ), + ], + ), + "prefix__tcatalog": ( + ["table_name"], + [("prefix__t",)], + ), + }, + expected_indexes=[ + ("prefix", "__id"), + ("prefix__t", "__id"), + ("prefix__t", "id"), + ("prefix__t", "sub_object__id"), + ("prefix__t", "sub_object__sub_sub_object__id"), + ], + ) + + +def case_nested_object_underexpansion() -> QueryTC: + return QueryTC( + Call( + "prefix", + json_depth=1, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + "subObject": { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "value": "sub-value", + }, + }, + ], }, - expected_indexes=[ - ("prefix", "__id"), - ("prefix__t", "__id"), - ("prefix__t", "id"), - ("prefix__t", "sub_object__id"), - ("prefix__t", "sub_object__sub_sub_object__id"), - ], - ) - - def case_nested_object_underexpansion(self) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=1, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - "subObject": { + ), + expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], + expected_values={ + "prefix__t": ( + ["id", "value", "sub_object"], + [ + ( + "b096504a-3d54-4664-9bf5-1b872466fd66", + "value", + json.dumps( + { "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", "value": "sub-value", }, - }, - ], - }, - ), - expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], - expected_values={ - "prefix__t": ( - ["id", "value", "sub_object"], - [ - ( - "b096504a-3d54-4664-9bf5-1b872466fd66", - "value", - json.dumps( - { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "value": "sub-value", - }, - indent=4, - ), + indent=4, ), - ], - ), - "prefix__tcatalog": ( - ["table_name"], - [("prefix__t",)], - ), - }, - ) - - def case_id_generation(self) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=4, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "subObjects": [ - { - "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", - "subSubObjects": [ - { - "id": ( - "2b94c631-fca9-4892-a730-03ee529ffe2a" - ), - }, - { - "id": ( - "8516a913-8bf7-55a4-ab71-417aba9171c9" - ), - }, - ], - }, - { - "id": "b5d8cdc4-9441-487c-90cf-0c7ec97728eb", - "subSubObjects": [ - { - "id": ( - "13a24cc8-a15c-4158-abbd-4abf25c8815a" - ), - }, - { - "id": ( - "37344879-09ce-4cd8-976f-bf1a57c0cfa6" - ), - }, - ], - }, - ], - }, - ], - }, + ), + ], ), - expected_tables=[ - "prefix", - "prefix__t", - "prefix__t__sub_objects", - "prefix__t__sub_objects__sub_sub_objects", - "prefix__tcatalog", - ], - expected_values={ - "prefix__t__sub_objects": ( - ["__id", "id", "sub_objects__o", "sub_objects__id"], - [ - ( - "1", - "b096504a-3d54-4664-9bf5-1b872466fd66", - "1", - "2b94c631-fca9-4892-a730-03ee529ffe2a", - ), - ( - "2", - "b096504a-3d54-4664-9bf5-1b872466fd66", - "2", - "b5d8cdc4-9441-487c-90cf-0c7ec97728eb", - ), - ], - ), - "prefix__t__sub_objects__sub_sub_objects": ( - ["__id", "sub_objects__o", "sub_objects__sub_sub_objects__o"], - [ - ("1", "1", "1"), - ("2", "1", "2"), - ("3", "2", "1"), - ("4", "2", "2"), - ], - ), + "prefix__tcatalog": ( + ["table_name"], + [("prefix__t",)], + ), + }, + ) + + +def case_id_generation() -> QueryTC: + return QueryTC( + Call( + "prefix", + json_depth=4, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "subObjects": [ + { + "id": "2b94c631-fca9-4892-a730-03ee529ffe2a", + "subSubObjects": [ + { + "id": ("2b94c631-fca9-4892-a730-03ee529ffe2a"), + }, + { + "id": ("8516a913-8bf7-55a4-ab71-417aba9171c9"), + }, + ], + }, + { + "id": "b5d8cdc4-9441-487c-90cf-0c7ec97728eb", + "subSubObjects": [ + { + "id": ("13a24cc8-a15c-4158-abbd-4abf25c8815a"), + }, + { + "id": ("37344879-09ce-4cd8-976f-bf1a57c0cfa6"), + }, + ], + }, + ], + }, + ], }, - ) - - def case_indexing_id_like(self) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=4, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "otherId": "b096504a-3d54-4664-9bf5-1b872466fd66", - "anIdButWithADifferentEnding": ( - "b096504a-3d54-4664-9bf5-1b872466fd66" - ), - }, - ], - }, + ), + expected_tables=[ + "prefix", + "prefix__t", + "prefix__t__sub_objects", + "prefix__t__sub_objects__sub_sub_objects", + "prefix__tcatalog", + ], + expected_values={ + "prefix__t__sub_objects": ( + ["__id", "id", "sub_objects__o", "sub_objects__id"], + [ + ( + "1", + "b096504a-3d54-4664-9bf5-1b872466fd66", + "1", + "2b94c631-fca9-4892-a730-03ee529ffe2a", + ), + ( + "2", + "b096504a-3d54-4664-9bf5-1b872466fd66", + "2", + "b5d8cdc4-9441-487c-90cf-0c7ec97728eb", + ), + ], ), - expected_tables=[ - "prefix", - "prefix__t", - "prefix__tcatalog", - ], - expected_values={}, - expected_indexes=[ - ("prefix", "__id"), - ("prefix__t", "__id"), - ("prefix__t", "id"), - ("prefix__t", "other_id"), - ("prefix__t", "an_id_but_with_a_different_ending"), - ], - ) - - @parametrize(json_depth=range(1, 2)) - def case_drop_raw(self, json_depth: int) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=json_depth, - keep_raw=False, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - }, - ], - }, + "prefix__t__sub_objects__sub_sub_objects": ( + ["__id", "sub_objects__o", "sub_objects__sub_sub_objects__o"], + [ + ("1", "1", "1"), + ("2", "1", "2"), + ("3", "2", "1"), + ("4", "2", "2"), + ], ), - expected_tables=["prefix__t", "prefix__tcatalog"], - expected_values={ - "prefix__t": ( - ["id", "value"], - [("b096504a-3d54-4664-9bf5-1b872466fd66", "value")], - ), - "prefix__tcatalog": (["table_name"], [("prefix__t",)]), + }, + ) + + +def case_indexing_id_like() -> QueryTC: + return QueryTC( + Call( + "prefix", + json_depth=4, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "otherId": "b096504a-3d54-4664-9bf5-1b872466fd66", + "anIdButWithADifferentEnding": ( + "b096504a-3d54-4664-9bf5-1b872466fd66" + ), + }, + ], }, - expected_indexes=[ - ("prefix__t", "__id"), - ("prefix__t", "id"), - ], - ) - - # this case should be testing the FolioClient class - # but it isn't setup to mock the data properly right now - def case_null_records(self) -> QueryCase: - return QueryCase( - Call( - "prefix", - json_depth=1, - returns={ - "purchaseOrders": [ - { - "id": "b096504a-3d54-4664-9bf5-1b872466fd66", - "value": "value", - }, - None, - ], - }, + ), + expected_tables=[ + "prefix", + "prefix__t", + "prefix__tcatalog", + ], + expected_values={}, + expected_indexes=[ + ("prefix", "__id"), + ("prefix__t", "__id"), + ("prefix__t", "id"), + ("prefix__t", "other_id"), + ("prefix__t", "an_id_but_with_a_different_ending"), + ], + ) + + +@parametrize(json_depth=range(1, 2)) +def case_drop_raw(json_depth: int) -> QueryTC: + return QueryTC( + Call( + "prefix", + json_depth=json_depth, + keep_raw=False, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + }, + ], + }, + ), + expected_tables=["prefix__t", "prefix__tcatalog"], + expected_values={ + "prefix__t": ( + ["id", "value"], + [("b096504a-3d54-4664-9bf5-1b872466fd66", "value")], ), - expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], - expected_values={}, - expected_indexes=[ - ("prefix", "__id"), - ("prefix__t", "__id"), - ("prefix__t", "id"), - ], - ) + "prefix__tcatalog": (["table_name"], [("prefix__t",)]), + }, + expected_indexes=[ + ("prefix__t", "__id"), + ("prefix__t", "id"), + ], + ) + + +# this case should be testing the FolioClient class +# but it isn't setup to mock the data properly right now +def case_null_records() -> QueryTC: + return QueryTC( + Call( + "prefix", + json_depth=1, + returns={ + "purchaseOrders": [ + { + "id": "b096504a-3d54-4664-9bf5-1b872466fd66", + "value": "value", + }, + None, + ], + }, + ), + expected_tables=["prefix", "prefix__t", "prefix__tcatalog"], + expected_values={}, + expected_indexes=[ + ("prefix", "__id"), + ("prefix__t", "__id"), + ("prefix__t", "id"), + ], + ) def _arrange( client_get_mock: MagicMock, httpx_post_mock: MagicMock, - tc: QueryCase, + tc: QueryTC, ) -> "ldlite.LDLite": from ldlite import LDLite @@ -617,7 +615,7 @@ def _arrange( return uut -def _act(uut: "ldlite.LDLite", tc: QueryCase) -> None: +def _act(uut: "ldlite.LDLite", tc: QueryTC) -> None: for call in tc.calls_list: uut.query( table=call.prefix, @@ -630,7 +628,7 @@ def _act(uut: "ldlite.LDLite", tc: QueryCase) -> None: def _assert( conn: "dbapi.DBAPIConnection", res_schema: str, # TODO: have schema be part of tc - tc: QueryCase, + tc: QueryTC, ) -> None: with closing(conn.cursor()) as cur: cur.execute( @@ -662,11 +660,11 @@ def _assert( @mock.patch("httpx_folio.auth.httpx.post") @mock.patch("httpx_folio.factories.httpx.Client.get") -@parametrize_with_cases("tc", cases=QueryTestCases) +@parametrize_with_cases("tc", cases=".") def test_duckdb( client_get_mock: MagicMock, httpx_post_mock: MagicMock, - tc: QueryCase, + tc: QueryTC, ) -> None: uut = _arrange(client_get_mock, httpx_post_mock, tc) dsn = f":memory:{tc.db}" @@ -680,12 +678,12 @@ def test_duckdb( @mock.patch("httpx_folio.auth.httpx.post") @mock.patch("httpx_folio.factories.httpx.Client.get") -@parametrize_with_cases("tc", cases=QueryTestCases) +@parametrize_with_cases("tc", cases=".") def test_postgres( client_get_mock: MagicMock, httpx_post_mock: MagicMock, pg_dsn: None | Callable[[str], str], - tc: QueryCase, + tc: QueryTC, ) -> None: if pg_dsn is None: pytest.skip("Specify the pg host using --pg-host to run")