Skip to content

Commit b2bb02d

Browse files
authored
Merge pull request #83 from SOFTNETWORK-APP/feature/licensingInfrastructure
Closed Issue #82
2 parents 2cfd173 + c0bd076 commit b2bb02d

7 files changed

Lines changed: 381 additions & 32 deletions

File tree

core/src/test/scala/app/softnetwork/elastic/client/LicenseExecutorSpec.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ class LicenseExecutorSpec extends AnyFlatSpec with Matchers {
7979
row should contain key "max_materialized_views"
8080
row("max_materialized_views") shouldBe "3"
8181
row should contain key "max_clusters"
82-
row("max_clusters") shouldBe "2"
82+
row("max_clusters") shouldBe "0"
8383
row should contain key "max_result_rows"
8484
row("max_result_rows") shouldBe "10000"
8585
row should contain key "max_concurrent_queries"

licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -88,45 +88,63 @@ package object licensing {
8888
}
8989
}
9090

91+
/** Compute the number of days between now and a target instant. Positive = future, negative =
92+
* past. Single source of truth for expiry/grace computations.
93+
*/
94+
def daysBetween(now: java.time.Instant, target: java.time.Instant): Long =
95+
java.time.Duration.between(now, target).toDays
96+
9197
case class LicenseKey(
9298
id: String,
9399
licenseType: LicenseType,
94100
features: Set[Feature],
95101
expiresAt: Option[java.time.Instant],
96-
metadata: Map[String, String] = Map.empty
102+
metadata: Map[String, String] = Map.empty,
103+
quota: Option[Quota] = None,
104+
usage: Option[LicenseUsage] = None,
105+
platform: Option[Platform] = None
97106
) {
98107

99108
/** Whether this is a trial license (Pro trial via API key). */
100109
def isTrial: Boolean = metadata.get("trial").contains("true")
101110

102-
/** Days remaining until expiration, or None if no expiry. */
103-
def daysRemaining: Option[Long] = expiresAt.map { exp =>
104-
java.time.Duration.between(java.time.Instant.now(), exp).toDays
105-
}
111+
/** Days remaining until expiration, or None if no expiry. Positive = not yet expired. */
112+
def daysRemaining: Option[Long] = daysRemainingAt(java.time.Instant.now())
113+
114+
/** Testable variant: days remaining relative to a given instant. */
115+
def daysRemainingAt(now: java.time.Instant): Option[Long] = expiresAt.map(daysBetween(now, _))
116+
117+
/** Days since expiration, or None if no expiry. Positive = expired. */
118+
def daysSinceExpiry: Option[Long] = daysSinceExpiryAt(java.time.Instant.now())
119+
120+
/** Testable variant: days since expiry relative to a given instant. */
121+
def daysSinceExpiryAt(now: java.time.Instant): Option[Long] =
122+
expiresAt.map(exp => -daysBetween(now, exp))
106123
}
107124

108125
object LicenseKey {
109126
val Community: LicenseKey = LicenseKey(
110127
id = "community",
111128
licenseType = LicenseType.Community,
112-
features = Set(Feature.MaterializedViews, Feature.JdbcDriver),
113-
expiresAt = None
129+
features = Set(Feature.MaterializedViews, Feature.JdbcDriver, Feature.FlightSql),
130+
expiresAt = None,
131+
quota = Some(Quota.Community)
114132
)
115133
}
116134

117135
case class Quota(
118136
maxMaterializedViews: Option[Int], // None = unlimited
119137
maxQueryResults: Option[Int], // None = unlimited
120138
maxConcurrentQueries: Option[Int],
121-
maxClusters: Option[Int] = Some(2) // None = unlimited
139+
maxClusters: Option[Int] = Some(0) // None = unlimited
122140
)
123141

