Files
komp_ac/web/src/pages/admin/ecb/ui.rs
2026-08-15 12:44:36 +02:00

233 lines
8.2 KiB
Rust

use askama::Template;
use crate::{i18n::Locale, tr};
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> {
nav: Nav,
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 {
nav: page.nav.clone(),
page,
})
}
pub(crate) fn render_error(locale: Locale, message: &str) -> String {
render(&ErrorPage {
nav: Nav::default(),
heading: &tr!(locale, "ecb-unavailable-title"),
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()],
transactions_postable_through: Some("2026-08-13".to_string()),
statements_postable_through: Some("2026-08-12".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"));
assert!(html.contains("3 publication days behind"));
}
/// Timestamps arrive as RFC 3339 instants and must not reach the page that
/// way; how long a batch took has to be worked out here, since the backend
/// only sends the two ends.
#[test]
fn timestamps_are_printed_rather_than_dumped() {
let html = render_page(&healthy_page());
assert!(html.contains("2026-08-12 17:00 UTC"), "{html}");
assert!(!html.contains("2026-08-12T17:00:00Z"), "{html}");
// Started 17:00:00, finished 17:00:04.
assert!(html.contains("17:00:04"));
assert!(html.contains("4s"));
}
/// The counts above the table are how a run of bad days is told apart from
/// one bad day without reading every row.
#[test]
fn failed_attempts_are_counted_above_the_table() {
let mut page = healthy_page();
page.batches.push(batch(8, "failed"));
page.batches.push(batch(7, "failed"));
let html = render_page(&page);
assert!(html.contains("2 failed"), "{html}");
}
/// Coverage is a publication date; the reader's question is which dates
/// they may post. The card has to answer the second one outright, and the
/// two bounds differ because the two rules differ.
#[test]
fn the_card_says_which_dates_can_be_posted() {
let html = render_page(&healthy_page());
assert!(html.contains("What can be posted right now"), "{html}");
assert!(html.contains("Ordinary transactions"));
assert!(html.contains("<strong>2026-08-13</strong>"), "{html}");
// Askama escapes `&` numerically. The title now comes from the
// catalogue as plain text, where it was pre-escaped markup before.
assert!(html.contains("Statements &#38; decisive dates"), "{html}");
assert!(html.contains("<strong>2026-08-12</strong>"), "{html}");
}
/// 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();
page.transactions_postable_through = None;
page.statements_postable_through = None;
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"));
// And the postable block says so too rather than showing an empty date.
assert!(html.contains("<strong>Nothing.</strong>"), "{html}");
}
/// 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"));
}
}