openzeppelin_relayer/repositories/transaction/
transaction_in_memory.rs

1//! This module defines an in-memory transaction repository for managing
2//! transaction data. It provides asynchronous methods for creating, retrieving,
3//! updating, and deleting transactions, as well as querying transactions by
4//! various criteria such as relayer ID, status, and nonce. The repository
5//! is implemented using a `Mutex`-protected `HashMap` to store transaction
6//! data, ensuring thread-safe access in an asynchronous context.
7use crate::{
8    models::{
9        NetworkTransactionData, TransactionRepoModel, TransactionStatus, TransactionUpdateRequest,
10    },
11    repositories::*,
12};
13use async_trait::async_trait;
14use eyre::Result;
15use itertools::Itertools;
16use std::collections::HashMap;
17use tokio::sync::{Mutex, MutexGuard};
18
19#[derive(Debug)]
20pub struct InMemoryTransactionRepository {
21    store: Mutex<HashMap<String, TransactionRepoModel>>,
22}
23
24impl Clone for InMemoryTransactionRepository {
25    fn clone(&self) -> Self {
26        // Try to get the current data, or use empty HashMap if lock fails
27        let data = self
28            .store
29            .try_lock()
30            .map(|guard| guard.clone())
31            .unwrap_or_else(|_| HashMap::new());
32
33        Self {
34            store: Mutex::new(data),
35        }
36    }
37}
38
39impl InMemoryTransactionRepository {
40    pub fn new() -> Self {
41        Self {
42            store: Mutex::new(HashMap::new()),
43        }
44    }
45
46    async fn acquire_lock<T>(lock: &Mutex<T>) -> Result<MutexGuard<T>, RepositoryError> {
47        Ok(lock.lock().await)
48    }
49
50    /// Get the sort key for a transaction based on its status.
51    /// - For Confirmed status: use confirmed_at (on-chain confirmation order)
52    /// - For all other statuses: use created_at (queue/processing order)
53    ///
54    /// Returns a tuple (timestamp_string, is_confirmed) for consistent sorting.
55    fn get_sort_key(tx: &TransactionRepoModel) -> (&str, bool) {
56        if tx.status == TransactionStatus::Confirmed {
57            if let Some(ref confirmed_at) = tx.confirmed_at {
58                return (confirmed_at, true);
59            }
60            // Fallback to created_at if confirmed_at not set (shouldn't happen)
61        }
62        (&tx.created_at, false)
63    }
64
65    /// Compare two transactions for sorting (newest first).
66    /// Uses the same logic as Redis implementation: confirmed_at for Confirmed, created_at for others.
67    fn compare_for_sort(a: &TransactionRepoModel, b: &TransactionRepoModel) -> std::cmp::Ordering {
68        let (a_key, _) = Self::get_sort_key(a);
69        let (b_key, _) = Self::get_sort_key(b);
70        b_key
71            .cmp(a_key) // Descending (newest first)
72            .then_with(|| b.id.cmp(&a.id)) // Tie-breaker: sort by ID for deterministic ordering
73    }
74}
75
76// Implement both traits for InMemoryTransactionRepository
77
78#[async_trait]
79impl Repository<TransactionRepoModel, String> for InMemoryTransactionRepository {
80    async fn create(
81        &self,
82        tx: TransactionRepoModel,
83    ) -> Result<TransactionRepoModel, RepositoryError> {
84        let mut store = Self::acquire_lock(&self.store).await?;
85        if store.contains_key(&tx.id) {
86            return Err(RepositoryError::ConstraintViolation(format!(
87                "Transaction with ID {} already exists",
88                tx.id
89            )));
90        }
91        store.insert(tx.id.clone(), tx.clone());
92        Ok(tx)
93    }
94
95    async fn get_by_id(&self, id: String) -> Result<TransactionRepoModel, RepositoryError> {
96        let store = Self::acquire_lock(&self.store).await?;
97        store
98            .get(&id)
99            .cloned()
100            .ok_or_else(|| RepositoryError::NotFound(format!("Transaction with ID {id} not found")))
101    }
102
103    #[allow(clippy::map_entry)]
104    async fn update(
105        &self,
106        id: String,
107        tx: TransactionRepoModel,
108    ) -> Result<TransactionRepoModel, RepositoryError> {
109        let mut store = Self::acquire_lock(&self.store).await?;
110        if store.contains_key(&id) {
111            let mut updated_tx = tx;
112            updated_tx.id = id.clone();
113            store.insert(id, updated_tx.clone());
114            Ok(updated_tx)
115        } else {
116            Err(RepositoryError::NotFound(format!(
117                "Transaction with ID {id} not found"
118            )))
119        }
120    }
121
122    async fn delete_by_id(&self, id: String) -> Result<(), RepositoryError> {
123        let mut store = Self::acquire_lock(&self.store).await?;
124        if store.remove(&id).is_some() {
125            Ok(())
126        } else {
127            Err(RepositoryError::NotFound(format!(
128                "Transaction with ID {id} not found"
129            )))
130        }
131    }
132
133    async fn list_all(&self) -> Result<Vec<TransactionRepoModel>, RepositoryError> {
134        let store = Self::acquire_lock(&self.store).await?;
135        Ok(store.values().cloned().collect())
136    }
137
138    async fn list_paginated(
139        &self,
140        query: PaginationQuery,
141    ) -> Result<PaginatedResult<TransactionRepoModel>, RepositoryError> {
142        let total = self.count().await?;
143        let start = ((query.page - 1) * query.per_page) as usize;
144        let store = Self::acquire_lock(&self.store).await?;
145        let items: Vec<TransactionRepoModel> = store
146            .values()
147            .skip(start)
148            .take(query.per_page as usize)
149            .cloned()
150            .collect();
151
152        Ok(PaginatedResult {
153            items,
154            total: total as u64,
155            page: query.page,
156            per_page: query.per_page,
157        })
158    }
159
160    async fn count(&self) -> Result<usize, RepositoryError> {
161        let store = Self::acquire_lock(&self.store).await?;
162        Ok(store.len())
163    }
164
165    async fn has_entries(&self) -> Result<bool, RepositoryError> {
166        let store = Self::acquire_lock(&self.store).await?;
167        Ok(!store.is_empty())
168    }
169
170    async fn drop_all_entries(&self) -> Result<(), RepositoryError> {
171        let mut store = Self::acquire_lock(&self.store).await?;
172        store.clear();
173        Ok(())
174    }
175}
176
177#[async_trait]
178impl TransactionRepository for InMemoryTransactionRepository {
179    async fn find_by_relayer_id(
180        &self,
181        relayer_id: &str,
182        query: PaginationQuery,
183    ) -> Result<PaginatedResult<TransactionRepoModel>, RepositoryError> {
184        let store = Self::acquire_lock(&self.store).await?;
185        let filtered: Vec<TransactionRepoModel> = store
186            .values()
187            .filter(|tx| tx.relayer_id == relayer_id)
188            .cloned()
189            .collect();
190
191        let total = filtered.len() as u64;
192
193        if total == 0 {
194            return Ok(PaginatedResult::<TransactionRepoModel> {
195                items: vec![],
196                total: 0,
197                page: query.page,
198                per_page: query.per_page,
199            });
200        }
201
202        let start = ((query.page - 1) * query.per_page) as usize;
203
204        // Sort and paginate (newest first)
205        let items = filtered
206            .into_iter()
207            .sorted_by(|a, b| b.created_at.cmp(&a.created_at)) // Sort by created_at descending (newest first)
208            .skip(start)
209            .take(query.per_page as usize)
210            .collect();
211
212        Ok(PaginatedResult {
213            items,
214            total,
215            page: query.page,
216            per_page: query.per_page,
217        })
218    }
219
220    async fn find_by_status(
221        &self,
222        relayer_id: &str,
223        statuses: &[TransactionStatus],
224    ) -> Result<Vec<TransactionRepoModel>, RepositoryError> {
225        let store = Self::acquire_lock(&self.store).await?;
226        let filtered: Vec<TransactionRepoModel> = store
227            .values()
228            .filter(|tx| tx.relayer_id == relayer_id && statuses.contains(&tx.status))
229            .cloned()
230            .collect();
231
232        // Sort by created_at (newest first)
233        let sorted = filtered
234            .into_iter()
235            .sorted_by(|a, b| b.created_at.cmp(&a.created_at))
236            .collect();
237
238        Ok(sorted)
239    }
240
241    async fn find_by_status_paginated(
242        &self,
243        relayer_id: &str,
244        statuses: &[TransactionStatus],
245        query: PaginationQuery,
246        oldest_first: bool,
247    ) -> Result<PaginatedResult<TransactionRepoModel>, RepositoryError> {
248        let store = Self::acquire_lock(&self.store).await?;
249
250        // Filter by relayer_id and statuses
251        let filtered: Vec<TransactionRepoModel> = store
252            .values()
253            .filter(|tx| tx.relayer_id == relayer_id && statuses.contains(&tx.status))
254            .cloned()
255            .collect();
256
257        let total = filtered.len() as u64;
258        let start = ((query.page.saturating_sub(1)) * query.per_page) as usize;
259
260        // Sort using status-aware ordering: confirmed_at for Confirmed, created_at for others
261        // oldest_first: ascending order, otherwise descending (newest first)
262        let items: Vec<TransactionRepoModel> = if oldest_first {
263            filtered
264                .into_iter()
265                .sorted_by(|a, b| {
266                    let (a_key, _) = Self::get_sort_key(a);
267                    let (b_key, _) = Self::get_sort_key(b);
268                    a_key
269                        .cmp(b_key) // Ascending (oldest first)
270                        .then_with(|| a.id.cmp(&b.id)) // Tie-breaker: sort by ID for deterministic ordering
271                })
272                .skip(start)
273                .take(query.per_page as usize)
274                .collect()
275        } else {
276            filtered
277                .into_iter()
278                .sorted_by(Self::compare_for_sort) // Descending (newest first)
279                .skip(start)
280                .take(query.per_page as usize)
281                .collect()
282        };
283
284        Ok(PaginatedResult {
285            items,
286            total,
287            page: query.page,
288            per_page: query.per_page,
289        })
290    }
291
292    async fn find_by_nonce(
293        &self,
294        relayer_id: &str,
295        nonce: u64,
296    ) -> Result<Option<TransactionRepoModel>, RepositoryError> {
297        let store = Self::acquire_lock(&self.store).await?;
298        let filtered: Vec<TransactionRepoModel> = store
299            .values()
300            .filter(|tx| {
301                tx.relayer_id == relayer_id
302                    && match &tx.network_data {
303                        NetworkTransactionData::Evm(data) => data.nonce == Some(nonce),
304                        _ => false,
305                    }
306            })
307            .cloned()
308            .collect();
309
310        Ok(filtered.into_iter().next())
311    }
312
313    async fn update_status(
314        &self,
315        tx_id: String,
316        status: TransactionStatus,
317    ) -> Result<TransactionRepoModel, RepositoryError> {
318        let update = TransactionUpdateRequest {
319            status: Some(status),
320            ..Default::default()
321        };
322        self.partial_update(tx_id, update).await
323    }
324
325    async fn partial_update(
326        &self,
327        tx_id: String,
328        update: TransactionUpdateRequest,
329    ) -> Result<TransactionRepoModel, RepositoryError> {
330        let mut store = Self::acquire_lock(&self.store).await?;
331
332        if let Some(tx) = store.get_mut(&tx_id) {
333            // Apply partial updates using the model's business logic
334            tx.apply_partial_update(update);
335            Ok(tx.clone())
336        } else {
337            Err(RepositoryError::NotFound(format!(
338                "Transaction with ID {tx_id} not found"
339            )))
340        }
341    }
342
343    async fn update_network_data(
344        &self,
345        tx_id: String,
346        network_data: NetworkTransactionData,
347    ) -> Result<TransactionRepoModel, RepositoryError> {
348        let mut tx = self.get_by_id(tx_id.clone()).await?;
349        tx.network_data = network_data;
350        self.update(tx_id, tx).await
351    }
352
353    async fn set_sent_at(
354        &self,
355        tx_id: String,
356        sent_at: String,
357    ) -> Result<TransactionRepoModel, RepositoryError> {
358        let mut tx = self.get_by_id(tx_id.clone()).await?;
359        tx.sent_at = Some(sent_at);
360        self.update(tx_id, tx).await
361    }
362
363    async fn set_confirmed_at(
364        &self,
365        tx_id: String,
366        confirmed_at: String,
367    ) -> Result<TransactionRepoModel, RepositoryError> {
368        let mut tx = self.get_by_id(tx_id.clone()).await?;
369        tx.confirmed_at = Some(confirmed_at);
370        self.update(tx_id, tx).await
371    }
372
373    async fn count_by_status(
374        &self,
375        relayer_id: &str,
376        statuses: &[TransactionStatus],
377    ) -> Result<u64, RepositoryError> {
378        let store = Self::acquire_lock(&self.store).await?;
379        let count = store
380            .values()
381            .filter(|tx| tx.relayer_id == relayer_id && statuses.contains(&tx.status))
382            .count() as u64;
383        Ok(count)
384    }
385
386    async fn delete_by_ids(&self, ids: Vec<String>) -> Result<BatchDeleteResult, RepositoryError> {
387        if ids.is_empty() {
388            return Ok(BatchDeleteResult::default());
389        }
390
391        let mut store = Self::acquire_lock(&self.store).await?;
392        let mut deleted_count = 0;
393        let mut failed = Vec::new();
394
395        for id in ids {
396            if store.remove(&id).is_some() {
397                deleted_count += 1;
398            } else {
399                failed.push((id.clone(), format!("Transaction with ID {id} not found")));
400            }
401        }
402
403        Ok(BatchDeleteResult {
404            deleted_count,
405            failed,
406        })
407    }
408
409    async fn delete_by_requests(
410        &self,
411        requests: Vec<TransactionDeleteRequest>,
412    ) -> Result<BatchDeleteResult, RepositoryError> {
413        if requests.is_empty() {
414            return Ok(BatchDeleteResult::default());
415        }
416
417        // For in-memory storage, we only need the IDs (no separate indexes to clean up)
418        let ids: Vec<String> = requests.into_iter().map(|r| r.id).collect();
419        self.delete_by_ids(ids).await
420    }
421}
422
423impl Default for InMemoryTransactionRepository {
424    fn default() -> Self {
425        Self::new()
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use crate::models::{evm::Speed, EvmTransactionData, NetworkType};
432    use lazy_static::lazy_static;
433    use std::str::FromStr;
434
435    use crate::models::U256;
436
437    use super::*;
438
439    use tokio::sync::Mutex;
440
441    lazy_static! {
442        static ref ENV_MUTEX: Mutex<()> = Mutex::new(());
443    }
444    // Helper function to create test transactions
445    fn create_test_transaction(id: &str) -> TransactionRepoModel {
446        TransactionRepoModel {
447            id: id.to_string(),
448            relayer_id: "relayer-1".to_string(),
449            status: TransactionStatus::Pending,
450            status_reason: None,
451            created_at: "2025-01-27T15:31:10.777083+00:00".to_string(),
452            sent_at: Some("2025-01-27T15:31:10.777083+00:00".to_string()),
453            confirmed_at: Some("2025-01-27T15:31:10.777083+00:00".to_string()),
454            valid_until: None,
455            delete_at: None,
456            network_type: NetworkType::Evm,
457            priced_at: None,
458            hashes: vec![],
459            network_data: NetworkTransactionData::Evm(EvmTransactionData {
460                gas_price: Some(1000000000),
461                gas_limit: Some(21000),
462                nonce: Some(1),
463                value: U256::from_str("1000000000000000000").unwrap(),
464                data: Some("0x".to_string()),
465                from: "0xSender".to_string(),
466                to: Some("0xRecipient".to_string()),
467                chain_id: 1,
468                signature: None,
469                hash: Some(format!("0x{}", id)),
470                speed: Some(Speed::Fast),
471                max_fee_per_gas: None,
472                max_priority_fee_per_gas: None,
473                raw: None,
474            }),
475            noop_count: None,
476            is_canceled: Some(false),
477        }
478    }
479
480    fn create_test_transaction_pending_state(id: &str) -> TransactionRepoModel {
481        TransactionRepoModel {
482            id: id.to_string(),
483            relayer_id: "relayer-1".to_string(),
484            status: TransactionStatus::Pending,
485            status_reason: None,
486            created_at: "2025-01-27T15:31:10.777083+00:00".to_string(),
487            sent_at: None,
488            confirmed_at: None,
489            valid_until: None,
490            delete_at: None,
491            network_type: NetworkType::Evm,
492            priced_at: None,
493            hashes: vec![],
494            network_data: NetworkTransactionData::Evm(EvmTransactionData {
495                gas_price: Some(1000000000),
496                gas_limit: Some(21000),
497                nonce: Some(1),
498                value: U256::from_str("1000000000000000000").unwrap(),
499                data: Some("0x".to_string()),
500                from: "0xSender".to_string(),
501                to: Some("0xRecipient".to_string()),
502                chain_id: 1,
503                signature: None,
504                hash: Some(format!("0x{}", id)),
505                speed: Some(Speed::Fast),
506                max_fee_per_gas: None,
507                max_priority_fee_per_gas: None,
508                raw: None,
509            }),
510            noop_count: None,
511            is_canceled: Some(false),
512        }
513    }
514
515    #[tokio::test]
516    async fn test_create_transaction() {
517        let repo = InMemoryTransactionRepository::new();
518        let tx = create_test_transaction("test-1");
519
520        let result = repo.create(tx.clone()).await.unwrap();
521        assert_eq!(result.id, tx.id);
522        assert_eq!(repo.count().await.unwrap(), 1);
523    }
524
525    #[tokio::test]
526    async fn test_get_transaction() {
527        let repo = InMemoryTransactionRepository::new();
528        let tx = create_test_transaction("test-1");
529
530        repo.create(tx.clone()).await.unwrap();
531        let stored = repo.get_by_id("test-1".to_string()).await.unwrap();
532        if let NetworkTransactionData::Evm(stored_data) = &stored.network_data {
533            if let NetworkTransactionData::Evm(tx_data) = &tx.network_data {
534                assert_eq!(stored_data.hash, tx_data.hash);
535            }
536        }
537    }
538
539    #[tokio::test]
540    async fn test_update_transaction() {
541        let repo = InMemoryTransactionRepository::new();
542        let mut tx = create_test_transaction("test-1");
543
544        repo.create(tx.clone()).await.unwrap();
545        tx.status = TransactionStatus::Confirmed;
546
547        let updated = repo.update("test-1".to_string(), tx).await.unwrap();
548        assert!(matches!(updated.status, TransactionStatus::Confirmed));
549    }
550
551    #[tokio::test]
552    async fn test_delete_transaction() {
553        let repo = InMemoryTransactionRepository::new();
554        let tx = create_test_transaction("test-1");
555
556        repo.create(tx).await.unwrap();
557        repo.delete_by_id("test-1".to_string()).await.unwrap();
558
559        let result = repo.get_by_id("test-1".to_string()).await;
560        assert!(result.is_err());
561    }
562
563    #[tokio::test]
564    async fn test_list_all_transactions() {
565        let repo = InMemoryTransactionRepository::new();
566        let tx1 = create_test_transaction("test-1");
567        let tx2 = create_test_transaction("test-2");
568
569        repo.create(tx1).await.unwrap();
570        repo.create(tx2).await.unwrap();
571
572        let transactions = repo.list_all().await.unwrap();
573        assert_eq!(transactions.len(), 2);
574    }
575
576    #[tokio::test]
577    async fn test_count_transactions() {
578        let repo = InMemoryTransactionRepository::new();
579        let tx = create_test_transaction("test-1");
580
581        assert_eq!(repo.count().await.unwrap(), 0);
582        repo.create(tx).await.unwrap();
583        assert_eq!(repo.count().await.unwrap(), 1);
584    }
585
586    #[tokio::test]
587    async fn test_get_nonexistent_transaction() {
588        let repo = InMemoryTransactionRepository::new();
589        let result = repo.get_by_id("nonexistent".to_string()).await;
590        assert!(matches!(result, Err(RepositoryError::NotFound(_))));
591    }
592
593    #[tokio::test]
594    async fn test_duplicate_transaction_creation() {
595        let repo = InMemoryTransactionRepository::new();
596        let tx = create_test_transaction("test-1");
597
598        repo.create(tx.clone()).await.unwrap();
599        let result = repo.create(tx).await;
600
601        assert!(matches!(
602            result,
603            Err(RepositoryError::ConstraintViolation(_))
604        ));
605    }
606
607    #[tokio::test]
608    async fn test_update_nonexistent_transaction() {
609        let repo = InMemoryTransactionRepository::new();
610        let tx = create_test_transaction("test-1");
611
612        let result = repo.update("nonexistent".to_string(), tx).await;
613        assert!(matches!(result, Err(RepositoryError::NotFound(_))));
614    }
615
616    #[tokio::test]
617    async fn test_partial_update() {
618        let repo = InMemoryTransactionRepository::new();
619        let tx = create_test_transaction_pending_state("test-tx-id");
620        repo.create(tx.clone()).await.unwrap();
621
622        // Test updating only status
623        let update1 = TransactionUpdateRequest {
624            status: Some(TransactionStatus::Sent),
625            status_reason: None,
626            sent_at: None,
627            confirmed_at: None,
628            network_data: None,
629            hashes: None,
630            priced_at: None,
631            noop_count: None,
632            is_canceled: None,
633            delete_at: None,
634        };
635        let updated_tx1 = repo
636            .partial_update("test-tx-id".to_string(), update1)
637            .await
638            .unwrap();
639        assert_eq!(updated_tx1.status, TransactionStatus::Sent);
640        assert_eq!(updated_tx1.sent_at, None);
641
642        // Test updating multiple fields
643        let update2 = TransactionUpdateRequest {
644            status: Some(TransactionStatus::Confirmed),
645            status_reason: None,
646            sent_at: Some("2023-01-01T12:00:00Z".to_string()),
647            confirmed_at: Some("2023-01-01T12:05:00Z".to_string()),
648            network_data: None,
649            hashes: None,
650            priced_at: None,
651            noop_count: None,
652            is_canceled: None,
653            delete_at: None,
654        };
655        let updated_tx2 = repo
656            .partial_update("test-tx-id".to_string(), update2)
657            .await
658            .unwrap();
659        assert_eq!(updated_tx2.status, TransactionStatus::Confirmed);
660        assert_eq!(
661            updated_tx2.sent_at,
662            Some("2023-01-01T12:00:00Z".to_string())
663        );
664        assert_eq!(
665            updated_tx2.confirmed_at,
666            Some("2023-01-01T12:05:00Z".to_string())
667        );
668
669        // Test updating non-existent transaction
670        let update3 = TransactionUpdateRequest {
671            status: Some(TransactionStatus::Failed),
672            status_reason: None,
673            sent_at: None,
674            confirmed_at: None,
675            network_data: None,
676            hashes: None,
677            priced_at: None,
678            noop_count: None,
679            is_canceled: None,
680            delete_at: None,
681        };
682        let result = repo
683            .partial_update("non-existent-id".to_string(), update3)
684            .await;
685        assert!(result.is_err());
686        assert!(matches!(result.unwrap_err(), RepositoryError::NotFound(_)));
687    }
688
689    #[tokio::test]
690    async fn test_update_status() {
691        let repo = InMemoryTransactionRepository::new();
692        let tx = create_test_transaction("test-1");
693
694        repo.create(tx).await.unwrap();
695
696        // Update status to Confirmed
697        let updated = repo
698            .update_status("test-1".to_string(), TransactionStatus::Confirmed)
699            .await
700            .unwrap();
701
702        // Verify the status was updated in the returned transaction
703        assert_eq!(updated.status, TransactionStatus::Confirmed);
704
705        // Also verify by getting the transaction directly
706        let stored = repo.get_by_id("test-1".to_string()).await.unwrap();
707        assert_eq!(stored.status, TransactionStatus::Confirmed);
708
709        // Update status to Failed
710        let updated = repo
711            .update_status("test-1".to_string(), TransactionStatus::Failed)
712            .await
713            .unwrap();
714
715        // Verify the status was updated
716        assert_eq!(updated.status, TransactionStatus::Failed);
717
718        // Verify updating a non-existent transaction
719        let result = repo
720            .update_status("non-existent".to_string(), TransactionStatus::Confirmed)
721            .await;
722        assert!(matches!(result, Err(RepositoryError::NotFound(_))));
723    }
724
725    #[tokio::test]
726    async fn test_list_paginated() {
727        let repo = InMemoryTransactionRepository::new();
728
729        // Create multiple transactions
730        for i in 1..=10 {
731            let tx = create_test_transaction(&format!("test-{}", i));
732            repo.create(tx).await.unwrap();
733        }
734
735        // Test first page with 3 items per page
736        let query = PaginationQuery {
737            page: 1,
738            per_page: 3,
739        };
740        let result = repo.list_paginated(query).await.unwrap();
741        assert_eq!(result.items.len(), 3);
742        assert_eq!(result.total, 10);
743        assert_eq!(result.page, 1);
744        assert_eq!(result.per_page, 3);
745
746        // Test second page with 3 items per page
747        let query = PaginationQuery {
748            page: 2,
749            per_page: 3,
750        };
751        let result = repo.list_paginated(query).await.unwrap();
752        assert_eq!(result.items.len(), 3);
753        assert_eq!(result.total, 10);
754        assert_eq!(result.page, 2);
755        assert_eq!(result.per_page, 3);
756
757        // Test page with fewer items than per_page
758        let query = PaginationQuery {
759            page: 4,
760            per_page: 3,
761        };
762        let result = repo.list_paginated(query).await.unwrap();
763        assert_eq!(result.items.len(), 1);
764        assert_eq!(result.total, 10);
765        assert_eq!(result.page, 4);
766        assert_eq!(result.per_page, 3);
767
768        // Test empty page (beyond total items)
769        let query = PaginationQuery {
770            page: 5,
771            per_page: 3,
772        };
773        let result = repo.list_paginated(query).await.unwrap();
774        assert_eq!(result.items.len(), 0);
775        assert_eq!(result.total, 10);
776    }
777
778    #[tokio::test]
779    async fn test_find_by_nonce() {
780        let repo = InMemoryTransactionRepository::new();
781
782        // Create transactions with different nonces
783        let tx1 = create_test_transaction("test-1");
784
785        let mut tx2 = create_test_transaction("test-2");
786        if let NetworkTransactionData::Evm(ref mut data) = tx2.network_data {
787            data.nonce = Some(2);
788        }
789
790        let mut tx3 = create_test_transaction("test-3");
791        tx3.relayer_id = "relayer-2".to_string();
792        if let NetworkTransactionData::Evm(ref mut data) = tx3.network_data {
793            data.nonce = Some(1);
794        }
795
796        repo.create(tx1).await.unwrap();
797        repo.create(tx2).await.unwrap();
798        repo.create(tx3).await.unwrap();
799
800        // Test finding transaction with specific relayer_id and nonce
801        let result = repo.find_by_nonce("relayer-1", 1).await.unwrap();
802        assert!(result.is_some());
803        assert_eq!(result.as_ref().unwrap().id, "test-1");
804
805        // Test finding transaction with a different nonce
806        let result = repo.find_by_nonce("relayer-1", 2).await.unwrap();
807        assert!(result.is_some());
808        assert_eq!(result.as_ref().unwrap().id, "test-2");
809
810        // Test finding transaction from a different relayer
811        let result = repo.find_by_nonce("relayer-2", 1).await.unwrap();
812        assert!(result.is_some());
813        assert_eq!(result.as_ref().unwrap().id, "test-3");
814
815        // Test finding transaction that doesn't exist
816        let result = repo.find_by_nonce("relayer-1", 99).await.unwrap();
817        assert!(result.is_none());
818    }
819
820    #[tokio::test]
821    async fn test_update_network_data() {
822        let repo = InMemoryTransactionRepository::new();
823        let tx = create_test_transaction("test-1");
824
825        repo.create(tx.clone()).await.unwrap();
826
827        // Create new network data with updated values
828        let updated_network_data = NetworkTransactionData::Evm(EvmTransactionData {
829            gas_price: Some(2000000000),
830            gas_limit: Some(30000),
831            nonce: Some(2),
832            value: U256::from_str("2000000000000000000").unwrap(),
833            data: Some("0xUpdated".to_string()),
834            from: "0xSender".to_string(),
835            to: Some("0xRecipient".to_string()),
836            chain_id: 1,
837            signature: None,
838            hash: Some("0xUpdated".to_string()),
839            raw: None,
840            speed: None,
841            max_fee_per_gas: None,
842            max_priority_fee_per_gas: None,
843        });
844
845        let updated = repo
846            .update_network_data("test-1".to_string(), updated_network_data)
847            .await
848            .unwrap();
849
850        // Verify the network data was updated
851        if let NetworkTransactionData::Evm(data) = &updated.network_data {
852            assert_eq!(data.gas_price, Some(2000000000));
853            assert_eq!(data.gas_limit, Some(30000));
854            assert_eq!(data.nonce, Some(2));
855            assert_eq!(data.hash, Some("0xUpdated".to_string()));
856            assert_eq!(data.data, Some("0xUpdated".to_string()));
857        } else {
858            panic!("Expected EVM network data");
859        }
860    }
861
862    #[tokio::test]
863    async fn test_set_sent_at() {
864        let repo = InMemoryTransactionRepository::new();
865        let tx = create_test_transaction("test-1");
866
867        repo.create(tx).await.unwrap();
868
869        // Updated sent_at timestamp
870        let new_sent_at = "2025-02-01T10:00:00.000000+00:00".to_string();
871
872        let updated = repo
873            .set_sent_at("test-1".to_string(), new_sent_at.clone())
874            .await
875            .unwrap();
876
877        // Verify the sent_at timestamp was updated
878        assert_eq!(updated.sent_at, Some(new_sent_at.clone()));
879
880        // Also verify by getting the transaction directly
881        let stored = repo.get_by_id("test-1".to_string()).await.unwrap();
882        assert_eq!(stored.sent_at, Some(new_sent_at.clone()));
883    }
884
885    #[tokio::test]
886    async fn test_set_confirmed_at() {
887        let repo = InMemoryTransactionRepository::new();
888        let tx = create_test_transaction("test-1");
889
890        repo.create(tx).await.unwrap();
891
892        // Updated confirmed_at timestamp
893        let new_confirmed_at = "2025-02-01T11:30:45.123456+00:00".to_string();
894
895        let updated = repo
896            .set_confirmed_at("test-1".to_string(), new_confirmed_at.clone())
897            .await
898            .unwrap();
899
900        // Verify the confirmed_at timestamp was updated
901        assert_eq!(updated.confirmed_at, Some(new_confirmed_at.clone()));
902
903        // Also verify by getting the transaction directly
904        let stored = repo.get_by_id("test-1".to_string()).await.unwrap();
905        assert_eq!(stored.confirmed_at, Some(new_confirmed_at.clone()));
906    }
907
908    #[tokio::test]
909    async fn test_find_by_relayer_id() {
910        let repo = InMemoryTransactionRepository::new();
911        let tx1 = create_test_transaction("test-1");
912        let tx2 = create_test_transaction("test-2");
913
914        // Create a transaction with a different relayer_id
915        let mut tx3 = create_test_transaction("test-3");
916        tx3.relayer_id = "relayer-2".to_string();
917
918        repo.create(tx1).await.unwrap();
919        repo.create(tx2).await.unwrap();
920        repo.create(tx3).await.unwrap();
921
922        // Test finding transactions for relayer-1
923        let query = PaginationQuery {
924            page: 1,
925            per_page: 10,
926        };
927        let result = repo
928            .find_by_relayer_id("relayer-1", query.clone())
929            .await
930            .unwrap();
931        assert_eq!(result.total, 2);
932        assert_eq!(result.items.len(), 2);
933        assert!(result.items.iter().all(|tx| tx.relayer_id == "relayer-1"));
934
935        // Test finding transactions for relayer-2
936        let result = repo
937            .find_by_relayer_id("relayer-2", query.clone())
938            .await
939            .unwrap();
940        assert_eq!(result.total, 1);
941        assert_eq!(result.items.len(), 1);
942        assert!(result.items.iter().all(|tx| tx.relayer_id == "relayer-2"));
943
944        // Test finding transactions for non-existent relayer
945        let result = repo
946            .find_by_relayer_id("non-existent", query.clone())
947            .await
948            .unwrap();
949        assert_eq!(result.total, 0);
950        assert_eq!(result.items.len(), 0);
951    }
952
953    #[tokio::test]
954    async fn test_find_by_relayer_id_sorted_by_created_at_newest_first() {
955        let repo = InMemoryTransactionRepository::new();
956
957        // Create transactions with different created_at timestamps
958        let mut tx1 = create_test_transaction("test-1");
959        tx1.created_at = "2025-01-27T10:00:00.000000+00:00".to_string(); // Oldest
960
961        let mut tx2 = create_test_transaction("test-2");
962        tx2.created_at = "2025-01-27T12:00:00.000000+00:00".to_string(); // Middle
963
964        let mut tx3 = create_test_transaction("test-3");
965        tx3.created_at = "2025-01-27T14:00:00.000000+00:00".to_string(); // Newest
966
967        // Create transactions in non-chronological order to ensure sorting works
968        repo.create(tx2.clone()).await.unwrap(); // Middle first
969        repo.create(tx1.clone()).await.unwrap(); // Oldest second
970        repo.create(tx3.clone()).await.unwrap(); // Newest last
971
972        let query = PaginationQuery {
973            page: 1,
974            per_page: 10,
975        };
976        let result = repo.find_by_relayer_id("relayer-1", query).await.unwrap();
977
978        assert_eq!(result.total, 3);
979        assert_eq!(result.items.len(), 3);
980
981        // Verify transactions are sorted by created_at descending (newest first)
982        assert_eq!(
983            result.items[0].id, "test-3",
984            "First item should be newest (test-3)"
985        );
986        assert_eq!(
987            result.items[0].created_at,
988            "2025-01-27T14:00:00.000000+00:00"
989        );
990
991        assert_eq!(
992            result.items[1].id, "test-2",
993            "Second item should be middle (test-2)"
994        );
995        assert_eq!(
996            result.items[1].created_at,
997            "2025-01-27T12:00:00.000000+00:00"
998        );
999
1000        assert_eq!(
1001            result.items[2].id, "test-1",
1002            "Third item should be oldest (test-1)"
1003        );
1004        assert_eq!(
1005            result.items[2].created_at,
1006            "2025-01-27T10:00:00.000000+00:00"
1007        );
1008    }
1009
1010    #[tokio::test]
1011    async fn test_find_by_status() {
1012        let repo = InMemoryTransactionRepository::new();
1013        let tx1 = create_test_transaction_pending_state("tx1");
1014        let mut tx2 = create_test_transaction_pending_state("tx2");
1015        tx2.status = TransactionStatus::Submitted;
1016        let mut tx3 = create_test_transaction_pending_state("tx3");
1017        tx3.relayer_id = "relayer-2".to_string();
1018        tx3.status = TransactionStatus::Pending;
1019
1020        repo.create(tx1.clone()).await.unwrap();
1021        repo.create(tx2.clone()).await.unwrap();
1022        repo.create(tx3.clone()).await.unwrap();
1023
1024        // Test finding by single status
1025        let pending_txs = repo
1026            .find_by_status("relayer-1", &[TransactionStatus::Pending])
1027            .await
1028            .unwrap();
1029        assert_eq!(pending_txs.len(), 1);
1030        assert_eq!(pending_txs[0].id, "tx1");
1031
1032        let submitted_txs = repo
1033            .find_by_status("relayer-1", &[TransactionStatus::Submitted])
1034            .await
1035            .unwrap();
1036        assert_eq!(submitted_txs.len(), 1);
1037        assert_eq!(submitted_txs[0].id, "tx2");
1038
1039        // Test finding by multiple statuses
1040        let multiple_status_txs = repo
1041            .find_by_status(
1042                "relayer-1",
1043                &[TransactionStatus::Pending, TransactionStatus::Submitted],
1044            )
1045            .await
1046            .unwrap();
1047        assert_eq!(multiple_status_txs.len(), 2);
1048
1049        // Test finding for different relayer
1050        let relayer2_pending = repo
1051            .find_by_status("relayer-2", &[TransactionStatus::Pending])
1052            .await
1053            .unwrap();
1054        assert_eq!(relayer2_pending.len(), 1);
1055        assert_eq!(relayer2_pending[0].id, "tx3");
1056
1057        // Test finding for non-existent relayer
1058        let no_txs = repo
1059            .find_by_status("non-existent", &[TransactionStatus::Pending])
1060            .await
1061            .unwrap();
1062        assert_eq!(no_txs.len(), 0);
1063    }
1064
1065    #[tokio::test]
1066    async fn test_find_by_status_sorted_by_created_at() {
1067        let repo = InMemoryTransactionRepository::new();
1068
1069        // Helper function to create transaction with custom created_at timestamp
1070        let create_tx_with_timestamp = |id: &str, timestamp: &str| -> TransactionRepoModel {
1071            let mut tx = create_test_transaction_pending_state(id);
1072            tx.created_at = timestamp.to_string();
1073            tx.status = TransactionStatus::Pending;
1074            tx
1075        };
1076
1077        // Create transactions with different timestamps (out of chronological order)
1078        let tx3 = create_tx_with_timestamp("tx3", "2025-01-27T17:00:00.000000+00:00"); // Latest
1079        let tx1 = create_tx_with_timestamp("tx1", "2025-01-27T15:00:00.000000+00:00"); // Earliest
1080        let tx2 = create_tx_with_timestamp("tx2", "2025-01-27T16:00:00.000000+00:00"); // Middle
1081
1082        // Create them in reverse chronological order to test sorting
1083        repo.create(tx3.clone()).await.unwrap();
1084        repo.create(tx1.clone()).await.unwrap();
1085        repo.create(tx2.clone()).await.unwrap();
1086
1087        // Find by status
1088        let result = repo
1089            .find_by_status("relayer-1", &[TransactionStatus::Pending])
1090            .await
1091            .unwrap();
1092
1093        // Verify they are sorted by created_at (newest first) for Pending status
1094        assert_eq!(result.len(), 3);
1095        assert_eq!(result[0].id, "tx3"); // Latest
1096        assert_eq!(result[1].id, "tx2"); // Middle
1097        assert_eq!(result[2].id, "tx1"); // Earliest
1098
1099        // Verify the timestamps are in descending order
1100        assert_eq!(result[0].created_at, "2025-01-27T17:00:00.000000+00:00");
1101        assert_eq!(result[1].created_at, "2025-01-27T16:00:00.000000+00:00");
1102        assert_eq!(result[2].created_at, "2025-01-27T15:00:00.000000+00:00");
1103    }
1104
1105    #[tokio::test]
1106    async fn test_find_by_status_paginated() {
1107        let repo = InMemoryTransactionRepository::new();
1108
1109        // Helper function to create transaction with custom created_at timestamp
1110        let create_tx_with_timestamp =
1111            |id: &str, timestamp: &str, status: TransactionStatus| -> TransactionRepoModel {
1112                let mut tx = create_test_transaction_pending_state(id);
1113                tx.created_at = timestamp.to_string();
1114                tx.status = status;
1115                tx
1116            };
1117
1118        // Create 5 pending transactions
1119        for i in 1..=5 {
1120            let tx = create_tx_with_timestamp(
1121                &format!("tx{}", i),
1122                &format!("2025-01-27T{:02}:00:00.000000+00:00", 10 + i),
1123                TransactionStatus::Pending,
1124            );
1125            repo.create(tx).await.unwrap();
1126        }
1127
1128        // Create 2 confirmed transactions
1129        for i in 6..=7 {
1130            let tx = create_tx_with_timestamp(
1131                &format!("tx{}", i),
1132                &format!("2025-01-27T{:02}:00:00.000000+00:00", 10 + i),
1133                TransactionStatus::Confirmed,
1134            );
1135            repo.create(tx).await.unwrap();
1136        }
1137
1138        // Test first page (2 items per page)
1139        let query = PaginationQuery {
1140            page: 1,
1141            per_page: 2,
1142        };
1143        let result = repo
1144            .find_by_status_paginated("relayer-1", &[TransactionStatus::Pending], query, false)
1145            .await
1146            .unwrap();
1147
1148        assert_eq!(result.total, 5);
1149        assert_eq!(result.items.len(), 2);
1150        assert_eq!(result.page, 1);
1151        assert_eq!(result.per_page, 2);
1152        // Should be newest first (tx5, tx4)
1153        assert_eq!(result.items[0].id, "tx5");
1154        assert_eq!(result.items[1].id, "tx4");
1155
1156        // Test second page
1157        let query = PaginationQuery {
1158            page: 2,
1159            per_page: 2,
1160        };
1161        let result = repo
1162            .find_by_status_paginated("relayer-1", &[TransactionStatus::Pending], query, false)
1163            .await
1164            .unwrap();
1165
1166        assert_eq!(result.total, 5);
1167        assert_eq!(result.items.len(), 2);
1168        assert_eq!(result.page, 2);
1169        // Should be tx3, tx2
1170        assert_eq!(result.items[0].id, "tx3");
1171        assert_eq!(result.items[1].id, "tx2");
1172
1173        // Test last page (partial)
1174        let query = PaginationQuery {
1175            page: 3,
1176            per_page: 2,
1177        };
1178        let result = repo
1179            .find_by_status_paginated("relayer-1", &[TransactionStatus::Pending], query, false)
1180            .await
1181            .unwrap();
1182
1183        assert_eq!(result.total, 5);
1184        assert_eq!(result.items.len(), 1);
1185        assert_eq!(result.page, 3);
1186        assert_eq!(result.items[0].id, "tx1");
1187
1188        // Test beyond last page
1189        let query = PaginationQuery {
1190            page: 10,
1191            per_page: 2,
1192        };
1193        let result = repo
1194            .find_by_status_paginated("relayer-1", &[TransactionStatus::Pending], query, false)
1195            .await
1196            .unwrap();
1197
1198        assert_eq!(result.total, 5);
1199        assert_eq!(result.items.len(), 0);
1200
1201        // Test multiple statuses
1202        let query = PaginationQuery {
1203            page: 1,
1204            per_page: 10,
1205        };
1206        let result = repo
1207            .find_by_status_paginated(
1208                "relayer-1",
1209                &[TransactionStatus::Pending, TransactionStatus::Confirmed],
1210                query,
1211                false,
1212            )
1213            .await
1214            .unwrap();
1215
1216        assert_eq!(result.total, 7);
1217        assert_eq!(result.items.len(), 7);
1218
1219        // Test empty result
1220        let query = PaginationQuery {
1221            page: 1,
1222            per_page: 10,
1223        };
1224        let result = repo
1225            .find_by_status_paginated("relayer-1", &[TransactionStatus::Failed], query, false)
1226            .await
1227            .unwrap();
1228
1229        assert_eq!(result.total, 0);
1230        assert_eq!(result.items.len(), 0);
1231    }
1232
1233    #[tokio::test]
1234    async fn test_find_by_status_paginated_oldest_first() {
1235        let repo = InMemoryTransactionRepository::new();
1236
1237        // Helper function to create transaction with custom created_at timestamp
1238        let create_tx_with_timestamp =
1239            |id: &str, timestamp: &str, status: TransactionStatus| -> TransactionRepoModel {
1240                let mut tx = create_test_transaction_pending_state(id);
1241                tx.created_at = timestamp.to_string();
1242                tx.status = status;
1243                tx
1244            };
1245
1246        // Create 5 pending transactions with ascending timestamps
1247        for i in 1..=5 {
1248            let tx = create_tx_with_timestamp(
1249                &format!("tx{}", i),
1250                &format!("2025-01-27T{:02}:00:00.000000+00:00", 10 + i),
1251                TransactionStatus::Pending,
1252            );
1253            repo.create(tx).await.unwrap();
1254        }
1255
1256        // Test oldest_first: true - should return tx1, tx2, tx3... (ascending order)
1257        let query = PaginationQuery {
1258            page: 1,
1259            per_page: 3,
1260        };
1261        let result = repo
1262            .find_by_status_paginated("relayer-1", &[TransactionStatus::Pending], query, true)
1263            .await
1264            .unwrap();
1265
1266        assert_eq!(result.total, 5);
1267        assert_eq!(result.items.len(), 3);
1268        // Should be oldest first (tx1, tx2, tx3)
1269        assert_eq!(
1270            result.items[0].id, "tx1",
1271            "First item should be oldest (tx1)"
1272        );
1273        assert_eq!(result.items[1].id, "tx2", "Second item should be tx2");
1274        assert_eq!(result.items[2].id, "tx3", "Third item should be tx3");
1275
1276        // Test second page with oldest_first
1277        let query = PaginationQuery {
1278            page: 2,
1279            per_page: 3,
1280        };
1281        let result = repo
1282            .find_by_status_paginated("relayer-1", &[TransactionStatus::Pending], query, true)
1283            .await
1284            .unwrap();
1285
1286        assert_eq!(result.total, 5);
1287        assert_eq!(result.items.len(), 2);
1288        // Should be tx4, tx5
1289        assert_eq!(result.items[0].id, "tx4");
1290        assert_eq!(result.items[1].id, "tx5");
1291    }
1292
1293    #[tokio::test]
1294    async fn test_find_by_status_paginated_oldest_first_single_item() {
1295        let repo = InMemoryTransactionRepository::new();
1296
1297        // Create 3 pending transactions with different timestamps
1298        let timestamps = [
1299            ("tx-oldest", "2025-01-27T08:00:00.000000+00:00"),
1300            ("tx-middle", "2025-01-27T10:00:00.000000+00:00"),
1301            ("tx-newest", "2025-01-27T12:00:00.000000+00:00"),
1302        ];
1303
1304        for (id, timestamp) in timestamps {
1305            let mut tx = create_test_transaction_pending_state(id);
1306            tx.created_at = timestamp.to_string();
1307            tx.status = TransactionStatus::Pending;
1308            repo.create(tx).await.unwrap();
1309        }
1310
1311        // Request just 1 item with oldest_first: true - should get the oldest
1312        let query = PaginationQuery {
1313            page: 1,
1314            per_page: 1,
1315        };
1316        let result = repo
1317            .find_by_status_paginated(
1318                "relayer-1",
1319                &[TransactionStatus::Pending],
1320                query.clone(),
1321                true,
1322            )
1323            .await
1324            .unwrap();
1325
1326        assert_eq!(result.total, 3);
1327        assert_eq!(result.items.len(), 1);
1328        assert_eq!(
1329            result.items[0].id, "tx-oldest",
1330            "With oldest_first and per_page=1, should return the oldest transaction"
1331        );
1332
1333        // Contrast with oldest_first: false - should get the newest
1334        let result = repo
1335            .find_by_status_paginated("relayer-1", &[TransactionStatus::Pending], query, false)
1336            .await
1337            .unwrap();
1338
1339        assert_eq!(result.items.len(), 1);
1340        assert_eq!(
1341            result.items[0].id, "tx-newest",
1342            "With oldest_first=false and per_page=1, should return the newest transaction"
1343        );
1344    }
1345
1346    #[tokio::test]
1347    async fn test_find_by_status_paginated_multi_status_oldest_first() {
1348        let repo = InMemoryTransactionRepository::new();
1349
1350        // Create transactions with different statuses and timestamps
1351        let transactions = [
1352            (
1353                "tx-pending-old",
1354                "2025-01-27T08:00:00.000000+00:00",
1355                TransactionStatus::Pending,
1356            ),
1357            (
1358                "tx-sent-mid",
1359                "2025-01-27T10:00:00.000000+00:00",
1360                TransactionStatus::Sent,
1361            ),
1362            (
1363                "tx-pending-new",
1364                "2025-01-27T12:00:00.000000+00:00",
1365                TransactionStatus::Pending,
1366            ),
1367            (
1368                "tx-sent-old",
1369                "2025-01-27T07:00:00.000000+00:00",
1370                TransactionStatus::Sent,
1371            ),
1372        ];
1373
1374        for (id, timestamp, status) in transactions {
1375            let mut tx = create_test_transaction_pending_state(id);
1376            tx.created_at = timestamp.to_string();
1377            tx.status = status;
1378            repo.create(tx).await.unwrap();
1379        }
1380
1381        // Query multiple statuses with oldest_first: true
1382        let query = PaginationQuery {
1383            page: 1,
1384            per_page: 10,
1385        };
1386        let result = repo
1387            .find_by_status_paginated(
1388                "relayer-1",
1389                &[TransactionStatus::Pending, TransactionStatus::Sent],
1390                query,
1391                true,
1392            )
1393            .await
1394            .unwrap();
1395
1396        assert_eq!(result.total, 4);
1397        assert_eq!(result.items.len(), 4);
1398        // Should be sorted by created_at ascending (oldest first)
1399        assert_eq!(result.items[0].id, "tx-sent-old", "Oldest should be first");
1400        assert_eq!(result.items[1].id, "tx-pending-old");
1401        assert_eq!(result.items[2].id, "tx-sent-mid");
1402        assert_eq!(
1403            result.items[3].id, "tx-pending-new",
1404            "Newest should be last"
1405        );
1406    }
1407
1408    #[tokio::test]
1409    async fn test_has_entries() {
1410        let repo = InMemoryTransactionRepository::new();
1411        assert!(!repo.has_entries().await.unwrap());
1412
1413        let tx = create_test_transaction("test");
1414        repo.create(tx.clone()).await.unwrap();
1415
1416        assert!(repo.has_entries().await.unwrap());
1417    }
1418
1419    #[tokio::test]
1420    async fn test_drop_all_entries() {
1421        let repo = InMemoryTransactionRepository::new();
1422        let tx = create_test_transaction("test");
1423        repo.create(tx.clone()).await.unwrap();
1424
1425        assert!(repo.has_entries().await.unwrap());
1426
1427        repo.drop_all_entries().await.unwrap();
1428        assert!(!repo.has_entries().await.unwrap());
1429    }
1430
1431    // Tests for delete_at field setting on final status updates
1432
1433    #[tokio::test]
1434    async fn test_update_status_sets_delete_at_for_final_statuses() {
1435        let _lock = ENV_MUTEX.lock().await;
1436
1437        use chrono::{DateTime, Duration, Utc};
1438        use std::env;
1439
1440        // Use a unique test environment variable to avoid conflicts
1441        env::set_var("TRANSACTION_EXPIRATION_HOURS", "6");
1442
1443        let repo = InMemoryTransactionRepository::new();
1444
1445        let final_statuses = [
1446            TransactionStatus::Canceled,
1447            TransactionStatus::Confirmed,
1448            TransactionStatus::Failed,
1449            TransactionStatus::Expired,
1450        ];
1451
1452        for (i, status) in final_statuses.iter().enumerate() {
1453            let tx_id = format!("test-final-{}", i);
1454            let tx = create_test_transaction_pending_state(&tx_id);
1455
1456            // Ensure transaction has no delete_at initially
1457            assert!(tx.delete_at.is_none());
1458
1459            repo.create(tx).await.unwrap();
1460
1461            let before_update = Utc::now();
1462
1463            // Update to final status
1464            let updated = repo
1465                .update_status(tx_id.clone(), status.clone())
1466                .await
1467                .unwrap();
1468
1469            // Should have delete_at set
1470            assert!(
1471                updated.delete_at.is_some(),
1472                "delete_at should be set for status: {:?}",
1473                status
1474            );
1475
1476            // Verify the timestamp is reasonable (approximately 6 hours from now)
1477            let delete_at_str = updated.delete_at.unwrap();
1478            let delete_at = DateTime::parse_from_rfc3339(&delete_at_str)
1479                .expect("delete_at should be valid RFC3339")
1480                .with_timezone(&Utc);
1481
1482            let duration_from_before = delete_at.signed_duration_since(before_update);
1483            let expected_duration = Duration::hours(6);
1484            let tolerance = Duration::minutes(5);
1485
1486            assert!(
1487                duration_from_before >= expected_duration - tolerance &&
1488                duration_from_before <= expected_duration + tolerance,
1489                "delete_at should be approximately 6 hours from now for status: {:?}. Duration: {:?}",
1490                status, duration_from_before
1491            );
1492        }
1493
1494        // Cleanup
1495        env::remove_var("TRANSACTION_EXPIRATION_HOURS");
1496    }
1497
1498    #[tokio::test]
1499    async fn test_update_status_does_not_set_delete_at_for_non_final_statuses() {
1500        let _lock = ENV_MUTEX.lock().await;
1501
1502        use std::env;
1503
1504        env::set_var("TRANSACTION_EXPIRATION_HOURS", "4");
1505
1506        let repo = InMemoryTransactionRepository::new();
1507
1508        let non_final_statuses = [
1509            TransactionStatus::Pending,
1510            TransactionStatus::Sent,
1511            TransactionStatus::Submitted,
1512            TransactionStatus::Mined,
1513        ];
1514
1515        for (i, status) in non_final_statuses.iter().enumerate() {
1516            let tx_id = format!("test-non-final-{}", i);
1517            let tx = create_test_transaction_pending_state(&tx_id);
1518
1519            repo.create(tx).await.unwrap();
1520
1521            // Update to non-final status
1522            let updated = repo
1523                .update_status(tx_id.clone(), status.clone())
1524                .await
1525                .unwrap();
1526
1527            // Should NOT have delete_at set
1528            assert!(
1529                updated.delete_at.is_none(),
1530                "delete_at should NOT be set for status: {:?}",
1531                status
1532            );
1533        }
1534
1535        // Cleanup
1536        env::remove_var("TRANSACTION_EXPIRATION_HOURS");
1537    }
1538
1539    #[tokio::test]
1540    async fn test_partial_update_sets_delete_at_for_final_statuses() {
1541        let _lock = ENV_MUTEX.lock().await;
1542
1543        use chrono::{DateTime, Duration, Utc};
1544        use std::env;
1545
1546        env::set_var("TRANSACTION_EXPIRATION_HOURS", "8");
1547
1548        let repo = InMemoryTransactionRepository::new();
1549        let tx = create_test_transaction_pending_state("test-partial-final");
1550
1551        repo.create(tx).await.unwrap();
1552
1553        let before_update = Utc::now();
1554
1555        // Use partial_update to set status to Confirmed (final status)
1556        let update = TransactionUpdateRequest {
1557            status: Some(TransactionStatus::Confirmed),
1558            status_reason: Some("Transaction completed".to_string()),
1559            confirmed_at: Some("2023-01-01T12:05:00Z".to_string()),
1560            ..Default::default()
1561        };
1562
1563        let updated = repo
1564            .partial_update("test-partial-final".to_string(), update)
1565            .await
1566            .unwrap();
1567
1568        // Should have delete_at set
1569        assert!(
1570            updated.delete_at.is_some(),
1571            "delete_at should be set when updating to Confirmed status"
1572        );
1573
1574        // Verify the timestamp is reasonable (approximately 8 hours from now)
1575        let delete_at_str = updated.delete_at.unwrap();
1576        let delete_at = DateTime::parse_from_rfc3339(&delete_at_str)
1577            .expect("delete_at should be valid RFC3339")
1578            .with_timezone(&Utc);
1579
1580        let duration_from_before = delete_at.signed_duration_since(before_update);
1581        let expected_duration = Duration::hours(8);
1582        let tolerance = Duration::minutes(5);
1583
1584        assert!(
1585            duration_from_before >= expected_duration - tolerance
1586                && duration_from_before <= expected_duration + tolerance,
1587            "delete_at should be approximately 8 hours from now. Duration: {:?}",
1588            duration_from_before
1589        );
1590
1591        // Also verify other fields were updated
1592        assert_eq!(updated.status, TransactionStatus::Confirmed);
1593        assert_eq!(
1594            updated.status_reason,
1595            Some("Transaction completed".to_string())
1596        );
1597        assert_eq!(
1598            updated.confirmed_at,
1599            Some("2023-01-01T12:05:00Z".to_string())
1600        );
1601
1602        // Cleanup
1603        env::remove_var("TRANSACTION_EXPIRATION_HOURS");
1604    }
1605
1606    #[tokio::test]
1607    async fn test_update_status_preserves_existing_delete_at() {
1608        let _lock = ENV_MUTEX.lock().await;
1609
1610        use std::env;
1611
1612        env::set_var("TRANSACTION_EXPIRATION_HOURS", "2");
1613
1614        let repo = InMemoryTransactionRepository::new();
1615        let mut tx = create_test_transaction_pending_state("test-preserve-delete-at");
1616
1617        // Set an existing delete_at value
1618        let existing_delete_at = "2025-01-01T12:00:00Z".to_string();
1619        tx.delete_at = Some(existing_delete_at.clone());
1620
1621        repo.create(tx).await.unwrap();
1622
1623        // Update to final status
1624        let updated = repo
1625            .update_status(
1626                "test-preserve-delete-at".to_string(),
1627                TransactionStatus::Confirmed,
1628            )
1629            .await
1630            .unwrap();
1631
1632        // Should preserve the existing delete_at value
1633        assert_eq!(
1634            updated.delete_at,
1635            Some(existing_delete_at),
1636            "Existing delete_at should be preserved when updating to final status"
1637        );
1638
1639        // Cleanup
1640        env::remove_var("TRANSACTION_EXPIRATION_HOURS");
1641    }
1642
1643    #[tokio::test]
1644    async fn test_partial_update_without_status_change_preserves_delete_at() {
1645        let _lock = ENV_MUTEX.lock().await;
1646
1647        use std::env;
1648
1649        env::set_var("TRANSACTION_EXPIRATION_HOURS", "3");
1650
1651        let repo = InMemoryTransactionRepository::new();
1652        let tx = create_test_transaction_pending_state("test-preserve-no-status");
1653
1654        repo.create(tx).await.unwrap();
1655
1656        // First, update to final status to set delete_at
1657        let updated1 = repo
1658            .update_status(
1659                "test-preserve-no-status".to_string(),
1660                TransactionStatus::Confirmed,
1661            )
1662            .await
1663            .unwrap();
1664
1665        assert!(updated1.delete_at.is_some());
1666        let original_delete_at = updated1.delete_at.clone();
1667
1668        // Now update other fields without changing status
1669        let update = TransactionUpdateRequest {
1670            status: None, // No status change
1671            status_reason: Some("Updated reason".to_string()),
1672            confirmed_at: Some("2023-01-01T12:10:00Z".to_string()),
1673            ..Default::default()
1674        };
1675
1676        let updated2 = repo
1677            .partial_update("test-preserve-no-status".to_string(), update)
1678            .await
1679            .unwrap();
1680
1681        // delete_at should be preserved
1682        assert_eq!(
1683            updated2.delete_at, original_delete_at,
1684            "delete_at should be preserved when status is not updated"
1685        );
1686
1687        // Other fields should be updated
1688        assert_eq!(updated2.status, TransactionStatus::Confirmed); // Unchanged
1689        assert_eq!(updated2.status_reason, Some("Updated reason".to_string()));
1690        assert_eq!(
1691            updated2.confirmed_at,
1692            Some("2023-01-01T12:10:00Z".to_string())
1693        );
1694
1695        // Cleanup
1696        env::remove_var("TRANSACTION_EXPIRATION_HOURS");
1697    }
1698
1699    #[tokio::test]
1700    async fn test_update_status_multiple_updates_idempotent() {
1701        let _lock = ENV_MUTEX.lock().await;
1702
1703        use std::env;
1704
1705        env::set_var("TRANSACTION_EXPIRATION_HOURS", "12");
1706
1707        let repo = InMemoryTransactionRepository::new();
1708        let tx = create_test_transaction_pending_state("test-idempotent");
1709
1710        repo.create(tx).await.unwrap();
1711
1712        // First update to final status
1713        let updated1 = repo
1714            .update_status("test-idempotent".to_string(), TransactionStatus::Confirmed)
1715            .await
1716            .unwrap();
1717
1718        assert!(updated1.delete_at.is_some());
1719        let first_delete_at = updated1.delete_at.clone();
1720
1721        // Second update to another final status
1722        let updated2 = repo
1723            .update_status("test-idempotent".to_string(), TransactionStatus::Failed)
1724            .await
1725            .unwrap();
1726
1727        // delete_at should remain the same (idempotent)
1728        assert_eq!(
1729            updated2.delete_at, first_delete_at,
1730            "delete_at should not change on subsequent final status updates"
1731        );
1732
1733        // Status should be updated
1734        assert_eq!(updated2.status, TransactionStatus::Failed);
1735
1736        // Cleanup
1737        env::remove_var("TRANSACTION_EXPIRATION_HOURS");
1738    }
1739
1740    // Tests for delete_by_ids batch delete functionality
1741
1742    #[tokio::test]
1743    async fn test_delete_by_ids_empty_list() {
1744        let repo = InMemoryTransactionRepository::new();
1745
1746        // Create a transaction to ensure repo is not empty
1747        let tx = create_test_transaction("test-1");
1748        repo.create(tx).await.unwrap();
1749
1750        // Delete with empty list should succeed and not affect existing data
1751        let result = repo.delete_by_ids(vec![]).await.unwrap();
1752
1753        assert_eq!(result.deleted_count, 0);
1754        assert!(result.failed.is_empty());
1755
1756        // Original transaction should still exist
1757        assert!(repo.get_by_id("test-1".to_string()).await.is_ok());
1758    }
1759
1760    #[tokio::test]
1761    async fn test_delete_by_ids_single_transaction() {
1762        let repo = InMemoryTransactionRepository::new();
1763
1764        let tx = create_test_transaction("test-1");
1765        repo.create(tx).await.unwrap();
1766
1767        let result = repo
1768            .delete_by_ids(vec!["test-1".to_string()])
1769            .await
1770            .unwrap();
1771
1772        assert_eq!(result.deleted_count, 1);
1773        assert!(result.failed.is_empty());
1774
1775        // Verify transaction was deleted
1776        assert!(repo.get_by_id("test-1".to_string()).await.is_err());
1777    }
1778
1779    #[tokio::test]
1780    async fn test_delete_by_ids_multiple_transactions() {
1781        let repo = InMemoryTransactionRepository::new();
1782
1783        // Create multiple transactions
1784        for i in 1..=5 {
1785            let tx = create_test_transaction(&format!("test-{}", i));
1786            repo.create(tx).await.unwrap();
1787        }
1788
1789        assert_eq!(repo.count().await.unwrap(), 5);
1790
1791        // Delete 3 of them
1792        let ids_to_delete = vec![
1793            "test-1".to_string(),
1794            "test-3".to_string(),
1795            "test-5".to_string(),
1796        ];
1797        let result = repo.delete_by_ids(ids_to_delete).await.unwrap();
1798
1799        assert_eq!(result.deleted_count, 3);
1800        assert!(result.failed.is_empty());
1801
1802        // Verify correct transactions were deleted
1803        assert!(repo.get_by_id("test-1".to_string()).await.is_err());
1804        assert!(repo.get_by_id("test-2".to_string()).await.is_ok()); // Not deleted
1805        assert!(repo.get_by_id("test-3".to_string()).await.is_err());
1806        assert!(repo.get_by_id("test-4".to_string()).await.is_ok()); // Not deleted
1807        assert!(repo.get_by_id("test-5".to_string()).await.is_err());
1808
1809        assert_eq!(repo.count().await.unwrap(), 2);
1810    }
1811
1812    #[tokio::test]
1813    async fn test_delete_by_ids_nonexistent_transactions() {
1814        let repo = InMemoryTransactionRepository::new();
1815
1816        // Try to delete transactions that don't exist
1817        let ids_to_delete = vec!["nonexistent-1".to_string(), "nonexistent-2".to_string()];
1818        let result = repo.delete_by_ids(ids_to_delete).await.unwrap();
1819
1820        assert_eq!(result.deleted_count, 0);
1821        assert_eq!(result.failed.len(), 2);
1822
1823        // Verify error messages contain the IDs
1824        assert!(result.failed.iter().any(|(id, _)| id == "nonexistent-1"));
1825        assert!(result.failed.iter().any(|(id, _)| id == "nonexistent-2"));
1826    }
1827
1828    #[tokio::test]
1829    async fn test_delete_by_ids_mixed_existing_and_nonexistent() {
1830        let repo = InMemoryTransactionRepository::new();
1831
1832        // Create some transactions
1833        for i in 1..=3 {
1834            let tx = create_test_transaction(&format!("test-{}", i));
1835            repo.create(tx).await.unwrap();
1836        }
1837
1838        // Try to delete mix of existing and non-existing
1839        let ids_to_delete = vec![
1840            "test-1".to_string(),        // exists
1841            "nonexistent-1".to_string(), // doesn't exist
1842            "test-2".to_string(),        // exists
1843            "nonexistent-2".to_string(), // doesn't exist
1844        ];
1845        let result = repo.delete_by_ids(ids_to_delete).await.unwrap();
1846
1847        assert_eq!(result.deleted_count, 2);
1848        assert_eq!(result.failed.len(), 2);
1849
1850        // Verify existing transactions were deleted
1851        assert!(repo.get_by_id("test-1".to_string()).await.is_err());
1852        assert!(repo.get_by_id("test-2".to_string()).await.is_err());
1853
1854        // Verify remaining transaction still exists
1855        assert!(repo.get_by_id("test-3".to_string()).await.is_ok());
1856
1857        // Verify failed IDs are reported
1858        let failed_ids: Vec<&String> = result.failed.iter().map(|(id, _)| id).collect();
1859        assert!(failed_ids.contains(&&"nonexistent-1".to_string()));
1860        assert!(failed_ids.contains(&&"nonexistent-2".to_string()));
1861    }
1862
1863    #[tokio::test]
1864    async fn test_delete_by_ids_all_transactions() {
1865        let repo = InMemoryTransactionRepository::new();
1866
1867        // Create transactions
1868        for i in 1..=10 {
1869            let tx = create_test_transaction(&format!("test-{}", i));
1870            repo.create(tx).await.unwrap();
1871        }
1872
1873        assert_eq!(repo.count().await.unwrap(), 10);
1874
1875        // Delete all
1876        let ids_to_delete: Vec<String> = (1..=10).map(|i| format!("test-{}", i)).collect();
1877        let result = repo.delete_by_ids(ids_to_delete).await.unwrap();
1878
1879        assert_eq!(result.deleted_count, 10);
1880        assert!(result.failed.is_empty());
1881        assert_eq!(repo.count().await.unwrap(), 0);
1882        assert!(!repo.has_entries().await.unwrap());
1883    }
1884
1885    #[tokio::test]
1886    async fn test_delete_by_ids_duplicate_ids() {
1887        let repo = InMemoryTransactionRepository::new();
1888
1889        let tx = create_test_transaction("test-1");
1890        repo.create(tx).await.unwrap();
1891
1892        // Try to delete same ID multiple times in one call
1893        let ids_to_delete = vec![
1894            "test-1".to_string(),
1895            "test-1".to_string(), // duplicate
1896            "test-1".to_string(), // duplicate
1897        ];
1898        let result = repo.delete_by_ids(ids_to_delete).await.unwrap();
1899
1900        // First delete succeeds, subsequent ones fail (already deleted)
1901        assert_eq!(result.deleted_count, 1);
1902        assert_eq!(result.failed.len(), 2);
1903
1904        // Verify transaction was deleted
1905        assert!(repo.get_by_id("test-1".to_string()).await.is_err());
1906    }
1907
1908    #[tokio::test]
1909    async fn test_delete_by_ids_preserves_other_relayer_transactions() {
1910        let repo = InMemoryTransactionRepository::new();
1911
1912        // Create transactions for different relayers
1913        let mut tx1 = create_test_transaction("tx-relayer-1");
1914        tx1.relayer_id = "relayer-1".to_string();
1915
1916        let mut tx2 = create_test_transaction("tx-relayer-2");
1917        tx2.relayer_id = "relayer-2".to_string();
1918
1919        repo.create(tx1).await.unwrap();
1920        repo.create(tx2).await.unwrap();
1921
1922        // Delete only relayer-1's transaction
1923        let result = repo
1924            .delete_by_ids(vec!["tx-relayer-1".to_string()])
1925            .await
1926            .unwrap();
1927
1928        assert_eq!(result.deleted_count, 1);
1929
1930        // relayer-2's transaction should still exist
1931        let remaining = repo.get_by_id("tx-relayer-2".to_string()).await.unwrap();
1932        assert_eq!(remaining.relayer_id, "relayer-2");
1933    }
1934}