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