diff --git a/domain/src/test/java/org/cryptomator/domain/VaultTest.java b/domain/src/test/java/org/cryptomator/domain/VaultTest.java new file mode 100644 index 000000000..4875ce318 --- /dev/null +++ b/domain/src/test/java/org/cryptomator/domain/VaultTest.java @@ -0,0 +1,258 @@ +package org.cryptomator.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class VaultTest { + + private static Vault buildMinimalVault() { + Cloud cloud = mock(Cloud.class); + when(cloud.type()).thenReturn(CloudType.WEBDAV); + return Vault.aVault() + .withId(1L) + .withName("Test") + .withPath("/test") + .withCloud(cloud) + .withPosition(0) + .build(); + } + + private static Vault.Builder minimalVaultBuilder() { + Cloud cloud = mock(Cloud.class); + when(cloud.type()).thenReturn(CloudType.WEBDAV); + return Vault.aVault() + .withId(1L) + .withName("Test") + .withPath("/test") + .withCloud(cloud) + .withPosition(0); + } + + @Nested + @DisplayName("Hub fields - default values") + class DefaultValues { + + @Test + @DisplayName("isHubVault defaults to false when not set") + void isHubVaultDefaultsFalse() { + Vault vault = buildMinimalVault(); + + assertThat(vault.isHubVault(), is(false)); + } + + @Test + @DisplayName("hasHubPaidLicense defaults to false when not set") + void hasHubPaidLicenseDefaultsFalse() { + Vault vault = buildMinimalVault(); + + assertThat(vault.hasHubPaidLicense(), is(false)); + } + } + + @Nested + @DisplayName("Hub fields - builder setter") + class BuilderSetters { + + @Test + @DisplayName("withHubVault(true) sets isHubVault to true") + void withHubVaultTrue() { + Vault vault = minimalVaultBuilder() + .withHubVault(true) + .build(); + + assertThat(vault.isHubVault(), is(true)); + } + + @Test + @DisplayName("withHubVault(false) keeps isHubVault false") + void withHubVaultFalse() { + Vault vault = minimalVaultBuilder() + .withHubVault(false) + .build(); + + assertThat(vault.isHubVault(), is(false)); + } + + @Test + @DisplayName("withHubPaidLicense(true) sets hasHubPaidLicense to true") + void withHubPaidLicenseTrue() { + Vault vault = minimalVaultBuilder() + .withHubPaidLicense(true) + .build(); + + assertThat(vault.hasHubPaidLicense(), is(true)); + } + + @Test + @DisplayName("withHubPaidLicense(false) keeps hasHubPaidLicense false") + void withHubPaidLicenseFalse() { + Vault vault = minimalVaultBuilder() + .withHubPaidLicense(false) + .build(); + + assertThat(vault.hasHubPaidLicense(), is(false)); + } + + @Test + @DisplayName("withHubVault and withHubPaidLicense can both be set independently") + void hubVaultAndPaidLicenseAreIndependent() { + Vault vaultHubOnly = minimalVaultBuilder() + .withHubVault(true) + .withHubPaidLicense(false) + .build(); + Vault vaultPaidOnly = minimalVaultBuilder() + .withHubVault(false) + .withHubPaidLicense(true) + .build(); + Vault vaultBoth = minimalVaultBuilder() + .withHubVault(true) + .withHubPaidLicense(true) + .build(); + + assertThat(vaultHubOnly.isHubVault(), is(true)); + assertThat(vaultHubOnly.hasHubPaidLicense(), is(false)); + + assertThat(vaultPaidOnly.isHubVault(), is(false)); + assertThat(vaultPaidOnly.hasHubPaidLicense(), is(true)); + + assertThat(vaultBoth.isHubVault(), is(true)); + assertThat(vaultBoth.hasHubPaidLicense(), is(true)); + } + } + + @Nested + @DisplayName("aCopyOf - hub field propagation") + class CopyOf { + + @Test + @DisplayName("aCopyOf preserves isHubVault=false") + void copyPreservesHubVaultFalse() { + Vault original = buildMinimalVault(); + + Vault copy = Vault.aCopyOf(original).build(); + + assertThat(copy.isHubVault(), is(false)); + } + + @Test + @DisplayName("aCopyOf preserves isHubVault=true") + void copyPreservesHubVaultTrue() { + Vault original = minimalVaultBuilder() + .withHubVault(true) + .build(); + + Vault copy = Vault.aCopyOf(original).build(); + + assertThat(copy.isHubVault(), is(true)); + } + + @Test + @DisplayName("aCopyOf preserves hasHubPaidLicense=false") + void copyPreservesHubPaidLicenseFalse() { + Vault original = buildMinimalVault(); + + Vault copy = Vault.aCopyOf(original).build(); + + assertThat(copy.hasHubPaidLicense(), is(false)); + } + + @Test + @DisplayName("aCopyOf preserves hasHubPaidLicense=true") + void copyPreservesHubPaidLicenseTrue() { + Vault original = minimalVaultBuilder() + .withHubPaidLicense(true) + .build(); + + Vault copy = Vault.aCopyOf(original).build(); + + assertThat(copy.hasHubPaidLicense(), is(true)); + } + + @Test + @DisplayName("aCopyOf allows overriding isHubVault after copy") + void copyAllowsOverridingHubVault() { + Vault original = minimalVaultBuilder() + .withHubVault(false) + .build(); + + Vault modified = Vault.aCopyOf(original) + .withHubVault(true) + .build(); + + assertThat(modified.isHubVault(), is(true)); + } + + @Test + @DisplayName("aCopyOf allows overriding hasHubPaidLicense after copy") + void copyAllowsOverridingHubPaidLicense() { + Vault original = minimalVaultBuilder() + .withHubPaidLicense(false) + .build(); + + Vault modified = Vault.aCopyOf(original) + .withHubPaidLicense(true) + .build(); + + assertThat(modified.hasHubPaidLicense(), is(true)); + } + + @Test + @DisplayName("aCopyOf with both hub fields set true preserves both in copy") + void copyPreservesBothHubFieldsTrue() { + Vault original = minimalVaultBuilder() + .withHubVault(true) + .withHubPaidLicense(true) + .build(); + + Vault copy = Vault.aCopyOf(original).build(); + + assertThat(copy.isHubVault(), is(true)); + assertThat(copy.hasHubPaidLicense(), is(true)); + } + + @Test + @DisplayName("aCopyOf does not mutate the original vault") + void copyDoesNotMutateOriginal() { + Vault original = minimalVaultBuilder() + .withHubVault(false) + .withHubPaidLicense(false) + .build(); + + Vault.aCopyOf(original) + .withHubVault(true) + .withHubPaidLicense(true) + .build(); + + assertThat(original.isHubVault(), is(false)); + assertThat(original.hasHubPaidLicense(), is(false)); + } + } + + @Nested + @DisplayName("Hub fields - regression: non-hub vaults remain unaffected") + class NonHubVaultRegression { + + @Test + @DisplayName("Standard vault without hub flags has false for both") + void standardVaultHasFalseForBothHubFlags() { + Cloud cloud = mock(Cloud.class); + when(cloud.type()).thenReturn(CloudType.S3); + Vault vault = Vault.aVault() + .withId(42L) + .withName("MyVault") + .withPath("/s3/my-vault") + .withCloud(cloud) + .withPosition(3) + .build(); + + assertThat(vault.isHubVault(), is(false)); + assertThat(vault.hasHubPaidLicense(), is(false)); + } + } +} \ No newline at end of file diff --git a/presentation/src/test/java/org/cryptomator/presentation/licensing/LicenseEnforcerTest.kt b/presentation/src/test/java/org/cryptomator/presentation/licensing/LicenseEnforcerTest.kt new file mode 100644 index 000000000..a6ea67bea --- /dev/null +++ b/presentation/src/test/java/org/cryptomator/presentation/licensing/LicenseEnforcerTest.kt @@ -0,0 +1,603 @@ +package org.cryptomator.presentation.licensing + +import android.app.Activity +import android.content.Context +import android.widget.Toast +import org.cryptomator.presentation.R +import org.cryptomator.presentation.model.VaultModel +import org.cryptomator.util.FlavorConfig +import org.cryptomator.util.SharedPreferencesHandler +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` + +class LicenseEnforcerTest { + + private val sharedPreferencesHandler: SharedPreferencesHandler = mock() + private lateinit var licenseEnforcer: LicenseEnforcer + + @BeforeEach + fun setUp() { + licenseEnforcer = LicenseEnforcer(sharedPreferencesHandler) + } + + // -- hasWriteAccess -- + + @Test + fun `hasWriteAccess returns true when license token is present`() { + `when`(sharedPreferencesHandler.licenseToken()).thenReturn("some-token") + `when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false) + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(0L) + + assertTrue(licenseEnforcer.hasWriteAccess()) + } + + @Test + fun `hasWriteAccess returns true when subscription is active`() { + `when`(sharedPreferencesHandler.licenseToken()).thenReturn("") + `when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(true) + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(0L) + + assertTrue(licenseEnforcer.hasWriteAccess()) + } + + @Test + fun `hasWriteAccess returns true when trial is active`() { + `when`(sharedPreferencesHandler.licenseToken()).thenReturn("") + `when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false) + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() + 86400000L) + `when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false) + + assertTrue(licenseEnforcer.hasWriteAccess()) + } + + @Test + fun `hasWriteAccess returns false when trial is expired`() { + assumeTrue(!FlavorConfig.isPremiumFlavor, "Licensing logic is bypassed on this flavor") + `when`(sharedPreferencesHandler.licenseToken()).thenReturn("") + `when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false) + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() - 1000L) + `when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false) + + assertFalse(licenseEnforcer.hasWriteAccess()) + } + + @Test + fun `hasWriteAccess returns false when no license and no trial and no subscription`() { + assumeTrue(!FlavorConfig.isPremiumFlavor, "Licensing logic is bypassed on this flavor") + `when`(sharedPreferencesHandler.licenseToken()).thenReturn("") + `when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false) + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(0L) + + assertFalse(licenseEnforcer.hasWriteAccess()) + } + + // -- hasWriteAccessForVault -- + + @Test + fun `hasWriteAccessForVault returns true for non-hub vault with write access`() { + `when`(sharedPreferencesHandler.licenseToken()).thenReturn("some-token") + `when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false) + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(0L) + val vault: VaultModel = mock() + `when`(vault.isHubVault).thenReturn(false) + + assertTrue(licenseEnforcer.hasWriteAccessForVault(vault)) + } + + @Test + fun `hasWriteAccessForVault returns false for non-hub vault without write access`() { + assumeTrue(!FlavorConfig.isPremiumFlavor, "Licensing logic is bypassed on this flavor") + `when`(sharedPreferencesHandler.licenseToken()).thenReturn("") + `when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false) + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(0L) + val vault: VaultModel = mock() + `when`(vault.isHubVault).thenReturn(false) + + assertFalse(licenseEnforcer.hasWriteAccessForVault(vault)) + } + + @Test + fun `hasWriteAccessForVault returns true for hub vault with paid license`() { + val vault: VaultModel = mock() + `when`(vault.isHubVault).thenReturn(true) + `when`(vault.hasHubPaidLicense).thenReturn(true) + + assertTrue(licenseEnforcer.hasWriteAccessForVault(vault)) + } + + @Test + fun `hasWriteAccessForVault returns false for hub vault without paid license and no local license`() { + assumeTrue(!FlavorConfig.isPremiumFlavor, "Licensing logic is bypassed on this flavor") + val vault: VaultModel = mock() + `when`(vault.isHubVault).thenReturn(true) + `when`(vault.hasHubPaidLicense).thenReturn(false) + `when`(sharedPreferencesHandler.licenseToken()).thenReturn("") + `when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false) + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(0L) + + assertFalse(licenseEnforcer.hasWriteAccessForVault(vault)) + } + + @Test + fun `hasWriteAccessForVault returns true for hub vault without paid license but with local license`() { + val vault: VaultModel = mock() + `when`(vault.isHubVault).thenReturn(true) + `when`(vault.hasHubPaidLicense).thenReturn(false) + `when`(sharedPreferencesHandler.licenseToken()).thenReturn("some-token") + `when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false) + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(0L) + + assertTrue(licenseEnforcer.hasWriteAccessForVault(vault)) + } + + @Test + fun `hasWriteAccessForVault returns true for hub vault without paid license but with active trial`() { + val vault: VaultModel = mock() + `when`(vault.isHubVault).thenReturn(true) + `when`(vault.hasHubPaidLicense).thenReturn(false) + `when`(sharedPreferencesHandler.licenseToken()).thenReturn("") + `when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false) + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() + 86400000L) + `when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false) + + assertTrue(licenseEnforcer.hasWriteAccessForVault(vault)) + } + + @Test + fun `ensureWriteAccessForVault returns true for hub vault without paid license but with local license`() { + val activity: Activity = mock() + val vault: VaultModel = mock() + `when`(vault.isHubVault).thenReturn(true) + `when`(vault.hasHubPaidLicense).thenReturn(false) + `when`(sharedPreferencesHandler.licenseToken()).thenReturn("some-token") + `when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false) + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(0L) + + assertTrue(licenseEnforcer.ensureWriteAccessForVault(activity, vault, LicenseEnforcer.LockedAction.UPLOAD_FILES)) + } + + @Test + fun `hasWriteAccessForVault returns true when vault is null and has write access`() { + `when`(sharedPreferencesHandler.licenseToken()).thenReturn("some-token") + `when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false) + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(0L) + + assertTrue(licenseEnforcer.hasWriteAccessForVault(null)) + } + + @Test + fun `hasWriteAccessForVault returns false when vault is null and has no write access`() { + assumeTrue(!FlavorConfig.isPremiumFlavor, "Licensing logic is bypassed on this flavor") + `when`(sharedPreferencesHandler.licenseToken()).thenReturn("") + `when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false) + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(0L) + + assertFalse(licenseEnforcer.hasWriteAccessForVault(null)) + } + + // -- ensureWriteAccessForVault -- + + @Test + fun `ensureWriteAccessForVault returns true for hub vault with paid license`() { + val activity: Activity = mock() + val vault: VaultModel = mock() + `when`(vault.isHubVault).thenReturn(true) + `when`(vault.hasHubPaidLicense).thenReturn(true) + + assertTrue(licenseEnforcer.ensureWriteAccessForVault(activity, vault, LicenseEnforcer.LockedAction.UPLOAD_FILES)) + } + + @Test + fun `ensureWriteAccessForVault returns false for hub vault without paid license and no local license`() { + assumeTrue(!FlavorConfig.isPremiumFlavor, "Licensing logic is bypassed on this flavor") + val activity: Activity = mock() + val vault: VaultModel = mock() + `when`(vault.isHubVault).thenReturn(true) + `when`(vault.hasHubPaidLicense).thenReturn(false) + `when`(sharedPreferencesHandler.licenseToken()).thenReturn("") + `when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false) + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(0L) + + mockStatic(Toast::class.java).use { toastMock -> + val toast: Toast = mock() + toastMock.`when` { Toast.makeText(activity, R.string.read_only_reason_hub_inactive, Toast.LENGTH_LONG) }.thenReturn(toast) + + assertFalse(licenseEnforcer.ensureWriteAccessForVault(activity, vault, LicenseEnforcer.LockedAction.UPLOAD_FILES)) + verify(toast).show() + } + } + + // -- hasPaidLicense -- + + @Test + fun `hasPaidLicense returns true when license token is present`() { + `when`(sharedPreferencesHandler.licenseToken()).thenReturn("some-token") + `when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false) + + assertTrue(licenseEnforcer.hasPaidLicense()) + } + + @Test + fun `hasPaidLicense returns true when subscription is active`() { + `when`(sharedPreferencesHandler.licenseToken()).thenReturn("") + `when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(true) + + assertTrue(licenseEnforcer.hasPaidLicense()) + } + + @Test + fun `hasPaidLicense returns false when only trial is active`() { + assumeTrue(!FlavorConfig.isPremiumFlavor, "Licensing logic is bypassed on this flavor") + `when`(sharedPreferencesHandler.licenseToken()).thenReturn("") + `when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false) + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() + 86400000L) + + assertFalse(licenseEnforcer.hasPaidLicense()) + } + + @Test + fun `hasPaidLicense returns false when no license and no subscription`() { + assumeTrue(!FlavorConfig.isPremiumFlavor, "Licensing logic is bypassed on this flavor") + `when`(sharedPreferencesHandler.licenseToken()).thenReturn("") + `when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false) + + assertFalse(licenseEnforcer.hasPaidLicense()) + } + + // -- hasActiveTrial -- + + @Test + fun `hasActiveTrial returns true when trial expiration is in the future`() { + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() + 86400000L) + `when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false) + + assertTrue(licenseEnforcer.hasActiveTrial()) + } + + @Test + fun `hasActiveTrial returns false when trial expiration is in the past`() { + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() - 1000L) + `when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false) + + assertFalse(licenseEnforcer.hasActiveTrial()) + } + + @Test + fun `hasActiveTrial returns false when no trial was started`() { + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(0L) + + assertFalse(licenseEnforcer.hasActiveTrial()) + } + + // -- startTrial -- + + @Test + fun `startTrial sets trial expiration date 30 days in the future`() { + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(0L) + val before = System.currentTimeMillis() + licenseEnforcer.startTrial() + val after = System.currentTimeMillis() + + val captor = ArgumentCaptor.forClass(Long::class.java) + verify(sharedPreferencesHandler).setTrialExpirationDate(captor.capture()) + + val thirtyDaysMs = 30L * 24 * 60 * 60 * 1000 + assertTrue(captor.value >= before + thirtyDaysMs) + assertTrue(captor.value <= after + thirtyDaysMs) + } + + @Test + fun `startTrial does not overwrite existing active trial`() { + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() + 86400000L) + + licenseEnforcer.startTrial() + + verify(sharedPreferencesHandler, never()).setTrialExpirationDate(anyLong()) + } + + @Test + fun `startTrial does not overwrite expired trial`() { + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() - 1000L) + + licenseEnforcer.startTrial() + + verify(sharedPreferencesHandler, never()).setTrialExpirationDate(anyLong()) + } + + // -- evaluateTrialState -- + + @Test + fun `evaluateTrialState returns active with formatted date when trial is active`() { + val futureDate = System.currentTimeMillis() + 86400000L + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(futureDate) + `when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false) + + val state = licenseEnforcer.evaluateTrialState() + + assertTrue(state.isActive) + assertFalse(state.isExpired) + assertNotNull(state.formattedExpirationDate) + } + + @Test + fun `evaluateTrialState returns expired with formatted date when trial is expired`() { + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() - 1000L) + `when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false) + + val state = licenseEnforcer.evaluateTrialState() + + assertFalse(state.isActive) + assertTrue(state.isExpired) + assertNotNull(state.formattedExpirationDate) + } + + @Test + fun `evaluateTrialState returns inactive and not expired when no trial started`() { + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(0L) + + val state = licenseEnforcer.evaluateTrialState() + + assertFalse(state.isActive) + assertFalse(state.isExpired) + assertNull(state.formattedExpirationDate) + } + + // -- sticky trial expiry -- + + @Test + fun `hasActiveTrial latches isTrialExpired when expiration is in the past`() { + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() - 1000L) + `when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false) + + assertFalse(licenseEnforcer.hasActiveTrial()) + verify(sharedPreferencesHandler).setTrialExpired(true) + } + + @Test + fun `hasActiveTrial returns false when isTrialExpired is already true even if date is in the future`() { + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() + 86400000L) + `when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(true) + + assertFalse(licenseEnforcer.hasActiveTrial()) + verify(sharedPreferencesHandler, never()).setTrialExpired(anyBoolean()) + } + + @Test + fun `evaluateTrialState latches isTrialExpired and returns expired when date is in the past`() { + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() - 1000L) + `when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false) + + val state = licenseEnforcer.evaluateTrialState() + + assertFalse(state.isActive) + assertTrue(state.isExpired) + verify(sharedPreferencesHandler).setTrialExpired(true) + } + + @Test + fun `evaluateTrialState returns expired when isTrialExpired is already true even if date is in the future`() { + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() + 86400000L) + `when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(true) + + val state = licenseEnforcer.evaluateTrialState() + + assertFalse(state.isActive) + assertTrue(state.isExpired) + assertNotNull(state.formattedExpirationDate) + verify(sharedPreferencesHandler, never()).setTrialExpired(anyBoolean()) + } + + @Test + fun `observeTrialExpiry idempotent once flag is set`() { + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() - 1000L) + `when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(true) + + licenseEnforcer.hasActiveTrial() + licenseEnforcer.evaluateTrialState() + + verify(sharedPreferencesHandler, never()).setTrialExpired(anyBoolean()) + } + + @Test + fun `startTrial does not reset sticky trialExpired flag`() { + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(0L) + `when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(true) + + licenseEnforcer.startTrial() + + verify(sharedPreferencesHandler, never()).setTrialExpired(anyBoolean()) + } + + @Test + fun `fresh install with no trial and no flag behaves unchanged`() { + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(0L) + `when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false) + + assertFalse(licenseEnforcer.hasActiveTrial()) + val state = licenseEnforcer.evaluateTrialState() + assertFalse(state.isActive) + assertFalse(state.isExpired) + assertNull(state.formattedExpirationDate) + verify(sharedPreferencesHandler, never()).setTrialExpired(anyBoolean()) + } + + // -- evaluateUiState -- + + @Test + fun `evaluateUiState returns active trial with expiration text`() { + assumeTrue(!FlavorConfig.isPremiumFlavor, "Licensing logic is bypassed on this flavor") + val context: Context = mock() + `when`(sharedPreferencesHandler.licenseToken()).thenReturn("") + `when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false) + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() + 86400000L) + `when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false) + `when`(context.getString(eq(R.string.screen_license_check_trial_expiration), any())).thenReturn("Expiration Date: Mar 28, 2026") + + val uiState = licenseEnforcer.evaluateUiState(context) + + assertTrue(uiState.hasWriteAccess) + assertFalse(uiState.hasPaidLicense) + assertTrue(uiState.trialState.isActive) + assertNotNull(uiState.trialExpirationText) + } + + @Test + fun `evaluateUiState returns expired trial with expiration date text`() { + assumeTrue(!FlavorConfig.isPremiumFlavor, "Licensing logic is bypassed on this flavor") + val context: Context = mock() + `when`(sharedPreferencesHandler.licenseToken()).thenReturn("") + `when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false) + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() - 1000L) + `when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false) + `when`(context.getString(eq(R.string.screen_license_check_trial_expiration), any())).thenReturn("Expiration Date: Mar 26, 2026") + + val uiState = licenseEnforcer.evaluateUiState(context) + + assertFalse(uiState.hasWriteAccess) + assertFalse(uiState.hasPaidLicense) + assertTrue(uiState.trialState.isExpired) + assertNotNull(uiState.trialExpirationText) + } + + @Test + fun `evaluateUiState returns null expiration text when no trial started`() { + assumeTrue(!FlavorConfig.isPremiumFlavor, "Licensing logic is bypassed on this flavor") + val context: Context = mock() + `when`(sharedPreferencesHandler.licenseToken()).thenReturn("") + `when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false) + `when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(0L) + + val uiState = licenseEnforcer.evaluateUiState(context) + + assertFalse(uiState.hasWriteAccess) + assertFalse(uiState.hasPaidLicense) + assertFalse(uiState.trialState.isActive) + assertFalse(uiState.trialState.isExpired) + assertNull(uiState.trialExpirationText) + } + + // -- LockedAction.fromName -- + + @Test + fun `LockedAction fromName returns correct action for CREATE_VAULT`() { + val action = LicenseEnforcer.LockedAction.fromName("CREATE_VAULT") + + assertNotNull(action) + assertTrue(action == LicenseEnforcer.LockedAction.CREATE_VAULT) + } + + @Test + fun `LockedAction fromName returns correct action for UPLOAD_FILES`() { + val action = LicenseEnforcer.LockedAction.fromName("UPLOAD_FILES") + + assertNotNull(action) + assertTrue(action == LicenseEnforcer.LockedAction.UPLOAD_FILES) + } + + @Test + fun `LockedAction fromName returns correct action for CREATE_FOLDER`() { + val action = LicenseEnforcer.LockedAction.fromName("CREATE_FOLDER") + + assertNotNull(action) + assertTrue(action == LicenseEnforcer.LockedAction.CREATE_FOLDER) + } + + @Test + fun `LockedAction fromName returns correct action for CREATE_TEXT_FILE`() { + val action = LicenseEnforcer.LockedAction.fromName("CREATE_TEXT_FILE") + + assertNotNull(action) + assertTrue(action == LicenseEnforcer.LockedAction.CREATE_TEXT_FILE) + } + + @Test + fun `LockedAction fromName returns correct action for SHARE_NODE`() { + val action = LicenseEnforcer.LockedAction.fromName("SHARE_NODE") + + assertNotNull(action) + assertTrue(action == LicenseEnforcer.LockedAction.SHARE_NODE) + } + + @Test + fun `LockedAction fromName returns correct action for RENAME_NODE`() { + val action = LicenseEnforcer.LockedAction.fromName("RENAME_NODE") + + assertNotNull(action) + assertTrue(action == LicenseEnforcer.LockedAction.RENAME_NODE) + } + + @Test + fun `LockedAction fromName returns correct action for MOVE_NODE`() { + val action = LicenseEnforcer.LockedAction.fromName("MOVE_NODE") + + assertNotNull(action) + assertTrue(action == LicenseEnforcer.LockedAction.MOVE_NODE) + } + + @Test + fun `LockedAction fromName returns correct action for DELETE_NODE`() { + val action = LicenseEnforcer.LockedAction.fromName("DELETE_NODE") + + assertNotNull(action) + assertTrue(action == LicenseEnforcer.LockedAction.DELETE_NODE) + } + + @Test + fun `LockedAction fromName returns null for unknown name`() { + val action = LicenseEnforcer.LockedAction.fromName("UNKNOWN_ACTION") + + assertNull(action) + } + + @Test + fun `LockedAction fromName returns null for null input`() { + val action = LicenseEnforcer.LockedAction.fromName(null) + + assertNull(action) + } + + @Test + fun `LockedAction fromName returns null for empty string`() { + val action = LicenseEnforcer.LockedAction.fromName("") + + assertNull(action) + } + + @Test + fun `LockedAction fromName is case-sensitive and returns null for lowercase`() { + val action = LicenseEnforcer.LockedAction.fromName("create_vault") + + assertNull(action) + } + + @Test + fun `LockedAction fromName is case-sensitive and returns null for mixed case`() { + val action = LicenseEnforcer.LockedAction.fromName("Create_Vault") + + assertNull(action) + } + + @Test + fun `LockedAction name roundtrip via fromName returns the same action`() { + LicenseEnforcer.LockedAction.values().forEach { originalAction -> + val retrieved = LicenseEnforcer.LockedAction.fromName(originalAction.name) + assertNotNull(retrieved) + assertTrue(retrieved == originalAction) + } + } +} \ No newline at end of file