diff --git a/AgentPayWatch.slnx b/AgentPayWatch.slnx index be2a722..d5d6501 100644 --- a/AgentPayWatch.slnx +++ b/AgentPayWatch.slnx @@ -12,7 +12,14 @@ - - + + + + + + + + + diff --git a/src/AgentPayWatch.Agents.Payment/PaymentProcessor.cs b/src/AgentPayWatch.Agents.Payment/PaymentProcessor.cs new file mode 100644 index 0000000..8ef16c8 --- /dev/null +++ b/src/AgentPayWatch.Agents.Payment/PaymentProcessor.cs @@ -0,0 +1,182 @@ +using AgentPayWatch.Domain.Entities; +using AgentPayWatch.Domain.Enums; +using AgentPayWatch.Domain.Events; +using AgentPayWatch.Domain.Interfaces; +using AgentPayWatch.Infrastructure.Messaging; +using Microsoft.Extensions.Logging; + +namespace AgentPayWatch.Agents.Payment; + +/// +/// Encapsulates the business logic for processing an approved payment. +/// Extracted from PaymentWorker to allow direct unit testing without Service Bus infrastructure. +/// +public sealed class PaymentProcessor +{ + private readonly IWatchRequestRepository _watchRepo; + private readonly IProductMatchRepository _matchRepo; + private readonly IPaymentTransactionRepository _transactionRepo; + private readonly IPaymentProvider _paymentProvider; + private readonly IEventPublisher _eventPublisher; + private readonly ILogger _logger; + + public PaymentProcessor( + IWatchRequestRepository watchRepo, + IProductMatchRepository matchRepo, + IPaymentTransactionRepository transactionRepo, + IPaymentProvider paymentProvider, + IEventPublisher eventPublisher, + ILogger logger) + { + _watchRepo = watchRepo; + _matchRepo = matchRepo; + _transactionRepo = transactionRepo; + _paymentProvider = paymentProvider; + _eventPublisher = eventPublisher; + _logger = logger; + } + + public async Task ProcessAsync(ApprovalDecided approvalEvent, CancellationToken ct) + { + var watchRequestId = approvalEvent.CorrelationId; + var matchId = approvalEvent.MatchId; + var approvalId = approvalEvent.ApprovalId; + + _logger.LogInformation( + "Processing approved payment for watch {WatchId}, match {MatchId}, approval {ApprovalId}", + watchRequestId, matchId, approvalId); + + var approvedWatches = await _watchRepo.GetByStatusAsync(WatchStatus.Approved); + var watch = approvedWatches.FirstOrDefault(w => w.Id == watchRequestId); + + if (watch is null) + { + _logger.LogWarning( + "Watch request {WatchId} not found in Approved status. It may have already been processed.", + watchRequestId); + return; + } + + var match = await _matchRepo.GetByIdAsync(matchId, watchRequestId); + if (match is null) + { + _logger.LogWarning( + "Product match {MatchId} not found for watch {WatchId}", matchId, watchRequestId); + return; + } + + watch.UpdateStatus(WatchStatus.Purchasing, "Payment initiated"); + await _watchRepo.UpdateAsync(watch); + + _logger.LogInformation( + "Watch {WatchId} transitioned to Purchasing. Executing payment: {Amount} {Currency} to {Seller}", + watch.Id, match.Price, match.Currency, match.Seller); + + var idempotencyKey = $"{matchId}:{approvalId}"; + var paymentMethodToken = string.IsNullOrEmpty(watch.PaymentMethodToken) + ? "tok_demo" + : watch.PaymentMethodToken; + + var paymentResult = await _paymentProvider.ExecutePaymentAsync( + idempotencyKey, match.Price, match.Currency, match.Seller, paymentMethodToken, ct); + + if (paymentResult.Success) + await HandlePaymentSuccessAsync(watch, match, approvalId, idempotencyKey, paymentResult, ct); + else + await HandlePaymentFailureAsync(watch, match, approvalId, idempotencyKey, paymentResult, ct); + } + + private async Task HandlePaymentSuccessAsync( + WatchRequest watch, + ProductMatch match, + Guid approvalId, + string idempotencyKey, + PaymentResult paymentResult, + CancellationToken ct) + { + var transaction = new PaymentTransaction + { + Id = Guid.NewGuid(), + MatchId = match.Id, + ApprovalId = approvalId, + WatchRequestId = watch.Id, + UserId = watch.UserId, + IdempotencyKey = idempotencyKey, + Amount = match.Price, + Currency = match.Currency, + Merchant = match.Seller, + Status = PaymentStatus.Succeeded, + PaymentProviderRef = paymentResult.ProviderReference ?? string.Empty, + InitiatedAt = DateTimeOffset.UtcNow, + CompletedAt = DateTimeOffset.UtcNow, + FailureReason = null + }; + + await _transactionRepo.CreateAsync(transaction); + + watch.UpdateStatus(WatchStatus.Completed, $"Payment succeeded, ref: {paymentResult.ProviderReference}"); + await _watchRepo.UpdateAsync(watch); + + var paymentCompletedEvent = new PaymentCompleted( + MessageId: Guid.NewGuid(), + CorrelationId: watch.Id, + Timestamp: DateTimeOffset.UtcNow, + Source: "payment-agent", + TransactionId: transaction.Id, + Amount: match.Price, + Currency: match.Currency, + Merchant: match.Seller); + + await _eventPublisher.PublishAsync(paymentCompletedEvent, TopicNames.PaymentCompleted, ct); + + _logger.LogInformation( + "Payment succeeded for watch {WatchId}: {Amount} {Currency} to {Merchant}, ref: {ProviderRef}", + watch.Id, match.Price, match.Currency, match.Seller, paymentResult.ProviderReference); + } + + private async Task HandlePaymentFailureAsync( + WatchRequest watch, + ProductMatch match, + Guid approvalId, + string idempotencyKey, + PaymentResult paymentResult, + CancellationToken ct) + { + var transaction = new PaymentTransaction + { + Id = Guid.NewGuid(), + MatchId = match.Id, + ApprovalId = approvalId, + WatchRequestId = watch.Id, + UserId = watch.UserId, + IdempotencyKey = idempotencyKey, + Amount = match.Price, + Currency = match.Currency, + Merchant = match.Seller, + Status = PaymentStatus.Failed, + PaymentProviderRef = string.Empty, + InitiatedAt = DateTimeOffset.UtcNow, + CompletedAt = null, + FailureReason = paymentResult.FailureReason + }; + + await _transactionRepo.CreateAsync(transaction); + + watch.UpdateStatus(WatchStatus.Active, $"Payment failed: {paymentResult.FailureReason}. Returning to active scanning."); + await _watchRepo.UpdateAsync(watch); + + var paymentFailedEvent = new PaymentFailed( + MessageId: Guid.NewGuid(), + CorrelationId: watch.Id, + Timestamp: DateTimeOffset.UtcNow, + Source: "payment-agent", + TransactionId: transaction.Id, + Reason: paymentResult.FailureReason ?? "Unknown failure"); + + await _eventPublisher.PublishAsync(paymentFailedEvent, TopicNames.PaymentFailed, ct); + + _logger.LogInformation( + "Payment failed for watch {WatchId}: {Reason}. Watch returned to Active for re-scanning.", + watch.Id, paymentResult.FailureReason); + } +} diff --git a/src/AgentPayWatch.Agents.Payment/PaymentWorker.cs b/src/AgentPayWatch.Agents.Payment/PaymentWorker.cs new file mode 100644 index 0000000..e5c39c3 --- /dev/null +++ b/src/AgentPayWatch.Agents.Payment/PaymentWorker.cs @@ -0,0 +1,129 @@ +using System.Text.Json; +using AgentPayWatch.Domain.Enums; +using AgentPayWatch.Domain.Events; +using AgentPayWatch.Infrastructure.Messaging; +using Azure.Messaging.ServiceBus; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace AgentPayWatch.Agents.Payment; + +public sealed class PaymentWorker : BackgroundService +{ + private readonly ServiceBusClient _serviceBusClient; + private readonly PaymentProcessor _processor; + private readonly ILogger _logger; + + private ServiceBusProcessor? _sbProcessor; + + public PaymentWorker( + ServiceBusClient serviceBusClient, + PaymentProcessor processor, + ILogger logger) + { + _serviceBusClient = serviceBusClient; + _processor = processor; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _sbProcessor = _serviceBusClient.CreateProcessor( + TopicNames.ApprovalDecided, + "payment-agent", + new ServiceBusProcessorOptions + { + AutoCompleteMessages = false, + MaxConcurrentCalls = 1, + PrefetchCount = 0 + }); + + _sbProcessor.ProcessMessageAsync += ProcessMessageAsync; + _sbProcessor.ProcessErrorAsync += ProcessErrorAsync; + + _logger.LogInformation("PaymentWorker starting. Listening on topic '{Topic}', subscription 'payment-agent'", + TopicNames.ApprovalDecided); + + await _sbProcessor.StartProcessingAsync(stoppingToken); + + try + { + await Task.Delay(Timeout.Infinite, stoppingToken); + } + catch (OperationCanceledException) + { + // Expected on shutdown + } + } + + private async Task ProcessMessageAsync(ProcessMessageEventArgs args) + { + var body = args.Message.Body.ToString(); + _logger.LogInformation("PaymentWorker received message: {MessageId}", args.Message.MessageId); + + ApprovalDecided? approvalEvent; + try + { + approvalEvent = JsonSerializer.Deserialize(body, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Failed to deserialize ApprovalDecided event from message {MessageId}", args.Message.MessageId); + await args.CompleteMessageAsync(args.Message); + return; + } + + if (approvalEvent is null) + { + _logger.LogWarning("Deserialized ApprovalDecided event was null. Message {MessageId}", args.Message.MessageId); + await args.CompleteMessageAsync(args.Message); + return; + } + + if (approvalEvent.Decision != ApprovalDecision.Approved) + { + _logger.LogInformation( + "Skipping non-approved decision '{Decision}' for watch {WatchId}. Message {MessageId}", + approvalEvent.Decision, approvalEvent.CorrelationId, args.Message.MessageId); + await args.CompleteMessageAsync(args.Message); + return; + } + + try + { + await _processor.ProcessAsync(approvalEvent, args.CancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error processing payment for watch {WatchId}, match {MatchId}", + approvalEvent.CorrelationId, approvalEvent.MatchId); + } + + await args.CompleteMessageAsync(args.Message); + } + + private Task ProcessErrorAsync(ProcessErrorEventArgs args) + { + _logger.LogError(args.Exception, + "PaymentWorker Service Bus error. Source: {ErrorSource}, Entity: {EntityPath}", + args.ErrorSource, args.FullyQualifiedNamespace); + return Task.CompletedTask; + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("PaymentWorker stopping"); + + if (_sbProcessor is not null) + { + await _sbProcessor.StopProcessingAsync(cancellationToken); + await _sbProcessor.DisposeAsync(); + } + + await base.StopAsync(cancellationToken); + } +} diff --git a/src/AgentPayWatch.Agents.Payment/Program.cs b/src/AgentPayWatch.Agents.Payment/Program.cs index 379616e..673ded2 100644 --- a/src/AgentPayWatch.Agents.Payment/Program.cs +++ b/src/AgentPayWatch.Agents.Payment/Program.cs @@ -1,6 +1,13 @@ +using AgentPayWatch.Agents.Payment; +using AgentPayWatch.Infrastructure; + var builder = Host.CreateApplicationBuilder(args); builder.AddServiceDefaults(); +builder.AddInfrastructureServices(); + +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); var host = builder.Build(); diff --git a/src/AgentPayWatch.Agents.Payment/appsettings.json b/src/AgentPayWatch.Agents.Payment/appsettings.json new file mode 100644 index 0000000..549399a --- /dev/null +++ b/src/AgentPayWatch.Agents.Payment/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "Azure.Messaging.ServiceBus": "Warning", + "Azure.Core": "Warning" + } + }, + "Payment": { + "MockSuccessRatePercent": 90 + } +} diff --git a/src/AgentPayWatch.Api/Contracts/TransactionResponse.cs b/src/AgentPayWatch.Api/Contracts/TransactionResponse.cs new file mode 100644 index 0000000..2946633 --- /dev/null +++ b/src/AgentPayWatch.Api/Contracts/TransactionResponse.cs @@ -0,0 +1,36 @@ +using AgentPayWatch.Domain.Entities; + +namespace AgentPayWatch.Api.Contracts; + +public sealed record TransactionResponse( + Guid Id, + Guid MatchId, + Guid ApprovalId, + Guid WatchRequestId, + string UserId, + string IdempotencyKey, + decimal Amount, + string Currency, + string Merchant, + string Status, + string PaymentProviderRef, + DateTimeOffset InitiatedAt, + DateTimeOffset? CompletedAt, + string? FailureReason) +{ + public static TransactionResponse FromEntity(PaymentTransaction entity) => new( + Id: entity.Id, + MatchId: entity.MatchId, + ApprovalId: entity.ApprovalId, + WatchRequestId: entity.WatchRequestId, + UserId: entity.UserId, + IdempotencyKey: entity.IdempotencyKey, + Amount: entity.Amount, + Currency: entity.Currency, + Merchant: entity.Merchant, + Status: entity.Status.ToString(), + PaymentProviderRef: entity.PaymentProviderRef, + InitiatedAt: entity.InitiatedAt, + CompletedAt: entity.CompletedAt, + FailureReason: entity.FailureReason); +} diff --git a/src/AgentPayWatch.Api/Endpoints/TransactionEndpoints.cs b/src/AgentPayWatch.Api/Endpoints/TransactionEndpoints.cs new file mode 100644 index 0000000..eb9e3c3 --- /dev/null +++ b/src/AgentPayWatch.Api/Endpoints/TransactionEndpoints.cs @@ -0,0 +1,60 @@ +using AgentPayWatch.Api.Contracts; +using AgentPayWatch.Domain.Interfaces; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace AgentPayWatch.Api.Endpoints; + +public static class TransactionEndpoints +{ + public static IEndpointRouteBuilder MapTransactionEndpoints(this IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/api/transactions"); + + group.MapGet("/", async ( + string userId, + IPaymentTransactionRepository transactionRepo) => + { + if (string.IsNullOrWhiteSpace(userId)) + { + return Results.BadRequest(new { error = "userId query parameter is required." }); + } + + var transactions = await transactionRepo.GetByUserIdAsync(userId); + + var response = transactions + .OrderByDescending(t => t.InitiatedAt) + .Select(TransactionResponse.FromEntity) + .ToList(); + + return Results.Ok(response); + }) + .WithName("GetTransactions") + .WithOpenApi(); + + group.MapGet("/{id:guid}", async ( + Guid id, + string userId, + IPaymentTransactionRepository transactionRepo) => + { + if (string.IsNullOrWhiteSpace(userId)) + { + return Results.BadRequest(new { error = "userId query parameter is required." }); + } + + var transaction = await transactionRepo.GetByIdAsync(id, userId); + + if (transaction is null) + { + return Results.NotFound(new { error = $"Transaction {id} not found." }); + } + + return Results.Ok(TransactionResponse.FromEntity(transaction)); + }) + .WithName("GetTransaction") + .WithOpenApi(); + + return routes; + } +} diff --git a/src/AgentPayWatch.Api/Program.cs b/src/AgentPayWatch.Api/Program.cs index b31a4f2..2ba693d 100644 --- a/src/AgentPayWatch.Api/Program.cs +++ b/src/AgentPayWatch.Api/Program.cs @@ -50,6 +50,7 @@ app.MapWatchEndpoints(); app.MapMatchEndpoints(); app.MapCallbackEndpoints(); +app.MapTransactionEndpoints(); app.Run(); diff --git a/src/AgentPayWatch.Domain/Interfaces/IPaymentProvider.cs b/src/AgentPayWatch.Domain/Interfaces/IPaymentProvider.cs new file mode 100644 index 0000000..4d4232f --- /dev/null +++ b/src/AgentPayWatch.Domain/Interfaces/IPaymentProvider.cs @@ -0,0 +1,17 @@ +namespace AgentPayWatch.Domain.Interfaces; + +public record PaymentResult( + bool Success, + string? ProviderReference, + string? FailureReason); + +public interface IPaymentProvider +{ + Task ExecutePaymentAsync( + string idempotencyKey, + decimal amount, + string currency, + string merchant, + string paymentMethodToken, + CancellationToken ct); +} diff --git a/src/AgentPayWatch.Infrastructure/DependencyInjection.cs b/src/AgentPayWatch.Infrastructure/DependencyInjection.cs index ee2f16f..6925520 100644 --- a/src/AgentPayWatch.Infrastructure/DependencyInjection.cs +++ b/src/AgentPayWatch.Infrastructure/DependencyInjection.cs @@ -30,6 +30,7 @@ public static IHostApplicationBuilder AddInfrastructureServices( // Mocks builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); return builder; } diff --git a/src/AgentPayWatch.Infrastructure/Mocks/MockPaymentProvider.cs b/src/AgentPayWatch.Infrastructure/Mocks/MockPaymentProvider.cs new file mode 100644 index 0000000..02b90e3 --- /dev/null +++ b/src/AgentPayWatch.Infrastructure/Mocks/MockPaymentProvider.cs @@ -0,0 +1,97 @@ +using System.Collections.Concurrent; +using AgentPayWatch.Domain.Interfaces; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace AgentPayWatch.Infrastructure.Mocks; + +public sealed class MockPaymentProvider : IPaymentProvider +{ + private readonly ILogger _logger; + private readonly int _successRatePercent; + private readonly ConcurrentDictionary _processedPayments = new(); + private readonly Random _random = new(); + + private static readonly string[] FailureReasons = + [ + "Insufficient funds", + "Card declined", + "Provider timeout" + ]; + + public MockPaymentProvider( + ILogger logger, + IConfiguration configuration) + { + _logger = logger; + _successRatePercent = configuration.GetValue("Payment:MockSuccessRatePercent", 90); + } + + public async Task ExecutePaymentAsync( + string idempotencyKey, + decimal amount, + string currency, + string merchant, + string paymentMethodToken, + CancellationToken ct) + { + if (_processedPayments.TryGetValue(idempotencyKey, out var cachedResult)) + { + _logger.LogInformation( + "Idempotent payment request detected for key {IdempotencyKey}. Returning cached result: Success={Success}", + idempotencyKey, + cachedResult.Success); + return cachedResult; + } + + _logger.LogInformation( + "Processing payment: {Amount} {Currency} to {Merchant} with token {Token}, idempotency key {IdempotencyKey}", + amount, + currency, + merchant, + paymentMethodToken, + idempotencyKey); + + var delayMs = _random.Next(1000, 2001); + await Task.Delay(delayMs, ct); + + var roll = _random.Next(100); + PaymentResult result; + + if (roll < _successRatePercent) + { + var providerRef = $"PAY-{Guid.NewGuid():N}"; + result = new PaymentResult( + Success: true, + ProviderReference: providerRef, + FailureReason: null); + + _logger.LogInformation( + "Payment succeeded for key {IdempotencyKey}: {Amount} {Currency} to {Merchant}, ref: {ProviderRef}", + idempotencyKey, + amount, + currency, + merchant, + providerRef); + } + else + { + var failureReason = FailureReasons[_random.Next(FailureReasons.Length)]; + result = new PaymentResult( + Success: false, + ProviderReference: null, + FailureReason: failureReason); + + _logger.LogWarning( + "Payment failed for key {IdempotencyKey}: {Amount} {Currency} to {Merchant}, reason: {Reason}", + idempotencyKey, + amount, + currency, + merchant, + failureReason); + } + + _processedPayments.TryAdd(idempotencyKey, result); + return result; + } +} diff --git a/tests/AgentPayWatch.Agents.Payment.Tests/AgentPayWatch.Agents.Payment.Tests.csproj b/tests/AgentPayWatch.Agents.Payment.Tests/AgentPayWatch.Agents.Payment.Tests.csproj new file mode 100644 index 0000000..1803270 --- /dev/null +++ b/tests/AgentPayWatch.Agents.Payment.Tests/AgentPayWatch.Agents.Payment.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + AgentPayWatch.Agents.Payment.Tests + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/tests/AgentPayWatch.Agents.Payment.Tests/Fakes.cs b/tests/AgentPayWatch.Agents.Payment.Tests/Fakes.cs new file mode 100644 index 0000000..912f451 --- /dev/null +++ b/tests/AgentPayWatch.Agents.Payment.Tests/Fakes.cs @@ -0,0 +1,145 @@ +using AgentPayWatch.Domain.Entities; +using AgentPayWatch.Domain.Enums; +using AgentPayWatch.Domain.Interfaces; + +namespace AgentPayWatch.Agents.Payment.Tests; + +// ── FakeWatchRequestRepository ──────────────────────────────────────────────── + +internal sealed class FakeWatchRequestRepository : IWatchRequestRepository +{ + private readonly Dictionary _store; + + public List UpdatedWatches { get; } = []; + + public FakeWatchRequestRepository(params WatchRequest[] watches) + { + _store = watches.ToDictionary(w => w.Id); + } + + public Task CreateAsync(WatchRequest watchRequest, CancellationToken ct = default) + { + _store[watchRequest.Id] = watchRequest; + return Task.FromResult(watchRequest); + } + + public Task GetByIdAsync(Guid id, string? userId = null, CancellationToken ct = default) => + Task.FromResult(_store.GetValueOrDefault(id)); + + public Task> GetByUserIdAsync(string userId, CancellationToken ct = default) => + Task.FromResult>( + _store.Values.Where(w => w.UserId == userId).ToList()); + + public Task> GetByStatusAsync(WatchStatus status, CancellationToken ct = default) => + Task.FromResult>( + _store.Values.Where(w => w.Status == status).ToList()); + + public Task UpdateAsync(WatchRequest watchRequest, CancellationToken ct = default) + { + _store[watchRequest.Id] = watchRequest; + UpdatedWatches.Add(watchRequest); + return Task.CompletedTask; + } + + public WatchRequest? GetById(Guid id) => _store.GetValueOrDefault(id); +} + +// ── FakeProductMatchRepository ──────────────────────────────────────────────── + +internal sealed class FakeProductMatchRepository : IProductMatchRepository +{ + private readonly Dictionary _store; + + public FakeProductMatchRepository(params ProductMatch[] matches) + { + _store = matches.ToDictionary(m => m.Id); + } + + public Task CreateAsync(ProductMatch match, CancellationToken ct = default) + { + _store[match.Id] = match; + return Task.FromResult(match); + } + + public Task GetByIdAsync(Guid id, Guid watchRequestId, CancellationToken ct = default) => + Task.FromResult(_store.GetValueOrDefault(id)); + + public Task> GetByWatchRequestIdAsync(Guid watchRequestId, CancellationToken ct = default) => + Task.FromResult>( + _store.Values.Where(m => m.WatchRequestId == watchRequestId).ToList()); +} + +// ── FakePaymentTransactionRepository ───────────────────────────────────────── + +internal sealed class FakePaymentTransactionRepository : IPaymentTransactionRepository +{ + private readonly Dictionary _store = []; + + public List CreatedTransactions { get; } = []; + + public Task CreateAsync(PaymentTransaction transaction, CancellationToken ct = default) + { + _store[transaction.Id] = transaction; + CreatedTransactions.Add(transaction); + return Task.FromResult(transaction); + } + + public Task GetByIdAsync(Guid id, string userId, CancellationToken ct = default) => + Task.FromResult(_store.GetValueOrDefault(id)); + + public Task> GetByUserIdAsync(string userId, CancellationToken ct = default) => + Task.FromResult>( + _store.Values.Where(t => t.UserId == userId).ToList()); + + public Task UpdateAsync(PaymentTransaction transaction, CancellationToken ct = default) + { + _store[transaction.Id] = transaction; + return Task.CompletedTask; + } +} + +// ── FakeEventPublisher ──────────────────────────────────────────────────────── + +internal sealed class FakeEventPublisher : IEventPublisher +{ + public List<(object Event, string Topic)> PublishedEvents { get; } = []; + + public Task PublishAsync(T message, string topicName, CancellationToken ct) + { + PublishedEvents.Add((message!, topicName)); + return Task.CompletedTask; + } +} + +// ── FakePaymentProvider ─────────────────────────────────────────────────────── + +internal sealed class FakePaymentProvider : IPaymentProvider +{ + private readonly bool _alwaysSucceed; + private readonly string _failureReason; + + public List<(string Key, decimal Amount, string Currency, string Merchant, string Token)> Calls { get; } = []; + + public FakePaymentProvider(bool alwaysSucceed = true, string failureReason = "Card declined") + { + _alwaysSucceed = alwaysSucceed; + _failureReason = failureReason; + } + + public Task ExecutePaymentAsync( + string idempotencyKey, + decimal amount, + string currency, + string merchant, + string paymentMethodToken, + CancellationToken ct) + { + Calls.Add((idempotencyKey, amount, currency, merchant, paymentMethodToken)); + + var result = _alwaysSucceed + ? new PaymentResult(true, $"PAY-{Guid.NewGuid():N}", null) + : new PaymentResult(false, null, _failureReason); + + return Task.FromResult(result); + } +} diff --git a/tests/AgentPayWatch.Agents.Payment.Tests/MockPaymentProviderTests.cs b/tests/AgentPayWatch.Agents.Payment.Tests/MockPaymentProviderTests.cs new file mode 100644 index 0000000..af4da4f --- /dev/null +++ b/tests/AgentPayWatch.Agents.Payment.Tests/MockPaymentProviderTests.cs @@ -0,0 +1,142 @@ +using AgentPayWatch.Infrastructure.Mocks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentPayWatch.Agents.Payment.Tests; + +public sealed class MockPaymentProviderTests +{ + // NOTE: MockPaymentProvider includes a 1-2s simulated delay per call. + // Tests are kept to the absolute minimum number of calls to stay fast. + + // ── Success result shape ────────────────────────────────────────────────── + + [Fact] + public async Task ExecutePaymentAsync_SuccessResult_HasProviderRefAndNoFailureReason() + { + var provider = BuildProvider(successRate: 100); + + var result = await provider.ExecutePaymentAsync( + "key-1", 249.99m, "USD", "TechMart", "tok_demo", CancellationToken.None); + + Assert.True(result.Success); + Assert.NotNull(result.ProviderReference); + Assert.StartsWith("PAY-", result.ProviderReference); + Assert.Null(result.FailureReason); + } + + // ── Failure result shape ────────────────────────────────────────────────── + + [Fact] + public async Task ExecutePaymentAsync_FailureResult_HasFailureReasonAndNoProviderRef() + { + var provider = BuildProvider(successRate: 0); + + var result = await provider.ExecutePaymentAsync( + "key-1", 249.99m, "USD", "TechMart", "tok_demo", CancellationToken.None); + + Assert.False(result.Success); + Assert.Null(result.ProviderReference); + Assert.NotNull(result.FailureReason); + Assert.NotEmpty(result.FailureReason); + } + + [Fact] + public async Task ExecutePaymentAsync_FailureReason_IsOneOfKnownValues() + { + var provider = BuildProvider(successRate: 0); + string[] knownReasons = ["Insufficient funds", "Card declined", "Provider timeout"]; + + // 3 calls — enough to verify the reason is always drawn from the known set + for (var i = 0; i < 3; i++) + { + var result = await provider.ExecutePaymentAsync( + Guid.NewGuid().ToString(), 100m, "USD", "Shop", "tok", CancellationToken.None); + + Assert.Contains(result.FailureReason!, knownReasons); + } + } + + // ── Idempotency ─────────────────────────────────────────────────────────── + + [Fact] + public async Task ExecutePaymentAsync_ReturnsSameResult_ForDuplicateKey() + { + var provider = BuildProvider(successRate: 100); + const string key = "idempotent-key-abc"; + + var first = await provider.ExecutePaymentAsync(key, 100m, "USD", "Shop", "tok", CancellationToken.None); + var second = await provider.ExecutePaymentAsync(key, 100m, "USD", "Shop", "tok", CancellationToken.None); + + Assert.Equal(first.Success, second.Success); + Assert.Equal(first.ProviderReference, second.ProviderReference); + Assert.Equal(first.FailureReason, second.FailureReason); + } + + [Fact] + public async Task ExecutePaymentAsync_IdempotencyKey_CanPreserveFailure() + { + var provider = BuildProvider(successRate: 0); + const string key = "idempotent-failure-key"; + + var first = await provider.ExecutePaymentAsync(key, 100m, "USD", "Shop", "tok", CancellationToken.None); + var second = await provider.ExecutePaymentAsync(key, 100m, "USD", "Shop", "tok", CancellationToken.None); + + Assert.False(first.Success); + Assert.Equal(first.FailureReason, second.FailureReason); + } + + [Fact] + public async Task ExecutePaymentAsync_DifferentKeys_ProduceDifferentProviderRefs() + { + var provider = BuildProvider(successRate: 100); + + var result1 = await provider.ExecutePaymentAsync("key-a", 100m, "USD", "Shop", "tok", CancellationToken.None); + var result2 = await provider.ExecutePaymentAsync("key-b", 100m, "USD", "Shop", "tok", CancellationToken.None); + + Assert.True(result1.Success); + Assert.True(result2.Success); + Assert.NotEqual(result1.ProviderReference, result2.ProviderReference); + } + + // ── Configuration ───────────────────────────────────────────────────────── + + [Fact] + public async Task ExecutePaymentAsync_ReturnsValidResult_WhenNoConfigProvided() + { + // Default is 90% success — just verify the result structure is valid + var provider = new MockPaymentProvider( + NullLogger.Instance, + new ConfigurationBuilder().Build()); + + var result = await provider.ExecutePaymentAsync( + "key-default", 100m, "USD", "Shop", "tok", CancellationToken.None); + + // Result must be either a clean success or a clean failure — never ambiguous + if (result.Success) + { + Assert.NotNull(result.ProviderReference); + Assert.Null(result.FailureReason); + } + else + { + Assert.Null(result.ProviderReference); + Assert.NotNull(result.FailureReason); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static MockPaymentProvider BuildProvider(int successRate) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Payment:MockSuccessRatePercent"] = successRate.ToString() + }) + .Build(); + + return new MockPaymentProvider(NullLogger.Instance, config); + } +} diff --git a/tests/AgentPayWatch.Agents.Payment.Tests/PaymentProcessorTests.cs b/tests/AgentPayWatch.Agents.Payment.Tests/PaymentProcessorTests.cs new file mode 100644 index 0000000..003b3fe --- /dev/null +++ b/tests/AgentPayWatch.Agents.Payment.Tests/PaymentProcessorTests.cs @@ -0,0 +1,353 @@ +using AgentPayWatch.Domain.Entities; +using AgentPayWatch.Domain.Enums; +using AgentPayWatch.Domain.Events; +using AgentPayWatch.Infrastructure.Messaging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentPayWatch.Agents.Payment.Tests; + +public sealed class PaymentProcessorTests +{ + // ── Watch not found ─────────────────────────────────────────────────────── + + [Fact] + public async Task ProcessAsync_DoesNothing_WhenWatchNotInApprovedStatus() + { + // Watch exists but in wrong status (Active, not Approved) + var watch = MakeWatch(WatchStatus.Active); + var (transactionRepo, publisher) = await RunAsync( + approvalEvent: MakeApprovalEvent(watchId: watch.Id), + watches: [watch], + matches: []); + + Assert.Empty(transactionRepo.CreatedTransactions); + Assert.Empty(publisher.PublishedEvents); + } + + [Fact] + public async Task ProcessAsync_DoesNothing_WhenWatchIdNotFound() + { + var (transactionRepo, publisher) = await RunAsync( + approvalEvent: MakeApprovalEvent(watchId: Guid.NewGuid()), + watches: [], + matches: []); + + Assert.Empty(transactionRepo.CreatedTransactions); + Assert.Empty(publisher.PublishedEvents); + } + + // ── Match not found ─────────────────────────────────────────────────────── + + [Fact] + public async Task ProcessAsync_DoesNothing_WhenMatchNotFound() + { + var watch = MakeWatch(WatchStatus.Approved); + var approvalEvent = MakeApprovalEvent(watchId: watch.Id, matchId: Guid.NewGuid()); + + var (transactionRepo, publisher) = await RunAsync(approvalEvent, [watch], []); + + Assert.Empty(transactionRepo.CreatedTransactions); + Assert.Empty(publisher.PublishedEvents); + } + + // ── Successful payment ──────────────────────────────────────────────────── + + [Fact] + public async Task ProcessAsync_CreatesTransaction_OnPaymentSuccess() + { + var (watch, match, approvalEvent) = MakeApprovedScenario(); + var (transactionRepo, _) = await RunAsync(approvalEvent, [watch], [match], succeed: true); + + Assert.Single(transactionRepo.CreatedTransactions); + var tx = transactionRepo.CreatedTransactions[0]; + Assert.Equal(PaymentStatus.Succeeded, tx.Status); + Assert.Equal(match.Id, tx.MatchId); + Assert.Equal(approvalEvent.ApprovalId, tx.ApprovalId); + Assert.Equal(watch.Id, tx.WatchRequestId); + Assert.Equal(watch.UserId, tx.UserId); + Assert.Equal(match.Price, tx.Amount); + Assert.Equal(match.Currency, tx.Currency); + Assert.Equal(match.Seller, tx.Merchant); + Assert.NotNull(tx.CompletedAt); + Assert.Null(tx.FailureReason); + } + + [Fact] + public async Task ProcessAsync_TransitionsWatchToCompleted_OnPaymentSuccess() + { + var (watch, match, approvalEvent) = MakeApprovedScenario(); + var watchRepo = new FakeWatchRequestRepository(watch); + + await RunWithRepoAsync(approvalEvent, watchRepo, [match], succeed: true); + + Assert.Equal(WatchStatus.Completed, watchRepo.GetById(watch.Id)!.Status); + } + + [Fact] + public async Task ProcessAsync_PublishesPaymentCompletedEvent_OnPaymentSuccess() + { + var (watch, match, approvalEvent) = MakeApprovedScenario(); + var (_, publisher) = await RunAsync(approvalEvent, [watch], [match], succeed: true); + + Assert.Single(publisher.PublishedEvents); + var (evt, topic) = publisher.PublishedEvents[0]; + Assert.IsType(evt); + Assert.Equal(TopicNames.PaymentCompleted, topic); + + var completed = (PaymentCompleted)evt; + Assert.Equal(watch.Id, completed.CorrelationId); + Assert.Equal(match.Price, completed.Amount); + Assert.Equal(match.Currency, completed.Currency); + Assert.Equal(match.Seller, completed.Merchant); + Assert.Equal("payment-agent", completed.Source); + } + + [Fact] + public async Task ProcessAsync_TransitionsToPurchasingBeforeCallingProvider() + { + // Verifies the watch passes through Purchasing on the way to Completed + var (watch, match, approvalEvent) = MakeApprovedScenario(); + var watchRepo = new FakeWatchRequestRepository(watch); + + await RunWithRepoAsync(approvalEvent, watchRepo, [match], succeed: true); + + var finalWatch = watchRepo.GetById(watch.Id)!; + Assert.Equal(WatchStatus.Completed, finalWatch.Status); + // Status history confirms the Purchasing intermediate state was recorded + Assert.Contains(finalWatch.StatusHistory, h => h.To == WatchStatus.Purchasing); + Assert.Contains(finalWatch.StatusHistory, h => h.To == WatchStatus.Completed); + } + + // ── Failed payment ──────────────────────────────────────────────────────── + + [Fact] + public async Task ProcessAsync_CreatesFailedTransaction_OnPaymentFailure() + { + var (watch, match, approvalEvent) = MakeApprovedScenario(); + var (transactionRepo, _) = await RunAsync( + approvalEvent, [watch], [match], succeed: false, failureReason: "Card declined"); + + Assert.Single(transactionRepo.CreatedTransactions); + var tx = transactionRepo.CreatedTransactions[0]; + Assert.Equal(PaymentStatus.Failed, tx.Status); + Assert.Equal("Card declined", tx.FailureReason); + Assert.Empty(tx.PaymentProviderRef); + Assert.Null(tx.CompletedAt); + } + + [Fact] + public async Task ProcessAsync_ReturnsWatchToActive_OnPaymentFailure() + { + var (watch, match, approvalEvent) = MakeApprovedScenario(); + var watchRepo = new FakeWatchRequestRepository(watch); + + await RunWithRepoAsync(approvalEvent, watchRepo, [match], succeed: false); + + Assert.Equal(WatchStatus.Active, watchRepo.GetById(watch.Id)!.Status); + } + + [Fact] + public async Task ProcessAsync_PublishesPaymentFailedEvent_OnPaymentFailure() + { + var (watch, match, approvalEvent) = MakeApprovedScenario(); + var (_, publisher) = await RunAsync( + approvalEvent, [watch], [match], succeed: false, failureReason: "Insufficient funds"); + + Assert.Single(publisher.PublishedEvents); + var (evt, topic) = publisher.PublishedEvents[0]; + Assert.IsType(evt); + Assert.Equal(TopicNames.PaymentFailed, topic); + + var failed = (PaymentFailed)evt; + Assert.Equal(watch.Id, failed.CorrelationId); + Assert.Equal("Insufficient funds", failed.Reason); + Assert.Equal("payment-agent", failed.Source); + } + + [Fact] + public async Task ProcessAsync_FailedPayment_TransitionsToPurchasingThenBackToActive() + { + // Verifies the watch passes through Purchasing before returning to Active + var (watch, match, approvalEvent) = MakeApprovedScenario(); + var watchRepo = new FakeWatchRequestRepository(watch); + + await RunWithRepoAsync(approvalEvent, watchRepo, [match], succeed: false); + + var finalWatch = watchRepo.GetById(watch.Id)!; + Assert.Equal(WatchStatus.Active, finalWatch.Status); + // Status history confirms Purchasing was an intermediate state + Assert.Contains(finalWatch.StatusHistory, h => h.To == WatchStatus.Purchasing); + Assert.Contains(finalWatch.StatusHistory, + h => h.From == WatchStatus.Purchasing && h.To == WatchStatus.Active); + } + + // ── Idempotency key format ──────────────────────────────────────────────── + + [Fact] + public async Task ProcessAsync_IdempotencyKey_IsMatchIdColonApprovalId() + { + var (watch, match, approvalEvent) = MakeApprovedScenario(); + var paymentProvider = new FakePaymentProvider(alwaysSucceed: true); + + await RunWithProviderAsync( + approvalEvent, new FakeWatchRequestRepository(watch), [match], paymentProvider); + + Assert.Single(paymentProvider.Calls); + var expectedKey = $"{match.Id}:{approvalEvent.ApprovalId}"; + Assert.Equal(expectedKey, paymentProvider.Calls[0].Key); + } + + [Fact] + public async Task ProcessAsync_Transaction_IdempotencyKeyMatchesProviderCall() + { + var (watch, match, approvalEvent) = MakeApprovedScenario(); + var (transactionRepo, _) = await RunAsync(approvalEvent, [watch], [match], succeed: true); + + var tx = transactionRepo.CreatedTransactions[0]; + Assert.Equal($"{match.Id}:{approvalEvent.ApprovalId}", tx.IdempotencyKey); + } + + // ── Payment method token ────────────────────────────────────────────────── + + [Fact] + public async Task ProcessAsync_UsesFallbackToken_WhenWatchHasNoPaymentMethodToken() + { + var (watch, match, approvalEvent) = MakeApprovedScenario(); + watch.PaymentMethodToken = string.Empty; + var paymentProvider = new FakePaymentProvider(alwaysSucceed: true); + + await RunWithProviderAsync( + approvalEvent, new FakeWatchRequestRepository(watch), [match], paymentProvider); + + Assert.Equal("tok_demo", paymentProvider.Calls[0].Token); + } + + [Fact] + public async Task ProcessAsync_UsesWatchToken_WhenWatchHasPaymentMethodToken() + { + var (watch, match, approvalEvent) = MakeApprovedScenario(); + watch.PaymentMethodToken = "tok_real_card_xyz"; + var paymentProvider = new FakePaymentProvider(alwaysSucceed: true); + + await RunWithProviderAsync( + approvalEvent, new FakeWatchRequestRepository(watch), [match], paymentProvider); + + Assert.Equal("tok_real_card_xyz", paymentProvider.Calls[0].Token); + } + + // ── Success transaction details ─────────────────────────────────────────── + + [Fact] + public async Task ProcessAsync_SuccessTransaction_HasProviderRefSet() + { + var (watch, match, approvalEvent) = MakeApprovedScenario(); + var (transactionRepo, _) = await RunAsync(approvalEvent, [watch], [match], succeed: true); + + var tx = transactionRepo.CreatedTransactions[0]; + Assert.NotEmpty(tx.PaymentProviderRef); + Assert.StartsWith("PAY-", tx.PaymentProviderRef); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static async Task<(FakePaymentTransactionRepository, FakeEventPublisher)> RunAsync( + ApprovalDecided approvalEvent, + WatchRequest[] watches, + ProductMatch[] matches, + bool succeed = true, + string failureReason = "Card declined") + { + var watchRepo = new FakeWatchRequestRepository(watches); + var provider = new FakePaymentProvider(succeed, failureReason); + return await RunWithProviderAsync(approvalEvent, watchRepo, matches, provider); + } + + private static async Task RunWithRepoAsync( + ApprovalDecided approvalEvent, + FakeWatchRequestRepository watchRepo, + ProductMatch[] matches, + bool succeed = true, + string failureReason = "Card declined") + { + var provider = new FakePaymentProvider(succeed, failureReason); + await RunWithProviderAsync(approvalEvent, watchRepo, matches, provider); + } + + private static async Task<(FakePaymentTransactionRepository, FakeEventPublisher)> RunWithProviderAsync( + ApprovalDecided approvalEvent, + FakeWatchRequestRepository watchRepo, + ProductMatch[] matches, + FakePaymentProvider paymentProvider) + { + var matchRepo = new FakeProductMatchRepository(matches); + var transactionRepo = new FakePaymentTransactionRepository(); + var publisher = new FakeEventPublisher(); + + var processor = new PaymentProcessor( + watchRepo, + matchRepo, + transactionRepo, + paymentProvider, + publisher, + NullLogger.Instance); + + await processor.ProcessAsync(approvalEvent, CancellationToken.None); + + return (transactionRepo, publisher); + } + + private static ApprovalDecided MakeApprovalEvent( + Guid? watchId = null, + Guid? matchId = null, + Guid? approvalId = null) => + new( + MessageId: Guid.NewGuid(), + CorrelationId: watchId ?? Guid.NewGuid(), + Timestamp: DateTimeOffset.UtcNow, + Source: "approval-agent", + ApprovalId: approvalId ?? Guid.NewGuid(), + MatchId: matchId ?? Guid.NewGuid(), + Decision: ApprovalDecision.Approved); + + private static WatchRequest MakeWatch(WatchStatus status, Guid? id = null) => + new() + { + Id = id ?? Guid.NewGuid(), + UserId = "user-1", + ProductName = "Test Product", + MaxPrice = 999m, + Status = status, + StatusHistory = status switch + { + WatchStatus.Approved => + [ + new(WatchStatus.Active, WatchStatus.Matched, DateTimeOffset.UtcNow.AddMinutes(-30), null), + new(WatchStatus.Matched, WatchStatus.AwaitingApproval, DateTimeOffset.UtcNow.AddMinutes(-20), null), + new(WatchStatus.AwaitingApproval, WatchStatus.Approved, DateTimeOffset.UtcNow.AddMinutes(-5), null) + ], + _ => [] + } + }; + + private static ProductMatch MakeMatch(Guid watchRequestId) => + new() + { + Id = Guid.NewGuid(), + WatchRequestId = watchRequestId, + UserId = "user-1", + ProductName = "Test Product", + Price = 849.99m, + Currency = "USD", + Seller = "TechDeals Direct" + }; + + private static (WatchRequest watch, ProductMatch match, ApprovalDecided approvalEvent) + MakeApprovedScenario() + { + var watch = MakeWatch(WatchStatus.Approved); + var match = MakeMatch(watch.Id); + var approvalEvent = MakeApprovalEvent(watchId: watch.Id, matchId: match.Id); + return (watch, match, approvalEvent); + } +} diff --git a/tests/AgentPayWatch.Api.Tests/ApiTestFactory.cs b/tests/AgentPayWatch.Api.Tests/ApiTestFactory.cs index 2cc17d1..5089674 100644 --- a/tests/AgentPayWatch.Api.Tests/ApiTestFactory.cs +++ b/tests/AgentPayWatch.Api.Tests/ApiTestFactory.cs @@ -21,6 +21,9 @@ public sealed class ApiTestFactory : WebApplicationFactory public IProductMatchRepository MatchRepository { get; } = Substitute.For(); + public IPaymentTransactionRepository TransactionRepository { get; } = + Substitute.For(); + public IEventPublisher EventPublisher { get; } = Substitute.For(); @@ -60,6 +63,9 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.RemoveAll(); services.AddScoped(_ => MatchRepository); + services.RemoveAll(); + services.AddScoped(_ => TransactionRepository); + services.RemoveAll(); services.AddSingleton(_ => EventPublisher); }); diff --git a/tests/AgentPayWatch.Api.Tests/TransactionEndpointsTests.cs b/tests/AgentPayWatch.Api.Tests/TransactionEndpointsTests.cs new file mode 100644 index 0000000..8b220ce --- /dev/null +++ b/tests/AgentPayWatch.Api.Tests/TransactionEndpointsTests.cs @@ -0,0 +1,211 @@ +using System.Net; +using System.Net.Http.Json; +using AgentPayWatch.Domain.Entities; +using AgentPayWatch.Domain.Enums; +using NSubstitute; +using Xunit; + +namespace AgentPayWatch.Api.Tests; + +public sealed class TransactionEndpointsTests : IClassFixture +{ + private readonly HttpClient _client; + private readonly ApiTestFactory _factory; + + public TransactionEndpointsTests(ApiTestFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + } + + // ── GET /api/transactions ───────────────────────────────────────────────── + + [Fact] + public async Task GetTransactions_Returns200WithList_WhenTransactionsExist() + { + var tx = MakeTransaction("user-1"); + _factory.TransactionRepository + .GetByUserIdAsync("user-1", Arg.Any()) + .Returns(new List { tx }); + + var response = await _client.GetAsync("/api/transactions?userId=user-1"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(body); + Assert.Single(body); + Assert.Equal(tx.Id, body[0].Id); + Assert.Equal("Succeeded", body[0].Status); + } + + [Fact] + public async Task GetTransactions_Returns200WithEmptyList_WhenNoTransactions() + { + _factory.TransactionRepository + .GetByUserIdAsync("empty-user", Arg.Any()) + .Returns(new List()); + + var response = await _client.GetAsync("/api/transactions?userId=empty-user"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(body); + Assert.Empty(body); + } + + [Fact] + public async Task GetTransactions_Returns400_WhenUserIdMissing() + { + var response = await _client.GetAsync("/api/transactions?userId="); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetTransactions_ReturnsMostRecentFirst() + { + var older = MakeTransaction("user-1", initiatedAt: DateTimeOffset.UtcNow.AddHours(-2)); + var newer = MakeTransaction("user-1", initiatedAt: DateTimeOffset.UtcNow.AddHours(-1)); + var newest = MakeTransaction("user-1", initiatedAt: DateTimeOffset.UtcNow); + + _factory.TransactionRepository + .GetByUserIdAsync("user-1", Arg.Any()) + .Returns(new List { older, newest, newer }); // unsorted + + var response = await _client.GetAsync("/api/transactions?userId=user-1"); + + var body = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(body); + Assert.Equal(3, body.Count); + // Should be sorted newest→oldest + Assert.Equal(newest.Id, body[0].Id); + Assert.Equal(newer.Id, body[1].Id); + Assert.Equal(older.Id, body[2].Id); + } + + [Fact] + public async Task GetTransactions_ResponseContainsAllFields() + { + var tx = MakeTransaction("user-1"); + _factory.TransactionRepository + .GetByUserIdAsync("user-1", Arg.Any()) + .Returns(new List { tx }); + + var response = await _client.GetAsync("/api/transactions?userId=user-1"); + var body = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(body); + + var dto = body[0]; + Assert.Equal(tx.Id, dto.Id); + Assert.Equal(tx.MatchId, dto.MatchId); + Assert.Equal(tx.ApprovalId, dto.ApprovalId); + Assert.Equal(tx.WatchRequestId, dto.WatchRequestId); + Assert.Equal(tx.UserId, dto.UserId); + Assert.Equal(tx.IdempotencyKey, dto.IdempotencyKey); + Assert.Equal(tx.Amount, dto.Amount); + Assert.Equal(tx.Currency, dto.Currency); + Assert.Equal(tx.Merchant, dto.Merchant); + Assert.Equal("Succeeded", dto.Status); + Assert.Equal(tx.PaymentProviderRef, dto.PaymentProviderRef); + Assert.NotNull(dto.CompletedAt); + Assert.Null(dto.FailureReason); + } + + // ── GET /api/transactions/{id} ──────────────────────────────────────────── + + [Fact] + public async Task GetTransactionById_Returns200_WhenFound() + { + var tx = MakeTransaction("user-1"); + _factory.TransactionRepository + .GetByIdAsync(tx.Id, "user-1", Arg.Any()) + .Returns(tx); + + var response = await _client.GetAsync($"/api/transactions/{tx.Id}?userId=user-1"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(tx.Id, body.Id); + } + + [Fact] + public async Task GetTransactionById_Returns404_WhenNotFound() + { + var id = Guid.NewGuid(); + _factory.TransactionRepository + .GetByIdAsync(id, "user-1", Arg.Any()) + .Returns((PaymentTransaction?)null); + + var response = await _client.GetAsync($"/api/transactions/{id}?userId=user-1"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetTransactionById_Returns400_WhenUserIdMissing() + { + var response = await _client.GetAsync($"/api/transactions/{Guid.NewGuid()}?userId="); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetTransactionById_FailedTransaction_HasFailureReasonSet() + { + var tx = MakeTransaction("user-1", status: PaymentStatus.Failed, failureReason: "Insufficient funds"); + _factory.TransactionRepository + .GetByIdAsync(tx.Id, "user-1", Arg.Any()) + .Returns(tx); + + var response = await _client.GetAsync($"/api/transactions/{tx.Id}?userId=user-1"); + var body = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(body); + Assert.Equal("Failed", body.Status); + Assert.Equal("Insufficient funds", body.FailureReason); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static PaymentTransaction MakeTransaction( + string userId, + PaymentStatus status = PaymentStatus.Succeeded, + string? failureReason = null, + DateTimeOffset? initiatedAt = null) => + new() + { + Id = Guid.NewGuid(), + MatchId = Guid.NewGuid(), + ApprovalId = Guid.NewGuid(), + WatchRequestId = Guid.NewGuid(), + UserId = userId, + IdempotencyKey = $"{Guid.NewGuid()}:{Guid.NewGuid()}", + Amount = 849.99m, + Currency = "USD", + Merchant = "TechDeals Direct", + Status = status, + PaymentProviderRef = status == PaymentStatus.Succeeded ? $"PAY-{Guid.NewGuid():N}" : string.Empty, + InitiatedAt = initiatedAt ?? DateTimeOffset.UtcNow, + CompletedAt = status == PaymentStatus.Succeeded ? DateTimeOffset.UtcNow : null, + FailureReason = failureReason + }; +} + +// ── Response DTO for deserialization ───────────────────────────────────────── + +file sealed record TransactionDto( + Guid Id, + Guid MatchId, + Guid ApprovalId, + Guid WatchRequestId, + string UserId, + string IdempotencyKey, + decimal Amount, + string Currency, + string Merchant, + string Status, + string PaymentProviderRef, + DateTimeOffset InitiatedAt, + DateTimeOffset? CompletedAt, + string? FailureReason);