1use 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 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 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 }
62 (&tx.created_at, false)
63 }
64
65 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) .then_with(|| b.id.cmp(&a.id)) }
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#[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 let items = filtered
216 .into_iter()
217 .sorted_by(|a, b| b.created_at.cmp(&a.created_at)) .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 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 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 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) .then_with(|| a.id.cmp(&b.id)) })
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) .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 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 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 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 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 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 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 let updated = repo
804 .update_status("test-1".to_string(), TransactionStatus::Confirmed)
805 .await
806 .unwrap();
807
808 assert_eq!(updated.status, TransactionStatus::Confirmed);
810
811 let stored = repo.get_by_id("test-1".to_string()).await.unwrap();
813 assert_eq!(stored.status, TransactionStatus::Confirmed);
814
815 let updated = repo
817 .update_status("test-1".to_string(), TransactionStatus::Failed)
818 .await
819 .unwrap();
820
821 assert_eq!(updated.status, TransactionStatus::Failed);
823
824 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 for i in 1..=10 {
837 let tx = create_test_transaction(&format!("test-{i}"));
838 repo.create(tx).await.unwrap();
839 }
840
841 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(updated.sent_at, Some(new_sent_at.clone()));
985
986 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 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 assert_eq!(updated.confirmed_at, Some(new_confirmed_at.clone()));
1008
1009 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 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 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 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 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 let mut tx1 = create_test_transaction("test-1");
1065 tx1.created_at = "2025-01-27T10:00:00.000000+00:00".to_string(); let mut tx2 = create_test_transaction("test-2");
1068 tx2.created_at = "2025-01-27T12:00:00.000000+00:00".to_string(); let mut tx3 = create_test_transaction("test-3");
1071 tx3.created_at = "2025-01-27T14:00:00.000000+00:00".to_string(); repo.create(tx2.clone()).await.unwrap(); repo.create(tx1.clone()).await.unwrap(); repo.create(tx3.clone()).await.unwrap(); 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 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 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 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 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 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 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 let tx3 = create_tx_with_timestamp("tx3", "2025-01-27T17:00:00.000000+00:00"); let tx1 = create_tx_with_timestamp("tx1", "2025-01-27T15:00:00.000000+00:00"); let tx2 = create_tx_with_timestamp("tx2", "2025-01-27T16:00:00.000000+00:00"); repo.create(tx3.clone()).await.unwrap();
1190 repo.create(tx1.clone()).await.unwrap();
1191 repo.create(tx2.clone()).await.unwrap();
1192
1193 let result = repo
1195 .find_by_status("relayer-1", &[TransactionStatus::Pending])
1196 .await
1197 .unwrap();
1198
1199 assert_eq!(result.len(), 3);
1201 assert_eq!(result[0].id, "tx3"); assert_eq!(result[1].id, "tx2"); assert_eq!(result[2].id, "tx1"); 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 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 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 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 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 assert_eq!(result.items[0].id, "tx5");
1260 assert_eq!(result.items[1].id, "tx4");
1261
1262 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 assert_eq!(result.items[0].id, "tx3");
1277 assert_eq!(result.items[1].id, "tx2");
1278
1279 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 assert!(tx.delete_at.is_none());
1564
1565 repo.create(tx).await.unwrap();
1566
1567 let before_update = Utc::now();
1568
1569 let updated = repo
1571 .update_status(tx_id.clone(), status.clone())
1572 .await
1573 .unwrap();
1574
1575 assert!(
1577 updated.delete_at.is_some(),
1578 "delete_at should be set for status: {status:?}"
1579 );
1580
1581 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 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 let updated = repo
1627 .update_status(tx_id.clone(), status.clone())
1628 .await
1629 .unwrap();
1630
1631 assert!(
1633 updated.delete_at.is_none(),
1634 "delete_at should NOT be set for status: {status:?}"
1635 );
1636 }
1637
1638 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 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 assert!(
1673 updated.delete_at.is_some(),
1674 "delete_at should be set when updating to Confirmed status"
1675 );
1676
1677 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 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 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 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 let updated = repo
1727 .update_status(
1728 "test-preserve-delete-at".to_string(),
1729 TransactionStatus::Confirmed,
1730 )
1731 .await
1732 .unwrap();
1733
1734 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 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 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 let update = TransactionUpdateRequest {
1772 status: None, 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 assert_eq!(
1785 updated2.delete_at, original_delete_at,
1786 "delete_at should be preserved when status is not updated"
1787 );
1788
1789 assert_eq!(updated2.status, TransactionStatus::Confirmed); 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 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 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 let updated2 = repo
1825 .update_status("test-idempotent".to_string(), TransactionStatus::Failed)
1826 .await
1827 .unwrap();
1828
1829 assert_eq!(
1831 updated2.delete_at, first_delete_at,
1832 "delete_at should not change on subsequent final status updates"
1833 );
1834
1835 assert_eq!(updated2.status, TransactionStatus::Failed);
1837
1838 env::remove_var("TRANSACTION_EXPIRATION_HOURS");
1840 }
1841
1842 #[tokio::test]
1845 async fn test_delete_by_ids_empty_list() {
1846 let repo = InMemoryTransactionRepository::new();
1847
1848 let tx = create_test_transaction("test-1");
1850 repo.create(tx).await.unwrap();
1851
1852 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 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 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 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 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 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()); 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()); 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 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 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 for i in 1..=3 {
1936 let tx = create_test_transaction(&format!("test-{i}"));
1937 repo.create(tx).await.unwrap();
1938 }
1939
1940 let ids_to_delete = vec![
1942 "test-1".to_string(), "nonexistent-1".to_string(), "test-2".to_string(), "nonexistent-2".to_string(), ];
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 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 assert!(repo.get_by_id("test-3".to_string()).await.is_ok());
1958
1959 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 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 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 let ids_to_delete = vec![
1996 "test-1".to_string(),
1997 "test-1".to_string(), "test-1".to_string(), ];
2000 let result = repo.delete_by_ids(ids_to_delete).await.unwrap();
2001
2002 assert_eq!(result.deleted_count, 1);
2004 assert_eq!(result.failed.len(), 2);
2005
2006 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 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 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 let remaining = repo.get_by_id("tx-relayer-2".to_string()).await.unwrap();
2034 assert_eq!(remaining.relayer_id, "relayer-2");
2035 }
2036
2037 #[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 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 #[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 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 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 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 #[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 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 #[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 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}