diff --git a/core/src/test/scala/app/softnetwork/elastic/client/LicenseExecutorSpec.scala b/core/src/test/scala/app/softnetwork/elastic/client/LicenseExecutorSpec.scala index 0a2f8e98..f82da7f9 100644 --- a/core/src/test/scala/app/softnetwork/elastic/client/LicenseExecutorSpec.scala +++ b/core/src/test/scala/app/softnetwork/elastic/client/LicenseExecutorSpec.scala @@ -79,7 +79,7 @@ class LicenseExecutorSpec extends AnyFlatSpec with Matchers { row should contain key "max_materialized_views" row("max_materialized_views") shouldBe "3" row should contain key "max_clusters" - row("max_clusters") shouldBe "2" + row("max_clusters") shouldBe "0" row should contain key "max_result_rows" row("max_result_rows") shouldBe "10000" row should contain key "max_concurrent_queries" diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala index c1272310..9a061fac 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala @@ -88,29 +88,47 @@ package object licensing { } } + /** Compute the number of days between now and a target instant. Positive = future, negative = + * past. Single source of truth for expiry/grace computations. + */ + def daysBetween(now: java.time.Instant, target: java.time.Instant): Long = + java.time.Duration.between(now, target).toDays + case class LicenseKey( id: String, licenseType: LicenseType, features: Set[Feature], expiresAt: Option[java.time.Instant], - metadata: Map[String, String] = Map.empty + metadata: Map[String, String] = Map.empty, + quota: Option[Quota] = None, + usage: Option[LicenseUsage] = None, + platform: Option[Platform] = None ) { /** Whether this is a trial license (Pro trial via API key). */ def isTrial: Boolean = metadata.get("trial").contains("true") - /** Days remaining until expiration, or None if no expiry. */ - def daysRemaining: Option[Long] = expiresAt.map { exp => - java.time.Duration.between(java.time.Instant.now(), exp).toDays - } + /** Days remaining until expiration, or None if no expiry. Positive = not yet expired. */ + def daysRemaining: Option[Long] = daysRemainingAt(java.time.Instant.now()) + + /** Testable variant: days remaining relative to a given instant. */ + def daysRemainingAt(now: java.time.Instant): Option[Long] = expiresAt.map(daysBetween(now, _)) + + /** Days since expiration, or None if no expiry. Positive = expired. */ + def daysSinceExpiry: Option[Long] = daysSinceExpiryAt(java.time.Instant.now()) + + /** Testable variant: days since expiry relative to a given instant. */ + def daysSinceExpiryAt(now: java.time.Instant): Option[Long] = + expiresAt.map(exp => -daysBetween(now, exp)) } object LicenseKey { val Community: LicenseKey = LicenseKey( id = "community", licenseType = LicenseType.Community, - features = Set(Feature.MaterializedViews, Feature.JdbcDriver), - expiresAt = None + features = Set(Feature.MaterializedViews, Feature.JdbcDriver, Feature.FlightSql), + expiresAt = None, + quota = Some(Quota.Community) ) } @@ -118,7 +136,7 @@ package object licensing { maxMaterializedViews: Option[Int], // None = unlimited maxQueryResults: Option[Int], // None = unlimited maxConcurrentQueries: Option[Int], - maxClusters: Option[Int] = Some(2) // None = unlimited + maxClusters: Option[Int] = Some(0) // None = unlimited ) object Quota { @@ -126,7 +144,7 @@ package object licensing { maxMaterializedViews = Some(3), maxQueryResults = Some(10000), maxConcurrentQueries = Some(5), - maxClusters = Some(2) + maxClusters = Some(0) ) val Pro: Quota = Quota( @@ -144,10 +162,70 @@ package object licensing { ) } + sealed trait Platform { + def name: String + override def toString: String = name + } + + object Platform { + case object Production extends Platform { val name = "PRODUCTION" } + case object Staging extends Platform { val name = "STAGING" } + case object Integration extends Platform { val name = "INTEGRATION" } + case object Development extends Platform { val name = "DEVELOPMENT" } + + val values: Seq[Platform] = Seq(Production, Staging, Integration, Development) + + def fromString(s: String): Option[Platform] = s.trim.toUpperCase match { + case "PRODUCTION" => Some(Production) + case "STAGING" => Some(Staging) + case "INTEGRATION" => Some(Integration) + case "DEVELOPMENT" => Some(Development) + case _ => None + } + } + + case class LicenseUsage( + totalMvsActive: Int = 0, + totalFederatedClusters: Int = 0 + ) { + require(totalMvsActive >= 0, s"totalMvsActive must be non-negative, got $totalMvsActive") + require( + totalFederatedClusters >= 0, + s"totalFederatedClusters must be non-negative, got $totalFederatedClusters" + ) + + /** Check usage for a specific feature against a quota. Returns the exceeded limit, if any. */ + def checkQuota(feature: Feature, quota: Quota): Option[QuotaExceeded] = feature match { + case Feature.MaterializedViews => + quota.maxMaterializedViews.collect { + case max if totalMvsActive > max => + QuotaExceeded("maxMaterializedViews", totalMvsActive, max) + } + case Feature.Federation => + quota.maxClusters.collect { + case max if totalFederatedClusters > max => + QuotaExceeded("maxClusters", totalFederatedClusters, max) + } + case _ => None + } + } + + object LicenseUsage { + val Empty: LicenseUsage = LicenseUsage() + } + sealed trait GraceStatus object GraceStatus { case object NotInGrace extends GraceStatus + + /** @param daysExpired must be computed via `LicenseKey.daysSinceExpiryAt(now)` */ case class EarlyGrace(daysExpired: Long) extends GraceStatus + + /** @param daysExpired + * must be computed via `LicenseKey.daysSinceExpiryAt(now)` + * @param daysRemaining + * grace period days remaining (gracePeriodDays - daysExpired) + */ case class MidGrace(daysExpired: Long, daysRemaining: Long) extends GraceStatus } diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeySpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeySpec.scala index 8999d693..372d4cfc 100644 --- a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeySpec.scala +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeySpec.scala @@ -138,4 +138,100 @@ class LicenseKeySpec extends AnyFlatSpec with Matchers { ) key.daysRemaining.get should be < 0L } + + "daysRemainingAt" should "be deterministic with a fixed instant" in { + val now = Instant.parse("2026-01-15T00:00:00Z") + val key = LicenseKey( + id = "org-123", + licenseType = LicenseType.Pro, + features = Set(Feature.MaterializedViews), + expiresAt = Some(Instant.parse("2026-01-25T00:00:00Z")), + metadata = Map.empty + ) + key.daysRemainingAt(now) shouldBe Some(10L) + } + + "daysSinceExpiryAt" should "return positive for expired keys" in { + val now = Instant.parse("2026-01-20T00:00:00Z") + val key = LicenseKey( + id = "org-123", + licenseType = LicenseType.Pro, + features = Set(Feature.MaterializedViews), + expiresAt = Some(Instant.parse("2026-01-15T00:00:00Z")), + metadata = Map.empty + ) + key.daysSinceExpiryAt(now) shouldBe Some(5L) + } + + it should "return negative for not-yet-expired keys" in { + val now = Instant.parse("2026-01-10T00:00:00Z") + val key = LicenseKey( + id = "org-123", + licenseType = LicenseType.Pro, + features = Set(Feature.MaterializedViews), + expiresAt = Some(Instant.parse("2026-01-15T00:00:00Z")), + metadata = Map.empty + ) + key.daysSinceExpiryAt(now) shouldBe Some(-5L) + } + + it should "be consistent with daysRemainingAt (sum to zero)" in { + val now = Instant.parse("2026-01-15T00:00:00Z") + val key = LicenseKey( + id = "org-123", + licenseType = LicenseType.Pro, + features = Set(Feature.MaterializedViews), + expiresAt = Some(Instant.parse("2026-01-22T00:00:00Z")), + metadata = Map.empty + ) + val remaining = key.daysRemainingAt(now).get + val expired = key.daysSinceExpiryAt(now).get + (remaining + expired) shouldBe 0L + } + + "LicenseKey.Community" should "carry Community quota with no usage or platform" in { + LicenseKey.Community.quota shouldBe Some(Quota.Community) + LicenseKey.Community.usage shouldBe None + LicenseKey.Community.platform shouldBe None + } + + "LicenseKey with all new fields" should "preserve quota, usage, and platform" in { + val key = LicenseKey( + id = "pro-key", + licenseType = LicenseType.Pro, + features = Set(Feature.MaterializedViews, Feature.Federation), + expiresAt = Some(Instant.now().plus(Duration.ofDays(365))), + metadata = Map("org_name" -> "Acme Corp"), + quota = Some(Quota.Pro), + usage = Some(LicenseUsage(totalMvsActive = 10, totalFederatedClusters = 2)), + platform = Some(Platform.Production) + ) + key.quota shouldBe Some(Quota.Pro) + key.usage shouldBe Some(LicenseUsage(totalMvsActive = 10, totalFederatedClusters = 2)) + key.platform shouldBe Some(Platform.Production) + } + + it should "support equality with identical field values" in { + val now = Instant.now() + val usage = LicenseUsage(totalMvsActive = 5, totalFederatedClusters = 1) + val key1 = LicenseKey( + "k", + LicenseType.Pro, + Set.empty, + Some(now), + quota = Some(Quota.Pro), + usage = Some(usage), + platform = Some(Platform.Staging) + ) + val key2 = LicenseKey( + "k", + LicenseType.Pro, + Set.empty, + Some(now), + quota = Some(Quota.Pro), + usage = Some(usage), + platform = Some(Platform.Staging) + ) + key1 shouldBe key2 + } } diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpec.scala index 8cb34b66..87f4f92c 100644 --- a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpec.scala +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpec.scala @@ -21,53 +21,45 @@ import org.scalatest.matchers.should.Matchers class LicenseManagerSpec extends AnyFlatSpec with Matchers { + private val manager = new CommunityLicenseManager + "CommunityLicenseManager" should "include MaterializedViews" in { - val manager = new CommunityLicenseManager manager.hasFeature(Feature.MaterializedViews) shouldBe true } it should "include JdbcDriver" in { - val manager = new CommunityLicenseManager manager.hasFeature(Feature.JdbcDriver) shouldBe true } - it should "not include FlightSql" in { - val manager = new CommunityLicenseManager - manager.hasFeature(Feature.FlightSql) shouldBe false + it should "include FlightSql" in { + manager.hasFeature(Feature.FlightSql) shouldBe true } it should "not include Federation" in { - val manager = new CommunityLicenseManager manager.hasFeature(Feature.Federation) shouldBe false } it should "not include OdbcDriver" in { - val manager = new CommunityLicenseManager manager.hasFeature(Feature.OdbcDriver) shouldBe false } it should "not include UnlimitedResults" in { - val manager = new CommunityLicenseManager manager.hasFeature(Feature.UnlimitedResults) shouldBe false } it should "not include AdvancedAggregations" in { - val manager = new CommunityLicenseManager manager.hasFeature(Feature.AdvancedAggregations) shouldBe false } it should "return Community quotas" in { - val manager = new CommunityLicenseManager manager.quotas shouldBe Quota.Community } it should "always be Community type" in { - val manager = new CommunityLicenseManager manager.licenseType shouldBe LicenseType.Community } it should "reject any key validation" in { - val manager = new CommunityLicenseManager manager.validate("PRO-test-key") shouldBe a[Left[_, _]] manager.validate("ENT-test-key") shouldBe a[Left[_, _]] manager.validate("anything") shouldBe a[Left[_, _]] @@ -77,14 +69,21 @@ class LicenseManagerSpec extends AnyFlatSpec with Matchers { } it should "return Left(RefreshNotSupported) on refresh" in { - val manager = new CommunityLicenseManager manager.refresh() shouldBe Left(RefreshNotSupported) } + it should "return LicenseKey.Community as currentLicenseKey" in { + manager.currentLicenseKey shouldBe LicenseKey.Community + } + + it should "not be a trial" in { + manager.isTrial shouldBe false + } + "LicenseManager trait" should "be source-compatible" in { - val manager: LicenseManager = new CommunityLicenseManager - manager.licenseType shouldBe LicenseType.Community - manager.quotas shouldBe Quota.Community + val m: LicenseManager = new CommunityLicenseManager + m.licenseType shouldBe LicenseType.Community + m.quotas shouldBe Quota.Community } it should "default refresh to Left(RefreshNotSupported)" in { @@ -97,8 +96,19 @@ class LicenseManagerSpec extends AnyFlatSpec with Matchers { stub.refresh() shouldBe Left(RefreshNotSupported) } + it should "default currentLicenseKey to LicenseKey.Community" in { + val stub = new LicenseManager { + def validate(key: String): Either[LicenseError, LicenseKey] = Left(InvalidLicense("stub")) + def hasFeature(feature: Feature): Boolean = false + def quotas: Quota = Quota.Community + def licenseType: LicenseType = LicenseType.Community + } + stub.currentLicenseKey shouldBe LicenseKey.Community + stub.isTrial shouldBe false + } + "DefaultLicenseManager" should "be a deprecated alias for CommunityLicenseManager" in { - val manager: DefaultLicenseManager = new CommunityLicenseManager - manager shouldBe a[CommunityLicenseManager] + val m: DefaultLicenseManager = new CommunityLicenseManager + m shouldBe a[CommunityLicenseManager] } } diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseUsageSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseUsageSpec.scala new file mode 100644 index 00000000..722699ad --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseUsageSpec.scala @@ -0,0 +1,105 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class LicenseUsageSpec extends AnyFlatSpec with Matchers { + + "LicenseUsage" should "default to zero counters" in { + val usage = LicenseUsage() + usage.totalMvsActive shouldBe 0 + usage.totalFederatedClusters shouldBe 0 + } + + it should "reject negative totalMvsActive" in { + an[IllegalArgumentException] should be thrownBy LicenseUsage(totalMvsActive = -1) + } + + it should "reject negative totalFederatedClusters" in { + an[IllegalArgumentException] should be thrownBy LicenseUsage(totalFederatedClusters = -1) + } + + it should "accept boundary values" in { + noException should be thrownBy LicenseUsage(totalMvsActive = 0, totalFederatedClusters = 0) + noException should be thrownBy LicenseUsage( + totalMvsActive = Int.MaxValue, + totalFederatedClusters = Int.MaxValue + ) + } + + "LicenseUsage.Empty" should "equal default-constructed LicenseUsage" in { + LicenseUsage.Empty shouldBe LicenseUsage() + } + + "checkQuota for MaterializedViews" should "return None when within quota" in { + val usage = LicenseUsage(totalMvsActive = 2) + usage.checkQuota(Feature.MaterializedViews, Quota.Community) shouldBe None + } + + it should "return None at exact quota boundary" in { + val usage = LicenseUsage(totalMvsActive = 3) + usage.checkQuota(Feature.MaterializedViews, Quota.Community) shouldBe None + } + + it should "detect exceeded maxMaterializedViews" in { + val usage = LicenseUsage(totalMvsActive = 4) + usage.checkQuota(Feature.MaterializedViews, Quota.Community) shouldBe Some( + QuotaExceeded("maxMaterializedViews", 4, 3) + ) + } + + it should "not check maxClusters" in { + val usage = LicenseUsage(totalMvsActive = 0, totalFederatedClusters = 100) + usage.checkQuota(Feature.MaterializedViews, Quota.Community) shouldBe None + } + + it should "return None for Enterprise (unlimited)" in { + val usage = LicenseUsage(totalMvsActive = 1000) + usage.checkQuota(Feature.MaterializedViews, Quota.Enterprise) shouldBe None + } + + "checkQuota for Federation" should "return None when within quota" in { + val usage = LicenseUsage(totalFederatedClusters = 4) + usage.checkQuota(Feature.Federation, Quota.Pro) shouldBe None + } + + it should "detect exceeded maxClusters" in { + val usage = LicenseUsage(totalFederatedClusters = 6) + usage.checkQuota(Feature.Federation, Quota.Pro) shouldBe Some( + QuotaExceeded("maxClusters", 6, 5) + ) + } + + it should "not check maxMaterializedViews" in { + val usage = LicenseUsage(totalMvsActive = 100, totalFederatedClusters = 0) + usage.checkQuota(Feature.Federation, Quota.Community) shouldBe None + } + + it should "return None for Enterprise (unlimited)" in { + val usage = LicenseUsage(totalFederatedClusters = 100) + usage.checkQuota(Feature.Federation, Quota.Enterprise) shouldBe None + } + + "checkQuota for other features" should "always return None" in { + val usage = LicenseUsage(totalMvsActive = 100, totalFederatedClusters = 100) + usage.checkQuota(Feature.JdbcDriver, Quota.Community) shouldBe None + usage.checkQuota(Feature.FlightSql, Quota.Community) shouldBe None + usage.checkQuota(Feature.UnlimitedResults, Quota.Community) shouldBe None + } +} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/PlatformSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/PlatformSpec.scala new file mode 100644 index 00000000..0b3ea1f2 --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/PlatformSpec.scala @@ -0,0 +1,60 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class PlatformSpec extends AnyFlatSpec with Matchers { + + "Platform.fromString" should "parse PRODUCTION" in { + Platform.fromString("PRODUCTION") shouldBe Some(Platform.Production) + } + + it should "parse case-insensitively" in { + Platform.fromString("staging") shouldBe Some(Platform.Staging) + Platform.fromString("Integration") shouldBe Some(Platform.Integration) + Platform.fromString("development") shouldBe Some(Platform.Development) + } + + it should "return None for unknown values" in { + Platform.fromString("UNKNOWN") shouldBe None + Platform.fromString("CUSTOM_ENV") shouldBe None + Platform.fromString("") shouldBe None + } + + it should "trim whitespace" in { + Platform.fromString(" PRODUCTION ") shouldBe Some(Platform.Production) + } + + "Platform.toString" should "return uppercase name" in { + Platform.Production.toString shouldBe "PRODUCTION" + Platform.Staging.toString shouldBe "STAGING" + Platform.Integration.toString shouldBe "INTEGRATION" + Platform.Development.toString shouldBe "DEVELOPMENT" + } + + it should "round-trip through fromString" in { + Platform.values.foreach { p => + Platform.fromString(p.toString) shouldBe Some(p) + } + } + + "Platform.values" should "contain all four platforms" in { + Platform.values should have size 4 + } +} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/QuotaSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/QuotaSpec.scala index 2b2a5bd3..38bf5066 100644 --- a/licensing/src/test/scala/app/softnetwork/elastic/licensing/QuotaSpec.scala +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/QuotaSpec.scala @@ -21,8 +21,8 @@ import org.scalatest.matchers.should.Matchers class QuotaSpec extends AnyFlatSpec with Matchers { - "Quota.Community" should "have maxClusters = Some(2)" in { - Quota.Community.maxClusters shouldBe Some(2) + "Quota.Community" should "have maxClusters = Some(0)" in { + Quota.Community.maxClusters shouldBe Some(0) } it should "have maxMaterializedViews = Some(3)" in { @@ -69,12 +69,12 @@ class QuotaSpec extends AnyFlatSpec with Matchers { Quota.Enterprise.maxConcurrentQueries shouldBe None } - "Quota default constructor" should "use maxClusters = Some(2)" in { + "Quota default constructor" should "use maxClusters = Some(0)" in { val quota = Quota( maxMaterializedViews = Some(10), maxQueryResults = Some(100), maxConcurrentQueries = Some(1) ) - quota.maxClusters shouldBe Some(2) + quota.maxClusters shouldBe Some(0) } }