Skip to main content

klauthed_testing/
context.rs

1//! Deterministic [`RequestContext`] builders for tests.
2//!
3//! [`RequestContext::new`](klauthed_core::context::RequestContext::new) mints a
4//! random request id and stamps the arrival time from the system clock, so two
5//! contexts are never equal and timestamps drift run-to-run. These helpers
6//! produce a context with a **fixed, seeded** request id and a **pinned**
7//! `received_at`, giving reproducible fixtures while still letting you set the
8//! fields a test cares about (tenant, locale, …).
9
10use klauthed_core::context::{RequestContext, RequestId};
11use klauthed_core::time::Timestamp;
12
13use crate::ids::seeded_id;
14
15/// The default seed for a test context's request id.
16const DEFAULT_REQUEST_SEED: u64 = 1;
17
18/// The default arrival instant for a test context (`1_700_000_000_000` ms ≈
19/// 2023-11-14T22:13:20Z), chosen as a stable, recognizable point in time.
20const DEFAULT_RECEIVED_AT_MILLIS: i64 = 1_700_000_000_000;
21
22/// A deterministic [`RequestContext`] with a seeded request id and a pinned
23/// `received_at`.
24///
25/// Equivalent to `TestContextBuilder::new().build()`. Use the builder when you
26/// need to set tenant, locale, or other fields.
27///
28/// ```
29/// use klauthed_testing::context::test_context;
30///
31/// let a = test_context();
32/// let b = test_context();
33/// // Reproducible: same request id and arrival time every time.
34/// assert_eq!(a.request_id(), b.request_id());
35/// assert_eq!(a.received_at(), b.received_at());
36/// ```
37pub fn test_context() -> RequestContext {
38    TestContextBuilder::new().build()
39}
40
41/// Builds a deterministic [`RequestContext`] for tests.
42///
43/// Starts from a seeded request id and a pinned arrival time, then layers on the
44/// optional fields a test sets. Construct via [`TestContextBuilder::new`].
45///
46/// ```
47/// use klauthed_testing::context::TestContextBuilder;
48///
49/// let ctx = TestContextBuilder::new()
50///     .seed(42)
51///     .tenant("acme")
52///     .locale("tr-TR")
53///     .correlation_id("trace-1")
54///     .metadata("feature_flag", "beta")
55///     .build();
56///
57/// assert_eq!(ctx.tenant(), Some("acme"));
58/// assert_eq!(ctx.locale(), Some("tr-TR"));
59/// assert_eq!(ctx.correlation_id(), Some("trace-1"));
60/// assert_eq!(ctx.metadata_get("feature_flag"), Some("beta"));
61/// ```
62#[derive(Debug, Clone)]
63pub struct TestContextBuilder {
64    seed: u64,
65    received_at: Timestamp,
66    tenant: Option<String>,
67    principal: Option<String>,
68    locale: Option<String>,
69    correlation_id: Option<String>,
70    deadline: Option<Timestamp>,
71    metadata: Vec<(String, String)>,
72}
73
74impl TestContextBuilder {
75    /// A builder with default seed and pinned arrival time, and no other fields.
76    pub fn new() -> Self {
77        Self {
78            seed: DEFAULT_REQUEST_SEED,
79            received_at: Timestamp::from_unix_millis(DEFAULT_RECEIVED_AT_MILLIS),
80            tenant: None,
81            principal: None,
82            locale: None,
83            correlation_id: None,
84            deadline: None,
85            metadata: Vec::new(),
86        }
87    }
88
89    /// Set the seed used to derive the (deterministic) request id.
90    #[must_use]
91    pub fn seed(mut self, seed: u64) -> Self {
92        self.seed = seed;
93        self
94    }
95
96    /// Pin the arrival time (`received_at`).
97    #[must_use]
98    pub fn received_at(mut self, at: Timestamp) -> Self {
99        self.received_at = at;
100        self
101    }
102
103    /// Set an absolute deadline.
104    #[must_use]
105    pub fn deadline(mut self, deadline: Timestamp) -> Self {
106        self.deadline = Some(deadline);
107        self
108    }
109
110    /// Set the tenant identifier.
111    #[must_use]
112    pub fn tenant(mut self, tenant: impl Into<String>) -> Self {
113        self.tenant = Some(tenant.into());
114        self
115    }
116
117    /// Set the authenticated principal / subject.
118    #[must_use]
119    pub fn principal(mut self, principal: impl Into<String>) -> Self {
120        self.principal = Some(principal.into());
121        self
122    }
123
124    /// Set the preferred locale (BCP-47, e.g. `en-US`).
125    #[must_use]
126    pub fn locale(mut self, locale: impl Into<String>) -> Self {
127        self.locale = Some(locale.into());
128        self
129    }
130
131    /// Set the inbound correlation / trace id.
132    #[must_use]
133    pub fn correlation_id(mut self, id: impl Into<String>) -> Self {
134        self.correlation_id = Some(id.into());
135        self
136    }
137
138    /// Add a metadata entry.
139    #[must_use]
140    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
141        self.metadata.push((key.into(), value.into()));
142        self
143    }
144
145    /// The seeded [`RequestId`] this builder will use.
146    pub fn request_id(&self) -> RequestId {
147        seeded_id(self.seed)
148    }
149
150    /// Build the [`RequestContext`].
151    pub fn build(self) -> RequestContext {
152        let mut ctx = RequestContext::new()
153            .with_request_id(seeded_id(self.seed))
154            .with_received_at(self.received_at);
155        if let Some(tenant) = self.tenant {
156            ctx = ctx.with_tenant(tenant);
157        }
158        if let Some(principal) = self.principal {
159            ctx = ctx.with_principal(principal);
160        }
161        if let Some(locale) = self.locale {
162            ctx = ctx.with_locale(locale);
163        }
164        if let Some(correlation_id) = self.correlation_id {
165            ctx = ctx.with_correlation_id(correlation_id);
166        }
167        if let Some(deadline) = self.deadline {
168            ctx = ctx.with_deadline(deadline);
169        }
170        for (key, value) in self.metadata {
171            ctx = ctx.with_metadata(key, value);
172        }
173        ctx
174    }
175}
176
177impl Default for TestContextBuilder {
178    fn default() -> Self {
179        Self::new()
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::ids::seeded_id;
187
188    #[test]
189    fn test_context_is_deterministic() {
190        let a = test_context();
191        let b = test_context();
192        assert_eq!(a.request_id(), b.request_id());
193        assert_eq!(a.received_at(), b.received_at());
194        assert_eq!(a.request_id(), seeded_id(DEFAULT_REQUEST_SEED));
195    }
196
197    #[test]
198    fn builder_sets_fields() {
199        let ctx = TestContextBuilder::new()
200            .seed(9)
201            .tenant("acme")
202            .principal("user-1")
203            .locale("de-DE")
204            .correlation_id("corr-9")
205            .metadata("k", "v")
206            .build();
207
208        assert_eq!(ctx.request_id(), seeded_id::<_>(9));
209        assert_eq!(ctx.tenant(), Some("acme"));
210        assert_eq!(ctx.principal(), Some("user-1"));
211        assert_eq!(ctx.locale(), Some("de-DE"));
212        assert_eq!(ctx.correlation_id(), Some("corr-9"));
213        assert_eq!(ctx.metadata_get("k"), Some("v"));
214    }
215
216    #[test]
217    fn deadline_and_received_at_pinning() {
218        let received = Timestamp::from_unix_millis(10_000);
219        let deadline = Timestamp::from_unix_millis(15_000);
220        let ctx = TestContextBuilder::new().received_at(received).deadline(deadline).build();
221        assert_eq!(ctx.received_at(), received);
222        assert_eq!(ctx.deadline(), Some(deadline));
223    }
224
225    #[test]
226    fn request_id_accessor_matches_built_context() {
227        let builder = TestContextBuilder::new().seed(123);
228        let id = builder.request_id();
229        assert_eq!(builder.build().request_id(), id);
230    }
231}