//! The background fetch (SPEC §9.12 CP-4, CP-6): the four Stockholm slots, no user headers, the 2-hour rule, the //! 4-per-24-hours quota, no backlog after downtime, an ended consent expiring the connection; driven with the //! scripted clock through `poller::tick`. use kvitt_server::cards::poller; use serde_json::json; use crate::common::*; use crate::support::*; #[tokio::test] async fn slots_run_in_the_background_without_user_headers_and_skip_a_recent_success() { let app = CardsApp::new().await; app.stub.set_transactions(vec![booked( "r1", &days_ago(1), "10.00", "Testkaféet Ankan", )]); app.connect(&admin_token(), 30).await; // a user-present fetch at 12:00Z (14:00 local) app.stub.clear(); let mut last = None; // 11:45 local is due, but a fetch succeeded less than 2 hours ago. assert_eq!(poller::tick(&app.state, &mut last).await, 0); assert!(app.stub.transaction_requests().is_empty()); // 16:45 local (14:45Z): due. app.set_now("2026-10-17T14:45:00.000Z"); assert_eq!(poller::tick(&app.state, &mut last).await, 1); let requests = app.stub.transaction_requests(); assert_eq!( requests.len(), 1, "one window: 10 days before the latest booking" ); assert!( requests[0].psu_headers().is_empty(), "a background fetch sends no PSU header (CP-5)" ); // The same slot never runs twice. app.set_now("2026-10-17T15:30:00.000Z"); assert_eq!(poller::tick(&app.state, &mut last).await, 0); let starts: String = sqlx::query_scalar("SELECT background_fetches FROM card_connections") .fetch_one(&app.state.db) .await .unwrap(); assert_eq!(starts, r#"["2026-10-17T14:45:00.000Z"]"#); } #[tokio::test] async fn four_background_fetches_in_24_hours_are_the_limit() { let app = CardsApp::new().await; app.connect(&admin_token(), 7).await; sqlx::query("UPDATE card_connections SET last_fetch_at = '2026-10-16T00:00:00.000Z', background_fetches = ?") .bind(json!(["2026-10-16T19:50:00.000Z", "2026-10-17T04:45:00.000Z", "2026-10-17T09:45:00.000Z", "2026-10-17T14:45:00.000Z"]).to_string()) .execute(&app.state.db) .await .unwrap(); app.stub.clear(); let mut last = None; app.set_now("2026-10-17T19:45:00.000Z"); assert_eq!( poller::tick(&app.state, &mut last).await, 0, "4 started in the preceding 24 hours" ); // The next morning the oldest two have left the window. app.set_now("2026-10-18T04:46:00.000Z"); assert_eq!(poller::tick(&app.state, &mut last).await, 1); assert_eq!(app.stub.transaction_requests().len(), 1); } #[tokio::test] async fn after_downtime_only_the_due_slot_runs() { let app = CardsApp::new().await; app.connect(&admin_token(), 7).await; let mut last = None; app.set_now("2026-10-17T14:45:30.000Z"); assert_eq!(poller::tick(&app.state, &mut last).await, 1); app.stub.clear(); // Down for two days and a half: eight slots passed, one runs. app.set_now("2026-10-20T03:00:00.000Z"); assert_eq!(poller::tick(&app.state, &mut last).await, 1); assert_eq!(poller::tick(&app.state, &mut last).await, 0); assert_eq!(app.stub.transaction_requests().len(), 1); } #[tokio::test] async fn an_ended_consent_expires_the_connection_and_nothing_is_fetched() { let app = CardsApp::new().await; app.connect(&admin_token(), 7).await; app.stub.clear(); let mut last = None; app.set_now("2027-04-15T12:00:00.000Z"); assert_eq!(poller::tick(&app.state, &mut last).await, 0); assert!(app.stub.transaction_requests().is_empty()); let c = app.connection(&admin_token()).await; assert_eq!(c["status"], "expired"); }