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)
This commit is contained in:
@@ -426,6 +426,25 @@ impl FappRouter {
|
|||||||
let out = std::mem::take(&mut *q);
|
let out = std::mem::take(&mut *q);
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register a therapist's public key for signature verification.
|
||||||
|
pub fn register_therapist_key(&self, address: [u8; 16], public_key: [u8; 32]) -> Result<()> {
|
||||||
|
let mut store = self
|
||||||
|
.store
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| anyhow::anyhow!("store lock poisoned: {e}"))?;
|
||||||
|
store.register_therapist_key(address, public_key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a slot announcement directly (for testing or local therapist).
|
||||||
|
pub fn store_announce(&self, announce: SlotAnnounce) -> Result<bool> {
|
||||||
|
let mut store = self
|
||||||
|
.store
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| anyhow::anyhow!("store lock poisoned: {e}"))?;
|
||||||
|
Ok(store.store(announce))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
387
crates/quicprochat-p2p/tests/fapp_flow.rs
Normal file
387
crates/quicprochat-p2p/tests/fapp_flow.rs
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
//! 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user