feat(projects): automate project infrastructure provisioning
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import httpx
|
||||
|
||||
from app.infra.geoserver import client as geoserver_client
|
||||
|
||||
|
||||
def test_project_workspace_sets_30_day_client_cache(monkeypatch):
|
||||
cache_updates: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
path = request.url.path
|
||||
if request.method == "GET" and path.endswith("/rest/workspaces/demo.json"):
|
||||
return httpx.Response(404)
|
||||
if request.method == "GET" and "/gwc/rest/layers/" in path:
|
||||
return httpx.Response(
|
||||
200,
|
||||
content=b"<GeoServerLayer><name>demo</name><expireClients>0</expireClients></GeoServerLayer>",
|
||||
)
|
||||
if request.method == "PUT" and "/gwc/rest/layers/" in path:
|
||||
root = ElementTree.fromstring(request.content)
|
||||
cache_updates.append(root.findtext("expireClients"))
|
||||
return httpx.Response(200)
|
||||
return httpx.Response(201)
|
||||
|
||||
monkeypatch.setattr(geoserver_client.settings, "GEOSERVER_USERNAME", "admin")
|
||||
monkeypatch.setattr(geoserver_client.settings, "GEOSERVER_PASSWORD", "secret")
|
||||
monkeypatch.setattr(
|
||||
geoserver_client.settings,
|
||||
"GEOSERVER_CLIENT_CACHE_SECONDS",
|
||||
2_592_000,
|
||||
)
|
||||
http_client = httpx.Client(
|
||||
base_url="http://geoserver.example/geoserver",
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
client = geoserver_client.GeoServerAdminClient(client=http_client)
|
||||
|
||||
layers = client.create_project_workspace(
|
||||
workspace="demo",
|
||||
database_name="demo",
|
||||
map_bbox=(1.0, 2.0, 3.0, 4.0),
|
||||
)
|
||||
|
||||
assert layers == geoserver_client.PROJECT_LAYER_NAMES
|
||||
assert cache_updates == ["2592000"] * len(layers)
|
||||
@@ -5,6 +5,7 @@ from uuid import uuid4
|
||||
import pytest
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from app.infra.db.metadb.repositories import metadata_repository
|
||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||
|
||||
|
||||
@@ -49,3 +50,38 @@ async def test_list_projects_for_user_only_queries_active_projects():
|
||||
assert "user_project_membership.user_id" in sql
|
||||
assert "projects.status = 'active'" in sql
|
||||
assert [item.code for item in projects] == ["active-project"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_provisioned_project_commits_project_routes_and_membership_once(
|
||||
monkeypatch,
|
||||
):
|
||||
added = []
|
||||
session = SimpleNamespace(
|
||||
add=added.append,
|
||||
commit=AsyncMock(),
|
||||
refresh=AsyncMock(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
metadata_repository,
|
||||
"_encrypt_database_secret",
|
||||
lambda value: f"encrypted::{value}",
|
||||
)
|
||||
user_id = uuid4()
|
||||
|
||||
project = await MetadataRepository(session).create_provisioned_project(
|
||||
name="Demo",
|
||||
code="demo",
|
||||
description=None,
|
||||
gs_workspace="demo",
|
||||
map_extent={"bbox": [1, 2, 3, 4], "zoom": 14},
|
||||
creator_user_id=user_id,
|
||||
business_dsn="postgresql://business/demo",
|
||||
timescale_dsn="postgresql://timescale/demo",
|
||||
)
|
||||
|
||||
assert project.status == "active"
|
||||
assert [record.db_role for record in added[1:3]] == ["biz_data", "iot_data"]
|
||||
assert added[3].user_id == user_id
|
||||
session.commit.assert_awaited_once()
|
||||
session.refresh.assert_awaited_once_with(project)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from app.native.wndb.core.model_replace import _copy_out_statement
|
||||
|
||||
|
||||
def test_copy_out_statement_relabels_project_geometry_to_target_srid() -> None:
|
||||
statement = _copy_out_statement(
|
||||
"gis",
|
||||
"labels",
|
||||
["id", "label", "geom"],
|
||||
source_geometry_srids={"geom": 900914},
|
||||
target_geometry_srids={"geom": 990001},
|
||||
)
|
||||
|
||||
assert statement.as_string(None) == (
|
||||
'copy (select "id", "label", '
|
||||
'st_setsrid("geom", 990001) as "geom" '
|
||||
'from "gis"."labels") to stdout'
|
||||
)
|
||||
|
||||
|
||||
def test_copy_out_statement_keeps_direct_copy_when_srid_matches() -> None:
|
||||
statement = _copy_out_statement(
|
||||
"gis",
|
||||
"node_geometries",
|
||||
["node_id", "geom"],
|
||||
source_geometry_srids={"geom": 900914},
|
||||
target_geometry_srids={"geom": 900914},
|
||||
)
|
||||
|
||||
assert statement.as_string(None) == (
|
||||
'copy "gis"."node_geometries" ("node_id", "geom") to stdout'
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import project_inp
|
||||
|
||||
|
||||
def test_temporary_project_inp_exports_unique_file_and_removes_it(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
exported_paths: list[Path] = []
|
||||
|
||||
def fake_dump_inp(project_code: str, path: str, version: str) -> None:
|
||||
assert project_code == "demo"
|
||||
assert version == "2"
|
||||
exported_path = Path(path)
|
||||
exported_path.write_text("[TITLE]\ndemo", encoding="utf-8")
|
||||
exported_paths.append(exported_path)
|
||||
|
||||
monkeypatch.setattr(project_inp, "PROJECT_INP_DIRECTORY", tmp_path)
|
||||
monkeypatch.setattr(project_inp, "dump_inp", fake_dump_inp)
|
||||
|
||||
with project_inp.temporary_project_inp(
|
||||
"demo", purpose="sensor-placement"
|
||||
) as first_path:
|
||||
assert first_path.read_text(encoding="utf-8") == "[TITLE]\ndemo"
|
||||
with project_inp.temporary_project_inp(
|
||||
"demo", purpose="sensor-placement"
|
||||
) as second_path:
|
||||
assert second_path.exists()
|
||||
|
||||
assert first_path != second_path
|
||||
assert exported_paths == [first_path, second_path]
|
||||
assert all(not path.exists() for path in exported_paths)
|
||||
|
||||
|
||||
def test_temporary_project_inp_removes_empty_export(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setattr(project_inp, "PROJECT_INP_DIRECTORY", tmp_path)
|
||||
monkeypatch.setattr(project_inp, "dump_inp", lambda *_args: None)
|
||||
|
||||
with pytest.raises(ValueError, match="INP 导出失败"):
|
||||
with project_inp.temporary_project_inp("missing", purpose="burst"):
|
||||
pass
|
||||
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
@@ -0,0 +1,158 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import project_provisioning
|
||||
|
||||
|
||||
class _FakeGeoServer:
|
||||
calls: list[tuple] = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return None
|
||||
|
||||
def check_ready(self):
|
||||
self.calls.append(("geoserver_ready",))
|
||||
|
||||
def workspace_exists(self, workspace):
|
||||
self.calls.append(("workspace_exists", workspace))
|
||||
return False
|
||||
|
||||
def create_project_workspace(self, **kwargs):
|
||||
self.calls.append(("create_workspace", kwargs))
|
||||
return ("junctions", "pipes")
|
||||
|
||||
def delete_workspace(self, workspace):
|
||||
self.calls.append(("delete_workspace", workspace))
|
||||
|
||||
|
||||
def _arrange_preflight(monkeypatch, calls):
|
||||
schema_template = project_provisioning.settings.WNDB_SCHEMA_TEMPLATE_DB_NAME
|
||||
monkeypatch.setattr(
|
||||
project_provisioning,
|
||||
"have_project",
|
||||
lambda name: name == schema_template,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
project_provisioning,
|
||||
"require_timescale_schema_template",
|
||||
lambda: calls.append(("require_timescale_template",)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
project_provisioning,
|
||||
"ensure_replication_worker_capacity",
|
||||
lambda: calls.append(("replication_capacity",)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
project_provisioning,
|
||||
"timescale_database_exists",
|
||||
lambda _name: False,
|
||||
)
|
||||
monkeypatch.setattr(project_provisioning, "GeoServerAdminClient", _FakeGeoServer)
|
||||
|
||||
|
||||
def test_provision_project_creates_resources_in_dependency_order(monkeypatch):
|
||||
calls: list[tuple] = []
|
||||
_FakeGeoServer.calls = calls
|
||||
_arrange_preflight(monkeypatch, calls)
|
||||
monkeypatch.setattr(
|
||||
project_provisioning,
|
||||
"create_project",
|
||||
lambda name: calls.append(("create_business", name)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
project_provisioning,
|
||||
"network_update",
|
||||
lambda path, name: calls.append(("import_model", Path(path).name, name)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
project_provisioning,
|
||||
"get_project_map_bbox",
|
||||
lambda name: (1.0, 2.0, 3.0, 4.0),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
project_provisioning,
|
||||
"create_project_model_template",
|
||||
lambda name: calls.append(("create_model_template", name)) or "demo_template",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
project_provisioning,
|
||||
"create_timescale_database",
|
||||
lambda name: calls.append(("create_timescale", name)),
|
||||
)
|
||||
|
||||
result = project_provisioning.provision_project_infrastructure(
|
||||
code="demo",
|
||||
workspace="demo",
|
||||
inp_path="model.inp",
|
||||
)
|
||||
|
||||
ordered_actions = [call[0] for call in calls]
|
||||
assert ordered_actions.index("create_business") < ordered_actions.index("import_model")
|
||||
assert ordered_actions.index("import_model") < ordered_actions.index("create_model_template")
|
||||
assert ordered_actions.index("create_model_template") < ordered_actions.index("create_timescale")
|
||||
assert ordered_actions.index("create_timescale") < ordered_actions.index("create_workspace")
|
||||
assert result.model_template == "demo_template"
|
||||
assert result.map_bbox == (1.0, 2.0, 3.0, 4.0)
|
||||
|
||||
|
||||
def test_provision_project_rolls_back_created_resources_in_reverse_order(monkeypatch):
|
||||
calls: list[tuple] = []
|
||||
_FakeGeoServer.calls = calls
|
||||
_arrange_preflight(monkeypatch, calls)
|
||||
monkeypatch.setattr(
|
||||
project_provisioning,
|
||||
"create_project",
|
||||
lambda name: calls.append(("create_business", name)),
|
||||
)
|
||||
monkeypatch.setattr(project_provisioning, "network_update", lambda *_args: None)
|
||||
monkeypatch.setattr(
|
||||
project_provisioning,
|
||||
"get_project_map_bbox",
|
||||
lambda _name: (1.0, 2.0, 3.0, 4.0),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
project_provisioning,
|
||||
"create_project_model_template",
|
||||
lambda name: calls.append(("create_model_template", name)) or "demo_template",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
project_provisioning,
|
||||
"create_timescale_database",
|
||||
lambda _name: (_ for _ in ()).throw(RuntimeError("timescale unavailable")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
project_provisioning,
|
||||
"delete_project_model_template",
|
||||
lambda name: calls.append(("delete_model_template", name)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
project_provisioning,
|
||||
"delete_project",
|
||||
lambda name: calls.append(("delete_business", name)),
|
||||
)
|
||||
|
||||
with pytest.raises(project_provisioning.ProjectProvisioningError) as exc_info:
|
||||
project_provisioning.provision_project_infrastructure(
|
||||
code="demo",
|
||||
workspace="demo",
|
||||
inp_path="model.inp",
|
||||
)
|
||||
|
||||
assert exc_info.value.stage == "timescaledb"
|
||||
assert [call[0] for call in calls[-2:]] == [
|
||||
"delete_model_template",
|
||||
"delete_business",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
["Demo", "1demo", "demo-project", "demo_template", "postgres"],
|
||||
)
|
||||
def test_validate_project_code_rejects_unsafe_or_reserved_names(code):
|
||||
with pytest.raises(ValueError):
|
||||
project_provisioning.validate_project_code(code)
|
||||
@@ -7,6 +7,7 @@ from app.infra.db.project_routing import (
|
||||
get_active_project_routing,
|
||||
get_project_database_name,
|
||||
get_project_pgconn_string,
|
||||
get_schema_template_database_name,
|
||||
get_project_template_database_name,
|
||||
get_project_timescale_pgconn_string,
|
||||
)
|
||||
@@ -37,12 +38,12 @@ def test_project_database_uses_exact_routing_dsn_for_project_code() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_business_template_keeps_server_and_timescale_ignores_legacy_db_name(
|
||||
def test_business_template_follows_physical_project_and_timescale_uses_routing(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.infra.db.project_routing.settings.WNDB_TEMPLATE_DB_NAME",
|
||||
"tjwater_v2_template",
|
||||
"app.infra.db.project_routing.settings.WNDB_SCHEMA_TEMPLATE_DB_NAME",
|
||||
"tjwater_v2_schema_template",
|
||||
)
|
||||
with activate_project_routing(_routing()):
|
||||
template_name = get_project_template_database_name("project_a")
|
||||
@@ -54,7 +55,7 @@ def test_business_template_keeps_server_and_timescale_ignores_legacy_db_name(
|
||||
assert business == {
|
||||
"user": "biz_user",
|
||||
"password": "biz_password",
|
||||
"dbname": "tjwater_v2_template",
|
||||
"dbname": "biz_database_template",
|
||||
"host": "biz.example",
|
||||
"port": "5432",
|
||||
"sslmode": "require",
|
||||
@@ -71,24 +72,32 @@ def test_business_template_keeps_server_and_timescale_ignores_legacy_db_name(
|
||||
|
||||
def test_project_code_resolves_to_physical_business_database(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.infra.db.project_routing.settings.WNDB_TEMPLATE_DB_NAME",
|
||||
"tjwater_v2_template",
|
||||
"app.infra.db.project_routing.settings.WNDB_SCHEMA_TEMPLATE_DB_NAME",
|
||||
"tjwater_v2_schema_template",
|
||||
)
|
||||
with activate_project_routing(_routing()):
|
||||
assert get_project_database_name("project_a") == "biz_database"
|
||||
assert get_project_database_name("temporary_run") == "temporary_run"
|
||||
assert get_project_template_database_name("project_a") == "tjwater_v2_template"
|
||||
assert (
|
||||
get_project_template_database_name("project_a")
|
||||
== "biz_database_template"
|
||||
)
|
||||
|
||||
|
||||
def test_template_falls_back_to_config_outside_project_routing(monkeypatch) -> None:
|
||||
def test_project_template_uses_database_name_outside_project_routing() -> None:
|
||||
assert (
|
||||
get_project_template_database_name("physical_project")
|
||||
== "physical_project_template"
|
||||
)
|
||||
|
||||
|
||||
def test_schema_template_comes_from_config(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.infra.db.project_routing.settings.WNDB_TEMPLATE_DB_NAME",
|
||||
"tjwater_v2_template",
|
||||
"app.infra.db.project_routing.settings.WNDB_SCHEMA_TEMPLATE_DB_NAME",
|
||||
"tjwater_v2_schema_template",
|
||||
)
|
||||
|
||||
assert get_project_template_database_name("ignored-project-code") == (
|
||||
"tjwater_v2_template"
|
||||
)
|
||||
assert get_schema_template_database_name() == "tjwater_v2_schema_template"
|
||||
|
||||
|
||||
def test_project_routing_is_nested_and_request_local() -> None:
|
||||
|
||||
@@ -39,7 +39,9 @@ def test_read_inp_refreshes_after_committed_replace_when_cleanup_fails(
|
||||
monkeypatch.setattr(
|
||||
importer,
|
||||
"copy_project",
|
||||
lambda _source, _target: events.append("copy"),
|
||||
lambda source, _target, **kwargs: events.append(
|
||||
f"copy:{source}:{kwargs.get('allow_template_source')}"
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(importer, "project_transaction", fake_transaction)
|
||||
monkeypatch.setattr(
|
||||
@@ -63,4 +65,10 @@ def test_read_inp_refreshes_after_committed_replace_when_cleanup_fails(
|
||||
)
|
||||
|
||||
assert importer.read_inp("project_a", "model.inp") is True
|
||||
assert events == ["copy", "parse", "replace", "cleanup-failed", "refresh"]
|
||||
assert events == [
|
||||
"copy:tjwater_v2_schema_template:True",
|
||||
"parse",
|
||||
"replace",
|
||||
"cleanup-failed",
|
||||
"refresh",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from app.native.wndb.model.options_v3 import _inp_in_option_v3, inp_in_option_v3
|
||||
|
||||
|
||||
def _operation_by_type(section: list[str], option_type: str) -> dict[str, str]:
|
||||
result = _inp_in_option_v3(section)
|
||||
return next(operation for operation in result.operations if operation["type"] == option_type)
|
||||
|
||||
|
||||
def test_v3_import_preserves_legacy_options_from_standard_inp() -> None:
|
||||
section = [
|
||||
"Units LPS",
|
||||
"Pattern PAT_BASE",
|
||||
"Quality None mg/L",
|
||||
"Trials 80",
|
||||
"Unbalanced Continue 10",
|
||||
"CHECKFREQ 2",
|
||||
]
|
||||
|
||||
legacy = _operation_by_type(section, "option")
|
||||
v3 = _operation_by_type(section, "option_v3")
|
||||
|
||||
assert legacy["PATTERN"] == "PAT_BASE"
|
||||
assert legacy["QUALITY"] == "None mg/L"
|
||||
assert legacy["TRIALS"] == "80"
|
||||
assert legacy["UNBALANCED"] == "CONTINUE 10"
|
||||
assert legacy["CHECKFREQ"] == "2"
|
||||
assert v3["DEMAND_PATTERN"] == "PAT_BASE"
|
||||
assert v3["QUALITY_MODEL"] == "NONE"
|
||||
|
||||
statement = inp_in_option_v3(section)
|
||||
assert "engine_version = 'legacy' and key = 'PATTERN'" in statement
|
||||
assert "engine_version = 'v3' and key = 'DEMAND_PATTERN'" in statement
|
||||
|
||||
|
||||
def test_v3_import_ignores_empty_option_lines() -> None:
|
||||
legacy = _operation_by_type(["", " ", "Pattern PAT_BASE"], "option")
|
||||
|
||||
assert legacy["PATTERN"] == "PAT_BASE"
|
||||
@@ -43,6 +43,15 @@ class _FakeConnection:
|
||||
return self._cursor
|
||||
|
||||
|
||||
class _SequenceCursor(_FakeCursor):
|
||||
def __init__(self, rows: list[dict]) -> None:
|
||||
super().__init__()
|
||||
self._fetch_rows = iter(rows)
|
||||
|
||||
def fetchone(self):
|
||||
return next(self._fetch_rows)
|
||||
|
||||
|
||||
def _admin_connection(cursor: _FakeCursor):
|
||||
@contextmanager
|
||||
def connection():
|
||||
@@ -51,14 +60,22 @@ def _admin_connection(cursor: _FakeCursor):
|
||||
return connection
|
||||
|
||||
|
||||
def _project_connection(cursor: _FakeCursor):
|
||||
@contextmanager
|
||||
def connection(_name):
|
||||
yield _FakeConnection(cursor)
|
||||
|
||||
return connection
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"postgres",
|
||||
"project",
|
||||
"system_hub",
|
||||
"SYSTEM_HUB",
|
||||
"tjwater_v2_template",
|
||||
"tjwater_v2_schema_template",
|
||||
"another_template",
|
||||
],
|
||||
)
|
||||
@@ -109,12 +126,12 @@ def test_create_project_allows_the_protected_template_as_source(monkeypatch) ->
|
||||
|
||||
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 closed == ["tjwater_v2_schema_template", "project_a"]
|
||||
assert any(call[1] == ("tjwater_v2_schema_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",)
|
||||
and call[1] == ("tjwater_v2_schema_template",)
|
||||
for call in cursor.calls
|
||||
)
|
||||
assert any("create database" in str(call[0]).lower() for call in cursor.calls)
|
||||
@@ -127,8 +144,7 @@ def test_list_project_excludes_metadata_database(monkeypatch) -> None:
|
||||
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
|
||||
assert "tjwater_v2_schema_template" in excluded
|
||||
|
||||
|
||||
def test_delete_project_uses_routed_physical_database_name(monkeypatch) -> None:
|
||||
@@ -177,6 +193,63 @@ def test_temporary_database_capacity_rejects_creation_at_limit(
|
||||
assert any("pg_advisory_unlock" in str(statement) for statement, _ in cursor.calls)
|
||||
|
||||
|
||||
def test_project_model_template_requires_ready_active_subscription(monkeypatch) -> None:
|
||||
cursor = _SequenceCursor(
|
||||
[
|
||||
{
|
||||
"subscriptions": 1,
|
||||
"enabled_subscriptions": 1,
|
||||
"active_workers": 1,
|
||||
},
|
||||
{"relations": 32, "pending_relations": 0},
|
||||
]
|
||||
)
|
||||
closed: list[str] = []
|
||||
monkeypatch.setattr(projects, "project_connection", _project_connection(cursor))
|
||||
monkeypatch.setattr(projects, "close_project_pool", closed.append)
|
||||
|
||||
projects._ensure_project_model_template_ready("project_a_template")
|
||||
|
||||
assert closed == ["project_a_template"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "relations", "message"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"subscriptions": 1,
|
||||
"enabled_subscriptions": 1,
|
||||
"active_workers": 0,
|
||||
},
|
||||
{"relations": 32, "pending_relations": 0},
|
||||
"subscription is not active",
|
||||
),
|
||||
(
|
||||
{
|
||||
"subscriptions": 1,
|
||||
"enabled_subscriptions": 1,
|
||||
"active_workers": 1,
|
||||
},
|
||||
{"relations": 32, "pending_relations": 1},
|
||||
"still synchronizing",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_project_model_template_rejects_unready_subscription(
|
||||
monkeypatch, status, relations, message
|
||||
) -> None:
|
||||
cursor = _SequenceCursor([status, relations])
|
||||
closed: list[str] = []
|
||||
monkeypatch.setattr(projects, "project_connection", _project_connection(cursor))
|
||||
monkeypatch.setattr(projects, "close_project_pool", closed.append)
|
||||
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
projects._ensure_project_model_template_ready("project_a_template")
|
||||
|
||||
assert closed == ["project_a_template"]
|
||||
|
||||
|
||||
def test_temporary_project_database_cleans_up_after_failure(monkeypatch) -> None:
|
||||
calls: list[tuple[str, ...]] = []
|
||||
monkeypatch.setattr(
|
||||
@@ -187,7 +260,9 @@ def test_temporary_project_database_cleans_up_after_failure(monkeypatch) -> None
|
||||
monkeypatch.setattr(
|
||||
projects,
|
||||
"copy_project",
|
||||
lambda source, target: calls.append(("copy", source, target)),
|
||||
lambda source, target, **kwargs: calls.append(
|
||||
("copy", source, target, str(kwargs.get("allow_template_source")))
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(projects, "have_project", lambda name: True)
|
||||
monkeypatch.setattr(
|
||||
@@ -195,26 +270,13 @@ def test_temporary_project_database_cleans_up_after_failure(monkeypatch) -> None
|
||||
"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"),
|
||||
("copy", "project_a_template", "isolated_run", "True"),
|
||||
("delete", "isolated_run"),
|
||||
]
|
||||
|
||||
@@ -229,7 +291,9 @@ def test_temporary_template_database_does_not_clone_a_project(monkeypatch) -> No
|
||||
monkeypatch.setattr(
|
||||
projects,
|
||||
"copy_project",
|
||||
lambda source, target: calls.append(("copy", source, target)),
|
||||
lambda source, target, **kwargs: calls.append(
|
||||
("copy", source, target, str(kwargs.get("allow_template_source")))
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(projects, "have_project", lambda name: True)
|
||||
monkeypatch.setattr(
|
||||
@@ -242,7 +306,12 @@ def test_temporary_template_database_does_not_clone_a_project(monkeypatch) -> No
|
||||
assert name == "empty_conversion"
|
||||
|
||||
assert calls == [
|
||||
("copy", "tjwater_v2_template", "empty_conversion"),
|
||||
(
|
||||
"copy",
|
||||
"tjwater_v2_schema_template",
|
||||
"empty_conversion",
|
||||
"True",
|
||||
),
|
||||
("delete", "empty_conversion"),
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user