Files
Northern-Thailand-Ping-Rive…/rust/collector/src/web/tests.rs
T
grabowski 7e642555b4 chore: remove the Python service; Python is the training stack only
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.
2026-10-18 17:15:07 +07:00

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;
}