233 lines
8.2 KiB
Rust
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 & 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"));
|
|
}
|
|
}
|