//! Free Appointment Propagation Protocol (FAPP) for psychotherapy slot discovery. //! //! FAPP is an optional mesh extension that lets licensed therapists announce free //! appointment slots. Patients discover slots via anonymous mesh queries. No //! central registry — the mesh IS the directory. //! //! Privacy model: //! - Therapist identity is **public** (licensed professional, Approbation bound). //! - Patient queries are **anonymous** (no identifying information). use std::collections::{HashMap, HashSet, VecDeque}; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::announce::compute_address; use crate::identity::MeshIdentity; // --------------------------------------------------------------------------- // Capability flags (extend announce.rs bitfield) // --------------------------------------------------------------------------- /// Node is a licensed therapist publishing slots. pub const CAP_FAPP_THERAPIST: u16 = 0x0100; /// Node caches and relays FAPP slot announcements. pub const CAP_FAPP_RELAY: u16 = 0x0200; /// Node can issue anonymous slot queries. pub const CAP_FAPP_PATIENT: u16 = 0x0400; // --------------------------------------------------------------------------- // Domain enums (German names — these are domain concepts) // --------------------------------------------------------------------------- /// Therapy specialization (Richtlinienverfahren). #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Fachrichtung { Verhaltenstherapie, TiefenpsychologischFundiert, Analytisch, Systemisch, KinderJugend, } /// Session modality. #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Modalitaet { Praxis, Video, Hybrid, } /// Insurance / payment type. #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Kostentraeger { GKV, PKV, Selbstzahler, } /// Appointment type within the German therapy system. #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum SlotType { /// Psychotherapeutische Sprechstunde (initial consultation). Erstgespraech, /// Probatorische Sitzungen (trial sessions). Probatorik, /// Regular therapy session. Therapie, /// Akutbehandlung (acute/crisis treatment). Akut, } // --------------------------------------------------------------------------- // Time slot // --------------------------------------------------------------------------- /// A single free time slot. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TimeSlot { /// Start time in Unix seconds. pub start_unix: u64, /// Duration in minutes (typically 50 or 25). pub duration_minutes: u16, /// Type of appointment. pub slot_type: SlotType, } // --------------------------------------------------------------------------- // SlotAnnounce // --------------------------------------------------------------------------- /// A therapist's announcement of free appointment slots. /// /// Propagates through the mesh like [`MeshAnnounce`](crate::announce::MeshAnnounce), /// cached by relay nodes with `CAP_FAPP_RELAY`. /// /// # Security Note /// /// Patients should verify therapists before booking. The `profile_url` field /// allows cross-referencing with official sources (Jameda, KBV, practice website). /// See `docs/specs/fapp-security.md` for the full security model. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SlotAnnounce { /// Unique announcement ID. pub id: [u8; 16], /// MeshAddress of the therapist node. pub therapist_address: [u8; 16], /// Therapy specializations offered. pub fachrichtung: Vec, /// Session modalities. pub modalitaet: Vec, /// Accepted insurance / payment types. pub kostentraeger: Vec, /// Postal code (PLZ) only — never exact address. pub location_hint: String, /// Available time slots. pub slots: Vec, /// SHA-256 of the therapist's Approbation number. pub approbation_hash: [u8; 32], /// Optional URL to therapist's public profile for verification. /// Examples: Jameda profile, KBV listing, practice website. #[serde(default, skip_serializing_if = "Option::is_none")] pub profile_url: Option, /// Monotonically increasing per therapist (dedup/supersede). pub sequence: u64, /// Time-to-live in hours (default 168 = 7 days). pub ttl_hours: u16, /// Unix timestamp of creation. pub timestamp: u64, /// Ed25519 signature over all fields except `signature` and `hop_count`. pub signature: Vec, /// Current hop count (incremented on re-broadcast). pub hop_count: u8, /// Maximum propagation hops. pub max_hops: u8, } /// Default TTL for slot announcements: 7 days. const DEFAULT_TTL_HOURS: u16 = 168; /// Default maximum hops for slot propagation. const DEFAULT_MAX_HOPS: u8 = 8; /// Maximum announcements cached per therapist. const MAX_PER_THERAPIST: usize = 50; /// Maximum total announcements in the FAPP store. const MAX_TOTAL_ANNOUNCES: usize = 10_000; /// Maximum seen IDs for deduplication. const MAX_SEEN_IDS: usize = 50_000; impl SlotAnnounce { /// Create and sign a new slot announcement. pub fn new( identity: &MeshIdentity, fachrichtung: Vec, modalitaet: Vec, kostentraeger: Vec, location_hint: String, slots: Vec, approbation_hash: [u8; 32], sequence: u64, ) -> Self { Self::with_profile( identity, fachrichtung, modalitaet, kostentraeger, location_hint, slots, approbation_hash, sequence, None, ) } /// Create and sign a new slot announcement with optional profile URL. /// /// The `profile_url` allows patients to verify the therapist's identity /// against official sources (Jameda, KBV, practice website). pub fn with_profile( identity: &MeshIdentity, fachrichtung: Vec, modalitaet: Vec, kostentraeger: Vec, location_hint: String, slots: Vec, approbation_hash: [u8; 32], sequence: u64, profile_url: Option, ) -> Self { let pk = identity.public_key(); let therapist_address = compute_address(&pk); let mut id = [0u8; 16]; let id_hash = Sha256::digest(&[&therapist_address[..], &sequence.to_le_bytes()].concat()); id.copy_from_slice(&id_hash[..16]); let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); let mut announce = Self { id, therapist_address, fachrichtung, modalitaet, kostentraeger, location_hint, slots, approbation_hash, profile_url, sequence, ttl_hours: DEFAULT_TTL_HOURS, timestamp, signature: Vec::new(), hop_count: 0, max_hops: DEFAULT_MAX_HOPS, }; let signable = announce.signable_bytes(); announce.signature = identity.sign(&signable).to_vec(); announce } /// Assemble the byte string that is signed / verified. /// /// `hop_count` and `signature` are excluded — forwarding nodes increment /// hop_count without re-signing (same design as MeshAnnounce). fn signable_bytes(&self) -> Vec { let mut buf = Vec::with_capacity(256); buf.extend_from_slice(&self.id); buf.extend_from_slice(&self.therapist_address); // Encode fachrichtung as indices for deterministic signing. for f in &self.fachrichtung { buf.push(fachrichtung_index(f)); } buf.push(0xFF); // separator for m in &self.modalitaet { buf.push(modalitaet_index(m)); } buf.push(0xFF); for k in &self.kostentraeger { buf.push(kostentraeger_index(k)); } buf.push(0xFF); buf.extend_from_slice(self.location_hint.as_bytes()); buf.push(0xFF); for slot in &self.slots { buf.extend_from_slice(&slot.start_unix.to_le_bytes()); buf.extend_from_slice(&slot.duration_minutes.to_le_bytes()); buf.push(slot_type_index(&slot.slot_type)); } buf.push(0xFF); buf.extend_from_slice(&self.approbation_hash); // profile_url is signed to prevent tampering. if let Some(ref url) = self.profile_url { buf.push(0x01); // present marker buf.extend_from_slice(url.as_bytes()); } else { buf.push(0x00); // absent marker } buf.push(0xFF); buf.extend_from_slice(&self.sequence.to_le_bytes()); buf.extend_from_slice(&self.ttl_hours.to_le_bytes()); buf.extend_from_slice(&self.timestamp.to_le_bytes()); buf.push(self.max_hops); buf } /// Verify the Ed25519 signature on this announcement. /// /// Requires the therapist's public key (32 bytes) since the announce only /// carries the truncated address. pub fn verify(&self, therapist_public_key: &[u8; 32]) -> bool { // Verify that the address matches the key. let expected_addr = compute_address(therapist_public_key); if expected_addr != self.therapist_address { return false; } let sig: [u8; 64] = match self.signature.as_slice().try_into() { Ok(s) => s, Err(_) => return false, }; let signable = self.signable_bytes(); quicprochat_core::IdentityKeypair::verify_raw(therapist_public_key, &signable, &sig) .is_ok() } /// Check whether this announcement has expired. pub fn is_expired(&self) -> bool { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); let ttl_secs = u64::from(self.ttl_hours) * 3600; now.saturating_sub(self.timestamp) > ttl_secs } /// Create a forwarded copy with `hop_count` incremented. pub fn forwarded(&self) -> Self { let mut copy = self.clone(); copy.hop_count = copy.hop_count.saturating_add(1); copy } /// Whether this announce can still propagate. pub fn can_propagate(&self) -> bool { self.hop_count < self.max_hops && !self.is_expired() } /// Serialize to CBOR for wire transmission. pub fn to_wire(&self) -> Vec { let mut buf = Vec::new(); ciborium::into_writer(self, &mut buf).expect("CBOR serialization should not fail"); buf } /// Deserialize from CBOR. pub fn from_wire(bytes: &[u8]) -> anyhow::Result { let announce: Self = ciborium::from_reader(bytes)?; Ok(announce) } } // --------------------------------------------------------------------------- // SlotQuery // --------------------------------------------------------------------------- /// Anonymous query for available therapy slots. /// /// Carries no patient identity. Propagated through the mesh; relay nodes with /// cached SlotAnnounces can respond directly. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SlotQuery { /// Random query ID for response correlation. pub query_id: [u8; 16], /// Filter by specialization. pub fachrichtung: Option, /// Filter by modality. pub modalitaet: Option, /// Filter by insurance type. pub kostentraeger: Option, /// Filter by PLZ prefix (e.g. "80" for Munich area). pub plz_prefix: Option, /// Earliest acceptable slot (Unix seconds). pub earliest: Option, /// Latest acceptable slot (Unix seconds). pub latest: Option, /// Filter by appointment type. pub slot_type: Option, /// Maximum results requested. pub max_results: u8, } // --------------------------------------------------------------------------- // SlotResponse // --------------------------------------------------------------------------- /// Response to a SlotQuery with matching announcements. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SlotResponse { /// Correlates to the original query. pub query_id: [u8; 16], /// Matching slot announcements (full, so patient can verify signatures). pub matches: Vec, } // --------------------------------------------------------------------------- // SlotReserve // --------------------------------------------------------------------------- /// Patient claims a specific slot. The `encrypted_contact` field is E2E /// encrypted to the therapist's public key — the mesh cannot read it. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SlotReserve { /// ID of the SlotAnnounce being reserved. pub slot_announce_id: [u8; 16], /// Index into the SlotAnnounce's slots vector. pub slot_index: u16, /// X25519 ephemeral public key for reply encryption. pub patient_ephemeral_key: [u8; 32], /// Patient contact info, encrypted to therapist's key. pub encrypted_contact: Vec, } // --------------------------------------------------------------------------- // SlotConfirm // --------------------------------------------------------------------------- /// Therapist confirms or rejects a reservation. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SlotConfirm { /// Original SlotAnnounce ID. pub slot_announce_id: [u8; 16], /// Slot index. pub slot_index: u16, /// Whether the reservation is accepted. pub confirmed: bool, /// Appointment details, encrypted to patient's ephemeral key. pub encrypted_details: Vec, } // --------------------------------------------------------------------------- // FappStore — in-memory cache of active slot announcements // --------------------------------------------------------------------------- /// In-memory cache of active [`SlotAnnounce`]s for relay nodes. /// /// Mirrors the design of [`MeshStore`](crate::store::MeshStore) with /// FAPP-specific dedup (therapist_address + sequence) and query matching. pub struct FappStore { /// Active announces keyed by therapist address. announces: HashMap<[u8; 16], Vec>, /// Known therapist public keys (needed for signature verification). known_keys: HashMap<[u8; 16], [u8; 32]>, /// Seen announce IDs for deduplication. seen: HashSet<[u8; 16]>, /// Insertion-ordered seen IDs for bounded eviction. seen_order: VecDeque<[u8; 16]>, /// Highest sequence seen per therapist (supersede older). latest_sequence: HashMap<[u8; 16], u64>, } impl FappStore { /// Create an empty FAPP store. pub fn new() -> Self { Self { announces: HashMap::new(), known_keys: HashMap::new(), seen: HashSet::new(), seen_order: VecDeque::new(), latest_sequence: HashMap::new(), } } /// Register a therapist's public key (needed for signature verification). pub fn register_therapist_key(&mut self, address: [u8; 16], public_key: [u8; 32]) { self.known_keys.insert(address, public_key); } /// Store a slot announcement. Returns `false` if rejected (dedup, invalid, /// expired, or at capacity). pub fn store(&mut self, announce: SlotAnnounce) -> bool { // Reject expired. if announce.is_expired() { return false; } // Dedup by ID. if self.seen.contains(&announce.id) { return false; } // Sequence check: reject if we already have a higher sequence for this therapist. if let Some(&latest) = self.latest_sequence.get(&announce.therapist_address) { if announce.sequence < latest { return false; } } // Verify signature if we know the therapist's key. if let Some(pk) = self.known_keys.get(&announce.therapist_address) { if !announce.verify(pk) { return false; } } let addr = announce.therapist_address; // Capacity check per therapist (evict oldest if needed). { let queue = self.announces.entry(addr).or_default(); if queue.len() >= MAX_PER_THERAPIST { queue.remove(0); } } // Total capacity check. let total: usize = self.announces.values().map(|q| q.len()).sum(); if total >= MAX_TOTAL_ANNOUNCES { return false; } // Track seen ID. self.seen.insert(announce.id); self.seen_order.push_back(announce.id); while self.seen_order.len() > MAX_SEEN_IDS { if let Some(old_id) = self.seen_order.pop_front() { self.seen.remove(&old_id); } } // Update latest sequence. self.latest_sequence.insert(addr, announce.sequence); self.announces.entry(addr).or_default().push(announce); true } /// Query the store for matching slot announcements. pub fn query(&self, query: &SlotQuery) -> SlotResponse { let mut matches = Vec::new(); let max = query.max_results as usize; for announces in self.announces.values() { for announce in announces { if announce.is_expired() { continue; } if !matches_query(announce, query) { continue; } matches.push(announce.clone()); if matches.len() >= max { return SlotResponse { query_id: query.query_id, matches, }; } } } SlotResponse { query_id: query.query_id, matches, } } /// Remove all expired announcements. Returns count removed. pub fn gc_expired(&mut self) -> usize { let mut removed = 0; self.announces.retain(|_addr, queue| { let before = queue.len(); queue.retain(|a| !a.is_expired()); removed += before - queue.len(); !queue.is_empty() }); removed } /// Return `(total_announces, unique_therapists)`. pub fn stats(&self) -> (usize, usize) { let total: usize = self.announces.values().map(|q| q.len()).sum(); let therapists = self.announces.len(); (total, therapists) } /// Check whether an announce ID has already been seen. pub fn seen(&self, id: &[u8; 16]) -> bool { self.seen.contains(id) } /// Get all active announces for a therapist address. pub fn get_therapist(&self, address: &[u8; 16]) -> &[SlotAnnounce] { self.announces .get(address) .map(|v| v.as_slice()) .unwrap_or_default() } } impl Default for FappStore { fn default() -> Self { Self::new() } } // --------------------------------------------------------------------------- // Query matching // --------------------------------------------------------------------------- /// Check whether a SlotAnnounce matches the filters in a SlotQuery. fn matches_query(announce: &SlotAnnounce, query: &SlotQuery) -> bool { // Fachrichtung filter. if let Some(ref f) = query.fachrichtung { if !announce.fachrichtung.contains(f) { return false; } } // Modalitaet filter. if let Some(ref m) = query.modalitaet { if !announce.modalitaet.contains(m) { return false; } } // Kostentraeger filter. if let Some(ref k) = query.kostentraeger { if !announce.kostentraeger.contains(k) { return false; } } // PLZ prefix filter. if let Some(ref prefix) = query.plz_prefix { if !announce.location_hint.starts_with(prefix.as_str()) { return false; } } // Slot type filter. if let Some(ref st) = query.slot_type { if !announce.slots.iter().any(|s| &s.slot_type == st) { return false; } } // Time range filter. let has_matching_time = announce.slots.iter().any(|slot| { let end = slot.start_unix + u64::from(slot.duration_minutes) * 60; let after_earliest = query.earliest.map_or(true, |e| end > e); let before_latest = query.latest.map_or(true, |l| slot.start_unix < l); after_earliest && before_latest }); if !has_matching_time && (query.earliest.is_some() || query.latest.is_some()) { return false; } true } // --------------------------------------------------------------------------- // Enum index helpers (for deterministic signable bytes) // --------------------------------------------------------------------------- fn fachrichtung_index(f: &Fachrichtung) -> u8 { match f { Fachrichtung::Verhaltenstherapie => 0, Fachrichtung::TiefenpsychologischFundiert => 1, Fachrichtung::Analytisch => 2, Fachrichtung::Systemisch => 3, Fachrichtung::KinderJugend => 4, } } fn modalitaet_index(m: &Modalitaet) -> u8 { match m { Modalitaet::Praxis => 0, Modalitaet::Video => 1, Modalitaet::Hybrid => 2, } } fn kostentraeger_index(k: &Kostentraeger) -> u8 { match k { Kostentraeger::GKV => 0, Kostentraeger::PKV => 1, Kostentraeger::Selbstzahler => 2, } } fn slot_type_index(s: &SlotType) -> u8 { match s { SlotType::Erstgespraech => 0, SlotType::Probatorik => 1, SlotType::Therapie => 2, SlotType::Akut => 3, } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; fn test_identity() -> MeshIdentity { MeshIdentity::generate() } fn sample_slots() -> Vec { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); vec![ TimeSlot { start_unix: now + 86400, // tomorrow duration_minutes: 50, slot_type: SlotType::Erstgespraech, }, TimeSlot { start_unix: now + 2 * 86400, // day after tomorrow duration_minutes: 50, slot_type: SlotType::Therapie, }, ] } fn sample_announce(identity: &MeshIdentity) -> SlotAnnounce { SlotAnnounce::new( identity, vec![Fachrichtung::Verhaltenstherapie], vec![Modalitaet::Praxis, Modalitaet::Video], vec![Kostentraeger::GKV, Kostentraeger::Selbstzahler], "80331".into(), sample_slots(), [0xAA; 32], 1, ) } #[test] fn create_and_verify() { let id = test_identity(); let announce = sample_announce(&id); let pk = id.public_key(); assert!(announce.verify(&pk), "freshly created announce must verify"); assert_eq!(announce.hop_count, 0); assert_eq!(announce.ttl_hours, DEFAULT_TTL_HOURS); assert_eq!(announce.max_hops, DEFAULT_MAX_HOPS); assert_eq!(announce.sequence, 1); assert_eq!(announce.location_hint, "80331"); assert_eq!(announce.fachrichtung, vec![Fachrichtung::Verhaltenstherapie]); } #[test] fn tampered_fails_verify() { let id = test_identity(); let mut announce = sample_announce(&id); announce.location_hint = "99999".into(); // tamper let pk = id.public_key(); assert!(!announce.verify(&pk), "tampered announce must fail verification"); } #[test] fn wrong_key_fails_verify() { let id = test_identity(); let announce = sample_announce(&id); let other_id = test_identity(); let wrong_pk = other_id.public_key(); assert!( !announce.verify(&wrong_pk), "announce verified with wrong key must fail" ); } #[test] fn forwarded_still_verifies() { let id = test_identity(); let announce = sample_announce(&id); let pk = id.public_key(); assert!(announce.verify(&pk)); let fwd = announce.forwarded(); assert_eq!(fwd.hop_count, 1); assert!(fwd.verify(&pk), "forwarded announce must still verify"); let fwd2 = fwd.forwarded(); assert_eq!(fwd2.hop_count, 2); assert!(fwd2.verify(&pk), "double-forwarded must still verify"); } #[test] fn expired_announce() { let id = test_identity(); let mut announce = sample_announce(&id); announce.timestamp = 0; // far in the past announce.ttl_hours = 1; assert!(announce.is_expired(), "announce from epoch should be expired"); } #[test] fn not_expired() { let id = test_identity(); let announce = sample_announce(&id); assert!(!announce.is_expired(), "fresh announce should not be expired"); } #[test] fn cbor_roundtrip() { let id = test_identity(); let announce = sample_announce(&id); let pk = id.public_key(); let wire = announce.to_wire(); let restored = SlotAnnounce::from_wire(&wire).expect("CBOR deserialize"); assert_eq!(announce.id, restored.id); assert_eq!(announce.therapist_address, restored.therapist_address); assert_eq!(announce.fachrichtung, restored.fachrichtung); assert_eq!(announce.modalitaet, restored.modalitaet); assert_eq!(announce.kostentraeger, restored.kostentraeger); assert_eq!(announce.location_hint, restored.location_hint); assert_eq!(announce.sequence, restored.sequence); assert_eq!(announce.ttl_hours, restored.ttl_hours); assert_eq!(announce.timestamp, restored.timestamp); assert_eq!(announce.signature, restored.signature); assert!(restored.verify(&pk)); } #[test] fn can_propagate() { let id = test_identity(); let announce = sample_announce(&id); assert!(announce.can_propagate()); // Exceed hop limit. let mut at_limit = announce.clone(); at_limit.hop_count = at_limit.max_hops; assert!(!at_limit.can_propagate()); // Expired. let mut expired = announce.clone(); expired.timestamp = 0; expired.ttl_hours = 1; assert!(!expired.can_propagate()); } // ----------------------------------------------------------------------- // FappStore tests // ----------------------------------------------------------------------- #[test] fn store_and_query() { let id = test_identity(); let pk = id.public_key(); let announce = sample_announce(&id); let addr = announce.therapist_address; let mut store = FappStore::new(); store.register_therapist_key(addr, pk); assert!(store.store(announce)); assert_eq!(store.stats(), (1, 1)); // Query matching fachrichtung. let 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 = store.query(&query); assert_eq!(response.matches.len(), 1); assert_eq!(response.query_id, [0x01; 16]); // Query non-matching fachrichtung. let query2 = SlotQuery { query_id: [0x02; 16], fachrichtung: Some(Fachrichtung::Analytisch), modalitaet: None, kostentraeger: None, plz_prefix: None, earliest: None, latest: None, slot_type: None, max_results: 10, }; let response2 = store.query(&query2); assert!(response2.matches.is_empty()); } #[test] fn store_dedup() { let id = test_identity(); let pk = id.public_key(); let announce = sample_announce(&id); let addr = announce.therapist_address; let mut store = FappStore::new(); store.register_therapist_key(addr, pk); assert!(store.store(announce.clone())); assert!(!store.store(announce), "duplicate should be rejected"); assert_eq!(store.stats(), (1, 1)); } #[test] fn store_rejects_expired() { let id = test_identity(); let mut announce = sample_announce(&id); announce.timestamp = 0; announce.ttl_hours = 1; let mut store = FappStore::new(); assert!(!store.store(announce), "expired announce should be rejected"); } #[test] fn store_sequence_supersede() { let id = test_identity(); let pk = id.public_key(); let announce1 = SlotAnnounce::new( &id, vec![Fachrichtung::Verhaltenstherapie], vec![Modalitaet::Praxis], vec![Kostentraeger::GKV], "80331".into(), sample_slots(), [0xAA; 32], 5, // sequence 5 ); let addr = announce1.therapist_address; let mut store = FappStore::new(); store.register_therapist_key(addr, pk); assert!(store.store(announce1)); // Announce with lower sequence should be rejected. let announce_old = SlotAnnounce::new( &id, vec![Fachrichtung::Verhaltenstherapie], vec![Modalitaet::Praxis], vec![Kostentraeger::GKV], "80331".into(), sample_slots(), [0xAA; 32], 3, // sequence 3 < 5 ); assert!( !store.store(announce_old), "older sequence should be rejected" ); } #[test] fn store_rejects_bad_signature() { let id = test_identity(); let pk = id.public_key(); let mut announce = sample_announce(&id); let addr = announce.therapist_address; // Tamper after signing. announce.location_hint = "99999".into(); let mut store = FappStore::new(); store.register_therapist_key(addr, pk); assert!( !store.store(announce), "tampered announce should be rejected by store" ); } #[test] fn store_gc_expired() { let id = test_identity(); let pk = id.public_key(); let mut announce = sample_announce(&id); let addr = announce.therapist_address; announce.timestamp = 0; announce.ttl_hours = 1; // Bypass normal store validation to insert an expired announce. let mut store = FappStore::new(); store.register_therapist_key(addr, pk); store.announces.entry(addr).or_default().push(announce); assert_eq!(store.stats(), (1, 1)); let removed = store.gc_expired(); assert_eq!(removed, 1); assert_eq!(store.stats(), (0, 0)); } #[test] fn query_plz_prefix() { let id = test_identity(); let pk = id.public_key(); let announce = sample_announce(&id); // PLZ 80331 let addr = announce.therapist_address; let mut store = FappStore::new(); store.register_therapist_key(addr, pk); store.store(announce); // Match: "80" prefix. let query = SlotQuery { query_id: [0; 16], fachrichtung: None, modalitaet: None, kostentraeger: None, plz_prefix: Some("80".into()), earliest: None, latest: None, slot_type: None, max_results: 10, }; assert_eq!(store.query(&query).matches.len(), 1); // No match: "10" prefix (Berlin). let query2 = SlotQuery { plz_prefix: Some("10".into()), ..query }; assert!(store.query(&query2).matches.is_empty()); } #[test] fn query_slot_type_filter() { let id = test_identity(); let pk = id.public_key(); let announce = sample_announce(&id); // has Erstgespraech + Therapie let addr = announce.therapist_address; let mut store = FappStore::new(); store.register_therapist_key(addr, pk); store.store(announce); // Match: Erstgespraech. let query = SlotQuery { query_id: [0; 16], fachrichtung: None, modalitaet: None, kostentraeger: None, plz_prefix: None, earliest: None, latest: None, slot_type: Some(SlotType::Erstgespraech), max_results: 10, }; assert_eq!(store.query(&query).matches.len(), 1); // No match: Akut. let query2 = SlotQuery { slot_type: Some(SlotType::Akut), ..query }; assert!(store.query(&query2).matches.is_empty()); } #[test] fn query_max_results() { let id = test_identity(); let pk = id.public_key(); let addr = compute_address(&pk); let mut store = FappStore::new(); store.register_therapist_key(addr, pk); // Insert 5 announces with different sequences. for seq in 1..=5 { let announce = SlotAnnounce::new( &id, vec![Fachrichtung::Verhaltenstherapie], vec![Modalitaet::Praxis], vec![Kostentraeger::GKV], "80331".into(), sample_slots(), [0xAA; 32], seq, ); store.store(announce); } let query = SlotQuery { query_id: [0; 16], fachrichtung: None, modalitaet: None, kostentraeger: None, plz_prefix: None, earliest: None, latest: None, slot_type: None, max_results: 3, }; let response = store.query(&query); assert_eq!(response.matches.len(), 3, "should respect max_results"); } #[test] fn query_kostentraeger_filter() { let id = test_identity(); let pk = id.public_key(); let announce = sample_announce(&id); // GKV + Selbstzahler let addr = announce.therapist_address; let mut store = FappStore::new(); store.register_therapist_key(addr, pk); store.store(announce); // Match: GKV. let query = SlotQuery { query_id: [0; 16], fachrichtung: None, modalitaet: None, kostentraeger: Some(Kostentraeger::GKV), plz_prefix: None, earliest: None, latest: None, slot_type: None, max_results: 10, }; assert_eq!(store.query(&query).matches.len(), 1); // No match: PKV. let query2 = SlotQuery { kostentraeger: Some(Kostentraeger::PKV), ..query }; assert!(store.query(&query2).matches.is_empty()); } #[test] fn get_therapist_announces() { let id = test_identity(); let pk = id.public_key(); let announce = sample_announce(&id); let addr = announce.therapist_address; let mut store = FappStore::new(); store.register_therapist_key(addr, pk); store.store(announce); assert_eq!(store.get_therapist(&addr).len(), 1); assert!(store.get_therapist(&[0xFF; 16]).is_empty()); } }