671c00d0d8
Build Kvitt images / build (push) Failing after 8m17s
Contract (SPEC §14.8): migration 0004_card_purchases.sql (server schema 4, byte-identical contract and server copies), E-36 to E-44 in contracts/openapi.yaml with the four new error codes and a 502 response, examples for every card body and error code, the match_card_purchase vectors (17 cases) with their Python reference in tools/contracts-check.py (157 checks), web/src/api/schema.d.ts regenerated; web/src/lib/vectors.test.ts lists match_card_purchase as not ported until K3 ports it (orchestrator's allowance). Server: src/cards/ with the provider registry (CP-1), the CardSource trait and its Enable Banking adapter (JWT RS256 kid = application id, /aspsps, /auth, /sessions, transactions with PSU headers for user-present fetches, CP-14 error mapping, CP-7 mapping), the windowed fetch (30-day windows newest first, split at the 100-row cap down to a day, de-duplication by provider_ref, continuation keys, WRONG_TRANSACTIONS_PERIOD ends the walk), CP-8 matching with the secondary rule, the background poller (06:45, 11:45, 16:45, 21:45, 4 per 24 hours, 2-hour skip, no backlog), the bilingual callback page (E-39), E-36 to E-38 and E-40 to E-44, link clearing in sync push step 7, attempt pruning at 04:00, the card_fetch log line with aggregates only, KVITT_EB_* configuration. Tests against a local stub (tests/cards_api/, 34 tests); the real API is never called. Infra: Pi compose mounts secrets/cards read-only and passes the two application ids; .env.example keys; dev stack stages infra/secrets/cards/; NPM block with the /cards/callback gate line and unlogged location (kvitt.conf and README step 4); README "Card purchases". Pre-commit hook bypassed (--no-verify, orchestrator's decision): the Android fast tests fail in :domain VectorFilesTest (and the full check in :data ExampleRoundTripTest) because they pin the contract's vector and example sets; matchCardPurchase and the card DTOs are K2's by design (contract-first landing; K2 lands next). Server, contracts-check and web gates are green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
103 lines
3.7 KiB
Rust
103 lines
3.7 KiB
Rust
//! 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");
|
|
}
|