Migrate one-time donations tests to OneTimeDonationControllerTest

This commit is contained in:
Chris Eager
2026-02-06 18:40:31 -06:00
committed by Chris Eager
parent 39beb59b58
commit 368e705b68
3 changed files with 525 additions and 419 deletions

View File

@@ -0,0 +1,218 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.controllers;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Clock;
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.util.MockUtils;
import org.whispersystems.textsecuregcm.util.SystemMapper;
class AbstractV1SubscriptionControllerTest {
static final Clock CLOCK = mock(Clock.class);
private static final ObjectMapper YAML_MAPPER = SystemMapper.yamlMapper();
static final OneTimeDonationConfiguration ONETIME_CONFIG = ConfigHelper.getOneTimeConfig();
static final StripeManager STRIPE_MANAGER = MockUtils.buildMock(StripeManager.class, mgr ->
when(mgr.getProvider()).thenReturn(PaymentProvider.STRIPE));
static final BraintreeManager BRAINTREE_MANAGER = MockUtils.buildMock(BraintreeManager.class, mgr ->
when(mgr.getProvider()).thenReturn(PaymentProvider.BRAINTREE));
static final IssuedReceiptsManager ISSUED_RECEIPTS_MANAGER = mock(IssuedReceiptsManager.class);
static final ServerZkReceiptOperations ZK_OPS = mock(ServerZkReceiptOperations.class);
/**
* Encapsulates {@code static} configuration, to keep the class header simpler and avoid illegal forward references
*/
record ConfigHelper() {
static SubscriptionConfiguration getSubscriptionConfig() {
return readValue(SUBSCRIPTION_CONFIG_YAML, SubscriptionConfiguration.class);
}
static OneTimeDonationConfiguration getOneTimeConfig() {
return readValue(ONETIME_CONFIG_YAML, OneTimeDonationConfiguration.class);
}
private static <T> T readValue(String yaml, Class<T> type) {
try {
return YAML_MAPPER.readValue(yaml, type);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static final String SUBSCRIPTION_CONFIG_YAML = """
badgeExpiration: P30D
badgeGracePeriod: P15D
backupExpiration: P3D
backupGracePeriod: P10D
backupFreeTierMediaDuration: P30D
backupLevels:
201:
playProductId: testPlayProductId
mediaTtl: P40D
prices:
usd:
amount: '5'
processorIds:
STRIPE: R4
BRAINTREE: M4
jpy:
amount: '500'
processorIds:
STRIPE: Q4
BRAINTREE: N4
bif:
amount: '5000'
processorIds:
STRIPE: S4
BRAINTREE: O4
eur:
amount: '5'
processorIds:
STRIPE: A4
BRAINTREE: B4
levels:
5:
badge: B1
prices:
usd:
amount: '5'
processorIds:
STRIPE: R1
BRAINTREE: M1
jpy:
amount: '500'
processorIds:
STRIPE: Q1
BRAINTREE: N1
bif:
amount: '5000'
processorIds:
STRIPE: S1
BRAINTREE: O1
eur:
amount: '5'
processorIds:
STRIPE: A1
BRAINTREE: B1
15:
badge: B2
prices:
usd:
amount: '15'
processorIds:
STRIPE: R2
BRAINTREE: M2
jpy:
amount: '1500'
processorIds:
STRIPE: Q2
BRAINTREE: N2
bif:
amount: '15000'
processorIds:
STRIPE: S2
BRAINTREE: O2
eur:
amount: '15'
processorIds:
STRIPE: A2
BRAINTREE: B2
35:
badge: B3
prices:
usd:
amount: '35'
processorIds:
STRIPE: R3
BRAINTREE: M3
jpy:
amount: '3500'
processorIds:
STRIPE: Q3
BRAINTREE: N3
bif:
amount: '35000'
processorIds:
STRIPE: S3
BRAINTREE: O3
eur:
amount: '35'
processorIds:
STRIPE: A3
BRAINTREE: B3
""";
private static final String ONETIME_CONFIG_YAML = """
boost:
level: 1
expiration: P45D
badge: BOOST
gift:
level: 100
expiration: P60D
badge: GIFT
currencies:
usd:
minimum: '2.50' # fractional to test BigDecimal conversion
gift: '20'
boosts:
- '5.50'
- '6'
- '7'
- '8'
- '9'
- '10'
eur:
minimum: '3'
gift: '5'
boosts:
- '5'
- '10'
- '20'
- '30'
- '50'
- '100'
jpy:
minimum: '250'
gift: '2000'
boosts:
- '550'
- '600'
- '700'
- '800'
- '900'
- '1000'
bif:
minimum: '2500'
gift: '20000'
boosts:
- '5500'
- '6000'
- '7000'
- '8000'
- '9000'
- '10000'
sepaMaximumEuros: '10000'
""";
}
}

View File

@@ -0,0 +1,303 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.controllers;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.when;
import com.stripe.model.PaymentIntent;
import io.dropwizard.auth.AuthValueFactoryProvider;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.core.Response;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Stream;
import org.assertj.core.api.InstanceOfAssertFactories;
import org.glassfish.jersey.server.ServerProperties;
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper;
import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
import org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator;
import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails;
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorException;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.SystemMapper;
@ExtendWith(DropwizardExtensionsSupport.class)
class OneTimeDonationControllerTest extends AbstractV1SubscriptionControllerTest {
private static final PaymentIntent PAYMENT_INTENT = mock(PaymentIntent.class);
private static final PayPalDonationsTranslator PAYPAL_ONE_TIME_DONATION_LINE_ITEM_TRANSLATOR = mock(
PayPalDonationsTranslator.class);
private static final OneTimeDonationsManager ONE_TIME_DONATIONS_MANAGER = mock(OneTimeDonationsManager.class);
private static final OneTimeDonationController ONE_TIME_CONTROLLER = new OneTimeDonationController(CLOCK,
ONETIME_CONFIG, STRIPE_MANAGER, BRAINTREE_MANAGER, PAYPAL_ONE_TIME_DONATION_LINE_ITEM_TRANSLATOR,
ZK_OPS, ISSUED_RECEIPTS_MANAGER, ONE_TIME_DONATIONS_MANAGER);
private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder()
.addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)
.addProvider(AuthHelper.getAuthFilter())
.addProvider(CompletionExceptionMapper.class)
.addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))
.addProvider(SubscriptionExceptionMapper.class)
.setMapper(SystemMapper.jsonMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(ONE_TIME_CONTROLLER)
.build();
@BeforeEach
void setUp() {
reset(CLOCK, STRIPE_MANAGER, BRAINTREE_MANAGER, ZK_OPS, PAYPAL_ONE_TIME_DONATION_LINE_ITEM_TRANSLATOR);
when(STRIPE_MANAGER.getProvider()).thenReturn(PaymentProvider.STRIPE);
when(BRAINTREE_MANAGER.getProvider()).thenReturn(PaymentProvider.BRAINTREE);
when(PAYPAL_ONE_TIME_DONATION_LINE_ITEM_TRANSLATOR.translate(any(), any())).thenReturn("Donation to Signal Technology Foundation");
List.of(STRIPE_MANAGER, BRAINTREE_MANAGER)
.forEach(manager -> when(manager.supportsPaymentMethod(any()))
.thenCallRealMethod());
when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.CARD))
.thenReturn(Set.of("usd", "jpy", "bif", "eur"));
when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.SEPA_DEBIT))
.thenReturn(Set.of("eur"));
when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.IDEAL))
.thenReturn(Set.of("eur"));
when(BRAINTREE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.PAYPAL))
.thenReturn(Set.of("usd", "jpy"));
}
@Test
void testCreateBoostPaymentIntentAmountBelowCurrencyMinimum() {
when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.CARD))
.thenReturn(Set.of("usd", "jpy", "bif", "eur"));
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create")
.request()
.post(Entity.json("""
{
"currency": "USD",
"amount": 249,
"level": null
}
"""));
assertThat(response.getStatus()).isEqualTo(400);
assertThat(response.hasEntity()).isTrue();
final Map responseMap = response.readEntity(Map.class);
assertThat(responseMap.get("error")).isEqualTo("amount_below_currency_minimum");
assertThat(responseMap.get("minimum")).isEqualTo("2.50");
}
@Test
void testCreateBoostPaymentIntentAmountAboveSepaLimit() {
when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.SEPA_DEBIT))
.thenReturn(Set.of("eur"));
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create")
.request()
.post(Entity.json("""
{
"currency": "EUR",
"amount": 1000001,
"level": null,
"paymentMethod": "SEPA_DEBIT"
}
"""));
assertThat(response.getStatus()).isEqualTo(400);
assertThat(response.hasEntity()).isTrue();
final Map responseMap = response.readEntity(Map.class);
assertThat(responseMap.get("error")).isEqualTo("amount_above_sepa_limit");
assertThat(responseMap.get("maximum")).isEqualTo("10000");
}
@Test
void testCreateBoostPaymentIntentUnsupportedCurrency() {
when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.SEPA_DEBIT))
.thenReturn(Set.of("eur"));
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create")
.request()
.post(Entity.json("""
{
"currency": "USD",
"amount": 3000,
"level": null,
"paymentMethod": "SEPA_DEBIT"
}
"""));
assertThat(response.getStatus()).isEqualTo(400);
assertThat(response.hasEntity()).isTrue();
final Map responseMap = response.readEntity(Map.class);
assertThat(responseMap.get("error")).isEqualTo("unsupported_currency");
}
@Test
void testCreateBoostPaymentIntentLevelAmountMismatch() {
when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.CARD))
.thenReturn(Set.of("usd", "jpy", "bif", "eur"));
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create")
.request()
.post(Entity.json("""
{
"currency": "USD",
"amount": 25,
"level": 100
}
"""
));
assertThat(response.getStatus()).isEqualTo(409);
}
@Test
void testCreateBoostPaymentIntent() {
when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.CARD))
.thenReturn(Set.of("usd", "jpy", "bif", "eur"));
when(STRIPE_MANAGER.createPaymentIntent(anyString(), anyLong(), anyLong(), any()))
.thenReturn(CompletableFuture.completedFuture(PAYMENT_INTENT));
String clientSecret = "some_client_secret";
when(PAYMENT_INTENT.getClientSecret()).thenReturn(clientSecret);
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create")
.request()
.post(Entity.json("{\"currency\": \"USD\", \"amount\": 300, \"level\": null}"));
assertThat(response.getStatus()).isEqualTo(200);
}
@Test
void testCreateBoostPayPal() {
final BraintreeManager.PayPalOneTimePaymentApprovalDetails payPalOneTimePaymentApprovalDetails = mock(
BraintreeManager.PayPalOneTimePaymentApprovalDetails.class);
when(BRAINTREE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.PAYPAL))
.thenReturn(Set.of("usd", "jpy", "bif", "eur"));
when(BRAINTREE_MANAGER.createOneTimePayment(anyString(), anyLong(), anyString(), anyString(), anyString(), anyString()))
.thenReturn(CompletableFuture.completedFuture(payPalOneTimePaymentApprovalDetails));
when(payPalOneTimePaymentApprovalDetails.approvalUrl()).thenReturn("approvalUrl");
when(payPalOneTimePaymentApprovalDetails.paymentId()).thenReturn("someId");
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/paypal/create")
.request()
.post(Entity.json("""
{
"currency": "USD",
"amount": 300,
"cancelUrl": "cancelUrl",
"returnUrl": "returnUrl"
}
"""
));
assertThat(response.getStatus()).isEqualTo(200);
}
@Test
void createBoostReceiptInvalid() {
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials")
.request()
// invalid, request body should have receiptCredentialRequest
.post(Entity.json("{\"paymentIntentId\": \"foo\"}"));
assertThat(response.getStatus()).isEqualTo(422);
}
@ParameterizedTest
@MethodSource
void createBoostReceiptPaymentRequired(final ChargeFailure chargeFailure, boolean expectChargeFailure) {
when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(new PaymentDetails(
"id",
Collections.emptyMap(),
PaymentStatus.FAILED,
Instant.now(),
chargeFailure)
));
Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials")
.request()
.post(Entity.json("""
{
"paymentIntentId": "foo",
"receiptCredentialRequest": "abcd",
"processor": "STRIPE"
}
"""));
assertThat(response.getStatus()).isEqualTo(402);
if (expectChargeFailure) {
assertThat(response.readEntity(OneTimeDonationController.CreateBoostReceiptCredentialsErrorResponse.class).chargeFailure()).isEqualTo(chargeFailure);
} else {
assertThat(response.readEntity(String.class)).isEqualTo("{}");
}
}
private static Stream<Arguments> createBoostReceiptPaymentRequired() {
return Stream.of(
Arguments.of(new ChargeFailure(
"generic_decline",
"some failure message",
null,
null,
null
), true),
Arguments.of(null, false)
);
}
@Test
void confirmPaypalBoostProcessorError() {
when(BRAINTREE_MANAGER.captureOneTimePayment(anyString(), anyString(), anyString(), anyString(), anyLong(),
anyLong(), any()))
.thenReturn(CompletableFuture.failedFuture(new SubscriptionProcessorException(PaymentProvider.BRAINTREE,
new ChargeFailure("2046", "Declined", null, null, null))));
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/paypal/confirm")
.request()
.post(Entity.json(Map.of("payerId", "payer123",
"paymentId", "PAYID-456",
"paymentToken", "EC-789",
"currency", "usd",
"amount", 123)));
assertThat(response.getStatus()).isEqualTo(SubscriptionExceptionMapper.PROCESSOR_ERROR_STATUS_CODE);
final Map responseMap = response.readEntity(Map.class);
assertThat(responseMap.get("processor")).isEqualTo("BRAINTREE");
assertThat(responseMap.get("chargeFailure")).asInstanceOf(
InstanceOfAssertFactories.map(String.class, Object.class))
.extracting("code")
.isEqualTo("2046");
}
@Test
void createBoostReceiptNoRequest() {
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials")
.request()
.post(Entity.json(""));
assertThat(response.getStatus()).isEqualTo(422);
}
}

