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("2026-08-13"), "{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 & decisive dates"), "{html}"); assert!(html.contains("2026-08-12"), "{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("Nothing."), "{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")); } }