Skip to main content

teksilo_telemetry/
stub.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Stub reporter for tests.
5//!
6//! Collects events into a `Mutex<Vec<OwnedEvent>>` so tests can assert
7//! on what was emitted. Always reports the `"stub"` adapter name and
8//! `"stub://"` endpoint.
9//!
10//! Two flavors:
11//!
12//! - `StubReporter::anonymous()` — `install_id() == None`,
13//!   `erase_remote_data` / `fetch_remote_data` return the
14//!   `*Unsupported` errors (matches anonymous-mode adapters).
15//! - `StubReporter::pseudonymous(uuid)` — `install_id() == Some(uuid)`,
16//!   `erase_remote_data` clears the recorded vec, `fetch_remote_data`
17//!   returns a `RemoteDataExport` mirroring what was recorded.
18
19use std::collections::BTreeMap;
20use std::sync::Mutex;
21use std::time::SystemTime;
22
23use teksilo_core::telemetry::{
24    ConsentScope, Event, OwnedEvent, RemoteDataExport, RemoteEvent, TelemetryError, UsageReporter,
25};
26
27pub struct StubReporter {
28    pub recorded: Mutex<Vec<OwnedEvent>>,
29    install_id: Option<String>,
30    /// Mirrors recorded events into the fetch result. Set to `false`
31    /// for adapters that should reject fetch requests entirely.
32    fetch_supported: bool,
33    erase_supported: bool,
34}
35
36impl StubReporter {
37    /// Anonymous-mode stub: no install id, fetch + erase return
38    /// `Err(*Unsupported)`.
39    pub fn anonymous() -> Self {
40        Self {
41            recorded: Mutex::new(Vec::new()),
42            install_id: None,
43            fetch_supported: false,
44            erase_supported: false,
45        }
46    }
47
48    /// Pseudonymous-mode stub with the given install id. Fetch + erase
49    /// operate on the local recorded vec.
50    pub fn pseudonymous(install_id: impl Into<String>) -> Self {
51        Self {
52            recorded: Mutex::new(Vec::new()),
53            install_id: Some(install_id.into()),
54            fetch_supported: true,
55            erase_supported: true,
56        }
57    }
58
59    pub fn recorded_count(&self) -> usize {
60        self.recorded
61            .lock()
62            .expect("StubReporter mutex poisoned")
63            .len()
64    }
65
66    pub fn last_recorded_name(&self) -> Option<String> {
67        self.recorded
68            .lock()
69            .expect("StubReporter mutex poisoned")
70            .last()
71            .map(|e| e.name.clone())
72    }
73
74    pub fn clear_recorded(&self) {
75        self.recorded
76            .lock()
77            .expect("StubReporter mutex poisoned")
78            .clear();
79    }
80}
81
82impl UsageReporter for StubReporter {
83    fn record(&self, event: &Event<'_>) {
84        self.recorded
85            .lock()
86            .expect("StubReporter mutex poisoned")
87            .push(event.to_owned());
88    }
89
90    fn discard_pending(&self) -> Result<(), TelemetryError> {
91        self.clear_recorded();
92        Ok(())
93    }
94
95    fn erase_remote_data(&self) -> Result<(), TelemetryError> {
96        if !self.erase_supported {
97            return Err(TelemetryError::ErasureUnsupported);
98        }
99        self.clear_recorded();
100        Ok(())
101    }
102
103    fn fetch_remote_data(&self) -> Result<RemoteDataExport, TelemetryError> {
104        if !self.fetch_supported {
105            return Err(TelemetryError::FetchUnsupported);
106        }
107        let events = self
108            .recorded
109            .lock()
110            .expect("StubReporter mutex poisoned")
111            .iter()
112            .map(|e| RemoteEvent {
113                name: e.name.to_string(),
114                timestamp: e.timestamp,
115                properties: BTreeMap::new(), // simplified — tests assert on count, not props
116            })
117            .collect();
118        Ok(RemoteDataExport {
119            install_id: self.install_id.clone().unwrap_or_default(),
120            fetched_at: SystemTime::now(),
121            adapter: "stub",
122            endpoint: "stub://".to_string(),
123            schema_version: 1,
124            events,
125            server_metadata: BTreeMap::new(),
126        })
127    }
128
129    fn install_id(&self) -> Option<&str> {
130        self.install_id.as_deref()
131    }
132
133    fn adapter_name(&self) -> &'static str {
134        "stub"
135    }
136
137    fn endpoint(&self) -> &str {
138        "stub://"
139    }
140
141    fn supported_scopes(&self) -> ConsentScope {
142        ConsentScope::all()
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use teksilo_core::telemetry::EventCategory;
150
151    #[test]
152    fn anonymous_stub_rejects_fetch_and_erase() {
153        let r = StubReporter::anonymous();
154        assert!(matches!(
155            r.erase_remote_data(),
156            Err(TelemetryError::ErasureUnsupported)
157        ));
158        assert!(matches!(
159            r.fetch_remote_data(),
160            Err(TelemetryError::FetchUnsupported)
161        ));
162        assert!(r.install_id().is_none());
163    }
164
165    #[test]
166    fn pseudonymous_stub_round_trips() {
167        let r = StubReporter::pseudonymous("test-uuid");
168        let props = [];
169        let e = Event {
170            name: "intent.dispatched",
171            category: EventCategory::Intent,
172            timestamp: SystemTime::UNIX_EPOCH,
173            install_id: Some("test-uuid"),
174            session_id: "s",
175            schema_version: 1,
176            props: &props,
177        };
178        r.record(&e);
179        assert_eq!(r.recorded_count(), 1);
180        let export = r.fetch_remote_data().unwrap();
181        assert_eq!(export.install_id, "test-uuid");
182        assert_eq!(export.events.len(), 1);
183        r.erase_remote_data().unwrap();
184        assert_eq!(r.recorded_count(), 0);
185    }
186}