refactor(storage): route project DSNs and remove legacy backends
This commit is contained in:
@@ -6,7 +6,10 @@ from uuid import uuid4
|
||||
import pytest
|
||||
from cryptography.fernet import InvalidToken
|
||||
|
||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||
from app.infra.db.metadb.repositories.metadata_repository import (
|
||||
MetadataRepository,
|
||||
_normalize_postgres_dsn,
|
||||
)
|
||||
|
||||
|
||||
class _DummyResult:
|
||||
@@ -124,6 +127,12 @@ def test_encrypted_dsn_decrypts_without_migration(monkeypatch):
|
||||
session.commit.assert_not_awaited()
|
||||
|
||||
|
||||
def test_psycopg_sqlalchemy_dsn_is_normalized_for_direct_psycopg_clients():
|
||||
assert _normalize_postgres_dsn(
|
||||
"postgresql+psycopg://user:secret@db.example/project"
|
||||
) == "postgresql://user:secret@db.example/project"
|
||||
|
||||
|
||||
def test_upsert_project_database_config_encrypts_plaintext_dsn(monkeypatch):
|
||||
project_id = uuid4()
|
||||
session = SimpleNamespace(
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import pytest
|
||||
from psycopg.conninfo import conninfo_to_dict
|
||||
|
||||
from app.infra.db.project_routing import (
|
||||
ActiveProjectRouting,
|
||||
activate_project_routing,
|
||||
get_active_project_routing,
|
||||
get_project_pgconn_string,
|
||||
get_project_timescale_pgconn_string,
|
||||
)
|
||||
|
||||
|
||||
def _routing(project_code: str = "project_a") -> ActiveProjectRouting:
|
||||
return ActiveProjectRouting(
|
||||
project_code=project_code,
|
||||
business_dsn=(
|
||||
"postgresql://biz_user:biz_password@biz.example:5432/biz_database"
|
||||
"?sslmode=require"
|
||||
),
|
||||
timescale_dsn=(
|
||||
"postgresql://ts_user:ts_password@timescale.example:5433/ts_database"
|
||||
"?sslmode=require"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_project_database_uses_exact_routing_dsn_for_project_code() -> None:
|
||||
routing = _routing()
|
||||
|
||||
with activate_project_routing(routing):
|
||||
assert get_project_pgconn_string("project_a") == routing.business_dsn
|
||||
assert (
|
||||
get_project_timescale_pgconn_string("project_a")
|
||||
== routing.timescale_dsn
|
||||
)
|
||||
|
||||
|
||||
def test_business_template_keeps_server_and_timescale_ignores_legacy_db_name() -> None:
|
||||
with activate_project_routing(_routing()):
|
||||
business = conninfo_to_dict(get_project_pgconn_string("project_a_template"))
|
||||
timescale = conninfo_to_dict(
|
||||
get_project_timescale_pgconn_string("temporary_scheme")
|
||||
)
|
||||
|
||||
assert business == {
|
||||
"user": "biz_user",
|
||||
"password": "biz_password",
|
||||
"dbname": "project_a_template",
|
||||
"host": "biz.example",
|
||||
"port": "5432",
|
||||
"sslmode": "require",
|
||||
}
|
||||
assert timescale == {
|
||||
"user": "ts_user",
|
||||
"password": "ts_password",
|
||||
"dbname": "ts_database",
|
||||
"host": "timescale.example",
|
||||
"port": "5433",
|
||||
"sslmode": "require",
|
||||
}
|
||||
|
||||
|
||||
def test_project_routing_is_nested_and_request_local() -> None:
|
||||
first = _routing("project_a")
|
||||
second = _routing("project_b")
|
||||
|
||||
assert get_active_project_routing() is None
|
||||
with activate_project_routing(first):
|
||||
assert get_active_project_routing() is first
|
||||
with activate_project_routing(second):
|
||||
assert get_active_project_routing() is second
|
||||
assert get_active_project_routing() is first
|
||||
assert get_active_project_routing() is None
|
||||
|
||||
|
||||
def test_timescale_access_requires_iot_routing_in_project_request() -> None:
|
||||
business_only = _routing()
|
||||
business_only = ActiveProjectRouting(
|
||||
project_code=business_only.project_code,
|
||||
business_dsn=business_only.business_dsn,
|
||||
)
|
||||
|
||||
with activate_project_routing(business_only):
|
||||
with pytest.raises(RuntimeError, match="TimescaleDB routing is not configured"):
|
||||
get_project_timescale_pgconn_string()
|
||||
@@ -35,7 +35,9 @@ class _FakeConnection:
|
||||
def test_query_scheme_list_pushes_scheme_type_into_sql(monkeypatch):
|
||||
cursor = _FakeCursor()
|
||||
monkeypatch.setattr(
|
||||
scheme_management, "get_pgconn_string", lambda db_name=None: "postgres://test"
|
||||
scheme_management,
|
||||
"get_project_pgconn_string",
|
||||
lambda db_name=None: "postgres://test",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
scheme_management.psycopg, "connect", lambda _conn_string: _FakeConnection(cursor)
|
||||
|
||||
@@ -45,9 +45,11 @@ class _FakeConnection:
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_native_connections():
|
||||
connection.g_conn_dict.clear()
|
||||
connection.g_conninfo_dict.clear()
|
||||
connection._project_locks.clear()
|
||||
yield
|
||||
connection.g_conn_dict.clear()
|
||||
connection.g_conninfo_dict.clear()
|
||||
connection._project_locks.clear()
|
||||
|
||||
|
||||
@@ -61,6 +63,10 @@ def test_is_project_open_drops_closed_cached_connection():
|
||||
def test_open_connection_reuses_healthy_cached_connection(monkeypatch):
|
||||
cached = _FakeConnection()
|
||||
connection.g_conn_dict["fengyang"] = cached
|
||||
connection.g_conninfo_dict["fengyang"] = "dbname=fengyang"
|
||||
monkeypatch.setattr(
|
||||
connection, "get_project_pgconn_string", lambda db_name: f"dbname={db_name}"
|
||||
)
|
||||
|
||||
def fail_connect(*, conninfo, autocommit):
|
||||
raise AssertionError("cached connection should be reused")
|
||||
@@ -84,7 +90,7 @@ def test_read_all_reopens_closed_cached_connection(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(connection.pg, "connect", fake_connect)
|
||||
monkeypatch.setattr(
|
||||
connection, "get_pgconn_string", lambda db_name: f"dbname={db_name}"
|
||||
connection, "get_project_pgconn_string", lambda db_name: f"dbname={db_name}"
|
||||
)
|
||||
|
||||
rows = database.read_all("fengyang", "select * from times")
|
||||
@@ -99,6 +105,7 @@ def test_read_all_reopens_cached_connection_when_health_check_fails(monkeypatch)
|
||||
stale = _FakeConnection(fail_ping=True)
|
||||
fresh = _FakeConnection(rows=[{"scheme_name": "base"}])
|
||||
connection.g_conn_dict["fengyang"] = stale
|
||||
connection.g_conninfo_dict["fengyang"] = "dbname=fengyang"
|
||||
|
||||
opened = []
|
||||
|
||||
@@ -108,7 +115,7 @@ def test_read_all_reopens_cached_connection_when_health_check_fails(monkeypatch)
|
||||
|
||||
monkeypatch.setattr(connection.pg, "connect", fake_connect)
|
||||
monkeypatch.setattr(
|
||||
connection, "get_pgconn_string", lambda db_name: f"dbname={db_name}"
|
||||
connection, "get_project_pgconn_string", lambda db_name: f"dbname={db_name}"
|
||||
)
|
||||
|
||||
rows = database.read_all("fengyang", "select * from scheme_list")
|
||||
@@ -119,3 +126,20 @@ def test_read_all_reopens_cached_connection_when_health_check_fails(monkeypatch)
|
||||
assert opened == [("dbname=fengyang", True)]
|
||||
assert connection.g_conn_dict["fengyang"] is fresh
|
||||
assert fresh.executed == ["select * from scheme_list"]
|
||||
|
||||
|
||||
def test_open_connection_replaces_cache_when_project_dsn_changes(monkeypatch):
|
||||
cached = _FakeConnection()
|
||||
fresh = _FakeConnection()
|
||||
connection.g_conn_dict["fengyang"] = cached
|
||||
connection.g_conninfo_dict["fengyang"] = "host=old dbname=fengyang"
|
||||
monkeypatch.setattr(
|
||||
connection,
|
||||
"get_project_pgconn_string",
|
||||
lambda db_name: f"host=new dbname={db_name}",
|
||||
)
|
||||
monkeypatch.setattr(connection.pg, "connect", lambda **_kwargs: fresh)
|
||||
|
||||
assert connection.open_connection("fengyang") is fresh
|
||||
assert cached.close_calls == 1
|
||||
assert connection.g_conninfo_dict["fengyang"] == "host=new dbname=fengyang"
|
||||
|
||||
Reference in New Issue
Block a user