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