Skip to main content

reinhardt_testkit/server_fn/
context.rs

1//! Enhanced Server Function Test Context.
2//!
3//! This module provides an enhanced version of `ServerFnTestContext` with
4//! additional features for authentication mocking, HTTP request/response
5//! simulation, and transaction management.
6//!
7//! # Example
8//!
9//! ```rust,ignore
10//! use reinhardt_testkit::server_fn::{ServerFnTestContext, TestUser};
11//! use reinhardt_di::SingletonScope;
12//! use std::sync::Arc;
13//!
14//! #[rstest]
15//! #[tokio::test]
16//! async fn test_protected_endpoint(singleton_scope: Arc<SingletonScope>) {
17//!     let ctx = ServerFnTestContext::new(singleton_scope)
18//!         .with_authenticated_user(TestUser::admin())
19//!         .with_transaction_rollback()
20//!         .build();
21//!
22//!     let result = my_server_fn::test_call(input, &ctx).await;
23//!     assert!(result.is_ok());
24//! }
25//! ```
26
27#![cfg(native)]
28
29use std::collections::HashMap;
30use std::sync::Arc;
31
32use http::{HeaderMap, HeaderValue, StatusCode};
33use reinhardt_di::{InjectionContext, SingletonScope};
34use uuid::Uuid;
35
36use super::auth::{MockSession, TestUser};
37use super::mock_request::MockHttpRequest;
38
39/// Transaction mode for test database operations.
40#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
41pub enum TransactionMode {
42	/// Automatically rollback after each test (recommended).
43	#[default]
44	Rollback,
45	/// Allow commits (use with caution).
46	Commit,
47	/// No transaction management.
48	None,
49}
50
51/// Enhanced test context builder for server function testing.
52///
53/// This builder extends the basic `ServerFnTestContext` with:
54/// - Authentication/authorization mocking
55/// - HTTP request/response simulation
56/// - Transaction management
57/// - CSRF token handling
58///
59/// # Example
60///
61/// ```rust,ignore
62/// let ctx = ServerFnTestContext::new(singleton_scope)
63///     .with_authenticated_user(TestUser::authenticated("alice"))
64///     .with_permissions(vec!["read", "write"])
65///     .with_csrf_token("test-token")
66///     .build();
67/// ```
68// Boxed closures for DI overrides require complex type signatures that cannot
69// be simplified without losing flexibility.
70#[allow(clippy::type_complexity)]
71pub struct ServerFnTestContext {
72	singleton_scope: Arc<SingletonScope>,
73	overrides: Vec<Box<dyn FnOnce(&InjectionContext) + Send>>,
74	mock_request: Option<MockHttpRequest>,
75	mock_session: Option<MockSession>,
76	test_user: Option<TestUser>,
77	transaction_mode: TransactionMode,
78	request_headers: HeaderMap,
79	csrf_token: Option<String>,
80}
81
82impl ServerFnTestContext {
83	/// Create a new server function test context builder.
84	///
85	/// # Arguments
86	///
87	/// * `singleton_scope` - The singleton scope for dependency resolution.
88	pub fn new(singleton_scope: Arc<SingletonScope>) -> Self {
89		Self {
90			singleton_scope,
91			overrides: Vec::new(),
92			mock_request: None,
93			mock_session: None,
94			test_user: None,
95			transaction_mode: TransactionMode::default(),
96			request_headers: HeaderMap::new(),
97			csrf_token: None,
98		}
99	}
100
101	/// Add a database connection override to the test context.
102	///
103	/// # Arguments
104	///
105	/// * `pool` - The database connection pool (typically from TestContainers)
106	pub fn with_database<P: Clone + Send + Sync + 'static>(mut self, pool: P) -> Self {
107		self.overrides.push(Box::new(move |ctx| {
108			ctx.set_singleton(pool);
109		}));
110		self
111	}
112
113	/// Add a custom singleton dependency to the test context.
114	///
115	/// # Arguments
116	///
117	/// * `value` - The singleton value to register
118	pub fn with_singleton<T: Clone + Send + Sync + 'static>(mut self, value: T) -> Self {
119		self.overrides.push(Box::new(move |ctx| {
120			ctx.set_singleton(value);
121		}));
122		self
123	}
124
125	/// Set the authenticated user for this test.
126	///
127	/// This configures the test context to simulate an authenticated user,
128	/// allowing you to test protected endpoints.
129	///
130	/// # Arguments
131	///
132	/// * `user` - The test user to authenticate as
133	///
134	/// # Example
135	///
136	/// ```rust,ignore
137	/// let ctx = ServerFnTestContext::new(singleton)
138	///     .with_authenticated_user(TestUser::admin())
139	///     .build();
140	/// ```
141	#[deprecated(
142		since = "0.1.0-rc.16",
143		note = "use `.auth().session(&user).done()` instead"
144	)]
145	pub fn with_authenticated_user(mut self, user: TestUser) -> Self {
146		self.test_user = Some(user.clone());
147		self.mock_session = Some(MockSession::authenticated(user));
148		self
149	}
150
151	/// Set permissions for the authenticated user.
152	///
153	/// This is a convenience method that modifies the test user's permissions.
154	///
155	/// # Arguments
156	///
157	/// * `permissions` - List of permission strings to grant
158	// Fixes #868
159	pub fn with_permissions<S: Into<String>>(mut self, permissions: Vec<S>) -> Self {
160		if let Some(ref mut user) = self.test_user {
161			for perm in permissions {
162				user.permissions.push(perm.into());
163			}
164			// Synchronize mock_session user with updated test_user
165			if let Some(ref mut session) = self.mock_session {
166				session.user = Some(user.clone());
167			}
168		} else {
169			let mut user = TestUser::authenticated("test-user");
170			for perm in permissions {
171				user.permissions.push(perm.into());
172			}
173			self.test_user = Some(user.clone());
174			self.mock_session = Some(MockSession::authenticated(user));
175		}
176		self
177	}
178
179	/// Set roles for the authenticated user.
180	///
181	/// # Arguments
182	///
183	/// * `roles` - List of role strings to assign
184	// Fixes #868
185	pub fn with_roles<S: Into<String>>(mut self, roles: Vec<S>) -> Self {
186		if let Some(ref mut user) = self.test_user {
187			for role in roles {
188				user.roles.push(role.into());
189			}
190			// Synchronize mock_session user with updated test_user
191			if let Some(ref mut session) = self.mock_session {
192				session.user = Some(user.clone());
193			}
194		} else {
195			let mut user = TestUser::authenticated("test-user");
196			for role in roles {
197				user.roles.push(role.into());
198			}
199			self.test_user = Some(user.clone());
200			self.mock_session = Some(MockSession::authenticated(user));
201		}
202		self
203	}
204
205	/// Set a mock HTTP request for the context.
206	///
207	/// This is useful for testing server functions that access request data
208	/// like headers, cookies, or body.
209	///
210	/// # Arguments
211	///
212	/// * `request` - The mock HTTP request
213	pub fn with_request(mut self, request: MockHttpRequest) -> Self {
214		self.mock_request = Some(request);
215		self
216	}
217
218	/// Add request headers to the context.
219	///
220	/// # Arguments
221	///
222	/// * `headers` - Headers to add
223	pub fn with_request_headers(mut self, headers: HeaderMap) -> Self {
224		self.request_headers = headers;
225		self
226	}
227
228	/// Add a single request header.
229	///
230	/// # Arguments
231	///
232	/// * `name` - Header name
233	/// * `value` - Header value
234	pub fn with_header(mut self, name: &str, value: &str) -> Self {
235		if let Ok(header_value) = HeaderValue::from_str(value)
236			&& let Ok(header_name) = http::header::HeaderName::from_bytes(name.as_bytes())
237		{
238			self.request_headers.insert(header_name, header_value);
239		}
240		self
241	}
242
243	/// Set a CSRF token for the request.
244	///
245	/// This automatically adds the token to both headers and session.
246	///
247	/// # Arguments
248	///
249	/// * `token` - The CSRF token string
250	pub fn with_csrf_token(mut self, token: &str) -> Self {
251		self.csrf_token = Some(token.to_string());
252
253		// Add to headers
254		if let Ok(header_value) = HeaderValue::from_str(token) {
255			self.request_headers
256				.insert("x-csrf-token", header_value.clone());
257		}
258
259		// Add to session if present
260		if let Some(ref mut session) = self.mock_session {
261			session.csrf_token = token.to_string();
262		}
263
264		self
265	}
266
267	/// Set the transaction mode for database operations.
268	///
269	/// # Arguments
270	///
271	/// * `mode` - The transaction mode
272	pub fn with_transaction_mode(mut self, mode: TransactionMode) -> Self {
273		self.transaction_mode = mode;
274		self
275	}
276
277	/// Enable automatic transaction rollback after the test.
278	///
279	/// This is a convenience method for `with_transaction_mode(TransactionMode::Rollback)`.
280	pub fn with_transaction_rollback(self) -> Self {
281		self.with_transaction_mode(TransactionMode::Rollback)
282	}
283
284	/// Set a mock session directly.
285	///
286	/// # Arguments
287	///
288	/// * `session` - The mock session
289	pub fn with_session(mut self, session: MockSession) -> Self {
290		self.mock_session = Some(session);
291		self
292	}
293
294	/// Start building auth configuration for this server_fn test context.
295	///
296	/// Mirrors [`crate::client::APIClient::auth()`] API for consistent developer experience.
297	///
298	/// # Examples
299	///
300	/// ```rust,ignore
301	/// let env = ServerFnTestContext::new(scope.clone())
302	///     .with_database(pool.clone())
303	///     .auth()
304	///         .session(&user)
305	///         .with_staff(true)
306	///     .done()
307	///     .build();
308	/// ```
309	#[cfg(native)]
310	pub fn auth(self) -> crate::auth::ServerFnAuthBuilder {
311		crate::auth::ServerFnAuthBuilder::new(self)
312	}
313
314	/// Add a mock session with default configuration.
315	pub fn with_mock_session(mut self) -> Self {
316		if self.mock_session.is_none() {
317			self.mock_session = Some(MockSession::anonymous());
318		}
319		self
320	}
321
322	/// Build the test environment with all configured options.
323	///
324	/// Returns a `ServerFnTestEnv` containing the injection context and
325	/// any additional test state.
326	pub fn build(self) -> ServerFnTestEnv {
327		let ctx = InjectionContext::builder(self.singleton_scope.clone()).build();
328
329		// Apply all overrides
330		for override_fn in self.overrides {
331			override_fn(&ctx);
332		}
333
334		// Register mock session if present
335		if let Some(session) = self.mock_session.clone() {
336			ctx.set_singleton(session);
337		}
338
339		// Register test user if present
340		if let Some(user) = self.test_user.clone() {
341			ctx.set_singleton(user);
342		}
343
344		// Register mock request if present
345		if let Some(request) = self.mock_request.clone() {
346			ctx.set_singleton(request);
347		}
348
349		ServerFnTestEnv {
350			injection_context: ctx,
351			mock_session: self.mock_session,
352			test_user: self.test_user,
353			mock_request: self.mock_request,
354			transaction_mode: self.transaction_mode,
355			request_headers: self.request_headers,
356			csrf_token: self.csrf_token,
357		}
358	}
359
360	/// Build and return just the injection context.
361	///
362	/// This is a convenience method when you don't need the full test environment.
363	pub fn build_context(self) -> InjectionContext {
364		self.build().injection_context
365	}
366}
367
368/// The built test environment containing all test state.
369#[derive(Clone)]
370pub struct ServerFnTestEnv {
371	/// The injection context for dependency resolution.
372	pub injection_context: InjectionContext,
373	/// The mock session if configured.
374	pub mock_session: Option<MockSession>,
375	/// The test user if authenticated.
376	pub test_user: Option<TestUser>,
377	/// The mock HTTP request if configured.
378	pub mock_request: Option<MockHttpRequest>,
379	/// The transaction mode.
380	pub transaction_mode: TransactionMode,
381	/// Request headers.
382	pub request_headers: HeaderMap,
383	/// CSRF token if set.
384	pub csrf_token: Option<String>,
385}
386
387impl ServerFnTestEnv {
388	/// Get a reference to the injection context.
389	pub fn context(&self) -> &InjectionContext {
390		&self.injection_context
391	}
392
393	/// Check if a user is authenticated.
394	pub fn is_authenticated(&self) -> bool {
395		self.test_user.is_some() && self.mock_session.as_ref().is_some_and(|s| s.user.is_some())
396	}
397
398	/// Get the current user ID if authenticated.
399	pub fn user_id(&self) -> Option<Uuid> {
400		self.test_user.as_ref().map(|u| u.id)
401	}
402
403	/// Check if the user has a specific permission.
404	// Fixes #864
405	pub fn has_permission(&self, permission: &str) -> bool {
406		self.test_user
407			.as_ref()
408			.is_some_and(|u| u.has_permission(permission))
409	}
410
411	/// Check if the user has a specific role.
412	pub fn has_role(&self, role: &str) -> bool {
413		self.test_user
414			.as_ref()
415			.is_some_and(|u| u.roles.iter().any(|r| r == role))
416	}
417
418	/// Get a request header value.
419	pub fn get_header(&self, name: &str) -> Option<&str> {
420		self.request_headers.get(name).and_then(|v| v.to_str().ok())
421	}
422}
423
424impl std::ops::Deref for ServerFnTestEnv {
425	type Target = InjectionContext;
426
427	fn deref(&self) -> &Self::Target {
428		&self.injection_context
429	}
430}
431
432/// Result builder for testing server function responses.
433///
434/// This allows building expected results for assertion comparisons.
435#[derive(Debug, Clone)]
436pub struct ExpectedResult<T> {
437	/// The expected value.
438	pub value: Option<T>,
439	/// The expected status code.
440	pub status: Option<StatusCode>,
441	/// Expected headers.
442	pub headers: HashMap<String, String>,
443}
444
445impl<T> Default for ExpectedResult<T> {
446	fn default() -> Self {
447		Self {
448			value: None,
449			status: None,
450			headers: HashMap::new(),
451		}
452	}
453}
454
455impl<T> ExpectedResult<T> {
456	/// Create a new expected result builder.
457	pub fn new() -> Self {
458		Self::default()
459	}
460
461	/// Set the expected value.
462	pub fn with_value(mut self, value: T) -> Self {
463		self.value = Some(value);
464		self
465	}
466
467	/// Set the expected status code.
468	pub fn with_status(mut self, status: StatusCode) -> Self {
469		self.status = Some(status);
470		self
471	}
472
473	/// Add an expected header.
474	pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
475		self.headers.insert(name.into(), value.into());
476		self
477	}
478
479	/// Expect a successful (200 OK) response.
480	pub fn success(self) -> Self {
481		self.with_status(StatusCode::OK)
482	}
483
484	/// Expect a created (201 Created) response.
485	pub fn created(self) -> Self {
486		self.with_status(StatusCode::CREATED)
487	}
488
489	/// Expect a bad request (400) response.
490	pub fn bad_request(self) -> Self {
491		self.with_status(StatusCode::BAD_REQUEST)
492	}
493
494	/// Expect an unauthorized (401) response.
495	pub fn unauthorized(self) -> Self {
496		self.with_status(StatusCode::UNAUTHORIZED)
497	}
498
499	/// Expect a forbidden (403) response.
500	pub fn forbidden(self) -> Self {
501		self.with_status(StatusCode::FORBIDDEN)
502	}
503
504	/// Expect a not found (404) response.
505	pub fn not_found(self) -> Self {
506		self.with_status(StatusCode::NOT_FOUND)
507	}
508
509	/// Expect a conflict (409) response.
510	pub fn conflict(self) -> Self {
511		self.with_status(StatusCode::CONFLICT)
512	}
513
514	/// Expect an internal server error (500) response.
515	pub fn internal_error(self) -> Self {
516		self.with_status(StatusCode::INTERNAL_SERVER_ERROR)
517	}
518}
519
520#[cfg(test)]
521mod tests {
522	use super::*;
523
524	#[test]
525	fn test_context_builder() {
526		let singleton = Arc::new(SingletonScope::new());
527		let ctx = ServerFnTestContext::new(singleton)
528			.with_mock_session()
529			.build();
530
531		assert!(ctx.mock_session.is_some());
532	}
533
534	#[test]
535	fn test_authenticated_user() {
536		let singleton = Arc::new(SingletonScope::new());
537		let user = TestUser::admin();
538		let ctx = ServerFnTestContext::new(singleton)
539			.with_authenticated_user(user)
540			.build();
541
542		assert!(ctx.is_authenticated());
543		assert!(ctx.test_user.is_some());
544	}
545
546	#[test]
547	fn test_permissions() {
548		let singleton = Arc::new(SingletonScope::new());
549		let ctx = ServerFnTestContext::new(singleton)
550			.with_authenticated_user(TestUser::authenticated("alice"))
551			.with_permissions(vec!["read", "write"])
552			.build();
553
554		assert!(ctx.has_permission("read"));
555		assert!(ctx.has_permission("write"));
556		assert!(!ctx.has_permission("admin"));
557	}
558
559	#[test]
560	fn test_csrf_token() {
561		let singleton = Arc::new(SingletonScope::new());
562		let ctx = ServerFnTestContext::new(singleton)
563			.with_mock_session()
564			.with_csrf_token("test-token")
565			.build();
566
567		assert_eq!(ctx.csrf_token.as_deref(), Some("test-token"));
568		assert_eq!(ctx.get_header("x-csrf-token"), Some("test-token"));
569	}
570
571	#[test]
572	fn test_transaction_mode() {
573		let singleton = Arc::new(SingletonScope::new());
574		let ctx = ServerFnTestContext::new(singleton)
575			.with_transaction_rollback()
576			.build();
577
578		assert_eq!(ctx.transaction_mode, TransactionMode::Rollback);
579	}
580}