M8-T12: scope energy contracts

This commit is contained in:
2026-08-23 21:22:06 +02:00
parent a9458394f2
commit b812d5ac46
13 changed files with 533 additions and 47 deletions
+67
View File
@@ -55,6 +55,7 @@ from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from app.models.energy import EnergyContract, EnergyContractVersion
from app.services.contracts import activate_contract
# ---------------------------------------------------------------------------
# Shared helpers
@@ -567,6 +568,72 @@ def test_deactivate_contract(contracts_client):
assert resp.json()["active"] is False
def test_scope_defaults_filtering_and_kind_mismatch(contracts_client):
"""Old clients default to electricity; a supplied incompatible scope is rejected."""
client, engine = contracts_client
_login(client)
created = client.post(
"/api/energy/contracts",
json=_manual_payload(),
headers={"X-CSRF-Token": _CSRF},
)
assert created.status_code == 201
assert created.json()["scope"] == "electricity"
assert client.get("/api/energy/contracts").json()["total"] == 1
assert client.get("/api/energy/contracts?scope=thermal").json()["items"] == []
mismatch = client.post(
"/api/energy/contracts",
json=_manual_payload(scope="thermal"),
headers={"X-CSRF-Token": _CSRF},
)
assert mismatch.status_code == 422
with Session(engine) as session:
assert len(session.execute(select(EnergyContract)).scalars().all()) == 1
def test_activation_is_scope_local_and_transaction_rollback_is_safe(contracts_client):
"""A thermal activation neither deactivates electricity nor survives rollback."""
client, engine = contracts_client
_login(client)
electricity = client.post(
"/api/energy/contracts",
json=_manual_payload(name="Electricity"),
headers={"X-CSRF-Token": _CSRF},
).json()
client.patch(
f"/api/energy/contracts/{electricity['id']}",
json={"active": True},
headers={"X-CSRF-Token": _CSRF},
)
now = datetime.now(UTC)
with Session(engine) as session:
thermal = EnergyContract(
name="Future thermal", kind="district_heating", scope="thermal", active=False,
currency="EUR", created_at=now, updated_at=now,
)
session.add(thermal)
session.commit()
thermal_id = thermal.id
with Session(engine) as session:
thermal = session.get(EnergyContract, thermal_id)
assert thermal is not None
activate_contract(session, thermal)
session.rollback() # Simulate a later write failure in this transaction.
with Session(engine) as session:
rows = {row.scope: row for row in session.execute(select(EnergyContract)).scalars()}
assert rows["electricity"].active is True
assert rows["thermal"].active is False
activate_contract(session, rows["thermal"])
session.commit()
with Session(engine) as session:
active = session.execute(select(EnergyContract).where(EnergyContract.active.is_(True))).scalars().all()
assert {row.scope for row in active} == {"electricity", "thermal"}
# ---------------------------------------------------------------------------
# POST /api/energy/contracts/{id}/versions
# ---------------------------------------------------------------------------
+160 -1
View File
@@ -157,11 +157,15 @@ def test_energy_contract_columns(energy_db):
inspector = inspect(energy_db)
columns = {col["name"]: col for col in inspector.get_columns("energy_contract")}
required_non_nullable = {"id", "name", "kind", "active", "currency", "created_at", "updated_at"}
required_non_nullable = {
"id", "name", "kind", "scope", "active", "currency", "created_at", "updated_at"
}
for col_name in required_non_nullable:
assert col_name in columns, f"Missing column: {col_name}"
assert not columns[col_name]["nullable"], f"{col_name} should be NOT NULL"
assert any(index["name"] == "ix_energy_contract_scope" for index in inspector.get_indexes("energy_contract"))
def test_energy_contract_version_columns(energy_db):
"""energy_contract_version must have all required columns with correct nullability."""
@@ -537,6 +541,7 @@ def test_energy_contract_insert_and_retrieve(energy_db):
assert fetched is not None
assert fetched.name == "My Manual Contract"
assert fetched.kind == "manual"
assert fetched.scope == "electricity"
assert fetched.active is True
assert fetched.currency == "EUR"
@@ -1208,3 +1213,157 @@ def test_migration_downgrade_removes_meter_table(tmp_path: Path):
"meter_id must be removed from energy_cost_period after downgrade"
)
engine.dispose()
def test_contract_scope_migration_preserves_historical_contract_audit(tmp_path: Path):
"""A revision-17 fixture upgrades/downgrades without altering contract audit rows."""
db_url = f"sqlite:///{tmp_path / 'contract_scope_history.db'}"
cfg = _make_app_alembic_config(db_url)
command.upgrade(cfg, "20260822_17_warmtelink_readings")
engine = create_engine(db_url, connect_args={"check_same_thread": False})
now = datetime.now(tz=timezone.utc).replace(tzinfo=None)
values = '{"energy":{"buy":{"normal":0.4}}}'
pricing = '{"historic":"unchanged"}'
with engine.begin() as connection:
contract_id = connection.execute(
text(
"INSERT INTO energy_contract (name, kind, active, currency, created_at, updated_at) "
"VALUES ('Historic', 'manual', 1, 'EUR', :now, :now)"
),
{"now": now},
).lastrowid
version_id = connection.execute(
text(
"INSERT INTO energy_contract_version "
"(contract_id, effective_from, effective_to, \"values\", created_at) "
"VALUES (:contract_id, :now, NULL, :values, :now)"
),
{"contract_id": contract_id, "now": now, "values": values},
).lastrowid
connection.execute(
text(
"INSERT INTO energy_cost_period "
"(period_start, d1_kwh, d2_kwh, r1_kwh, r2_kwh, import_cost, export_revenue, "
"net_cost, currency, pricing, contract_version_id, degraded, computed_at, meter_id, "
"source_binding_id) VALUES (:now, 1, 2, 0, 0, 3, 0, 3, 'EUR', :pricing, :version_id, "
"0, :now, NULL, NULL)"
),
{"now": now, "pricing": pricing, "version_id": version_id},
)
command.upgrade(cfg, "head")
command.upgrade(cfg, "head")
with engine.connect() as connection:
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == (
"20260822_18_contract_scopes"
)
assert connection.execute(text("SELECT scope FROM energy_contract")).scalar_one() == "electricity"
assert connection.execute(text("SELECT COUNT(*) FROM energy_contract")).scalar_one() == 1
assert connection.execute(text("SELECT COUNT(*) FROM energy_contract_version")).scalar_one() == 1
assert connection.execute(text("SELECT COUNT(*) FROM energy_cost_period")).scalar_one() == 1
assert connection.execute(text("SELECT \"values\" FROM energy_contract_version")).scalar_one() == values
assert connection.execute(text("SELECT pricing FROM energy_cost_period")).scalar_one() == pricing
assert connection.execute(
text(
"SELECT COUNT(*) FROM energy_contract_version v LEFT JOIN energy_contract c "
"ON c.id = v.contract_id WHERE c.id IS NULL"
)
).scalar_one() == 0
inspector = inspect(connection)
assert any(item["name"] == "ix_energy_contract_scope" for item in inspector.get_indexes("energy_contract"))
command.downgrade(cfg, "20260822_17_warmtelink_readings")
with engine.connect() as connection:
assert "scope" not in {item["name"] for item in inspect(connection).get_columns("energy_contract")}
assert connection.execute(text("SELECT COUNT(*) FROM energy_contract_version")).scalar_one() == 1
assert connection.execute(text("SELECT COUNT(*) FROM energy_cost_period")).scalar_one() == 1
engine.dispose()
def test_contract_scope_migration_audit_failure_restores_revision_17(tmp_path: Path):
"""A post-DDL audit failure leaves no SQLite batch-migration residue."""
db_url = f"sqlite:///{tmp_path / 'contract_scope_audit_failure.db'}"
cfg = _make_app_alembic_config(db_url)
command.upgrade(cfg, "20260822_17_warmtelink_readings")
engine = _engine_with_fk(db_url)
now = datetime.now(tz=timezone.utc).replace(tzinfo=None)
values = '{"energy":{"buy":{"normal":0.4}}}'
pricing = '{"historic":"unchanged"}'
with engine.begin() as connection:
contract_id = connection.execute(
text(
"INSERT INTO energy_contract (name, kind, active, currency, created_at, updated_at) "
"VALUES ('Historic', 'manual', 1, 'EUR', :now, :now)"
),
{"now": now},
).lastrowid
version_id = connection.execute(
text(
"INSERT INTO energy_contract_version "
"(contract_id, effective_from, effective_to, \"values\", created_at) "
"VALUES (:contract_id, :now, NULL, :values, :now)"
),
{"contract_id": contract_id, "now": now, "values": values},
).lastrowid
connection.execute(
text(
"INSERT INTO energy_cost_period "
"(period_start, d1_kwh, d2_kwh, r1_kwh, r2_kwh, import_cost, export_revenue, "
"net_cost, currency, pricing, contract_version_id, degraded, computed_at, meter_id, "
"source_binding_id) VALUES (:now, 1, 2, 0, 0, 3, 0, 3, 'EUR', :pricing, :version_id, "
"0, :now, NULL, NULL)"
),
{"now": now, "pricing": pricing, "version_id": version_id},
)
engine.dispose()
def _raise_after_ddl() -> None:
raise RuntimeError("injected post-DDL audit failure")
cfg.attributes["m8_t12_post_ddl_audit_failure"] = _raise_after_ddl
with pytest.raises(RuntimeError, match="injected post-DDL audit failure"):
command.upgrade(cfg, "head")
engine = _engine_with_fk(db_url)
with engine.connect() as connection:
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == (
"20260822_17_warmtelink_readings"
)
assert "scope" not in {item["name"] for item in inspect(connection).get_columns("energy_contract")}
assert not any(
item["name"] == "ix_energy_contract_scope"
for item in inspect(connection).get_indexes("energy_contract")
)
assert connection.execute(text("SELECT COUNT(*) FROM energy_contract")).scalar_one() == 1
assert connection.execute(text("SELECT COUNT(*) FROM energy_contract_version")).scalar_one() == 1
assert connection.execute(text("SELECT COUNT(*) FROM energy_cost_period")).scalar_one() == 1
assert connection.execute(text("SELECT \"values\" FROM energy_contract_version")).scalar_one() == values
assert connection.execute(text("SELECT pricing FROM energy_cost_period")).scalar_one() == pricing
assert connection.execute(
text(
"SELECT COUNT(*) FROM energy_contract_version v LEFT JOIN energy_contract c "
"ON c.id = v.contract_id WHERE c.id IS NULL"
)
).scalar_one() == 0
assert connection.execute(
text(
"SELECT COUNT(*) FROM energy_cost_period p LEFT JOIN energy_contract_version v "
"ON v.id = p.contract_version_id "
"WHERE p.contract_version_id IS NOT NULL AND v.id IS NULL"
)
).scalar_one() == 0
assert connection.execute(text("PRAGMA foreign_key_check")).all() == []
assert connection.execute(
text("SELECT name FROM sqlite_master WHERE name LIKE '_alembic_tmp_%'")
).all() == []
engine.dispose()
del cfg.attributes["m8_t12_post_ddl_audit_failure"]
command.upgrade(cfg, "head")
engine = _engine_with_fk(db_url)
with engine.connect() as connection:
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == (
"20260822_18_contract_scopes"
)
assert connection.execute(text("SELECT scope FROM energy_contract")).scalar_one() == "electricity"
engine.dispose()
+6 -9
View File
@@ -14,8 +14,6 @@ from sqlalchemy import create_engine, event, inspect, text
from sqlalchemy.orm import Session
from app.models.meter_source import MeterSourceChannel, WarmteLinkReading
from scripts.app_db_adopt import APP_BASELINE_REVISION
REVISION_16 = "20260822_16_dsmr_source_adoption"
REVISION_17 = "20260822_17_warmtelink_readings"
@@ -78,8 +76,8 @@ def _insert_reading(connection, channel_id: int, timestamp: datetime, value: Dec
def test_empty_database_and_revision_16_upgrade_are_additive_and_idempotent(tmp_path: Path):
empty_url = f"sqlite:///{tmp_path / 'warmtelink_empty.db'}"
empty_config = _config(empty_url)
command.upgrade(empty_config, "head")
command.upgrade(empty_config, "head")
command.upgrade(empty_config, REVISION_17)
command.upgrade(empty_config, REVISION_17)
empty_engine = _engine(empty_url)
try:
with empty_engine.connect() as connection:
@@ -106,8 +104,8 @@ def test_empty_database_and_revision_16_upgrade_are_additive_and_idempotent(tmp_
finally:
engine.dispose()
command.upgrade(config, "head")
command.upgrade(config, "head")
command.upgrade(config, REVISION_17)
command.upgrade(config, REVISION_17)
engine = _engine(database_url)
try:
with engine.connect() as connection:
@@ -125,7 +123,7 @@ def test_empty_database_and_revision_16_upgrade_are_additive_and_idempotent(tmp_
def test_warmtelink_reading_constraints_indexes_and_decimal_round_trip(tmp_path: Path):
database_url = f"sqlite:///{tmp_path / 'warmtelink_constraints.db'}"
config = _config(database_url)
command.upgrade(config, "head")
command.upgrade(config, REVISION_17)
timestamp = datetime(2026, 8, 22, 10, 30, tzinfo=timezone.utc)
engine = _engine(database_url)
try:
@@ -233,13 +231,12 @@ def test_warmtelink_reading_model_uses_restrictive_relationship_and_aware_column
assert "delete-orphan" not in relationship.cascade
assert WarmteLinkReading.__table__.c.recorded_at.type.timezone is True
assert WarmteLinkReading.__table__.c.received_at.type.timezone is True
assert APP_BASELINE_REVISION == REVISION_17
def test_warmtelink_reading_downgrade_is_schema_only_on_temporary_database(tmp_path: Path):
database_url = f"sqlite:///{tmp_path / 'warmtelink_downgrade.db'}"
config = _config(database_url)
command.upgrade(config, "head")
command.upgrade(config, REVISION_17)
command.downgrade(config, REVISION_16)
engine = _engine(database_url)