exporting ECB
This commit is contained in:
@@ -7,6 +7,12 @@ pub(crate) const STRUCT_VALIDATION: &str = "struct:validation";
|
||||
pub(crate) const STRUCT_ROLE: &str = "struct:role";
|
||||
pub(crate) const STRUCT_USER: &str = "struct:user";
|
||||
pub(crate) const MANAGE: &str = "manage";
|
||||
pub(crate) const READ: &str = "read";
|
||||
|
||||
/// Every ECB object, which is what the pipeline status is checked against:
|
||||
/// one importer feeds every profile, so reading its health is not a
|
||||
/// per-profile question.
|
||||
pub(crate) const ALL_ECB: &str = "ecb:*";
|
||||
|
||||
pub(crate) fn permits(snapshot: &AuthorizationSnapshot, object: &str, action: &str) -> bool {
|
||||
permissions_permit(&snapshot.permissions, object, action)
|
||||
@@ -26,6 +32,12 @@ pub(crate) fn can_manage(snapshot: &AuthorizationSnapshot, area: &str) -> bool {
|
||||
permits(snapshot, area, MANAGE)
|
||||
}
|
||||
|
||||
/// Whether the caller may see the reference-rate pipeline. Mirrors the
|
||||
/// server's own check in `server/src/ecb/grpc.rs`.
|
||||
pub(crate) fn can_read_ecb(snapshot: &AuthorizationSnapshot) -> bool {
|
||||
permits(snapshot, ALL_ECB, READ)
|
||||
}
|
||||
|
||||
pub(crate) fn can_open_admin(snapshot: &AuthorizationSnapshot) -> bool {
|
||||
[
|
||||
STRUCT_PROFILE,
|
||||
|
||||
@@ -70,6 +70,13 @@ mod definitions {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod ecb {
|
||||
include!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../common/src/proto/komp_ac.ecb.rs"
|
||||
));
|
||||
}
|
||||
|
||||
use auth::auth_service_client::AuthServiceClient;
|
||||
use definitions::{
|
||||
table_definition::table_definition_client::TableDefinitionClient,
|
||||
@@ -81,6 +88,7 @@ use definitions::{
|
||||
use tonic::transport::Channel;
|
||||
|
||||
use analytics::analytics_service_client::AnalyticsServiceClient;
|
||||
use ecb::ecb_service_client::EcbServiceClient;
|
||||
|
||||
const APP_CSS: &str = include_str!("../static/app.css");
|
||||
|
||||
@@ -95,6 +103,7 @@ pub(crate) struct AppState {
|
||||
structures: TableStructureServiceClient<Channel>,
|
||||
validations: TableValidationServiceClient<Channel>,
|
||||
tables_data: TablesDataClient<Channel>,
|
||||
ecb: EcbServiceClient<Channel>,
|
||||
}
|
||||
|
||||
/// Starts the web UI as a detached task on the current Tokio runtime.
|
||||
@@ -126,6 +135,7 @@ pub async fn serve() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
scripts: TableScriptClient::new(channel.clone()),
|
||||
validations: TableValidationServiceClient::new(channel.clone()),
|
||||
tables_data: TablesDataClient::new(channel.clone()),
|
||||
ecb: EcbServiceClient::new(channel.clone()),
|
||||
structures: TableStructureServiceClient::new(channel),
|
||||
};
|
||||
|
||||
@@ -147,6 +157,7 @@ fn router(state: AppState) -> Router {
|
||||
.merge(pages::admin::admin::router())
|
||||
.merge(pages::permissions::router())
|
||||
.merge(pages::admin::table_definition::router())
|
||||
.merge(pages::admin::ecb::router())
|
||||
.merge(pages::add_table::router())
|
||||
.merge(pages::add_logic::router())
|
||||
.merge(pages::add_validation::router())
|
||||
@@ -189,6 +200,7 @@ mod tests {
|
||||
scripts: TableScriptClient::new(channel.clone()),
|
||||
validations: TableValidationServiceClient::new(channel.clone()),
|
||||
tables_data: TablesDataClient::new(channel.clone()),
|
||||
ecb: EcbServiceClient::new(channel.clone()),
|
||||
structures: TableStructureServiceClient::new(channel),
|
||||
})
|
||||
}
|
||||
@@ -334,6 +346,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The exchange-rate page is read-only and behind a session like every
|
||||
/// other page, so an anonymous visitor is sent to the login page rather
|
||||
/// than to the backend.
|
||||
#[tokio::test]
|
||||
async fn the_exchange_rate_pages_are_mounted_and_need_a_session() {
|
||||
for path in ["/admin/ecb", "/admin/ecb/status"] {
|
||||
let (status, _) = get(path).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
axum::http::StatusCode::SEE_OTHER,
|
||||
"{path} did not send an anonymous visitor to the login page"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Permissions is a nav section of its own, so its three pages are mounted
|
||||
/// at the top level rather than under /admin — and each of them, like every
|
||||
/// other page behind a session, sends an anonymous visitor to the login
|
||||
|
||||
@@ -167,6 +167,7 @@ pub(crate) async fn load_admin_page(
|
||||
can_export: authorization.permissions.iter().any(|permission| {
|
||||
permission.action == "read" && permission.object.starts_with("data:")
|
||||
}),
|
||||
can_ecb: crate::authz::can_read_ecb(&authorization),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ pub(crate) struct AdminPageState {
|
||||
pub can_manage_scripts: bool,
|
||||
pub can_manage_validations: bool,
|
||||
pub can_export: bool,
|
||||
/// Whether the exchange-rate pipeline is visible to this caller. Not a
|
||||
/// structural area: it is granted through the ECB object, like the
|
||||
/// conversions it reports on.
|
||||
pub can_ecb: bool,
|
||||
}
|
||||
|
||||
impl AdminPageState {
|
||||
|
||||
@@ -53,6 +53,7 @@ mod tests {
|
||||
can_permissions: true,
|
||||
can_import: false,
|
||||
can_export: false,
|
||||
can_ecb: false,
|
||||
active: "admin",
|
||||
};
|
||||
let page = AdminPageState {
|
||||
@@ -66,6 +67,7 @@ mod tests {
|
||||
can_manage_scripts: true,
|
||||
can_manage_validations: true,
|
||||
can_export: true,
|
||||
can_ecb: true,
|
||||
};
|
||||
let html = render_page(&page);
|
||||
for route in [
|
||||
@@ -108,6 +110,7 @@ mod tests {
|
||||
can_manage_scripts: true,
|
||||
can_manage_validations: true,
|
||||
can_export: true,
|
||||
can_ecb: true,
|
||||
};
|
||||
|
||||
let html = render_workspace(&page);
|
||||
@@ -142,6 +145,7 @@ mod tests {
|
||||
can_manage_scripts: true,
|
||||
can_manage_validations: true,
|
||||
can_export: true,
|
||||
can_ecb: true,
|
||||
};
|
||||
|
||||
let html = render_workspace(&page);
|
||||
@@ -181,6 +185,7 @@ mod tests {
|
||||
can_manage_scripts: true,
|
||||
can_manage_validations: true,
|
||||
can_export: true,
|
||||
can_ecb: true,
|
||||
};
|
||||
|
||||
let html = render_workspace(&page);
|
||||
|
||||
90
web/src/pages/admin/ecb/loader.rs
Normal file
90
web/src/pages/admin/ecb/loader.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
//! Two calls: the caller's authorization, and the pipeline status itself.
|
||||
//!
|
||||
//! The status RPC is authorized server-side against the whole ECB object, so
|
||||
//! the check here is only about whether to draw the page at all — the backend
|
||||
//! is still the authority, and a caller who slips past this gets a
|
||||
//! `PermissionDenied` from it rather than data.
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::GetAuthorizationRequest,
|
||||
ecb::GetEcbPipelineStatusRequest,
|
||||
services::authenticated_request,
|
||||
};
|
||||
|
||||
use super::state::{EcbPageState, ImportBatchView, LoadError};
|
||||
|
||||
/// How many attempts the table shows. Enough to cover a fortnight of daily
|
||||
/// runs plus the retries a bad day produces.
|
||||
const BATCH_LIMIT: i32 = 25;
|
||||
|
||||
pub(crate) async fn load_ecb_page(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<EcbPageState, LoadError> {
|
||||
let authorization_request = authenticated_request(headers, GetAuthorizationRequest {})
|
||||
.map_err(|_| LoadError::Unauthenticated)?;
|
||||
let mut auth = state.auth;
|
||||
let authorization = auth
|
||||
.get_authorization(authorization_request)
|
||||
.await
|
||||
.map_err(|error| match error.code() {
|
||||
tonic::Code::Unauthenticated => LoadError::Unauthenticated,
|
||||
tonic::Code::PermissionDenied => LoadError::Forbidden,
|
||||
_ => LoadError::Backend(error.message().to_string()),
|
||||
})?
|
||||
.into_inner();
|
||||
|
||||
if !crate::authz::can_read_ecb(&authorization) {
|
||||
return Err(LoadError::Forbidden);
|
||||
}
|
||||
|
||||
let mut ecb = state.ecb;
|
||||
let status = ecb
|
||||
.get_ecb_pipeline_status(
|
||||
authenticated_request(
|
||||
headers,
|
||||
GetEcbPipelineStatusRequest {
|
||||
batch_limit: BATCH_LIMIT,
|
||||
},
|
||||
)
|
||||
.map_err(|_| LoadError::Unauthenticated)?,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| match error.code() {
|
||||
tonic::Code::Unauthenticated => LoadError::Unauthenticated,
|
||||
tonic::Code::PermissionDenied => LoadError::Forbidden,
|
||||
_ => LoadError::Backend(error.message().to_string()),
|
||||
})?
|
||||
.into_inner();
|
||||
|
||||
Ok(EcbPageState {
|
||||
nav: crate::ui::Nav::new(headers, "ecb").with_authorization(&authorization),
|
||||
verified_through_date: status.verified_through_date,
|
||||
latest_verifiable_date: status.latest_verifiable_date,
|
||||
healthy: status.healthy,
|
||||
days_behind: status.days_behind,
|
||||
import_running: status.import_running,
|
||||
next_import_at: status.next_import_at,
|
||||
covered_currencies: status.covered_currencies,
|
||||
batches: status
|
||||
.batches
|
||||
.into_iter()
|
||||
.map(|batch| ImportBatchView {
|
||||
batch_id: batch.batch_id,
|
||||
status: batch.status,
|
||||
requested_from: batch.requested_from,
|
||||
requested_through: batch.requested_through,
|
||||
endpoint: batch.endpoint,
|
||||
started_at: batch.started_at,
|
||||
completed_at: batch.completed_at,
|
||||
verified_through_date: batch.verified_through_date,
|
||||
observation_count: batch.observation_count,
|
||||
inserted_observation_count: batch.inserted_observation_count,
|
||||
error_message: batch.error_message,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
52
web/src/pages/admin/ecb/logic.rs
Normal file
52
web/src/pages/admin/ecb/logic.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
//! Two GETs and no writes. The pipeline is driven by a scheduler inside the
|
||||
//! server, so there is nothing here for a browser to start or stop — only to
|
||||
//! watch.
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
use super::{loader::load_ecb_page, state::LoadError, ui};
|
||||
|
||||
/// GET /admin/ecb
|
||||
pub(crate) async fn page(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
match load_ecb_page(state, &headers).await {
|
||||
Ok(page) => Html(ui::render_page(&page)).into_response(),
|
||||
Err(error) => error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /admin/ecb/status — the status card alone.
|
||||
///
|
||||
/// Polled only while a batch is running; the card stops asking for itself once
|
||||
/// the batch it was watching has finished, because the fragment it swaps in
|
||||
/// carries no trigger.
|
||||
pub(crate) async fn status_fragment(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
match load_ecb_page(state, &headers).await {
|
||||
Ok(page) => Html(ui::render_status(&page)).into_response(),
|
||||
Err(error) => error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(error: LoadError) -> Response {
|
||||
match error {
|
||||
LoadError::Unauthenticated => Redirect::to("/login").into_response(),
|
||||
LoadError::Forbidden => (
|
||||
StatusCode::FORBIDDEN,
|
||||
Html(ui::render_error(
|
||||
"Reading exchange-rate data is required to open this page.",
|
||||
)),
|
||||
)
|
||||
.into_response(),
|
||||
LoadError::Backend(message) => {
|
||||
(StatusCode::BAD_GATEWAY, Html(ui::render_error(&message))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
28
web/src/pages/admin/ecb/mod.rs
Normal file
28
web/src/pages/admin/ecb/mod.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
//! Health of the ECB reference-rate pipeline.
|
||||
//!
|
||||
//! Read-only, and deliberately not profile-scoped: one importer feeds every
|
||||
//! profile, so the question "are the rates current" has one answer for the
|
||||
//! whole deployment. That is also why it is its own page rather than a panel
|
||||
//! on a profile's — it is not about a profile at all.
|
||||
//!
|
||||
//! The page matters because coverage is a precondition, not a detail: a
|
||||
//! conversion whose publication date falls beyond verified coverage is refused
|
||||
//! outright by the backend. When posting starts failing for that reason, this
|
||||
//! is the screen that says so.
|
||||
|
||||
mod loader;
|
||||
mod logic;
|
||||
mod state;
|
||||
mod ui;
|
||||
|
||||
use axum::{Router, routing::get};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub(crate) fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/admin/ecb", get(logic::page))
|
||||
// The status card on its own, for the poll that keeps it live while a
|
||||
// batch is running.
|
||||
.route("/admin/ecb/status", get(logic::status_fragment))
|
||||
}
|
||||
117
web/src/pages/admin/ecb/state.rs
Normal file
117
web/src/pages/admin/ecb/state.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
//! What the ECB page renders.
|
||||
//!
|
||||
//! Dates and timestamps arrive as strings and stay strings: the backend
|
||||
//! derived them from the accounting calendar, and reparsing them here would
|
||||
//! only create a second opinion about what "today" means.
|
||||
|
||||
/// One import attempt, as the audit log recorded it.
|
||||
pub(crate) struct ImportBatchView {
|
||||
pub batch_id: i64,
|
||||
pub status: String,
|
||||
pub requested_from: String,
|
||||
pub requested_through: String,
|
||||
pub endpoint: String,
|
||||
pub started_at: String,
|
||||
pub completed_at: Option<String>,
|
||||
pub verified_through_date: Option<String>,
|
||||
pub observation_count: Option<i32>,
|
||||
pub inserted_observation_count: Option<i32>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl ImportBatchView {
|
||||
pub(crate) fn succeeded(&self) -> bool {
|
||||
self.status == "succeeded"
|
||||
}
|
||||
|
||||
pub(crate) fn running(&self) -> bool {
|
||||
self.status == "running"
|
||||
}
|
||||
|
||||
pub(crate) fn failed(&self) -> bool {
|
||||
self.status == "failed"
|
||||
}
|
||||
|
||||
/// The row's own date range, collapsed when it covers a single day.
|
||||
pub(crate) fn window(&self) -> String {
|
||||
if self.requested_from == self.requested_through {
|
||||
self.requested_from.clone()
|
||||
} else {
|
||||
format!("{} → {}", self.requested_from, self.requested_through)
|
||||
}
|
||||
}
|
||||
|
||||
/// New observations against those the response carried. They differ when a
|
||||
/// batch re-fetched days an earlier one already owned.
|
||||
pub(crate) fn observations(&self) -> String {
|
||||
match (self.inserted_observation_count, self.observation_count) {
|
||||
(Some(inserted), Some(total)) if inserted == total => inserted.to_string(),
|
||||
(Some(inserted), Some(total)) => format!("{inserted} new of {total}"),
|
||||
(None, Some(total)) => total.to_string(),
|
||||
_ => "—".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct EcbPageState {
|
||||
pub nav: crate::ui::Nav,
|
||||
pub verified_through_date: Option<String>,
|
||||
pub latest_verifiable_date: String,
|
||||
pub healthy: bool,
|
||||
pub days_behind: i32,
|
||||
pub import_running: bool,
|
||||
pub next_import_at: String,
|
||||
pub batches: Vec<ImportBatchView>,
|
||||
pub covered_currencies: Vec<String>,
|
||||
}
|
||||
|
||||
impl EcbPageState {
|
||||
/// The headline. Three states, because "behind" and "never ran" need
|
||||
/// different things done about them.
|
||||
pub(crate) fn headline(&self) -> &'static str {
|
||||
if self.healthy {
|
||||
"Rates are current"
|
||||
} else if self.verified_through_date.is_none() {
|
||||
"No rates have ever been imported"
|
||||
} else {
|
||||
"Rates are behind"
|
||||
}
|
||||
}
|
||||
|
||||
/// What a reader should do about it, in one sentence.
|
||||
pub(crate) fn explanation(&self) -> String {
|
||||
if self.healthy {
|
||||
"Every publication day up to the latest one is verified, so conversions post normally.".to_string()
|
||||
} else if self.verified_through_date.is_none() {
|
||||
"Until one succeeds, every conversion in a foreign currency is refused for want of coverage.".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{} publication {} missing. A conversion dated inside the gap is refused until the importer catches up.",
|
||||
self.days_behind,
|
||||
if self.days_behind == 1 { "day is" } else { "days are" }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The class the status card is painted with.
|
||||
pub(crate) fn health_class(&self) -> &'static str {
|
||||
if self.healthy { "healthy" } else { "unhealthy" }
|
||||
}
|
||||
|
||||
/// While a batch is running the card polls; otherwise it sits still. There
|
||||
/// is nothing to watch between the scheduled 17:00 runs.
|
||||
pub(crate) fn should_poll(&self) -> bool {
|
||||
self.import_running
|
||||
}
|
||||
|
||||
pub(crate) fn last_failure(&self) -> Option<&ImportBatchView> {
|
||||
self.batches.iter().find(|batch| batch.failed())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum LoadError {
|
||||
Unauthenticated,
|
||||
Forbidden,
|
||||
Backend(String),
|
||||
}
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub(crate) mod admin;
|
||||
pub(crate) mod ecb;
|
||||
pub(crate) mod table_definition;
|
||||
|
||||
@@ -48,6 +48,7 @@ mod tests {
|
||||
can_permissions: true,
|
||||
can_import: false,
|
||||
can_export: false,
|
||||
can_ecb: true,
|
||||
active: "permissions",
|
||||
},
|
||||
tabs: Tabs {
|
||||
|
||||
@@ -37,6 +37,7 @@ mod tests {
|
||||
can_permissions: true,
|
||||
can_import: false,
|
||||
can_export: false,
|
||||
can_ecb: true,
|
||||
active: "permissions",
|
||||
},
|
||||
tabs: Tabs {
|
||||
|
||||
@@ -37,6 +37,7 @@ mod tests {
|
||||
can_permissions: true,
|
||||
can_import: false,
|
||||
can_export: false,
|
||||
can_ecb: true,
|
||||
active: "permissions",
|
||||
},
|
||||
tabs: Tabs {
|
||||
|
||||
@@ -19,6 +19,7 @@ pub(crate) struct Nav {
|
||||
pub can_permissions: bool,
|
||||
pub can_import: bool,
|
||||
pub can_export: bool,
|
||||
pub can_ecb: bool,
|
||||
pub active: &'static str,
|
||||
}
|
||||
|
||||
@@ -34,6 +35,7 @@ impl Nav {
|
||||
can_permissions: false,
|
||||
can_import: false,
|
||||
can_export: false,
|
||||
can_ecb: false,
|
||||
active,
|
||||
}
|
||||
}
|
||||
@@ -54,6 +56,7 @@ impl Nav {
|
||||
self.can_export = authorization.permissions.iter().any(|permission| {
|
||||
permission.action == "read" && permission.object.starts_with("data:")
|
||||
});
|
||||
self.can_ecb = crate::authz::can_read_ecb(authorization);
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -67,6 +70,7 @@ impl Default for Nav {
|
||||
can_permissions: false,
|
||||
can_import: false,
|
||||
can_export: false,
|
||||
can_ecb: false,
|
||||
active: "",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user