466 lines
20 KiB
Python
466 lines
20 KiB
Python
"""Migration and ORM contracts for the commodity-scoped meter cost ledger."""
|
|||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from datetime import datetime, timedelta, timezone
|
||
|
|
from decimal import Decimal
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from alembic import command
|
||
|
|
from alembic.config import Config
|
||
|
|
from sqlalchemy import create_engine, event as sa_event, insert, inspect, select, text
|
||
|
|
from sqlalchemy.exc import IntegrityError, StatementError
|
||
|
|
from sqlalchemy.orm import Session
|
||
|
|
|
||
|
|
from app.models.energy import MeterCostPeriod
|
||
|
|
from scripts.app_db_adopt import APP_BASELINE_REVISION
|
||
|
|
|
||
|
|
|
||
|
|
REVISION_18 = "20260822_18_contract_scopes"
|
||
|
|
REVISION_19 = "20260822_19_meter_cost_periods"
|
||
|
|
UTC = timezone.utc
|
||
|
|
|
||
|
|
|
||
|
|
def _config(database_url: str) -> Config:
|
||
|
|
config = Config("alembic_app.ini")
|
||
|
|
config.set_main_option("sqlalchemy.url", database_url)
|
||
|
|
return config
|
||
|
|
|
||
|
|
|
||
|
|
def _fk_engine(database_url: str):
|
||
|
|
engine = create_engine(database_url)
|
||
|
|
|
||
|
|
@sa_event.listens_for(engine, "connect")
|
||
|
|
def _enable_foreign_keys(connection, _record) -> None:
|
||
|
|
connection.execute("PRAGMA foreign_keys = ON")
|
||
|
|
|
||
|
|
return engine
|
||
|
|
|
||
|
|
|
||
|
|
def _period(**overrides) -> MeterCostPeriod:
|
||
|
|
start = datetime(2026, 8, 22, 12, tzinfo=UTC)
|
||
|
|
values = {
|
||
|
|
"commodity": "heating",
|
||
|
|
"period_start": start,
|
||
|
|
"period_end": start + timedelta(minutes=15),
|
||
|
|
"quantity": Decimal("999999999.123456"),
|
||
|
|
"cost": Decimal("999999.123456789"),
|
||
|
|
"currency": "EUR",
|
||
|
|
"cost_breakdown": {"heating": "0.012345678"},
|
||
|
|
"pricing_snapshot": {"heating": "10.000000", "kind": "district_heating"},
|
||
|
|
"quality": "valid",
|
||
|
|
"degraded": True,
|
||
|
|
"degraded_reason": "test_fixture_without_audit_links",
|
||
|
|
"created_at": start,
|
||
|
|
"updated_at": start,
|
||
|
|
}
|
||
|
|
values.update(overrides)
|
||
|
|
return MeterCostPeriod(**values)
|
||
|
|
|
||
|
|
|
||
|
|
def test_meter_cost_period_empty_db_upgrade_shape_and_baseline(tmp_path: Path) -> None:
|
||
|
|
database_url = f"sqlite:///{tmp_path / 'empty.db'}"
|
||
|
|
config = _config(database_url)
|
||
|
|
command.upgrade(config, "head")
|
||
|
|
command.upgrade(config, "head")
|
||
|
|
|
||
|
|
engine = create_engine(database_url)
|
||
|
|
inspector = inspect(engine)
|
||
|
|
columns = {column["name"]: column for column in inspector.get_columns("meter_cost_period")}
|
||
|
|
assert APP_BASELINE_REVISION == REVISION_19
|
||
|
|
assert {"commodity", "period_start", "period_end", "quantity", "cost"} <= columns.keys()
|
||
|
|
assert columns["meter_id"]["nullable"]
|
||
|
|
assert columns["source_binding_id"]["nullable"]
|
||
|
|
assert columns["contract_version_id"]["nullable"]
|
||
|
|
assert {index["name"] for index in inspector.get_indexes("meter_cost_period")} >= {
|
||
|
|
"ix_meter_cost_period_commodity_start",
|
||
|
|
"ix_meter_cost_period_source_binding_id",
|
||
|
|
}
|
||
|
|
assert ("commodity", "period_start") in {
|
||
|
|
tuple(constraint["column_names"])
|
||
|
|
for constraint in inspector.get_unique_constraints("meter_cost_period")
|
||
|
|
}
|
||
|
|
foreign_keys = {
|
||
|
|
foreign_key["constrained_columns"][0]: foreign_key
|
||
|
|
for foreign_key in inspector.get_foreign_keys("meter_cost_period")
|
||
|
|
}
|
||
|
|
for column, table in {
|
||
|
|
"meter_id": "meter",
|
||
|
|
"source_binding_id": "meter_source_binding",
|
||
|
|
"contract_version_id": "energy_contract_version",
|
||
|
|
}.items():
|
||
|
|
assert foreign_keys[column]["referred_table"] == table
|
||
|
|
assert foreign_keys[column]["options"]["ondelete"] == "RESTRICT"
|
||
|
|
engine.dispose()
|
||
|
|
|
||
|
|
|
||
|
|
def test_meter_cost_period_revision_18_upgrade_preserves_electricity_rows(tmp_path: Path) -> None:
|
||
|
|
database_url = f"sqlite:///{tmp_path / 'revision18.db'}"
|
||
|
|
config = _config(database_url)
|
||
|
|
command.upgrade(config, REVISION_18)
|
||
|
|
engine = create_engine(database_url)
|
||
|
|
stamp = datetime(2026, 8, 22, 12)
|
||
|
|
with engine.begin() as connection:
|
||
|
|
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, "
|
||
|
|
"meter_id, source_binding_id, degraded, computed_at) "
|
||
|
|
"VALUES (:stamp, 1, 2, 3, 4, 5, 6, 7, 'EUR', :pricing, NULL, NULL, NULL, 0, :stamp)"
|
||
|
|
),
|
||
|
|
{"stamp": stamp, "pricing": '{"historic":"unchanged"}'},
|
||
|
|
)
|
||
|
|
command.upgrade(config, "head")
|
||
|
|
command.upgrade(config, "head")
|
||
|
|
with engine.connect() as connection:
|
||
|
|
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == REVISION_19
|
||
|
|
assert connection.execute(text("SELECT COUNT(*) FROM energy_cost_period")).scalar_one() == 1
|
||
|
|
assert connection.execute(text("SELECT pricing FROM energy_cost_period")).scalar_one() == (
|
||
|
|
'{"historic":"unchanged"}'
|
||
|
|
)
|
||
|
|
assert connection.execute(text("SELECT COUNT(*) FROM meter_cost_period")).scalar_one() == 0
|
||
|
|
command.downgrade(config, REVISION_18)
|
||
|
|
assert "meter_cost_period" not in inspect(engine).get_table_names()
|
||
|
|
engine.dispose()
|
||
|
|
|
||
|
|
|
||
|
|
def test_meter_cost_period_decimal_json_and_degraded_round_trip(tmp_path: Path) -> None:
|
||
|
|
database_url = f"sqlite:///{tmp_path / 'roundtrip.db'}"
|
||
|
|
command.upgrade(_config(database_url), "head")
|
||
|
|
engine = create_engine(database_url)
|
||
|
|
with Session(engine) as session:
|
||
|
|
normal = _period()
|
||
|
|
degraded = _period(
|
||
|
|
commodity="hot_water",
|
||
|
|
meter_id=None,
|
||
|
|
source_binding_id=None,
|
||
|
|
contract_version_id=None,
|
||
|
|
quality="unverifiable",
|
||
|
|
degraded=True,
|
||
|
|
degraded_reason="missing_binding",
|
||
|
|
quantity=Decimal("0.000000"),
|
||
|
|
cost=Decimal("0.000000000"),
|
||
|
|
cost_breakdown={"hot_water": "0.000000000"},
|
||
|
|
pricing_snapshot={"reason": "missing_binding"},
|
||
|
|
)
|
||
|
|
session.add_all((normal, degraded))
|
||
|
|
session.commit()
|
||
|
|
session.expire_all()
|
||
|
|
stored = session.get(MeterCostPeriod, normal.id)
|
||
|
|
assert stored is not None
|
||
|
|
assert stored.quantity == Decimal("999999999.123456")
|
||
|
|
assert stored.cost == Decimal("999999.123456789")
|
||
|
|
assert stored.cost_breakdown == {"heating": "0.012345678"}
|
||
|
|
assert all(not isinstance(value, float) for value in stored.pricing_snapshot.values())
|
||
|
|
assert session.get(MeterCostPeriod, degraded.id).degraded_reason == "missing_binding"
|
||
|
|
engine.dispose()
|
||
|
|
|
||
|
|
|
||
|
|
def test_meter_cost_period_uses_decimal_text_and_normalises_json_snapshots(tmp_path: Path) -> None:
|
||
|
|
database_url = f"sqlite:///{tmp_path / 'exact.db'}"
|
||
|
|
command.upgrade(_config(database_url), "head")
|
||
|
|
engine = create_engine(database_url)
|
||
|
|
with Session(engine) as session:
|
||
|
|
row = _period(
|
||
|
|
cost_breakdown={"nested": [Decimal("2.000000000")]},
|
||
|
|
pricing_snapshot={"rate": Decimal("2.000000")},
|
||
|
|
)
|
||
|
|
session.add(row)
|
||
|
|
session.commit()
|
||
|
|
session.expire_all()
|
||
|
|
stored = session.get(MeterCostPeriod, row.id)
|
||
|
|
assert stored is not None
|
||
|
|
assert stored.cost_breakdown == {"nested": ["2.000000000"]}
|
||
|
|
assert stored.pricing_snapshot == {"rate": "2.000000"}
|
||
|
|
raw = session.execute(text("SELECT typeof(quantity), typeof(cost) FROM meter_cost_period")).one()
|
||
|
|
assert raw == ("text", "text")
|
||
|
|
with pytest.raises(ValueError, match="numeric JSON"):
|
||
|
|
_period(cost_breakdown={"nested": [1]})
|
||
|
|
with pytest.raises(ValueError, match="numeric JSON"):
|
||
|
|
_period(pricing_snapshot={"nested": [1.25]})
|
||
|
|
engine.dispose()
|
||
|
|
|
||
|
|
|
||
|
|
def test_meter_cost_period_core_json_bind_normalises_decimal_and_rejects_numeric(tmp_path: Path) -> None:
|
||
|
|
database_url = f"sqlite:///{tmp_path / 'core-json.db'}"
|
||
|
|
command.upgrade(_config(database_url), "head")
|
||
|
|
engine = create_engine(database_url)
|
||
|
|
values = {
|
||
|
|
"commodity": "heating",
|
||
|
|
"period_start": datetime(2026, 8, 22, 12, tzinfo=UTC),
|
||
|
|
"period_end": datetime(2026, 8, 22, 12, 15, tzinfo=UTC),
|
||
|
|
"quantity": Decimal("0.000000"),
|
||
|
|
"cost": Decimal("0.000000000"),
|
||
|
|
"currency": "EUR",
|
||
|
|
"cost_breakdown": {"nested": [Decimal("2.000000000")]},
|
||
|
|
"pricing_snapshot": {"rate": Decimal("2.000000")},
|
||
|
|
"quality": "valid",
|
||
|
|
"degraded": True,
|
||
|
|
"degraded_reason": "core fixture",
|
||
|
|
"created_at": datetime(2026, 8, 22, 12, tzinfo=UTC),
|
||
|
|
"updated_at": datetime(2026, 8, 22, 12, tzinfo=UTC),
|
||
|
|
}
|
||
|
|
with engine.begin() as connection:
|
||
|
|
connection.execute(insert(MeterCostPeriod.__table__).values(values))
|
||
|
|
with Session(engine) as session:
|
||
|
|
stored = session.scalar(select(MeterCostPeriod))
|
||
|
|
assert stored is not None
|
||
|
|
assert stored.cost_breakdown == {"nested": ["2.000000000"]}
|
||
|
|
assert stored.pricing_snapshot == {"rate": "2.000000"}
|
||
|
|
for field, numeric_value in (("cost_breakdown", {"nested": [1]}), ("pricing_snapshot", {"rate": 1.25})):
|
||
|
|
with engine.begin() as connection, pytest.raises(StatementError, match="numeric JSON"):
|
||
|
|
connection.execute(
|
||
|
|
insert(MeterCostPeriod.__table__).values(
|
||
|
|
{**values, "commodity": f"invalid-{field}", field: numeric_value}
|
||
|
|
)
|
||
|
|
)
|
||
|
|
engine.dispose()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("field", "value", "message"),
|
||
|
|
[
|
||
|
|
("quantity", Decimal("1000000000.000000"), "precision"),
|
||
|
|
("quantity", Decimal("1.1234567"), "scale"),
|
||
|
|
("cost", Decimal("1000000.000000000"), "precision"),
|
||
|
|
("cost", Decimal("1.1234567899"), "scale"),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_meter_cost_period_rejects_decimal_precision_and_scale_overflow(field, value, message) -> None:
|
||
|
|
with pytest.raises(ValueError, match=message):
|
||
|
|
_period(**{field: value})
|
||
|
|
|
||
|
|
|
||
|
|
def test_meter_cost_period_accepts_signed_decimal_boundaries_without_float_bind(tmp_path: Path) -> None:
|
||
|
|
database_url = f"sqlite:///{tmp_path / 'boundaries.db'}"
|
||
|
|
command.upgrade(_config(database_url), "head")
|
||
|
|
engine = create_engine(database_url)
|
||
|
|
with Session(engine) as session:
|
||
|
|
rows = (
|
||
|
|
_period(quantity=Decimal("999999999.999999"), cost=Decimal("999999.999999999")),
|
||
|
|
_period(
|
||
|
|
commodity="hot_water",
|
||
|
|
quantity=Decimal("-999999999.999999"),
|
||
|
|
cost=Decimal("-999999.999999999"),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
session.add_all(rows)
|
||
|
|
session.commit()
|
||
|
|
session.expire_all()
|
||
|
|
assert session.get(MeterCostPeriod, rows[0].id).quantity == Decimal("999999999.999999")
|
||
|
|
assert session.get(MeterCostPeriod, rows[1].id).cost == Decimal("-999999.999999999")
|
||
|
|
engine.dispose()
|
||
|
|
|
||
|
|
|
||
|
|
def test_meter_cost_period_rejects_duplicate_period_invalid_interval_and_json_float(tmp_path: Path) -> None:
|
||
|
|
database_url = f"sqlite:///{tmp_path / 'constraints.db'}"
|
||
|
|
command.upgrade(_config(database_url), "head")
|
||
|
|
engine = create_engine(database_url)
|
||
|
|
with Session(engine) as session:
|
||
|
|
first = _period()
|
||
|
|
session.add(first)
|
||
|
|
session.commit()
|
||
|
|
session.add(_period(cost=Decimal("1.000000000")))
|
||
|
|
with pytest.raises(IntegrityError):
|
||
|
|
session.commit()
|
||
|
|
session.rollback()
|
||
|
|
session.add(_period(commodity="hot_water", period_end=datetime(2026, 8, 22, 12, tzinfo=UTC)))
|
||
|
|
with pytest.raises(ValueError, match="after period_start"):
|
||
|
|
session.flush()
|
||
|
|
session.rollback()
|
||
|
|
with pytest.raises(ValueError, match="timezone-aware"):
|
||
|
|
_period(
|
||
|
|
commodity="hot_water",
|
||
|
|
period_start=datetime(2026, 8, 22, 12),
|
||
|
|
period_end=datetime(2026, 8, 22, 12, 15, tzinfo=UTC),
|
||
|
|
)
|
||
|
|
with pytest.raises(ValueError, match="decimal strings"):
|
||
|
|
_period(pricing_snapshot={"heating": 1.25})
|
||
|
|
with pytest.raises(ValueError, match="normal meter cost periods"):
|
||
|
|
session.add(_period(commodity="hot_water", degraded=False, degraded_reason=None))
|
||
|
|
session.flush()
|
||
|
|
session.rollback()
|
||
|
|
engine.dispose()
|
||
|
|
|
||
|
|
|
||
|
|
def test_meter_cost_period_uses_utc_instant_idempotency_and_db_constraints(tmp_path: Path) -> None:
|
||
|
|
database_url = f"sqlite:///{tmp_path / 'utc.db'}"
|
||
|
|
command.upgrade(_config(database_url), "head")
|
||
|
|
engine = create_engine(database_url)
|
||
|
|
utc_start = datetime(2026, 8, 22, 12, tzinfo=UTC)
|
||
|
|
offset_start = datetime(2026, 8, 22, 14, tzinfo=timezone(timedelta(hours=2)))
|
||
|
|
with Session(engine) as session:
|
||
|
|
first = _period(period_start=utc_start, period_end=utc_start + timedelta(minutes=15))
|
||
|
|
assert first.period_start == utc_start
|
||
|
|
session.add(first)
|
||
|
|
session.commit()
|
||
|
|
session.add(
|
||
|
|
_period(
|
||
|
|
period_start=offset_start,
|
||
|
|
period_end=offset_start + timedelta(minutes=15),
|
||
|
|
cost=Decimal("1.000000000"),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
with pytest.raises(IntegrityError):
|
||
|
|
session.commit()
|
||
|
|
session.rollback()
|
||
|
|
with engine.begin() as connection:
|
||
|
|
values = {
|
||
|
|
"start": "2026-08-23 00:00:00.000000",
|
||
|
|
"quantity": "0.000000",
|
||
|
|
"cost": "0.000000000",
|
||
|
|
"now": "2026-08-23 00:00:00.000000",
|
||
|
|
}
|
||
|
|
for commodity, end in (("equal", values["start"]), ("reversed", "2026-08-22 23:59:59.000000")):
|
||
|
|
with pytest.raises(IntegrityError):
|
||
|
|
connection.execute(
|
||
|
|
text(
|
||
|
|
"INSERT INTO meter_cost_period "
|
||
|
|
"(commodity, period_start, period_end, quantity, cost, currency, cost_breakdown, "
|
||
|
|
"pricing_snapshot, quality, degraded, degraded_reason, created_at, updated_at) "
|
||
|
|
"VALUES (:commodity, :start, :end, :quantity, :cost, 'EUR', '{}', '{}', 'valid', "
|
||
|
|
"1, 'core test', :now, :now)"
|
||
|
|
),
|
||
|
|
{**values, "commodity": commodity, "end": end},
|
||
|
|
)
|
||
|
|
with pytest.raises(IntegrityError):
|
||
|
|
connection.execute(
|
||
|
|
text(
|
||
|
|
"INSERT INTO meter_cost_period "
|
||
|
|
"(commodity, period_start, period_end, quantity, cost, currency, cost_breakdown, "
|
||
|
|
"pricing_snapshot, quality, degraded, degraded_reason, created_at, updated_at) "
|
||
|
|
"VALUES ('missing-reason', :start, '2026-08-23 00:15:00.000000', :quantity, :cost, "
|
||
|
|
"'EUR', '{}', '{}', 'valid', 1, NULL, :now, :now)"
|
||
|
|
),
|
||
|
|
values,
|
||
|
|
)
|
||
|
|
engine.dispose()
|
||
|
|
|
||
|
|
|
||
|
|
def test_meter_cost_period_core_uses_utc_instant_idempotency_and_reloads_aware(tmp_path: Path) -> None:
|
||
|
|
database_url = f"sqlite:///{tmp_path / 'core-utc.db'}"
|
||
|
|
command.upgrade(_config(database_url), "head")
|
||
|
|
engine = create_engine(database_url)
|
||
|
|
utc_start = datetime(2026, 8, 22, 12, tzinfo=UTC)
|
||
|
|
values = {
|
||
|
|
"commodity": "heating",
|
||
|
|
"period_start": utc_start,
|
||
|
|
"period_end": utc_start + timedelta(minutes=15),
|
||
|
|
"quantity": Decimal("0.000000"),
|
||
|
|
"cost": Decimal("0.000000000"),
|
||
|
|
"currency": "EUR",
|
||
|
|
"cost_breakdown": {},
|
||
|
|
"pricing_snapshot": {},
|
||
|
|
"quality": "valid",
|
||
|
|
"degraded": True,
|
||
|
|
"degraded_reason": "core fixture",
|
||
|
|
"created_at": utc_start,
|
||
|
|
"updated_at": utc_start,
|
||
|
|
}
|
||
|
|
with engine.begin() as connection:
|
||
|
|
row_id = connection.execute(insert(MeterCostPeriod.__table__).values(values)).inserted_primary_key[0]
|
||
|
|
with engine.begin() as connection, pytest.raises(IntegrityError):
|
||
|
|
connection.execute(
|
||
|
|
insert(MeterCostPeriod.__table__).values(
|
||
|
|
{
|
||
|
|
**values,
|
||
|
|
"period_start": datetime(2026, 8, 22, 14, tzinfo=timezone(timedelta(hours=2))),
|
||
|
|
"period_end": datetime(2026, 8, 22, 14, 15, tzinfo=timezone(timedelta(hours=2))),
|
||
|
|
}
|
||
|
|
)
|
||
|
|
)
|
||
|
|
with engine.connect() as connection, pytest.raises(StatementError, match="timezone-aware"):
|
||
|
|
connection.execute(
|
||
|
|
insert(MeterCostPeriod.__table__).values(
|
||
|
|
{**values, "commodity": "naive", "period_start": datetime(2026, 8, 23, 12)}
|
||
|
|
)
|
||
|
|
)
|
||
|
|
with Session(engine) as session:
|
||
|
|
stored = session.get(MeterCostPeriod, row_id)
|
||
|
|
assert stored is not None
|
||
|
|
assert stored.period_start == utc_start
|
||
|
|
assert stored.period_start.tzinfo is not None
|
||
|
|
assert stored.period_start.utcoffset() == timedelta(0)
|
||
|
|
stored.quality = "unverifiable"
|
||
|
|
session.commit()
|
||
|
|
engine.dispose()
|
||
|
|
|
||
|
|
|
||
|
|
def test_meter_cost_period_foreign_keys_restrict_deletion(tmp_path: Path) -> None:
|
||
|
|
database_url = f"sqlite:///{tmp_path / 'foreign_keys.db'}"
|
||
|
|
command.upgrade(_config(database_url), "head")
|
||
|
|
engine = _fk_engine(database_url)
|
||
|
|
timestamp = datetime(2026, 8, 22, 12)
|
||
|
|
with engine.begin() as connection:
|
||
|
|
meter_id = connection.execute(
|
||
|
|
text(
|
||
|
|
"INSERT INTO meter (uuid, label, commodity, started_at, ended_at, reason, note, created_at) "
|
||
|
|
"VALUES ('meter-cost-test', 'Test', 'heating', :timestamp, NULL, 'initial', NULL, :timestamp)"
|
||
|
|
),
|
||
|
|
{"timestamp": timestamp},
|
||
|
|
).lastrowid
|
||
|
|
source_id = connection.execute(
|
||
|
|
text(
|
||
|
|
"INSERT INTO meter_source (uuid, name, kind, enabled, config, status, created_at, updated_at) "
|
||
|
|
"VALUES ('source-cost-test', 'Test', 'warmtelink_serial', 1, '{}', 'online', :timestamp, :timestamp)"
|
||
|
|
),
|
||
|
|
{"timestamp": timestamp},
|
||
|
|
).lastrowid
|
||
|
|
channel_id = connection.execute(
|
||
|
|
text(
|
||
|
|
"INSERT INTO meter_source_channel (uuid, source_id, channel_key, label, unit, created_at, updated_at) "
|
||
|
|
"VALUES ('channel-cost-test', :source_id, 'heating', 'Heating', 'GJ', :timestamp, :timestamp)"
|
||
|
|
),
|
||
|
|
{"source_id": source_id, "timestamp": timestamp},
|
||
|
|
).lastrowid
|
||
|
|
binding_id = connection.execute(
|
||
|
|
text(
|
||
|
|
"INSERT INTO meter_source_binding "
|
||
|
|
"(uuid, meter_id, channel_id, started_at, ended_at, created_at, updated_at) "
|
||
|
|
"VALUES ('binding-cost-test', :meter_id, :channel_id, :timestamp, NULL, :timestamp, :timestamp)"
|
||
|
|
),
|
||
|
|
{"meter_id": meter_id, "channel_id": channel_id, "timestamp": timestamp},
|
||
|
|
).lastrowid
|
||
|
|
contract_id = connection.execute(
|
||
|
|
text(
|
||
|
|
"INSERT INTO energy_contract (name, kind, scope, active, currency, created_at, updated_at) "
|
||
|
|
"VALUES ('Test', 'district_heating', 'thermal', 1, 'EUR', :timestamp, :timestamp)"
|
||
|
|
),
|
||
|
|
{"timestamp": timestamp},
|
||
|
|
).lastrowid
|
||
|
|
version_id = connection.execute(
|
||
|
|
text(
|
||
|
|
"INSERT INTO energy_contract_version "
|
||
|
|
"(contract_id, effective_from, effective_to, \"values\", created_at) "
|
||
|
|
"VALUES (:contract_id, :timestamp, NULL, '{}', :timestamp)"
|
||
|
|
),
|
||
|
|
{"contract_id": contract_id, "timestamp": timestamp},
|
||
|
|
).lastrowid
|
||
|
|
connection.execute(
|
||
|
|
text(
|
||
|
|
"INSERT INTO meter_cost_period "
|
||
|
|
"(commodity, period_start, period_end, meter_id, source_binding_id, contract_version_id, "
|
||
|
|
"quantity, cost, currency, cost_breakdown, pricing_snapshot, quality, degraded, degraded_reason, "
|
||
|
|
"created_at, updated_at) VALUES "
|
||
|
|
"('heating', :timestamp, :period_end, :meter_id, :binding_id, :version_id, "
|
||
|
|
"'0.001000', '0.010000000', 'EUR', '{}', '{}', 'valid', 0, NULL, :timestamp, :timestamp)"
|
||
|
|
),
|
||
|
|
{
|
||
|
|
"timestamp": timestamp,
|
||
|
|
"period_end": timestamp + timedelta(minutes=15),
|
||
|
|
"meter_id": meter_id,
|
||
|
|
"binding_id": binding_id,
|
||
|
|
"version_id": version_id,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
for statement, values in (
|
||
|
|
("DELETE FROM meter WHERE id = :id", {"id": meter_id}),
|
||
|
|
("DELETE FROM meter_source_binding WHERE id = :id", {"id": binding_id}),
|
||
|
|
("DELETE FROM energy_contract_version WHERE id = :id", {"id": version_id}),
|
||
|
|
):
|
||
|
|
with pytest.raises(IntegrityError):
|
||
|
|
connection.execute(text(statement), values)
|
||
|
|
engine.dispose()
|