Phase 4 of docs/RUST_REWRITE.md. Since 2026-10-18 ping-collector (rust/) collects, forecasts, notifies (ntfy + Matrix) and serves the website/API; nothing in production ran the Python service code any more. Removed (~17k lines): the FastAPI app (web_api, schemas, health_check, metrics, postgres_history, forecast_history, thaiwater), the Python RID collector and CLI (water_scraper_v3, main, run.py), the notifier and Matrix alerting (notify, alerting), the multi-backend adapters (SQLite, MySQL, InfluxDB, VictoriaMetrics) with demo databases, models/validators/exceptions/ logging/rate limiter, ml/skill.py (served by Rust now), the migration tooling (api_parity, collector_parity, e2e_notify, install_collector.sh, gen_notify_cases.py, spikes/rust-forecast), Docker/compose/Grafana files, sql/, the release workflow and the docs for all of that. Their tests went with them. Kept in Python: src/ml (training, evaluation harness, export; predict.py as the reference the Rust forecaster is tested against), the HII and RID-dam clients/stores for the archive backfills (their live collectors removed), the backfill scripts, retrain.sh + timer, forecast_parity.py. - PostgreSQL only: src/config.py is database_url() from POSTGRES_* / .env; the stores lost their sqlite/mysql branches (and the 2026-08 column migration). The database tests run against a throwaway schema from TEST_DATABASE_URL (tests/conftest.py pg_url, skipped without it), as the Rust tests do; CI gets a PostgreSQL service. 100 tests (66 without a DB). - The notifier settings moved from src/notify.py into src/ml/export.py; flood_config.json regenerates byte-identical. - tests/test_dashboard.py checks every path the dashboard fetches against ping-collector's router. - Dependencies: fastapi, uvicorn, pydantic, influxdb, pymysql, psutil, schedule, sphinx, pyinstaller, mypy, pytest-asyncio dropped; python-dotenv 1.1.1 explicit (it came in through uvicorn[standard]). - Formatting contract extended to scripts/ and tests/ (reformatted once). - scripts/install.sh is the one installer (fresh host or update, in place in a checkout without touching its ownership): binary, units, uv sync, .env defaults, removes the retired water-monitor.service and any old --alert-check cron job, dry run + forecast check, restart, readiness wait. - Docs, CLAUDE.md, CONTRIBUTING.md, .env.example, Makefile, unit comments and Rust doc comments updated to the Rust service.
294 lines
12 KiB
Rust
294 lines
12 KiB
Rust
//! Route tests: the router as the server runs it, against a throwaway
|
|
//! schema (TEST_DATABASE_URL; see db_tests.rs) or, for the routes that do
|
|
//! not touch the database, a pool that never connects. Output formats are
|
|
//! the retired FastAPI app's (field order, isoformat, errors).
|
|
|
|
use super::{AppState, router};
|
|
use crate::config::Config;
|
|
use crate::db_tests::TestDb;
|
|
use crate::stations::Stations;
|
|
use axum::Router;
|
|
use axum::body::Body;
|
|
use axum::http::{HeaderMap, Request, StatusCode};
|
|
use serde_json::{Value, json};
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, RwLock};
|
|
use tower::ServiceExt;
|
|
|
|
fn config(extra: &[(&str, &str)]) -> Config {
|
|
let static_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../src/static");
|
|
let mut vars: HashMap<String, String> = [
|
|
(
|
|
"POSTGRES_CONNECTION_STRING",
|
|
"postgres://test@localhost:1/none",
|
|
),
|
|
("UMAMI_TRACK_API", "false"),
|
|
("STATIC_DIR", static_dir),
|
|
("NTFY_SERVER", "https://ntfy.example"),
|
|
("STATION_CONFIG_PATH", "/nonexistent/stations.json"),
|
|
]
|
|
.iter()
|
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
|
.collect();
|
|
for (k, v) in extra {
|
|
vars.insert(k.to_string(), v.to_string());
|
|
}
|
|
Config::from_vars(&|k| vars.get(k).cloned()).unwrap()
|
|
}
|
|
|
|
fn app(cfg: Config, pool: sqlx::PgPool) -> Router {
|
|
let state = AppState::new(
|
|
cfg,
|
|
pool,
|
|
reqwest::Client::new(),
|
|
Arc::new(RwLock::new(Stations::bundled())),
|
|
None,
|
|
None,
|
|
);
|
|
router(state)
|
|
}
|
|
|
|
async fn call(
|
|
app: &Router,
|
|
method: &str,
|
|
uri: &str,
|
|
headers: &[(&str, &str)],
|
|
) -> (StatusCode, HeaderMap, Vec<u8>) {
|
|
let mut req = Request::builder().method(method).uri(uri);
|
|
for (k, v) in headers {
|
|
req = req.header(*k, *v);
|
|
}
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(req.body(Body::empty()).unwrap())
|
|
.await
|
|
.unwrap();
|
|
let (parts, body) = resp.into_parts();
|
|
let bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
|
|
(parts.status, parts.headers, bytes.to_vec())
|
|
}
|
|
|
|
async fn get_json(app: &Router, uri: &str) -> (StatusCode, HeaderMap, Value) {
|
|
let (s, h, b) = call(app, "GET", uri, &[]).await;
|
|
let v = serde_json::from_slice(&b)
|
|
.unwrap_or_else(|e| panic!("{uri}: {e}: {}", String::from_utf8_lossy(&b)));
|
|
(s, h, v)
|
|
}
|
|
|
|
fn header<'a>(h: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
|
h.get(name).and_then(|v| v.to_str().ok())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn routes_without_database() {
|
|
let pool = sqlx::PgPool::connect_lazy("postgres://test@localhost:1/none").unwrap();
|
|
let app = app(config(&[]), pool);
|
|
|
|
let (s, h, stations) = get_json(&app, "/stations").await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
assert_eq!(header(&h, "cache-control"), Some("public, max-age=300"));
|
|
assert_eq!(header(&h, "content-type"), Some("application/json"));
|
|
assert_eq!(stations.as_array().unwrap().len(), 16);
|
|
// the list leaves geohash out, the single station has it (as FastAPI)
|
|
assert_eq!(stations[7]["station_code"], "P.1");
|
|
assert_eq!(stations[7]["geohash"], Value::Null);
|
|
let (_, _, p1) = get_json(&app, "/stations/8").await;
|
|
assert_eq!(
|
|
p1.as_object().unwrap().keys().collect::<Vec<_>>(),
|
|
[
|
|
"station_id",
|
|
"station_code",
|
|
"thai_name",
|
|
"english_name",
|
|
"latitude",
|
|
"longitude",
|
|
"geohash",
|
|
"status"
|
|
]
|
|
);
|
|
|
|
let (s, _, e) = get_json(&app, "/stations/abc").await;
|
|
assert_eq!(s, StatusCode::UNPROCESSABLE_ENTITY);
|
|
assert_eq!(e["detail"][0]["type"], "int_parsing");
|
|
assert_eq!(e["detail"][0]["loc"], json!(["path", "station_id"]));
|
|
assert_eq!(
|
|
get_json(&app, "/stations/99").await.2,
|
|
json!({"detail": "Station not found"})
|
|
);
|
|
assert_eq!(
|
|
get_json(&app, "/nope").await.2,
|
|
json!({"detail": "Not Found"})
|
|
);
|
|
let (s, _, b) = call(&app, "DELETE", "/forecast", &[]).await;
|
|
assert_eq!(s, StatusCode::METHOD_NOT_ALLOWED);
|
|
assert_eq!(b, br#"{"detail":"Method Not Allowed"}"#);
|
|
|
|
let (s, _, e) = get_json(&app, "/config").await;
|
|
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
|
|
assert_eq!(
|
|
e["detail"],
|
|
"Admin API disabled: ADMIN_API_KEY is not configured on the server"
|
|
);
|
|
let (s, _, e) = get_json(&app, "/measurements/history/P.1?hours=0").await;
|
|
assert_eq!(s, StatusCode::UNPROCESSABLE_ENTITY);
|
|
assert_eq!(e["detail"][0]["ctx"], json!({"ge": 1}));
|
|
|
|
let (_, _, n) = get_json(&app, "/api/notifications").await;
|
|
assert_eq!(n["topics"]["warning"], "ping-warning");
|
|
assert_eq!(n["server"], "https://ntfy.example");
|
|
|
|
let (s, h, spec) = get_json(&app, "/openapi.json").await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
assert_eq!(header(&h, "content-encoding"), None);
|
|
assert!(spec["paths"]["/forecast"]["get"].is_object());
|
|
|
|
let (s, h, html) = call(&app, "GET", "/", &[("accept-encoding", "gzip")]).await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
assert_eq!(header(&h, "cache-control"), Some("public, max-age=120"));
|
|
assert_eq!(header(&h, "content-encoding"), Some("gzip"));
|
|
assert!(html.len() > 100);
|
|
let (s, h, _) = call(&app, "GET", "/docs", &[]).await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
assert_eq!(header(&h, "content-type"), Some("text/html; charset=utf-8"));
|
|
let (s, h, _) = call(&app, "GET", "/static/custom-markers.json", &[]).await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
assert_eq!(header(&h, "cache-control"), Some("public, max-age=3600"));
|
|
let (s, h, _) = call(&app, "GET", "/robots.txt", &[]).await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
assert_eq!(
|
|
header(&h, "content-type"),
|
|
Some("text/plain; charset=utf-8")
|
|
);
|
|
assert_eq!(
|
|
call(&app, "GET", "/static/nope.json", &[]).await.0,
|
|
StatusCode::NOT_FOUND
|
|
);
|
|
|
|
// admin key
|
|
let app = super::router(AppState::new(
|
|
config(&[("ADMIN_API_KEY", "k3y")]),
|
|
sqlx::PgPool::connect_lazy("postgres://test@localhost:1/none").unwrap(),
|
|
reqwest::Client::new(),
|
|
Arc::new(RwLock::new(Stations::bundled())),
|
|
None,
|
|
None,
|
|
));
|
|
assert_eq!(
|
|
call(&app, "GET", "/config", &[("x-api-key", "nope")])
|
|
.await
|
|
.0,
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
let (s, _, b) = call(&app, "GET", "/config", &[("x-api-key", "k3y")]).await;
|
|
assert_eq!(s, StatusCode::OK, "{}", String::from_utf8_lossy(&b));
|
|
assert!(String::from_utf8_lossy(&b).contains("postgres://test:***@localhost:1/none"));
|
|
let (s, _, b) = call(&app, "POST", "/scrape/trigger", &[("x-api-key", "k3y")]).await;
|
|
assert_eq!(s, StatusCode::CONFLICT, "{}", String::from_utf8_lossy(&b));
|
|
}
|
|
|
|
async fn seed(pool: &sqlx::PgPool) {
|
|
for sql in [
|
|
"INSERT INTO stations (id, station_code, thai_name, english_name) VALUES
|
|
(1, 'P.20', 'บ้านเชียงดาว', 'Ban Chiang Dao'), (8, 'P.1', 'สะพานนวรัฐ', 'Nawarat Bridge')",
|
|
// P.1 hourly 2026-10-01 00:00 .. 10-03 23:00, one missing discharge, a hole at 10-02 05:00
|
|
"INSERT INTO water_measurements (timestamp, station_id, water_level, discharge, discharge_percent, status)
|
|
SELECT t, 8, round((1.2 + extract(hour from t) / 100.0)::numeric, 3),
|
|
CASE WHEN t = timestamp '2026-10-02 03:00' THEN NULL ELSE 55.5 END, 12.94, 'active'
|
|
FROM generate_series(timestamp '2026-10-01 00:00', timestamp '2026-10-03 23:00', interval '1 hour') t
|
|
WHERE t <> timestamp '2026-10-02 05:00'",
|
|
"INSERT INTO water_measurements (timestamp, station_id, water_level, discharge, discharge_percent, status)
|
|
VALUES (timestamp '2026-10-03 22:00', 1, 0.95, NULL, NULL, 'active')",
|
|
"INSERT INTO forecast_history (as_of, station_code, horizon_hours, predicted_max_level, p_warning,
|
|
p_danger, current_level, model_version, source)
|
|
SELECT t, 'P.1', 6, 1.5, 0.01, 0.0, 1.2, CASE WHEN t < timestamp '2026-10-02 00:00' THEN 'v1' ELSE 'v2' END, 'model'
|
|
FROM generate_series(timestamp '2026-10-01 00:00', timestamp '2026-10-03 12:00', interval '1 hour') t",
|
|
"INSERT INTO forecast_current (id, computed_at, model_versions, payload)
|
|
VALUES ('all', timestamp '2026-10-03 23:10', 'v2', '[{\"station_code\":\"P.1\",\"horizon_hours\":6}]')",
|
|
] {
|
|
sqlx::query(sql).execute(pool).await.unwrap();
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn routes_against_the_database() {
|
|
let Some(db) = TestDb::new().await else {
|
|
return;
|
|
};
|
|
seed(&db.pool).await;
|
|
let app = app(
|
|
config(&[("FORECAST_MAX_AGE_SECONDS", "60")]),
|
|
db.pool.clone(),
|
|
);
|
|
|
|
let (s, h, latest) = get_json(&app, "/measurements/latest?limit=5").await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
assert_eq!(header(&h, "cache-control"), Some("public, max-age=30"));
|
|
// ORDER BY station_code: P.1 before P.20
|
|
assert_eq!(
|
|
latest,
|
|
json!([
|
|
{"timestamp": "2026-10-03T23:00:00", "station_code": "P.1", "station_name_en": "Nawarat Bridge",
|
|
"station_name_th": "สะพานนวรัฐ", "water_level": 1.43, "discharge": 55.5,
|
|
"discharge_percent": 12.94, "status": "active"},
|
|
{"timestamp": "2026-10-03T22:00:00", "station_code": "P.20", "station_name_en": "Ban Chiang Dao",
|
|
"station_name_th": "บ้านเชียงดาว", "water_level": 0.95, "discharge": null,
|
|
"discharge_percent": null, "status": "active"}
|
|
])
|
|
);
|
|
|
|
let (_, _, h) = get_json(
|
|
&app,
|
|
"/measurements/history/P.1?start=2026-10-02&end=2026-10-02&limit=6",
|
|
)
|
|
.await;
|
|
let rows = h.as_array().unwrap();
|
|
assert_eq!(rows.len(), 6);
|
|
assert_eq!(rows[0]["timestamp"], "2026-10-02T00:00:00");
|
|
// discharge missing in the database: 20 * H, rounded
|
|
assert_eq!(rows[3]["discharge"], json!(24.6));
|
|
assert_eq!(rows[5]["timestamp"], "2026-10-02T06:00:00"); // the hole at 05:00
|
|
let (s, _, e) = get_json(
|
|
&app,
|
|
"/measurements/history/P.1?start=2026-10-03&end=2026-10-02",
|
|
)
|
|
.await;
|
|
assert_eq!(s, StatusCode::BAD_GATEWAY);
|
|
assert_eq!(e["detail"], "Measurement history unavailable");
|
|
|
|
let (_, _, stats) = get_json(&app, "/api/stats").await;
|
|
assert_eq!(stats["rid_measurements"], 72);
|
|
assert_eq!(stats["rid_station_count"], 2);
|
|
assert_eq!(stats["first_timestamp"], "2026-10-01T00:00:00");
|
|
assert_eq!(stats["days_spanned"], 3);
|
|
assert_eq!(stats["coverage_percent"], json!(98.6)); // 71 of 72 hours
|
|
|
|
let (_, _, fh) = get_json(&app, "/api/forecast/history/P.1?start=2026-10-03&horizon=6").await;
|
|
assert_eq!(fh.as_array().unwrap().len(), 13);
|
|
assert_eq!(fh[0]["predicted_max_level"], json!(1.5));
|
|
|
|
let (s, h, f) = get_json(&app, "/forecast").await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
assert_eq!(f[0]["station_code"], "P.1");
|
|
// computed long ago: served, marked
|
|
assert_eq!(header(&h, "x-data-stale"), Some("true"));
|
|
|
|
let (_, _, sk) = get_json(&app, "/api/forecast/skill?station_code=P.1&horizon=6").await;
|
|
let versions = sk["versions"].as_array().unwrap();
|
|
assert_eq!(versions.len(), 2);
|
|
assert_eq!(versions[0]["model_version"], "v1");
|
|
assert_eq!(versions[0]["n"], 24);
|
|
assert_eq!(versions[0]["enough_data"], true);
|
|
assert_eq!(sk["current"]["model_version"], "v2");
|
|
assert_eq!(sk["trend"]["previous_version"], "v1");
|
|
|
|
let (_, _, health) = get_json(&app, "/health").await;
|
|
assert_eq!(health["checks"]["database"]["status"], "degraded"); // data from 2026-10-03
|
|
assert!(
|
|
health["checks"]["database"]["message"]
|
|
.as_str()
|
|
.unwrap()
|
|
.starts_with("Latest data is old: 2026-10-03 23:00:00")
|
|
);
|
|
db.drop().await;
|
|
}
|