|
| 1 | +using System; |
| 2 | +using System.Collections.Generic; |
| 3 | +using System.IO; |
| 4 | +using System.Linq; |
| 5 | +using System.Threading; |
| 6 | +using System.Threading.Tasks; |
| 7 | +using BTCPayServer; |
| 8 | +using BTCPayServer.Client.Models; |
| 9 | +using BTCPayServer.Data; |
| 10 | +using BTCPayServer.Events; |
| 11 | +using BTCPayServer.Logging; |
| 12 | +using BTCPayServer.Payments; |
| 13 | +using BTCPayServer.Plugins.Depix.Data.Models; |
| 14 | +using BTCPayServer.Plugins.Depix.PaymentHandlers; |
| 15 | +using BTCPayServer.Plugins.Depix.Services; |
| 16 | +using BTCPayServer.Rating; |
| 17 | +using BTCPayServer.Services.Invoices; |
| 18 | +using BTCPayServer.Services.Stores; |
| 19 | +using BTCPayServer.Tests; |
| 20 | +using Newtonsoft.Json.Linq; |
| 21 | +using Xunit; |
| 22 | +using Xunit.Abstractions; |
| 23 | + |
| 24 | +namespace BTCPayServer.Plugins.Depix.Tests; |
| 25 | + |
| 26 | +[Collection(SharedPluginTestCollection.CollectionName)] |
| 27 | +public sealed class DepixWebhookServiceTests : IAsyncLifetime |
| 28 | +{ |
| 29 | + private static readonly PaymentMethodId PixPmid = new("PIX"); |
| 30 | + private readonly UnitTestBase _unitTestBase; |
| 31 | + private ServerTester _server = null!; |
| 32 | + |
| 33 | + public DepixWebhookServiceTests(SharedPluginTestFixture fixture, ITestOutputHelper output) |
| 34 | + { |
| 35 | + _ = fixture; |
| 36 | + _unitTestBase = new UnitTestBase(output); |
| 37 | + } |
| 38 | + |
| 39 | + public async Task InitializeAsync() |
| 40 | + { |
| 41 | + _server = _unitTestBase.CreateServerTester(scope: CreateScopePath(), newDb: true); |
| 42 | + await _server.StartAsync(); |
| 43 | + } |
| 44 | + |
| 45 | + public Task DisposeAsync() |
| 46 | + { |
| 47 | + _server.Dispose(); |
| 48 | + return Task.CompletedTask; |
| 49 | + } |
| 50 | + |
| 51 | + [Fact(Timeout = TestUtils.TestTimeout)] |
| 52 | + public async Task ApprovedWebhookCreatesPaymentAndDepixSentUpdatesExistingPayment() |
| 53 | + { |
| 54 | + var storeId = await CreateStoreAsync(); |
| 55 | + var invoice = await CreatePixInvoiceAsync(storeId, qrId: "qr-approved-1", valueInCents: 1234); |
| 56 | + var service = _server.PayTester.GetService<DepixService>(); |
| 57 | + var events = _server.PayTester.GetService<EventAggregator>(); |
| 58 | + var receivedPaymentEvents = new List<InvoiceEvent>(); |
| 59 | + var needUpdateEvents = new List<InvoiceNeedUpdateEvent>(); |
| 60 | + var dataChangedEvents = new List<InvoiceDataChangedEvent>(); |
| 61 | + using var receivedPaymentSubscription = events.Subscribe<InvoiceEvent>(evt => |
| 62 | + { |
| 63 | + if (evt.InvoiceId == invoice.Id && evt.Name == InvoiceEvent.ReceivedPayment) |
| 64 | + receivedPaymentEvents.Add(evt); |
| 65 | + }); |
| 66 | + using var needUpdateSubscription = events.Subscribe<InvoiceNeedUpdateEvent>(evt => |
| 67 | + { |
| 68 | + if (evt.InvoiceId == invoice.Id) |
| 69 | + needUpdateEvents.Add(evt); |
| 70 | + }); |
| 71 | + using var dataChangedSubscription = events.Subscribe<InvoiceDataChangedEvent>(evt => |
| 72 | + { |
| 73 | + if (evt.InvoiceId == invoice.Id) |
| 74 | + dataChangedEvents.Add(evt); |
| 75 | + }); |
| 76 | + |
| 77 | + await service.ProcessWebhookAsync(storeId, new DepositWebhookBody |
| 78 | + { |
| 79 | + QrId = "qr-approved-1", |
| 80 | + Status = "approved", |
| 81 | + ValueInCents = 1234, |
| 82 | + PayerName = "Alice" |
| 83 | + }, CancellationToken.None); |
| 84 | + |
| 85 | + var approvedPayment = await GetPixPaymentAsync(invoice.Id); |
| 86 | + Assert.Equal(PaymentStatus.Settled, approvedPayment.Status); |
| 87 | + var receivedPaymentEvent = Assert.Single(receivedPaymentEvents); |
| 88 | + Assert.Equal(approvedPayment.Id, receivedPaymentEvent.Payment.Id); |
| 89 | + var approvedDetails = GetPixPaymentDetails(approvedPayment); |
| 90 | + Assert.Equal("approved", approvedDetails.Status); |
| 91 | + Assert.Equal("Alice", approvedDetails.PayerName); |
| 92 | + |
| 93 | + await service.ProcessWebhookAsync(storeId, new DepositWebhookBody |
| 94 | + { |
| 95 | + QrId = "qr-approved-1", |
| 96 | + Status = "depix_sent", |
| 97 | + ValueInCents = 1234, |
| 98 | + BlockchainTxId = "liquid-tx-1" |
| 99 | + }, CancellationToken.None); |
| 100 | + |
| 101 | + var invoiceAfterDepixSent = await _server.PayTester.InvoiceRepository.GetInvoice(invoice.Id); |
| 102 | + var payments = invoiceAfterDepixSent.GetPayments(false).Where(p => p.PaymentMethodId == PixPmid).ToList(); |
| 103 | + var updatedPayment = Assert.Single(payments); |
| 104 | + var updatedDetails = GetPixPaymentDetails(updatedPayment); |
| 105 | + Assert.Equal("depix_sent", updatedDetails.Status); |
| 106 | + Assert.Equal("liquid-tx-1", updatedDetails.BlockchainTxId); |
| 107 | + Assert.Equal("Alice", updatedDetails.PayerName); |
| 108 | + Assert.Equal("depix_sent", (await GetPixPromptDetailsAsync(invoice.Id)).Status); |
| 109 | + Assert.Contains(needUpdateEvents, evt => evt.InvoiceId == invoice.Id); |
| 110 | + Assert.Contains(dataChangedEvents, evt => evt.InvoiceId == invoice.Id); |
| 111 | + } |
| 112 | + |
| 113 | + [Fact(Timeout = TestUtils.TestTimeout)] |
| 114 | + public async Task ApprovedAfterDepixSentDoesNotDowngradeStoredStatus() |
| 115 | + { |
| 116 | + var storeId = await CreateStoreAsync(); |
| 117 | + var invoice = await CreatePixInvoiceAsync(storeId, qrId: "qr-out-of-order", valueInCents: 1234); |
| 118 | + var service = _server.PayTester.GetService<DepixService>(); |
| 119 | + |
| 120 | + await service.ProcessWebhookAsync(storeId, new DepositWebhookBody |
| 121 | + { |
| 122 | + QrId = "qr-out-of-order", |
| 123 | + Status = "depix_sent", |
| 124 | + ValueInCents = 1234, |
| 125 | + BlockchainTxId = "liquid-tx-1" |
| 126 | + }, CancellationToken.None); |
| 127 | + |
| 128 | + await service.ProcessWebhookAsync(storeId, new DepositWebhookBody |
| 129 | + { |
| 130 | + QrId = "qr-out-of-order", |
| 131 | + Status = "approved", |
| 132 | + ValueInCents = 1234, |
| 133 | + BankTxId = "bank-tx-late", |
| 134 | + PayerName = "Late payer" |
| 135 | + }, CancellationToken.None); |
| 136 | + |
| 137 | + var invoiceAfterApproved = await _server.PayTester.InvoiceRepository.GetInvoice(invoice.Id); |
| 138 | + var payment = Assert.Single(invoiceAfterApproved.GetPayments(false), p => p.PaymentMethodId == PixPmid); |
| 139 | + var details = GetPixPaymentDetails(payment); |
| 140 | + Assert.Equal("depix_sent", details.Status); |
| 141 | + Assert.Equal("liquid-tx-1", details.BlockchainTxId); |
| 142 | + Assert.Equal("bank-tx-late", details.BankTxId); |
| 143 | + Assert.Equal("Late payer", details.PayerName); |
| 144 | + |
| 145 | + var promptDetails = await GetPixPromptDetailsAsync(invoice.Id); |
| 146 | + Assert.Equal("depix_sent", promptDetails.Status); |
| 147 | + Assert.Equal("Late payer", promptDetails.Payer?.Name); |
| 148 | + } |
| 149 | + |
| 150 | + [Fact(Timeout = TestUtils.TestTimeout)] |
| 151 | + public async Task DuplicateWebhookWithMismatchedAmountDoesNotChangeStoredAmount() |
| 152 | + { |
| 153 | + var storeId = await CreateStoreAsync(); |
| 154 | + var invoice = await CreatePixInvoiceAsync(storeId, qrId: "qr-amount-mismatch", valueInCents: 1234); |
| 155 | + var service = _server.PayTester.GetService<DepixService>(); |
| 156 | + |
| 157 | + await service.ProcessWebhookAsync(storeId, new DepositWebhookBody |
| 158 | + { |
| 159 | + QrId = "qr-amount-mismatch", |
| 160 | + Status = "approved", |
| 161 | + ValueInCents = 1234, |
| 162 | + PayerName = "Alice" |
| 163 | + }, CancellationToken.None); |
| 164 | + |
| 165 | + await service.ProcessWebhookAsync(storeId, new DepositWebhookBody |
| 166 | + { |
| 167 | + QrId = "qr-amount-mismatch", |
| 168 | + Status = "depix_sent", |
| 169 | + ValueInCents = 9999, |
| 170 | + BlockchainTxId = "liquid-tx-mismatch" |
| 171 | + }, CancellationToken.None); |
| 172 | + |
| 173 | + var invoiceAfterMismatch = await _server.PayTester.InvoiceRepository.GetInvoice(invoice.Id); |
| 174 | + var payment = Assert.Single(invoiceAfterMismatch.GetPayments(false), p => p.PaymentMethodId == PixPmid); |
| 175 | + var details = GetPixPaymentDetails(payment); |
| 176 | + Assert.Equal(12.34m, payment.Value); |
| 177 | + Assert.Equal("depix_sent", details.Status); |
| 178 | + Assert.Equal(1234, details.ValueInCents); |
| 179 | + Assert.Equal("liquid-tx-mismatch", details.BlockchainTxId); |
| 180 | + Assert.Equal("Alice", details.PayerName); |
| 181 | + |
| 182 | + var promptDetails = await GetPixPromptDetailsAsync(invoice.Id); |
| 183 | + Assert.Equal("depix_sent", promptDetails.Status); |
| 184 | + Assert.Equal(1234, promptDetails.ValueInCents); |
| 185 | + } |
| 186 | + |
| 187 | + [Fact(Timeout = TestUtils.TestTimeout)] |
| 188 | + public async Task FirstConfirmedWebhookWithMismatchedAmountUsesPromptAmount() |
| 189 | + { |
| 190 | + var storeId = await CreateStoreAsync(); |
| 191 | + var invoice = await CreatePixInvoiceAsync(storeId, qrId: "qr-first-amount-mismatch", valueInCents: 1234); |
| 192 | + var service = _server.PayTester.GetService<DepixService>(); |
| 193 | + |
| 194 | + await service.ProcessWebhookAsync(storeId, new DepositWebhookBody |
| 195 | + { |
| 196 | + QrId = "qr-first-amount-mismatch", |
| 197 | + Status = "approved", |
| 198 | + ValueInCents = 9999, |
| 199 | + PayerName = "Alice" |
| 200 | + }, CancellationToken.None); |
| 201 | + |
| 202 | + var payment = await GetPixPaymentAsync(invoice.Id); |
| 203 | + Assert.Equal(12.34m, payment.Value); |
| 204 | + var details = GetPixPaymentDetails(payment); |
| 205 | + Assert.Equal("approved", details.Status); |
| 206 | + Assert.Null(details.ValueInCents); |
| 207 | + Assert.Equal("Alice", details.PayerName); |
| 208 | + |
| 209 | + var promptDetails = await GetPixPromptDetailsAsync(invoice.Id); |
| 210 | + Assert.Equal("approved", promptDetails.Status); |
| 211 | + Assert.Equal(1234, promptDetails.ValueInCents); |
| 212 | + } |
| 213 | + |
| 214 | + private async Task<string> CreateStoreAsync() |
| 215 | + { |
| 216 | + var account = _server.NewAccount(); |
| 217 | + await account.RegisterAsync(isAdmin: true); |
| 218 | + await account.CreateStoreAsync(); |
| 219 | + return account.StoreId; |
| 220 | + } |
| 221 | + |
| 222 | + private async Task<InvoiceEntity> CreatePixInvoiceAsync( |
| 223 | + string storeId, |
| 224 | + string qrId, |
| 225 | + int valueInCents) |
| 226 | + { |
| 227 | + var storeRepository = _server.PayTester.GetService<StoreRepository>(); |
| 228 | + var handlers = _server.PayTester.GetService<PaymentMethodHandlerDictionary>(); |
| 229 | + var handler = handlers[PixPmid]; |
| 230 | + var invoiceRepository = _server.PayTester.InvoiceRepository; |
| 231 | + var store = await storeRepository.FindStore(storeId) ?? throw new InvalidOperationException("Store not found."); |
| 232 | + |
| 233 | + var invoice = invoiceRepository.CreateNewInvoice(storeId); |
| 234 | + invoice.Currency = "BRL"; |
| 235 | + invoice.Price = valueInCents / 100m; |
| 236 | + invoice.AddRate(new CurrencyPair("BRL", "BRL"), 1m); |
| 237 | + var details = new DePixPaymentMethodDetails |
| 238 | + { |
| 239 | + QrId = qrId, |
| 240 | + Status = "pending", |
| 241 | + ValueInCents = valueInCents |
| 242 | + }; |
| 243 | + |
| 244 | + invoice.SetPaymentPrompt(PixPmid, new PaymentPrompt |
| 245 | + { |
| 246 | + Currency = "BRL", |
| 247 | + Divisibility = 2, |
| 248 | + Destination = "https://example.invalid/pix.png", |
| 249 | + Details = JToken.FromObject(details, handler.Serializer) |
| 250 | + }); |
| 251 | + |
| 252 | + await invoiceRepository.CreateInvoiceAsync(new InvoiceCreationContext( |
| 253 | + store, |
| 254 | + store.GetStoreBlob(), |
| 255 | + invoice, |
| 256 | + new InvoiceLogs(), |
| 257 | + handlers, |
| 258 | + invoicePaymentMethodFilter: null)); |
| 259 | + |
| 260 | + return invoice; |
| 261 | + } |
| 262 | + |
| 263 | + private async Task<PaymentEntity> GetPixPaymentAsync(string invoiceId) |
| 264 | + { |
| 265 | + var invoice = await _server.PayTester.InvoiceRepository.GetInvoice(invoiceId); |
| 266 | + return Assert.Single(invoice.GetPayments(false), p => p.PaymentMethodId == PixPmid); |
| 267 | + } |
| 268 | + |
| 269 | + private DePixPaymentData GetPixPaymentDetails(PaymentEntity payment) |
| 270 | + { |
| 271 | + var handler = _server.PayTester.GetService<PaymentMethodHandlerDictionary>()[PixPmid]; |
| 272 | + return payment.GetDetails<DePixPaymentData>(handler) ?? throw new InvalidOperationException("Missing Pix payment details."); |
| 273 | + } |
| 274 | + |
| 275 | + private async Task<DePixPaymentMethodDetails> GetPixPromptDetailsAsync(string invoiceId) |
| 276 | + { |
| 277 | + var invoice = await _server.PayTester.InvoiceRepository.GetInvoice(invoiceId); |
| 278 | + var prompt = invoice.GetPaymentPrompt(PixPmid) ?? throw new InvalidOperationException("Missing Pix prompt."); |
| 279 | + var handler = _server.PayTester.GetService<PaymentMethodHandlerDictionary>()[PixPmid]; |
| 280 | + return handler.ParsePaymentPromptDetails(prompt.Details) as DePixPaymentMethodDetails ?? |
| 281 | + throw new InvalidOperationException("Missing Pix prompt details."); |
| 282 | + } |
| 283 | + |
| 284 | + private static string CreateScopePath() |
| 285 | + { |
| 286 | + return Path.Combine(Path.GetTempPath(), "depix-webhook-service", Guid.NewGuid().ToString("N")); |
| 287 | + } |
| 288 | +} |
0 commit comments