reinhardt_testkit/
testcase.rs1use crate::client::APIClient;
6use crate::resource::AsyncTestResource;
7use std::sync::Arc;
8use thiserror::Error;
9use tokio::sync::RwLock;
10
11#[derive(Debug, Error)]
13pub enum TeardownError {
14 #[error("Failed to rollback transactions: {0}")]
16 TransactionRollbackFailed(String),
17
18 #[error("Failed to close database connection: {0}")]
20 ConnectionCloseFailed(String),
21
22 #[error("Failed to cleanup client state: {0}")]
24 ClientCleanupFailed(String),
25}
26
27#[cfg(feature = "testcontainers")]
33#[derive(Debug, Clone)]
34pub struct TransactionHandle {
35 id: String,
37 committed: bool,
39}
40
41#[cfg(feature = "testcontainers")]
42impl TransactionHandle {
43 pub fn new() -> Self {
45 Self {
46 id: uuid::Uuid::now_v7().to_string(),
47 committed: false,
48 }
49 }
50
51 pub fn id(&self) -> &str {
53 &self.id
54 }
55
56 pub fn is_committed(&self) -> bool {
58 self.committed
59 }
60
61 pub fn mark_committed(&mut self) {
63 self.committed = true;
64 }
65}
66
67#[cfg(feature = "testcontainers")]
68impl Default for TransactionHandle {
69 fn default() -> Self {
70 Self::new()
71 }
72}
73
74pub struct APITestCase {
105 client: Arc<RwLock<APIClient>>,
106 #[cfg(feature = "testcontainers")]
107 database_url: Arc<RwLock<Option<String>>>,
108 #[cfg(feature = "testcontainers")]
109 db_connection: Arc<RwLock<Option<sqlx::AnyPool>>>,
110 #[cfg(feature = "testcontainers")]
111 active_transactions: Arc<RwLock<Vec<TransactionHandle>>>,
112}
113
114impl APITestCase {
115 #[cfg(feature = "testcontainers")]
117 pub async fn database_url(&self) -> Option<String> {
118 self.database_url.read().await.clone()
119 }
120
121 pub async fn client(&self) -> tokio::sync::RwLockReadGuard<'_, APIClient> {
123 self.client.read().await
124 }
125
126 pub async fn client_mut(&self) -> tokio::sync::RwLockWriteGuard<'_, APIClient> {
128 self.client.write().await
129 }
130
131 #[cfg(feature = "testcontainers")]
133 pub async fn set_database_url(&self, url: String) {
134 let mut db_url = self.database_url.write().await;
135 *db_url = Some(url);
136 }
137
138 #[cfg(feature = "testcontainers")]
151 pub async fn set_database_connection(&self, pool: sqlx::AnyPool) {
152 let mut conn = self.db_connection.write().await;
153 *conn = Some(pool);
154 }
155
156 #[cfg(feature = "testcontainers")]
158 pub async fn db_connection(&self) -> Option<sqlx::AnyPool> {
159 self.db_connection.read().await.clone()
160 }
161
162 #[cfg(feature = "testcontainers")]
179 pub async fn begin_transaction(&self) -> TransactionHandle {
180 let handle = TransactionHandle::new();
181 let mut transactions = self.active_transactions.write().await;
182 transactions.push(handle.clone());
183 handle
184 }
185
186 #[cfg(feature = "testcontainers")]
191 pub async fn commit_transaction(&self, transaction_id: &str) {
192 let mut transactions = self.active_transactions.write().await;
193 if let Some(pos) = transactions.iter().position(|t| t.id() == transaction_id) {
194 let mut handle = transactions.remove(pos);
195 handle.mark_committed();
196 }
197 }
198
199 #[cfg(feature = "testcontainers")]
201 pub async fn active_transaction_count(&self) -> usize {
202 self.active_transactions.read().await.len()
203 }
204}
205
206#[async_trait::async_trait]
207impl AsyncTestResource for APITestCase {
208 async fn setup() -> Self {
209 Self {
210 client: Arc::new(RwLock::new(APIClient::new())),
211 #[cfg(feature = "testcontainers")]
212 database_url: Arc::new(RwLock::new(None)),
213 #[cfg(feature = "testcontainers")]
214 db_connection: Arc::new(RwLock::new(None)),
215 #[cfg(feature = "testcontainers")]
216 active_transactions: Arc::new(RwLock::new(Vec::new())),
217 }
218 }
219
220 async fn teardown(self) {
221 {
223 let client = self.client.write().await;
224 client.cleanup().await;
225 }
226
227 #[cfg(feature = "testcontainers")]
229 {
230 let transactions = self.active_transactions.read().await;
232 let uncommitted_count = transactions.iter().filter(|t| !t.is_committed()).count();
233 if uncommitted_count > 0 {
234 tracing::debug!(
237 "Rolling back {} uncommitted transaction(s) during teardown",
238 uncommitted_count
239 );
240 }
241 drop(transactions);
242
243 let mut pool_guard = self.db_connection.write().await;
245 if let Some(pool) = pool_guard.take() {
246 pool.close().await;
248 }
249 }
250
251 drop(self.client);
253 }
254}
255
256#[macro_export]
273macro_rules! test_case {
274 (
275 async fn $name:ident($case:ident: &APITestCase) $body:block
276 ) => {
277 #[rstest::rstest]
278 #[tokio::test]
279 async fn $name() {
280 use $crate::resource::AsyncTeardownGuard;
281 use $crate::testcase::APITestCase;
282
283 let guard = AsyncTeardownGuard::<APITestCase>::new().await;
284 let $case = &*guard;
285
286 $body
288
289 }
291 };
292}
293
294#[macro_export]
296macro_rules! authenticated_test_case {
297 (
298 async fn $name:ident($case:ident: &APITestCase, $user:ident: serde_json::Value) $body:block
299 ) => {
300 #[rstest::rstest]
301 #[tokio::test]
302 async fn $name() {
303 use $crate::resource::AsyncTeardownGuard;
304 use $crate::testcase::APITestCase;
305
306 let guard = AsyncTeardownGuard::<APITestCase>::new().await;
307 let $case = &*guard;
308
309 let $user = serde_json::json!({
311 "id": 1,
312 "username": "testuser",
313 });
314
315 $body
317
318 }
320 };
321}
322
323#[cfg(feature = "testcontainers")]
368#[macro_export]
369macro_rules! test_case_with_db {
370 (
371 postgres,
372 async fn $name:ident($case:ident: &APITestCase) $body:block
373 ) => {
374 #[rstest::rstest]
375 #[tokio::test]
376 async fn $name() {
377 use $crate::containers::{with_postgres, PostgresContainer};
378 use $crate::resource::AsyncTeardownGuard;
379 use $crate::testcase::APITestCase;
380
381 with_postgres(|db| async move {
382 let guard = AsyncTeardownGuard::<APITestCase>::new().await;
383 let $case = &*guard;
384 $case.set_database_url(db.connection_url()).await;
385
386 $body
388
389 Ok(())
391 })
392 .await
393 .unwrap();
394 }
395 };
396 (
397 mysql,
398 async fn $name:ident($case:ident: &APITestCase) $body:block
399 ) => {
400 #[rstest::rstest]
401 #[tokio::test]
402 async fn $name() {
403 use $crate::containers::{with_mysql, MySqlContainer};
404 use $crate::resource::AsyncTeardownGuard;
405 use $crate::testcase::APITestCase;
406
407 with_mysql(|db| async move {
408 let guard = AsyncTeardownGuard::<APITestCase>::new().await;
409 let $case = &*guard;
410 $case.set_database_url(db.connection_url()).await;
411
412 $body
414
415 Ok(())
417 })
418 .await
419 .unwrap();
420 }
421 };
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use rstest::rstest;
428
429 #[rstest]
434 fn test_teardown_error_transaction_rollback_display() {
435 let error = TeardownError::TransactionRollbackFailed("tx-123 failed".to_string());
437
438 let display = format!("{}", error);
440
441 assert_eq!(display, "Failed to rollback transactions: tx-123 failed");
443 }
444
445 #[rstest]
446 fn test_teardown_error_connection_close_display() {
447 let error = TeardownError::ConnectionCloseFailed("connection refused".to_string());
449
450 let display = format!("{}", error);
452
453 assert_eq!(
455 display,
456 "Failed to close database connection: connection refused"
457 );
458 }
459
460 #[rstest]
461 fn test_teardown_error_client_cleanup_display() {
462 let error = TeardownError::ClientCleanupFailed("timeout".to_string());
464
465 let display = format!("{}", error);
467
468 assert_eq!(display, "Failed to cleanup client state: timeout");
470 }
471
472 #[rstest]
473 fn test_teardown_error_debug() {
474 let error = TeardownError::TransactionRollbackFailed("debug test".to_string());
476
477 let debug = format!("{:?}", error);
479
480 assert!(
482 debug.contains("debug test"),
483 "Debug output should contain the message, got: {}",
484 debug
485 );
486 }
487
488 #[rstest]
493 #[tokio::test]
494 async fn test_api_test_case_setup_creates_client() {
495 let test_case = APITestCase::setup().await;
497
498 let client = test_case.client().await;
500 drop(client);
502 }
503
504 #[rstest]
505 #[tokio::test]
506 async fn test_api_test_case_client_read_access() {
507 let test_case = APITestCase::setup().await;
509
510 let client = test_case.client().await;
512
513 assert!(
516 std::mem::size_of_val(&*client) > 0,
517 "Client should have non-zero size"
518 );
519 }
520
521 #[rstest]
522 #[tokio::test]
523 async fn test_api_test_case_teardown_completes() {
524 let test_case = APITestCase::setup().await;
526
527 test_case.teardown().await;
530 }
531
532 #[rstest]
533 #[tokio::test]
534 async fn test_api_test_case_multiple_reads() {
535 let test_case = APITestCase::setup().await;
537
538 let client1 = test_case.client().await;
540 let client2 = test_case.client().await;
541
542 assert!(
545 std::mem::size_of_val(&*client1) > 0,
546 "First client read should succeed"
547 );
548 assert!(
549 std::mem::size_of_val(&*client2) > 0,
550 "Second client read should succeed"
551 );
552 }
553
554 #[cfg(feature = "testcontainers")]
559 mod testcontainers_tests {
560 use super::*;
561 use rstest::rstest;
562
563 #[rstest]
564 fn test_transaction_handle_new() {
565 let handle = TransactionHandle::new();
567
568 assert!(!handle.id().is_empty(), "ID should not be empty");
570 assert!(!handle.is_committed(), "New handle should not be committed");
571 }
572
573 #[rstest]
574 fn test_transaction_handle_mark_committed() {
575 let mut handle = TransactionHandle::new();
577
578 handle.mark_committed();
580
581 assert!(handle.is_committed());
583 }
584
585 #[rstest]
586 fn test_transaction_handle_default() {
587 let handle = TransactionHandle::default();
589
590 assert!(!handle.id().is_empty(), "Default ID should not be empty");
592 assert!(
593 !handle.is_committed(),
594 "Default handle should not be committed"
595 );
596 }
597
598 #[rstest]
599 fn test_transaction_handle_id_is_uuid() {
600 let handle = TransactionHandle::new();
602
603 let id = handle.id();
605 let parts: Vec<&str> = id.split('-').collect();
607 assert_eq!(
608 parts.len(),
609 5,
610 "UUID should have 5 parts separated by hyphens, got: {}",
611 id
612 );
613 assert_eq!(parts[0].len(), 8);
614 assert_eq!(parts[1].len(), 4);
615 assert_eq!(parts[2].len(), 4);
616 assert_eq!(parts[3].len(), 4);
617 assert_eq!(parts[4].len(), 12);
618 }
619 }
620}