refactor(db)!: finalize pooled WNDB v2 migration
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
|
||||
from app.infra.db.project_routing import ActiveProjectRouting, activate_project_routing
|
||||
from app.native.wndb.core import projects
|
||||
|
||||
|
||||
class _FakeCursor:
|
||||
def __init__(self, *, rows=None, current_database: str = "postgres") -> None:
|
||||
self.rows = rows or []
|
||||
self.current_database = current_database
|
||||
self.calls: list[tuple[object, object]] = []
|
||||
self._last_statement = None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return None
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.rows)
|
||||
|
||||
def execute(self, statement, params=None):
|
||||
self.calls.append((statement, params))
|
||||
self._last_statement = statement
|
||||
return self
|
||||
|
||||
def fetchone(self):
|
||||
if isinstance(self._last_statement, str) and self._last_statement.startswith(
|
||||
"select datallowconn"
|
||||
):
|
||||
return {"datallowconn": False}
|
||||
return {"current_database": self.current_database}
|
||||
|
||||
|
||||
class _FakeConnection:
|
||||
def __init__(self, cursor: _FakeCursor) -> None:
|
||||
self._cursor = cursor
|
||||
|
||||
def cursor(self, **_kwargs):
|
||||
return self._cursor
|
||||
|
||||
|
||||
def _admin_connection(cursor: _FakeCursor):
|
||||
@contextmanager
|
||||
def connection():
|
||||
yield _FakeConnection(cursor)
|
||||
|
||||
return connection
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"postgres",
|
||||
"project",
|
||||
"system_hub",
|
||||
"SYSTEM_HUB",
|
||||
"tjwater_v2_template",
|
||||
"another_template",
|
||||
],
|
||||
)
|
||||
def test_delete_project_rejects_protected_database_before_side_effects(
|
||||
monkeypatch, name
|
||||
) -> None:
|
||||
closed: list[str] = []
|
||||
monkeypatch.setattr(projects, "close_project_pool", closed.append)
|
||||
monkeypatch.setattr(
|
||||
projects,
|
||||
"admin_connection",
|
||||
lambda: pytest.fail("protected database opened an administration connection"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="protected"):
|
||||
projects.delete_project(name)
|
||||
|
||||
assert closed == []
|
||||
|
||||
|
||||
def test_copy_project_rejects_metadata_source(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
projects,
|
||||
"admin_connection",
|
||||
lambda: pytest.fail("protected database opened an administration connection"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="protected"):
|
||||
projects.copy_project("system_hub", "copy")
|
||||
|
||||
|
||||
def test_copy_project_rejects_unconfigured_template_source(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
projects,
|
||||
"admin_connection",
|
||||
lambda: pytest.fail("unconfigured template opened an administration connection"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="protected"):
|
||||
projects.copy_project("tjwater_next_template", "copy")
|
||||
|
||||
|
||||
def test_create_project_allows_the_protected_template_as_source(monkeypatch) -> None:
|
||||
cursor = _FakeCursor()
|
||||
closed: list[str] = []
|
||||
monkeypatch.setattr(projects, "admin_connection", _admin_connection(cursor))
|
||||
monkeypatch.setattr(projects, "close_project_pool", closed.append)
|
||||
|
||||
projects.create_project("project_a")
|
||||
|
||||
assert closed == ["tjwater_v2_template", "project_a"]
|
||||
assert any(call[1] == ("tjwater_v2_template",) for call in cursor.calls)
|
||||
assert any(
|
||||
isinstance(call[0], str)
|
||||
and call[0].startswith("select pg_terminate_backend")
|
||||
and call[1] == ("tjwater_v2_template",)
|
||||
for call in cursor.calls
|
||||
)
|
||||
assert any("create database" in str(call[0]).lower() for call in cursor.calls)
|
||||
|
||||
|
||||
def test_list_project_excludes_metadata_database(monkeypatch) -> None:
|
||||
cursor = _FakeCursor(rows=[{"datname": "project_a"}])
|
||||
monkeypatch.setattr(projects, "admin_connection", _admin_connection(cursor))
|
||||
|
||||
assert projects.list_project() == ["project_a"]
|
||||
excluded = cursor.calls[0][1][0]
|
||||
assert "system_hub" in excluded
|
||||
assert "project" in excluded
|
||||
assert "tjwater_v2_template" in excluded
|
||||
|
||||
|
||||
def test_delete_project_uses_routed_physical_database_name(monkeypatch) -> None:
|
||||
cursor = _FakeCursor()
|
||||
closed: list[str] = []
|
||||
monkeypatch.setattr(projects, "admin_connection", _admin_connection(cursor))
|
||||
monkeypatch.setattr(projects, "close_project_pool", closed.append)
|
||||
routing = ActiveProjectRouting(
|
||||
project_code="logical-project",
|
||||
business_dsn="postgresql://user:password@db.example/tjwater_v2",
|
||||
)
|
||||
|
||||
with activate_project_routing(routing):
|
||||
projects.delete_project("logical-project")
|
||||
|
||||
assert closed == ["logical-project"]
|
||||
assert any(call[1] == ("tjwater_v2",) for call in cursor.calls)
|
||||
|
||||
|
||||
def test_temporary_project_names_are_unique_and_postgres_safe() -> None:
|
||||
first = projects.temporary_project_name(
|
||||
"TJWater V2 / Production With A Very Long Project Name",
|
||||
"Burst Analysis",
|
||||
)
|
||||
second = projects.temporary_project_name(
|
||||
"TJWater V2 / Production With A Very Long Project Name",
|
||||
"Burst Analysis",
|
||||
)
|
||||
|
||||
assert first != second
|
||||
assert len(first.encode("utf-8")) <= 63
|
||||
assert first.startswith("tjw_tmp_burst_analysis_tjwat")
|
||||
|
||||
|
||||
def test_temporary_database_capacity_rejects_creation_at_limit(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
cursor = _FakeCursor()
|
||||
monkeypatch.setattr(projects.settings, "WNDB_TEMP_DB_MAX_COUNT", 2)
|
||||
cursor.fetchone = lambda: {"count": 2}
|
||||
|
||||
with pytest.raises(RuntimeError, match="limit reached"):
|
||||
with projects._temporary_database_capacity(cursor, "tjw_tmp_analysis_123"):
|
||||
pytest.fail("capacity guard yielded after reaching the limit")
|
||||
|
||||
assert any("pg_advisory_unlock" in str(statement) for statement, _ in cursor.calls)
|
||||
|
||||
|
||||
def test_temporary_project_database_cleans_up_after_failure(monkeypatch) -> None:
|
||||
calls: list[tuple[str, ...]] = []
|
||||
monkeypatch.setattr(
|
||||
projects,
|
||||
"temporary_project_name",
|
||||
lambda project, purpose: "isolated_run",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
projects,
|
||||
"copy_project",
|
||||
lambda source, target: calls.append(("copy", source, target)),
|
||||
)
|
||||
monkeypatch.setattr(projects, "have_project", lambda name: True)
|
||||
monkeypatch.setattr(
|
||||
projects,
|
||||
"delete_project",
|
||||
lambda name: calls.append(("delete", name)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.native.wndb.core.model_replace.replace_project_model",
|
||||
lambda target, source, *, copy_source_scada: calls.append(
|
||||
("clone", target, source, str(copy_source_scada))
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.native.wndb.core.database.refresh_materialized_views_after_commit",
|
||||
lambda name: calls.append(("refresh", name)),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="analysis failed"):
|
||||
with projects.temporary_project_database("project_a", "age") as name:
|
||||
assert name == "isolated_run"
|
||||
raise RuntimeError("analysis failed")
|
||||
|
||||
assert calls == [
|
||||
("copy", "tjwater_v2_template", "isolated_run"),
|
||||
("clone", "isolated_run", "project_a", "True"),
|
||||
("refresh", "isolated_run"),
|
||||
("delete", "isolated_run"),
|
||||
]
|
||||
|
||||
|
||||
def test_temporary_template_database_does_not_clone_a_project(monkeypatch) -> None:
|
||||
calls: list[tuple[str, ...]] = []
|
||||
monkeypatch.setattr(
|
||||
projects,
|
||||
"temporary_project_name",
|
||||
lambda project, purpose: "empty_conversion",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
projects,
|
||||
"copy_project",
|
||||
lambda source, target: calls.append(("copy", source, target)),
|
||||
)
|
||||
monkeypatch.setattr(projects, "have_project", lambda name: True)
|
||||
monkeypatch.setattr(
|
||||
projects,
|
||||
"delete_project",
|
||||
lambda name: calls.append(("delete", name)),
|
||||
)
|
||||
|
||||
with projects.temporary_template_database("conversion", "v3_to_v2") as name:
|
||||
assert name == "empty_conversion"
|
||||
|
||||
assert calls == [
|
||||
("copy", "tjwater_v2_template", "empty_conversion"),
|
||||
("delete", "empty_conversion"),
|
||||
]
|
||||
|
||||
|
||||
def test_clean_project_deletes_only_explicit_unique_targets(monkeypatch) -> None:
|
||||
cursor = _FakeCursor()
|
||||
closed: list[str] = []
|
||||
monkeypatch.setattr(projects, "admin_connection", _admin_connection(cursor))
|
||||
monkeypatch.setattr(projects, "close_project_pool", closed.append)
|
||||
|
||||
projects.clean_project(["temp_a", "temp_b", "temp_a"])
|
||||
|
||||
assert closed == ["temp_a", "temp_b"]
|
||||
termination_targets = [
|
||||
params[0]
|
||||
for statement, params in cursor.calls
|
||||
if isinstance(statement, str) and statement.startswith("select pg_terminate_backend")
|
||||
]
|
||||
assert termination_targets == ["temp_a", "temp_b"]
|
||||
|
||||
|
||||
def test_clean_project_validates_all_targets_before_deleting(monkeypatch) -> None:
|
||||
closed: list[str] = []
|
||||
monkeypatch.setattr(projects, "close_project_pool", closed.append)
|
||||
monkeypatch.setattr(
|
||||
projects,
|
||||
"admin_connection",
|
||||
lambda: pytest.fail("invalid targets opened an administration connection"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="protected"):
|
||||
projects.clean_project(["temp_a", "system_hub"])
|
||||
|
||||
assert closed == []
|
||||
Reference in New Issue
Block a user