exporting ECB
This commit is contained in:
177
web/src/pages/admin/ecb/ui.rs
Normal file
177
web/src/pages/admin/ecb/ui.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
use askama::Template;
|
||||
|
||||
use crate::ui::{ErrorPage, Nav, render};
|
||||
|
||||
use super::state::EcbPageState;
|
||||
|
||||
/// GET /admin/ecb
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/admin/ecb/ecb.html")]
|
||||
struct EcbPage<'a> {
|
||||
nav: Nav,
|
||||
page: &'a EcbPageState,
|
||||
}
|
||||
|
||||
/// GET /admin/ecb/status — the card the page polls while a batch runs.
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/admin/ecb/status.html")]
|
||||
struct StatusFragment<'a> {
|
||||
page: &'a EcbPageState,
|
||||
}
|
||||
|
||||
pub(crate) fn render_page(page: &EcbPageState) -> String {
|
||||
render(&EcbPage {
|
||||
nav: page.nav.clone(),
|
||||
page,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_status(page: &EcbPageState) -> String {
|
||||
render(&StatusFragment { page })
|
||||
}
|
||||
|
||||
pub(crate) fn render_error(message: &str) -> String {
|
||||
render(&ErrorPage {
|
||||
nav: Nav::default(),
|
||||
heading: "Exchange rates unavailable",
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pages::admin::ecb::state::ImportBatchView;
|
||||
|
||||
fn batch(id: i64, status: &str) -> ImportBatchView {
|
||||
ImportBatchView {
|
||||
batch_id: id,
|
||||
status: status.to_string(),
|
||||
requested_from: "2026-08-10".to_string(),
|
||||
requested_through: "2026-08-12".to_string(),
|
||||
endpoint: "https://data.ecb.europa.eu/...".to_string(),
|
||||
started_at: "2026-08-12T17:00:00Z".to_string(),
|
||||
completed_at: Some("2026-08-12T17:00:04Z".to_string()),
|
||||
verified_through_date: Some("2026-08-12".to_string()),
|
||||
observation_count: Some(90),
|
||||
inserted_observation_count: Some(30),
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn healthy_page() -> EcbPageState {
|
||||
EcbPageState {
|
||||
nav: Nav::default(),
|
||||
verified_through_date: Some("2026-08-12".to_string()),
|
||||
latest_verifiable_date: "2026-08-12".to_string(),
|
||||
healthy: true,
|
||||
days_behind: 0,
|
||||
import_running: false,
|
||||
next_import_at: "2026-08-13T15:00:00Z".to_string(),
|
||||
batches: vec![batch(9, "succeeded")],
|
||||
covered_currencies: vec!["CZK".to_string(), "USD".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
/// The headline has to answer "can I post right now" without reading the
|
||||
/// table underneath it.
|
||||
#[test]
|
||||
fn a_current_pipeline_says_so_at_the_top() {
|
||||
let html = render_page(&healthy_page());
|
||||
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
assert!(html.contains("Rates are current"));
|
||||
assert!(html.contains("healthy"));
|
||||
assert!(html.contains("2026-08-12"));
|
||||
assert!(html.contains("CZK"));
|
||||
// Nothing is running, so the card does not ask for itself again.
|
||||
assert!(!html.contains("hx-trigger=\"every"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_behind_pipeline_counts_the_missing_publication_days() {
|
||||
let mut page = healthy_page();
|
||||
page.healthy = false;
|
||||
page.days_behind = 3;
|
||||
page.verified_through_date = Some("2026-08-07".to_string());
|
||||
|
||||
let html = render_page(&page);
|
||||
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
assert!(html.contains("Rates are behind"));
|
||||
assert!(html.contains("unhealthy"));
|
||||
assert!(html.contains("3 publication days are missing"));
|
||||
}
|
||||
|
||||
/// A pipeline that has never succeeded is a different problem from one
|
||||
/// that has fallen behind, and reads differently.
|
||||
#[test]
|
||||
fn a_pipeline_that_never_ran_says_that_instead() {
|
||||
let mut page = healthy_page();
|
||||
page.healthy = false;
|
||||
page.verified_through_date = None;
|
||||
page.covered_currencies.clear();
|
||||
page.batches.clear();
|
||||
|
||||
let html = render_page(&page);
|
||||
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
assert!(html.contains("No rates have ever been imported"));
|
||||
assert!(html.contains("every conversion in a foreign currency is refused"));
|
||||
}
|
||||
|
||||
/// While a batch runs the card watches itself, and the fragment it swaps
|
||||
/// in is what carries the next trigger.
|
||||
#[test]
|
||||
fn a_running_import_polls_until_it_finishes() {
|
||||
let mut page = healthy_page();
|
||||
page.import_running = true;
|
||||
page.batches.insert(0, batch(10, "running"));
|
||||
|
||||
let html = render_page(&page);
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
assert!(html.contains("/admin/ecb/status"));
|
||||
assert!(html.contains("hx-trigger=\"every"));
|
||||
|
||||
// And the fragment alone carries it too, so the poll survives a swap.
|
||||
let fragment = render_status(&page);
|
||||
assert!(fragment.contains("hx-trigger=\"every"));
|
||||
|
||||
// Once it finishes, the swapped-in card stops asking.
|
||||
page.import_running = false;
|
||||
assert!(!render_status(&page).contains("hx-trigger=\"every"));
|
||||
}
|
||||
|
||||
/// A failed attempt has to surface its reason without expanding anything:
|
||||
/// it is the only thing on the page that says why coverage stopped.
|
||||
#[test]
|
||||
fn a_failure_shows_the_backend_message() {
|
||||
let mut page = healthy_page();
|
||||
page.healthy = false;
|
||||
page.batches = vec![ImportBatchView {
|
||||
status: "failed".to_string(),
|
||||
completed_at: None,
|
||||
verified_through_date: None,
|
||||
observation_count: None,
|
||||
inserted_observation_count: None,
|
||||
error_message: Some("the ECB endpoint returned 503".to_string()),
|
||||
..batch(11, "failed")
|
||||
}];
|
||||
|
||||
let html = render_page(&page);
|
||||
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
assert!(html.contains("the ECB endpoint returned 503"));
|
||||
}
|
||||
|
||||
/// Re-fetching a day an earlier batch already owns inserts nothing, and
|
||||
/// the count has to explain that rather than look like a lost import.
|
||||
#[test]
|
||||
fn partially_inserted_observations_are_explained() {
|
||||
let page = healthy_page();
|
||||
|
||||
let html = render_page(&page);
|
||||
|
||||
assert!(html.contains("30 new of 90"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user