feat(projects): automate project infrastructure provisioning
Generic Container CI/CD / test-build-publish (push) Successful in 1m13s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m13s

This commit is contained in:
2026-09-11 10:57:51 +08:00
parent 10a7a66a41
commit 90b02057bc
38 changed files with 2345 additions and 189 deletions
+113 -1
View File
@@ -1,6 +1,7 @@
import asyncio
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, Mock
from uuid import uuid4
import pytest
@@ -191,3 +192,114 @@ def test_committed_refresh_failure_is_not_wrapped_as_retryable_500(
asyncio.run(model_import._apply_model_update(b"[TITLE]\n", "demo"))
assert exc_info.value is error
def test_project_provision_creates_metadata_only_after_infrastructure(monkeypatch):
project_id = uuid4()
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
project = SimpleNamespace(
id=project_id,
name="Demo",
code="demo",
description=None,
gs_workspace="demo",
map_extent={"bbox": [1.0, 2.0, 3.0, 4.0], "zoom": 15},
status="active",
created_at=now,
updated_at=now,
)
infrastructure = SimpleNamespace(
map_bbox=(1.0, 2.0, 3.0, 4.0),
business_dsn="postgresql://business/demo",
timescale_dsn="postgresql://timescale/demo",
model_template="demo_template",
layers=("junctions", "pipes"),
)
repo = SimpleNamespace(
session=SimpleNamespace(rollback=AsyncMock()),
get_project_by_code=AsyncMock(return_value=None),
create_provisioned_project=AsyncMock(return_value=project),
)
monkeypatch.setattr(
model_import,
"_run_uploaded_inp",
AsyncMock(return_value='{"simulation_result":"successful"}'),
)
monkeypatch.setattr(
model_import,
"is_database_encryption_configured",
lambda: True,
)
async def fake_threadpool(function, *args, **kwargs):
assert function is model_import._provision_from_inp_sync
return infrastructure
monkeypatch.setattr(model_import, "run_in_threadpool", fake_threadpool)
monkeypatch.setattr(model_import, "log_audit_event", AsyncMock())
client = _client(
admin=SimpleNamespace(id=uuid4(), role="admin", is_superuser=True),
repo=repo,
)
response = client.post(
"/api/v1/admin/project-provisions",
data={"name": "Demo", "code": "demo", "map_zoom": "15"},
files={"file": ("model.inp", VALID_INP)},
)
assert response.status_code == 201
assert response.json()["model_template_database"] == "demo_template"
repo.create_provisioned_project.assert_awaited_once()
assert repo.create_provisioned_project.await_args.kwargs["business_dsn"] == infrastructure.business_dsn
model_import.log_audit_event.assert_awaited_once()
def test_project_provision_cleans_infrastructure_when_metadata_commit_fails(monkeypatch):
cleanup = Mock(return_value=[])
infrastructure = SimpleNamespace(
map_bbox=(1.0, 2.0, 3.0, 4.0),
business_dsn="postgresql://business/demo",
timescale_dsn="postgresql://timescale/demo",
model_template="demo_template",
layers=("junctions", "pipes"),
cleanup=cleanup,
)
repo = SimpleNamespace(
session=SimpleNamespace(rollback=AsyncMock()),
get_project_by_code=AsyncMock(return_value=None),
create_provisioned_project=AsyncMock(side_effect=RuntimeError("metadata down")),
)
monkeypatch.setattr(
model_import,
"_run_uploaded_inp",
AsyncMock(return_value='{"simulation_result":"successful"}'),
)
monkeypatch.setattr(
model_import,
"is_database_encryption_configured",
lambda: True,
)
async def fake_threadpool(function, *args, **kwargs):
if function is model_import._provision_from_inp_sync:
return infrastructure
return function(*args, **kwargs)
monkeypatch.setattr(model_import, "run_in_threadpool", fake_threadpool)
monkeypatch.setattr(model_import, "log_audit_event", AsyncMock())
client = _client(
admin=SimpleNamespace(id=uuid4(), role="admin", is_superuser=True),
repo=repo,
)
response = client.post(
"/api/v1/admin/project-provisions",
data={"name": "Demo", "code": "demo"},
files={"file": ("model.inp", VALID_INP)},
)
assert response.status_code == 503
repo.session.rollback.assert_awaited_once()
cleanup.assert_called_once_with()
model_import.log_audit_event.assert_not_awaited()