393 lines
15 KiB
Python
393 lines
15 KiB
Python
import asyncio
|
|
import os
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from psycopg import connect
|
|
|
|
from app.core.config import get_pgconn_string
|
|
from app.infra.epanet import run_project_return_dict
|
|
from app.infra.db.dynamic_manager import ProjectConnectionManager
|
|
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
|
from app.infra.db.timescaledb.sync_pool import timescale_connection
|
|
from app.native.wndb.commands.api import delete_pattern_cascade
|
|
from app.native.wndb.core.connection import project_connection, project_transaction
|
|
from app.native.wndb.core.projects import have_project, temporary_project_database
|
|
from app.native.wndb.core.database import ChangeSet, g_delete_prefix, write
|
|
from app.native.wndb.model import demands, junctions, patterns
|
|
from app.services.scheme_management import create_analysis_run, update_analysis_run
|
|
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
os.getenv("RUN_DB_INTEGRATION") != "1",
|
|
reason="set RUN_DB_INTEGRATION=1 to test configured PostgreSQL databases",
|
|
)
|
|
|
|
PROJECT = os.getenv("DB_INTEGRATION_PROJECT", "tjwater_v2")
|
|
|
|
|
|
def _read_business_database(_: int) -> str:
|
|
with project_connection(PROJECT) as conn, conn.cursor() as cur:
|
|
cur.execute("select current_database()")
|
|
return str(cur.fetchone()["current_database"])
|
|
|
|
|
|
def _read_timeseries_database(_: int) -> str:
|
|
with timescale_connection(PROJECT) as conn, conn.cursor() as cur:
|
|
cur.execute("select current_database()")
|
|
return str(cur.fetchone()["current_database"])
|
|
|
|
|
|
def test_business_pool_handles_concurrent_borrows() -> None:
|
|
with ThreadPoolExecutor(max_workers=16) as executor:
|
|
names = list(executor.map(_read_business_database, range(64)))
|
|
|
|
assert names == [PROJECT] * 64
|
|
|
|
|
|
def test_timeseries_pool_handles_concurrent_borrows() -> None:
|
|
with ThreadPoolExecutor(max_workers=16) as executor:
|
|
names = list(executor.map(_read_timeseries_database, range(64)))
|
|
|
|
assert names == [PROJECT] * 64
|
|
|
|
|
|
def test_temporary_project_clone_runs_with_network_data_only() -> None:
|
|
def counts(project: str) -> dict:
|
|
with project_connection(project) as conn, conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
select
|
|
(select count(*) from network.nodes) as nodes,
|
|
(select count(*) from network.links) as links,
|
|
(select count(*) from asset.scada_devices) as scada,
|
|
(select count(*) from gis.node_geometries) as node_geometries,
|
|
(select count(*) from gis.link_vertices) as link_vertices
|
|
"""
|
|
)
|
|
return dict(cur.fetchone())
|
|
|
|
source_counts = counts(PROJECT)
|
|
temporary = None
|
|
with temporary_project_database(PROJECT, "clone_validation") as temporary:
|
|
temporary_counts = counts(temporary)
|
|
assert temporary_counts["nodes"] == source_counts["nodes"]
|
|
assert temporary_counts["links"] == source_counts["links"]
|
|
assert temporary_counts["scada"] == 0
|
|
assert temporary_counts["node_geometries"] == 0
|
|
assert temporary_counts["link_vertices"] == 0
|
|
|
|
result = run_project_return_dict(temporary)
|
|
output = result["output"]
|
|
assert result["simulation_result"] == "successful"
|
|
assert len(output["node_results"]) == source_counts["nodes"]
|
|
assert len(output["link_results"]) == source_counts["links"]
|
|
|
|
assert temporary is not None
|
|
assert have_project(temporary) is False
|
|
|
|
|
|
def test_dynamic_pool_replaces_terminated_idle_connection() -> None:
|
|
async def exercise_pool() -> None:
|
|
manager = ProjectConnectionManager()
|
|
dsn = get_pgconn_string(db_name=PROJECT)
|
|
try:
|
|
project_id = uuid4()
|
|
async with manager.pg_connection(
|
|
project_id, "biz_data", dsn, 1, 1
|
|
) as conn:
|
|
async with conn.cursor() as cur:
|
|
await cur.execute("select pg_backend_pid()")
|
|
backend_pid = int((await cur.fetchone())["pg_backend_pid"])
|
|
|
|
with connect(dsn, autocommit=True) as admin_conn:
|
|
with admin_conn.cursor() as cur:
|
|
cur.execute("select pg_terminate_backend(%s)", (backend_pid,))
|
|
assert cur.fetchone()[0] is True
|
|
|
|
async with manager.pg_connection(
|
|
project_id, "biz_data", dsn, 1, 1
|
|
) as conn:
|
|
async with conn.cursor() as cur:
|
|
await cur.execute("select current_database()")
|
|
assert (await cur.fetchone())["current_database"] == PROJECT
|
|
finally:
|
|
await manager.close_all()
|
|
|
|
asyncio.run(exercise_pool())
|
|
|
|
|
|
def test_scada_api_repository_reads_device_ids_through_dynamic_pool() -> None:
|
|
async def read_scada_devices() -> list[dict]:
|
|
manager = ProjectConnectionManager()
|
|
try:
|
|
async with manager.pg_connection(
|
|
uuid4(), "biz_data", get_pgconn_string(db_name=PROJECT), 1, 4
|
|
) as conn:
|
|
return await ScadaInfoRepository.get_scadas(conn)
|
|
finally:
|
|
await manager.close_all()
|
|
|
|
devices = asyncio.run(read_scada_devices())
|
|
|
|
assert devices
|
|
device_ids = [device["device_id"] for device in devices]
|
|
assert len(device_ids) == len(set(device_ids))
|
|
assert all(device_ids)
|
|
assert all(
|
|
device["longitude"] is not None and device["latitude"] is not None
|
|
for device in devices
|
|
)
|
|
|
|
|
|
def test_nested_wndb_writes_roll_back_as_one_transaction() -> None:
|
|
with pytest.raises(RuntimeError, match="force rollback"):
|
|
with project_transaction(PROJECT) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"create temporary table wndb_pool_rollback_probe (value integer) on commit drop"
|
|
)
|
|
cur.execute("insert into wndb_pool_rollback_probe values (1)")
|
|
with project_connection(PROJECT) as nested:
|
|
assert nested is conn
|
|
raise RuntimeError("force rollback")
|
|
|
|
with project_connection(PROJECT) as conn, conn.cursor() as cur:
|
|
cur.execute("select to_regclass('pg_temp.wndb_pool_rollback_probe')")
|
|
assert cur.fetchone()["to_regclass"] is None
|
|
|
|
|
|
def test_analysis_run_lifecycle_uses_one_execution_id() -> None:
|
|
run_id = None
|
|
with pytest.raises(RuntimeError, match="force rollback"):
|
|
with project_transaction(PROJECT) as conn:
|
|
run_id = create_analysis_run(
|
|
PROJECT,
|
|
"integration-lifecycle-probe",
|
|
"integration_test",
|
|
"pytest",
|
|
"2026-08-24T00:00:00Z",
|
|
{"temporary": True},
|
|
)
|
|
update_analysis_run(
|
|
PROJECT,
|
|
run_id,
|
|
status="completed",
|
|
username="pytest",
|
|
scheme_detail={"temporary": True},
|
|
)
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"select status from analysis.runs where run_id = %s", (run_id,)
|
|
)
|
|
assert cur.fetchone()["status"] == "completed"
|
|
raise RuntimeError("force rollback")
|
|
|
|
with project_connection(PROJECT) as conn, conn.cursor() as cur:
|
|
cur.execute("select count(*) as count from analysis.runs where run_id = %s", (run_id,))
|
|
assert cur.fetchone()["count"] == 0
|
|
|
|
|
|
def test_legacy_wndb_batch_treats_malicious_id_as_data() -> None:
|
|
malicious = "integration'); DROP SCHEMA network CASCADE; --"
|
|
command = patterns._add_pattern(
|
|
PROJECT,
|
|
ChangeSet({"id": malicious, "factors": [1.0]}),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="force rollback"):
|
|
with project_transaction(PROJECT) as conn:
|
|
write(PROJECT, command.sql)
|
|
with conn.cursor() as cur:
|
|
cur.execute("select count(*) as count from network.patterns where id = %s", (malicious,))
|
|
assert cur.fetchone()["count"] == 1
|
|
cur.execute("select to_regnamespace('network') as namespace")
|
|
assert cur.fetchone()["namespace"] is not None
|
|
raise RuntimeError("force rollback")
|
|
|
|
|
|
def test_wndb_database_command_pattern_crud_uses_pooled_transaction() -> None:
|
|
pattern_id = f"integration-command-{uuid4()}"
|
|
|
|
with pytest.raises(RuntimeError, match="force rollback"):
|
|
with project_transaction(PROJECT) as conn:
|
|
added = patterns.add_pattern(
|
|
PROJECT,
|
|
ChangeSet({"id": pattern_id, "factors": [1.0, 1.1]}),
|
|
)
|
|
updated = patterns.set_pattern(
|
|
PROJECT,
|
|
ChangeSet({"id": pattern_id, "factors": [0.8, 1.2, 1.0]}),
|
|
)
|
|
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"select factor from network.pattern_values "
|
|
"where pattern_id = %s order by sequence_no",
|
|
(pattern_id,),
|
|
)
|
|
assert [float(row["factor"]) for row in cur.fetchall()] == [
|
|
0.8,
|
|
1.2,
|
|
1.0,
|
|
]
|
|
|
|
deleted = patterns.delete_pattern(
|
|
PROJECT,
|
|
ChangeSet({"id": pattern_id}),
|
|
)
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"select count(*) as count from network.patterns where id = %s",
|
|
(pattern_id,),
|
|
)
|
|
assert cur.fetchone()["count"] == 0
|
|
|
|
assert added.operations[0]["operation"] == "add"
|
|
assert updated.operations[0]["operation"] == "update"
|
|
assert deleted.operations == [
|
|
{"operation": "delete", "type": "pattern", "id": pattern_id}
|
|
]
|
|
raise RuntimeError("force rollback")
|
|
|
|
with project_connection(PROJECT) as conn, conn.cursor() as cur:
|
|
cur.execute(
|
|
"select count(*) as count from network.patterns where id = %s",
|
|
(pattern_id,),
|
|
)
|
|
assert cur.fetchone()["count"] == 0
|
|
|
|
|
|
def test_wndb_ordered_detail_tables_use_parent_scoped_primary_keys() -> None:
|
|
expected = {
|
|
"gis.link_vertices": "PRIMARY KEY (link_id, sequence_no)",
|
|
"network.curve_points": "PRIMARY KEY (curve_id, sequence_no)",
|
|
"network.demands": "PRIMARY KEY (junction_id, sequence_no)",
|
|
"network.pattern_flow_samples": "PRIMARY KEY (pattern_id, sequence_no)",
|
|
"network.pattern_values": "PRIMARY KEY (pattern_id, sequence_no)",
|
|
}
|
|
|
|
with project_connection(PROJECT) as conn, conn.cursor() as cur:
|
|
cur.execute(
|
|
"select conrelid::regclass::text as table_name, "
|
|
"pg_get_constraintdef(oid) as definition "
|
|
"from pg_constraint "
|
|
"where conname = any(%s) order by 1",
|
|
([f"{table.rsplit('.', 1)[1]}_pkey" for table in expected],),
|
|
)
|
|
actual = {row["table_name"]: row["definition"] for row in cur.fetchall()}
|
|
|
|
assert actual == expected
|
|
|
|
|
|
def test_gis_unified_views_cover_all_materialized_network_layers() -> None:
|
|
with project_connection(PROJECT) as conn, conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
(SELECT COUNT(*) FROM gis.network_nodes) AS nodes,
|
|
(SELECT COUNT(*) FROM gis.junctions)
|
|
+ (SELECT COUNT(*) FROM gis.reservoirs)
|
|
+ (SELECT COUNT(*) FROM gis.tanks) AS source_nodes,
|
|
(SELECT COUNT(*) FROM gis.network_links) AS links,
|
|
(SELECT COUNT(*) FROM gis.pipes)
|
|
+ (SELECT COUNT(*) FROM gis.pumps)
|
|
+ (SELECT COUNT(*) FROM gis.valves) AS source_links
|
|
"""
|
|
)
|
|
counts = cur.fetchone()
|
|
cur.execute(
|
|
"""
|
|
SELECT obj_description('gis.network_nodes'::regclass) AS node_comment,
|
|
obj_description('gis.network_links'::regclass) AS link_comment
|
|
"""
|
|
)
|
|
comments = cur.fetchone()
|
|
cur.execute(
|
|
"""
|
|
SELECT COUNT(*) AS undocumented_columns
|
|
FROM pg_attribute
|
|
WHERE attrelid = ANY(
|
|
ARRAY['gis.network_nodes'::regclass, 'gis.network_links'::regclass]
|
|
)
|
|
AND attnum > 0
|
|
AND NOT attisdropped
|
|
AND col_description(attrelid, attnum) IS NULL
|
|
"""
|
|
)
|
|
undocumented_columns = cur.fetchone()["undocumented_columns"]
|
|
|
|
assert counts["nodes"] == counts["source_nodes"]
|
|
assert counts["links"] == counts["source_links"]
|
|
assert comments["node_comment"]
|
|
assert comments["link_comment"]
|
|
assert undocumented_columns == 0
|
|
|
|
|
|
def test_wndb_pattern_cascade_unsets_dependent_demand_atomically() -> None:
|
|
suffix = uuid4()
|
|
junction_id = f"integration-junction-{suffix}"
|
|
pattern_id = f"integration-cascade-{suffix}"
|
|
retained_pattern_id = f"integration-retained-{suffix}"
|
|
|
|
with pytest.raises(RuntimeError, match="force rollback"):
|
|
with project_transaction(PROJECT):
|
|
junctions.add_junction(
|
|
PROJECT,
|
|
ChangeSet(
|
|
{"id": junction_id, "x": 0.0, "y": 0.0, "elevation": 1.0}
|
|
),
|
|
)
|
|
patterns.add_pattern(
|
|
PROJECT,
|
|
ChangeSet({"id": pattern_id, "factors": [1.0]}),
|
|
)
|
|
patterns.add_pattern(
|
|
PROJECT,
|
|
ChangeSet({"id": retained_pattern_id, "factors": [1.0]}),
|
|
)
|
|
demands.set_demand(
|
|
PROJECT,
|
|
ChangeSet(
|
|
{
|
|
"junction": junction_id,
|
|
"demands": [
|
|
{
|
|
"demand": 1.0,
|
|
"pattern": pattern_id,
|
|
"category": "integration",
|
|
},
|
|
{
|
|
"demand": 2.0,
|
|
"pattern": retained_pattern_id,
|
|
"category": "retained",
|
|
},
|
|
],
|
|
}
|
|
),
|
|
)
|
|
|
|
result = delete_pattern_cascade(
|
|
PROJECT,
|
|
ChangeSet(g_delete_prefix | {"id": pattern_id}),
|
|
)
|
|
|
|
assert patterns.get_pattern(PROJECT, pattern_id) == {}
|
|
assert demands.get_demand(PROJECT, junction_id)["demands"] == [
|
|
{"demand": 1.0, "pattern": None, "category": "integration"},
|
|
{
|
|
"demand": 2.0,
|
|
"pattern": retained_pattern_id,
|
|
"category": "retained",
|
|
},
|
|
]
|
|
assert result.operations[-1] == {
|
|
"operation": "delete",
|
|
"type": "pattern",
|
|
"id": pattern_id,
|
|
}
|
|
raise RuntimeError("force rollback")
|
|
|
|
assert junctions.get_junction(PROJECT, junction_id) == {}
|