M7-T01: add meter table + model + energy_cost_period.meter_id + backfill migration
This commit is contained in:
+443
-9
@@ -1,13 +1,15 @@
|
||||
"""Tests for M6-T01: energy pricing and DSMR metering tables and ORM models.
|
||||
"""Tests for energy pricing, DSMR metering, and meter epoch tables and ORM models.
|
||||
|
||||
Covers:
|
||||
1. Migration shape: upgrade to head creates all five tables with correct columns,
|
||||
1. Migration shape: upgrade to head creates all six tables with correct columns,
|
||||
constraints, and indexes; downgrade -1 cleanly removes them.
|
||||
2. ORM metadata: Base.metadata.tables contains all five tables; FKs are RESTRICT;
|
||||
2. ORM metadata: Base.metadata.tables contains all six tables; FKs are RESTRICT;
|
||||
JSON columns are present; unique and index constraints are correct.
|
||||
3. Baseline constant: APP_BASELINE_REVISION matches the actual Alembic head.
|
||||
4. Basic ORM round-trip: insert + retrieve for each table, JSON payload round-trip.
|
||||
5. FK RESTRICT: deleting a referenced parent row must fail when children exist.
|
||||
6. Meter model: fields, nullability, FK from energy_cost_period.
|
||||
7. Migration backfill: initial meter creation, idempotency, audit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,6 +29,7 @@ from app.models.energy import (
|
||||
DsmrReading,
|
||||
EnergyContract,
|
||||
EnergyContractVersion,
|
||||
Meter,
|
||||
TibberPrice,
|
||||
EnergyCostPeriod,
|
||||
)
|
||||
@@ -92,10 +95,11 @@ def energy_db_with_fk(tmp_path: Path):
|
||||
|
||||
|
||||
def test_energy_tables_exist_after_upgrade(energy_db):
|
||||
"""All five energy tables must exist after upgrade to head."""
|
||||
"""All six energy tables must exist after upgrade to head."""
|
||||
inspector = inspect(energy_db)
|
||||
table_names = inspector.get_table_names()
|
||||
expected_tables = {
|
||||
"meter",
|
||||
"dsmr_reading",
|
||||
"energy_contract",
|
||||
"energy_contract_version",
|
||||
@@ -200,7 +204,7 @@ def test_energy_cost_period_columns(energy_db):
|
||||
"import_cost", "export_revenue", "net_cost", "currency",
|
||||
"pricing", "degraded", "computed_at",
|
||||
}
|
||||
nullable = {"contract_version_id"}
|
||||
nullable = {"contract_version_id", "meter_id"}
|
||||
|
||||
for col_name in non_nullable:
|
||||
assert col_name in columns, f"Missing column: {col_name}"
|
||||
@@ -234,10 +238,35 @@ def test_energy_cost_period_fk_to_contract_version(energy_db):
|
||||
"""energy_cost_period.contract_version_id must have a FK referencing energy_contract_version.id."""
|
||||
inspector = inspect(energy_db)
|
||||
fks = inspector.get_foreign_keys("energy_cost_period")
|
||||
assert len(fks) == 1, f"Expected 1 FK on energy_cost_period, got {len(fks)}"
|
||||
fk = fks[0]
|
||||
# Two FKs: contract_version_id → energy_contract_version.id
|
||||
# meter_id → meter.id
|
||||
fk_by_col = {
|
||||
col: fk
|
||||
for fk in fks
|
||||
for col in fk["constrained_columns"]
|
||||
}
|
||||
assert "contract_version_id" in fk_by_col, (
|
||||
"energy_cost_period must have a FK on contract_version_id"
|
||||
)
|
||||
fk = fk_by_col["contract_version_id"]
|
||||
assert fk["referred_table"] == "energy_contract_version"
|
||||
assert "contract_version_id" in fk["constrained_columns"]
|
||||
assert "id" in fk["referred_columns"]
|
||||
|
||||
|
||||
def test_energy_cost_period_fk_to_meter(energy_db):
|
||||
"""energy_cost_period.meter_id must have a FK referencing meter.id."""
|
||||
inspector = inspect(energy_db)
|
||||
fks = inspector.get_foreign_keys("energy_cost_period")
|
||||
fk_by_col = {
|
||||
col: fk
|
||||
for fk in fks
|
||||
for col in fk["constrained_columns"]
|
||||
}
|
||||
assert "meter_id" in fk_by_col, (
|
||||
"energy_cost_period must have a FK on meter_id"
|
||||
)
|
||||
fk = fk_by_col["meter_id"]
|
||||
assert fk["referred_table"] == "meter"
|
||||
assert "id" in fk["referred_columns"]
|
||||
|
||||
|
||||
@@ -308,8 +337,9 @@ def test_dsmr_decouple_migration_downgrade_restores_source_id_unique(tmp_path: P
|
||||
|
||||
|
||||
def test_base_metadata_contains_energy_tables():
|
||||
"""Base.metadata.tables must include all five new energy tables."""
|
||||
"""Base.metadata.tables must include all six energy tables."""
|
||||
expected = {
|
||||
"meter",
|
||||
"dsmr_reading",
|
||||
"energy_contract",
|
||||
"energy_contract_version",
|
||||
@@ -366,6 +396,17 @@ def test_energy_cost_period_fk_ondelete_restrict():
|
||||
)
|
||||
|
||||
|
||||
def test_energy_cost_period_meter_id_fk_ondelete_restrict():
|
||||
"""The FK from energy_cost_period.meter_id must be ON DELETE RESTRICT."""
|
||||
table = Base.metadata.tables["energy_cost_period"]
|
||||
col = table.columns["meter_id"]
|
||||
assert col.foreign_keys, "meter_id must have a foreign key"
|
||||
fk = next(iter(col.foreign_keys))
|
||||
assert fk.ondelete == "RESTRICT", (
|
||||
f"meter_id FK ondelete must be RESTRICT, got: {fk.ondelete!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_dsmr_reading_recorded_at_unique_in_metadata():
|
||||
"""DsmrReading.recorded_at must be the unique de-dup key in ORM metadata,
|
||||
and source_id must NOT be unique (decoupled from the telegram id)."""
|
||||
@@ -739,3 +780,396 @@ def test_cost_period_restrict_prevents_version_deletion(tmp_path: Path):
|
||||
session.commit()
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Meter model tests (M7-T01)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_meter_table_exists_after_upgrade(energy_db):
|
||||
"""meter table must exist after upgrade to head."""
|
||||
inspector = inspect(energy_db)
|
||||
assert "meter" in inspector.get_table_names(), "meter table missing after upgrade to head"
|
||||
|
||||
|
||||
def test_meter_columns(energy_db):
|
||||
"""meter must have all required columns with correct nullability."""
|
||||
inspector = inspect(energy_db)
|
||||
columns = {col["name"]: col for col in inspector.get_columns("meter")}
|
||||
|
||||
non_nullable = {"id", "label", "commodity", "started_at", "reason", "created_at"}
|
||||
nullable = {"ended_at", "note"}
|
||||
|
||||
for col_name in 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"
|
||||
|
||||
for col_name in nullable:
|
||||
assert col_name in columns, f"Missing column: {col_name}"
|
||||
assert columns[col_name]["nullable"], f"{col_name} should be nullable"
|
||||
|
||||
|
||||
def test_meter_orm_metadata():
|
||||
"""Meter must be registered in Base.metadata with correct field types."""
|
||||
assert "meter" in Base.metadata.tables, "meter not in Base.metadata"
|
||||
table = Base.metadata.tables["meter"]
|
||||
assert "id" in table.columns
|
||||
assert "label" in table.columns
|
||||
assert "commodity" in table.columns
|
||||
assert "started_at" in table.columns
|
||||
assert "ended_at" in table.columns
|
||||
assert "reason" in table.columns
|
||||
assert "note" in table.columns
|
||||
assert "created_at" in table.columns
|
||||
|
||||
|
||||
def test_meter_insert_and_retrieve(energy_db):
|
||||
"""A Meter row can be inserted and retrieved with all fields intact."""
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
with Session(energy_db) as session:
|
||||
m = Meter(
|
||||
label="Test meter @ Dorpsstraat 1",
|
||||
commodity="electricity",
|
||||
started_at=now,
|
||||
ended_at=None,
|
||||
reason="initial",
|
||||
note="2G smart meter",
|
||||
created_at=now,
|
||||
)
|
||||
session.add(m)
|
||||
session.commit()
|
||||
meter_id = m.id
|
||||
|
||||
with Session(energy_db) as session:
|
||||
fetched = session.get(Meter, meter_id)
|
||||
assert fetched is not None
|
||||
assert fetched.label == "Test meter @ Dorpsstraat 1"
|
||||
assert fetched.commodity == "electricity"
|
||||
assert fetched.reason == "initial"
|
||||
assert fetched.note == "2G smart meter"
|
||||
assert fetched.ended_at is None
|
||||
|
||||
|
||||
def test_meter_ended_at_nullable(energy_db):
|
||||
"""Meter.ended_at and Meter.note can both be None (active/ongoing meter)."""
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
with Session(energy_db) as session:
|
||||
m = Meter(
|
||||
label="Active meter",
|
||||
commodity="electricity",
|
||||
started_at=now,
|
||||
ended_at=None,
|
||||
reason="home_move",
|
||||
note=None,
|
||||
created_at=now,
|
||||
)
|
||||
session.add(m)
|
||||
session.commit()
|
||||
meter_id = m.id
|
||||
|
||||
with Session(energy_db) as session:
|
||||
fetched = session.get(Meter, meter_id)
|
||||
assert fetched is not None
|
||||
assert fetched.ended_at is None
|
||||
assert fetched.note is None
|
||||
|
||||
|
||||
def test_energy_cost_period_meter_id_nullable(energy_db):
|
||||
"""EnergyCostPeriod.meter_id must be nullable (no meter FK required)."""
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
with Session(energy_db) as session:
|
||||
period = EnergyCostPeriod(
|
||||
period_start=now,
|
||||
d1_kwh=0.5,
|
||||
d2_kwh=1.2,
|
||||
r1_kwh=0.0,
|
||||
r2_kwh=0.0,
|
||||
import_cost=0.225,
|
||||
export_revenue=0.0,
|
||||
net_cost=0.225,
|
||||
currency="EUR",
|
||||
pricing={"kind": "manual"},
|
||||
contract_version_id=None,
|
||||
meter_id=None,
|
||||
degraded=False,
|
||||
computed_at=now,
|
||||
)
|
||||
session.add(period)
|
||||
session.commit()
|
||||
period_id = period.id
|
||||
|
||||
with Session(energy_db) as session:
|
||||
fetched = session.get(EnergyCostPeriod, period_id)
|
||||
assert fetched is not None
|
||||
assert fetched.meter_id is None
|
||||
|
||||
|
||||
def test_energy_cost_period_meter_id_fk_enforced(tmp_path: Path):
|
||||
"""Inserting an EnergyCostPeriod with a non-existent meter_id must fail (RESTRICT FK)."""
|
||||
db_path = tmp_path / "meter_fk_test.db"
|
||||
db_url = f"sqlite:///{db_path}"
|
||||
alembic_cfg = _make_app_alembic_config(db_url)
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
engine = _engine_with_fk(db_url)
|
||||
|
||||
try:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
with pytest.raises(sqlalchemy.exc.IntegrityError):
|
||||
with Session(engine) as session:
|
||||
period = EnergyCostPeriod(
|
||||
period_start=now,
|
||||
d1_kwh=0.0,
|
||||
d2_kwh=0.0,
|
||||
r1_kwh=0.0,
|
||||
r2_kwh=0.0,
|
||||
import_cost=0.0,
|
||||
export_revenue=0.0,
|
||||
net_cost=0.0,
|
||||
currency="EUR",
|
||||
pricing={},
|
||||
contract_version_id=None,
|
||||
meter_id=9999, # non-existent meter
|
||||
degraded=False,
|
||||
computed_at=now,
|
||||
)
|
||||
session.add(period)
|
||||
session.commit()
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_meter_restrict_prevents_deletion_with_cost_periods(tmp_path: Path):
|
||||
"""Deleting a Meter that has cost periods attributed to it must fail (ON DELETE RESTRICT)."""
|
||||
db_path = tmp_path / "meter_restrict_test.db"
|
||||
db_url = f"sqlite:///{db_path}"
|
||||
alembic_cfg = _make_app_alembic_config(db_url)
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
engine = _engine_with_fk(db_url)
|
||||
|
||||
try:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
with Session(engine) as session:
|
||||
m = Meter(
|
||||
label="RESTRICT Test Meter",
|
||||
commodity="electricity",
|
||||
started_at=now,
|
||||
ended_at=None,
|
||||
reason="initial",
|
||||
note=None,
|
||||
created_at=now,
|
||||
)
|
||||
session.add(m)
|
||||
session.flush()
|
||||
|
||||
period = EnergyCostPeriod(
|
||||
period_start=now,
|
||||
d1_kwh=0.1,
|
||||
d2_kwh=0.2,
|
||||
r1_kwh=0.0,
|
||||
r2_kwh=0.0,
|
||||
import_cost=0.05,
|
||||
export_revenue=0.0,
|
||||
net_cost=0.05,
|
||||
currency="EUR",
|
||||
pricing={"kind": "manual"},
|
||||
contract_version_id=None,
|
||||
meter_id=m.id,
|
||||
degraded=False,
|
||||
computed_at=now,
|
||||
)
|
||||
session.add(period)
|
||||
session.commit()
|
||||
meter_id = m.id
|
||||
|
||||
with pytest.raises(sqlalchemy.exc.IntegrityError):
|
||||
with Session(engine) as session:
|
||||
session.execute(
|
||||
text("DELETE FROM meter WHERE id = :mid"),
|
||||
{"mid": meter_id},
|
||||
)
|
||||
session.commit()
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Migration backfill tests (M7-T01)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_migration_empty_db_no_initial_meter(tmp_path: Path):
|
||||
"""Upgrading an empty database must not create any meter rows."""
|
||||
db_path = tmp_path / "empty_backfill_test.db"
|
||||
db_url = f"sqlite:///{db_path}"
|
||||
alembic_cfg = _make_app_alembic_config(db_url)
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
with Session(engine) as session:
|
||||
count = session.execute(text("SELECT COUNT(*) FROM meter")).scalar()
|
||||
assert count == 0, f"Expected 0 meter rows in empty DB after upgrade, got {count}"
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_migration_backfill_creates_initial_meter_from_readings(tmp_path: Path):
|
||||
"""Upgrading with existing dsmr_reading rows must create exactly one initial meter
|
||||
and back-fill all energy_cost_period rows with its id."""
|
||||
from datetime import timedelta
|
||||
|
||||
db_path = tmp_path / "backfill_readings_test.db"
|
||||
db_url = f"sqlite:///{db_path}"
|
||||
alembic_cfg = _make_app_alembic_config(db_url)
|
||||
|
||||
# Upgrade only to the revision just before M7-T01 (i.e. pre-meter).
|
||||
command.upgrade(alembic_cfg, "20260624_12_dsmr_decouple_telegram_id")
|
||||
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
t0 = now - timedelta(hours=2)
|
||||
t1 = now - timedelta(hours=1)
|
||||
|
||||
with Session(engine) as session:
|
||||
# Insert two dsmr_reading rows.
|
||||
session.execute(
|
||||
text(
|
||||
"INSERT INTO dsmr_reading (recorded_at, source_id, payload) "
|
||||
"VALUES (:ts, NULL, '{}')"
|
||||
),
|
||||
{"ts": t0.strftime("%Y-%m-%dT%H:%M:%S")},
|
||||
)
|
||||
session.execute(
|
||||
text(
|
||||
"INSERT INTO dsmr_reading (recorded_at, source_id, payload) "
|
||||
"VALUES (:ts, NULL, '{}')"
|
||||
),
|
||||
{"ts": t1.strftime("%Y-%m-%dT%H:%M:%S")},
|
||||
)
|
||||
# Insert two energy_cost_period rows (no meter_id column yet at this revision).
|
||||
session.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) "
|
||||
"VALUES (:ps, 0.1, 0.2, 0.0, 0.0, 0.05, 0.0, 0.05, 'EUR', '{}', "
|
||||
"NULL, 0, :now)"
|
||||
),
|
||||
{"ps": t0.strftime("%Y-%m-%dT%H:%M:%S"), "now": now.strftime("%Y-%m-%dT%H:%M:%S")},
|
||||
)
|
||||
session.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) "
|
||||
"VALUES (:ps, 0.1, 0.2, 0.0, 0.0, 0.05, 0.0, 0.05, 'EUR', '{}', "
|
||||
"NULL, 0, :now)"
|
||||
),
|
||||
{"ps": t1.strftime("%Y-%m-%dT%H:%M:%S"), "now": now.strftime("%Y-%m-%dT%H:%M:%S")},
|
||||
)
|
||||
session.commit()
|
||||
engine.dispose()
|
||||
|
||||
# Now upgrade to head (applies M7-T01 migration with backfill).
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
with Session(engine) as session:
|
||||
# Exactly one initial meter must exist.
|
||||
meter_count = session.execute(
|
||||
text("SELECT COUNT(*) FROM meter WHERE reason = 'initial' AND ended_at IS NULL")
|
||||
).scalar()
|
||||
assert meter_count == 1, f"Expected 1 initial meter, got {meter_count}"
|
||||
|
||||
# All non-degraded energy_cost_period rows must have meter_id set.
|
||||
null_count = session.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM energy_cost_period "
|
||||
"WHERE meter_id IS NULL AND degraded = 0"
|
||||
)
|
||||
).scalar()
|
||||
assert null_count == 0, (
|
||||
f"Expected 0 non-degraded periods with NULL meter_id, got {null_count}"
|
||||
)
|
||||
|
||||
# The initial meter started_at must match the earliest dsmr_reading.recorded_at.
|
||||
meter_row = session.execute(
|
||||
text("SELECT started_at FROM meter WHERE reason = 'initial' LIMIT 1")
|
||||
).fetchone()
|
||||
assert meter_row is not None
|
||||
meter_started = meter_row[0]
|
||||
# Both meter_started and t0 are now UTC strings / datetimes — just verify
|
||||
# the date portion matches (second-level precision is sufficient).
|
||||
assert t0.strftime("%Y-%m-%dT%H:%M:%S") in str(meter_started), (
|
||||
f"initial meter started_at {meter_started!r} should match earliest reading {t0}"
|
||||
)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_migration_backfill_idempotent(tmp_path: Path):
|
||||
"""Running upgrade head twice must not create duplicate initial meters or corrupt data."""
|
||||
db_path = tmp_path / "idempotent_test.db"
|
||||
db_url = f"sqlite:///{db_path}"
|
||||
alembic_cfg = _make_app_alembic_config(db_url)
|
||||
|
||||
# Upgrade to pre-meter revision and insert a reading.
|
||||
command.upgrade(alembic_cfg, "20260624_12_dsmr_decouple_telegram_id")
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
with Session(engine) as session:
|
||||
session.execute(
|
||||
text(
|
||||
"INSERT INTO dsmr_reading (recorded_at, source_id, payload) "
|
||||
"VALUES (:ts, NULL, '{}')"
|
||||
),
|
||||
{"ts": now.strftime("%Y-%m-%dT%H:%M:%S")},
|
||||
)
|
||||
session.commit()
|
||||
engine.dispose()
|
||||
|
||||
# First upgrade to head.
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
|
||||
# Downgrade and re-upgrade to simulate idempotency (tests the guard condition).
|
||||
command.downgrade(alembic_cfg, "20260624_12_dsmr_decouple_telegram_id")
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
with Session(engine) as session:
|
||||
meter_count = session.execute(
|
||||
text("SELECT COUNT(*) FROM meter WHERE reason = 'initial' AND ended_at IS NULL")
|
||||
).scalar()
|
||||
assert meter_count == 1, (
|
||||
f"Expected exactly 1 initial meter after repeated upgrade, got {meter_count}"
|
||||
)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_migration_downgrade_removes_meter_table(tmp_path: Path):
|
||||
"""Downgrading from M7-T01 must remove the meter table and the meter_id column."""
|
||||
db_path = tmp_path / "downgrade_meter_test.db"
|
||||
db_url = f"sqlite:///{db_path}"
|
||||
alembic_cfg = _make_app_alembic_config(db_url)
|
||||
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
inspector = inspect(engine)
|
||||
assert "meter" in inspector.get_table_names(), "meter must exist before downgrade"
|
||||
ecp_cols = {col["name"] for col in inspector.get_columns("energy_cost_period")}
|
||||
assert "meter_id" in ecp_cols, "meter_id must exist in energy_cost_period before downgrade"
|
||||
engine.dispose()
|
||||
|
||||
# Downgrade one step (removes the meter table + meter_id column).
|
||||
command.downgrade(alembic_cfg, "20260624_12_dsmr_decouple_telegram_id")
|
||||
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
inspector = inspect(engine)
|
||||
assert "meter" not in inspector.get_table_names(), "meter must be gone after downgrade"
|
||||
ecp_cols_after = {col["name"] for col in inspector.get_columns("energy_cost_period")}
|
||||
assert "meter_id" not in ecp_cols_after, (
|
||||
"meter_id must be removed from energy_cost_period after downgrade"
|
||||
)
|
||||
engine.dispose()
|
||||
|
||||
Reference in New Issue
Block a user