refactor(db)!: finalize pooled WNDB v2 migration
This commit is contained in:
@@ -2,7 +2,7 @@ from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
import psycopg
|
||||
|
||||
from tests.conftest import build_test_app, install_stub, load_module_from_path
|
||||
|
||||
@@ -15,7 +15,7 @@ def _load_meta_module(monkeypatch):
|
||||
{
|
||||
"ProjectContext": object,
|
||||
"get_project_context": lambda: None,
|
||||
"get_project_pg_session": lambda: None,
|
||||
"get_project_pg_connection": lambda: None,
|
||||
"get_project_timescale_connection": lambda: None,
|
||||
"get_metadata_repository": lambda: None,
|
||||
},
|
||||
@@ -68,16 +68,18 @@ def test_meta_project_returns_map_extent(monkeypatch):
|
||||
def test_meta_db_health_returns_503_for_postgres_errors(monkeypatch):
|
||||
module = _load_meta_module(monkeypatch)
|
||||
|
||||
class BrokenSession:
|
||||
async def execute(self, _query):
|
||||
raise SQLAlchemyError("pg unavailable")
|
||||
class BrokenConnection:
|
||||
def cursor(self):
|
||||
raise psycopg.OperationalError("pg unavailable")
|
||||
|
||||
class DummyTimescaleConnection:
|
||||
def cursor(self):
|
||||
raise AssertionError("timescale should not be queried after postgres failure")
|
||||
|
||||
app = build_test_app(module.router, "/api/v1")
|
||||
app.dependency_overrides[module.get_project_pg_session] = lambda: BrokenSession()
|
||||
app.dependency_overrides[module.get_project_pg_connection] = (
|
||||
lambda: BrokenConnection()
|
||||
)
|
||||
app.dependency_overrides[module.get_project_timescale_connection] = lambda: DummyTimescaleConnection()
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -12,6 +14,7 @@ from app.auth.metadata_dependencies import (
|
||||
)
|
||||
from app.infra.db.metadb.repositories.metadata_repository import ProjectDbRouting
|
||||
from app.infra.db.project_routing import get_project_pgconn_string
|
||||
from app.native.wndb.core.database import MaterializedViewRefreshAfterCommitError
|
||||
from tests.conftest import build_test_app
|
||||
|
||||
|
||||
@@ -144,3 +147,47 @@ def test_model_update_uses_project_business_routing(monkeypatch):
|
||||
"dsn": "postgresql://user:password@biz.example/routed_business",
|
||||
}
|
||||
repo.get_project_db_routing.assert_awaited_once_with(project_id, "biz_data")
|
||||
|
||||
|
||||
def test_gb18030_upload_is_normalized_to_utf8() -> None:
|
||||
content = "[TITLE]\n天津供水\n[JUNCTIONS]\n".encode("gb18030")
|
||||
|
||||
class FakeUpload:
|
||||
filename = "model.inp"
|
||||
|
||||
async def read(self, _limit: int) -> bytes:
|
||||
return content
|
||||
|
||||
normalized, filename = asyncio.run(model_import._read_upload(FakeUpload()))
|
||||
|
||||
assert filename == "model.inp"
|
||||
assert normalized.decode("utf-8") == "[TITLE]\n天津供水\n[JUNCTIONS]\n"
|
||||
|
||||
|
||||
def test_model_update_runs_blocking_import_in_threadpool(monkeypatch) -> None:
|
||||
calls: list[tuple[object, tuple[object, ...]]] = []
|
||||
|
||||
async def fake_threadpool(function, *args):
|
||||
calls.append((function, args))
|
||||
|
||||
monkeypatch.setattr(model_import, "run_in_threadpool", fake_threadpool)
|
||||
|
||||
asyncio.run(model_import._update_from_inp(b"[TITLE]\n", "demo"))
|
||||
|
||||
assert calls == [(model_import._update_from_inp_sync, (b"[TITLE]\n", "demo"))]
|
||||
|
||||
|
||||
def test_committed_refresh_failure_is_not_wrapped_as_retryable_500(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
error = MaterializedViewRefreshAfterCommitError("demo")
|
||||
|
||||
async def fail_update(_content: bytes, _project_code: str) -> None:
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr(model_import, "_update_from_inp", fail_update)
|
||||
|
||||
with pytest.raises(MaterializedViewRefreshAfterCommitError) as exc_info:
|
||||
asyncio.run(model_import._apply_model_update(b"[TITLE]\n", "demo"))
|
||||
|
||||
assert exc_info.value is error
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
@@ -12,6 +11,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.endpoints import simulation as simulation_endpoint
|
||||
from app.api.pagination import PaginatedList
|
||||
from app.api.problem_details import install_problem_details_handlers
|
||||
from app.api.v1.rest_router import api_router, build_rest_router
|
||||
from app.api.v1.router import api_router as source_api_router
|
||||
from app.auth.project_dependencies import (
|
||||
@@ -207,19 +207,51 @@ def test_valve_isolation_route_uses_the_isolation_handler() -> None:
|
||||
assert route.name == "valve_isolation_endpoint"
|
||||
|
||||
|
||||
def test_open_project_route_requires_business_and_timescale_routing() -> None:
|
||||
route = next(
|
||||
route
|
||||
def test_legacy_project_pool_lifecycle_routes_are_not_published() -> None:
|
||||
operations = {
|
||||
(method, route.path)
|
||||
for route in api_router.routes
|
||||
if isinstance(route, APIRoute)
|
||||
and route.path == "/projects/current"
|
||||
and route.methods == {"POST"}
|
||||
)
|
||||
routing_parameter = inspect.signature(route.endpoint).parameters[
|
||||
"_rest_project_routing"
|
||||
]
|
||||
for method in route.methods or set()
|
||||
}
|
||||
|
||||
assert routing_parameter.default.dependency is get_project_simulation_routing
|
||||
assert ("POST", "/projects/current") not in operations
|
||||
assert ("DELETE", "/projects/current") not in operations
|
||||
assert ("GET", "/projects/current/status") not in operations
|
||||
|
||||
|
||||
def test_legacy_server_filesystem_inp_routes_are_not_published() -> None:
|
||||
operations = {
|
||||
(method, route.path)
|
||||
for route in source_api_router.routes
|
||||
if isinstance(route, APIRoute)
|
||||
for method in route.methods or set()
|
||||
}
|
||||
|
||||
assert operations.isdisjoint(
|
||||
{
|
||||
("POST", "/projects/current/imports"),
|
||||
("POST", "/projects/current/exports/inp"),
|
||||
("GET", "/projects/current/files/inp"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_committed_write_refresh_failure_has_explicit_http_contract() -> None:
|
||||
from app.native.wndb.core.database import MaterializedViewRefreshAfterCommitError
|
||||
|
||||
app = FastAPI()
|
||||
install_problem_details_handlers(app)
|
||||
|
||||
@app.get("/probe")
|
||||
def probe():
|
||||
raise MaterializedViewRefreshAfterCommitError("project_a")
|
||||
|
||||
response = TestClient(app, raise_server_exceptions=False).get("/probe")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["X-TJWater-Changes-Committed"] == "true"
|
||||
assert response.json()["code"] == "materialized_view_refresh_failed_after_commit"
|
||||
|
||||
|
||||
def test_valve_isolation_runtime_accepts_frontend_query(monkeypatch) -> None:
|
||||
|
||||
@@ -18,6 +18,9 @@ class DummyChangeSet:
|
||||
|
||||
|
||||
def _load_project_module(monkeypatch):
|
||||
class DummyProjectContext:
|
||||
project_code = "demo"
|
||||
|
||||
install_stub(monkeypatch, "app.services", package=True)
|
||||
install_stub(
|
||||
monkeypatch,
|
||||
@@ -33,14 +36,9 @@ def _load_project_module(monkeypatch):
|
||||
"have_project": lambda network: network == "demo",
|
||||
"create_project": lambda network: None,
|
||||
"delete_project": lambda network: None,
|
||||
"is_project_open": lambda network: False,
|
||||
"open_project": lambda network: None,
|
||||
"close_project": lambda network: None,
|
||||
"copy_project": lambda source, target: None,
|
||||
"import_inp": lambda network, cs: {"ok": True},
|
||||
"export_inp": lambda network, version: DummyChangeSet({"kind": "export"}),
|
||||
"read_inp": lambda network, inp: True,
|
||||
"dump_inp": lambda network, inp: True,
|
||||
"get_all_vertices": lambda network: [],
|
||||
"get_all_scada_info": lambda network: [],
|
||||
"get_all_district_metering_areas": lambda network: [],
|
||||
@@ -52,7 +50,12 @@ def _load_project_module(monkeypatch):
|
||||
install_stub(
|
||||
monkeypatch,
|
||||
"app.auth.project_dependencies",
|
||||
{"get_metadata_repository": lambda: None},
|
||||
{
|
||||
"ProjectContext": DummyProjectContext,
|
||||
"get_metadata_repository": lambda: None,
|
||||
"get_project_context": lambda: DummyProjectContext(),
|
||||
"use_project_business_routing": lambda: None,
|
||||
},
|
||||
)
|
||||
return load_module_from_path(
|
||||
"tests_project_endpoints_module",
|
||||
@@ -98,40 +101,21 @@ def test_project_info_returns_project_workspace(monkeypatch):
|
||||
assert "geoserver" not in payload
|
||||
|
||||
|
||||
def test_open_project_uses_unified_wndb_connection_path(monkeypatch):
|
||||
def test_legacy_open_project_endpoint_is_removed(monkeypatch):
|
||||
module = _load_project_module(monkeypatch)
|
||||
called = []
|
||||
|
||||
monkeypatch.setattr(module, "open_project", lambda network: called.append(network))
|
||||
|
||||
client = TestClient(build_test_app(module.router, "/api/v1"))
|
||||
|
||||
response = client.post("/api/v1/projects/current", params={"network": "demo"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == "demo"
|
||||
assert called == ["demo"]
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
def test_project_lock_lifecycle(monkeypatch):
|
||||
def test_legacy_physical_project_routes_are_removed(monkeypatch):
|
||||
module = _load_project_module(monkeypatch)
|
||||
module.lockedPrjs.clear()
|
||||
client = TestClient(build_test_app(module.router, "/api/v1"))
|
||||
|
||||
first_lock = client.post("/api/v1/projects/current/lock", params={"network": "demo"})
|
||||
second_lock = client.post("/api/v1/projects/current/lock", params={"network": "demo"})
|
||||
locked_by_me = client.get(
|
||||
"/api/v1/projects/current/lock/ownership",
|
||||
params={"network": "demo"},
|
||||
)
|
||||
unlock = client.delete(
|
||||
"/api/v1/projects/current/lock",
|
||||
params={"network": "demo"},
|
||||
)
|
||||
locked = client.get("/api/v1/projects/current/lock", params={"network": "demo"})
|
||||
|
||||
assert first_lock.json() == 0
|
||||
assert second_lock.json() == 1
|
||||
assert locked_by_me.json() is True
|
||||
assert unlock.json() is True
|
||||
assert locked.json() is False
|
||||
assert client.get("/api/v1/project-codes").status_code == 404
|
||||
assert client.get("/api/v1/projects/existence").status_code == 404
|
||||
assert client.post("/api/v1/project-copies").status_code == 404
|
||||
assert client.get("/api/v1/projects/current/lock").status_code == 404
|
||||
assert client.post("/api/v1/project-conversions").status_code == 404
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
from threading import get_ident
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.endpoints.components import curves
|
||||
from app.api.v1.rest_router import api_router
|
||||
from app.auth.project_dependencies import (
|
||||
ProjectContext,
|
||||
get_project_business_routing,
|
||||
get_project_context,
|
||||
)
|
||||
from app.infra.db.project_routing import ActiveProjectRouting, get_active_project_routing
|
||||
|
||||
|
||||
def test_sync_wndb_endpoint_parses_body_and_runs_in_worker_thread(monkeypatch) -> None:
|
||||
call: dict[str, object] = {}
|
||||
project_id = uuid4()
|
||||
user_id = uuid4()
|
||||
context = ProjectContext(
|
||||
project_id=project_id,
|
||||
project_code="project_a",
|
||||
user_id=user_id,
|
||||
project_role="member",
|
||||
)
|
||||
routing = ActiveProjectRouting(
|
||||
project_code="project_a",
|
||||
business_dsn="postgresql://user:password@db.example/project_a",
|
||||
)
|
||||
|
||||
async def override_context() -> ProjectContext:
|
||||
call["event_loop_thread"] = get_ident()
|
||||
return context
|
||||
|
||||
async def override_routing() -> ActiveProjectRouting:
|
||||
return routing
|
||||
|
||||
def fake_add_curve(network, changes):
|
||||
call["worker_thread"] = get_ident()
|
||||
call["network"] = network
|
||||
call["operations"] = changes.operations
|
||||
call["routing"] = get_active_project_routing()
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(curves, "add_curve", fake_add_curve)
|
||||
app = FastAPI()
|
||||
app.include_router(api_router)
|
||||
app.dependency_overrides[get_project_context] = override_context
|
||||
app.dependency_overrides[get_project_business_routing] = override_routing
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/curves",
|
||||
params={"curve": "C-1"},
|
||||
json={"points": [[0, 10], [1, 20]]},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json() == {"ok": True}
|
||||
assert call == {
|
||||
"event_loop_thread": call["event_loop_thread"],
|
||||
"worker_thread": call["worker_thread"],
|
||||
"network": "project_a",
|
||||
"operations": [{"id": "C-1", "points": [[0, 10], [1, 20]]}],
|
||||
"routing": routing,
|
||||
}
|
||||
assert call["worker_thread"] != call["event_loop_thread"]
|
||||
Reference in New Issue
Block a user