#![cfg(feature = "testkit")]
use metering::interval::{MeterInterval, Sparte};
use metering::measurement_series::{MeasurementSeries, MeasurementSource};
use meterstore::Settings;
use meterstore::testkit::TestHarness;
use time::macros::datetime;
use time::{Duration, OffsetDateTime};
const START: OffsetDateTime = datetime!(2026-07-20 00:00 UTC);
const MALO: &str = "12345678905";
fn file(url: &str, warehouse: &std::path::Path) -> String {
format!(
r#"
[hot]
url = "{url}"
max_connections = 4
[cold]
catalog = "sql"
uri = "{url}"
warehouse = "file://{}"
namespace = "metering"
file_target_bytes = 8388608
metadata_pool_max_connections = 2
[[tables]]
name = "readings_versions"
[tables.archival]
settlement_lag = "1d"
archival_step = "1d"
"#,
warehouse.display(),
)
}
#[tokio::test]
async fn a_configuration_file_builds_a_working_store() {
let url = meterstore::testkit::postgres::fresh_database()
.await
.expect("postgres");
let warehouse = tempfile::tempdir().expect("temp warehouse");
let settings = Settings::from_toml(&file(&url, warehouse.path())).expect("parse");
let deployment = settings.connect().await.expect("connect");
assert_eq!(deployment.tables.len(), 1);
let config = deployment.tables[0].clone();
assert_eq!(config.name(), TestHarness::TABLE);
assert_eq!(config.settlement_lag(), Duration::DAY);
let store = deployment.store().await.expect("store");
let intervals = vec![MeterInterval {
from: START,
to: START + Duration::minutes(15),
value: rust_decimal::Decimal::new(42, 0),
quality: metering::QualityFlag::Measured,
obis_code: "1-0:1.8.0".parse().ok(),
}];
let series = meterstore::encode::StoredSeries::new(
MeasurementSeries::new(
MALO.parse().expect("a valid MaLo-ID"),
"1-0:1.8.0".parse().ok(),
intervals,
MeasurementSource::Mscons {
pid: 13_005,
message_ref: None,
sender_mp_id: "9900000000001".parse().expect("a valid Marktpartner-ID"),
},
START,
),
meterstore::ScopedVersion::new(
meterstore::VersionScope::for_interval("9900000000001", START, Sparte::Strom)
.expect("scope"),
meterstore::Version::new(20_260_720_000_001).expect("version"),
),
START,
);
store.append(&[series]).await.expect("append");
let back = store
.series(MALO)
.expect("malo")
.range(START, START + Duration::DAY)
.intervals()
.await
.expect("read");
assert_eq!(back.len(), 1);
assert_eq!(back[0].value, rust_decimal::Decimal::new(42, 0));
assert!(!deployment.pool.is_closed());
}
#[tokio::test]
async fn a_file_naming_an_unopenable_warehouse_fails_at_validation() {
let url = meterstore::testkit::postgres::fresh_database()
.await
.expect("postgres");
let warehouse = tempfile::tempdir().expect("temp warehouse");
let mut settings = Settings::from_toml(&file(&url, warehouse.path())).expect("parse");
"ftp://host/warehouse".clone_into(&mut settings.cold.warehouse);
let err = settings
.connect()
.await
.expect_err("an unsupported scheme must not build")
.to_string();
assert!(err.contains("ftp"), "{err}");
settings.validate().expect("the tables are complete");
}
#[tokio::test]
async fn a_multi_table_file_builds_a_catalog_and_refuses_a_single_store() {
let url = meterstore::testkit::postgres::fresh_database()
.await
.expect("postgres");
let warehouse = tempfile::tempdir().expect("temp warehouse");
let text = format!(
"{}\n[[tables]]\nname = \"esa_typ2_versions\"\n\n[tables.archival]\nsettlement_lag = \"1d\"\narchival_step = \"1d\"\n",
file(&url, warehouse.path()),
);
let deployment = Settings::from_toml(&text)
.expect("parse")
.connect()
.await
.expect("connect");
let err = deployment
.store()
.await
.expect_err("two tables, so there is no single store")
.to_string();
assert!(err.contains("readings_versions"), "{err}");
assert!(err.contains("esa_typ2_versions"), "{err}");
assert!(err.contains("catalog"), "{err}");
let catalog = deployment.catalog().await.expect("catalog");
let mut names: Vec<&str> = catalog.tables().map(|t| t.table()).collect();
names.sort_unstable();
assert_eq!(names, ["esa_typ2_versions", "readings_versions"]);
catalog
.query("SELECT count(*) FROM readings")
.await
.expect("the resolved relation is registered");
}
#[tokio::test]
async fn a_declared_identity_column_reaches_both_tiers() {
let url = meterstore::testkit::postgres::fresh_database()
.await
.expect("postgres");
let warehouse = tempfile::tempdir().expect("temp warehouse");
let text = format!(
r#"
[hot]
url = "{url}"
max_connections = 4
[cold]
catalog = "sql"
uri = "{url}"
warehouse = "file://{}"
namespace = "metering"
file_target_bytes = 8388608
metadata_pool_max_connections = 2
[[tables]]
name = "readings_versions"
extra_columns = [
{{ name = "tenant", identity = true }},
{{ name = "bilanzkreis" }},
]
[tables.archival]
settlement_lag = "1d"
archival_step = "1d"
"#,
warehouse.path().display(),
);
let deployment = Settings::from_toml(&text)
.expect("parse")
.connect()
.await
.expect("connect");
let store = deployment
.store()
.await
.expect("the cold table must be created with the declared identity columns");
assert!(
store.config().merge_key().iter().any(|c| c == "tenant"),
"{:?}",
store.config().merge_key()
);
deployment.store().await.expect("idempotent");
}
#[tokio::test]
async fn a_subject_column_in_a_file_reaches_a_registry_that_can_erase() {
let url = meterstore::testkit::postgres::fresh_database()
.await
.expect("postgres");
let warehouse = tempfile::tempdir().expect("temp warehouse");
let text = format!(
r#"
[hot]
url = "{url}"
max_connections = 4
[cold]
catalog = "sql"
uri = "{url}"
warehouse = "file://{}"
namespace = "metering"
file_target_bytes = 8388608
metadata_pool_max_connections = 2
[privacy]
erasure_secret = "0123456789abcdef0123456789abcdef"
[[tables]]
name = "readings_versions"
subject_column = "subject_ref"
[tables.archival]
settlement_lag = "1d"
archival_step = "1d"
"#,
warehouse.path().display(),
);
let deployment = Settings::from_toml(&text)
.expect("parse")
.connect()
.await
.expect("connect");
assert!(
deployment.registry.is_some(),
"a declared subject column builds the registry it resolves against"
);
let store = deployment.store().await.expect("store");
let registry = store
.subject_registry()
.expect("the store carries the deployment's registry");
assert!(
registry.suppresses_reregistration(),
"the file supplied a key, so the suppression list is on"
);
let subject = store
.register_subject(
"tenant-a:12345678905",
START,
metering::interval::Sparte::Strom,
)
.await
.expect("register");
store
.erase_subject(&subject, "DSAR-2026-0042", "dpo", START)
.await
.expect("erase");
let trail = registry
.erasures(&meterstore::ErasureQuery::new().limit(10))
.await
.expect("trail");
assert_eq!(trail.len(), 1);
assert_eq!(trail[0].reason, "DSAR-2026-0042");
assert_eq!(trail[0].actor, "dpo");
assert_eq!(trail[0].subject, Some(subject));
let err = store
.register_subject(
"tenant-a:12345678905",
START,
metering::interval::Sparte::Strom,
)
.await
.expect_err("suppressed");
assert!(err.to_string().contains("erased"), "{err}");
}
#[tokio::test]
async fn a_deployment_with_no_subject_column_builds_no_registry() {
let url = meterstore::testkit::postgres::fresh_database()
.await
.expect("postgres");
let warehouse = tempfile::tempdir().expect("temp warehouse");
let deployment = Settings::from_toml(&file(&url, warehouse.path()))
.expect("parse")
.connect()
.await
.expect("connect");
assert!(deployment.registry.is_none());
assert!(
deployment
.store()
.await
.expect("store")
.subject_registry()
.is_none()
);
}