124142
object Quota {
125143
val Community: Quota = Quota(
126144
maxMaterializedViews = Some(3),
127145
maxQueryResults = Some(10000),
128146
maxConcurrentQueries = Some(5),
129-
maxClusters = Some(2)
147+
maxClusters = Some(0)
130148
)
131149

132150
val Pro: Quota = Quota(
@@ -144,10 +162,70 @@ package object licensing {
144162
)
145163
}
146164

165+
sealed trait Platform {
166+
def name: String
167+
override def toString: String = name
168+
}
169+
170+
object Platform {
171+
case object Production extends Platform { val name = "PRODUCTION" }
172+
case object Staging extends Platform { val name = "STAGING" }
173+
case object Integration extends Platform { val name = "INTEGRATION" }
174+
case object Development extends Platform { val name = "DEVELOPMENT" }
175+
176+
val values: Seq[Platform] = Seq(Production, Staging, Integration, Development)
177+
178+
def fromString(s: String): Option[Platform] = s.trim.toUpperCase match {
179+
case "PRODUCTION" => Some(Production)
180+
case "STAGING" => Some(Staging)
181+
case "INTEGRATION" => Some(Integration)
182+
case "DEVELOPMENT" => Some(Development)
183+
case _ => None
184+
}
185+
}
186+
187+
case class LicenseUsage(
188+
totalMvsActive: Int = 0,
189+
totalFederatedClusters: Int = 0
190+
) {
191+
require(totalMvsActive >= 0, s"totalMvsActive must be non-negative, got $totalMvsActive")
192+
require(
193+
totalFederatedClusters >= 0,
194+
s"totalFederatedClusters must be non-negative, got $totalFederatedClusters"
195+
)
196+
197+
/** Check usage for a specific feature against a quota. Returns the exceeded limit, if any. */
198+
def checkQuota(feature: Feature, quota: Quota): Option[QuotaExceeded] = feature match {
199+
case Feature.MaterializedViews =>
200+
quota.maxMaterializedViews.collect {
201+
case max if totalMvsActive > max =>
202+
QuotaExceeded("maxMaterializedViews", totalMvsActive, max)
203+
}
204+
case Feature.Federation =>
205+
quota.maxClusters.collect {
206+
case max if totalFederatedClusters > max =>
207+
QuotaExceeded("maxClusters", totalFederatedClusters, max)
208+
}
209+
case _ => None
210+
}
211+
}
212+
213+
object LicenseUsage {
214+
val Empty: LicenseUsage = LicenseUsage()
215+
}
216+
147217
sealed trait GraceStatus
148218
object GraceStatus {
149219
case object NotInGrace extends GraceStatus
220+
221+
/** @param daysExpired must be computed via `LicenseKey.daysSinceExpiryAt(now)` */
150222
case class EarlyGrace(daysExpired: Long) extends GraceStatus
223+
224+
/** @param daysExpired
225+
* must be computed via `LicenseKey.daysSinceExpiryAt(now)`
226+
* @param daysRemaining
227+
* grace period days remaining (gracePeriodDays - daysExpired)
228+
*/
151229
case class MidGrace(daysExpired: Long, daysRemaining: Long) extends GraceStatus
152230
}
153231

licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeySpec.scala

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,4 +138,100 @@ class LicenseKeySpec extends AnyFlatSpec with Matchers {
138138
)
139139
key.daysRemaining.get should be < 0L
140140
}
141+
142+
"daysRemainingAt" should "be deterministic with a fixed instant" in {
143+
val now = Instant.parse("2026-01-15T00:00:00Z")
144+
val key = LicenseKey(
145+
id = "org-123",
146+
licenseType = LicenseType.Pro,
147+
features = Set(Feature.MaterializedViews),
148+
expiresAt = Some(Instant.parse("2026-01-25T00:00:00Z")),
149+
metadata = Map.empty
150+
)
151+
key.daysRemainingAt(now) shouldBe Some(10L)
152+
}
153+
154+
"daysSinceExpiryAt" should "return positive for expired keys" in {
155+
val now = Instant.parse("2026-01-20T00:00:00Z")
156+
val key = LicenseKey(
157+
id = "org-123",
158+
licenseType = LicenseType.Pro,
159+
features = Set(Feature.MaterializedViews),
160+
expiresAt = Some(Instant.parse("2026-01-15T00:00:00Z")),
161+
metadata = Map.empty
162+
)
163+
key.daysSinceExpiryAt(now) shouldBe Some(5L)
164+
}
165+
166+
it should "return negative for not-yet-expired keys" in {
167+
val now = Instant.parse("2026-01-10T00:00:00Z")
168+
val key = LicenseKey(
169+
id = "org-123",
170+
licenseType = LicenseType.Pro,
171+
features = Set(Feature.MaterializedViews),
172+
expiresAt = Some(Instant.parse("2026-01-15T00:00:00Z")),
173+
metadata = Map.empty
174+
)
175+
key.daysSinceExpiryAt(now) shouldBe Some(-5L)
176+
}
177+
178+
it should "be consistent with daysRemainingAt (sum to zero)" in {
179+
val now = Instant.parse("2026-01-15T00:00:00Z")
180+
val key = LicenseKey(
181+
id = "org-123",
182+
licenseType = LicenseType.Pro,
183+
features = Set(Feature.MaterializedViews),
184+
expiresAt = Some(Instant.parse("2026-01-22T00:00:00Z")),
185+
metadata = Map.empty
186+
)
187+
val remaining = key.daysRemainingAt(now).get
188+
val expired = key.daysSinceExpiryAt(now).get
189+
(remaining + expired) shouldBe 0L
190+
}
191+
192+
"LicenseKey.Community" should "carry Community quota with no usage or platform" in {
193+
LicenseKey.Community.quota shouldBe Some(Quota.Community)
194+
LicenseKey.Community.usage shouldBe None
195+
LicenseKey.Community.platform shouldBe None
196+
}
197+
198+
"LicenseKey with all new fields" should "preserve quota, usage, and platform" in {
199+
val key = LicenseKey(
200+
id = "pro-key",
201+
licenseType = LicenseType.Pro,
202+
features = Set(Feature.MaterializedViews, Feature.Federation),
203+
expiresAt = Some(Instant.now().plus(Duration.ofDays(365))),
204+
metadata = Map("org_name" -> "Acme Corp"),
205+
quota = Some(Quota.Pro),
206+
usage = Some(LicenseUsage(totalMvsActive = 10, totalFederatedClusters = 2)),
207+
platform = Some(Platform.Production)
208+
)
209+
key.quota shouldBe Some(Quota.Pro)
210+
key.usage shouldBe Some(LicenseUsage(totalMvsActive = 10, totalFederatedClusters = 2))
211+
key.platform shouldBe Some(Platform.Production)
212+
}
213+
214+
it should "support equality with identical field values" in {
215+
val now = Instant.now()
216+
val usage = LicenseUsage(totalMvsActive = 5, totalFederatedClusters = 1)
217+
val key1 = LicenseKey(
218+
"k",
219+
LicenseType.Pro,
220+
Set.empty,
221+
Some(now),
222+
quota = Some(Quota.Pro),
223+
usage = Some(usage),
224+
platform = Some(Platform.Staging)
225+
)
226+
val key2 = LicenseKey(
227+
"k",
228+
LicenseType.Pro,
229+
Set.empty,
230+
Some(now),
231+
quota = Some(Quota.Pro),
232+
usage = Some(usage),
233+
platform = Some(Platform.Staging)
234+
)
235+
key1 shouldBe key2
236+
}
141237
}

licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpec.scala

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,53 +21,45 @@ import org.scalatest.matchers.should.Matchers
2121

2222
class LicenseManagerSpec extends AnyFlatSpec with Matchers {
2323

24+
private val manager = new CommunityLicenseManager
25+
2426
"CommunityLicenseManager" should "include MaterializedViews" in {
25-
val manager = new CommunityLicenseManager
2627
manager.hasFeature(Feature.MaterializedViews) shouldBe true
2728
}
2829

2930
it should "include JdbcDriver" in {
30-
val manager = new CommunityLicenseManager
3131
manager.hasFeature(Feature.JdbcDriver) shouldBe true
3232
}
3333

34-
it should "not include FlightSql" in {
35-
val manager = new CommunityLicenseManager
36-
manager.hasFeature(Feature.FlightSql) shouldBe false
34+
it should "include FlightSql" in {
35+
manager.hasFeature(Feature.FlightSql) shouldBe true
3736
}
3837

3938
it should "not include Federation" in {
40-
val manager = new CommunityLicenseManager
4139
manager.hasFeature(Feature.Federation) shouldBe false
4240
}
4341

4442
it should "not include OdbcDriver" in {
45-
val manager = new CommunityLicenseManager
4643
manager.hasFeature(Feature.OdbcDriver) shouldBe false
4744
}
4845

4946
it should "not include UnlimitedResults" in {
50-
val manager = new CommunityLicenseManager
5147
manager.hasFeature(Feature.UnlimitedResults) shouldBe false
5248
}
5349

5450
it should "not include AdvancedAggregations" in {
55-
val manager = new CommunityLicenseManager
5651
manager.hasFeature(Feature.AdvancedAggregations) shouldBe false
5752
}
5853

5954
it should "return Community quotas" in {
60-
val manager = new CommunityLicenseManager
6155
manager.quotas shouldBe Quota.Community
6256
}
6357

6458
it should "always be Community type" in {
65-
val manager = new CommunityLicenseManager
6659
manager.licenseType shouldBe LicenseType.Community
6760
}
6861

6962
it should "reject any key validation" in {
70-
val manager = new CommunityLicenseManager
7163
manager.validate("PRO-test-key") shouldBe a[Left[_, _]]
7264
manager.validate("ENT-test-key") shouldBe a[Left[_, _]]
7365
manager.validate("anything") shouldBe a[Left[_, _]]
@@ -77,14 +69,21 @@ class LicenseManagerSpec extends AnyFlatSpec with Matchers {
7769
}
7870

7971
it should "return Left(RefreshNotSupported) on refresh" in {
80-
val manager = new CommunityLicenseManager
8172
manager.refresh() shouldBe Left(RefreshNotSupported)
8273
}
8374

75+
it should "return LicenseKey.Community as currentLicenseKey" in {
76+
manager.currentLicenseKey shouldBe LicenseKey.Community
77+
}
78+
79+
it should "not be a trial" in {
80+
manager.isTrial shouldBe false
81+
}
82+
8483
"LicenseManager trait" should "be source-compatible" in {
85-
val manager: LicenseManager = new CommunityLicenseManager
86-
manager.licenseType shouldBe LicenseType.Community
87-
manager.quotas shouldBe Quota.Community
84+
val m: LicenseManager = new CommunityLicenseManager
85+
m.licenseType shouldBe LicenseType.Community
86+
m.quotas shouldBe Quota.Community
8887
}
8988

9089
it should "default refresh to Left(RefreshNotSupported)" in {
@@ -97,8 +96,19 @@ class LicenseManagerSpec extends AnyFlatSpec with Matchers {
9796
stub.refresh() shouldBe Left(RefreshNotSupported)
9897
}
9998

99+
it should "default currentLicenseKey to LicenseKey.Community" in {
100+
val stub = new LicenseManager {
101+
def validate(key: String): Either[LicenseError, LicenseKey] = Left(InvalidLicense("stub"))
102+
def hasFeature(feature: Feature): Boolean = false
103+
def quotas: Quota = Quota.Community
104+
def licenseType: LicenseType = LicenseType.Community
105+
}
106+
stub.currentLicenseKey shouldBe LicenseKey.Community
107+
stub.isTrial shouldBe false
108+
}
109+
100110
"DefaultLicenseManager" should "be a deprecated alias for CommunityLicenseManager" in {
101-
val manager: DefaultLicenseManager = new CommunityLicenseManager
102-
manager shouldBe a[CommunityLicenseManager]
111+
val m: DefaultLicenseManager = new CommunityLicenseManager
112+
m shouldBe a[CommunityLicenseManager]
103113
}
104114
}

0 commit comments

Comments
 (0)