Files
Christian Nennemann 6ae3251ebd feat(fapp): add full integration tests for FAPP flow
New tests/fapp_flow.rs with 3 integration tests:
- full_fapp_flow_announce_query_reserve_confirm: Complete flow
  from therapist announcement through patient reservation to
  confirmation with E2E encryption
- fapp_rejection_flow: Tests the rejection case
- fapp_query_filters: Tests Fachrichtung, PLZ, and other filters

FappRouter additions:
- register_therapist_key(): public method for key registration
- store_announce(): public method for storing announcements

Total tests: 217 (198 lib + 3 fapp_flow + 16 multi_node)
2026-04-01 16:35:57 +02:00

388 lines
14 KiB
Rust

//! FAPP end-to-end integration test.
//!
//! Tests the complete flow: therapist announces → patient queries →
//! patient reserves → therapist confirms.
use std::sync::{Arc, RwLock};
use std::time::Duration;
use quicprochat_p2p::fapp::{
Fachrichtung, FappStore, Kostentraeger, Modalitaet, PatientCrypto, PatientEphemeralKey,
SlotAnnounce, SlotQuery, SlotType, TherapistCrypto, TimeSlot, CAP_FAPP_PATIENT,
CAP_FAPP_RELAY, CAP_FAPP_THERAPIST,
};
use quicprochat_p2p::fapp_router::{FappAction, FappRouter};
use quicprochat_p2p::identity::MeshIdentity;
use quicprochat_p2p::routing_table::RoutingTable;
use quicprochat_p2p::transport_manager::TransportManager;
fn future_timestamp() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
+ 86400 // tomorrow
}
/// Helper to create a FappRouter with given capabilities.
fn make_router(capabilities: u16) -> (FappRouter, Arc<RwLock<RoutingTable>>) {
let routes = Arc::new(RwLock::new(RoutingTable::new(Duration::from_secs(300))));
let transports = Arc::new(TransportManager::new());
let store = FappStore::new();
let router = FappRouter::new(store, Arc::clone(&routes), transports, capabilities);
(router, routes)
}
#[test]
fn full_fapp_flow_announce_query_reserve_confirm() {
// =========================================================================
// Setup: Therapist, Relay, Patient
// =========================================================================
let therapist_id = MeshIdentity::generate();
let therapist_crypto = TherapistCrypto::new(MeshIdentity::from_seed(therapist_id.seed_bytes()));
// Therapist node (publishes slots)
let (therapist_router, _) = make_router(CAP_FAPP_THERAPIST | CAP_FAPP_RELAY);
// Relay node (caches and forwards)
let (relay_router, _) = make_router(CAP_FAPP_RELAY);
// Patient node (queries and reserves)
let (patient_router, _) = make_router(CAP_FAPP_PATIENT | CAP_FAPP_RELAY);
// =========================================================================
// Step 1: Therapist announces slots
// =========================================================================
let slots = vec![
TimeSlot {
start_unix: future_timestamp(),
duration_minutes: 50,
slot_type: SlotType::Erstgespraech,
},
TimeSlot {
start_unix: future_timestamp() + 3600,
duration_minutes: 50,
slot_type: SlotType::Probatorik,
},
];
let announce = SlotAnnounce::new(
&therapist_id,
vec![Fachrichtung::Verhaltenstherapie],
vec![Modalitaet::Praxis, Modalitaet::Video],
vec![Kostentraeger::GKV, Kostentraeger::Selbstzahler],
"80331".into(), // Munich
slots,
[0xAA; 32], // Approbation hash
1, // sequence
);
let announce_id = announce.id;
let therapist_addr = announce.therapist_address;
// Serialize to wire format
let announce_wire = announce.to_wire();
// =========================================================================
// Step 2: Relay receives and stores the announcement
// =========================================================================
// Simulate wire reception at relay
let mut relay_wire = vec![0x01]; // FAPP_WIRE_ANNOUNCE tag
relay_wire.extend_from_slice(&announce_wire);
// Relay needs the therapist's public key to verify
relay_router
.register_therapist_key(therapist_addr, therapist_id.public_key())
.expect("register key");
let action = relay_router.handle_incoming(&relay_wire);
// Relay should store and forward (but no routes, so just ignore forward failure)
match action {
FappAction::Forward { .. } | FappAction::Ignore => {
// Expected: either forward to neighbors or ignore if no routes
}
other => panic!("Expected Forward or Ignore, got {:?}", other),
}
// =========================================================================
// Step 3: Patient queries for therapists
// =========================================================================
let query = SlotQuery {
query_id: [0x42; 16],
fachrichtung: Some(Fachrichtung::Verhaltenstherapie),
modalitaet: Some(Modalitaet::Video),
kostentraeger: Some(Kostentraeger::GKV),
plz_prefix: Some("803".into()), // Munich area
earliest: None,
latest: None,
slot_type: Some(SlotType::Erstgespraech),
max_results: 10,
};
// Relay processes query and returns matches
let action = relay_router.process_slot_query(query.clone());
let response = match action {
FappAction::QueryResponse(r) => r,
other => panic!("Expected QueryResponse, got {:?}", other),
};
assert_eq!(response.query_id, [0x42; 16]);
assert_eq!(response.matches.len(), 1, "Should find one matching therapist");
assert_eq!(response.matches[0].therapist_address, therapist_addr);
// =========================================================================
// Step 4: Patient creates and sends a reservation
// =========================================================================
let patient_ephemeral = PatientEphemeralKey::generate();
let patient_pub = patient_ephemeral.public_bytes();
let patient_crypto = PatientCrypto::new(patient_ephemeral);
let contact_info = b"email: patient@example.com, Tel: +49 89 12345678";
let reserve = patient_crypto
.create_reserve(
announce_id,
0, // First slot (Erstgespraech)
contact_info,
&therapist_crypto.x25519_public(),
)
.expect("create reserve");
assert_eq!(reserve.slot_announce_id, announce_id);
assert_eq!(reserve.slot_index, 0);
// =========================================================================
// Step 5: Relay routes reserve to therapist
// =========================================================================
// Relay receives the reserve
let reserve_wire = reserve.to_wire();
let mut relay_reserve_wire = vec![0x04]; // FAPP_WIRE_RESERVE
relay_reserve_wire.extend_from_slice(&reserve_wire);
let action = relay_router.handle_incoming(&relay_reserve_wire);
match action {
FappAction::DeliverReserve { therapist_address, reserve: r } => {
assert_eq!(therapist_address, therapist_addr);
assert_eq!(r.slot_index, 0);
}
FappAction::Forward { .. } => {
// Also acceptable if we're flooding to find therapist
}
other => panic!("Expected DeliverReserve or Forward, got {:?}", other),
}
// =========================================================================
// Step 6: Therapist decrypts reserve and sees contact info
// =========================================================================
let decrypted_contact = therapist_crypto
.decrypt_reserve(&reserve)
.expect("therapist decrypt");
assert_eq!(decrypted_contact, contact_info);
// =========================================================================
// Step 7: Therapist creates confirmation
// =========================================================================
let details = b"Termin bestaetigt! Praxis: Leopoldstr. 42, 80802 Muenchen. Bitte 5 min vorher da sein.";
let confirm = therapist_crypto
.create_confirm(
announce_id,
0,
true, // confirmed
details,
&patient_pub,
)
.expect("create confirm");
assert!(confirm.confirmed);
// =========================================================================
// Step 8: Patient receives and decrypts confirmation
// =========================================================================
// Simulate wire reception at patient
let confirm_wire = confirm.to_wire();
let mut patient_confirm_wire = vec![0x05]; // FAPP_WIRE_CONFIRM
patient_confirm_wire.extend_from_slice(&confirm_wire);
let action = patient_router.handle_incoming(&patient_confirm_wire);
match action {
FappAction::DeliverConfirm { confirm: c, .. } => {
assert!(c.confirmed);
assert_eq!(c.slot_announce_id, announce_id);
}
other => panic!("Expected DeliverConfirm, got {:?}", other),
}
let decrypted_details = patient_crypto
.decrypt_confirm(&confirm)
.expect("patient decrypt");
assert_eq!(decrypted_details, details);
println!("=== FAPP Flow Complete ===");
println!("Therapist announced: {:?}", hex::encode(&therapist_addr[..4]));
println!("Patient reserved slot 0 (Erstgespraech)");
println!("Therapist confirmed appointment");
println!("Patient decrypted: {}", String::from_utf8_lossy(&decrypted_details));
}
#[test]
fn fapp_rejection_flow() {
// Test the rejection case: therapist declines reservation
let therapist_id = MeshIdentity::generate();
let therapist_crypto = TherapistCrypto::new(MeshIdentity::from_seed(therapist_id.seed_bytes()));
let patient_ephemeral = PatientEphemeralKey::generate();
let patient_pub = patient_ephemeral.public_bytes();
let patient_crypto = PatientCrypto::new(patient_ephemeral);
// Patient reserves
let reserve = patient_crypto
.create_reserve(
[0xAA; 16],
0,
b"patient@example.com",
&therapist_crypto.x25519_public(),
)
.expect("create reserve");
// Therapist sees it's already booked and rejects
let rejection = therapist_crypto
.create_confirm(
reserve.slot_announce_id,
reserve.slot_index,
false, // rejected
b"Termin leider bereits vergeben. Bitte waehlen Sie einen anderen Slot.",
&patient_pub,
)
.expect("create rejection");
assert!(!rejection.confirmed);
// Patient decrypts rejection
let decrypted = patient_crypto
.decrypt_confirm(&rejection)
.expect("decrypt rejection");
assert!(String::from_utf8_lossy(&decrypted).contains("bereits vergeben"));
}
#[test]
fn fapp_query_filters() {
// Test that query filters work correctly
let (router, _) = make_router(CAP_FAPP_RELAY);
// Add two therapists with different specializations
let vt_therapist = MeshIdentity::generate();
let tp_therapist = MeshIdentity::generate();
let vt_announce = SlotAnnounce::new(
&vt_therapist,
vec![Fachrichtung::Verhaltenstherapie],
vec![Modalitaet::Video],
vec![Kostentraeger::GKV],
"80331".into(),
vec![TimeSlot {
start_unix: future_timestamp(),
duration_minutes: 50,
slot_type: SlotType::Erstgespraech,
}],
[0x11; 32],
1,
);
let tp_announce = SlotAnnounce::new(
&tp_therapist,
vec![Fachrichtung::TiefenpsychologischFundiert],
vec![Modalitaet::Praxis],
vec![Kostentraeger::PKV],
"10115".into(), // Berlin
vec![TimeSlot {
start_unix: future_timestamp(),
duration_minutes: 50,
slot_type: SlotType::Therapie,
}],
[0x22; 32],
1,
);
// Register and store both
router.register_therapist_key(vt_announce.therapist_address, vt_therapist.public_key()).unwrap();
router.register_therapist_key(tp_announce.therapist_address, tp_therapist.public_key()).unwrap();
router.store_announce(vt_announce.clone()).unwrap();
router.store_announce(tp_announce.clone()).unwrap();
// Query for VT only
let vt_query = SlotQuery {
query_id: [0x01; 16],
fachrichtung: Some(Fachrichtung::Verhaltenstherapie),
modalitaet: None,
kostentraeger: None,
plz_prefix: None,
earliest: None,
latest: None,
slot_type: None,
max_results: 10,
};
let response = match router.process_slot_query(vt_query) {
FappAction::QueryResponse(r) => r,
other => panic!("Expected QueryResponse, got {:?}", other),
};
assert_eq!(response.matches.len(), 1);
assert_eq!(response.matches[0].therapist_address, vt_announce.therapist_address);
// Query for TP only
let tp_query = SlotQuery {
query_id: [0x02; 16],
fachrichtung: Some(Fachrichtung::TiefenpsychologischFundiert),
modalitaet: None,
kostentraeger: None,
plz_prefix: None,
earliest: None,
latest: None,
slot_type: None,
max_results: 10,
};
let response = match router.process_slot_query(tp_query) {
FappAction::QueryResponse(r) => r,
other => panic!("Expected QueryResponse, got {:?}", other),
};
assert_eq!(response.matches.len(), 1);
assert_eq!(response.matches[0].therapist_address, tp_announce.therapist_address);
// Query for Berlin (PLZ 101...)
let berlin_query = SlotQuery {
query_id: [0x03; 16],
fachrichtung: None,
modalitaet: None,
kostentraeger: None,
plz_prefix: Some("101".into()),
earliest: None,
latest: None,
slot_type: None,
max_results: 10,
};
let response = match router.process_slot_query(berlin_query) {
FappAction::QueryResponse(r) => r,
other => panic!("Expected QueryResponse, got {:?}", other),
};
assert_eq!(response.matches.len(), 1);
assert_eq!(response.matches[0].therapist_address, tp_announce.therapist_address);
}