Files
Northern-Thailand-Ping-Rive…/rust/collector/src/db_tests.rs
T
grabowski 4e619aa2ae
CI / Format & lint (push) Successful in 19s
CI / Test suite (push) Successful in 47s
Docs / Validate documentation (push) Successful in 19s
Rust / fmt, clippy, test, build (push) Successful in 7m35s
Security / Dependency vulnerabilities (push) Successful in 1m37s
Security / Static analysis (push) Successful in 17s
Security / License report (push) Successful in 1m30s
feat(rust): ping-collector, phase 1 of the Rust rewrite
rust/collector replaces the collection half of the Python web app: RID hourly
gauges (with the late-report semantics: store everything, retry every 10 min
while RID is behind, re-fetch yesterday's holes, startup gap fill), HII rain +
water level, RID dams (today + yesterday) and Open-Meteo rain. Same tables and
upsert semantics, one UNNEST statement per table, Bangkok wall-clock time
regardless of the server time zone.

Python side: COLLECTION_ENABLED=false makes the API leader stop collecting and
instead re-forecast + notify when the newest RID reading in the database
advances (polled every FORECAST_POLL_SECONDS) and once per clock hour;
/scraping/status then serves the collector's status file; /scrape/trigger
answers 409.

Verified: 20 Rust tests (parsers, gap/schedule logic, database tests against
PostgreSQL); live parity run of both collectors into two empty databases:
all 9 tables row-for-row identical (scripts/collector_parity.py); the Python
API served health, measurements, stats, status and model forecasts from a
database filled only by ping-collector. 177 Python tests pass.

reqwest uses native-tls: app.rid.go.th offers only TLS 1.2 CBC suites, which
rustls refuses.

Deploy/cutover/rollback: scripts/install_collector.sh, scripts/ping-collector.service;
CI: .gitea/workflows/rust.yml (fmt, clippy, tests with a PostgreSQL service,
release artifact).
2026-10-17 22:20:02 +07:00

333 lines
11 KiB
Rust

//! Database tests against a real PostgreSQL. Each test runs in its own
//! throwaway schema. Set TEST_DATABASE_URL to run them, e.g.
//! `postgres://postgres:ping@localhost:55432/ping`; without it they are
//! skipped (reported as passed with a note on stderr).
use crate::dams::DamRecord;
use crate::db;
use crate::hii::{RainRecord, WaterLevelRecord};
use crate::rain::RainRow;
use crate::rid::{self, Reading, RidSource};
use crate::stations::Stations;
use anyhow::Result;
use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
use sqlx::PgPool;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Mutex;
struct TestDb {
pool: PgPool,
admin: PgPool,
schema: String,
}
impl TestDb {
async fn new() -> Option<Self> {
let Ok(url) = std::env::var("TEST_DATABASE_URL") else {
eprintln!("TEST_DATABASE_URL not set: skipping database test");
return None;
};
let schema = format!(
"t_{}_{}",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap()
);
let admin = PgPool::connect(&url).await.expect("connect");
sqlx::query(&format!("CREATE SCHEMA {schema}"))
.execute(&admin)
.await
.unwrap();
let opts = PgConnectOptions::from_str(&url)
.unwrap()
.options([("search_path", schema.as_str())]);
let pool = PgPoolOptions::new()
.max_connections(2)
.connect_with(opts)
.await
.unwrap();
db::ensure_schema(&pool).await.expect("schema");
Some(TestDb {
pool,
admin,
schema,
})
}
async fn drop(self) {
self.pool.close().await;
sqlx::query(&format!("DROP SCHEMA {} CASCADE", self.schema))
.execute(&self.admin)
.await
.unwrap();
}
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn t(y: i32, m: u32, day: u32, h: u32, min: u32) -> NaiveDateTime {
d(y, m, day).and_hms_opt(h, min, 0).unwrap()
}
/// Canned RID: date -> last published hour (1..=24); P.1 (column 8) and P.20
/// (column 1). Records which reports were requested.
struct FakeRid {
published: HashMap<NaiveDate, i64>,
calls: Mutex<Vec<NaiveDate>>,
}
impl FakeRid {
fn new(published: &[(NaiveDate, i64)]) -> Self {
FakeRid {
published: published.iter().copied().collect(),
calls: Mutex::new(Vec::new()),
}
}
fn calls(&self) -> Vec<NaiveDate> {
self.calls.lock().unwrap().clone()
}
}
impl RidSource for FakeRid {
async fn fetch_report(&self, date: NaiveDate) -> Result<Vec<Reading>> {
self.calls.lock().unwrap().push(date);
let last = self.published.get(&date).copied().unwrap_or(0);
Ok((1..=last)
.flat_map(|h| {
let ts = date.and_time(NaiveTime::MIN) + Duration::hours(h);
[8u32, 1].map(|n| Reading {
timestamp: ts,
station_num: n,
water_level: 1.0 + h as f64 / 100.0,
discharge: Some(50.0),
discharge_percent: Some(12.5),
})
})
.collect())
}
}
async fn stored_hours(pool: &PgPool, day: NaiveDate) -> Vec<u32> {
let rec = db::recorded_hours_by_day(pool, day, day).await.unwrap();
let mut h: Vec<u32> = rec
.get(&day)
.cloned()
.unwrap_or_default()
.into_iter()
.collect();
h.sort();
h
}
#[tokio::test]
async fn late_report_is_stored_and_reported_behind() {
let Some(db) = TestDb::new().await else {
return;
};
let today = d(2026, 10, 17);
let yesterday = today - Duration::days(1);
let stations = Stations::bundled();
// yesterday complete already
let full = FakeRid::new(&[(yesterday, 24)]);
rid::fill_gaps(&full, &db.pool, &stations, Some(1), t(2026, 10, 17, 0, 30))
.await
.unwrap();
// 21:30 and RID has published up to 20:00
let src = FakeRid::new(&[(today, 20)]);
let o = rid::run_cycle(&src, &db.pool, &stations, t(2026, 10, 17, 21, 30)).await;
assert!(!o.current);
assert_eq!(o.latest, Some(t(2026, 10, 17, 20, 0)));
assert_eq!(o.saved, 40);
assert_eq!(src.calls(), vec![today]);
assert_eq!(
stored_hours(&db.pool, today).await,
(0..=20).collect::<Vec<_>>()
);
// the 21:00 reading lands; the next poll is current
let src = FakeRid::new(&[(today, 21)]);
let o = rid::run_cycle(&src, &db.pool, &stations, t(2026, 10, 17, 21, 40)).await;
assert!(o.current);
db.drop().await;
}
#[tokio::test]
async fn yesterdays_late_hours_are_recovered_after_midnight() {
let Some(db) = TestDb::new().await else {
return;
};
let today = d(2026, 10, 17);
let yesterday = today - Duration::days(1);
let stations = Stations::bundled();
// stored up to 21:00 yesterday (the evening RID published late)
let partial = FakeRid::new(&[(yesterday, 21)]);
rid::run_cycle(&partial, &db.pool, &stations, t(2026, 10, 16, 21, 5)).await;
assert_eq!(
stored_hours(&db.pool, yesterday).await,
(1..=21).collect::<Vec<_>>()
);
// 02:10: RID now has all of yesterday and today up to 02:00
let src = FakeRid::new(&[(yesterday, 24), (today, 2)]);
let o = rid::run_cycle(&src, &db.pool, &stations, t(2026, 10, 17, 2, 10)).await;
assert!(o.current);
assert_eq!(src.calls(), vec![yesterday, today]);
assert_eq!(
stored_hours(&db.pool, yesterday).await,
(1..=23).collect::<Vec<_>>()
);
assert_eq!(stored_hours(&db.pool, today).await, vec![0, 1, 2]);
// complete now: the next cycle fetches only today's report
let src = FakeRid::new(&[(yesterday, 24), (today, 3)]);
rid::run_cycle(&src, &db.pool, &stations, t(2026, 10, 17, 3, 5)).await;
assert_eq!(src.calls(), vec![today]);
db.drop().await;
}
#[tokio::test]
async fn rid_upsert_revises_values_and_rounds_like_numeric() {
let Some(db) = TestDb::new().await else {
return;
};
let stations = Stations::bundled();
let r = |level: f64| Reading {
timestamp: t(2026, 10, 17, 9, 0),
station_num: 8,
water_level: level,
discharge: None,
discharge_percent: None,
};
db::save_rid(&db.pool, &[r(1.0700000524520874)], &stations)
.await
.unwrap();
db::save_rid(&db.pool, &[r(1.0900000333786)], &stations)
.await
.unwrap();
let rows: Vec<(String, f64, Option<f64>, String)> = sqlx::query_as(
"SELECT s.station_code, w.water_level::float8, w.discharge::float8, w.status
FROM water_measurements w JOIN stations s ON s.id = w.station_id",
)
.fetch_all(&db.pool)
.await
.unwrap();
assert_eq!(rows, vec![("P.1".into(), 1.09, None, "active".into())]);
db.drop().await;
}
#[tokio::test]
async fn hii_dams_and_rain_round_trip() {
let Some(db) = TestDb::new().await else {
return;
};
let now = t(2026, 10, 17, 21, 5);
let rain = RainRecord {
station_id: 11,
oldcode: Some("CHM001".into()),
name_th: None,
name_en: Some("Chiang Mai 1".into()),
latitude: Some(18.79),
longitude: Some(99.0),
sub_basin_id: Some("601".into()),
agency: Some("HII".into()),
timestamp: t(2026, 10, 17, 21, 0),
rain_1h: Some(1.234),
rain_24h: Some(12.0),
};
assert_eq!(
db::save_hii_rain(&db.pool, &[rain.clone(), rain.clone()], now)
.await
.unwrap(),
1
);
let (r1h,): (f64,) = sqlx::query_as("SELECT rain_1h::float8 FROM hii_rainfall")
.fetch_one(&db.pool)
.await
.unwrap();
assert_eq!(r1h, 1.23);
let wl = WaterLevelRecord {
station_id: 5,
oldcode: Some("ridhydro_P.1".into()),
rid_code: Some("P.1".into()),
name_th: Some("สะพานนวรัฐ".into()),
name_en: None,
latitude: None,
longitude: None,
agency: None,
river_name: None,
offset_msl: Some(300.0),
ground_level_msl: None,
min_bank_msl: None,
critical_level_msl: None,
critical_level_m: None,
qmax: None,
is_key_station: true,
timestamp: t(2026, 10, 17, 21, 0),
wl_msl: Some(301.2),
wl_m: None,
discharge: None,
flow_rate: None,
storage_percent: None,
situation_level: Some(2),
diff_wl_bank: None,
};
db::save_hii_waterlevel(&db.pool, &[wl], now).await.unwrap();
let (code, key, sit): (String, bool, i32) = sqlx::query_as(
"SELECT s.rid_code, s.is_key_station, w.situation_level
FROM hii_waterlevel w JOIN hii_wl_stations s ON s.id = w.station_id",
)
.fetch_one(&db.pool)
.await
.unwrap();
assert_eq!((code.as_str(), key, sit), ("P.1", true, 2));
let dam = |name: Option<&str>, level: Option<f64>| DamRecord {
dam_id: "200103".into(),
region: Some("north".into()),
name_th: name.map(str::to_string),
latitude: Some(19.17),
longitude: None,
capacity_max_mcm: Some(265.0),
capacity_normal_mcm: None,
date: d(2026, 10, 17),
storage_mcm: Some(200.0),
storage_pct: Some(75.5),
inflow_mcm: None,
outflow_mcm: Some(1.2),
level_msl: level,
};
db::save_dams(&db.pool, &[dam(Some("แม่งัด"), Some(395.1))], now)
.await
.unwrap();
// a later snapshot without name / level must not blank them
db::save_dams(&db.pool, &[dam(None, None)], now)
.await
.unwrap();
let (name, level): (Option<String>, Option<f64>) = sqlx::query_as(
"SELECT d.name_th, r.level_msl::float8 FROM rid_reservoir_daily r JOIN rid_dams d USING (dam_id)",
)
.fetch_one(&db.pool)
.await
.unwrap();
assert_eq!(name.as_deref(), Some("แม่งัด"));
assert_eq!(level, Some(395.1));
let rows = vec![RainRow {
timestamp: t(2026, 10, 18, 3, 0),
points: [Some(1.0), None, Some(2.0), None, Some(0.333)],
catchment_mean: Some(1.111),
}];
db::save_rain(&db.pool, &rows).await.unwrap();
let (mean, missing): (f64, Option<f64>) =
sqlx::query_as("SELECT catchment_mean::float8, mae_taeng::float8 FROM openmeteo_rain")
.fetch_one(&db.pool)
.await
.unwrap();
assert_eq!((mean, missing), (1.11, None));
db.drop().await;
}