Make SubscriptionController synchronous

This commit is contained in:
ravi-signal
2025-09-02 15:11:05 -05:00
committed by GitHub
parent f52a262741
commit 774cc52b61
18 changed files with 1530 additions and 1381 deletions

View File

@@ -116,8 +116,8 @@ class DonationControllerTest {
when(receiptCredentialPresentation.getReceiptExpirationTime()).thenReturn(receiptExpiration);
when(redeemedReceiptsManager.put(same(receiptSerial), eq(receiptExpiration), eq(receiptLevel), eq(AuthHelper.VALID_UUID))).thenReturn(
CompletableFuture.completedFuture(Boolean.TRUE));
when(accountsManager.getByAccountIdentifierAsync(eq(AuthHelper.VALID_UUID))).thenReturn(
CompletableFuture.completedFuture(Optional.of(AuthHelper.VALID_ACCOUNT)));
when(accountsManager.getByAccountIdentifier(eq(AuthHelper.VALID_UUID)))
.thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));
RedeemReceiptRequest request = new RedeemReceiptRequest(presentation, true, true);
Response response = resources.getJerseyTest()
@@ -140,8 +140,8 @@ class DonationControllerTest {
when(receiptCredentialPresentation.getReceiptExpirationTime()).thenReturn(receiptExpiration);
when(redeemedReceiptsManager.put(same(receiptSerial), eq(receiptExpiration), eq(receiptLevel), eq(AuthHelper.VALID_UUID))).thenReturn(
CompletableFuture.completedFuture(Boolean.FALSE));
when(accountsManager.getByAccountIdentifierAsync(eq(AuthHelper.VALID_UUID))).thenReturn(
CompletableFuture.completedFuture(Optional.of(AuthHelper.VALID_ACCOUNT)));
when(accountsManager.getByAccountIdentifier(eq(AuthHelper.VALID_UUID)))
.thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));
RedeemReceiptRequest request = new RedeemReceiptRequest(presentation, true, true);
Response response = resources.getJerseyTest()

View File

