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    pub fn seed(mut self, seed: u64) -> Self {
91        self.seed = seed;
92        self
93    }
94
95    /// Pin the arrival time (`received_at`).
96    pub fn received_at(mut self, at: Timestamp) -> Self {
97        self.received_at = at;
98        self
99    }
100
101    /// Set an absolute deadline.
102    pub fn deadline(mut self, deadline: Timestamp) -> Self {
103        self.deadline = Some(deadline);
104        self
105    }
106
107    /// Set the tenant identifier.
108    pub fn tenant(mut self, tenant: impl Into<String>) -> Self {
109        self.tenant = Some(tenant.into());
110        self
111    }
112
113    /// Set the authenticated principal / subject.
114    pub fn principal(mut self, principal: impl Into<String>) -> Self {
115        self.principal = Some(principal.into());
116        self
117    }
118
119    /// Set the preferred locale (BCP-47, e.g. `en-US`).
120    pub fn locale(mut self, locale: impl Into<String>) -> Self {
121        self.locale = Some(locale.into());
122        self
123    }
124
125    /// Set the inbound correlation / trace id.
126    pub fn correlation_id(mut self, id: impl Into<String>) -> Self {
127        self.correlation_id = Some(id.into());
128        self
129    }
130
131    /// Add a metadata entry.
132    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
133        self.metadata.push((key.into(), value.into()));
134        self
135    }
136
137    /// The seeded [`RequestId`] this builder will use.
138    pub fn request_id(&self) -> RequestId {
139        seeded_id(self.seed)
140    }
141
142    /// Build the [`RequestContext`].
143    pub fn build(self) -> RequestContext {
144        let mut ctx = RequestContext::new()
145            .with_request_id(seeded_id(self.seed))
146            .with_received_at(self.received_at);
147        if let Some(tenant) = self.tenant {
148            ctx = ctx.with_tenant(tenant);
149        }
150        if let Some(principal) = self.principal {
151            ctx = ctx.with_principal(principal);
152        }
153        if let Some(locale) = self.locale {
154            ctx = ctx.with_locale(locale);
155        }
156        if let Some(correlation_id) = self.correlation_id {
157            ctx = ctx.with_correlation_id(correlation_id);
158        }
159        if let Some(deadline) = self.deadline {
160            ctx = ctx.with_deadline(deadline);
161        }
162        for (key, value) in self.metadata {
163            ctx = ctx.with_metadata(key, value);
164        }
165        ctx
166    }
167}
168
169impl Default for TestContextBuilder {
170    fn default() -> Self {
171        Self::new()
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::ids::seeded_id;
179
180    #[test]
181    fn test_context_is_deterministic() {
182        let a = test_context();
183        let b = test_context();
184        assert_eq!(a.request_id(), b.request_id());
185        assert_eq!(a.received_at(), b.received_at());
186        assert_eq!(a.request_id(), seeded_id(DEFAULT_REQUEST_SEED));
187    }
188
189    #[test]
190    fn builder_sets_fields() {
191        let ctx = TestContextBuilder::new()
192            .seed(9)
193            .tenant("acme")
194            .principal("user-1")
195            .locale("de-DE")
196            .correlation_id("corr-9")
197            .metadata("k", "v")
198            .build();
199
200        assert_eq!(ctx.request_id(), seeded_id::<_>(9));
201        assert_eq!(ctx.tenant(), Some("acme"));
202        assert_eq!(ctx.principal(), Some("user-1"));
203        assert_eq!(ctx.locale(), Some("de-DE"));
204        assert_eq!(ctx.correlation_id(), Some("corr-9"));
205        assert_eq!(ctx.metadata_get("k"), Some("v"));
206    }
207
208    #[test]
209    fn deadline_and_received_at_pinning() {
210        let received = Timestamp::from_unix_millis(10_000);
211        let deadline = Timestamp::from_unix_millis(15_000);
212        let ctx = TestContextBuilder::new().received_at(received).deadline(deadline).build();
213        assert_eq!(ctx.received_at(), received);
214        assert_eq!(ctx.deadline(), Some(deadline));
215    }
216
217    #[test]
218    fn request_id_accessor_matches_built_context() {
219        let builder = TestContextBuilder::new().seed(123);
220        let id = builder.request_id();
221        assert_eq!(builder.build().request_id(), id);
222    }
223}