exporting ECB

This commit is contained in:
Priec
2026-08-13 09:11:41 +02:00
parent f8e483efa8
commit fe8ea680e3
24 changed files with 895 additions and 1 deletions

View 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"));
}
}