refactor(db)!: finalize pooled WNDB v2 migration

This commit is contained in:
2026-08-27 17:26:22 +08:00
parent fa188af0b1
commit b74799a39d
105 changed files with 4988 additions and 5565 deletions
+8 -6
View File
@@ -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)
+47
View File
@@ -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
+43 -11
View File
@@ -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:
+17 -33
View File
@@ -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
+68
View File
@@ -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"]