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