View File

@@ -20,8 +20,6 @@ import static org.whispersystems.textsecuregcm.util.AttributeValues.b;
import static org.whispersystems.textsecuregcm.util.AttributeValues.n;
import static org.whispersystems.textsecuregcm.util.AttributeValues.s;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.stripe.model.PaymentIntent;
import io.dropwizard.auth.AuthValueFactoryProvider;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
@@ -29,12 +27,10 @@ import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -64,7 +60,6 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.badges.BadgeTranslator;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicBackupConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
@@ -75,27 +70,19 @@ import org.whispersystems.textsecuregcm.entities.BadgeSvg;
import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;
import org.whispersystems.textsecuregcm.storage.PaymentTime;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionChargeFailurePaymentRequiredException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionException;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
import org.whispersystems.textsecuregcm.storage.Subscriptions;
import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager;
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager.PayPalOneTimePaymentApprovalDetails;
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;
import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails;
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus;
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
import org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionChargeFailurePaymentRequiredException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidArgumentsException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionNotFoundException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiredException;
@@ -109,41 +96,24 @@ import org.whispersystems.textsecuregcm.util.SystemMapper;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
@ExtendWith(DropwizardExtensionsSupport.class)
class SubscriptionControllerTest {
private static final Clock CLOCK = mock(Clock.class);
private static final ObjectMapper YAML_MAPPER = SystemMapper.yamlMapper();
class SubscriptionControllerTest extends AbstractV1SubscriptionControllerTest {
private static final long MAX_TOTAL_BACKUP_MEDIA_BYTES = 1234L;
private static final SubscriptionConfiguration SUBSCRIPTION_CONFIG = ConfigHelper.getSubscriptionConfig();
private static final OneTimeDonationConfiguration ONETIME_CONFIG = ConfigHelper.getOneTimeConfig();
private static final Subscriptions SUBSCRIPTIONS = mock(Subscriptions.class);
private static final StripeManager STRIPE_MANAGER = MockUtils.buildMock(StripeManager.class, mgr ->
when(mgr.getProvider()).thenReturn(PaymentProvider.STRIPE));
private static final BraintreeManager BRAINTREE_MANAGER = MockUtils.buildMock(BraintreeManager.class, mgr ->
when(mgr.getProvider()).thenReturn(PaymentProvider.BRAINTREE));
private static final GooglePlayBillingManager PLAY_MANAGER = MockUtils.buildMock(GooglePlayBillingManager.class,
mgr -> when(mgr.getProvider()).thenReturn(PaymentProvider.GOOGLE_PLAY_BILLING));
private static final AppleAppStoreManager APPSTORE_MANAGER = MockUtils.buildMock(AppleAppStoreManager.class,
mgr -> when(mgr.getProvider()).thenReturn(PaymentProvider.APPLE_APP_STORE));
private static final PaymentIntent PAYMENT_INTENT = mock(PaymentIntent.class);
private static final ServerZkReceiptOperations ZK_OPS = mock(ServerZkReceiptOperations.class);
private static final IssuedReceiptsManager ISSUED_RECEIPTS_MANAGER = mock(IssuedReceiptsManager.class);
private static final OneTimeDonationsManager ONE_TIME_DONATIONS_MANAGER = mock(OneTimeDonationsManager.class);
private static final BadgeTranslator BADGE_TRANSLATOR = mock(BadgeTranslator.class);
private static final BankMandateTranslator BANK_MANDATE_TRANSLATOR = mock(BankMandateTranslator.class);
private static final PayPalDonationsTranslator PAYPAL_ONE_TIME_DONATION_LINE_ITEM_TRANSLATOR = mock(
PayPalDonationsTranslator.class);
private static final DynamicConfigurationManager<DynamicConfiguration> DYNAMIC_CONFIGURATION_MANAGER = mock(DynamicConfigurationManager.class);
private final static SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController(CLOCK,
SUBSCRIPTION_CONFIG, ONETIME_CONFIG,
new SubscriptionManager(SUBSCRIPTIONS, List.of(STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER, APPSTORE_MANAGER),
ZK_OPS, ISSUED_RECEIPTS_MANAGER), STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER, APPSTORE_MANAGER,
BADGE_TRANSLATOR, BANK_MANDATE_TRANSLATOR, DYNAMIC_CONFIGURATION_MANAGER);
private static final OneTimeDonationController ONE_TIME_CONTROLLER = new OneTimeDonationController(CLOCK,
ONETIME_CONFIG, STRIPE_MANAGER, BRAINTREE_MANAGER, PAYPAL_ONE_TIME_DONATION_LINE_ITEM_TRANSLATOR,
ZK_OPS, ISSUED_RECEIPTS_MANAGER, ONE_TIME_DONATIONS_MANAGER);
private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder()
.addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)
.addProvider(AuthHelper.getAuthFilter())
@@ -153,17 +123,14 @@ class SubscriptionControllerTest {
.setMapper(SystemMapper.jsonMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(SUBSCRIPTION_CONTROLLER)
.addResource(ONE_TIME_CONTROLLER)
.build();
@BeforeEach
void setUp() {
reset(CLOCK, SUBSCRIPTIONS, STRIPE_MANAGER, BRAINTREE_MANAGER, ZK_OPS, ISSUED_RECEIPTS_MANAGER, BADGE_TRANSLATOR,
PAYPAL_ONE_TIME_DONATION_LINE_ITEM_TRANSLATOR);
reset(CLOCK, SUBSCRIPTIONS, STRIPE_MANAGER, BRAINTREE_MANAGER, ZK_OPS, ISSUED_RECEIPTS_MANAGER, BADGE_TRANSLATOR);
when(STRIPE_MANAGER.getProvider()).thenReturn(PaymentProvider.STRIPE);
when(BRAINTREE_MANAGER.getProvider()).thenReturn(PaymentProvider.BRAINTREE);
when(PAYPAL_ONE_TIME_DONATION_LINE_ITEM_TRANSLATOR.translate(any(), any())).thenReturn("Donation to Signal Technology Foundation");
final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
when(dynamicConfiguration.getBackupConfiguration())
.thenReturn(new DynamicBackupConfiguration(null, null, null, null, MAX_TOTAL_BACKUP_MEDIA_BYTES));
@@ -182,210 +149,6 @@ class SubscriptionControllerTest {
.thenReturn(Set.of("usd", "jpy"));
}
@Test
void testCreateBoostPaymentIntentAmountBelowCurrencyMinimum() {
when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.CARD))
.thenReturn(Set.of("usd", "jpy", "bif", "eur"));
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create")
.request()
.post(Entity.json("""
{
"currency": "USD",
"amount": 249,
"level": null
}
"""));
assertThat(response.getStatus()).isEqualTo(400);
assertThat(response.hasEntity()).isTrue();
final Map responseMap = response.readEntity(Map.class);
assertThat(responseMap.get("error")).isEqualTo("amount_below_currency_minimum");
assertThat(responseMap.get("minimum")).isEqualTo("2.50");
}
@Test
void testCreateBoostPaymentIntentAmountAboveSepaLimit() {
when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.SEPA_DEBIT))
.thenReturn(Set.of("eur"));
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create")
.request()
.post(Entity.json("""
{
"currency": "EUR",
"amount": 1000001,
"level": null,
"paymentMethod": "SEPA_DEBIT"
}
"""));
assertThat(response.getStatus()).isEqualTo(400);
assertThat(response.hasEntity()).isTrue();
final Map responseMap = response.readEntity(Map.class);
assertThat(responseMap.get("error")).isEqualTo("amount_above_sepa_limit");
assertThat(responseMap.get("maximum")).isEqualTo("10000");
}
@Test
void testCreateBoostPaymentIntentUnsupportedCurrency() {
when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.SEPA_DEBIT))
.thenReturn(Set.of("eur"));
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create")
.request()
.post(Entity.json("""
{
"currency": "USD",
"amount": 3000,
"level": null,
"paymentMethod": "SEPA_DEBIT"
}
"""));
assertThat(response.getStatus()).isEqualTo(400);
assertThat(response.hasEntity()).isTrue();
final Map responseMap = response.readEntity(Map.class);
assertThat(responseMap.get("error")).isEqualTo("unsupported_currency");
}
@Test
void testCreateBoostPaymentIntentLevelAmountMismatch() {
when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.CARD))
.thenReturn(Set.of("usd", "jpy", "bif", "eur"));
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create")
.request()
.post(Entity.json("""
{
"currency": "USD",
"amount": 25,
"level": 100
}
"""
));
assertThat(response.getStatus()).isEqualTo(409);
}
@Test
void testCreateBoostPaymentIntent() {
when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.CARD))
.thenReturn(Set.of("usd", "jpy", "bif", "eur"));
when(STRIPE_MANAGER.createPaymentIntent(anyString(), anyLong(), anyLong(), any()))
.thenReturn(CompletableFuture.completedFuture(PAYMENT_INTENT));
String clientSecret = "some_client_secret";
when(PAYMENT_INTENT.getClientSecret()).thenReturn(clientSecret);
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create")
.request()
.post(Entity.json("{\"currency\": \"USD\", \"amount\": 300, \"level\": null}"));
assertThat(response.getStatus()).isEqualTo(200);
}
@Test
void testCreateBoostPayPal() {
final PayPalOneTimePaymentApprovalDetails payPalOneTimePaymentApprovalDetails = mock(PayPalOneTimePaymentApprovalDetails.class);
when(BRAINTREE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.PAYPAL))
.thenReturn(Set.of("usd", "jpy", "bif", "eur"));
when(BRAINTREE_MANAGER.createOneTimePayment(anyString(), anyLong(), anyString(), anyString(), anyString(), anyString()))
.thenReturn(CompletableFuture.completedFuture(payPalOneTimePaymentApprovalDetails));
when(payPalOneTimePaymentApprovalDetails.approvalUrl()).thenReturn("approvalUrl");
when(payPalOneTimePaymentApprovalDetails.paymentId()).thenReturn("someId");
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/paypal/create")
.request()
.post(Entity.json("""
{
"currency": "USD",
"amount": 300,
"cancelUrl": "cancelUrl",
"returnUrl": "returnUrl"
}
"""
));
assertThat(response.getStatus()).isEqualTo(200);
}
@Test
void createBoostReceiptInvalid() {
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials")
.request()
// invalid, request body should have receiptCredentialRequest
.post(Entity.json("{\"paymentIntentId\": \"foo\"}"));
assertThat(response.getStatus()).isEqualTo(422);
}
@ParameterizedTest
@MethodSource
void createBoostReceiptPaymentRequired(final ChargeFailure chargeFailure, boolean expectChargeFailure) {
when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(new PaymentDetails(
"id",
Collections.emptyMap(),
PaymentStatus.FAILED,
Instant.now(),
chargeFailure)
));
Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials")
.request()
.post(Entity.json("""
{
"paymentIntentId": "foo",
"receiptCredentialRequest": "abcd",
"processor": "STRIPE"
}
"""));
assertThat(response.getStatus()).isEqualTo(402);
if (expectChargeFailure) {
assertThat(response.readEntity(OneTimeDonationController.CreateBoostReceiptCredentialsErrorResponse.class).chargeFailure()).isEqualTo(chargeFailure);
} else {
assertThat(response.readEntity(String.class)).isEqualTo("{}");
}
}
private static Stream<Arguments> createBoostReceiptPaymentRequired() {
return Stream.of(
Arguments.of(new ChargeFailure(
"generic_decline",
"some failure message",
null,
null,
null
), true),
Arguments.of(null, false)
);
}
@Test
void confirmPaypalBoostProcessorError() {
when(BRAINTREE_MANAGER.captureOneTimePayment(anyString(), anyString(), anyString(), anyString(), anyLong(),
anyLong(), any()))
.thenReturn(CompletableFuture.failedFuture(new SubscriptionProcessorException(PaymentProvider.BRAINTREE,
new ChargeFailure("2046", "Declined", null, null, null))));
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/paypal/confirm")
.request()
.post(Entity.json(Map.of("payerId", "payer123",
"paymentId", "PAYID-456",
"paymentToken", "EC-789",
"currency", "usd",
"amount", 123)));
assertThat(response.getStatus()).isEqualTo(SubscriptionExceptionMapper.PROCESSOR_ERROR_STATUS_CODE);
final Map responseMap = response.readEntity(Map.class);
assertThat(responseMap.get("processor")).isEqualTo("BRAINTREE");
assertThat(responseMap.get("chargeFailure")).asInstanceOf(
InstanceOfAssertFactories.map(String.class, Object.class))
.extracting("code")
.isEqualTo("2046");
}
@Test
void createBoostReceiptNoRequest() {
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials")
.request()
.post(Entity.json(""));
assertThat(response.getStatus()).isEqualTo(422);
}
@Nested
class SetSubscriptionLevel {
@@ -1265,184 +1028,6 @@ class SubscriptionControllerTest {
});
}
/**
* Encapsulates {@code static} configuration, to keep the class header simpler and avoid illegal forward references
*/
private record ConfigHelper() {
private static SubscriptionConfiguration getSubscriptionConfig() {
return readValue(SUBSCRIPTION_CONFIG_YAML, SubscriptionConfiguration.class);
}
private static OneTimeDonationConfiguration getOneTimeConfig() {
return readValue(ONETIME_CONFIG_YAML, OneTimeDonationConfiguration.class);
}
private static <T> T readValue(String yaml, Class<T> type) {
try {
return YAML_MAPPER.readValue(yaml, type);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static final String SUBSCRIPTION_CONFIG_YAML = """
badgeExpiration: P30D
badgeGracePeriod: P15D
backupExpiration: P3D
backupGracePeriod: P10D
backupFreeTierMediaDuration: P30D
backupLevels:
201:
playProductId: testPlayProductId
mediaTtl: P40D
prices:
usd:
amount: '5'
processorIds:
STRIPE: R4
BRAINTREE: M4
jpy:
amount: '500'
processorIds:
STRIPE: Q4
BRAINTREE: N4
bif:
amount: '5000'
processorIds:
STRIPE: S4
BRAINTREE: O4
eur:
amount: '5'
processorIds:
STRIPE: A4
BRAINTREE: B4
levels:
5:
badge: B1
prices:
usd:
amount: '5'
processorIds:
STRIPE: R1
BRAINTREE: M1
jpy:
amount: '500'
processorIds:
STRIPE: Q1
BRAINTREE: N1
bif:
amount: '5000'
processorIds:
STRIPE: S1
BRAINTREE: O1
eur:
amount: '5'
processorIds:
STRIPE: A1
BRAINTREE: B1
15:
badge: B2
prices:
usd:
amount: '15'
processorIds:
STRIPE: R2
BRAINTREE: M2
jpy:
amount: '1500'
processorIds:
STRIPE: Q2
BRAINTREE: N2
bif:
amount: '15000'
processorIds:
STRIPE: S2
BRAINTREE: O2
eur:
amount: '15'
processorIds:
STRIPE: A2
BRAINTREE: B2
35:
badge: B3
prices:
usd:
amount: '35'
processorIds:
STRIPE: R3
BRAINTREE: M3
jpy:
amount: '3500'
processorIds:
STRIPE: Q3
BRAINTREE: N3
bif:
amount: '35000'
processorIds:
STRIPE: S3
BRAINTREE: O3
eur:
amount: '35'
processorIds:
STRIPE: A3
BRAINTREE: B3
""";
private static final String ONETIME_CONFIG_YAML = """
boost:
level: 1
expiration: P45D
badge: BOOST
gift:
level: 100
expiration: P60D
badge: GIFT
currencies:
usd:
minimum: '2.50' # fractional to test BigDecimal conversion
gift: '20'
boosts:
- '5.50'
- '6'
- '7'
- '8'
- '9'
- '10'
eur:
minimum: '3'
gift: '5'
boosts:
- '5'
- '10'
- '20'
- '30'
- '50'
- '100'
jpy:
minimum: '250'
gift: '2000'
boosts:
- '550'
- '600'
- '700'
- '800'
- '900'
- '1000'
bif:
minimum: '2500'
gift: '20000'
boosts:
- '5500'
- '6000'
- '7000'
- '8000'
- '9000'
- '10000'
sepaMaximumEuros: '10000'
""";
}
}