//! The card test app: the sync household (two accounts, the real audit writer) with an Enable Banking source pointed //! at the local stub, signing with a key generated for the test. Fixtures are invented merchants and amounts with //! K0's field presence (D-179): `entry_reference` on every row, `remittance_information` one line, the holder's //! name, a `CPAN` card number and the merchant's address present (they must never be stored, logged or served). use std::sync::Arc; use std::time::Duration as StdDuration; use axum::body::Body; use axum::http::{Method, Request}; use chrono::{Days, NaiveDate}; use kvitt_server::cards::Cards; use kvitt_server::cards::enable_banking::EnableBanking; use kvitt_server::router; use serde_json::{Value, json}; use crate::common::*; use crate::stub::{Stub, TestKey, test_key}; use crate::sync_fixtures; pub const APP_ID: &str = "0f0f0f0f-test-application"; /// The callback's user agent (the bank's redirect in the user's browser). pub const BROWSER_UA: &str = "Mozilla/5.0 (Linux; Android 16) KvittTest"; /// The never-stored fields of the fixtures. pub const HOLDER_NAME: &str = "Kortinnehavare Testsson"; pub const CARD_NUMBER: &str = "5299 **** **** 4711"; pub const MERCHANT_TOWN: &str = "Hemligstad"; pub struct CardsApp { pub app: TestApp, pub stub: Stub, pub key: TestKey, } impl std::ops::Deref for CardsApp { type Target = TestApp; fn deref(&self) -> &TestApp { &self.app } } impl CardsApp { /// Both users served by one application (the owner's, D-177). pub async fn new() -> Self { Self::build(true, true).await } /// `admin`, `member`: whether that user has a card application. pub async fn build(admin: bool, member: bool) -> Self { let mut app = sync_fixtures::app().await; let stub = Stub::start().await; let key = test_key(); let source: Arc = Arc::new( EnableBanking::new( APP_ID, key.pem.as_bytes(), &stub.url(), app.state.clock.clone(), ) .unwrap(), ); app.state.cards = Arc::new( Cards::new( admin.then(|| source.clone()), member.then(|| source.clone()), ) .with_refresh_wait(StdDuration::from_secs(10)), ); app.router = router::router(app.state.clone()); Self { app, stub, key } } /// Replaces the card state (and rebuilds the router). pub fn with_cards(&mut self, cards: Cards) { self.app.state.cards = Arc::new(cards); self.app.router = router::router(self.app.state.clone()); } pub async fn start_auth(&self, tok: &str, history_days: i64, client: &str) -> Reply { self.call( Method::POST, "/cards/auth", tok, Some(json!({"provider_id": "sj_prio_mastercard", "history_days": history_days, "client": client})), ) .await } /// `GET /cards/callback?` as the user's browser sends it (no token, a browser user agent). pub async fn callback(&self, query: &str, lang: &str) -> Reply { let request = Request::builder() .method(Method::GET) .uri(format!("/cards/callback?{query}")) .header("user-agent", BROWSER_UA) .header("accept-language", lang) .body(Body::empty()) .unwrap(); self.send(request).await } /// Connects the caller's card (E-37, then the bank's callback) and waits for the first fetch; the connection id. pub async fn connect(&self, tok: &str, history_days: i64) -> String { let attempt = self.start_auth(tok, history_days, "android").await; assert_eq!(attempt.status, 201, "{}", attempt.text()); let state = self.stub.last_state(); let page = self .callback(&format!("code=code-secret-1&state={state}"), "sv") .await; assert!( page.text().contains("Kortet är anslutet"), "{}", page.text() ); let id = attempt.json["id"].as_str().unwrap(); let polled = self .call(Method::GET, &format!("/cards/auth/{id}"), tok, None) .await; let connection = polled.json["connection"]["id"].as_str().unwrap().to_owned(); self.wait_idle(&connection).await; connection } pub async fn wait_idle(&self, connection: &str) { let id = uuid::Uuid::parse_str(connection).unwrap(); for _ in 0..500 { if !self.state.cards.is_fetching(id) { return; } tokio::time::sleep(StdDuration::from_millis(10)).await; } panic!("fetch still running"); } pub async fn refresh(&self, tok: &str, connection: &str) -> Reply { let request = Request::builder() .method(Method::POST) .uri(format!("/cards/connections/{connection}/refresh")) .header("x-kvitt-protocol", "1") .header("authorization", format!("Bearer {tok}")) .header("user-agent", "okhttp/5.0 Kvitt") .header("accept", "application/json") .header("accept-language", "sv-SE") .body(Body::empty()) .unwrap(); self.send(request).await } /// E-40 from 0, one page. pub async fn card_sync(&self, tok: &str) -> Value { let reply = self .call(Method::GET, "/cards/sync?since=0", tok, None) .await; assert_eq!(reply.status, 200, "{}", reply.text()); reply.json } /// The caller's non-deleted purchases, newest card_version last. pub async fn purchases(&self, tok: &str) -> Vec { self.card_sync(tok).await["purchases"] .as_array() .unwrap() .iter() .filter(|p| p["deleted_at"].is_null()) .cloned() .collect() } pub async fn connection(&self, tok: &str) -> Value { self.card_sync(tok).await["connections"][0].clone() } /// Runs one fetch of a user-present kind through E-42 (after the 60-second rule has passed). pub async fn refetch(&self, tok: &str, connection: &str) -> Reply { let now = self.state.clock.now(); self.clock.set(now + chrono::Duration::seconds(61)); let reply = self.refresh(tok, connection).await; assert_eq!(reply.status, 200, "{}", reply.text()); self.wait_idle(connection).await; reply } } /// Today in the harness (NOW is 2026-10-17 in Stockholm). pub fn today() -> NaiveDate { NaiveDate::from_ymd_opt(2026, 10, 17).unwrap() } /// `n` days before today, `YYYY-MM-DD`. pub fn days_ago(n: u64) -> String { today() .checked_sub_days(Days::new(n)) .unwrap() .format("%Y-%m-%d") .to_string() } /// A transaction as the SJ Prio card reports it (K0). pub fn tx( reference: Option<&str>, status: &str, indicator: &str, date: &str, amount: &str, merchant: &str, ) -> Value { let mut t = json!({ "transaction_id": format!("tid-{}", reference.unwrap_or("none")), "status": status, "credit_debit_indicator": indicator, "value_date": date, "transaction_amount": {"amount": amount, "currency": "SEK"}, "remittance_information": [merchant], "debtor": {"name": HOLDER_NAME}, "debtor_account": {"other": {"identification": CARD_NUMBER, "scheme_name": "CPAN"}}, "creditor": {"postal_address": {"country": "SE", "town_name": MERCHANT_TOWN}}, "bank_transaction_code": {"code": "PURCHASE"}, }); let o = t.as_object_mut().unwrap(); if let Some(r) = reference { o.insert("entry_reference".into(), json!(r)); } if status == "BOOK" { o.insert("booking_date".into(), json!(date)); } t } pub fn booked(reference: &str, date: &str, amount: &str, merchant: &str) -> Value { tx(Some(reference), "BOOK", "DBIT", date, amount, merchant) } pub fn pending(reference: &str, date: &str, amount: &str, merchant: &str) -> Value { tx(Some(reference), "PDNG", "DBIT", date, amount, merchant) } /// `n` booked purchases on `date`, references `-`. pub fn many(prefix: &str, date: &str, n: usize) -> Vec { (0..n) .map(|i| { booked( &format!("{prefix}-{i}"), date, &format!("{}.00", 10 + i), "Testbutiken Många", ) }) .collect() } pub fn by_merchant<'a>(purchases: &'a [Value], merchant: &str) -> &'a Value { purchases .iter() .find(|p| p["merchant"] == merchant) .unwrap_or_else(|| panic!("no purchase {merchant}: {purchases:#?}")) }