feat(admin): overview dashboard sections + system temperature/load sensors (0.85.0) (#11)
This commit was merged in pull request #11.
This commit is contained in:
125
backend/tests/api_admin_overview.rs
Normal file
125
backend/tests/api_admin_overview.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
//! Integration tests for the admin overview aggregates endpoint
|
||||
//! (`GET /api/v1/admin/overview`). Auth gating mirrors the system tab;
|
||||
//! the aggregate counts are asserted against seeded data.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::Router;
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use mangalord::repo;
|
||||
|
||||
async fn seed_admin(pool: &PgPool, app: &Router) -> String {
|
||||
let (username, cookie) = common::register_user(app).await;
|
||||
let u = repo::user::find_by_username(pool, &username)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
repo::user::set_is_admin_unchecked(pool, u.id, true).await.unwrap();
|
||||
cookie
|
||||
}
|
||||
|
||||
/// Seed a manga → chapter with `n` pages; mark the first `analyzed` of
|
||||
/// them as a `done` page_analysis. Returns the page ids.
|
||||
async fn seed_manga_pages(pool: &PgPool, title: &str, n: i32, analyzed: i32) -> Vec<uuid::Uuid> {
|
||||
let manga_id: uuid::Uuid =
|
||||
sqlx::query_scalar("INSERT INTO mangas (title) VALUES ($1) RETURNING id")
|
||||
.bind(title)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let chapter_id: uuid::Uuid =
|
||||
sqlx::query_scalar("INSERT INTO chapters (manga_id, number) VALUES ($1, 1) RETURNING id")
|
||||
.bind(manga_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut ids = Vec::new();
|
||||
for i in 1..=n {
|
||||
let id: uuid::Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO pages (chapter_id, page_number, storage_key, content_type) \
|
||||
VALUES ($1, $2, $3, 'image/png') RETURNING id",
|
||||
)
|
||||
.bind(chapter_id)
|
||||
.bind(i)
|
||||
.bind(format!("k/{manga_id}/{i}.png"))
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
if i <= analyzed {
|
||||
sqlx::query("INSERT INTO page_analysis (page_id, status) VALUES ($1, 'done')")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
ids.push(id);
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn requires_admin(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_u, cookie) = common::register_user(&h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie("/api/v1/admin/overview", &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn unauthenticated_request_is_rejected(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get("/api/v1/admin/overview"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn aggregates_users_mangas_and_coverage(pool: PgPool) {
|
||||
let h = common::harness(pool.clone());
|
||||
// seed_admin registers one user and promotes it; register a second
|
||||
// (non-admin) user so the counts have a known split.
|
||||
let cookie = seed_admin(&pool, &h.app).await;
|
||||
let _ = common::register_user(&h.app).await;
|
||||
|
||||
// Two mangas: 3 pages with 2 analyzed, and 2 pages with 0 analyzed.
|
||||
seed_manga_pages(&pool, "Alpha", 3, 2).await;
|
||||
seed_manga_pages(&pool, "Beta", 2, 0).await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie("/api/v1/admin/overview", &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
|
||||
let users = body.get("users").expect("users key");
|
||||
assert_eq!(users["total"], json!(2));
|
||||
assert_eq!(users["admins"], json!(1));
|
||||
assert!(users["newest_username"].is_string());
|
||||
|
||||
let mangas = body.get("mangas").expect("mangas key");
|
||||
assert_eq!(mangas["total"], json!(2));
|
||||
// Uploaded mangas (no manga_sources) classify as synced.
|
||||
assert_eq!(mangas["synced"], json!(2));
|
||||
assert_eq!(mangas["in_progress"], json!(0));
|
||||
assert_eq!(mangas["dropped"], json!(0));
|
||||
assert_eq!(mangas["total_chapters"], json!(2));
|
||||
assert_eq!(mangas["total_pages"], json!(5));
|
||||
assert!(mangas["newest_title"].is_string());
|
||||
|
||||
let analysis = body.get("analysis").expect("analysis key");
|
||||
assert_eq!(analysis["total_pages"], json!(5));
|
||||
assert_eq!(analysis["analyzed_pages"], json!(2));
|
||||
}
|
||||
@@ -86,6 +86,34 @@ async fn returns_disk_memory_cpu_alerts_shape(pool: PgPool) {
|
||||
"cpu out of range: {cpu_pct}"
|
||||
);
|
||||
|
||||
// Load average: three numeric keys. Values can legitimately be 0 on
|
||||
// an idle box, so only assert presence + non-negativity.
|
||||
let load = cpu["load_avg"].as_object().expect("cpu.load_avg object");
|
||||
for k in ["one", "five", "fifteen"] {
|
||||
let v = load[k].as_f64().unwrap_or_else(|| panic!("load_avg.{k} numeric"));
|
||||
assert!(v >= 0.0, "load_avg.{k} negative: {v}");
|
||||
}
|
||||
|
||||
// Per-core usage: an array of percentages in [0,100].
|
||||
let cores = cpu["per_core"].as_array().expect("cpu.per_core array");
|
||||
assert!(!cores.is_empty(), "expected at least one core");
|
||||
for c in cores {
|
||||
let p = c.as_f64().unwrap();
|
||||
assert!((0.0..=100.0).contains(&p), "per_core out of range: {p}");
|
||||
}
|
||||
|
||||
// Temperatures: an array (possibly empty in CI / containers without
|
||||
// /sys sensors). Each present entry has a string label + numeric °C.
|
||||
let temps = body
|
||||
.get("temperatures")
|
||||
.expect("temperatures key")
|
||||
.as_array()
|
||||
.expect("temperatures is an array");
|
||||
for t in temps {
|
||||
assert!(t["label"].is_string(), "temp label is string");
|
||||
assert!(t["celsius"].as_f64().is_some(), "temp celsius is numeric");
|
||||
}
|
||||
|
||||
let alerts = body.get("alerts").expect("alerts key").as_array().unwrap();
|
||||
// Don't assert on length — the box may genuinely be >90% on memory
|
||||
// when the test runs. Just confirm shape of any present entry.
|
||||
|
||||
Reference in New Issue
Block a user