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
+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()