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    fn is_final_state(status: &TransactionStatus) -> bool {
76        matches!(
77            status,
78            TransactionStatus::Confirmed
79                | TransactionStatus::Failed
80                | TransactionStatus::Expired
81                | TransactionStatus::Canceled
82        )
83    }
84}
85
86// Implement both traits for InMemoryTransactionRepository
87
88#[async_trait]
89impl Repository<TransactionRepoModel, String> for InMemoryTransactionRepository {
90    async fn create(
91        &self,
92        tx: TransactionRepoModel,
93    ) -> Result<TransactionRepoModel, RepositoryError> {
94        let mut store = Self::acquire_lock(&self.store).await?;
95        if store.contains_key(&tx.id) {
96            return Err(RepositoryError::ConstraintViolation(format!(
97                "Transaction with ID {} already exists",
98                tx.id
99            )));
100        }
101        store.insert(tx.id.clone(), tx.clone());
102        Ok(tx)
103    }
104
105    async fn get_by_id(&self, id: String) -> Result<TransactionRepoModel, RepositoryError> {
106        let store = Self::acquire_lock(&self.store).await?;
107        store
108            .get(&id)
109            .cloned()
110            .ok_or_else(|| RepositoryError::NotFound(format!("Transaction with ID {id} not found")))
111    }
112
113    #[allow(clippy::map_entry)]
114    async fn update(
115        &self,
116        id: String,
117        tx: TransactionRepoModel,
118    ) -> Result<TransactionRepoModel, RepositoryError> {
119        let mut store = Self::acquire_lock(&self.store).await?;
120        if store.contains_key(&id) {
121            let mut updated_tx = tx;
122            updated_tx.id = id.clone();
123            store.insert(id, updated_tx.clone());
124            Ok(updated_tx)
125        } else {
126            Err(RepositoryError::NotFound(format!(
127                "Transaction with ID {id} not found"
128            )))
129        }
130    }
131
132    async fn delete_by_id(&self, id: String) -> Result<(), RepositoryError> {
133        let mut store = Self::acquire_lock(&self.store).await?;
134        if store.remove(&id).is_some() {
135            Ok(())
136        } else {
137            Err(RepositoryError::NotFound(format!(
138                "Transaction with ID {id} not found"
139            )))
140        }
141    }
142
143    async fn list_all(&self) -> Result<Vec<TransactionRepoModel>, RepositoryError> {
144        let store = Self::acquire_lock(&self.store).await?;
145        Ok(store.values().cloned().collect())
146    }
147
148    async fn list_paginated(
149        &self,
150        query: PaginationQuery,
151    ) -> Result<PaginatedResult<TransactionRepoModel>, RepositoryError> {
152        let total = self.count().await?;
153        let start = ((query.page - 1) * query.per_page) as usize;
154        let store = Self::acquire_lock(&self.store).await?;
155        let items: Vec<TransactionRepoModel> = store
156            .values()
157            .skip(start)
158            .take(query.per_page as usize)
159            .cloned()
160            .collect();
161
162        Ok(PaginatedResult {
163            items,
164            total: total as u64,
165            page: query.page,
166            per_page: query.per_page,
167        })
168    }
169
170    async fn count(&self) -> Result<usize, RepositoryError> {
171        let store = Self::acquire_lock(&self.store).await?;
172        Ok(store.len())
173    }
174
175    async fn has_entries(&self) -> Result<bool, RepositoryError> {
176        let store = Self::acquire_lock(&self.store).await?;
177        Ok(!store.is_empty())
178    }
179
180    async fn drop_all_entries(&self) -> Result<(), RepositoryError> {
181        let mut store = Self::acquire_lock(&self.store).await?;
182        store.clear();
183        Ok(())
184    }
185}
186
187#[async_trait]
188impl TransactionRepository for InMemoryTransactionRepository {
189    async fn find_by_relayer_id(
190        &self,
191        relayer_id: &str,
192        query: PaginationQuery,
193    ) -> Result<PaginatedResult<TransactionRepoModel>, RepositoryError> {
194        let store = Self::acquire_lock(&self.store).await?;
195        let filtered: Vec<TransactionRepoModel> = store
196            .values()
197            .filter(|tx| tx.relayer_id == relayer_id)
198            .cloned()
199            .collect();
200
201        let total = filtered.len() as u64;
202
203        if total == 0 {
204            return Ok(PaginatedResult::<TransactionRepoModel> {
205                items: vec![],
206                total: 0,
207                page: query.page,
208                per_page: query.per_page,
209            });
210        }
211
212        let start = ((query.page - 1) * query.per_page) as usize;
213
214        // Sort and paginate (newest first)
215        let items = filtered
216            .into_iter()
217            .sorted_by(|a, b| b.created_at.cmp(&a.created_at)) // Sort by created_at descending (newest first)
218            .skip(start)
219            .take(query.per_page as usize)
220            .collect();
221
222        Ok(PaginatedResult {
223            items,
224            total,
225            page: query.page,
226            per_page: query.per_page,
227        })
228    }
229
230    async fn find_by_status(
231        &self,
232        relayer_id: &str,
233        statuses: &[TransactionStatus],
234    ) -> Result<Vec<TransactionRepoModel>, RepositoryError> {
235        let store = Self::acquire_lock(&self.store).await?;
236        let filtered: Vec<TransactionRepoModel> = store
237            .values()
238            .filter(|tx| tx.relayer_id == relayer_id && statuses.contains(&tx.status))
239            .cloned()
240            .collect();
241
242        // Sort by created_at (newest first)
243        let sorted = filtered
244            .into_iter()
245            .sorted_by(|a, b| b.created_at.cmp(&a.created_at))
246            .collect();
247
248        Ok(sorted)
249    }
250
251    async fn find_by_status_paginated(
252        &self,
253        relayer_id: &str,
254        statuses: &[TransactionStatus],
255        query: PaginationQuery,
256        oldest_first: bool,
257    ) -> Result<PaginatedResult<TransactionRepoModel>, RepositoryError> {
258        let store = Self::acquire_lock(&self.store).await?;
259
260        // Filter by relayer_id and statuses
261        let filtered: Vec<TransactionRepoModel> = store
262            .values()
263            .filter(|tx| tx.relayer_id == relayer_id && statuses.contains(&tx.status))
264            .cloned()
265            .collect();
266
267        let total = filtered.len() as u64;
268        let start = ((query.page.saturating_sub(1)) * query.per_page) as usize;
269
270        // Sort using status-aware ordering: confirmed_at for Confirmed, created_at for others
271        // oldest_first: ascending order, otherwise descending (newest first)
272        let items: Vec<TransactionRepoModel> = if oldest_first {
273            filtered
274                .into_iter()
275                .sorted_by(|a, b| {
276                    let (a_key, _) = Self::get_sort_key(a);
277                    let (b_key, _) = Self::get_sort_key(b);
278                    a_key
279                        .cmp(b_key) // Ascending (oldest first)
280                        .then_with(|| a.id.cmp(&b.id)) // Tie-breaker: sort by ID for deterministic ordering
281                })
282                .skip(start)
283                .take(query.per_page as usize)
284                .collect()
285        } else {
286            filtered
287                .into_iter()
288                .sorted_by(Self::compare_for_sort) // Descending (newest first)
289                .skip(start)
290                .take(query.per_page as usize)
291                .collect()
292        };
293
294        Ok(PaginatedResult {
295            items,
296            total,
297            page: query.page,
298            per_page: query.per_page,
299        })
300    }
301
302    async fn find_by_nonce(
303        &self,
304        relayer_id: &str,
305        nonce: u64,
306    ) -> Result<Option<TransactionRepoModel>, RepositoryError> {
307        let store = Self::acquire_lock(&self.store).await?;
308        let filtered: Vec<TransactionRepoModel> = store
309            .values()
310            .filter(|tx| {
311                tx.relayer_id == relayer_id
312                    && match &tx.network_data {
313                        NetworkTransactionData::Evm(data) => data.nonce == Some(nonce),
314                        _ => false,
315                    }
316            })
317            .cloned()
318            .collect();
319
320        Ok(filtered.into_iter().next())
321    }
322
323    async fn get_nonce_occupancy(
324        &self,
325        relayer_id: &str,
326        from_nonce: u64,
327        to_nonce: u64,
328    ) -> Result<Vec<(u64, Option<TransactionStatus>)>, RepositoryError> {
329        let mut results = Vec::new();
330        for nonce in from_nonce..to_nonce {
331            let tx = self.find_by_nonce(relayer_id, nonce).await?;
332            results.push((nonce, tx.map(|t| t.status)));
333        }
334        Ok(results)
335    }
336
337    async fn update_status(
338        &self,
339        tx_id: String,
340        status: TransactionStatus,
341    ) -> Result<TransactionRepoModel, RepositoryError> {
342        let update = TransactionUpdateRequest {
343            status: Some(status),
344            ..Default::default()
345        };
346        self.partial_update(tx_id, update).await
347    }
348
349    async fn partial_update(
350        &self,
351        tx_id: String,
352        update: TransactionUpdateRequest,
353    ) -> Result<TransactionRepoModel, RepositoryError> {
354        let mut store = Self::acquire_lock(&self.store).await?;
355
356        if let Some(tx) = store.get_mut(&tx_id) {
357            // Apply partial updates using the model's business logic
358            tx.apply_partial_update(update);
359            Ok(tx.clone())
360        } else {
361            Err(RepositoryError::NotFound(format!(
362                "Transaction with ID {tx_id} not found"
363            )))
364        }
365    }
366
367    async fn update_network_data(
368        &self,
369        tx_id: String,
370        network_data: NetworkTransactionData,
371    ) -> Result<TransactionRepoModel, RepositoryError> {
372        let mut tx = self.get_by_id(tx_id.clone()).await?;
373        tx.network_data = network_data;
374        self.update(tx_id, tx).await
375    }
376
377    async fn set_sent_at(
378        &self,
379        tx_id: String,
380        sent_at: String,
381    ) -> Result<TransactionRepoModel, RepositoryError> {
382        let update = TransactionUpdateRequest {
383            sent_at: Some(sent_at),
384            ..Default::default()
385        };
386        self.partial_update(tx_id, update).await
387    }
388
389    async fn increment_status_check_failures(
390        &self,
391        tx_id: String,
392    ) -> Result<TransactionRepoModel, RepositoryError> {
393        let mut store = Self::acquire_lock(&self.store).await?;
394
395        if let Some(tx) = store.get_mut(&tx_id) {
396            if Self::is_final_state(&tx.status) {
397                return Ok(tx.clone());
398            }
399            let mut metadata = tx.metadata.clone().unwrap_or_default();
400            metadata.consecutive_failures = metadata.consecutive_failures.saturating_add(1);
401            metadata.total_failures = metadata.total_failures.saturating_add(1);
402            tx.metadata = Some(metadata);
403            Ok(tx.clone())
404        } else {
405            Err(RepositoryError::NotFound(format!(
406                "Transaction with ID {tx_id} not found"
407            )))
408        }
409    }
410
411    async fn reset_status_check_consecutive_failures(
412        &self,
413        tx_id: String,
414    ) -> Result<TransactionRepoModel, RepositoryError> {
415        let mut store = Self::acquire_lock(&self.store).await?;
416
417        if let Some(tx) = store.get_mut(&tx_id) {
418            if Self::is_final_state(&tx.status) {
419                return Ok(tx.clone());
420            }
421            let mut metadata = tx.metadata.clone().unwrap_or_default();
422            metadata.consecutive_failures = 0;
423            tx.metadata = Some(metadata);
424            Ok(tx.clone())
425        } else {
426            Err(RepositoryError::NotFound(format!(
427                "Transaction with ID {tx_id} not found"
428            )))
429        }
430    }
431
432    async fn record_stellar_insufficient_fee_retry(
433        &self,
434        tx_id: String,
435        sent_at: String,
436    ) -> Result<TransactionRepoModel, RepositoryError> {
437        let mut store = Self::acquire_lock(&self.store).await?;
438
439        if let Some(tx) = store.get_mut(&tx_id) {
440            if Self::is_final_state(&tx.status) {
441                return Ok(tx.clone());
442            }
443            let mut metadata = tx.metadata.clone().unwrap_or_default();
444            metadata.insufficient_fee_retries = metadata.insufficient_fee_retries.saturating_add(1);
445            tx.metadata = Some(metadata);
446            tx.sent_at = Some(sent_at);
447            Ok(tx.clone())
448        } else {
449            Err(RepositoryError::NotFound(format!(
450                "Transaction with ID {tx_id} not found"
451            )))
452        }
453    }
454
455    async fn record_stellar_try_again_later_retry(
456        &self,
457        tx_id: String,
458        sent_at: String,
459    ) -> Result<TransactionRepoModel, RepositoryError> {
460        let mut store = Self::acquire_lock(&self.store).await?;
461
462        if let Some(tx) = store.get_mut(&tx_id) {
463            if Self::is_final_state(&tx.status) {
464                return Ok(tx.clone());
465            }
466            let mut metadata = tx.metadata.clone().unwrap_or_default();
467            metadata.try_again_later_retries = metadata.try_again_later_retries.saturating_add(1);
468            tx.metadata = Some(metadata);
469            tx.sent_at = Some(sent_at);
470            Ok(tx.clone())
471        } else {
472            Err(RepositoryError::NotFound(format!(
473                "Transaction with ID {tx_id} not found"
474            )))
475        }
476    }
477
478    async fn set_confirmed_at(
479        &self,
480        tx_id: String,
481        confirmed_at: String,
482    ) -> Result<TransactionRepoModel, RepositoryError> {
483        let mut tx = self.get_by_id(tx_id.clone()).await?;
484        tx.confirmed_at = Some(confirmed_at);
485        self.update(tx_id, tx).await
486    }
487
488    async fn count_by_status(
489        &self,
490        relayer_id: &str,
491        statuses: &[TransactionStatus],
492    ) -> Result<u64, RepositoryError> {
493        let store = Self::acquire_lock(&self.store).await?;
494        let count = store
495            .values()
496            .filter(|tx| tx.relayer_id == relayer_id && statuses.contains(&tx.status))
497            .count() as u64;
498        Ok(count)
499    }
500
501    async fn delete_by_ids(&self, ids: Vec<String>) -> Result<BatchDeleteResult, RepositoryError> {
502        if ids.is_empty() {
503            return Ok(BatchDeleteResult::default());
504        }
505
506        let mut store = Self::acquire_lock(&self.store).await?;
507        let mut deleted_count = 0;
508        let mut failed = Vec::new();
509
510        for id in ids {
511            if store.remove(&id).is_some() {
512                deleted_count += 1;
513            } else {
514                failed.push((id.clone(), format!("Transaction with ID {id} not found")));
515            }
516        }
517
518        Ok(BatchDeleteResult {
519            deleted_count,
520            failed,
521        })
522    }
523
524    async fn delete_by_requests(
525        &self,
526        requests: Vec<TransactionDeleteRequest>,
527    ) -> Result<BatchDeleteResult, RepositoryError> {
528        if requests.is_empty() {
529            return Ok(BatchDeleteResult::default());
530        }
531
532        // For in-memory storage, we only need the IDs (no separate indexes to clean up)
533        let ids: Vec<String> = requests.into_iter().map(|r| r.id).collect();
534        self.delete_by_ids(ids).await
535    }
536}
537
538impl Default for InMemoryTransactionRepository {
539    fn default() -> Self {
540        Self::new()
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use crate::models::{evm::Speed, EvmTransactionData, NetworkType};
547    use lazy_static::lazy_static;
548    use std::str::FromStr;
549
550    use crate::models::U256;
551
552    use super::*;
553
554    use tokio::sync::Mutex;
555
556    lazy_static! {
557        static ref ENV_MUTEX: Mutex<()> = Mutex::new(());
558    }
559    // Helper function to create test transactions
560    fn create_test_transaction(id: &str) -> TransactionRepoModel {
561        TransactionRepoModel {
562            id: id.to_string(),
563            relayer_id: "relayer-1".to_string(),
564            status: TransactionStatus::Pending,
565            status_reason: None,
566            created_at: "2025-01-27T15:31:10.777083+00:00".to_string(),
567            sent_at: Some("2025-01-27T15:31:10.777083+00:00".to_string()),
568            confirmed_at: Some("2025-01-27T15:31:10.777083+00:00".to_string()),
569            valid_until: None,
570            delete_at: None,
571            network_type: NetworkType::Evm,
572            priced_at: None,
573            hashes: vec![],
574            network_data: NetworkTransactionData::Evm(EvmTransactionData {
575                gas_price: Some(1000000000),
576                gas_limit: Some(21000),
577                nonce: Some(1),
578                value: U256::from_str("1000000000000000000").unwrap(),
579                data: Some("0x".to_string()),
580                from: "0xSender".to_string(),
581                to: Some("0xRecipient".to_string()),
582                chain_id: 1,
583                signature: None,
584                hash: Some(format!("0x{id}")),
585                speed: Some(Speed::Fast),
586                max_fee_per_gas: None,
587                max_priority_fee_per_gas: None,
588                raw: None,
589            }),
590            noop_count: None,
591            is_canceled: Some(false),
592            metadata: None,
593        }
594    }
595
596    fn create_test_transaction_pending_state(id: &str) -> TransactionRepoModel {
597        TransactionRepoModel {
598            id: id.to_string(),
599            relayer_id: "relayer-1".to_string(),
600            status: TransactionStatus::Pending,
601            status_reason: None,
602            created_at: "2025-01-27T15:31:10.777083+00:00".to_string(),
603            sent_at: None,
604            confirmed_at: None,
605            valid_until: None,
606            delete_at: None,
607            network_type: NetworkType::Evm,
608            priced_at: None,
609            hashes: vec![],
610            network_data: NetworkTransactionData::Evm(EvmTransactionData {
611                gas_price: Some(1000000000),
612                gas_limit: Some(21000),
613                nonce: Some(1),
614                value: U256::from_str("1000000000000000000").unwrap(),
615                data: Some("0x".to_string()),
616                from: "0xSender".to_string(),
617                to: Some("0xRecipient".to_string()),
618                chain_id: 1,
619                signature: None,
620                hash: Some(format!("0x{id}")),
621                speed: Some(Speed::Fast),
622                max_fee_per_gas: None,
623                max_priority_fee_per_gas: None,
624                raw: None,
625            }),
626            noop_count: None,
627            is_canceled: Some(false),
628            metadata: None,
629        }
630    }
631
632    #[tokio::test]
633    async fn test_create_transaction() {
634        let repo = InMemoryTransactionRepository::new();
635        let tx = create_test_transaction("test-1");
636
637        let result = repo.create(tx.clone()).await.unwrap();
638        assert_eq!(result.id, tx.id);
639        assert_eq!(repo.count().await.unwrap(), 1);
640    }
641
642    #[tokio::test]
643    async fn test_get_transaction() {
644        let repo = InMemoryTransactionRepository::new();
645        let tx = create_test_transaction("test-1");
646
647        repo.create(tx.clone()).await.unwrap();
648        let stored = repo.get_by_id("test-1".to_string()).await.unwrap();
649        if let NetworkTransactionData::Evm(stored_data) = &stored.network_data {
650            if let NetworkTransactionData::Evm(tx_data) = &tx.network_data {
651                assert_eq!(stored_data.hash, tx_data.hash);
652            }
653        }
654    }
655
656    #[tokio::test]
657    async fn test_update_transaction() {
658        let repo = InMemoryTransactionRepository::new();
659        let mut tx = create_test_transaction("test-1");
660
661        repo.create(tx.clone()).await.unwrap();
662        tx.status = TransactionStatus::Confirmed;
663
664        let updated = repo.update("test-1".to_string(), tx).await.unwrap();
665        assert!(matches!(updated.status, TransactionStatus::Confirmed));
666    }
667
668    #[tokio::test]
669    async fn test_delete_transaction() {
670        let repo = InMemoryTransactionRepository::new();
671        let tx = create_test_transaction("test-1");
672
673        repo.create(tx).await.unwrap();
674        repo.delete_by_id("test-1".to_string()).await.unwrap();
675
676        let result = repo.get_by_id("test-1".to_string()).await;
677        assert!(result.is_err());
678    }
679
680    #[tokio::test]
681    async fn test_list_all_transactions() {
682        let repo = InMemoryTransactionRepository::new();
683        let tx1 = create_test_transaction("test-1");
684        let tx2 = create_test_transaction("test-2");
685
686        repo.create(tx1).await.unwrap();
687        repo.create(tx2).await.unwrap();
688
689        let transactions = repo.list_all().await.unwrap();
690        assert_eq!(transactions.len(), 2);
691    }
692
693    #[tokio::test]
694    async fn test_count_transactions() {
695        let repo = InMemoryTransactionRepository::new();
696        let tx = create_test_transaction("test-1");
697
698        assert_eq!(repo.count().await.unwrap(), 0);
699        repo.create(tx).await.unwrap();
700        assert_eq!(repo.count().await.unwrap(), 1);
701    }
702
703    #[tokio::test]
704    async fn test_get_nonexistent_transaction() {
705        let repo = InMemoryTransactionRepository::new();
706        let result = repo.get_by_id("nonexistent".to_string()).await;
707        assert!(matches!(result, Err(RepositoryError::NotFound(_))));
708    }
709
710    #[tokio::test]
711    async fn test_duplicate_transaction_creation() {
712        let repo = InMemoryTransactionRepository::new();
713        let tx = create_test_transaction("test-1");
714
715        repo.create(tx.clone()).await.unwrap();
716        let result = repo.create(tx).await;
717
718        assert!(matches!(
719            result,
720            Err(RepositoryError::ConstraintViolation(_))
721        ));
722    }
723
724    #[tokio::test]
725    async fn test_update_nonexistent_transaction() {
726        let repo = InMemoryTransactionRepository::new();
727        let tx = create_test_transaction("test-1");
728
729        let result = repo.update("nonexistent".to_string(), tx).await;
730        assert!(matches!(result, Err(RepositoryError::NotFound(_))));
731    }
732
733    #[tokio::test]
734    async fn test_partial_update() {
735        let repo = InMemoryTransactionRepository::new();
736        let tx = create_test_transaction_pending_state("test-tx-id");
737        repo.create(tx.clone()).await.unwrap();
738
739        // Test updating only status
740        let update1 = TransactionUpdateRequest {
741            status: Some(TransactionStatus::Sent),
742            status_reason: None,
743            sent_at: None,
744            confirmed_at: None,
745            network_data: None,
746            hashes: None,
747            priced_at: None,
748            noop_count: None,
749            is_canceled: None,
750            delete_at: None,
751            metadata: None,
752        };
753        let updated_tx1 = repo
754            .partial_update("test-tx-id".to_string(), update1)
755            .await
756            .unwrap();
757        assert_eq!(updated_tx1.status, TransactionStatus::Sent);
758        assert_eq!(updated_tx1.sent_at, None);
759
760        // Test updating multiple fields
761        let update2 = TransactionUpdateRequest {
762            status: Some(TransactionStatus::Confirmed),
763            status_reason: None,
764            sent_at: Some("2023-01-01T12:00:00Z".to_string()),
765            confirmed_at: Some("2023-01-01T12:05:00Z".to_string()),
766            network_data: None,
767            hashes: None,
768            priced_at: None,
769            noop_count: None,
770            is_canceled: None,
771            delete_at: None,
772            metadata: None,
773        };
774        let updated_tx2 = repo
775            .partial_update("test-tx-id".to_string(), update2)
776            .await
777            .unwrap();
778        assert_eq!(updated_tx2.status, TransactionStatus::Confirmed);
779        assert_eq!(
780            updated_tx2.sent_at,
781            Some("2023-01-01T12:00:00Z".to_string())
782        );
783        assert_eq!(
784            updated_tx2.confirmed_at,
785            Some("2023-01-01T12:05:00Z".to_string())
786        );
787
788        // Test updating non-existent transaction
789        let update3 = TransactionUpdateRequest {
790            status: Some(TransactionStatus::Failed),
791            status_reason: None,
792            sent_at: None,
793            confirmed_at: None,
794            network_data: None,
795            hashes: None,
796            priced_at: None,
797            noop_count: None,
798            is_canceled: None,
799            delete_at: None,
800            metadata: None,
801        };
802        let result = repo
803            .partial_update("non-existent-id".to_string(), update3)
804            .await;
805        assert!(result.is_err());
806        assert!(matches!(result.unwrap_err(), RepositoryError::NotFound(_)));
807    }
808
809    #[tokio::test]
810    async fn test_update_status() {
811        let repo = InMemoryTransactionRepository::new();
812        let tx = create_test_transaction("test-1");
813
814        repo.create(tx).await.unwrap();
815
816        // Update status to Confirmed
817        let updated = repo
818            .update_status("test-1".to_string(), TransactionStatus::Confirmed)
819            .await
820            .unwrap();
821
822        // Verify the status was updated in the returned transaction
823        assert_eq!(updated.status, TransactionStatus::Confirmed);
824
825        // Also verify by getting the transaction directly
826        let stored = repo.get_by_id("test-1".to_string()).await.unwrap();
827        assert_eq!(stored.status, TransactionStatus::Confirmed);
828
829        // Update status to Failed
830        let updated = repo
831            .update_status("test-1".to_string(), TransactionStatus::Failed)
832            .await
833            .unwrap();
834
835        // Verify the status was updated
836        assert_eq!(updated.status, TransactionStatus::Failed);
837
838        // Verify updating a non-existent transaction
839        let result = repo
840            .update_status("non-existent".to_string(), TransactionStatus::Confirmed)
841            .await;
842        assert!(matches!(result, Err(RepositoryError::NotFound(_))));
843    }
844
845    #[tokio::test]
846    async fn test_list_paginated() {
847        let repo = InMemoryTransactionRepository::new();
848
849        // Create multiple transactions
850        for i in 1..=10 {
851            let tx = create_test_transaction(&format!("test-{i}"));
852            repo.create(tx).await.unwrap();
853        }
854
855        // Test first page with 3 items per page
856        let query = PaginationQuery {
857            page: 1,
858            per_page: 3,
859        };
860        let result = repo.list_paginated(query).await.unwrap();
861        assert_eq!(result.items.len(), 3);
862        assert_eq!(result.total, 10);
863        assert_eq!(result.page, 1);
864        assert_eq!(result.per_page, 3);
865
866        // Test second page with 3 items per page
867        let query = PaginationQuery {
868            page: 2,
869            per_page: 3,
870        };
871        let result = repo.list_paginated(query).await.unwrap();
872        assert_eq!(result.items.len(), 3);
873        assert_eq!(result.total, 10);
874        assert_eq!(result.page, 2);
875        assert_eq!(result.per_page, 3);
876
877        // Test page with fewer items than per_page
878        let query = PaginationQuery {
879            page: 4,
880            per_page: 3,
881        };
882        let result = repo.list_paginated(query).await.unwrap();
883        assert_eq!(result.items.len(), 1);
884        assert_eq!(result.total, 10);
885        assert_eq!(result.page, 4);
886        assert_eq!(result.per_page, 3);
887
888        // Test empty page (beyond total items)
889        let query = PaginationQuery {
890            page: 5,
891            per_page: 3,
892        };
893        let result = repo.list_paginated(query).await.unwrap();
894        assert_eq!(result.items.len(), 0);
895        assert_eq!(result.total, 10);
896    }
897
898    #[tokio::test]
899    async fn test_find_by_nonce() {
900        let repo = InMemoryTransactionRepository::new();
901
902        // Create transactions with different nonces
903        let tx1 = create_test_transaction("test-1");
904
905        let mut tx2 = create_test_transaction("test-2");
906        if let NetworkTransactionData::Evm(ref mut data) = tx2.network_data {
907            data.nonce = Some(2);
908        }
909
910        let mut tx3 = create_test_transaction("test-3");
911        tx3.relayer_id = "relayer-2".to_string();
912        if let NetworkTransactionData::Evm(ref mut data) = tx3.network_data {
913            data.nonce = Some(1);
914        }
915
916        repo.create(tx1).await.unwrap();
917        repo.create(tx2).await.unwrap();
918        repo.create(tx3).await.unwrap();
919
920        // Test finding transaction with specific relayer_id and nonce
921        let result = repo.find_by_nonce("relayer-1", 1).await.unwrap();
922        assert!(result.is_some());
923        assert_eq!(result.as_ref().unwrap().id, "test-1");
924
925        // Test finding transaction with a different nonce
926        let result = repo.find_by_nonce("relayer-1", 2).await.unwrap();
927        assert!(result.is_some());
928        assert_eq!(result.as_ref().unwrap().id, "test-2");
929
930        // Test finding transaction from a different relayer
931        let result = repo.find_by_nonce("relayer-2", 1).await.unwrap();
932        assert!(result.is_some());
933        assert_eq!(result.as_ref().unwrap().id, "test-3");
934
935        // Test finding transaction that doesn't exist
936        let result = repo.find_by_nonce("relayer-1", 99).await.unwrap();
937        assert!(result.is_none());
938    }
939
940    #[tokio::test]
941    async fn test_get_nonce_occupancy_mixed_slots() {
942        let repo = InMemoryTransactionRepository::new();
943
944        // nonce 1 → Pending (active), nonce 2 → Failed (gap), nonce 3 → empty
945        let tx1 = create_test_transaction("tx-1"); // nonce=1, status=Pending
946        repo.create(tx1).await.unwrap();
947
948        let mut tx2 = create_test_transaction("tx-2");
949        tx2.status = TransactionStatus::Failed;
950        if let NetworkTransactionData::Evm(ref mut data) = tx2.network_data {
951            data.nonce = Some(2);
952        }
953        repo.create(tx2).await.unwrap();
954
955        let result = repo.get_nonce_occupancy("relayer-1", 1, 4).await.unwrap();
956
957        assert_eq!(result.len(), 3);
958        assert_eq!(result[0], (1, Some(TransactionStatus::Pending)));
959        assert_eq!(result[1], (2, Some(TransactionStatus::Failed)));
960        assert_eq!(result[2], (3, None));
961    }
962
963    #[tokio::test]
964    async fn test_get_nonce_occupancy_empty_range() {
965        let repo = InMemoryTransactionRepository::new();
966
967        // from >= to → empty result
968        let result = repo.get_nonce_occupancy("relayer-1", 5, 5).await.unwrap();
969        assert!(result.is_empty());
970
971        let result = repo.get_nonce_occupancy("relayer-1", 10, 5).await.unwrap();
972        assert!(result.is_empty());
973    }
974
975    #[tokio::test]
976    async fn test_get_nonce_occupancy_wrong_relayer() {
977        let repo = InMemoryTransactionRepository::new();
978
979        let tx1 = create_test_transaction("tx-1"); // relayer-1, nonce=1
980        repo.create(tx1).await.unwrap();
981
982        // Different relayer should see empty slots
983        let result = repo.get_nonce_occupancy("relayer-999", 1, 2).await.unwrap();
984        assert_eq!(result, vec![(1, None)]);
985    }
986
987    #[tokio::test]
988    async fn test_update_network_data() {
989        let repo = InMemoryTransactionRepository::new();
990        let tx = create_test_transaction("test-1");
991
992        repo.create(tx.clone()).await.unwrap();
993
994        // Create new network data with updated values
995        let updated_network_data = NetworkTransactionData::Evm(EvmTransactionData {
996            gas_price: Some(2000000000),
997            gas_limit: Some(30000),
998            nonce: Some(2),
999            value: U256::from_str("2000000000000000000").unwrap(),
1000            data: Some("0xUpdated".to_string()),
1001            from: "0xSender".to_string(),
1002            to: Some("0xRecipient".to_string()),
1003            chain_id: 1,
1004            signature: None,
1005            hash: Some("0xUpdated".to_string()),
1006            raw: None,
1007            speed: None,
1008            max_fee_per_gas: None,
1009            max_priority_fee_per_gas: None,
1010        });
1011
1012        let updated = repo
1013            .update_network_data("test-1".to_string(), updated_network_data)
1014            .await
1015            .unwrap();
1016
1017        // Verify the network data was updated
1018        if let NetworkTransactionData::Evm(data) = &updated.network_data {
1019            assert_eq!(data.gas_price, Some(2000000000));
1020            assert_eq!(data.gas_limit, Some(30000));
1021            assert_eq!(data.nonce, Some(2));
1022            assert_eq!(data.hash, Some("0xUpdated".to_string()));
1023            assert_eq!(data.data, Some("0xUpdated".to_string()));
1024        } else {
1025            panic!("Expected EVM network data");
1026        }
1027    }
1028
1029    #[tokio::test]
1030    async fn test_set_sent_at() {
1031        let repo = InMemoryTransactionRepository::new();
1032        let tx = create_test_transaction("test-1");
1033
1034        repo.create(tx).await.unwrap();
1035
1036        // Updated sent_at timestamp
1037        let new_sent_at = "2025-02-01T10:00:00.000000+00:00".to_string();
1038
1039        let updated = repo
1040            .set_sent_at("test-1".to_string(), new_sent_at.clone())
1041            .await
1042            .unwrap();
1043
1044        // Verify the sent_at timestamp was updated
1045        assert_eq!(updated.sent_at, Some(new_sent_at.clone()));
1046
1047        // Also verify by getting the transaction directly
1048        let stored = repo.get_by_id("test-1".to_string()).await.unwrap();
1049        assert_eq!(stored.sent_at, Some(new_sent_at.clone()));
1050    }
1051
1052    #[tokio::test]
1053    async fn test_set_confirmed_at() {
1054        let repo = InMemoryTransactionRepository::new();
1055        let tx = create_test_transaction("test-1");
1056
1057        repo.create(tx).await.unwrap();
1058
1059        // Updated confirmed_at timestamp
1060        let new_confirmed_at = "2025-02-01T11:30:45.123456+00:00".to_string();
1061
1062        let updated = repo
1063            .set_confirmed_at("test-1".to_string(), new_confirmed_at.clone())
1064            .await
1065            .unwrap();
1066
1067        // Verify the confirmed_at timestamp was updated
1068        assert_eq!(updated.confirmed_at, Some(new_confirmed_at.clone()));
1069
1070        // Also verify by getting the transaction directly
1071        let stored = repo.get_by_id("test-1".to_string()).await.unwrap();
1072        assert_eq!(stored.confirmed_at, Some(new_confirmed_at.clone()));
1073    }
1074
1075    #[tokio::test]
1076    async fn test_find_by_relayer_id() {
1077        let repo = InMemoryTransactionRepository::new();
1078        let tx1 = create_test_transaction("test-1");
1079        let tx2 = create_test_transaction("test-2");
1080
1081        // Create a transaction with a different relayer_id
1082        let mut tx3 = create_test_transaction("test-3");
1083        tx3.relayer_id = "relayer-2".to_string();
1084
1085        repo.create(tx1).await.unwrap();
1086        repo.create(tx2).await.unwrap();
1087        repo.create(tx3).await.unwrap();
1088
1089        // Test finding transactions for relayer-1
1090        let query = PaginationQuery {
1091            page: 1,
1092            per_page: 10,
1093        };
1094        let result = repo
1095            .find_by_relayer_id("relayer-1", query.clone())
1096            .await
1097            .unwrap();
1098        assert_eq!(result.total, 2);
1099        assert_eq!(result.items.len(), 2);
1100        assert!(result.items.iter().all(|tx| tx.relayer_id == "relayer-1"));
1101
1102        // Test finding transactions for relayer-2
1103        let result = repo
1104            .find_by_relayer_id("relayer-2", query.clone())
1105            .await
1106            .unwrap();
1107        assert_eq!(result.total, 1);
1108        assert_eq!(result.items.len(), 1);
1109        assert!(result.items.iter().all(|tx| tx.relayer_id == "relayer-2"));
1110
1111        // Test finding transactions for non-existent relayer
1112        let result = repo
1113            .find_by_relayer_id("non-existent", query.clone())
1114            .await
1115            .unwrap();
1116        assert_eq!(result.total, 0);
1117        assert_eq!(result.items.len(), 0);
1118    }
1119
1120    #[tokio::test]
1121    async fn test_find_by_relayer_id_sorted_by_created_at_newest_first() {
1122        let repo = InMemoryTransactionRepository::new();
1123
1124        // Create transactions with different created_at timestamps
1125        let mut tx1 = create_test_transaction("test-1");
1126        tx1.created_at = "2025-01-27T10:00:00.000000+00:00".to_string(); // Oldest
1127
1128        let mut tx2 = create_test_transaction("test-2");
1129        tx2.created_at = "2025-01-27T12:00:00.000000+00:00".to_string(); // Middle
1130
1131        let mut tx3 = create_test_transaction("test-3");
1132        tx3.created_at = "2025-01-27T14:00:00.000000+00:00".to_string(); // Newest
1133
1134        // Create transactions in non-chronological order to ensure sorting works
1135        repo.create(tx2.clone()).await.unwrap(); // Middle first
1136        repo.create(tx1.clone()).await.unwrap(); // Oldest second
1137        repo.create(tx3.clone()).await.unwrap(); // Newest last
1138
1139        let query = PaginationQuery {
1140            page: 1,
1141            per_page: 10,
1142        };
1143        let result = repo.find_by_relayer_id("relayer-1", query).await.unwrap();
1144
1145        assert_eq!(result.total, 3);
1146        assert_eq!(result.items.len(), 3);
1147
1148        // Verify transactions are sorted by created_at descending (newest first)
1149        assert_eq!(
1150            result.items[0].id, "test-3",
1151            "First item should be newest (test-3)"
1152        );
1153        assert_eq!(
1154            result.items[0].created_at,
1155            "2025-01-27T14:00:00.000000+00:00"
1156        );
1157
1158        assert_eq!(
1159            result.items[1].id, "test-2",
1160            "Second item should be middle (test-2)"
1161        );
1162        assert_eq!(
1163            result.items[1].created_at,
1164            "2025-01-27T12:00:00.000000+00:00"
1165        );
1166
1167        assert_eq!(
1168            result.items[2].id, "test-1",
1169            "Third item should be oldest (test-1)"
1170        );
1171        assert_eq!(
1172            result.items[2].created_at,
1173            "2025-01-27T10:00:00.000000+00:00"
1174        );
1175    }
1176
1177    #[tokio::test]
1178    async fn test_find_by_status() {
1179        let repo = InMemoryTransactionRepository::new();
1180        let tx1 = create_test_transaction_pending_state("tx1");
1181        let mut tx2 = create_test_transaction_pending_state("tx2");
1182        tx2.status = TransactionStatus::Submitted;
1183        let mut tx3 = create_test_transaction_pending_state("tx3");
1184        tx3.relayer_id = "relayer-2".to_string();
1185        tx3.status = TransactionStatus::Pending;
1186
1187        repo.create(tx1.clone()).await.unwrap();
1188        repo.create(tx2.clone()).await.unwrap();
1189        repo.create(tx3.clone()).await.unwrap();
1190
1191        // Test finding by single status
1192        let pending_txs = repo
1193            .find_by_status("relayer-1", &[TransactionStatus::Pending])
1194            .await
1195            .unwrap();
1196        assert_eq!(pending_txs.len(), 1);
1197        assert_eq!(pending_txs[0].id, "tx1");
1198
1199        let submitted_txs = repo
1200            .find_by_status("relayer-1", &[TransactionStatus::Submitted])
1201            .await
1202            .unwrap();
1203        assert_eq!(submitted_txs.len(), 1);
1204        assert_eq!(submitted_txs[0].id, "tx2");
1205
1206        // Test finding by multiple statuses
1207        let multiple_status_txs = repo
1208            .find_by_status(
1209                "relayer-1",
1210                &[TransactionStatus::Pending, TransactionStatus::Submitted],
1211            )
1212            .await
1213            .unwrap();
1214        assert_eq!(multiple_status_txs.len(), 2);
1215
1216        // Test finding for different relayer
1217        let relayer2_pending = repo
1218            .find_by_status("relayer-2", &[TransactionStatus::Pending])
1219            .await
1220            .unwrap();
1221        assert_eq!(relayer2_pending.len(), 1);
1222        assert_eq!(relayer2_pending[0].id, "tx3");
1223
1224        // Test finding for non-existent relayer
1225        let no_txs = repo
1226            .find_by_status("non-existent", &[TransactionStatus::Pending])
1227            .await
1228            .unwrap();
1229        assert_eq!(no_txs.len(), 0);
1230    }
1231
1232    #[tokio::test]
1233    async fn test_find_by_status_sorted_by_created_at() {
1234        let repo = InMemoryTransactionRepository::new();
1235
1236        // Helper function to create transaction with custom created_at timestamp
1237        let create_tx_with_timestamp = |id: &str, timestamp: &str| -> TransactionRepoModel {
1238            let mut tx = create_test_transaction_pending_state(id);
1239            tx.created_at = timestamp.to_string();
1240            tx.status = TransactionStatus::Pending;
1241            tx
1242        };
1243
1244        // Create transactions with different timestamps (out of chronological order)
1245        let tx3 = create_tx_with_timestamp("tx3", "2025-01-27T17:00:00.000000+00:00"); // Latest
1246        let tx1 = create_tx_with_timestamp("tx1", "2025-01-27T15:00:00.000000+00:00"); // Earliest
1247        let tx2 = create_tx_with_timestamp("tx2", "2025-01-27T16:00:00.000000+00:00"); // Middle
1248
1249        // Create them in reverse chronological order to test sorting
1250        repo.create(tx3.clone()).await.unwrap();
1251        repo.create(tx1.clone()).await.unwrap();
1252        repo.create(tx2.clone()).await.unwrap();
1253
1254        // Find by status
1255        let result = repo
1256            .find_by_status("relayer-1", &[TransactionStatus::Pending])
1257            .await
1258            .unwrap();
1259
1260        // Verify they are sorted by created_at (newest first) for Pending status
1261        assert_eq!(result.len(), 3);
1262        assert_eq!(result[0].id, "tx3"); // Latest
1263        assert_eq!(result[1].id, "tx2"); // Middle
1264        assert_eq!(result[2].id, "tx1"); // Earliest
1265
1266        // Verify the timestamps are in descending order
1267        assert_eq!(result[0].created_at, "2025-01-27T17:00:00.000000+00:00");
1268        assert_eq!(result[1].created_at, "2025-01-27T16:00:00.000000+00:00");
1269        assert_eq!(result[2].created_at, "2025-01-27T15:00:00.000000+00:00");
1270    }
1271
1272    #[tokio::test]
1273    async fn test_find_by_status_paginated() {
1274        let repo = InMemoryTransactionRepository::new();
1275
1276        // Helper function to create transaction with custom created_at timestamp
1277        let create_tx_with_timestamp =
1278            |id: &str, timestamp: &str, status: TransactionStatus| -> TransactionRepoModel {
1279                let mut tx = create_test_transaction_pending_state(id);
1280                tx.created_at = timestamp.to_string();
1281                tx.status = status;
1282                tx
1283            };
1284
1285        // Create 5 pending transactions
1286        for i in 1..=5 {
1287            let tx = create_tx_with_timestamp(
1288                &format!("tx{i}"),
1289                &format!("2025-01-27T{:02}:00:00.000000+00:00", 10 + i),
1290                TransactionStatus::Pending,
1291            );
1292            repo.create(tx).await.unwrap();
1293        }
1294
1295        // Create 2 confirmed transactions
1296        for i in 6..=7 {
1297            let tx = create_tx_with_timestamp(
1298                &format!("tx{i}"),
1299                &format!("2025-01-27T{:02}:00:00.000000+00:00", 10 + i),
1300                TransactionStatus::Confirmed,
1301            );
1302            repo.create(tx).await.unwrap();
1303        }
1304
1305        // Test first page (2 items per page)
1306        let query = PaginationQuery {
1307            page: 1,
1308            per_page: 2,
1309        };
1310        let result = repo
1311            .find_by_status_paginated("relayer-1", &[TransactionStatus::Pending], query, false)
1312            .await
1313            .unwrap();
1314
1315        assert_eq!(result.total, 5);
1316        assert_eq!(result.items.len(), 2);
1317        assert_eq!(result.page, 1);
1318        assert_eq!(result.per_page, 2);
1319        // Should be newest first (tx5, tx4)
1320        assert_eq!(result.items[0].id, "tx5");
1321        assert_eq!(result.items[1].id, "tx4");
1322
1323        // Test second page
1324        let query = PaginationQuery {
1325            page: 2,
1326            per_page: 2,
1327        };
1328        let result = repo
1329            .find_by_status_paginated("relayer-1", &[TransactionStatus::Pending], query, false)
1330            .await
1331            .unwrap();
1332
1333        assert_eq!(result.total, 5);
1334        assert_eq!(result.items.len(), 2);
1335        assert_eq!(result.page, 2);
1336        // Should be tx3, tx2
1337        assert_eq!(result.items[0].id, "tx3");
1338        assert_eq!(result.items[1].id, "tx2");
1339
1340        // Test last page (partial)
1341        let query = PaginationQuery {
1342            page: 3,
1343            per_page: 2,
1344        };
1345        let result = repo
1346            .find_by_status_paginated("relayer-1", &[TransactionStatus::Pending], query, false)
1347            .await
1348            .unwrap();
1349
1350        assert_eq!(result.total, 5);
1351        assert_eq!(result.items.len(), 1);
1352        assert_eq!(result.page, 3);
1353        assert_eq!(result.items[0].id, "tx1");
1354
1355        // Test beyond last page
1356        let query = PaginationQuery {
1357            page: 10,
1358            per_page: 2,
1359        };
1360        let result = repo
1361            .find_by_status_paginated("relayer-1", &[TransactionStatus::Pending], query, false)
1362            .await
1363            .unwrap();
1364
1365        assert_eq!(result.total, 5);
1366        assert_eq!(result.items.len(), 0);
1367
1368        // Test multiple statuses
1369        let query = PaginationQuery {
1370            page: 1,
1371            per_page: 10,
1372        };
1373        let result = repo
1374            .find_by_status_paginated(
1375                "relayer-1",
1376                &[TransactionStatus::Pending, TransactionStatus::Confirmed],
1377                query,
1378                false,
1379            )
1380            .await
1381            .unwrap();
1382
1383        assert_eq!(result.total, 7);
1384        assert_eq!(result.items.len(), 7);
1385
1386        // Test empty result
1387        let query = PaginationQuery {
1388            page: 1,
1389            per_page: 10,
1390        };
1391        let result = repo
1392            .find_by_status_paginated("relayer-1", &[TransactionStatus::Failed], query, false)
1393            .await
1394            .unwrap();
1395
1396        assert_eq!(result.total, 0);
1397        assert_eq!(result.items.len(), 0);
1398    }
1399
1400    #[tokio::test]
1401    async fn test_find_by_status_paginated_oldest_first() {
1402        let repo = InMemoryTransactionRepository::new();
1403
1404        // Helper function to create transaction with custom created_at timestamp
1405        let create_tx_with_timestamp =
1406            |id: &str, timestamp: &str, status: TransactionStatus| -> TransactionRepoModel {
1407                let mut tx = create_test_transaction_pending_state(id);
1408                tx.created_at = timestamp.to_string();
1409                tx.status = status;
1410                tx
1411            };
1412
1413        // Create 5 pending transactions with ascending timestamps
1414        for i in 1..=5 {
1415            let tx = create_tx_with_timestamp(
1416                &format!("tx{i}"),
1417                &format!("2025-01-27T{:02}:00:00.000000+00:00", 10 + i),
1418                TransactionStatus::Pending,
1419            );
1420            repo.create(tx).await.unwrap();
1421        }
1422
1423        // Test oldest_first: true - should return tx1, tx2, tx3... (ascending order)
1424        let query = PaginationQuery {
1425            page: 1,
1426            per_page: 3,
1427        };
1428        let result = repo
1429            .find_by_status_paginated("relayer-1", &[TransactionStatus::Pending], query, true)
1430            .await
1431            .unwrap();
1432
1433        assert_eq!(result.total, 5);
1434        assert_eq!(result.items.len(), 3);
1435        // Should be oldest first (tx1, tx2, tx3)
1436        assert_eq!(
1437            result.items[0].id, "tx1",
1438            "First item should be oldest (tx1)"
1439        );
1440        assert_eq!(result.items[1].id, "tx2", "Second item should be tx2");
1441        assert_eq!(result.items[2].id, "tx3", "Third item should be tx3");
1442
1443        // Test second page with oldest_first
1444        let query = PaginationQuery {
1445            page: 2,
1446            per_page: 3,
1447        };
1448        let result = repo
1449            .find_by_status_paginated("relayer-1", &[TransactionStatus::Pending], query, true)
1450            .await
1451            .unwrap();
1452
1453        assert_eq!(result.total, 5);
1454        assert_eq!(result.items.len(), 2);
1455        // Should be tx4, tx5
1456        assert_eq!(result.items[0].id, "tx4");
1457        assert_eq!(result.items[1].id, "tx5");
1458    }
1459
1460    #[tokio::test]
1461    async fn test_find_by_status_paginated_oldest_first_single_item() {
1462        let repo = InMemoryTransactionRepository::new();
1463
1464        // Create 3 pending transactions with different timestamps
1465        let timestamps = [
1466            ("tx-oldest", "2025-01-27T08:00:00.000000+00:00"),
1467            ("tx-middle", "2025-01-27T10:00:00.000000+00:00"),
1468            ("tx-newest", "2025-01-27T12:00:00.000000+00:00"),
1469        ];
1470
1471        for (id, timestamp) in timestamps {
1472            let mut tx = create_test_transaction_pending_state(id);
1473            tx.created_at = timestamp.to_string();
1474            tx.status = TransactionStatus::Pending;
1475            repo.create(tx).await.unwrap();
1476        }
1477
1478        // Request just 1 item with oldest_first: true - should get the oldest
1479        let query = PaginationQuery {
1480            page: 1,
1481            per_page: 1,
1482        };
1483        let result = repo
1484            .find_by_status_paginated(
1485                "relayer-1",
1486                &[TransactionStatus::Pending],
1487                query.clone(),
1488                true,
1489            )
1490            .await
1491            .unwrap();
1492
1493        assert_eq!(result.total, 3);
1494        assert_eq!(result.items.len(), 1);
1495        assert_eq!(
1496            result.items[0].id, "tx-oldest",
1497            "With oldest_first and per_page=1, should return the oldest transaction"
1498        );
1499
1500        // Contrast with oldest_first: false - should get the newest
1501        let result = repo
1502            .find_by_status_paginated("relayer-1", &[TransactionStatus::Pending], query, false)
1503            .await
1504            .unwrap();
1505
1506        assert_eq!(result.items.len(), 1);
1507        assert_eq!(
1508            result.items[0].id, "tx-newest",
1509            "With oldest_first=false and per_page=1, should return the newest transaction"
1510        );
1511    }
1512
1513    #[tokio::test]
1514    async fn test_find_by_status_paginated_multi_status_oldest_first() {
1515        let repo = InMemoryTransactionRepository::new();
1516
1517        // Create transactions with different statuses and timestamps
1518        let transactions = [
1519            (
1520                "tx-pending-old",
1521                "2025-01-27T08:00:00.000000+00:00",
1522                TransactionStatus::Pending,
1523            ),
1524            (
1525                "tx-sent-mid",
1526                "2025-01-27T10:00:00.000000+00:00",
1527                TransactionStatus::Sent,
1528            ),
1529            (
1530                "tx-pending-new",
1531                "2025-01-27T12:00:00.000000+00:00",
1532                TransactionStatus::Pending,
1533            ),
1534            (
1535                "tx-sent-old",
1536                "2025-01-27T07:00:00.000000+00:00",
1537                TransactionStatus::Sent,
1538            ),
1539        ];
1540
1541        for (id, timestamp, status) in transactions {
1542            let mut tx = create_test_transaction_pending_state(id);
1543            tx.created_at = timestamp.to_string();
1544            tx.status = status;
1545            repo.create(tx).await.unwrap();
1546        }
1547
1548        // Query multiple statuses with oldest_first: true
1549        let query = PaginationQuery {
1550            page: 1,
1551            per_page: 10,
1552        };
1553        let result = repo
1554            .find_by_status_paginated(
1555                "relayer-1",
1556                &[TransactionStatus::Pending, TransactionStatus::Sent],
1557                query,
1558                true,
1559            )
1560            .await
1561            .unwrap();
1562
1563        assert_eq!(result.total, 4);
1564        assert_eq!(result.items.len(), 4);
1565        // Should be sorted by created_at ascending (oldest first)
1566        assert_eq!(result.items[0].id, "tx-sent-old", "Oldest should be first");
1567        assert_eq!(result.items[1].id, "tx-pending-old");
1568        assert_eq!(result.items[2].id, "tx-sent-mid");
1569        assert_eq!(
1570            result.items[3].id, "tx-pending-new",
1571            "Newest should be last"
1572        );
1573    }
1574
1575    #[tokio::test]
1576    async fn test_has_entries() {
1577        let repo = InMemoryTransactionRepository::new();
1578        assert!(!repo.has_entries().await.unwrap());
1579
1580        let tx = create_test_transaction("test");
1581        repo.create(tx.clone()).await.unwrap();
1582
1583        assert!(repo.has_entries().await.unwrap());
1584    }
1585
1586    #[tokio::test]
1587    async fn test_drop_all_entries() {
1588        let repo = InMemoryTransactionRepository::new();
1589        let tx = create_test_transaction("test");
1590        repo.create(tx.clone()).await.unwrap();
1591
1592        assert!(repo.has_entries().await.unwrap());
1593
1594        repo.drop_all_entries().await.unwrap();
1595        assert!(!repo.has_entries().await.unwrap());
1596    }
1597
1598    // Tests for delete_at field setting on final status updates
1599
1600    #[tokio::test]
1601    async fn test_update_status_sets_delete_at_for_final_statuses() {
1602        let _lock = ENV_MUTEX.lock().await;
1603
1604        use chrono::{DateTime, Duration, Utc};
1605        use std::env;
1606
1607        // Use a unique test environment variable to avoid conflicts
1608        env::set_var("TRANSACTION_EXPIRATION_HOURS", "6");
1609
1610        let repo = InMemoryTransactionRepository::new();
1611
1612        let final_statuses = [
1613            TransactionStatus::Canceled,
1614            TransactionStatus::Confirmed,
1615            TransactionStatus::Failed,
1616            TransactionStatus::Expired,
1617        ];
1618
1619        for (i, status) in final_statuses.iter().enumerate() {
1620            let tx_id = format!("test-final-{i}");
1621            let tx = create_test_transaction_pending_state(&tx_id);
1622
1623            // Ensure transaction has no delete_at initially
1624            assert!(tx.delete_at.is_none());
1625
1626            repo.create(tx).await.unwrap();
1627
1628            let before_update = Utc::now();
1629
1630            // Update to final status
1631            let updated = repo
1632                .update_status(tx_id.clone(), status.clone())
1633                .await
1634                .unwrap();
1635
1636            // Should have delete_at set
1637            assert!(
1638                updated.delete_at.is_some(),
1639                "delete_at should be set for status: {status:?}"
1640            );
1641
1642            // Verify the timestamp is reasonable (approximately 6 hours from now)
1643            let delete_at_str = updated.delete_at.unwrap();
1644            let delete_at = DateTime::parse_from_rfc3339(&delete_at_str)
1645                .expect("delete_at should be valid RFC3339")
1646                .with_timezone(&Utc);
1647
1648            let duration_from_before = delete_at.signed_duration_since(before_update);
1649            let expected_duration = Duration::hours(6);
1650            let tolerance = Duration::minutes(5);
1651
1652            assert!(
1653                duration_from_before >= expected_duration - tolerance &&
1654                duration_from_before <= expected_duration + tolerance,
1655                "delete_at should be approximately 6 hours from now for status: {status:?}. Duration: {duration_from_before:?}"
1656            );
1657        }
1658
1659        // Cleanup
1660        env::remove_var("TRANSACTION_EXPIRATION_HOURS");
1661    }
1662
1663    #[tokio::test]
1664    async fn test_update_status_does_not_set_delete_at_for_non_final_statuses() {
1665        let _lock = ENV_MUTEX.lock().await;
1666
1667        use std::env;
1668
1669        env::set_var("TRANSACTION_EXPIRATION_HOURS", "4");
1670
1671        let repo = InMemoryTransactionRepository::new();
1672
1673        let non_final_statuses = [
1674            TransactionStatus::Pending,
1675            TransactionStatus::Sent,
1676            TransactionStatus::Submitted,
1677            TransactionStatus::Mined,
1678        ];
1679
1680        for (i, status) in non_final_statuses.iter().enumerate() {
1681            let tx_id = format!("test-non-final-{i}");
1682            let tx = create_test_transaction_pending_state(&tx_id);
1683
1684            repo.create(tx).await.unwrap();
1685
1686            // Update to non-final status
1687            let updated = repo
1688                .update_status(tx_id.clone(), status.clone())
1689                .await
1690                .unwrap();
1691
1692            // Should NOT have delete_at set
1693            assert!(
1694                updated.delete_at.is_none(),
1695                "delete_at should NOT be set for status: {status:?}"
1696            );
1697        }
1698
1699        // Cleanup
1700        env::remove_var("TRANSACTION_EXPIRATION_HOURS");
1701    }
1702
1703    #[tokio::test]
1704    async fn test_partial_update_sets_delete_at_for_final_statuses() {
1705        let _lock = ENV_MUTEX.lock().await;
1706
1707        use chrono::{DateTime, Duration, Utc};
1708        use std::env;
1709
1710        env::set_var("TRANSACTION_EXPIRATION_HOURS", "8");
1711
1712        let repo = InMemoryTransactionRepository::new();
1713        let tx = create_test_transaction_pending_state("test-partial-final");
1714
1715        repo.create(tx).await.unwrap();
1716
1717        let before_update = Utc::now();
1718
1719        // Use partial_update to set status to Confirmed (final status)
1720        let update = TransactionUpdateRequest {
1721            status: Some(TransactionStatus::Confirmed),
1722            status_reason: Some("Transaction completed".to_string()),
1723            confirmed_at: Some("2023-01-01T12:05:00Z".to_string()),
1724            ..Default::default()
1725        };
1726
1727        let updated = repo
1728            .partial_update("test-partial-final".to_string(), update)
1729            .await
1730            .unwrap();
1731
1732        // Should have delete_at set
1733        assert!(
1734            updated.delete_at.is_some(),
1735            "delete_at should be set when updating to Confirmed status"
1736        );
1737
1738        // Verify the timestamp is reasonable (approximately 8 hours from now)
1739        let delete_at_str = updated.delete_at.unwrap();
1740        let delete_at = DateTime::parse_from_rfc3339(&delete_at_str)
1741            .expect("delete_at should be valid RFC3339")
1742            .with_timezone(&Utc);
1743
1744        let duration_from_before = delete_at.signed_duration_since(before_update);
1745        let expected_duration = Duration::hours(8);
1746        let tolerance = Duration::minutes(5);
1747
1748        assert!(
1749            duration_from_before >= expected_duration - tolerance
1750                && duration_from_before <= expected_duration + tolerance,
1751            "delete_at should be approximately 8 hours from now. Duration: {duration_from_before:?}"
1752        );
1753
1754        // Also verify other fields were updated
1755        assert_eq!(updated.status, TransactionStatus::Confirmed);
1756        assert_eq!(
1757            updated.status_reason,
1758            Some("Transaction completed".to_string())
1759        );
1760        assert_eq!(
1761            updated.confirmed_at,
1762            Some("2023-01-01T12:05:00Z".to_string())
1763        );
1764
1765        // Cleanup
1766        env::remove_var("TRANSACTION_EXPIRATION_HOURS");
1767    }
1768
1769    #[tokio::test]
1770    async fn test_update_status_preserves_existing_delete_at() {
1771        let _lock = ENV_MUTEX.lock().await;
1772
1773        use std::env;
1774
1775        env::set_var("TRANSACTION_EXPIRATION_HOURS", "2");
1776
1777        let repo = InMemoryTransactionRepository::new();
1778        let mut tx = create_test_transaction_pending_state("test-preserve-delete-at");
1779
1780        // Set an existing delete_at value
1781        let existing_delete_at = "2025-01-01T12:00:00Z".to_string();
1782        tx.delete_at = Some(existing_delete_at.clone());
1783
1784        repo.create(tx).await.unwrap();
1785
1786        // Update to final status
1787        let updated = repo
1788            .update_status(
1789                "test-preserve-delete-at".to_string(),
1790                TransactionStatus::Confirmed,
1791            )
1792            .await
1793            .unwrap();
1794
1795        // Should preserve the existing delete_at value
1796        assert_eq!(
1797            updated.delete_at,
1798            Some(existing_delete_at),
1799            "Existing delete_at should be preserved when updating to final status"
1800        );
1801
1802        // Cleanup
1803        env::remove_var("TRANSACTION_EXPIRATION_HOURS");
1804    }
1805
1806    #[tokio::test]
1807    async fn test_partial_update_without_status_change_preserves_delete_at() {
1808        let _lock = ENV_MUTEX.lock().await;
1809
1810        use std::env;
1811
1812        env::set_var("TRANSACTION_EXPIRATION_HOURS", "3");
1813
1814        let repo = InMemoryTransactionRepository::new();
1815        let tx = create_test_transaction_pending_state("test-preserve-no-status");
1816
1817        repo.create(tx).await.unwrap();
1818
1819        // First, update to final status to set delete_at
1820        let updated1 = repo
1821            .update_status(
1822                "test-preserve-no-status".to_string(),
1823                TransactionStatus::Confirmed,
1824            )
1825            .await
1826            .unwrap();
1827
1828        assert!(updated1.delete_at.is_some());
1829        let original_delete_at = updated1.delete_at.clone();
1830
1831        // Now update other fields without changing status
1832        let update = TransactionUpdateRequest {
1833            status: None, // No status change
1834            status_reason: Some("Updated reason".to_string()),
1835            confirmed_at: Some("2023-01-01T12:10:00Z".to_string()),
1836            ..Default::default()
1837        };
1838
1839        let updated2 = repo
1840            .partial_update("test-preserve-no-status".to_string(), update)
1841            .await
1842            .unwrap();
1843
1844        // delete_at should be preserved
1845        assert_eq!(
1846            updated2.delete_at, original_delete_at,
1847            "delete_at should be preserved when status is not updated"
1848        );
1849
1850        // Other fields should be updated
1851        assert_eq!(updated2.status, TransactionStatus::Confirmed); // Unchanged
1852        assert_eq!(updated2.status_reason, Some("Updated reason".to_string()));
1853        assert_eq!(
1854            updated2.confirmed_at,
1855            Some("2023-01-01T12:10:00Z".to_string())
1856        );
1857
1858        // Cleanup
1859        env::remove_var("TRANSACTION_EXPIRATION_HOURS");
1860    }
1861
1862    #[tokio::test]
1863    async fn test_update_status_multiple_updates_idempotent() {
1864        let _lock = ENV_MUTEX.lock().await;
1865
1866        use std::env;
1867
1868        env::set_var("TRANSACTION_EXPIRATION_HOURS", "12");
1869
1870        let repo = InMemoryTransactionRepository::new();
1871        let tx = create_test_transaction_pending_state("test-idempotent");
1872
1873        repo.create(tx).await.unwrap();
1874
1875        // First update to final status
1876        let updated1 = repo
1877            .update_status("test-idempotent".to_string(), TransactionStatus::Confirmed)
1878            .await
1879            .unwrap();
1880
1881        assert!(updated1.delete_at.is_some());
1882        let first_delete_at = updated1.delete_at.clone();
1883
1884        // Second update to another final status
1885        let updated2 = repo
1886            .update_status("test-idempotent".to_string(), TransactionStatus::Failed)
1887            .await
1888            .unwrap();
1889
1890        // delete_at should remain the same (idempotent)
1891        assert_eq!(
1892            updated2.delete_at, first_delete_at,
1893            "delete_at should not change on subsequent final status updates"
1894        );
1895
1896        // Status should be updated
1897        assert_eq!(updated2.status, TransactionStatus::Failed);
1898
1899        // Cleanup
1900        env::remove_var("TRANSACTION_EXPIRATION_HOURS");
1901    }
1902
1903    // Tests for delete_by_ids batch delete functionality
1904
1905    #[tokio::test]
1906    async fn test_delete_by_ids_empty_list() {
1907        let repo = InMemoryTransactionRepository::new();
1908
1909        // Create a transaction to ensure repo is not empty
1910        let tx = create_test_transaction("test-1");
1911        repo.create(tx).await.unwrap();
1912
1913        // Delete with empty list should succeed and not affect existing data
1914        let result = repo.delete_by_ids(vec![]).await.unwrap();
1915
1916        assert_eq!(result.deleted_count, 0);
1917        assert!(result.failed.is_empty());
1918
1919        // Original transaction should still exist
1920        assert!(repo.get_by_id("test-1".to_string()).await.is_ok());
1921    }
1922
1923    #[tokio::test]
1924    async fn test_delete_by_ids_single_transaction() {
1925        let repo = InMemoryTransactionRepository::new();
1926
1927        let tx = create_test_transaction("test-1");
1928        repo.create(tx).await.unwrap();
1929
1930        let result = repo
1931            .delete_by_ids(vec!["test-1".to_string()])
1932            .await
1933            .unwrap();
1934
1935        assert_eq!(result.deleted_count, 1);
1936        assert!(result.failed.is_empty());
1937
1938        // Verify transaction was deleted
1939        assert!(repo.get_by_id("test-1".to_string()).await.is_err());
1940    }
1941
1942    #[tokio::test]
1943    async fn test_delete_by_ids_multiple_transactions() {
1944        let repo = InMemoryTransactionRepository::new();
1945
1946        // Create multiple transactions
1947        for i in 1..=5 {
1948            let tx = create_test_transaction(&format!("test-{i}"));
1949            repo.create(tx).await.unwrap();
1950        }
1951
1952        assert_eq!(repo.count().await.unwrap(), 5);
1953
1954        // Delete 3 of them
1955        let ids_to_delete = vec![
1956            "test-1".to_string(),
1957            "test-3".to_string(),
1958            "test-5".to_string(),
1959        ];
1960        let result = repo.delete_by_ids(ids_to_delete).await.unwrap();
1961
1962        assert_eq!(result.deleted_count, 3);
1963        assert!(result.failed.is_empty());
1964
1965        // Verify correct transactions were deleted
1966        assert!(repo.get_by_id("test-1".to_string()).await.is_err());
1967        assert!(repo.get_by_id("test-2".to_string()).await.is_ok()); // Not deleted
1968        assert!(repo.get_by_id("test-3".to_string()).await.is_err());
1969        assert!(repo.get_by_id("test-4".to_string()).await.is_ok()); // Not deleted
1970        assert!(repo.get_by_id("test-5".to_string()).await.is_err());
1971
1972        assert_eq!(repo.count().await.unwrap(), 2);
1973    }
1974
1975    #[tokio::test]
1976    async fn test_delete_by_ids_nonexistent_transactions() {
1977        let repo = InMemoryTransactionRepository::new();
1978
1979        // Try to delete transactions that don't exist
1980        let ids_to_delete = vec!["nonexistent-1".to_string(), "nonexistent-2".to_string()];
1981        let result = repo.delete_by_ids(ids_to_delete).await.unwrap();
1982
1983        assert_eq!(result.deleted_count, 0);
1984        assert_eq!(result.failed.len(), 2);
1985
1986        // Verify error messages contain the IDs
1987        assert!(result.failed.iter().any(|(id, _)| id == "nonexistent-1"));
1988        assert!(result.failed.iter().any(|(id, _)| id == "nonexistent-2"));
1989    }
1990
1991    #[tokio::test]
1992    async fn test_delete_by_ids_mixed_existing_and_nonexistent() {
1993        let repo = InMemoryTransactionRepository::new();
1994
1995        // Create some transactions
1996        for i in 1..=3 {
1997            let tx = create_test_transaction(&format!("test-{i}"));
1998            repo.create(tx).await.unwrap();
1999        }
2000
2001        // Try to delete mix of existing and non-existing
2002        let ids_to_delete = vec![
2003            "test-1".to_string(),        // exists
2004            "nonexistent-1".to_string(), // doesn't exist
2005            "test-2".to_string(),        // exists
2006            "nonexistent-2".to_string(), // doesn't exist
2007        ];
2008        let result = repo.delete_by_ids(ids_to_delete).await.unwrap();
2009
2010        assert_eq!(result.deleted_count, 2);
2011        assert_eq!(result.failed.len(), 2);
2012
2013        // Verify existing transactions were deleted
2014        assert!(repo.get_by_id("test-1".to_string()).await.is_err());
2015        assert!(repo.get_by_id("test-2".to_string()).await.is_err());
2016
2017        // Verify remaining transaction still exists
2018        assert!(repo.get_by_id("test-3".to_string()).await.is_ok());
2019
2020        // Verify failed IDs are reported
2021        let failed_ids: Vec<&String> = result.failed.iter().map(|(id, _)| id).collect();
2022        assert!(failed_ids.contains(&&"nonexistent-1".to_string()));
2023        assert!(failed_ids.contains(&&"nonexistent-2".to_string()));
2024    }
2025
2026    #[tokio::test]
2027    async fn test_delete_by_ids_all_transactions() {
2028        let repo = InMemoryTransactionRepository::new();
2029
2030        // Create transactions
2031        for i in 1..=10 {
2032            let tx = create_test_transaction(&format!("test-{i}"));
2033            repo.create(tx).await.unwrap();
2034        }
2035
2036        assert_eq!(repo.count().await.unwrap(), 10);
2037
2038        // Delete all
2039        let ids_to_delete: Vec<String> = (1..=10).map(|i| format!("test-{i}")).collect();
2040        let result = repo.delete_by_ids(ids_to_delete).await.unwrap();
2041
2042        assert_eq!(result.deleted_count, 10);
2043        assert!(result.failed.is_empty());
2044        assert_eq!(repo.count().await.unwrap(), 0);
2045        assert!(!repo.has_entries().await.unwrap());
2046    }
2047
2048    #[tokio::test]
2049    async fn test_delete_by_ids_duplicate_ids() {
2050        let repo = InMemoryTransactionRepository::new();
2051
2052        let tx = create_test_transaction("test-1");
2053        repo.create(tx).await.unwrap();
2054
2055        // Try to delete same ID multiple times in one call
2056        let ids_to_delete = vec![
2057            "test-1".to_string(),
2058            "test-1".to_string(), // duplicate
2059            "test-1".to_string(), // duplicate
2060        ];
2061        let result = repo.delete_by_ids(ids_to_delete).await.unwrap();
2062
2063        // First delete succeeds, subsequent ones fail (already deleted)
2064        assert_eq!(result.deleted_count, 1);
2065        assert_eq!(result.failed.len(), 2);
2066
2067        // Verify transaction was deleted
2068        assert!(repo.get_by_id("test-1".to_string()).await.is_err());
2069    }
2070
2071    #[tokio::test]
2072    async fn test_delete_by_ids_preserves_other_relayer_transactions() {
2073        let repo = InMemoryTransactionRepository::new();
2074
2075        // Create transactions for different relayers
2076        let mut tx1 = create_test_transaction("tx-relayer-1");
2077        tx1.relayer_id = "relayer-1".to_string();
2078
2079        let mut tx2 = create_test_transaction("tx-relayer-2");
2080        tx2.relayer_id = "relayer-2".to_string();
2081
2082        repo.create(tx1).await.unwrap();
2083        repo.create(tx2).await.unwrap();
2084
2085        // Delete only relayer-1's transaction
2086        let result = repo
2087            .delete_by_ids(vec!["tx-relayer-1".to_string()])
2088            .await
2089            .unwrap();
2090
2091        assert_eq!(result.deleted_count, 1);
2092
2093        // relayer-2's transaction should still exist
2094        let remaining = repo.get_by_id("tx-relayer-2".to_string()).await.unwrap();
2095        assert_eq!(remaining.relayer_id, "relayer-2");
2096    }
2097
2098    // ── increment_status_check_failures ─────────────────────────────
2099
2100    #[tokio::test]
2101    async fn test_increment_status_check_failures_no_prior_metadata() {
2102        let repo = InMemoryTransactionRepository::new();
2103        let tx = create_test_transaction_pending_state("tx-inc-1");
2104        repo.create(tx).await.unwrap();
2105
2106        let updated = repo
2107            .increment_status_check_failures("tx-inc-1".to_string())
2108            .await
2109            .unwrap();
2110
2111        let meta = updated.metadata.expect("metadata should be set");
2112        assert_eq!(meta.consecutive_failures, 1);
2113        assert_eq!(meta.total_failures, 1);
2114        assert_eq!(meta.insufficient_fee_retries, 0);
2115    }
2116
2117    #[tokio::test]
2118    async fn test_increment_status_check_failures_accumulates() {
2119        let repo = InMemoryTransactionRepository::new();
2120        let tx = create_test_transaction_pending_state("tx-inc-2");
2121        repo.create(tx).await.unwrap();
2122
2123        repo.increment_status_check_failures("tx-inc-2".to_string())
2124            .await
2125            .unwrap();
2126        repo.increment_status_check_failures("tx-inc-2".to_string())
2127            .await
2128            .unwrap();
2129        let updated = repo
2130            .increment_status_check_failures("tx-inc-2".to_string())
2131            .await
2132            .unwrap();
2133
2134        let meta = updated.metadata.unwrap();
2135        assert_eq!(meta.consecutive_failures, 3);
2136        assert_eq!(meta.total_failures, 3);
2137    }
2138
2139    #[tokio::test]
2140    async fn test_increment_status_check_failures_noop_on_final_state() {
2141        let repo = InMemoryTransactionRepository::new();
2142        let mut tx = create_test_transaction_pending_state("tx-inc-final");
2143        tx.status = TransactionStatus::Confirmed;
2144        repo.create(tx).await.unwrap();
2145
2146        let result = repo
2147            .increment_status_check_failures("tx-inc-final".to_string())
2148            .await
2149            .unwrap();
2150
2151        // Should return unchanged — no metadata set
2152        assert!(result.metadata.is_none());
2153        assert_eq!(result.status, TransactionStatus::Confirmed);
2154    }
2155
2156    #[tokio::test]
2157    async fn test_increment_status_check_failures_not_found() {
2158        let repo = InMemoryTransactionRepository::new();
2159        let result = repo
2160            .increment_status_check_failures("nonexistent".to_string())
2161            .await;
2162
2163        assert!(matches!(result, Err(RepositoryError::NotFound(_))));
2164    }
2165
2166    // ── reset_status_check_consecutive_failures ─────────────────────
2167
2168    #[tokio::test]
2169    async fn test_reset_consecutive_failures() {
2170        let repo = InMemoryTransactionRepository::new();
2171        let tx = create_test_transaction_pending_state("tx-reset-1");
2172        repo.create(tx).await.unwrap();
2173
2174        // Increment a few times first
2175        repo.increment_status_check_failures("tx-reset-1".to_string())
2176            .await
2177            .unwrap();
2178        repo.increment_status_check_failures("tx-reset-1".to_string())
2179            .await
2180            .unwrap();
2181
2182        let updated = repo
2183            .reset_status_check_consecutive_failures("tx-reset-1".to_string())
2184            .await
2185            .unwrap();
2186
2187        let meta = updated.metadata.unwrap();
2188        assert_eq!(meta.consecutive_failures, 0);
2189        // total_failures should be preserved
2190        assert_eq!(meta.total_failures, 2);
2191    }
2192
2193    #[tokio::test]
2194    async fn test_reset_consecutive_failures_noop_on_final_state() {
2195        let repo = InMemoryTransactionRepository::new();
2196        let mut tx = create_test_transaction_pending_state("tx-reset-final");
2197        tx.status = TransactionStatus::Failed;
2198        tx.metadata = Some(crate::models::TransactionMetadata {
2199            consecutive_failures: 5,
2200            total_failures: 10,
2201            insufficient_fee_retries: 0,
2202            try_again_later_retries: 0,
2203            nonce_too_high_retries: 0,
2204        });
2205        repo.create(tx).await.unwrap();
2206
2207        let result = repo
2208            .reset_status_check_consecutive_failures("tx-reset-final".to_string())
2209            .await
2210            .unwrap();
2211
2212        // Should return unchanged
2213        let meta = result.metadata.unwrap();
2214        assert_eq!(meta.consecutive_failures, 5);
2215    }
2216
2217    #[tokio::test]
2218    async fn test_reset_consecutive_failures_not_found() {
2219        let repo = InMemoryTransactionRepository::new();
2220        let result = repo
2221            .reset_status_check_consecutive_failures("nonexistent".to_string())
2222            .await;
2223
2224        assert!(matches!(result, Err(RepositoryError::NotFound(_))));
2225    }
2226
2227    // ── record_stellar_insufficient_fee_retry ───────────────────────
2228
2229    #[tokio::test]
2230    async fn test_record_insufficient_fee_retry() {
2231        let repo = InMemoryTransactionRepository::new();
2232        let mut tx = create_test_transaction_pending_state("tx-fee-1");
2233        tx.status = TransactionStatus::Sent;
2234        tx.sent_at = None;
2235        repo.create(tx).await.unwrap();
2236
2237        let updated = repo
2238            .record_stellar_insufficient_fee_retry(
2239                "tx-fee-1".to_string(),
2240                "2025-03-18T10:00:00Z".to_string(),
2241            )
2242            .await
2243            .unwrap();
2244
2245        assert_eq!(updated.sent_at.as_deref(), Some("2025-03-18T10:00:00Z"));
2246        let meta = updated.metadata.unwrap();
2247        assert_eq!(meta.insufficient_fee_retries, 1);
2248        assert_eq!(meta.consecutive_failures, 0);
2249        assert_eq!(meta.total_failures, 0);
2250    }
2251
2252    #[tokio::test]
2253    async fn test_record_insufficient_fee_retry_accumulates() {
2254        let repo = InMemoryTransactionRepository::new();
2255        let mut tx = create_test_transaction_pending_state("tx-fee-2");
2256        tx.status = TransactionStatus::Sent;
2257        repo.create(tx).await.unwrap();
2258
2259        repo.record_stellar_insufficient_fee_retry(
2260            "tx-fee-2".to_string(),
2261            "2025-03-18T10:00:00Z".to_string(),
2262        )
2263        .await
2264        .unwrap();
2265
2266        let updated = repo
2267            .record_stellar_insufficient_fee_retry(
2268                "tx-fee-2".to_string(),
2269                "2025-03-18T10:01:00Z".to_string(),
2270            )
2271            .await
2272            .unwrap();
2273
2274        assert_eq!(updated.sent_at.as_deref(), Some("2025-03-18T10:01:00Z"));
2275        let meta = updated.metadata.unwrap();
2276        assert_eq!(meta.insufficient_fee_retries, 2);
2277    }
2278
2279    #[tokio::test]
2280    async fn test_record_insufficient_fee_retry_noop_on_final_state() {
2281        let repo = InMemoryTransactionRepository::new();
2282        let mut tx = create_test_transaction_pending_state("tx-fee-final");
2283        tx.status = TransactionStatus::Confirmed;
2284        tx.sent_at = Some("old-time".to_string());
2285        repo.create(tx).await.unwrap();
2286
2287        let result = repo
2288            .record_stellar_insufficient_fee_retry(
2289                "tx-fee-final".to_string(),
2290                "new-time".to_string(),
2291            )
2292            .await
2293            .unwrap();
2294
2295        // Should return unchanged
2296        assert_eq!(result.sent_at.as_deref(), Some("old-time"));
2297        assert!(result.metadata.is_none());
2298    }
2299
2300    #[tokio::test]
2301    async fn test_record_insufficient_fee_retry_not_found() {
2302        let repo = InMemoryTransactionRepository::new();
2303        let result = repo
2304            .record_stellar_insufficient_fee_retry(
2305                "nonexistent".to_string(),
2306                "2025-03-18T10:00:00Z".to_string(),
2307            )
2308            .await;
2309
2310        assert!(matches!(result, Err(RepositoryError::NotFound(_))));
2311    }
2312
2313    // ── record_stellar_try_again_later_retry ───────────────────────
2314
2315    #[tokio::test]
2316    async fn test_record_try_again_later_retry() {
2317        let repo = InMemoryTransactionRepository::new();
2318        let mut tx = create_test_transaction_pending_state("tx-tal-1");
2319        tx.status = TransactionStatus::Sent;
2320        tx.sent_at = None;
2321        repo.create(tx).await.unwrap();
2322
2323        let updated = repo
2324            .record_stellar_try_again_later_retry(
2325                "tx-tal-1".to_string(),
2326                "2025-03-18T10:00:00Z".to_string(),
2327            )
2328            .await
2329            .unwrap();
2330
2331        assert_eq!(updated.sent_at.as_deref(), Some("2025-03-18T10:00:00Z"));
2332        let meta = updated.metadata.unwrap();
2333        assert_eq!(meta.try_again_later_retries, 1);
2334        assert_eq!(meta.consecutive_failures, 0);
2335        assert_eq!(meta.total_failures, 0);
2336    }
2337
2338    #[tokio::test]
2339    async fn test_record_try_again_later_retry_accumulates() {
2340        let repo = InMemoryTransactionRepository::new();
2341        let mut tx = create_test_transaction_pending_state("tx-tal-2");
2342        tx.status = TransactionStatus::Sent;
2343        repo.create(tx).await.unwrap();
2344
2345        repo.record_stellar_try_again_later_retry(
2346            "tx-tal-2".to_string(),
2347            "2025-03-18T10:00:00Z".to_string(),
2348        )
2349        .await
2350        .unwrap();
2351
2352        let updated = repo
2353            .record_stellar_try_again_later_retry(
2354                "tx-tal-2".to_string(),
2355                "2025-03-18T10:01:00Z".to_string(),
2356            )
2357            .await
2358            .unwrap();
2359
2360        assert_eq!(updated.sent_at.as_deref(), Some("2025-03-18T10:01:00Z"));
2361        let meta = updated.metadata.unwrap();
2362        assert_eq!(meta.try_again_later_retries, 2);
2363    }
2364
2365    #[tokio::test]
2366    async fn test_record_try_again_later_retry_noop_on_final_state() {
2367        let repo = InMemoryTransactionRepository::new();
2368        let mut tx = create_test_transaction_pending_state("tx-tal-final");
2369        tx.status = TransactionStatus::Confirmed;
2370        tx.sent_at = Some("old-time".to_string());
2371        repo.create(tx).await.unwrap();
2372
2373        let result = repo
2374            .record_stellar_try_again_later_retry(
2375                "tx-tal-final".to_string(),
2376                "new-time".to_string(),
2377            )
2378            .await
2379            .unwrap();
2380
2381        // Should return unchanged
2382        assert_eq!(result.sent_at.as_deref(), Some("old-time"));
2383        assert!(result.metadata.is_none());
2384    }
2385
2386    #[tokio::test]
2387    async fn test_record_try_again_later_retry_not_found() {
2388        let repo = InMemoryTransactionRepository::new();
2389        let result = repo
2390            .record_stellar_try_again_later_retry(
2391                "nonexistent".to_string(),
2392                "2025-03-18T10:00:00Z".to_string(),
2393            )
2394            .await;
2395
2396        assert!(matches!(result, Err(RepositoryError::NotFound(_))));
2397    }
2398}