#![cfg(feature = "testkit")]
use meterstore::config::TableConfig;
use meterstore::testkit::{MeteringWorkload, TestHarness};
use meterstore::{MeterCatalog, ValidatedTableConfig};
use time::macros::datetime;
use time::{Duration, OffsetDateTime};
const START: OffsetDateTime = datetime!(2026-07-20 00:00 UTC);
const SECOND: &str = "esa_typ2_versions";
fn config(name: &str) -> ValidatedTableConfig {
TableConfig::new(name)
.settlement_lag(Duration::days(1))
.build()
.expect("config")
}
async fn two_tables() -> (TestHarness, MeterCatalog) {
let harness = TestHarness::start().await.expect("harness");
let catalog = MeterCatalog::builder()
.table(
harness
.builder_for(config(TestHarness::TABLE))
.await
.expect("primary builder"),
)
.table(
harness
.builder_for(config(SECOND))
.await
.expect("secondary builder"),
)
.build()
.await
.expect("catalog");
catalog.create_tables().await.expect("create both tables");
for store in catalog.tables() {
store
.hot_store()
.ensure_partitions(
store.config().name(),
START,
START + Duration::days(3),
Duration::DAY,
)
.await
.expect("partitions");
harness
.seed_watermark_for(store.config().name(), START, Duration::DAY)
.await
.expect("watermark");
}
let billing = MeteringWorkload::new(START)
.seed(0xB111)
.malo_ids(3)
.days(2);
let esa = MeteringWorkload::new(START)
.seed(0xE5A)
.malo_offset(500)
.malo_ids(1)
.days(2);
catalog
.table(TestHarness::TABLE)
.expect("primary")
.append(&billing.generate().expect("workload"))
.await
.expect("append billing");
catalog
.table(SECOND)
.expect("secondary")
.append(&esa.generate().expect("workload"))
.await
.expect("append esa");
(harness, catalog)
}
fn count(result: &meterstore::QueryResult) -> i64 {
use meterstore::arrow::array::AsArray;
result.batches()[0]
.column(0)
.as_primitive::<meterstore::arrow::datatypes::Int64Type>()
.value(0)
}
#[tokio::test]
async fn one_statement_can_mention_both_tables() {
let (_h, catalog) = two_tables().await;
let joined = catalog
.query(
"SELECT COUNT(*) FROM readings r \
JOIN esa_typ2 e ON r.malo_id = e.malo_id",
)
.await
.expect("a cross-table join must plan and run");
assert_eq!(count(&joined), 0);
let both = catalog
.query(
"SELECT COUNT(*) FROM (\
SELECT malo_id FROM readings UNION ALL SELECT malo_id FROM esa_typ2\
) AS all_rows",
)
.await
.expect("union");
let billing_only = catalog
.query("SELECT COUNT(*) FROM readings")
.await
.expect("billing");
let esa_only = catalog
.query("SELECT COUNT(*) FROM esa_typ2")
.await
.expect("esa");
assert_eq!(count(&both), count(&billing_only) + count(&esa_only));
assert!(count(&esa_only) > 0, "the second table must hold something");
assert_ne!(
count(&billing_only),
count(&esa_only),
"the two tables must differ, or a mix-up would be invisible"
);
}
#[tokio::test]
async fn a_result_carries_every_table_boundary_not_one() {
let (_h, catalog) = two_tables().await;
catalog
.table(TestHarness::TABLE)
.expect("primary")
.archive(START + Duration::days(2), 1)
.await
.expect("archive");
let result = catalog
.query("SELECT COUNT(*) FROM readings")
.await
.expect("query");
let watermarks = result.watermarks();
assert_eq!(watermarks.len(), 2, "one entry per table: {watermarks:?}");
let primary = watermarks
.iter()
.find(|(n, _)| n == TestHarness::TABLE)
.expect("primary boundary");
let secondary = watermarks
.iter()
.find(|(n, _)| n == SECOND)
.expect("secondary boundary");
assert_ne!(
primary.1, secondary.1,
"the fixture must leave the two tables at different boundaries"
);
assert_eq!(result.watermark(), secondary.1.min(primary.1));
}
#[tokio::test]
async fn system_tables_show_every_table() {
let (_h, catalog) = two_tables().await;
catalog
.archive_all(START + Duration::days(2), 4)
.await
.expect("archive");
catalog
.refresh_system_tables(START + Duration::days(3))
.await
.expect("refresh");
for relation in ["tables", "config", "resolution", "snapshots"] {
let result = catalog
.query(&format!(
"SELECT DISTINCT \"table\" FROM system.{relation} ORDER BY 1"
))
.await
.unwrap_or_else(|e| panic!("querying system.{relation}: {e}"));
use meterstore::arrow::array::AsArray;
let mut names = Vec::new();
for batch in result.batches() {
let column = batch.column(0).as_string::<i32>();
for i in 0..batch.num_rows() {
names.push(column.value(i).to_string());
}
}
names.sort();
assert_eq!(
names,
vec![SECOND.to_string(), TestHarness::TABLE.to_string()],
"system.{relation} must attribute its rows to both tables"
);
}
}
#[tokio::test]
async fn each_table_keeps_its_own_watermark_and_archiver() {
let (_h, catalog) = two_tables().await;
let before = catalog
.table(SECOND)
.expect("secondary")
.watermark()
.await
.expect("watermark");
catalog
.table(TestHarness::TABLE)
.expect("primary")
.archive(START + Duration::days(2), 1)
.await
.expect("archive");
let after = catalog
.table(SECOND)
.expect("secondary")
.watermark()
.await
.expect("watermark");
assert_eq!(before, after, "one table's archival is its own");
let primary = catalog
.table(TestHarness::TABLE)
.expect("primary")
.watermark()
.await
.expect("watermark");
assert!(primary > after, "and the archived one did move");
}
#[tokio::test]
async fn archiving_the_whole_catalog_reports_per_table() {
let (_h, catalog) = two_tables().await;
let outcomes = catalog
.archive_all(START + Duration::days(2), 4)
.await
.expect("archive all");
let names: Vec<&str> = outcomes.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(
names,
[SECOND, TestHarness::TABLE],
"name order, both present"
);
for store in catalog.tables() {
assert!(
store.watermark().await.expect("watermark").get() > START,
"{} did not advance",
store.config().name()
);
}
assert!(catalog.verify_invariant().await.expect("check").is_empty());
}
#[tokio::test]
async fn a_duplicate_table_name_is_refused() {
let harness = TestHarness::start().await.expect("harness");
let err = MeterCatalog::builder()
.table(
harness
.builder_for(config(TestHarness::TABLE))
.await
.expect("first"),
)
.table(
harness
.builder_for(config(TestHarness::TABLE))
.await
.expect("second"),
)
.build()
.await
.expect_err("a duplicate name must be refused");
let msg = err.to_string();
assert!(msg.contains("readings_versions"), "{msg}");
assert!(
msg.contains("silently read whichever won"),
"the message names the consequence: {msg}"
);
}
#[tokio::test]
async fn an_empty_catalog_is_refused() {
let err = MeterCatalog::builder()
.build()
.await
.expect_err("an empty catalog answers no query");
assert!(err.to_string().contains("at least one table"));
}
#[tokio::test]
async fn two_tables_whose_registered_names_collide_are_refused() {
let harness = TestHarness::start().await.expect("harness");
let err = MeterCatalog::builder()
.table(
harness
.builder_for(config("readings_versions"))
.await
.expect("first"),
)
.table(
harness
.builder_for(config("readings"))
.await
.expect("second"),
)
.build()
.await
.expect_err("colliding registered names must be refused");
let msg = err.to_string();
assert!(msg.contains("readings"), "{msg}");
assert!(
msg.contains("registers") || msg.contains("register"),
"the message must be about the registered name, not the configured one: {msg}"
);
}
#[tokio::test]
async fn a_catalog_with_mixed_read_modes_is_refused() {
use meterstore::ReadMode;
let harness = TestHarness::start().await.expect("harness");
let err = MeterCatalog::builder()
.table(
harness
.builder_for(config(TestHarness::TABLE))
.await
.expect("first"),
)
.table(
harness
.builder_for(config(SECOND))
.await
.expect("second")
.read_mode(ReadMode::Historical),
)
.build()
.await
.expect_err("mixed read modes must be refused");
let msg = err.to_string();
assert!(msg.contains("read mode"), "{msg}");
assert!(
msg.contains("mutable tier"),
"the message names the risk: {msg}"
);
}
#[tokio::test]
async fn a_cross_table_query_takes_bound_parameters() {
let (_h, catalog) = two_tables().await;
let all = catalog
.query("SELECT COUNT(*) FROM readings")
.await
.expect("unfiltered");
let filtered = catalog
.query_with_params(
"SELECT COUNT(*) FROM readings WHERE malo_id = $1",
vec![datafusion::scalar::ScalarValue::Utf8(Some(
"10000000000".to_string(),
))],
)
.await
.expect("a bound parameter must reach the engine");
assert!(count(&filtered) > 0, "the fixture must contain that meter");
assert!(
count(&filtered) < count(&all),
"and the parameter must actually have filtered"
);
}
#[cfg(feature = "flight")]
#[tokio::test]
async fn a_flight_client_over_a_catalog_store_sees_every_table() {
use arrow_flight::sql::client::FlightSqlServiceClient;
use futures::TryStreamExt;
use meterstore::serve::FlightSqlServer;
use tonic::transport::Channel;
let (_h, catalog) = two_tables().await;
let store = catalog.table(TestHarness::TABLE).expect("primary").clone();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
let (shutdown, rx) = tokio::sync::oneshot::channel::<()>();
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(FlightSqlServer::new(store).into_service())
.serve_with_incoming_shutdown(
tokio_stream::wrappers::TcpListenerStream::new(listener),
async {
let _ = rx.await;
},
)
.await
.expect("flight server");
});
let channel = Channel::from_shared(format!("http://{address}"))
.expect("endpoint")
.connect()
.await
.expect("connect");
let mut client = FlightSqlServiceClient::new(channel);
let info = client
.execute(
"SELECT table_name FROM information_schema.tables ORDER BY 1".to_string(),
None,
)
.await
.expect("get_flight_info");
let ticket = info.endpoint[0].ticket.clone().expect("ticket");
let batches: Vec<_> = client
.do_get(ticket)
.await
.expect("do_get")
.try_collect()
.await
.expect("collect");
use meterstore::arrow::array::AsArray;
let mut names = Vec::new();
for batch in &batches {
let column = batch.column(0).as_string::<i32>();
for i in 0..batch.num_rows() {
names.push(column.value(i).to_string());
}
}
for expected in [
"readings",
"readings_versions",
"esa_typ2",
"esa_typ2_versions",
] {
assert!(
names.iter().any(|n| n == expected),
"a Flight client must see {expected}: {names:?}"
);
}
drop(shutdown);
}