#![cfg(feature = "testkit")]
use meterstore::config::{TableConfig, TimeModel, eic_column};
use meterstore::encode::{StoredSeries, to_record_batch_with};
use meterstore::hot::PostgresHot;
use meterstore::tiering::store::HotStore;
use meterstore::{ScopedVersion, Version, VersionScope};
use datafusion::common::ScalarValue;
use metering::interval::{MeterInterval, QualityFlag, Sparte};
use metering::measurement_series::{MeasurementSeries, MeasurementSource};
use sqlx::PgPool;
use time::macros::datetime;
use time::{Duration, OffsetDateTime};
const TABLE: &str = "readings_versions";
const COLUMN: &str = "bilanzierungsgebiet";
const D20: OffsetDateTime = datetime!(2026-07-20 00:00 UTC);
const D21: OffsetDateTime = datetime!(2026-07-21 00:00 UTC);
const VALID: &str = "11YN000000000016";
const WRONG_CHECK_CHARACTER: &str = "11YN000000000017";
async fn hot_with_a_checked_column() -> (PostgresHot, TableConfig) {
let url = meterstore::testkit::postgres::fresh_database()
.await
.expect("postgres");
let pool = PgPool::connect(&url).await.expect("connect");
let hot = PostgresHot::new(pool);
let declared = TableConfig::new(TABLE).attribute_column(eic_column(COLUMN, true));
let config = declared.clone().build().expect("config");
hot.create_tables(
TABLE,
&config.merge_key(),
&config.extra_columns(),
TimeModel::Interval,
)
.await
.expect("hot table");
hot.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.expect("partitions");
(hot, declared)
}
fn delivery(bilanzierungsgebiet: Option<&str>) -> StoredSeries {
let obis = "1-0:1.8.0".parse().ok();
let series = MeasurementSeries::new(
"12345678905".parse().expect("MaLo-ID"),
obis,
vec![MeterInterval {
from: D20,
to: D20 + Duration::minutes(15),
value: "1.5".parse().expect("decimal"),
quality: QualityFlag::Measured,
obis_code: obis,
}],
MeasurementSource::Mscons {
pid: 13_005,
message_ref: None,
sender_mp_id: "9900000000001".parse().expect("Marktpartner-ID"),
},
datetime!(2026-07-27 06:00 UTC),
);
let stored = StoredSeries::new(
series,
ScopedVersion::new(
VersionScope::for_interval("9900000000001", D20, Sparte::Strom).expect("scope"),
Version::new(20_260_727_000_001).expect("version"),
),
datetime!(2026-07-27 06:00 UTC),
);
match bilanzierungsgebiet {
None => stored,
Some(code) => stored.with_extra(COLUMN, ScalarValue::Utf8(Some(code.to_string()))),
}
}
#[tokio::test]
async fn the_write_path_canonicalises_and_the_database_keeps_the_canonical_form() {
let (hot, declared) = hot_with_a_checked_column().await;
let config = declared.build().expect("config");
let batch = to_record_batch_with(
&[delivery(Some(" 11yn000000000016 "))],
&config.extra_columns(),
)
.expect("a valid EIC, however it was typed");
hot.append(TABLE, &config.merge_key(), &[batch])
.await
.expect("append");
let stored: Option<String> =
sqlx::query_scalar(&format!(r#"SELECT "{COLUMN}" FROM "{TABLE}""#))
.fetch_one(hot.pool())
.await
.expect("read back");
assert_eq!(
stored.as_deref(),
Some(VALID),
"the stored value is the domain type's canonical spelling, so a column \
in the merge key cannot hold one identifier under two keys"
);
}
#[tokio::test]
async fn a_wrong_check_character_never_reaches_the_database() {
let (_hot, declared) = hot_with_a_checked_column().await;
let config = declared.build().expect("config");
let err = to_record_batch_with(
&[delivery(Some(WRONG_CHECK_CHARACTER))],
&config.extra_columns(),
)
.expect_err("the check character is part of the code")
.to_string();
assert!(err.contains(COLUMN), "{err}");
assert!(err.contains("EIC"), "{err}");
}
#[tokio::test]
async fn the_database_carries_the_shape_and_says_so_in_its_own_regex_engine() {
let (hot, _) = hot_with_a_checked_column().await;
let definition: String = sqlx::query_scalar(
"SELECT pg_get_constraintdef(c.oid)
FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid
WHERE t.relname = $1 AND c.conname = $2",
)
.bind(TABLE)
.bind(format!("{COLUMN}_shape"))
.fetch_one(hot.pool())
.await
.expect("the checked column declares a shape constraint");
assert!(definition.contains("~"), "{definition}");
for (code, want) in [
(VALID, true),
(WRONG_CHECK_CHARACTER, true),
("10X---ENTSOE---L", true),
("11YN00000000001", false), ("11yn000000000016", false), ("11-N000000000016", false), ("11YN00000000001-", false), ("11YN0000000000!6", false),
] {
let matched: bool = sqlx::query_scalar(&format!(
"SELECT $1 {}",
definition
.trim_start_matches("CHECK ((")
.trim_end_matches("))")
.split_once('~')
.map(|(_, pattern)| format!("~{pattern}"))
.expect("the constraint is a regex match"),
))
.bind(code)
.fetch_one(hot.pool())
.await
.expect("evaluate the deployed pattern");
assert_eq!(matched, want, "{code}");
}
}
#[tokio::test]
async fn the_database_refuses_a_badly_shaped_value_from_a_writer_that_is_not_this_crate() {
let (hot, _) = hot_with_a_checked_column().await;
let insert = |code: &'static str| {
let pool = hot.pool().clone();
async move {
sqlx::query(&format!(
r#"INSERT INTO "{TABLE}"
(malo_id, obis_code, sparte, "from", "to", value, unit, quality,
source_kind, source_detail, version, version_scope, recorded_at,
balancing_day, "{COLUMN}")
VALUES ('12345678905','1-0:1.8.0','STROM',$1,$2,1.5,'KWH','MEASURED',
'MSCONS','{{}}',$3,'9900000000001:2026-07',$4,DATE '2026-07-20',$5)"#
))
.bind(D20)
.bind(D20 + Duration::minutes(15))
.bind(rust_decimal::Decimal::new(20_260_727_000_001, 0))
.bind(datetime!(2026-07-27 06:00 UTC))
.bind(code)
.execute(&pool)
.await
}
};
assert!(
insert("not-an-eic").await.is_err(),
"a badly shaped code must fail at the DB layer"
);
insert(WRONG_CHECK_CHARACTER)
.await
.expect("the shape check cannot see the check character");
}