@@ -28,6 +28,8 @@ 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.io.IOException;
import java.io.UncheckedIOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Clock;
@@ -42,7 +44,6 @@ import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.function.Predicate;
import java.util.stream.Stream;
import org.assertj.core.api.InstanceOfAssertFactories;
@@ -399,9 +400,9 @@ class SubscriptionControllerTest {
}
@Test
void createSubscriptionSuccess() {
void createSubscriptionSuccess() throws SubscriptionException {
when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong()))
.thenReturn(CompletableFuture.completedFuture(mock(CustomerAwareSubscriptionPaymentProcessor.SubscriptionId.class)));
.thenReturn(mock(CustomerAwareSubscriptionPaymentProcessor.SubscriptionId.class));
final String level = String.valueOf(levelId);
final String idempotencyKey = UUID.randomUUID().toString();
@@ -414,10 +415,10 @@ class SubscriptionControllerTest {
}
@Test
void createSubscriptionProcessorDeclined() {
void createSubscriptionProcessorDeclined() throws SubscriptionException {
when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong()))
.thenReturn(CompletableFuture.failedFuture(new SubscriptionException.ProcessorException(PaymentProvider.STRIPE,
new ChargeFailure("card_declined", "Insufficient funds", null, null, null))));
.thenThrow(new SubscriptionException.ProcessorException(PaymentProvider.STRIPE,
new ChargeFailure("card_declined", "Insufficient funds", null, null, null)));
final String level = String.valueOf(levelId);
final String idempotencyKey = UUID.randomUUID().toString();
@@ -489,11 +490,10 @@ class SubscriptionControllerTest {
}
@Test
void stripePaymentIntentRequiresAction() {
final ApiException stripeException = new ApiException("Payment intent requires action",
UUID.randomUUID().toString(), "subscription_payment_intent_requires_action", 400, new Exception());
void stripePaymentIntentRequiresAction()
throws SubscriptionException.InvalidArguments, SubscriptionException.ProcessorException {
when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong()))
.thenReturn(CompletableFuture.failedFuture(new CompletionException(stripeException)));
.thenThrow(new SubscriptionException.PaymentRequiresAction());
final String level = String.valueOf(levelId);
final String idempotencyKey = UUID.randomUUID().toString();
@@ -624,7 +624,7 @@ class SubscriptionControllerTest {
final ProcessorCustomer customer = new ProcessorCustomer(
customerId, PaymentProvider.STRIPE);
when(STRIPE_MANAGER.createCustomer(any(), any()))
.thenReturn(CompletableFuture.completedFuture(customer));
.thenReturn(customer);
final Map<String, AttributeValue> dynamoItemWithProcessorCustomer = new HashMap<>(dynamoItem);
dynamoItemWithProcessorCustomer.put(Subscriptions.KEY_PROCESSOR_ID_CUSTOMER_ID,
@@ -638,7 +638,7 @@ class SubscriptionControllerTest {
final String clientSecret = "some-client-secret";
when(STRIPE_MANAGER.createPaymentMethodSetupToken(customerId))
.thenReturn(CompletableFuture.completedFuture(clientSecret));
.thenReturn(clientSecret);
final SubscriptionController.CreatePaymentMethodResponse createPaymentMethodResponse = RESOURCE_EXTENSION
.target(String.format("/v1/subscription/%s/create_payment_method", subscriberId))
@@ -687,7 +687,8 @@ class SubscriptionControllerTest {
"35, M3",
"201, M4",
})
void setSubscriptionLevel(long levelId, String expectedProcessorId) {
void setSubscriptionLevel(long levelId, String expectedProcessorId)
throws SubscriptionException.ProcessorConflict, SubscriptionException.ProcessorException {
// set up record
final byte[] subscriberUserAndKey = new byte[32];
Arrays.fill(subscriberUserAndKey, (byte) 1);
@@ -711,8 +712,7 @@ class SubscriptionControllerTest {
.thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));
when(BRAINTREE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong()))
.thenReturn(CompletableFuture.completedFuture(new CustomerAwareSubscriptionPaymentProcessor.SubscriptionId(
"subscription")));
.thenReturn(new CustomerAwareSubscriptionPaymentProcessor.SubscriptionId("subscription"));
when(SUBSCRIPTIONS.subscriptionCreated(any(), any(), any(), anyLong()))
.thenReturn(CompletableFuture.completedFuture(null));
@@ -734,7 +734,8 @@ class SubscriptionControllerTest {
@ParameterizedTest
@MethodSource
void setSubscriptionLevelExistingSubscription(final String existingCurrency, final long existingLevel,
final String requestCurrency, final long requestLevel, final boolean expectUpdate) {
final String requestCurrency, final long requestLevel, final boolean expectUpdate)
throws SubscriptionException.ProcessorConflict, SubscriptionException.ProcessorException {
// set up record
final byte[] subscriberUserAndKey = new byte[32];
@@ -761,17 +762,14 @@ class SubscriptionControllerTest {
.thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));
final Object subscriptionObj = new Object();
when(BRAINTREE_MANAGER.getSubscription(any()))
.thenReturn(CompletableFuture.completedFuture(subscriptionObj));
when(BRAINTREE_MANAGER.getSubscription(any())).thenReturn(subscriptionObj);
when(BRAINTREE_MANAGER.getLevelAndCurrencyForSubscription(subscriptionObj))
.thenReturn(CompletableFuture.completedFuture(
new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(existingLevel, existingCurrency)));
.thenReturn(new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(existingLevel, existingCurrency));
final String updatedSubscriptionId = "updatedSubscriptionId";
if (expectUpdate) {
when(BRAINTREE_MANAGER.updateSubscription(any(), any(), anyLong(), anyString()))
.thenReturn(CompletableFuture.completedFuture(new CustomerAwareSubscriptionPaymentProcessor.SubscriptionId(
updatedSubscriptionId)));
.thenReturn(new CustomerAwareSubscriptionPaymentProcessor.SubscriptionId(updatedSubscriptionId));
when(SUBSCRIPTIONS.subscriptionLevelChanged(any(), any(), anyLong(), anyString()))
.thenReturn(CompletableFuture.completedFuture(null));
}
@@ -836,11 +834,9 @@ class SubscriptionControllerTest {
.thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));
final Object subscriptionObj = new Object();
when(BRAINTREE_MANAGER.getSubscription(any()))
.thenReturn(CompletableFuture.completedFuture(subscriptionObj));
when(BRAINTREE_MANAGER.getSubscription(any())).thenReturn(subscriptionObj);
when(BRAINTREE_MANAGER.getLevelAndCurrencyForSubscription(subscriptionObj))
.thenReturn(CompletableFuture.completedFuture(
new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(201, "usd")));
.thenReturn(new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(201, "usd"));
// Try to change from a backup subscription (201) to a donation subscription (5)
final Response response = RESOURCE_EXTENSION
@@ -857,7 +853,8 @@ class SubscriptionControllerTest {
}
@Test
public void setAppStoreTransactionId() {
public void setAppStoreTransactionId()
throws SubscriptionException.InvalidArguments, SubscriptionException.PaymentRequired, RateLimitExceededException, SubscriptionException.NotFound {
final String originalTxId = "aTxId";
final byte[] subscriberUserAndKey = new byte[32];
Arrays.fill(subscriberUserAndKey, (byte) 1);
@@ -877,7 +874,7 @@ class SubscriptionControllerTest {
.thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));
when(APPSTORE_MANAGER.validateTransaction(eq(originalTxId)))
.thenReturn(CompletableFuture.completedFuture(99L));
.thenReturn(99L);
when(SUBSCRIPTIONS.setIapPurchase(any(), any(), anyString(), anyLong(), any()))
.thenReturn(CompletableFuture.completedFuture(null));
@@ -900,7 +897,7 @@ class SubscriptionControllerTest {
@Test
public void setPlayPurchaseToken() {
public void setPlayPurchaseToken() throws RateLimitExceededException, SubscriptionException {
final String purchaseToken = "aPurchaseToken";
final byte[] subscriberUserAndKey = new byte[32];
Arrays.fill(subscriberUserAndKey, (byte) 1);
@@ -920,8 +917,7 @@ class SubscriptionControllerTest {
final GooglePlayBillingManager.ValidatedToken validatedToken = mock(GooglePlayBillingManager.ValidatedToken.class);
when(validatedToken.getLevel()).thenReturn(99L);
when(validatedToken.acknowledgePurchase()).thenReturn(CompletableFuture.completedFuture(null));
when(PLAY_MANAGER.validateToken(eq(purchaseToken))).thenReturn(CompletableFuture.completedFuture(validatedToken));
when(PLAY_MANAGER.validateToken(eq(purchaseToken))).thenReturn(validatedToken);
when(SUBSCRIPTIONS.setIapPurchase(any(), any(), anyString(), anyLong(), any()))
.thenReturn(CompletableFuture.completedFuture(null));
@@ -943,7 +939,7 @@ class SubscriptionControllerTest {
}
@Test
public void replacePlayPurchaseToken() {
public void replacePlayPurchaseToken() throws RateLimitExceededException, SubscriptionException {
final String oldPurchaseToken = "oldPurchaseToken";
final String newPurchaseToken = "newPurchaseToken";
final byte[] subscriberUserAndKey = new byte[32];
@@ -965,11 +961,8 @@ class SubscriptionControllerTest {
final GooglePlayBillingManager.ValidatedToken validatedToken = mock(GooglePlayBillingManager.ValidatedToken.class);
when(validatedToken.getLevel()).thenReturn(99L);
when(validatedToken.acknowledgePurchase()).thenReturn(CompletableFuture.completedFuture(null));
when(PLAY_MANAGER.validateToken(eq(newPurchaseToken))).thenReturn(CompletableFuture.completedFuture(validatedToken));
when(PLAY_MANAGER.cancelAllActiveSubscriptions(eq(oldPurchaseToken)))
.thenReturn(CompletableFuture.completedFuture(null));
when(PLAY_MANAGER.validateToken(eq(newPurchaseToken))).thenReturn(validatedToken);
when(SUBSCRIPTIONS.setIapPurchase(any(), any(), anyString(), anyLong(), any()))
.thenReturn(CompletableFuture.completedFuture(null));
@@ -993,7 +986,8 @@ class SubscriptionControllerTest {
}
@Test
void createReceiptChargeFailure() throws InvalidInputException, VerificationFailedException {
void createReceiptChargeFailure()
throws InvalidInputException, VerificationFailedException, SubscriptionException {
final byte[] subscriberUserAndKey = new byte[32];
Arrays.fill(subscriberUserAndKey, (byte) 1);
final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);
@@ -1009,9 +1003,9 @@ class SubscriptionControllerTest {
b(new ProcessorCustomer("customer", PaymentProvider.STRIPE).toDynamoBytes()),
Subscriptions.KEY_SUBSCRIPTION_ID, s("subscriptionId"))))));
when(STRIPE_MANAGER.getReceiptItem(any()))
.thenReturn(CompletableFuture.failedFuture(new SubscriptionException.ChargeFailurePaymentRequired(
.thenThrow(new SubscriptionException.ChargeFailurePaymentRequired(
PaymentProvider.STRIPE,
new ChargeFailure("card_declined", "Insufficient funds", null, null, null))));
new ChargeFailure("card_declined", "Insufficient funds", null, null, null)));
final ReceiptCredentialRequest receiptRequest = new ClientZkReceiptOperations(
ServerSecretParams.generate().getPublicParams()).createReceiptCredentialRequestContext(
@@ -1033,7 +1027,7 @@ class SubscriptionControllerTest {
@ParameterizedTest
@CsvSource({"5, P45D", "201, P13D"})
public void createReceiptCredential(long level, Duration expectedExpirationWindow)
throws InvalidInputException, VerificationFailedException {
throws InvalidInputException, VerificationFailedException, SubscriptionException.ChargeFailurePaymentRequired, SubscriptionException.ReceiptRequestedForOpenPayment {
final byte[] subscriberUserAndKey = new byte[32];
Arrays.fill(subscriberUserAndKey, (byte) 1);
final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);
@@ -1057,11 +1051,10 @@ class SubscriptionControllerTest {
when(SUBSCRIPTIONS.get(any(), any()))
.thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));
when(BRAINTREE_MANAGER.getReceiptItem(subscriptionId)).thenReturn(
CompletableFuture.completedFuture(new CustomerAwareSubscriptionPaymentProcessor.ReceiptItem(
new CustomerAwareSubscriptionPaymentProcessor.ReceiptItem(
"itemId",
PaymentTime.periodStart(Instant.ofEpochSecond(10).plus(Duration.ofDays(1))),
level
)));
level));
when(ISSUED_RECEIPTS_MANAGER.recordIssuance(eq("itemId"), eq(PaymentProvider.BRAINTREE), eq(receiptRequest), any()))
.thenReturn(CompletableFuture.completedFuture(null));
when(ZK_OPS.issueReceiptCredential(any(), anyLong(), eq(level))).thenReturn(receiptCredentialResponse);

View File

@@ -6,6 +6,8 @@
package org.whispersystems.textsecuregcm.subscriptions;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatException;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
@@ -26,6 +28,7 @@ import com.apple.itunes.storekit.model.SubscriptionGroupIdentifierItem;
import com.apple.itunes.storekit.verification.SignedDataVerifier;
import com.apple.itunes.storekit.verification.VerificationException;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
@@ -39,8 +42,8 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil;
class AppleAppStoreManagerTest {
@@ -54,27 +57,19 @@ class AppleAppStoreManagerTest {
private final AppStoreServerAPIClient apiClient = mock(AppStoreServerAPIClient.class);
private final SignedDataVerifier signedDataVerifier = mock(SignedDataVerifier.class);
private ScheduledExecutorService executor;
private AppleAppStoreManager appleAppStoreManager;
@BeforeEach
public void setup() {
reset(apiClient, signedDataVerifier);
executor = Executors.newSingleThreadScheduledExecutor();
appleAppStoreManager = new AppleAppStoreManager(apiClient, signedDataVerifier,
SUBSCRIPTION_GROUP_ID, Map.of(PRODUCT_ID, LEVEL), null, executor, executor);
}
@AfterEach
public void teardown() throws InterruptedException {
executor.shutdownNow();
executor.awaitTermination(1, TimeUnit.SECONDS);
SUBSCRIPTION_GROUP_ID, Map.of(PRODUCT_ID, LEVEL), null);
}
@Test
public void lookupTransaction() throws APIException, IOException, VerificationException {
public void lookupTransaction() throws APIException, IOException, VerificationException, SubscriptionException, RateLimitExceededException {
mockValidSubscription();
final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID).join();
final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID);
assertThat(info.active()).isTrue();
assertThat(info.paymentProcessing()).isFalse();
@@ -85,15 +80,17 @@ class AppleAppStoreManagerTest {
}
@Test
public void validateTransaction() throws VerificationException, APIException, IOException {
public void validateTransaction()
throws VerificationException, APIException, IOException, SubscriptionException, RateLimitExceededException {
mockValidSubscription();
assertThat(appleAppStoreManager.validateTransaction(ORIGINAL_TX_ID).join()).isEqualTo(LEVEL);
assertThat(appleAppStoreManager.validateTransaction(ORIGINAL_TX_ID)).isEqualTo(LEVEL);
}
@Test
public void generateReceipt() throws VerificationException, APIException, IOException {
public void generateReceipt()
throws VerificationException, APIException, IOException, SubscriptionException, RateLimitExceededException {
mockValidSubscription();
final SubscriptionPaymentProcessor.ReceiptItem receipt = appleAppStoreManager.getReceiptItem(ORIGINAL_TX_ID).join();
final SubscriptionPaymentProcessor.ReceiptItem receipt = appleAppStoreManager.getReceiptItem(ORIGINAL_TX_ID);
assertThat(receipt.level()).isEqualTo(LEVEL);
assertThat(receipt.paymentTime().receiptExpiration(Duration.ofDays(1), Duration.ZERO))
.isEqualTo(Instant.EPOCH.plus(Duration.ofDays(2)));
@@ -101,16 +98,17 @@ class AppleAppStoreManagerTest {
}
@Test
public void generateReceiptExpired() throws VerificationException, APIException, IOException {
public void generateReceiptExpired()
throws VerificationException, APIException, IOException {
mockSubscription(Status.EXPIRED, AutoRenewStatus.ON);
CompletableFutureTestUtil.assertFailsWithCause(SubscriptionException.PaymentRequired.class,
appleAppStoreManager.getReceiptItem(ORIGINAL_TX_ID));
assertThatExceptionOfType(SubscriptionException.PaymentRequired.class)
.isThrownBy(() -> appleAppStoreManager.getReceiptItem(ORIGINAL_TX_ID));
}
@Test
public void autoRenewOff() throws VerificationException, APIException, IOException {
public void autoRenewOff() throws VerificationException, APIException, IOException, SubscriptionException, RateLimitExceededException {
mockSubscription(Status.ACTIVE, AutoRenewStatus.OFF);
final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID).join();
final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID);
assertThat(info.cancelAtPeriodEnd()).isTrue();
@@ -121,7 +119,7 @@ class AppleAppStoreManagerTest {
}
@Test
public void lookupMultipleProducts() throws APIException, IOException, VerificationException {
public void lookupMultipleProducts() throws APIException, IOException, VerificationException, RateLimitExceededException, SubscriptionException {
// The lookup should select the transaction at i=1
final List<String> products = List.of("otherProduct1", PRODUCT_ID, "otherProduct3");
@@ -149,14 +147,14 @@ class AppleAppStoreManagerTest {
.originalPurchaseDate(Instant.EPOCH.toEpochMilli())
.expiresDate(Instant.EPOCH.plus(Duration.ofDays(1)).toEpochMilli()));
}
final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID).join();
final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID);
assertThat(info.price().amount().compareTo(new BigDecimal("100"))).isEqualTo(0);
}
@Test
public void retryEventuallyWorks() throws APIException, IOException, VerificationException {
public void retryEventuallyWorks() throws APIException, IOException, VerificationException, RateLimitExceededException, SubscriptionException {
// Should retry up to 3 times
when(apiClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}))
.thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), "test"))
@@ -169,7 +167,7 @@ class AppleAppStoreManagerTest {
.signedRenewalInfo(SIGNED_RENEWAL_INFO)
.signedTransactionInfo(SIGNED_TX_INFO)))));
mockDecode(AutoRenewStatus.ON);
final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID).join();
final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID);
assertThat(info.status()).isEqualTo(SubscriptionStatus.ACTIVE);
}
@@ -179,8 +177,10 @@ class AppleAppStoreManagerTest {
when(apiClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}))
.thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), "test"));
mockDecode(AutoRenewStatus.ON);
CompletableFutureTestUtil.assertFailsWithCause(APIException.class,
appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID));
assertThatException()
.isThrownBy(() -> appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID))
.isInstanceOf(UncheckedIOException.class)
.withRootCauseInstanceOf(APIException.class);
verify(apiClient, times(3)).getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{});
@@ -189,22 +189,22 @@ class AppleAppStoreManagerTest {
@Test
public void cancelRenewalDisabled() throws APIException, VerificationException, IOException {
mockSubscription(Status.ACTIVE, AutoRenewStatus.OFF);
assertDoesNotThrow(() -> appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID).join());
assertDoesNotThrow(() -> appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID));
}
@ParameterizedTest
@EnumSource(mode = EnumSource.Mode.EXCLUDE, names = {"EXPIRED", "REVOKED"})
public void cancelFailsForActiveSubscription(Status status) throws APIException, VerificationException, IOException {
mockSubscription(status, AutoRenewStatus.ON);
CompletableFutureTestUtil.assertFailsWithCause(SubscriptionException.InvalidArguments.class,
appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID));
assertThatExceptionOfType(SubscriptionException.InvalidArguments.class)
.isThrownBy(() -> appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID));
}
@ParameterizedTest
@EnumSource(mode = EnumSource.Mode.INCLUDE, names = {"EXPIRED", "REVOKED"})
public void cancelInactiveStatus(Status status) throws APIException, VerificationException, IOException {
mockSubscription(status, AutoRenewStatus.ON);
assertDoesNotThrow(() -> appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID).join());
assertDoesNotThrow(() -> appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID));
}
private void mockSubscription(final Status status, final AutoRenewStatus autoRenewStatus)

View File

@@ -51,6 +51,6 @@ class BraintreeManagerTest {
when(braintreeGateway.customer()).thenReturn(customerGateway);
assertTimeoutPreemptively(Duration.ofSeconds(5), () ->
braintreeManager.cancelAllActiveSubscriptions("customerId")).join();
braintreeManager.cancelAllActiveSubscriptions("customerId"));
}
}

View File

@@ -5,6 +5,8 @@
package org.whispersystems.textsecuregcm.subscriptions;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatException;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
@@ -31,12 +33,7 @@ import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
@@ -45,7 +42,6 @@ import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil;
import org.whispersystems.textsecuregcm.util.MockUtils;
import org.whispersystems.textsecuregcm.util.MutableClock;
@@ -74,7 +70,6 @@ class GooglePlayBillingManagerTest {
private final MutableClock clock = MockUtils.mutableClock(0L);
private ExecutorService executor;
private GooglePlayBillingManager googlePlayBillingManager;
@BeforeEach
@@ -105,19 +100,12 @@ class GooglePlayBillingManagerTest {
when(monetization.subscriptions()).thenReturn(msubscriptions);
when(msubscriptions.get(PACKAGE_NAME, PRODUCT_ID)).thenReturn(subscriptionConfig);
executor = Executors.newSingleThreadExecutor();
googlePlayBillingManager = new GooglePlayBillingManager(
androidPublisher, clock, PACKAGE_NAME, Map.of(PRODUCT_ID, 201L), executor);
}
@AfterEach
public void teardown() throws InterruptedException {
executor.shutdownNow();
executor.awaitTermination(1, TimeUnit.SECONDS);
androidPublisher, clock, PACKAGE_NAME, Map.of(PRODUCT_ID, 201L));
}
@Test
public void validatePurchase() throws IOException {
public void validatePurchase() throws IOException, RateLimitExceededException, SubscriptionException {
when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()
.setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.PENDING.apiString())
.setSubscriptionState(GooglePlayBillingManager.SubscriptionState.ACTIVE.apiString())
@@ -125,11 +113,10 @@ class GooglePlayBillingManagerTest {
.setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())
.setProductId(PRODUCT_ID))));
final GooglePlayBillingManager.ValidatedToken result = googlePlayBillingManager
.validateToken(PURCHASE_TOKEN).join();
final GooglePlayBillingManager.ValidatedToken result = googlePlayBillingManager.validateToken(PURCHASE_TOKEN);
assertThat(result.getLevel()).isEqualTo(201);
assertThatNoException().isThrownBy(() -> result.acknowledgePurchase().join());
assertThatNoException().isThrownBy(result::acknowledgePurchase);
verify(acknowledge, times(1)).execute();
}
@@ -143,16 +130,16 @@ class GooglePlayBillingManagerTest {
.setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())
.setProductId(PRODUCT_ID))));
final CompletableFuture<GooglePlayBillingManager.ValidatedToken> future = googlePlayBillingManager
.validateToken(PURCHASE_TOKEN);
switch (subscriptionState) {
case ACTIVE, IN_GRACE_PERIOD, CANCELED -> assertThatNoException().isThrownBy(() -> future.join());
default -> CompletableFutureTestUtil.assertFailsWithCause(SubscriptionException.PaymentRequired.class, future);
case ACTIVE, IN_GRACE_PERIOD, CANCELED -> assertThatNoException()
.isThrownBy(() -> googlePlayBillingManager.validateToken(PURCHASE_TOKEN));
default -> assertThatExceptionOfType(SubscriptionException.PaymentRequired.class)
.isThrownBy(() -> googlePlayBillingManager.validateToken(PURCHASE_TOKEN));
}
}
@Test
public void avoidDoubleAcknowledge() throws IOException {
public void avoidDoubleAcknowledge() throws IOException, RateLimitExceededException, SubscriptionException {
when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()
.setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.ACKNOWLEDGED.apiString())
.setSubscriptionState(GooglePlayBillingManager.SubscriptionState.ACTIVE.apiString())
@@ -160,11 +147,10 @@ class GooglePlayBillingManagerTest {
.setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())
.setProductId(PRODUCT_ID))));
final GooglePlayBillingManager.ValidatedToken result = googlePlayBillingManager
.validateToken(PURCHASE_TOKEN).join();
final GooglePlayBillingManager.ValidatedToken result = googlePlayBillingManager.validateToken(PURCHASE_TOKEN);
assertThat(result.getLevel()).isEqualTo(201);
assertThatNoException().isThrownBy(() -> result.acknowledgePurchase().join());
assertThatNoException().isThrownBy(result::acknowledgePurchase);
verifyNoInteractions(acknowledge);
}
@@ -178,7 +164,7 @@ class GooglePlayBillingManagerTest {
.setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())
.setProductId(PRODUCT_ID))));
assertThatNoException().isThrownBy(() ->
googlePlayBillingManager.cancelAllActiveSubscriptions(PURCHASE_TOKEN).join());
googlePlayBillingManager.cancelAllActiveSubscriptions(PURCHASE_TOKEN));
final int wanted = switch (subscriptionState) {
case CANCELED, EXPIRED -> 0;
default -> 1;
@@ -192,7 +178,7 @@ class GooglePlayBillingManagerTest {
when(mockException.getStatusCode()).thenReturn(404);
when(subscriptionsv2Get.execute()).thenThrow(mockException);
assertThatNoException().isThrownBy(() ->
googlePlayBillingManager.cancelAllActiveSubscriptions(PURCHASE_TOKEN).join());
googlePlayBillingManager.cancelAllActiveSubscriptions(PURCHASE_TOKEN));
verifyNoInteractions(cancel);
}
@@ -201,8 +187,8 @@ class GooglePlayBillingManagerTest {
final HttpResponseException mockException = mock(HttpResponseException.class);
when(mockException.getStatusCode()).thenReturn(429);
when(subscriptionsv2Get.execute()).thenThrow(mockException);
CompletableFutureTestUtil.assertFailsWithCause(
RateLimitExceededException.class, googlePlayBillingManager.getSubscriptionInformation(PURCHASE_TOKEN));
assertThatExceptionOfType(RateLimitExceededException.class).isThrownBy(() ->
googlePlayBillingManager.getSubscriptionInformation(PURCHASE_TOKEN));
}
@Test
@@ -213,13 +199,13 @@ class GooglePlayBillingManagerTest {
.setLineItems(List.of(new SubscriptionPurchaseLineItem()
.setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())
.setProductId(PRODUCT_ID))));
CompletableFutureTestUtil.assertFailsWithCause(
IllegalStateException.class,
assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() ->
googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN));
}
@Test
public void getReceiptExpiring() throws IOException {
public void getReceiptExpiring()
throws IOException, RateLimitExceededException, SubscriptionException {
final Instant day9 = Instant.EPOCH.plus(Duration.ofDays(9));
final Instant day10 = Instant.EPOCH.plus(Duration.ofDays(10));
@@ -232,7 +218,7 @@ class GooglePlayBillingManagerTest {
.setProductId(PRODUCT_ID))));
clock.setTimeInstant(day9);
SubscriptionPaymentProcessor.ReceiptItem item = googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN).join();
SubscriptionPaymentProcessor.ReceiptItem item = googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN);
assertThat(item.itemId()).isEqualTo(ORDER_ID);
assertThat(item.level()).isEqualTo(201L);
@@ -242,19 +228,18 @@ class GooglePlayBillingManagerTest {
// should still be able to get a receipt the next day
clock.setTimeInstant(day10);
item = googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN).join();
item = googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN);
assertThat(item.itemId()).isEqualTo(ORDER_ID);
// next second should be expired
clock.setTimeInstant(day10.plus(Duration.ofSeconds(1)));
CompletableFutureTestUtil.assertFailsWithCause(
SubscriptionException.PaymentRequired.class,
googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN));
assertThatExceptionOfType(SubscriptionException.PaymentRequired.class)
.isThrownBy(() -> googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN));
}
@Test
public void getSubscriptionInfo() throws IOException {
public void getSubscriptionInfo() throws IOException, RateLimitExceededException, SubscriptionException {
final String basePlanId = "basePlanId";
when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()
.setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.ACKNOWLEDGED.apiString())
@@ -275,7 +260,7 @@ class GooglePlayBillingManagerTest {
.setPrice(new Money().setCurrencyCode("USD").setUnits(1L).setNanos(750_000_000))));
when(subscriptionConfig.execute()).thenReturn(new Subscription().setBasePlans(List.of(basePlan)));
final SubscriptionInformation info = googlePlayBillingManager.getSubscriptionInformation(PURCHASE_TOKEN).join();
final SubscriptionInformation info = googlePlayBillingManager.getSubscriptionInformation(PURCHASE_TOKEN);
assertThat(info.active()).isTrue();
assertThat(info.paymentProcessing()).isFalse();
assertThat(info.price().currency()).isEqualTo("USD");
@@ -298,9 +283,17 @@ class GooglePlayBillingManagerTest {
final HttpResponseException mockException = mock(HttpResponseException.class);
when(mockException.getStatusCode()).thenReturn(httpStatus);
when(subscriptionsv2Get.execute()).thenThrow(mockException);
CompletableFutureTestUtil.assertFailsWithCause(expected,
googlePlayBillingManager.getSubscriptionInformation(PURCHASE_TOKEN));
assertThatException()
.isThrownBy(() -> googlePlayBillingManager.getSubscriptionInformation(PURCHASE_TOKEN))
// Verify the exception or its leaf cause is an instanceof expected. withRootCauseInstanceOf almost does what we
// want, but fails if the outermost exception does not have a cause
.matches(e -> {
Throwable cause = e;
while (cause.getCause() != null) {
cause = cause.getCause();
}
return expected.isInstance(cause);
});
}
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.subscriptions;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.braintreegateway.BraintreeGateway;
import com.braintreegateway.Customer;
import com.braintreegateway.CustomerGateway;
import com.google.cloud.pubsub.v1.Publisher;
import com.stripe.StripeClient;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import com.stripe.exception.ApiException;
import com.stripe.exception.StripeException;
import com.stripe.service.SubscriptionService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
class StripeManagerTest {
private StripeClient stripeClient;
private StripeManager stripeManager;
private ExecutorService executor;
@BeforeEach
void setup() {
this.executor = Executors.newSingleThreadExecutor();
this.stripeClient = mock(StripeClient.class);
this.stripeManager = new StripeManager(
this.stripeClient,
executor,
"idempotencyKey".getBytes(StandardCharsets.UTF_8),
"boost",
Map.of(PaymentMethod.CARD, Set.of("usd")));
}
@AfterEach
void teardown() throws InterruptedException {
this.executor.shutdownNow();
this.executor.awaitTermination(1, TimeUnit.SECONDS);
}
@Test
void paymentRequiresAction() throws StripeException {
final SubscriptionService subscriptionService = mock(SubscriptionService.class);
final ApiException stripeException = new ApiException("Payment intent requires action",
UUID.randomUUID().toString(), "subscription_payment_intent_requires_action", 400, new Exception());
when(subscriptionService.create(any(), any())).thenThrow(stripeException);
when(stripeClient.subscriptions()).thenReturn(subscriptionService);
assertThatExceptionOfType(SubscriptionException.PaymentRequiresAction.class).isThrownBy(() ->
stripeManager.createSubscription("customerId", "priceId", 1, 0));
}
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatNoException;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Stream;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class ExecutorUtilTest {
private ExecutorService executor;
@BeforeEach
void setUp() {
this.executor = Executors.newSingleThreadExecutor();
}
@AfterEach
void tearDown() throws InterruptedException {
this.executor.shutdown();
this.executor.awaitTermination(1, TimeUnit.SECONDS);
}
@Test
void runAllWaits() {
final AtomicLong c = new AtomicLong(5);
ExecutorUtil.runAll(executor, Stream
.<Runnable>generate(() -> () -> {
Util.sleep(1);
c.decrementAndGet();
})
.limit(5)
.toList());
assertThat(c.get()).isEqualTo(0);
}
@Test
void runAllWithException() {
assertThatExceptionOfType(IllegalStateException.class)
.isThrownBy(() -> ExecutorUtil.runAll(executor, List.of(Util.NOOP, Util.NOOP, () -> {
throw new IllegalStateException("oof");
})));
}
@Test
void runAllEmpty() {
assertThatNoException().isThrownBy(() -> ExecutorUtil.runAll(executor, List.of()));
}
}