diff --git a/.fernignore b/.fernignore index d364963f..33401d08 100644 --- a/.fernignore +++ b/.fernignore @@ -2,6 +2,10 @@ .claude/ .github/CODEOWNERS .github/workflows/claude-code.yml +.github/workflows/ci.yml +.gitignore +WASM_VERSION +scripts/download-wasm.sh CLAUDE.md LICENSE README.md @@ -31,7 +35,6 @@ src/SchematicHQ.Client/Schematic.cs src/SchematicHQ.Client/SchematicHQ.Client.Custom.props src/SchematicHQ.Client/Webhooks/WebhookUtils/ src/SchematicHQ.Client/OpenFeature/ -src/SchematicHQ.Client/generate-schema-hash.sh .fern/replay.lock .fern/replay.yml .gitattributes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9eed61c6..1b6320a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,11 @@ concurrency: env: DOTNET_NOLOGO: true + # Authenticates `gh release download` in scripts/download-wasm.sh; the rules engine + # WASM lives in the private SchematicHQ/schematic-api repo, so the default + # GITHUB_TOKEN (scoped to this repo) cannot read it. Requires a GH_TOKEN repo + # secret with read access to schematic-api releases. + GH_TOKEN: ${{ secrets.GH_TOKEN }} jobs: ci: @@ -22,6 +27,9 @@ jobs: with: dotnet-version: 10.x + - name: Download WASM binary + run: ./scripts/download-wasm.sh + - name: Install tools run: dotnet tool restore diff --git a/.gitignore b/.gitignore index 11014f2b..f83e9c99 100644 --- a/.gitignore +++ b/.gitignore @@ -482,3 +482,7 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp + +# Rules engine WASM binary (downloaded at build time from schematic-api GitHub Releases) +src/SchematicHQ.Client/RulesEngine/Wasm/rulesengine.wasm +src/SchematicHQ.Client/RulesEngine/Wasm/.wasm_version diff --git a/WASM_VERSION b/WASM_VERSION index 1d0ba9ea..8f0916f7 100644 --- a/WASM_VERSION +++ b/WASM_VERSION @@ -1 +1 @@ -0.4.0 +0.5.0 diff --git a/scripts/download-wasm.sh b/scripts/download-wasm.sh new file mode 100755 index 00000000..787cacaf --- /dev/null +++ b/scripts/download-wasm.sh @@ -0,0 +1,68 @@ +#!/bin/bash +set -e + +# Downloads the rules engine WASM binary from the schematic-api GitHub Release. +# Reads the pinned version from WASM_VERSION at the repo root. +# +# The binary is intentionally NOT tracked in git (see .gitignore); it is fetched +# at build time both locally (via the DownloadWasm MSBuild target) and in CI. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +WASM_DIR="$REPO_ROOT/src/SchematicHQ.Client/RulesEngine/Wasm" +VERSION_FILE="$REPO_ROOT/WASM_VERSION" + +GITHUB_REPO="SchematicHQ/schematic-api" + +if [ ! -f "$VERSION_FILE" ]; then + echo "ERROR: WASM_VERSION file not found at $VERSION_FILE" + exit 1 +fi + +VERSION=$(tr -d '[:space:]' < "$VERSION_FILE") +TAG="rulesengine/v${VERSION}" +ASSET_NAME="rulesengine-wasm-csharp-v${VERSION}.tar.gz" + +# Skip download if binary already exists and version matches +if [ -f "$WASM_DIR/rulesengine.wasm" ] && [ -f "$WASM_DIR/.wasm_version" ]; then + CURRENT=$(tr -d '[:space:]' < "$WASM_DIR/.wasm_version") + if [ "$CURRENT" = "$VERSION" ]; then + echo "WASM binary already at version $VERSION, skipping download." + exit 0 + fi +fi + +echo "Downloading rules engine WASM v${VERSION}..." +mkdir -p "$WASM_DIR" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +if ! gh release download "$TAG" \ + -R "$GITHUB_REPO" \ + -p "$ASSET_NAME" \ + -D "$TMPDIR" 2>/dev/null; then + echo "ERROR: Failed to download WASM binary" + echo "Tag: $TAG" + echo "Asset: $ASSET_NAME" + echo "" + echo "If this is a new version, ensure a release exists at:" + echo " https://github.com/${GITHUB_REPO}/releases/tag/${TAG}" + echo "" + echo "Ensure the GitHub CLI is authenticated with access to ${GITHUB_REPO}: gh auth status" + exit 1 +fi + +tar -xzf "$TMPDIR/$ASSET_NAME" -C "$TMPDIR" + +if [ ! -f "$TMPDIR/rulesengine.wasm" ]; then + echo "ERROR: rulesengine.wasm not found in release archive" + ls -la "$TMPDIR" + exit 1 +fi + +cp "$TMPDIR"/rulesengine.wasm "$WASM_DIR/" + +echo "$VERSION" > "$WASM_DIR/.wasm_version" + +echo "Downloaded rules engine WASM v${VERSION} to $WASM_DIR/" diff --git a/src/SchematicHQ.Client.Test/RulesEngine/CompanyTests.cs b/src/SchematicHQ.Client.Test/RulesEngine/CompanyTests.cs deleted file mode 100644 index 6ca02436..00000000 --- a/src/SchematicHQ.Client.Test/RulesEngine/CompanyTests.cs +++ /dev/null @@ -1,154 +0,0 @@ -using SchematicHQ.Client.RulesEngine; -using System.Diagnostics; -using NUnit.Framework; - -namespace SchematicHQ.Client.Test.RulesEngine -{ - [TestFixture] - public class CompanyTests - { - [Test] - public void AddMetric_Adds_New_Metric_When_None_Exists_With_Same_Constraints() - { - // Arrange - var company = TestHelpers.CreateTestCompany(); - int initialCount = company.Metrics.Count(); - - // Act - var metric = TestHelpers.CreateTestMetric(company, "test-event", RulesengineMetricPeriod.AllTime, 5); - company.AddMetric(metric); - - // Assert - Assert.That(company.Metrics.Count(), Is.EqualTo(initialCount + 1)); - Assert.That(company.Metrics, Does.Contain(metric)); - } - - [Test] - public void AddMetric_Replaces_Existing_Metric_With_Same_Constraints() - { - // Arrange - var company = TestHelpers.CreateTestCompany(); - - // Add initial metric - string eventSubtype = "test-event"; - var period = RulesengineMetricPeriod.AllTime; - RulesengineMetricPeriodMonthReset monthReset = RulesengineMetricPeriodMonthReset.FirstOfMonth; - - var initialMetric = TestHelpers.CreateTestMetric(company, eventSubtype, period, 5); - company.AddMetric(initialMetric); - int initialCount = company.Metrics.Count(); - - // Act - Add metric with same constraints but different value - var newMetric = TestHelpers.CreateTestMetric(company, eventSubtype, period, 10); - company.AddMetric(newMetric); - - // Assert - // Verify the length hasn't changed but the new metric replaced the old one - Assert.That(company.Metrics.Count(), Is.EqualTo(initialCount)); - Assert.That(company.Metrics, Does.Contain(newMetric)); - Assert.That(company.Metrics, Does.Not.Contain(initialMetric)); - - // Find the metric and verify it has the new value - var foundMetric = CompanyMetricExtensions.Find(company.Metrics, eventSubtype, period, monthReset); - Assert.That(foundMetric, Is.Not.Null); - Assert.That(foundMetric!.Value, Is.EqualTo(10)); - } - - [Test] - public void AddMetric_Handles_Concurrent_Updates_Safely() - { - // Arrange - var company = TestHelpers.CreateTestCompany(); - - // Set up concurrent tasks to add metrics - const int numTasks = 10; - var tasks = new List(); - - // Act - for (int i = 0; i < numTasks; i++) - { - var index = i; // Capture the current index for the task - tasks.Add(Task.Run(() => - { - // Create a metric with a unique event subtype to avoid collision - string uniqueSubtype = $"test-event-{DateTime.Now.Ticks}-{index}"; - var metric = TestHelpers.CreateTestMetric(company, uniqueSubtype, RulesengineMetricPeriod.AllTime, index); - - // Add the metric - company.AddMetric(metric); - })); - } - - var taskArray = tasks.ToArray(); - Debug.WriteLine($"Starting {taskArray.Length} concurrent tasks to add metrics..."); - - // Wait for all tasks to finish - Task.WaitAll(tasks.ToArray()); - - // Assert - Assert.That(company.Metrics.Count(), Is.GreaterThanOrEqualTo(numTasks)); - } - - [Test] - public void AddMetric_NullCompany_DoesNotThrow() - { - // Arrange - RulesengineCompany? company = null; - var metric = new RulesengineCompanyMetric - { - AccountId = "acct", - EnvironmentId = "env", - CompanyId = "comp", - EventSubtype = "test", - Value = 0, - Period = new RulesengineMetricPeriod("all_time"), - MonthReset = RulesengineMetricPeriodMonthReset.FirstOfMonth, - CreatedAt = DateTime.UtcNow - }; - - // Act & Assert - Assert.DoesNotThrow(() => company?.AddMetric(metric)); - } - - [Test] - public void AddMetric_NullMetric_DoesNotThrow() - { - // Arrange - var company = TestHelpers.CreateTestCompany(); - - // Act & Assert - Assert.DoesNotThrow(() => company.AddMetric(null)); - } - - [Test] - public void AddMetric_CompanyWithNoMetrics_DoesNotThrow() - { - // Arrange - var company = new RulesengineCompany - { - Id = TestHelpers.GenerateTestId("comp"), - AccountId = TestHelpers.GenerateTestId("acct"), - EnvironmentId = TestHelpers.GenerateTestId("env"), - PlanIds = new List { TestHelpers.GenerateTestId("plan"), TestHelpers.GenerateTestId("plan") }, - BillingProductIds = new List { TestHelpers.GenerateTestId("bilp"), TestHelpers.GenerateTestId("bilp") }, - BasePlanId = TestHelpers.GenerateTestId("plan"), - Traits = new List(), - Subscription = new RulesengineSubscription - { - Id = TestHelpers.GenerateTestId("bilsub"), - PeriodStart = DateTime.UtcNow.AddDays(-30), - PeriodEnd = DateTime.UtcNow.AddDays(30) - }, - // Initialize with null metrics list - Metrics = null! - }; - - var metric = TestHelpers.CreateTestMetric(company, "foo", RulesengineMetricPeriod.AllTime, 1); - - // Act & Assert - Assert.DoesNotThrow(() => company.AddMetric(metric)); - Assert.That(company.Metrics, Is.Not.Null); - Assert.That(company.Metrics.Count(), Is.EqualTo(1)); - } - } -} diff --git a/src/SchematicHQ.Client.Test/RulesEngine/EnumResilienceTests.cs b/src/SchematicHQ.Client.Test/RulesEngine/EnumResilienceTests.cs index 8deceb85..c8ee879a 100644 --- a/src/SchematicHQ.Client.Test/RulesEngine/EnumResilienceTests.cs +++ b/src/SchematicHQ.Client.Test/RulesEngine/EnumResilienceTests.cs @@ -19,8 +19,7 @@ public void Setup() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, Converters = { - new ResilientEnumConverter(), - new ComparableTypeConverter() + new ResilientEnumConverter() } }; } @@ -39,34 +38,6 @@ public void RuleType_UnknownValue_ShouldFallbackToUnknown() }); } - [Test] - public void ComparableType_UnknownValue_ShouldFallbackToDefault() - { - // Arrange - string unknownComparableTypeJson = "\"unknown_comparable_type\""; - - // Act & Assert - Assert.DoesNotThrow(() => - { - var result = JsonSerializer.Deserialize(unknownComparableTypeJson, _options); - Assert.That(result, Is.EqualTo(ComparableType.Unknown)); // First enum value - }); - } - - [Test] - public void ComparableType_EmptyString_ShouldFallbackToDefault() - { - // Arrange - string emptyComparableTypeJson = "\"\""; - - // Act & Assert - Assert.DoesNotThrow(() => - { - var result = JsonSerializer.Deserialize(emptyComparableTypeJson, _options); - Assert.That(result, Is.EqualTo(ComparableType.Unknown)); - }); - } - [Test] public void RuleType_ValidValue_ShouldParseCorrectly() { @@ -80,19 +51,6 @@ public void RuleType_ValidValue_ShouldParseCorrectly() Assert.That(result, Is.EqualTo(RulesengineRuleType.Standard)); } - [Test] - public void ComparableType_ValidValue_ShouldParseCorrectly() - { - // Arrange - string validComparableTypeJson = "\"String\""; - - // Act - var result = JsonSerializer.Deserialize(validComparableTypeJson, _options); - - // Assert - Assert.That(result, Is.EqualTo(ComparableType.String)); - } - [Test] public void RuleType_SnakeCaseValue_ShouldParseCorrectly() { @@ -106,20 +64,6 @@ public void RuleType_SnakeCaseValue_ShouldParseCorrectly() Assert.That(result, Is.EqualTo(RulesengineRuleType.GlobalOverride)); } - [Test] - public void ComparableType_NullValue_ShouldFallbackToDefault() - { - // Arrange - string nullComparableTypeJson = "null"; - - // Act & Assert - Assert.DoesNotThrow(() => - { - var result = JsonSerializer.Deserialize(nullComparableTypeJson, _options); - Assert.That(result, Is.Null); - }); - } - [Test] public void RuleType_InvalidValue_ShouldFallbackToUnknown() { @@ -153,19 +97,6 @@ public void RuleType_EmptyValue_ShouldFallbackToUnknown() }); } - [TestCase("\"invalid_type\"", ComparableType.Unknown)] - [TestCase("\"nonexistent\"", ComparableType.Unknown)] - [TestCase("\"\"", ComparableType.Unknown)] - public void ComparableType_InvalidValues_ShouldFallbackToDefault(string json, ComparableType expected) - { - // Act & Assert - Assert.DoesNotThrow(() => - { - var result = JsonSerializer.Deserialize(json, _options); - Assert.That(result, Is.EqualTo(expected)); - }); - } - [Test] public void RuleType_Serialization_ShouldUseSnakeCase() { @@ -191,18 +122,5 @@ public void RuleType_UnknownSerialization_ShouldSerializeAsEmptyString() // Assert Assert.That(json, Is.EqualTo("\"\"")); } - - [Test] - public void ComparableType_Serialization_ShouldUseSnakeCase() - { - // Arrange - var comparableType = ComparableType.String; - - // Act - var json = JsonSerializer.Serialize(comparableType, _options); - - // Assert - Assert.That(json, Is.EqualTo("\"string\"")); - } } } diff --git a/src/SchematicHQ.Client.Test/RulesEngine/JsonDeserializationTests.cs b/src/SchematicHQ.Client.Test/RulesEngine/JsonDeserializationTests.cs index 4c64612f..d72c8ffd 100644 --- a/src/SchematicHQ.Client.Test/RulesEngine/JsonDeserializationTests.cs +++ b/src/SchematicHQ.Client.Test/RulesEngine/JsonDeserializationTests.cs @@ -21,7 +21,6 @@ public void SetUp() WriteIndented = false, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, Converters = { - new ComparableTypeConverter(), new ResilientEnumConverter(), new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower, true) } @@ -328,12 +327,6 @@ public void RuleType_WithInvalidValue_ShouldFallbackToUnknown() } // Test helper classes - private class TestComparableTypeObject - { - [JsonPropertyName("comparable_type")] - public ComparableType ComparableType { get; set; } - } - private class TestTraitDefinitionComparableTypeObject { [JsonPropertyName("comparable_type")] diff --git a/src/SchematicHQ.Client.Test/RulesEngine/MetricsTests.cs b/src/SchematicHQ.Client.Test/RulesEngine/MetricsTests.cs deleted file mode 100644 index a293df85..00000000 --- a/src/SchematicHQ.Client.Test/RulesEngine/MetricsTests.cs +++ /dev/null @@ -1,605 +0,0 @@ -using SchematicHQ.Client.RulesEngine; -using NUnit.Framework; - -namespace SchematicHQ.Client.Test.RulesEngine -{ - [TestFixture] - public class MetricsTests - { - [Test] - public void TestGetCurrentMetricPeriodStartForCalendarMetricPeriod() - { - // Test for CurrentDay - { - var result = Metrics.GetCurrentMetricPeriodStartForCalendarMetricPeriod(RulesengineMetricPeriod.CurrentDay); - Assert.That(result, Is.Not.Null); - - var expected = DateTime.UtcNow.Date; - Assert.That(result?.Year, Is.EqualTo(expected.Year)); - Assert.That(result?.Month, Is.EqualTo(expected.Month)); - Assert.That(result?.Day, Is.EqualTo(expected.Day)); - Assert.That(result?.Hour, Is.EqualTo(0)); - Assert.That(result?.Minute, Is.EqualTo(0)); - Assert.That(result?.Second, Is.EqualTo(0)); - } - - // Test for CurrentWeek - { - var result = Metrics.GetCurrentMetricPeriodStartForCalendarMetricPeriod(RulesengineMetricPeriod.CurrentWeek); - Assert.That(result, Is.Not.Null); - - var now = DateTime.UtcNow; - int daysSinceMonday = ((int)now.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7; - var expected = now.Date.AddDays(-daysSinceMonday); - Assert.That(result?.Year, Is.EqualTo(expected.Year)); - Assert.That(result?.Month, Is.EqualTo(expected.Month)); - Assert.That(result?.Day, Is.EqualTo(expected.Day)); - Assert.That(result?.Hour, Is.EqualTo(0)); - Assert.That(result?.Minute, Is.EqualTo(0)); - Assert.That(result?.Second, Is.EqualTo(0)); - Assert.That(result?.DayOfWeek, Is.EqualTo(DayOfWeek.Monday)); - } - - // Test for CurrentMonth - { - var result = Metrics.GetCurrentMetricPeriodStartForCalendarMetricPeriod(RulesengineMetricPeriod.CurrentMonth); - Assert.That(result, Is.Not.Null); - - var now = DateTime.UtcNow; - var expected = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc); - Assert.That(result?.Year, Is.EqualTo(expected.Year)); - Assert.That(result?.Month, Is.EqualTo(expected.Month)); - Assert.That(result?.Day, Is.EqualTo(expected.Day)); - Assert.That(result?.Hour, Is.EqualTo(0)); - Assert.That(result?.Minute, Is.EqualTo(0)); - Assert.That(result?.Second, Is.EqualTo(0)); - Assert.That(result?.Day, Is.EqualTo(1)); - } - - // Test for AllTime - { - var result = Metrics.GetCurrentMetricPeriodStartForCalendarMetricPeriod(RulesengineMetricPeriod.AllTime); - Assert.That(result, Is.Null); - } - } - - [Test] - public void TestGetCurrentMetricPeriodStartForCompanyBillingSubscription() - { - // Test for null company - { - var result = Metrics.GetCurrentMetricPeriodStartForCompanyBillingSubscription(null); - Assert.That(result, Is.Not.Null); - - var now = DateTime.UtcNow; - var expected = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc); - Assert.That(result?.Year, Is.EqualTo(expected.Year)); - Assert.That(result?.Month, Is.EqualTo(expected.Month)); - Assert.That(result?.Day, Is.EqualTo(1)); - } - - // Test for company with null subscription - { - var company = TestHelpers.CreateTestCompany(); - company.Subscription = null; - var result = Metrics.GetCurrentMetricPeriodStartForCompanyBillingSubscription(company); - Assert.That(result, Is.Not.Null); - - var now = DateTime.UtcNow; - var expected = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc); - Assert.That(result?.Year, Is.EqualTo(expected.Year)); - Assert.That(result?.Month, Is.EqualTo(expected.Month)); - Assert.That(result?.Day, Is.EqualTo(1)); - } - - // Test for subscription period start in future - { - var company = TestHelpers.CreateTestCompany(); - company.Subscription = new RulesengineSubscription - { - Id = "test-subscription", - PeriodStart = DateTime.UtcNow.AddDays(7), - PeriodEnd = DateTime.UtcNow.AddDays(37) - }; - - var result = Metrics.GetCurrentMetricPeriodStartForCompanyBillingSubscription(company); - Assert.That(result, Is.Not.Null); - - var now = DateTime.UtcNow; - var expected = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc); - Assert.That(result?.Year, Is.EqualTo(expected.Year)); - Assert.That(result?.Month, Is.EqualTo(expected.Month)); - Assert.That(result?.Day, Is.EqualTo(1)); - } - - // Test for current month reset date not passed yet - { - var now = DateTime.UtcNow; - var company = TestHelpers.CreateTestCompany(); - - // Set subscription to start on a day later in the month than today - int futureDay = 15; - if (now.Day >= 15) // Avoid month boundary issues - { - futureDay = 5; - } - - company.Subscription = new RulesengineSubscription - { - Id = "test-subscription", - PeriodStart = new DateTime( - now.Year - 1, - now.Month, - futureDay, - 12, 0, 0, - DateTimeKind.Utc), - PeriodEnd = now.AddMonths(6) - }; - - var result = Metrics.GetCurrentMetricPeriodStartForCompanyBillingSubscription(company); - Assert.That(result, Is.Not.Null); - - // In this case, the result should be last month's reset date - DateTime expected; - if (now.Day < 15) - { - expected = new DateTime( - now.Year, - now.Month, - futureDay, - 12, 0, 0, - DateTimeKind.Utc); - } - else - { - expected = new DateTime( - now.Year, - now.Month + 1, - futureDay, - 12, 0, 0, - DateTimeKind.Utc); - if (now.Month == 12) - { - expected = new DateTime( - now.Year + 1, - 1, // January - futureDay, - 12, 0, 0, - DateTimeKind.Utc); - } - } - - Assert.That(result?.Year, Is.EqualTo(expected.Year)); - Assert.That(result?.Month, Is.EqualTo(expected.Month)); - Assert.That(result?.Day, Is.EqualTo(expected.Day)); - Assert.That(result?.Hour, Is.EqualTo(expected.Hour)); - } - - // Test for current month reset date has passed - { - var now = DateTime.UtcNow; - var company = TestHelpers.CreateTestCompany(); - - // Set subscription to start on a day earlier in the month than today - int pastDay = now.Day - 5; - if (pastDay < 1) - { - pastDay = 1; - } - - company.Subscription = new RulesengineSubscription - { - Id = "test-subscription", - PeriodStart = new DateTime( - now.Year - 1, - now.Month, - pastDay, - 12, 0, 0, - DateTimeKind.Utc), - PeriodEnd = now.AddMonths(6) - }; - - var result = Metrics.GetCurrentMetricPeriodStartForCompanyBillingSubscription(company); - Assert.That(result, Is.Not.Null); - - // In this case, the result should be this month's reset date - var expected = new DateTime( - now.Year, - now.Month+1, - pastDay, - 12, 0, 0, - DateTimeKind.Utc); - - if (now.Month == 12) // December - { - expected = new DateTime( - now.Year + 1, - 1, // January - pastDay, - 12, 0, 0, - DateTimeKind.Utc); - } - - Assert.That(result?.Year, Is.EqualTo(expected.Year)); - Assert.That(result?.Month, Is.EqualTo(expected.Month)); - Assert.That(result?.Day, Is.EqualTo(expected.Day)); - Assert.That(result?.Hour, Is.EqualTo(expected.Hour)); - } - - // Test for reset date before subscription period start - { - var now = DateTime.UtcNow; - var company = TestHelpers.CreateTestCompany(); - - var periodStart = now.AddMonths(-6); - // Set a recent subscription start date (10 days ago) - company.Subscription = new RulesengineSubscription - { - Id = "test-subscription", - PeriodStart = periodStart, - PeriodEnd = now.AddDays(10) - }; - - var result = Metrics.GetCurrentMetricPeriodStartForCompanyBillingSubscription(company); - Assert.That(result, Is.Not.Null); - - // The result should be the period start date - Assert.That(result?.Year, Is.EqualTo(company.Subscription.PeriodEnd.Year)); - Assert.That(result?.Month, Is.EqualTo(company.Subscription.PeriodEnd.Month)); - Assert.That(result?.Day, Is.EqualTo(company.Subscription.PeriodEnd.Day)); - Assert.That(result?.Hour, Is.EqualTo(company.Subscription.PeriodEnd.Hour)); - } - } - - [Test] - public void TestGetNextMetricPeriodStartForCalendarMetricPeriod() - { - // Test for CurrentDay - { - var result = Metrics.GetNextMetricPeriodStartForCalendarMetricPeriod(RulesengineMetricPeriod.CurrentDay); - Assert.That(result, Is.Not.Null); - - var expected = DateTime.UtcNow.Date.AddDays(1); - Assert.That(result?.Year, Is.EqualTo(expected.Year)); - Assert.That(result?.Month, Is.EqualTo(expected.Month)); - Assert.That(result?.Day, Is.EqualTo(expected.Day)); - Assert.That(result?.Hour, Is.EqualTo(0)); - Assert.That(result?.Minute, Is.EqualTo(0)); - Assert.That(result?.Second, Is.EqualTo(0)); - } - - // Test for CurrentWeek - { - var result = Metrics.GetNextMetricPeriodStartForCalendarMetricPeriod(RulesengineMetricPeriod.CurrentWeek); - Assert.That(result, Is.Not.Null); - - var now = DateTime.UtcNow; - int daysUntilMonday = ((int)DayOfWeek.Monday - (int)now.DayOfWeek + 7) % 7; - if (daysUntilMonday == 0) - daysUntilMonday = 7; // If today is Monday, get next Monday - var expected = now.Date.AddDays(daysUntilMonday); - Assert.That(result?.Year, Is.EqualTo(expected.Year)); - Assert.That(result?.Month, Is.EqualTo(expected.Month)); - Assert.That(result?.Day, Is.EqualTo(expected.Day)); - Assert.That(result?.Hour, Is.EqualTo(0)); - Assert.That(result?.Minute, Is.EqualTo(0)); - Assert.That(result?.Second, Is.EqualTo(0)); - Assert.That(result?.DayOfWeek, Is.EqualTo(DayOfWeek.Monday)); - } - - // Test for CurrentMonth - { - var result = Metrics.GetNextMetricPeriodStartForCalendarMetricPeriod(RulesengineMetricPeriod.CurrentMonth); - Assert.That(result, Is.Not.Null); - - var now = DateTime.UtcNow; - var firstDayOfCurrentMonth = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc); - var expected = firstDayOfCurrentMonth.AddMonths(1); - Assert.That(result?.Year, Is.EqualTo(expected.Year)); - Assert.That(result?.Month, Is.EqualTo(expected.Month)); - Assert.That(result?.Day, Is.EqualTo(expected.Day)); - Assert.That(result?.Hour, Is.EqualTo(0)); - Assert.That(result?.Minute, Is.EqualTo(0)); - Assert.That(result?.Second, Is.EqualTo(0)); - Assert.That(result?.Day, Is.EqualTo(1)); - } - - // Test for AllTime - { - var result = Metrics.GetNextMetricPeriodStartForCalendarMetricPeriod(RulesengineMetricPeriod.AllTime); - Assert.That(result, Is.Null); - } - } - - [Test] - public void TestGetNextMetricPeriodStartForCompanyBillingSubscription() - { - // Test for null company - { - var result = Metrics.GetNextMetricPeriodStartForCompanyBillingSubscription(null); - Assert.That(result, Is.Not.Null); - - var now = DateTime.UtcNow; - var firstDayOfCurrentMonth = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc); - var expected = firstDayOfCurrentMonth.AddMonths(1); - Assert.That(result?.Year, Is.EqualTo(expected.Year)); - Assert.That(result?.Month, Is.EqualTo(expected.Month)); - Assert.That(result?.Day, Is.EqualTo(1)); - } - - // Test for company with null subscription - { - var company = TestHelpers.CreateTestCompany(); - company.Subscription = null; - var result = Metrics.GetNextMetricPeriodStartForCompanyBillingSubscription(company); - Assert.That(result, Is.Not.Null); - - var now = DateTime.UtcNow; - var firstDayOfCurrentMonth = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc); - var expected = firstDayOfCurrentMonth.AddMonths(1); - Assert.That(result?.Year, Is.EqualTo(expected.Year)); - Assert.That(result?.Month, Is.EqualTo(expected.Month)); - Assert.That(result?.Day, Is.EqualTo(1)); - } - - // Test for subscription period start in future - { - var company = TestHelpers.CreateTestCompany(); - var now = DateTime.UtcNow; - - // Set subscription to start 7 days from now - company.Subscription = new RulesengineSubscription - { - Id = "test-subscription", - PeriodStart = now.AddDays(7), - PeriodEnd = now.AddDays(37) - }; - - var result = Metrics.GetNextMetricPeriodStartForCompanyBillingSubscription(company); - Assert.That(result, Is.Not.Null); - - // If period start is sooner than next month, period start should be used - var firstDayOfCurrentMonth = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc); - var startOfNextMonth = firstDayOfCurrentMonth.AddMonths(1); - - if (company.Subscription.PeriodStart > startOfNextMonth) - { - // Period start is after next month, so next month should be used - Assert.That(result, Is.EqualTo(startOfNextMonth)); - } - else - { - // Period start is before next month, so period start should be used - Assert.That(result, Is.EqualTo(company.Subscription.PeriodStart)); - } - } - - // Test for next reset date after subscription end - { - var company = TestHelpers.CreateTestCompany(); - var now = DateTime.UtcNow; - - // Set subscription to have started some time ago - company.Subscription = new RulesengineSubscription - { - Id = "test-subscription", - PeriodStart = now.AddMonths(-6), - PeriodEnd = now.AddDays(10) // But set it to end soon (before the next monthly reset) - }; - - var result = Metrics.GetNextMetricPeriodStartForCompanyBillingSubscription(company); - Assert.That(result, Is.Not.Null); - - // The result should be the period end date - Assert.That(result, Is.EqualTo(company.Subscription.PeriodEnd)); - } - - // Test for current month reset date has passed - { - var now = DateTime.UtcNow; - var company = TestHelpers.CreateTestCompany(); - - // Set subscription to start on a day earlier in the month than today - int pastDay = now.Day - 5; - if (pastDay < 1) - { - pastDay = 1; - } - - company.Subscription = new RulesengineSubscription - { - Id = "test-subscription", - PeriodStart = new DateTime( - now.Year - 1, - now.Month, - pastDay, - 12, 0, 0, - DateTimeKind.Utc), - PeriodEnd = now.AddYears(1) // Set end date far in the future - }; - - var result = Metrics.GetNextMetricPeriodStartForCompanyBillingSubscription(company); - Assert.That(result, Is.Not.Null); - - // In this case, the result should be next month's reset date - DateTime expected; - if (now.Month == 12) // December - { - expected = new DateTime( - now.Year + 1, - 1, // January - pastDay, - 12, 0, 0, - DateTimeKind.Utc); - } - else - { - expected = new DateTime( - now.Year, - now.Month + 1, - Math.Min(pastDay, DateTime.DaysInMonth(now.Year, now.Month + 1)), - 12, 0, 0, - DateTimeKind.Utc); - } - - Assert.That(result?.Year, Is.EqualTo(expected.Year)); - Assert.That(result?.Month, Is.EqualTo(expected.Month)); - Assert.That(result?.Day, Is.EqualTo(expected.Day)); - Assert.That(result?.Hour, Is.EqualTo(expected.Hour)); - } - - // Test for current month reset date has not passed yet - { - var now = DateTime.UtcNow; - var company = TestHelpers.CreateTestCompany(); - - // Set subscription to start on a day later in the month than today - // Ensure we have a truly future day by adding a safety margin - int futureDay = now.Day + 2; - if (futureDay > 28) // Avoid month boundary issues - { - // If we can't get a future day this month, use a day earlier this month - // and expect next month's reset date instead - futureDay = Math.Max(1, now.Day - 2); - - company.Subscription = new RulesengineSubscription - { - Id = "test-subscription", - PeriodStart = new DateTime( - now.Year - 1, - now.Month, - futureDay, - 12, 0, 0, - DateTimeKind.Utc), - PeriodEnd = now.AddYears(1) // Set end date far in the future - }; - - var result = Metrics.GetNextMetricPeriodStartForCompanyBillingSubscription(company); - Assert.That(result, Is.Not.Null); - - // Since the reset day is in the past this month, the next reset should be next month's reset date - DateTime expected; - if (now.Month == 12) // December - { - expected = new DateTime( - now.Year + 1, - 1, // January - futureDay, - 12, 0, 0, - DateTimeKind.Utc); - } - else - { - expected = new DateTime( - now.Year, - now.Month + 1, - Math.Min(futureDay, DateTime.DaysInMonth(now.Year, now.Month + 1)), - 12, 0, 0, - DateTimeKind.Utc); - } - - Assert.That(result?.Year, Is.EqualTo(expected.Year)); - Assert.That(result?.Month, Is.EqualTo(expected.Month)); - Assert.That(result?.Day, Is.EqualTo(expected.Day)); - Assert.That(result?.Hour, Is.EqualTo(expected.Hour)); - } - else - { - company.Subscription = new RulesengineSubscription - { - Id = "test-subscription", - PeriodStart = new DateTime( - now.Year - 1, - now.Month, - futureDay, - 12, 0, 0, - DateTimeKind.Utc), - PeriodEnd = now.AddYears(1) // Set end date far in the future - }; - - var result = Metrics.GetNextMetricPeriodStartForCompanyBillingSubscription(company); - Assert.That(result, Is.Not.Null); - - // Since the reset day is in the future this month, the next reset should be this month's reset date - DateTime expected = new DateTime( - now.Year, - now.Month, - futureDay, - 12, 0, 0, - DateTimeKind.Utc); - - Assert.That(result?.Year, Is.EqualTo(expected.Year)); - Assert.That(result?.Month, Is.EqualTo(expected.Month)); - Assert.That(result?.Day, Is.EqualTo(expected.Day)); - Assert.That(result?.Hour, Is.EqualTo(expected.Hour)); - } - } - } - - [Test] - public void TestGetNextMetricPeriodStartFromCondition() - { - // Test for null condition - { - var result = Metrics.GetNextMetricPeriodStartFromCondition(null, null); - Assert.That(result, Is.Null); - } - - // Test for condition that is not metric type - { - var condition = TestHelpers.CreateTestCondition(RulesengineConditionType.Trait); - var result = Metrics.GetNextMetricPeriodStartFromCondition(condition, null); - Assert.That(result, Is.Null); - } - - // Test for metric period is null - { - var condition = TestHelpers.CreateTestCondition(RulesengineConditionType.Metric); - condition.MetricPeriod = null; - var result = Metrics.GetNextMetricPeriodStartFromCondition(condition, null); - Assert.That(result, Is.Null); - } - - // Test for metric period is all time - { - var condition = TestHelpers.CreateTestCondition(RulesengineConditionType.Metric); - condition.MetricPeriod = RulesengineMetricPeriod.AllTime; - var result = Metrics.GetNextMetricPeriodStartFromCondition(condition, null); - Assert.That(result, Is.Null); - } - - // Test for metric period is current month with billing cycle reset - { - var company = TestHelpers.CreateTestCompany(); - company.Subscription = new RulesengineSubscription - { - Id = "test-subscription", - PeriodStart = DateTime.UtcNow.AddMonths(-1), - PeriodEnd = DateTime.UtcNow.AddMonths(1) - }; - - var condition = TestHelpers.CreateTestCondition(RulesengineConditionType.Metric); - condition.MetricPeriod = RulesengineMetricPeriod.CurrentMonth; - condition.MetricPeriodMonthReset = RulesengineMetricPeriodMonthReset.BillingCycle; - - var result = Metrics.GetNextMetricPeriodStartFromCondition(condition, company); - var expected = Metrics.GetNextMetricPeriodStartForCompanyBillingSubscription(company); - - Assert.That(result, Is.Not.Null); - Assert.That(result, Is.EqualTo(expected)); - } - - // Test for metric period is calendar-based - { - var condition = TestHelpers.CreateTestCondition(RulesengineConditionType.Metric); - condition.MetricPeriod = RulesengineMetricPeriod.CurrentDay; - - var result = Metrics.GetNextMetricPeriodStartFromCondition(condition, null); - var expected = Metrics.GetNextMetricPeriodStartForCalendarMetricPeriod(RulesengineMetricPeriod.CurrentDay); - - Assert.That(result, Is.Not.Null); - Assert.That(result, Is.EqualTo(expected)); - } - } - } -} diff --git a/src/SchematicHQ.Client.Test/RulesEngine/RuleCheckTests.cs b/src/SchematicHQ.Client.Test/RulesEngine/RuleCheckTests.cs deleted file mode 100644 index 190d4bac..00000000 --- a/src/SchematicHQ.Client.Test/RulesEngine/RuleCheckTests.cs +++ /dev/null @@ -1,474 +0,0 @@ -using NUnit.Framework; -using SchematicHQ.Client.RulesEngine; -using SchematicHQ.Client.RulesEngine.Utils; - -namespace SchematicHQ.Client.Test.RulesEngine -{ - [TestFixture] - public class RuleCheckServiceTests - { - [Test] - public async Task Check_ReturnsTrue_ForGlobalOverrideRules() - { - // Arrange - var svc = RuleCheckService.NewRuleCheckService(); - var company = TestHelpers.CreateTestCompany(); - var rule = TestHelpers.CreateTestRule(); - rule.RuleType = RulesengineRuleType.GlobalOverride; - - // Act - var result = await svc.Check(new CheckScope - { - Company = company, - Rule = rule - }); - - // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result.Match, Is.True); - } - - [Test] - public async Task Check_ReturnsFalse_ForNilRule() - { - // Arrange - var svc = RuleCheckService.NewRuleCheckService(); - var company = TestHelpers.CreateTestCompany(); - - // Act - var result = await svc.Check(new CheckScope - { - Company = company, - Rule = null - }); - - // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result.Match, Is.False); - } - - [Test] - public async Task Check_ReturnsTrue_ForDefaultRules() - { - // Arrange - var svc = RuleCheckService.NewRuleCheckService(); - var company = TestHelpers.CreateTestCompany(); - var rule = TestHelpers.CreateTestRule(); - rule.RuleType = RulesengineRuleType.Default; - - // Act - var result = await svc.Check(new CheckScope - { - Company = company, - Rule = rule - }); - - // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result.Match, Is.True); - } - - [Test] - public async Task Rule_Matches_SpecificCompany() - { - // Arrange - var svc = RuleCheckService.NewRuleCheckService(); - var company = TestHelpers.CreateTestCompany(); - var rule = TestHelpers.CreateTestRule(); - - // Create condition targeting the company - var condition = TestHelpers.CreateTestCondition(RulesengineConditionType.Company); - condition.ResourceIds = new List { company.Id }; - rule.Conditions = new List { condition }; - - // Act - var result = await svc.Check(new CheckScope - { - Company = company, - Rule = rule - }); - - // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result.Match, Is.True); - } - - [Test] - public async Task Rule_Matches_When_Metric_Within_Limit() - { - // Arrange - var svc = RuleCheckService.NewRuleCheckService(); - var company = TestHelpers.CreateTestCompany(); - - string eventSubtype = "test-event"; - var rule = TestHelpers.CreateTestRule(); - var condition = TestHelpers.CreateTestCondition(RulesengineConditionType.Metric); - condition.EventSubtype = eventSubtype; - condition.MetricValue = 10; - condition.Operator = ComparableOperator.Lte; - rule.Conditions = new List { condition }; - - var metric = TestHelpers.CreateTestMetric(company, eventSubtype, condition.MetricPeriod!.Value, 5); - company.Metrics = new List { metric }; - - // Act - var result = await svc.Check(new CheckScope - { - Company = company, - Rule = rule - }); - - // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result.Match, Is.True); - } - - [Test] - public async Task Rule_DoesNotMatch_When_Metric_Exceeds_Limit() - { - // Arrange - var svc = RuleCheckService.NewRuleCheckService(); - var company = TestHelpers.CreateTestCompany(); - - string eventSubtype = "test-event"; - var rule = TestHelpers.CreateTestRule(); - var condition = TestHelpers.CreateTestCondition(RulesengineConditionType.Metric); - condition.EventSubtype = eventSubtype; - condition.MetricValue = 5; - condition.Operator = ComparableOperator.Lte; - rule.Conditions = new List { condition }; - - var metric = TestHelpers.CreateTestMetric(company, eventSubtype, condition.MetricPeriod!.Value, 6); // Value exceeds limit - company.Metrics = new List { metric }; - - // Act - var result = await svc.Check(new CheckScope - { - Company = company, - Rule = rule - }); - - // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result.Match, Is.False); - } - - [Test] - public async Task Trait_Evaluation_MatchesWhenValueMatches() - { - // Arrange - var svc = RuleCheckService.NewRuleCheckService(); - var company = TestHelpers.CreateTestCompany(); - var trait = TestHelpers.CreateTestTrait("test-value", null); - company.Traits = new List { trait }; - - var rule = TestHelpers.CreateTestRule(); - var condition = TestHelpers.CreateTestCondition(RulesengineConditionType.Trait); - condition.TraitDefinition = trait.TraitDefinition; - condition.TraitValue = "test-value"; - condition.Operator = ComparableOperator.Eq; - rule.Conditions = new List { condition }; - - // Act - var result = await svc.Check(new CheckScope - { - Company = company, - Rule = rule - }); - - // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result.Match, Is.True); - } - - [Test] - public async Task ConditionGroup_Matches_When_Any_Condition_Matches() - { - // Arrange - var svc = RuleCheckService.NewRuleCheckService(); - var company = TestHelpers.CreateTestCompany(); - - var rule = TestHelpers.CreateTestRule(); - var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionType.Company); - var condition2 = TestHelpers.CreateTestCondition(RulesengineConditionType.Company); - condition2.ResourceIds = new List { company.Id }; - - var group = new RulesengineConditionGroup - { - Conditions = new List { condition1, condition2 } - }; - rule.ConditionGroups = new List { group }; - - // Act - var result = await svc.Check(new CheckScope - { - Company = company, - Rule = rule - }); - - // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result.Match, Is.True); - } - - [Test] - public async Task ConditionGroup_DoesNotMatch_When_No_Conditions_Match() - { - // Arrange - var svc = RuleCheckService.NewRuleCheckService(); - var company = TestHelpers.CreateTestCompany(); - - var rule = TestHelpers.CreateTestRule(); - var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionType.Company); - var condition2 = TestHelpers.CreateTestCondition(RulesengineConditionType.Company); - // No matching condition added to the group - - var group = new RulesengineConditionGroup - { - Conditions = new List { condition1, condition2 } - }; - rule.ConditionGroups = new List { group }; - - // Act - var result = await svc.Check(new CheckScope - { - Company = company, - Rule = rule - }); - - // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result.Match, Is.False); - } - - [Test] - public async Task User_Trait_Evaluation() - { - // Arrange - var svc = RuleCheckService.NewRuleCheckService(); - var user = TestHelpers.CreateTestUser(); - var traitDef = TestHelpers.CreateTestTraitDefinition(RulesengineTraitDefinitionComparableType.String, RulesengineEntityType.User); - var trait = TestHelpers.CreateTestTrait("user-trait-value", traitDef); - user.Traits = new List { trait }; - - var rule = TestHelpers.CreateTestRule(); - var condition = TestHelpers.CreateTestCondition(RulesengineConditionType.Trait); - condition.TraitDefinition = traitDef; - condition.TraitValue = "user-trait-value"; - condition.Operator = ComparableOperator.Eq; - rule.Conditions = new List { condition }; - - // Act - var result = await svc.Check(new CheckScope - { - User = user, - Rule = rule - }); - - // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result.Match, Is.True); - } - - [Test] - public async Task Multiple_Conditions_All_Must_Match() - { - // Arrange - var svc = RuleCheckService.NewRuleCheckService(); - var company = TestHelpers.CreateTestCompany(); - var trait = TestHelpers.CreateTestTrait("test-value", null); - company.Traits = new List { trait }; - - var rule = TestHelpers.CreateTestRule(); - - // Company condition - var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionType.Company); - condition1.ResourceIds = new List { company.Id }; - - // Trait condition - var condition2 = TestHelpers.CreateTestCondition(RulesengineConditionType.Trait); - condition2.TraitDefinition = trait.TraitDefinition; - condition2.TraitValue = "test-value"; - condition2.Operator = ComparableOperator.Eq; - - rule.Conditions = new List { condition1, condition2 }; - - // Act - var result = await svc.Check(new CheckScope - { - Company = company, - Rule = rule - }); - - // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result.Match, Is.True); - } - - [Test] - public async Task Multiple_Conditions_Fail_If_Any_Does_Not_Match() - { - // Arrange - var svc = RuleCheckService.NewRuleCheckService(); - var company = TestHelpers.CreateTestCompany(); - var trait = TestHelpers.CreateTestTrait("test-value", null); - company.Traits = new List { trait }; - - var rule = TestHelpers.CreateTestRule(); - - // Company condition - var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionType.Company); - condition1.ResourceIds = new List { company.Id }; - - // Trait condition that doesn't match - var condition2 = TestHelpers.CreateTestCondition(RulesengineConditionType.Trait); - condition2.TraitDefinition = trait.TraitDefinition; - condition2.TraitValue = "different-value"; - condition2.Operator = ComparableOperator.Eq; - - rule.Conditions = new List { condition1, condition2 }; - - // Act - var result = await svc.Check(new CheckScope - { - Company = company, - Rule = rule - }); - - // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result.Match, Is.False); - } - - [Test] - public async Task Rule_Matches_WithSufficientCreditBalance() - { - // Arrange - var svc = RuleCheckService.NewRuleCheckService(); - - // Create a company with a credit balance - var company = TestHelpers.CreateTestCompany(); - var creditId = "test-credit-id"; - company.CreditBalances = new Dictionary - { - { creditId, 100.0 } // Set a credit balance of 100.0 - }; - - // Create a rule with a credit condition - var rule = TestHelpers.CreateTestRule(); - var condition = TestHelpers.CreateTestCondition(RulesengineConditionType.Credit); - condition.CreditId = creditId; - condition.ConsumptionRate = 50.0; // Consumption rate less than the balance - rule.Conditions = new List { condition }; - - // Act - var result = await svc.Check(new CheckScope - { - Company = company, - Rule = rule - }); - - // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result.Match, Is.True); - } - - [Test] - public async Task Rule_DoesNotMatch_WithInsufficientCreditBalance() - { - // Arrange - var svc = RuleCheckService.NewRuleCheckService(); - - // Create a company with a credit balance - var company = TestHelpers.CreateTestCompany(); - var creditId = "test-credit-id"; - company.CreditBalances = new Dictionary - { - { creditId, 20.0 } // Set a credit balance of 20.0 - }; - - // Create a rule with a credit condition - var rule = TestHelpers.CreateTestRule(); - var condition = TestHelpers.CreateTestCondition(RulesengineConditionType.Credit); - condition.CreditId = creditId; - condition.ConsumptionRate = 50.0; // Consumption rate more than the balance - rule.Conditions = new List { condition }; - - // Act - var result = await svc.Check(new CheckScope - { - Company = company, - Rule = rule - }); - - // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result.Match, Is.False); - } - - [Test] - public async Task Rule_Matches_WithDefaultConsumptionRate() - { - // Arrange - var svc = RuleCheckService.NewRuleCheckService(); - - // Create a company with a credit balance - var company = TestHelpers.CreateTestCompany(); - var creditId = "test-credit-id"; - company.CreditBalances = new Dictionary - { - { creditId, 2.0 } // Set a credit balance of 2.0 - }; - - // Create a rule with a credit condition - var rule = TestHelpers.CreateTestRule(); - var condition = TestHelpers.CreateTestCondition(RulesengineConditionType.Credit); - condition.CreditId = creditId; - condition.ConsumptionRate = null; // Default consumption rate is 1.0 - rule.Conditions = new List { condition }; - - // Act - var result = await svc.Check(new CheckScope - { - Company = company, - Rule = rule - }); - - // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result.Match, Is.True); - } - - [Test] - public async Task Rule_DoesNotMatch_WithNonExistentCreditId() - { - // Arrange - var svc = RuleCheckService.NewRuleCheckService(); - - // Create a company with a credit balance - var company = TestHelpers.CreateTestCompany(); - company.CreditBalances = new Dictionary - { - { "existing-credit-id", 100.0 } - }; - - // Create a rule with a credit condition for a different credit ID - var rule = TestHelpers.CreateTestRule(); - var condition = TestHelpers.CreateTestCondition(RulesengineConditionType.Credit); - condition.CreditId = "non-existent-credit-id"; // Different from the one in company - condition.ConsumptionRate = 1.0; - rule.Conditions = new List { condition }; - - // Act - var result = await svc.Check(new CheckScope - { - Company = company, - Rule = rule - }); - - // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result.Match, Is.False); - } - } -} diff --git a/src/SchematicHQ.Client.Test/RulesEngine/SchemaVersionGeneratorTests.cs b/src/SchematicHQ.Client.Test/RulesEngine/SchemaVersionGeneratorTests.cs new file mode 100644 index 00000000..e209b3f9 --- /dev/null +++ b/src/SchematicHQ.Client.Test/RulesEngine/SchemaVersionGeneratorTests.cs @@ -0,0 +1,30 @@ +using NUnit.Framework; +using SchematicHQ.Client; +using SchematicHQ.Client.RulesEngine.Utils; + +namespace SchematicHQ.Client.Test.RulesEngine +{ + /// + /// Guards the reflection-based resolution of the datastream cache version from the + /// Fern-generated enum. If a future codegen changes + /// the enum's shape so the single real value can't be found, this catches the regression + /// (the resolver would silently fall back to "1"). + /// + [TestFixture] + public class SchemaVersionGeneratorTests + { + [Test] + public void Resolves_Current_Fern_Schema_Version_Not_Fallback() + { + var version = SchemaVersionGenerator.GetGlobalSchemaVersion(); + + Assert.That(version, Is.Not.Null.And.Not.Empty); + // Not the "unexpected enum shape" fallback... + Assert.That(version, Is.Not.EqualTo("1")); + // ...and not the Fern placeholder member. + Assert.That(version, Is.Not.EqualTo("placeholder-for-fern-compatibility")); + // It must be one of the real (non-placeholder) values on the generated enum. + Assert.That(version, Is.EqualTo(RulesEngineSchemaVersion.V5B3E7220.Value)); + } + } +} diff --git a/src/SchematicHQ.Client.Test/RulesEngine/FlagCheckTests.cs b/src/SchematicHQ.Client.Test/RulesEngine/Wasm/WasmFlagCheckParityTests.cs similarity index 73% rename from src/SchematicHQ.Client.Test/RulesEngine/FlagCheckTests.cs rename to src/SchematicHQ.Client.Test/RulesEngine/Wasm/WasmFlagCheckParityTests.cs index 36574a9c..e7cc6f0a 100644 --- a/src/SchematicHQ.Client.Test/RulesEngine/FlagCheckTests.cs +++ b/src/SchematicHQ.Client.Test/RulesEngine/Wasm/WasmFlagCheckParityTests.cs @@ -1,80 +1,79 @@ -using SchematicHQ.Client.RulesEngine.Utils; -using SchematicHQ.Client.RulesEngine; +using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; +using SchematicHQ.Client; +using SchematicHQ.Client.RulesEngine; -namespace SchematicHQ.Client.Test.RulesEngine +namespace SchematicHQ.Client.Test.RulesEngine.Wasm { + /// + /// Behavioral parity coverage for the WASM rules engine, ported from the former native + /// FlagCheckService unit tests. Each case drives + /// and asserts the same evaluation outcomes (value, matched rule, entitlement usage / + /// allocation) the native C# port produced — the regression guard the migration needs. + /// + /// Native-only reason strings (e.g. "No rules matched") are not asserted here; the + /// WASM engine emits its own reasons, so these tests assert on Value/RuleId + /// (a null RuleId means no rule matched → flag default). + /// [TestFixture] - public class FlagCheckServiceTests + public class WasmFlagCheckParityTests { - [Test] - public async Task Returns_ErrorResult_WhenFlagIsNil() - { - // Arrange - var company = TestHelpers.CreateTestCompany(); + private WasmRulesEngine _engine = null!; - // Act - var result = await FlagCheckService.CheckFlag(company, null, null); - - // Assert - Assert.That(result.Reason, Is.EqualTo(FlagCheckService.ReasonFlagNotFound)); - Assert.That(result.Error, Is.EqualTo(Errors.ErrorFlagNotFound)); + [OneTimeSetUp] + public void SetUp() + { + _engine = new WasmRulesEngine(NullLogger.Instance); + _engine.Initialize(); } + [OneTimeTearDown] + public void TearDown() => _engine.Dispose(); + [Test] - public async Task Returns_DefaultValue_WhenNoRulesMatch() + public void Returns_DefaultValue_WhenNoRulesMatch() { - // Arrange var company = TestHelpers.CreateTestCompany(); var flag = TestHelpers.CreateTestFlag(); flag.DefaultValue = true; - // Act - var result = await FlagCheckService.CheckFlag(company, null, flag); + var result = _engine.CheckFlag(company, null, flag); - // Assert - Assert.That(result.Reason, Is.EqualTo(FlagCheckService.ReasonNoRulesMatched)); Assert.That(result.Value, Is.True); Assert.That(result.CompanyId, Is.EqualTo(company.Id)); + Assert.That(result.RuleId, Is.Null); } [Test] - public async Task GlobalOverride_TakesPrecedence() + public void GlobalOverride_TakesPrecedence() { - // Arrange var company = TestHelpers.CreateTestCompany(); var flag = TestHelpers.CreateTestFlag(); - // Create a standard rule that matches var standardRule = TestHelpers.CreateTestRule(); standardRule.Value = false; var standardCondition = TestHelpers.CreateTestCondition(RulesengineConditionType.Company); standardCondition.ResourceIds = new List { company.Id }; standardRule.Conditions = new List { standardCondition }; - // Create a global override rule var overrideRule = TestHelpers.CreateTestRule(); overrideRule.RuleType = RulesengineRuleType.GlobalOverride; overrideRule.Value = true; flag.Rules = new List { standardRule, overrideRule }; - // Act - var result = await FlagCheckService.CheckFlag(company, null, flag); + var result = _engine.CheckFlag(company, null, flag); - // Assert Assert.That(result.Value, Is.True); Assert.That(result.RuleId, Is.EqualTo(overrideRule.Id)); } [Test] - public async Task Rules_Evaluated_In_Priority_Order() + public void Rules_Evaluated_In_Priority_Order() { - // Arrange var company = TestHelpers.CreateTestCompany(); var flag = TestHelpers.CreateTestFlag(); - // Create two matching rules with different priorities var rule1 = TestHelpers.CreateTestRule(); rule1.Priority = 2; rule1.Value = false; @@ -91,25 +90,21 @@ public async Task Rules_Evaluated_In_Priority_Order() flag.Rules = new List { rule1, rule2 }; - // Act - var result = await FlagCheckService.CheckFlag(company, null, flag); + var result = _engine.CheckFlag(company, null, flag); - // Assert Assert.That(result.Value, Is.True); Assert.That(result.RuleId, Is.EqualTo(rule2.Id)); } [Test] - public async Task Condition_Group_Matches_When_Any_Condition_Matches() + public void Condition_Group_Matches_When_Any_Condition_Matches() { - // Arrange var company = TestHelpers.CreateTestCompany(); var flag = TestHelpers.CreateTestFlag(); var rule = TestHelpers.CreateTestRule(); rule.Value = true; - // Create condition group with two conditions var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionType.Company); condition1.ResourceIds = new List { "non-matching-id" }; @@ -124,23 +119,19 @@ public async Task Condition_Group_Matches_When_Any_Condition_Matches() rule.ConditionGroups = new List { group }; flag.Rules = new List { rule }; - // Act - var result = await FlagCheckService.CheckFlag(company, null, flag); + var result = _engine.CheckFlag(company, null, flag); - // Assert Assert.That(result.Value, Is.True); Assert.That(result.RuleId, Is.EqualTo(rule.Id)); } [Test] - public async Task Sets_Usage_And_Allocation_For_Metric_Condition() + public void Sets_Usage_And_Allocation_For_Metric_Condition() { - // Arrange var company = TestHelpers.CreateTestCompany(); var flag = TestHelpers.CreateTestFlag(); - // Create entitlement rule with metric condition - string eventSubtype = "test-event"; + const string eventSubtype = "test-event"; var rule = TestHelpers.CreateTestRule(); rule.RuleType = RulesengineRuleType.PlanEntitlement; rule.Value = true; @@ -153,35 +144,27 @@ public async Task Sets_Usage_And_Allocation_For_Metric_Condition() rule.Conditions = new List { condition }; flag.Rules = new List { rule }; - // Create company metric var metric = TestHelpers.CreateTestMetric(company, eventSubtype, condition.MetricPeriod!.Value, 5); company.Metrics = new List { metric }; - // Act - var result = await FlagCheckService.CheckFlag(company, null, flag); + var result = _engine.CheckFlag(company, null, flag); - // Assert Assert.That(result.Value, Is.True); Assert.That(result.RuleId, Is.EqualTo(rule.Id)); - Assert.That(result.FeatureUsage, Is.Not.Null); Assert.That(result.FeatureUsage, Is.EqualTo(5)); - Assert.That(result.FeatureAllocation, Is.Not.Null); Assert.That(result.FeatureAllocation, Is.EqualTo(10)); } [Test] - public async Task Sets_Usage_And_Allocation_For_Trait_Condition() + public void Sets_Usage_And_Allocation_For_Trait_Condition() { - // Arrange var company = TestHelpers.CreateTestCompany(); var flag = TestHelpers.CreateTestFlag(); - // Create trait var traitDef = TestHelpers.CreateTestTraitDefinition(RulesengineTraitDefinitionComparableType.Int, RulesengineEntityType.Company); var trait = TestHelpers.CreateTestTrait("5", traitDef); company.Traits = new List { trait }; - // Create entitlement rule with trait condition var rule = TestHelpers.CreateTestRule(); rule.RuleType = RulesengineRuleType.PlanEntitlement; rule.Value = true; @@ -194,23 +177,17 @@ public async Task Sets_Usage_And_Allocation_For_Trait_Condition() rule.Conditions = new List { condition }; flag.Rules = new List { rule }; - // Act - var result = await FlagCheckService.CheckFlag(company, null, flag); + var result = _engine.CheckFlag(company, null, flag); - // Assert Assert.That(result.Value, Is.True); Assert.That(result.RuleId, Is.EqualTo(rule.Id)); - Assert.That(result.FeatureUsage, Is.Not.Null); Assert.That(result.FeatureUsage, Is.EqualTo(5)); - Assert.That(result.FeatureAllocation, Is.Not.Null); Assert.That(result.FeatureAllocation, Is.EqualTo(10)); } - [Test] - public async Task Matches_User_Specific_Conditions() + public void Matches_User_Specific_Conditions() { - // Arrange var user = TestHelpers.CreateTestUser(); var flag = TestHelpers.CreateTestFlag(); @@ -222,19 +199,16 @@ public async Task Matches_User_Specific_Conditions() flag.Rules = new List { rule }; - // Act - var result = await FlagCheckService.CheckFlag(null, user, flag); + var result = _engine.CheckFlag(null, user, flag); - // Assert Assert.That(result.Value, Is.True); Assert.That(result.UserId, Is.EqualTo(user.Id)); Assert.That(result.RuleId, Is.EqualTo(rule.Id)); } [Test] - public async Task Checks_User_Traits() + public void Checks_User_Traits() { - // Arrange var user = TestHelpers.CreateTestUser(); var traitDef = TestHelpers.CreateTestTraitDefinition(RulesengineTraitDefinitionComparableType.String, RulesengineEntityType.User); var trait = TestHelpers.CreateTestTrait("test-value", traitDef); @@ -252,18 +226,15 @@ public async Task Checks_User_Traits() rule.Conditions = new List { condition }; flag.Rules = new List { rule }; - // Act - var result = await FlagCheckService.CheckFlag(null, user, flag); + var result = _engine.CheckFlag(null, user, flag); - // Assert Assert.That(result.Value, Is.True); Assert.That(result.RuleId, Is.EqualTo(rule.Id)); } [Test] - public async Task Handles_Multiple_Condition_Types_And_Groups() + public void Handles_Multiple_Condition_Types_And_Groups() { - // Arrange var company = TestHelpers.CreateTestCompany(); var trait = TestHelpers.CreateTestTrait("test-value", null); company.Traits = new List { trait }; @@ -272,7 +243,6 @@ public async Task Handles_Multiple_Condition_Types_And_Groups() var rule = TestHelpers.CreateTestRule(); rule.Value = true; - // Add direct conditions var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionType.Company); condition1.ResourceIds = new List { company.Id }; @@ -283,7 +253,6 @@ public async Task Handles_Multiple_Condition_Types_And_Groups() rule.Conditions = new List { condition1, condition2 }; - // Add condition group var groupCondition1 = TestHelpers.CreateTestCondition(RulesengineConditionType.Plan); groupCondition1.ResourceIds = new List { company.PlanIds.First() }; @@ -301,23 +270,19 @@ public async Task Handles_Multiple_Condition_Types_And_Groups() rule.ConditionGroups = new List { group }; flag.Rules = new List { rule }; - // Act - var result = await FlagCheckService.CheckFlag(company, null, flag); + var result = _engine.CheckFlag(company, null, flag); - // Assert Assert.That(result.Value, Is.True); Assert.That(result.RuleId, Is.EqualTo(rule.Id)); } [Test] - public async Task Handles_Missing_Or_Invalid_Data_Gracefully() + public void Handles_Missing_Or_Invalid_Data_Gracefully() { - // Arrange var company = TestHelpers.CreateTestCompany(); var flag = TestHelpers.CreateTestFlag(); var rule = TestHelpers.CreateTestRule(); - // Add condition with null fields var condition = new RulesengineCondition { Id = "", @@ -329,29 +294,24 @@ public async Task Handles_Missing_Or_Invalid_Data_Gracefully() }; rule.Conditions = new List { condition }; - // Add empty condition group var group = new RulesengineConditionGroup(); rule.ConditionGroups = new List { group }; flag.Rules = new List { rule }; - // Act - var result = await FlagCheckService.CheckFlag(company, null, flag); + var result = _engine.CheckFlag(company, null, flag); - // Assert Assert.That(result.Value, Is.EqualTo(flag.DefaultValue)); - Assert.That(result.Reason, Is.EqualTo(FlagCheckService.ReasonNoRulesMatched)); + Assert.That(result.RuleId, Is.Null); } [Test] - public async Task CompanyProvidedRule_IsEvaluatedAlongWithFlagRules() + public void CompanyProvidedRule_IsEvaluatedAlongWithFlagRules() { - // Arrange var company = TestHelpers.CreateTestCompany(); var flag = TestHelpers.CreateTestFlag(); flag.DefaultValue = false; - // Create a company-provided rule that matches var companyRule = TestHelpers.CreateTestRule(); companyRule.FlagId = flag.Id; companyRule.Value = true; @@ -361,22 +321,18 @@ public async Task CompanyProvidedRule_IsEvaluatedAlongWithFlagRules() company.Rules = new List { companyRule }; - // Act - var result = await FlagCheckService.CheckFlag(company, null, flag); + var result = _engine.CheckFlag(company, null, flag); - // Assert Assert.That(result.Value, Is.True); Assert.That(result.RuleId, Is.EqualTo(companyRule.Id)); } [Test] - public async Task CompanyProvidedRule_RespectsPriorityOrdering() + public void CompanyProvidedRule_RespectsPriorityOrdering() { - // Arrange var company = TestHelpers.CreateTestCompany(); var flag = TestHelpers.CreateTestFlag(); - // Create flag rule with lower priority var flagRule = TestHelpers.CreateTestRule(); flagRule.Priority = 2; flagRule.Value = false; @@ -384,7 +340,6 @@ public async Task CompanyProvidedRule_RespectsPriorityOrdering() condition1.ResourceIds = new List { company.Id }; flagRule.Conditions = new List { condition1 }; - // Create company rule with higher priority var companyRule = TestHelpers.CreateTestRule(); companyRule.FlagId = flag.Id; companyRule.Priority = 1; @@ -396,29 +351,24 @@ public async Task CompanyProvidedRule_RespectsPriorityOrdering() flag.Rules = new List { flagRule }; company.Rules = new List { companyRule }; - // Act - var result = await FlagCheckService.CheckFlag(company, null, flag); + var result = _engine.CheckFlag(company, null, flag); - // Assert Assert.That(result.Value, Is.True); Assert.That(result.RuleId, Is.EqualTo(companyRule.Id)); } [Test] - public async Task CompanyProvidedRule_WithGlobalOverrideTakesPrecedence() + public void CompanyProvidedRule_WithGlobalOverrideTakesPrecedence() { - // Arrange var company = TestHelpers.CreateTestCompany(); var flag = TestHelpers.CreateTestFlag(); - // Create standard flag rule var flagRule = TestHelpers.CreateTestRule(); flagRule.Value = false; var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionType.Company); condition1.ResourceIds = new List { company.Id }; flagRule.Conditions = new List { condition1 }; - // Create company rule with global override var companyRule = TestHelpers.CreateTestRule(); companyRule.FlagId = flag.Id; companyRule.RuleType = RulesengineRuleType.GlobalOverride; @@ -427,23 +377,19 @@ public async Task CompanyProvidedRule_WithGlobalOverrideTakesPrecedence() flag.Rules = new List { flagRule }; company.Rules = new List { companyRule }; - // Act - var result = await FlagCheckService.CheckFlag(company, null, flag); + var result = _engine.CheckFlag(company, null, flag); - // Assert Assert.That(result.Value, Is.True); Assert.That(result.RuleId, Is.EqualTo(companyRule.Id)); } [Test] - public async Task MultipleCompanyProvidedRules_AreAllEvaluated() + public void MultipleCompanyProvidedRules_AreAllEvaluated() { - // Arrange var company = TestHelpers.CreateTestCompany(); var flag = TestHelpers.CreateTestFlag(); flag.DefaultValue = false; - // Create two company rules, only one matches var companyRule1 = TestHelpers.CreateTestRule(); companyRule1.FlagId = flag.Id; companyRule1.Priority = 1; @@ -462,23 +408,19 @@ public async Task MultipleCompanyProvidedRules_AreAllEvaluated() company.Rules = new List { companyRule1, companyRule2 }; - // Act - var result = await FlagCheckService.CheckFlag(company, null, flag); + var result = _engine.CheckFlag(company, null, flag); - // Assert Assert.That(result.Value, Is.True); Assert.That(result.RuleId, Is.EqualTo(companyRule2.Id)); } [Test] - public async Task UserProvidedRule_IsEvaluatedAlongWithFlagRules() + public void UserProvidedRule_IsEvaluatedAlongWithFlagRules() { - // Arrange var user = TestHelpers.CreateTestUser(); var flag = TestHelpers.CreateTestFlag(); flag.DefaultValue = false; - // Create a user-provided rule that matches var userRule = TestHelpers.CreateTestRule(); userRule.FlagId = flag.Id; userRule.Value = true; @@ -488,22 +430,18 @@ public async Task UserProvidedRule_IsEvaluatedAlongWithFlagRules() user.Rules = new List { userRule }; - // Act - var result = await FlagCheckService.CheckFlag(null, user, flag); + var result = _engine.CheckFlag(null, user, flag); - // Assert Assert.That(result.Value, Is.True); Assert.That(result.RuleId, Is.EqualTo(userRule.Id)); } [Test] - public async Task UserProvidedRule_RespectsPriorityOrdering() + public void UserProvidedRule_RespectsPriorityOrdering() { - // Arrange var user = TestHelpers.CreateTestUser(); var flag = TestHelpers.CreateTestFlag(); - // Create flag rule with lower priority var flagRule = TestHelpers.CreateTestRule(); flagRule.Priority = 2; flagRule.Value = false; @@ -511,7 +449,6 @@ public async Task UserProvidedRule_RespectsPriorityOrdering() condition1.ResourceIds = new List { user.Id }; flagRule.Conditions = new List { condition1 }; - // Create user rule with higher priority var userRule = TestHelpers.CreateTestRule(); userRule.FlagId = flag.Id; userRule.Priority = 1; @@ -523,29 +460,24 @@ public async Task UserProvidedRule_RespectsPriorityOrdering() flag.Rules = new List { flagRule }; user.Rules = new List { userRule }; - // Act - var result = await FlagCheckService.CheckFlag(null, user, flag); + var result = _engine.CheckFlag(null, user, flag); - // Assert Assert.That(result.Value, Is.True); Assert.That(result.RuleId, Is.EqualTo(userRule.Id)); } [Test] - public async Task UserProvidedRule_WithGlobalOverrideTakesPrecedence() + public void UserProvidedRule_WithGlobalOverrideTakesPrecedence() { - // Arrange var user = TestHelpers.CreateTestUser(); var flag = TestHelpers.CreateTestFlag(); - // Create standard flag rule var flagRule = TestHelpers.CreateTestRule(); flagRule.Value = false; var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionType.User); condition1.ResourceIds = new List { user.Id }; flagRule.Conditions = new List { condition1 }; - // Create user rule with global override var userRule = TestHelpers.CreateTestRule(); userRule.FlagId = flag.Id; userRule.RuleType = RulesengineRuleType.GlobalOverride; @@ -554,24 +486,20 @@ public async Task UserProvidedRule_WithGlobalOverrideTakesPrecedence() flag.Rules = new List { flagRule }; user.Rules = new List { userRule }; - // Act - var result = await FlagCheckService.CheckFlag(null, user, flag); + var result = _engine.CheckFlag(null, user, flag); - // Assert Assert.That(result.Value, Is.True); Assert.That(result.RuleId, Is.EqualTo(userRule.Id)); } [Test] - public async Task BothCompanyAndUserRules_AreEvaluated() + public void BothCompanyAndUserRules_AreEvaluated() { - // Arrange var company = TestHelpers.CreateTestCompany(); var user = TestHelpers.CreateTestUser(); var flag = TestHelpers.CreateTestFlag(); flag.DefaultValue = false; - // Create company rule that doesn't match var companyRule = TestHelpers.CreateTestRule(); companyRule.FlagId = flag.Id; companyRule.Priority = 1; @@ -580,7 +508,6 @@ public async Task BothCompanyAndUserRules_AreEvaluated() condition1.ResourceIds = new List { "non-matching-id" }; companyRule.Conditions = new List { condition1 }; - // Create user rule that matches var userRule = TestHelpers.CreateTestRule(); userRule.FlagId = flag.Id; userRule.Priority = 2; @@ -592,24 +519,20 @@ public async Task BothCompanyAndUserRules_AreEvaluated() company.Rules = new List { companyRule }; user.Rules = new List { userRule }; - // Act - var result = await FlagCheckService.CheckFlag(company, user, flag); + var result = _engine.CheckFlag(company, user, flag); - // Assert Assert.That(result.Value, Is.True); Assert.That(result.RuleId, Is.EqualTo(userRule.Id)); } [Test] - public async Task AllThreeRuleSources_EvaluatedWithCorrectPriority() + public void AllThreeRuleSources_EvaluatedWithCorrectPriority() { - // Arrange var company = TestHelpers.CreateTestCompany(); var user = TestHelpers.CreateTestUser(); var flag = TestHelpers.CreateTestFlag(); flag.DefaultValue = false; - // Create rules from all three sources - all matching their respective conditions var flagRule = TestHelpers.CreateTestRule(); flagRule.Priority = 2; flagRule.Value = true; @@ -637,25 +560,19 @@ public async Task AllThreeRuleSources_EvaluatedWithCorrectPriority() company.Rules = new List { companyRule }; user.Rules = new List { userRule }; - // Act - var result = await FlagCheckService.CheckFlag(company, user, flag); + var result = _engine.CheckFlag(company, user, flag); - // Assert Assert.That(result.Value, Is.True); - Assert.That(result.RuleId, Is.Not.Null); - // Should match the user rule since it has highest priority (lowest number) - Assert.That(result.RuleId, Is.EqualTo(userRule.Id)); + Assert.That(result.RuleId, Is.EqualTo(userRule.Id)); // user rule has highest priority } [Test] - public async Task CompanyProvidedRule_WithWrongFlagId_IsNotEvaluated() + public void CompanyProvidedRule_WithWrongFlagId_IsNotEvaluated() { - // Arrange var company = TestHelpers.CreateTestCompany(); var flag = TestHelpers.CreateTestFlag(); flag.DefaultValue = false; - // Create a company-provided rule for a different flag var companyRule = TestHelpers.CreateTestRule(); companyRule.FlagId = TestHelpers.GenerateTestId("flag"); // Different flag ID companyRule.Value = true; @@ -665,23 +582,19 @@ public async Task CompanyProvidedRule_WithWrongFlagId_IsNotEvaluated() company.Rules = new List { companyRule }; - // Act - var result = await FlagCheckService.CheckFlag(company, null, flag); + var result = _engine.CheckFlag(company, null, flag); - // Assert - Assert.That(result.Value, Is.False); // Should use flag's default value - Assert.That(result.Reason, Is.EqualTo(FlagCheckService.ReasonNoRulesMatched)); + Assert.That(result.Value, Is.False); // flag default + Assert.That(result.RuleId, Is.Null); } [Test] - public async Task UserProvidedRule_WithWrongFlagId_IsNotEvaluated() + public void UserProvidedRule_WithWrongFlagId_IsNotEvaluated() { - // Arrange var user = TestHelpers.CreateTestUser(); var flag = TestHelpers.CreateTestFlag(); flag.DefaultValue = false; - // Create a user-provided rule for a different flag var userRule = TestHelpers.CreateTestRule(); userRule.FlagId = TestHelpers.GenerateTestId("flag"); // Different flag ID userRule.Value = true; @@ -691,23 +604,19 @@ public async Task UserProvidedRule_WithWrongFlagId_IsNotEvaluated() user.Rules = new List { userRule }; - // Act - var result = await FlagCheckService.CheckFlag(null, user, flag); + var result = _engine.CheckFlag(null, user, flag); - // Assert - Assert.That(result.Value, Is.False); // Should use flag's default value - Assert.That(result.Reason, Is.EqualTo(FlagCheckService.ReasonNoRulesMatched)); + Assert.That(result.Value, Is.False); // flag default + Assert.That(result.RuleId, Is.Null); } [Test] - public async Task CompanyProvidedRule_WithNullFlagId_IsNotEvaluated() + public void CompanyProvidedRule_WithNullFlagId_IsNotEvaluated() { - // Arrange var company = TestHelpers.CreateTestCompany(); var flag = TestHelpers.CreateTestFlag(); flag.DefaultValue = false; - // Create a company-provided rule with null FlagId var companyRule = TestHelpers.CreateTestRule(); companyRule.FlagId = null; companyRule.Value = true; @@ -717,23 +626,19 @@ public async Task CompanyProvidedRule_WithNullFlagId_IsNotEvaluated() company.Rules = new List { companyRule }; - // Act - var result = await FlagCheckService.CheckFlag(company, null, flag); + var result = _engine.CheckFlag(company, null, flag); - // Assert - Assert.That(result.Value, Is.False); // Should use flag's default value - Assert.That(result.Reason, Is.EqualTo(FlagCheckService.ReasonNoRulesMatched)); + Assert.That(result.Value, Is.False); // flag default + Assert.That(result.RuleId, Is.Null); } [Test] - public async Task UserProvidedRule_WithNullFlagId_IsNotEvaluated() + public void UserProvidedRule_WithNullFlagId_IsNotEvaluated() { - // Arrange var user = TestHelpers.CreateTestUser(); var flag = TestHelpers.CreateTestFlag(); flag.DefaultValue = false; - // Create a user-provided rule with null FlagId var userRule = TestHelpers.CreateTestRule(); userRule.FlagId = null; userRule.Value = true; @@ -743,12 +648,10 @@ public async Task UserProvidedRule_WithNullFlagId_IsNotEvaluated() user.Rules = new List { userRule }; - // Act - var result = await FlagCheckService.CheckFlag(null, user, flag); + var result = _engine.CheckFlag(null, user, flag); - // Assert - Assert.That(result.Value, Is.False); // Should use flag's default value - Assert.That(result.Reason, Is.EqualTo(FlagCheckService.ReasonNoRulesMatched)); + Assert.That(result.Value, Is.False); // flag default + Assert.That(result.RuleId, Is.Null); } } } diff --git a/src/SchematicHQ.Client.Test/RulesEngine/Wasm/WasmRulesEngineTests.cs b/src/SchematicHQ.Client.Test/RulesEngine/Wasm/WasmRulesEngineTests.cs new file mode 100644 index 00000000..4fabf49e --- /dev/null +++ b/src/SchematicHQ.Client.Test/RulesEngine/Wasm/WasmRulesEngineTests.cs @@ -0,0 +1,68 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using SchematicHQ.Client; +using SchematicHQ.Client.RulesEngine; + +namespace SchematicHQ.Client.Test.RulesEngine.Wasm +{ + /// + /// Regression coverage for the WASM rules engine that replaced the native C# port. + /// Asserts parity with the previous native results (value + entitlement fields) and + /// that feature_usage_reset_at is populated on the clockless wasmtime host (SCHY-471). + /// + [TestFixture] + public class WasmRulesEngineTests + { + private WasmRulesEngine _engine = null!; + + [OneTimeSetUp] + public void SetUp() + { + _engine = new WasmRulesEngine(NullLogger.Instance); + _engine.Initialize(); + } + + [OneTimeTearDown] + public void TearDown() => _engine.Dispose(); + + [Test] + public void Initializes_From_Embedded_Wasm() + { + Assert.That(_engine.IsInitialized, Is.True); + } + + [Test] + public void Bounded_Metric_Period_Populates_Reset_At() + { + // SCHY-471: with setCurrentTimeMillis wired, a calendar metric period must + // populate feature_usage_reset_at (rather than trapping / omitting it). + var company = TestHelpers.CreateTestCompany(); + var flag = TestHelpers.CreateTestFlag(); + flag.DefaultValue = false; + + const string eventSubtype = "monthly-event"; + var rule = TestHelpers.CreateTestRule(); + rule.RuleType = RulesengineRuleType.PlanEntitlement; + rule.Value = true; + + var condition = TestHelpers.CreateTestCondition(RulesengineConditionType.Metric); + condition.EventSubtype = eventSubtype; + condition.MetricValue = 10; + condition.Operator = ComparableOperator.Lte; + condition.MetricPeriod = RulesengineMetricPeriod.CurrentMonth; + rule.Conditions = new List { condition }; + flag.Rules = new List { rule }; + + company.Metrics = new List + { + TestHelpers.CreateTestMetric(company, eventSubtype, RulesengineMetricPeriod.CurrentMonth, 5), + }; + + var result = _engine.CheckFlag(company, null, flag); + + Assert.That(result.Value, Is.True); + Assert.That(result.FeatureUsageResetAt, Is.Not.Null); + Assert.That(result.FeatureUsageResetAt, Is.GreaterThan(DateTime.UtcNow)); + } + } +} diff --git a/src/SchematicHQ.Client/Cache/ComparableTypeConverter.cs b/src/SchematicHQ.Client/Cache/ComparableTypeConverter.cs deleted file mode 100644 index fc977843..00000000 --- a/src/SchematicHQ.Client/Cache/ComparableTypeConverter.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.Text.Json; -using System.Text.Json.Serialization; -using SchematicHQ.Client.RulesEngine.Utils; - -namespace SchematicHQ.Client.Cache -{ - public class ComparableTypeConverter : JsonConverter - { - public override ComparableType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var value = reader.GetString(); - - return value switch - { - "" or null => ComparableType.Unknown, - "string" => ComparableType.String, - "int" => ComparableType.Int, - "bool" => ComparableType.Bool, - "date" => ComparableType.Date, - _ => ComparableType.Unknown - }; - } - - public override void Write(Utf8JsonWriter writer, ComparableType value, JsonSerializerOptions options) - { - var stringValue = value switch - { - ComparableType.Unknown => "", - ComparableType.String => "string", - ComparableType.Int => "int", - ComparableType.Bool => "bool", - ComparableType.Date => "date", - _ => "" - }; - - writer.WriteStringValue(stringValue); - } - } -} \ No newline at end of file diff --git a/src/SchematicHQ.Client/Cache/SchematicCacheSerializerDefaults.cs b/src/SchematicHQ.Client/Cache/SchematicCacheSerializerDefaults.cs index 9ed526e0..3996fb92 100644 --- a/src/SchematicHQ.Client/Cache/SchematicCacheSerializerDefaults.cs +++ b/src/SchematicHQ.Client/Cache/SchematicCacheSerializerDefaults.cs @@ -10,8 +10,7 @@ public static class SchematicCacheSerializerDefaults PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, Converters = { - new ComparableTypeConverter(), // Specific handler for ComparableType empty strings - new ResilientEnumConverter() // Fallback for all other enums + new ResilientEnumConverter() // Fallback for all enums } }; } \ No newline at end of file diff --git a/src/SchematicHQ.Client/Datastream/Client.cs b/src/SchematicHQ.Client/Datastream/Client.cs index c0c8af2b..c4dde7d5 100644 --- a/src/SchematicHQ.Client/Datastream/Client.cs +++ b/src/SchematicHQ.Client/Datastream/Client.cs @@ -22,6 +22,11 @@ public class DatastreamClient : IDisposable private readonly TimeSpan _cacheTtl; private readonly Action _connectionStateCallback; private IWebSocketClient _webSocket; + + // Local flag evaluator (shared Rust rules engine, hosted via WASM). Null when + // initialization failed, in which case flag checks fall back to the flag default. + private readonly WasmRulesEngine? _rulesEngine; + private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); private readonly SemaphoreSlim _reconnectSemaphore = new SemaphoreSlim(1, 1); private CancellationTokenSource _readCancellationSource = new CancellationTokenSource(); @@ -64,6 +69,11 @@ public class DatastreamClient : IDisposable // Authentication error close code private const int AuthErrorCloseCode = 4001; + // Reasons returned when the WASM rules engine cannot evaluate a flag; the flag's + // default value is returned in both cases (matching schematic-java's semantics). + private const string ReasonRulesEngineUnavailable = "RULES_ENGINE_UNAVAILABLE"; + private const string ReasonRulesEngineError = "RULES_ENGINE_ERROR"; + // Handshake headers attached to the WebSocket connection so the backend can // distinguish direct-SDK connections from the schematic-datastream-replicator // and correlate either to a specific release. Mode is always "direct" here — @@ -115,6 +125,20 @@ public DatastreamClient( _flagsCache = new DatastreamCacheDecorator(cacheProvider, flagTTL); _webSocket = webSocket ?? new StandardWebSocketClient(); + + // Initialize the WASM rules engine. On failure, degrade gracefully: flag + // checks return the flag's default value rather than throwing. + try + { + var rulesEngine = new WasmRulesEngine(_logger); + rulesEngine.Initialize(); + _rulesEngine = rulesEngine; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize WASM rules engine; flag checks will return default values"); + _rulesEngine = null; + } } public void Start() @@ -840,23 +864,40 @@ private void HandleErrorMessage(DataStreamResponse response) } } - internal async Task CheckFlag(RulesengineCompany? company, RulesengineUser? user, RulesengineFlag flag, CancellationToken cancellationToken = default) + internal Task CheckFlag(RulesengineCompany? company, RulesengineUser? user, RulesengineFlag flag, CancellationToken cancellationToken = default) { + if (_rulesEngine == null || !_rulesEngine.IsInitialized) + { + _logger.LogWarning("WASM rules engine unavailable; returning default value for flag {FlagKey}", flag.Key); + return Task.FromResult(new CheckFlagResult + { + Reason = ReasonRulesEngineUnavailable, + FlagKey = flag.Key, + FlagId = flag.Id, + Value = flag.DefaultValue, + CompanyId = company?.Id, + UserId = user?.Id, + }); + } + try { - var result = await FlagCheckService.CheckFlag(company, user, flag); - return result; + var result = _rulesEngine.CheckFlag(company, user, flag); + return Task.FromResult(result); } catch (Exception ex) { _logger.LogError(ex, "Error checking flag {FlagKey}", flag.Key); - return new CheckFlagResult + return Task.FromResult(new CheckFlagResult { - Reason = "Error", + Reason = ReasonRulesEngineError, FlagKey = flag.Key, + FlagId = flag.Id, Error = ex, - Value = false, - }; + Value = flag.DefaultValue, + CompanyId = company?.Id, + UserId = user?.Id, + }); } } @@ -1516,6 +1557,9 @@ public void Dispose() lockEntry.Value.Dispose(); } _companyUpdateLocks.Clear(); + + // Release the WASM rules engine's native Wasmtime resources. + _rulesEngine?.Dispose(); } } } diff --git a/src/SchematicHQ.Client/RulesEngine/CheckFlagResult.cs b/src/SchematicHQ.Client/RulesEngine/CheckFlagResult.cs new file mode 100644 index 00000000..76baea83 --- /dev/null +++ b/src/SchematicHQ.Client/RulesEngine/CheckFlagResult.cs @@ -0,0 +1,29 @@ +namespace SchematicHQ.Client.RulesEngine +{ + /// + /// Result of a local flag evaluation, returned by the datastream client. + /// + /// Retained for backward compatibility. Internally the WASM rules engine produces the + /// generated , which is mapped onto this type at the + /// engine boundary. (The former native-only SetRuleFields helper was removed with the + /// native port; the WASM engine populates the entitlement fields directly.) + /// + public class CheckFlagResult + { + public string? CompanyId { get; set; } + public RulesengineFeatureEntitlement? Entitlement { get; set; } + public Exception? Error { get; set; } + public long? FeatureAllocation { get; set; } + public long? FeatureUsage { get; set; } + public string? FeatureUsageEvent { get; set; } + public RulesengineMetricPeriod? FeatureUsagePeriod { get; set; } + public DateTime? FeatureUsageResetAt { get; set; } + public string? FlagId { get; set; } + public required string FlagKey { get; set; } + public required string Reason { get; set; } + public string? RuleId { get; set; } + public RulesengineRuleType? RuleType { get; set; } + public string? UserId { get; set; } + public bool Value { get; set; } + } +} diff --git a/src/SchematicHQ.Client/RulesEngine/CheckFlagWithEntitlementResponse.cs b/src/SchematicHQ.Client/RulesEngine/CheckFlagWithEntitlementResponse.cs new file mode 100644 index 00000000..237c42d6 --- /dev/null +++ b/src/SchematicHQ.Client/RulesEngine/CheckFlagWithEntitlementResponse.cs @@ -0,0 +1,127 @@ +namespace SchematicHQ.Client.RulesEngine +{ + /// + /// Response from CheckFlagWithEntitlement containing flag check result with entitlement information. + /// This is a subset of CheckFlagResponseData with deprecated fields removed. + /// + /// This is the public return type of and is + /// intentionally kept distinct from the generated (used + /// internally on the datastream/WASM path) so that its shape stays stable for existing consumers. + /// + public class CheckFlagWithEntitlementResponse + { + /// + /// If company keys were provided and matched a company, its ID + /// + public string? CompanyId { get; set; } + + /// + /// Entitlement information for the feature, if applicable + /// + public RulesengineFeatureEntitlement? Entitlement { get; set; } + + /// + /// If a flag was found, its ID + /// + public string? FlagId { get; set; } + + /// + /// The key used to check the flag + /// + public required string FlagKey { get; set; } + + /// + /// A human-readable explanation of the result + /// + public required string Reason { get; set; } + + /// + /// If a rule was found, its ID + /// + public string? RuleId { get; set; } + + /// + /// If a rule was found, its type + /// + public string? RuleType { get; set; } + + /// + /// If user keys were provided and matched a user, its ID + /// + public string? UserId { get; set; } + + /// + /// A boolean flag check result; for feature entitlements, this represents whether further consumption is permitted + /// + public bool Value { get; set; } + + /// + /// Create a CheckFlagWithEntitlementResponse from a rules engine result + /// + public static CheckFlagWithEntitlementResponse FromCheckFlagResult(CheckFlagResult result) + { + return new CheckFlagWithEntitlementResponse + { + CompanyId = result.CompanyId, + Entitlement = result.Entitlement, + FlagId = result.FlagId, + FlagKey = result.FlagKey, + Reason = result.Reason, + RuleId = result.RuleId, + RuleType = result.RuleType?.Value, + UserId = result.UserId, + Value = result.Value + }; + } + + /// + /// Create a CheckFlagWithEntitlementResponse from an API CheckFlagResponseData + /// + public static CheckFlagWithEntitlementResponse FromApiResponse(CheckFlagResponseData data, string flagKey) + { + return new CheckFlagWithEntitlementResponse + { + CompanyId = data.CompanyId, + Entitlement = ToRulesEngineEntitlement(data.Entitlement), + FlagId = data.FlagId, + FlagKey = flagKey, + Reason = data.Reason, + RuleId = data.RuleId, + RuleType = data.RuleType?.Value, + UserId = data.UserId, + Value = data.Value + }; + } + + /// + /// Convert API FeatureEntitlement to RulesengineFeatureEntitlement + /// + private static RulesengineFeatureEntitlement? ToRulesEngineEntitlement(FeatureEntitlement? entitlement) + { + if (entitlement == null) + return null; + + return new RulesengineFeatureEntitlement + { + Allocation = entitlement.Allocation, + CreditId = entitlement.CreditId, + CreditRemaining = entitlement.CreditRemaining, + CreditTotal = entitlement.CreditTotal, + CreditUsed = entitlement.CreditUsed, + EventName = entitlement.EventName, + FeatureId = entitlement.FeatureId, + FeatureKey = entitlement.FeatureKey, + MetricPeriod = entitlement.MetricPeriod.HasValue + ? new RulesengineMetricPeriod(entitlement.MetricPeriod.Value.Value) + : null, + MetricResetAt = entitlement.MetricResetAt, + MonthReset = entitlement.MonthReset.HasValue + ? new RulesengineMetricPeriodMonthReset(entitlement.MonthReset.Value.Value) + : null, + SoftLimit = entitlement.SoftLimit, + Usage = entitlement.Usage, + ValueType = new RulesengineEntitlementValueType(entitlement.ValueType.Value) + }; + } + } +} diff --git a/src/SchematicHQ.Client/RulesEngine/Consts.cs b/src/SchematicHQ.Client/RulesEngine/Consts.cs deleted file mode 100644 index 6bc04ae6..00000000 --- a/src/SchematicHQ.Client/RulesEngine/Consts.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Text.Json.Serialization; -using System.Collections.Generic; - -namespace SchematicHQ.Client.RulesEngine -{ - public static class RulesengineRuleTypeExtensions - { - public static string DisplayName(this RulesengineRuleType ruleType) - { - return ruleType.Value.Replace("_", " "); - } - - public static bool IsEntitlement(this RulesengineRuleType ruleType) - { - return ruleType == RulesengineRuleType.PlanEntitlement.Value || - ruleType == RulesengineRuleType.PlanEntitlementUsageExceeded.Value || - ruleType == RulesengineRuleType.CompanyOverride.Value || - ruleType == RulesengineRuleType.CompanyOverrideUsageExceeded.Value; - } - - public static RulePrioritizationMethod PrioritizationMethod(this RulesengineRuleType ruleType) - { - if (ruleType == RulesengineRuleType.Standard.Value) - { - return RulePrioritizationMethod.Priority; - } - - if (ruleType == RulesengineRuleType.CompanyOverride.Value || - ruleType == RulesengineRuleType.PlanEntitlement.Value || - ruleType == RulesengineRuleType.CompanyOverrideUsageExceeded.Value || - ruleType == RulesengineRuleType.PlanEntitlementUsageExceeded.Value) - { - return RulePrioritizationMethod.Optimistic; - } - - return RulePrioritizationMethod.None; - } - - public static List RuleTypePriority = new List - { - RulesengineRuleType.GlobalOverride, - RulesengineRuleType.CompanyOverride, - RulesengineRuleType.PlanEntitlement, - RulesengineRuleType.CompanyOverrideUsageExceeded, - RulesengineRuleType.PlanEntitlementUsageExceeded, - RulesengineRuleType.Standard, - RulesengineRuleType.Default - }; - } - - [JsonConverter(typeof(JsonStringEnumConverter))] - public enum RulePrioritizationMethod - { - [JsonPropertyName("none")] - None, - - [JsonPropertyName("priority")] - Priority, - - [JsonPropertyName("optimistic")] - Optimistic - } -} diff --git a/src/SchematicHQ.Client/RulesEngine/Errors.cs b/src/SchematicHQ.Client/RulesEngine/Errors.cs new file mode 100644 index 00000000..30da1725 --- /dev/null +++ b/src/SchematicHQ.Client/RulesEngine/Errors.cs @@ -0,0 +1,8 @@ +namespace SchematicHQ.Client.RulesEngine +{ + public static class Errors + { + public static readonly Exception ErrorUnexpected = new Exception("Unexpected error"); + public static readonly Exception ErrorFlagNotFound = new Exception("Flag not found"); + } +} diff --git a/src/SchematicHQ.Client/RulesEngine/FlagCheck.cs b/src/SchematicHQ.Client/RulesEngine/FlagCheck.cs deleted file mode 100644 index 2a204c27..00000000 --- a/src/SchematicHQ.Client/RulesEngine/FlagCheck.cs +++ /dev/null @@ -1,394 +0,0 @@ -using SchematicHQ.Client.RulesEngine.Utils; - -namespace SchematicHQ.Client.RulesEngine -{ - public class CheckFlagResult - { - public string? CompanyId { get; set; } - public RulesengineFeatureEntitlement? Entitlement { get; set; } - public Exception? Error { get; set; } - public long? FeatureAllocation { get; set; } - public long? FeatureUsage { get; set; } - public string? FeatureUsageEvent { get; set; } - public RulesengineMetricPeriod? FeatureUsagePeriod { get; set; } - public DateTime? FeatureUsageResetAt { get; set; } - public string? FlagId { get; set; } - public required string FlagKey { get; set; } - public required string Reason { get; set; } - public string? RuleId { get; set; } - public RulesengineRuleType? RuleType { get; set; } - public string? UserId { get; set; } - public bool Value { get; set; } - - public void SetRuleFields(RulesengineCompany? company, RulesengineRule? rule) - { - if (rule == null) - { - return; - } - - RuleId = rule.Id; - RuleType = rule.RuleType; - - - if (company == null) - { - return; - } - - // Only set entitlement fields if the matched rule is an entitlement rule - if (!rule.RuleType.IsEntitlement()) - { - return; - } - - // For a numeric entitlement rule, there will be a metric or trait condition; - // for a boolean or unlimited entitlement rule, we don't need to set these fields - var usageCondition = rule.Conditions.FirstOrDefault(c => - c != null && (c.ConditionType == RulesengineConditionType.Metric || c.ConditionType == RulesengineConditionType.Trait)); - - if (usageCondition == null) - { - return; - } - - // Set usage, allocation, and other usage-related fields - long usage = 0; - long allocation = 0; - - if (usageCondition.ConditionType == RulesengineConditionType.Metric) - { - FeatureUsageEvent = usageCondition.EventSubtype; - - if (!string.IsNullOrEmpty(usageCondition.EventSubtype)) - { - var usageMetric = CompanyMetricExtensions.Find( - company.Metrics, - usageCondition.EventSubtype, - usageCondition.MetricPeriod, - usageCondition.MetricPeriodMonthReset - ); - - if (usageMetric != null) - { - usage = usageMetric.Value; - } - } - - if (usageCondition.MetricValue.HasValue) - { - allocation = usageCondition.MetricValue.Value; - } - - var metricPeriod = usageCondition.MetricPeriod; - - FeatureUsagePeriod = metricPeriod; - FeatureUsageResetAt = Metrics.GetNextMetricPeriodStartFromCondition(usageCondition, company); - } - else if (usageCondition.ConditionType == RulesengineConditionType.Trait) - { - if (usageCondition.TraitDefinition != null) - { - var companyUsageTrait = company.GetTraitByDefinitionId(usageCondition.TraitDefinition.Id); - if (companyUsageTrait != null) - { - usage = TypeConverter.StringToInt64(companyUsageTrait.Value); - } - } - - allocation = TypeConverter.StringToInt64(usageCondition.TraitValue); - } - - // If there is a comparison trait, this takes precedence for allocation over the numeric value - if (usageCondition.ComparisonTraitDefinition != null) - { - var companyAllocationTrait = company.GetTraitByDefinitionId(usageCondition.ComparisonTraitDefinition.Id); - if (companyAllocationTrait != null) - { - allocation = TypeConverter.StringToInt64(companyAllocationTrait.Value); - } - } - - FeatureUsage = usage; - FeatureAllocation = allocation; - } - } - - public static class Errors - { - public static readonly Exception ErrorUnexpected = new Exception("Unexpected error"); - public static readonly Exception ErrorFlagNotFound = new Exception("Flag not found"); - } - - public static class FlagCheckService - { - public const string ReasonNoCompanyOrUser = "No company or user context; default value for flag"; - public const string ReasonCompanyNotFound = "Company not found"; - public const string ReasonCompanyNotSpecified = "Must specify a company"; - public const string ReasonFlagNotFound = "Flag not found"; - public const string ReasonNoRulesMatched = "No rules matched; default value for flag"; - public const string ReasonServerError = "Server error; Schematic has been notified"; - public const string ReasonUserNotFound = "User not found"; - - public static async Task CheckFlag( - RulesengineCompany? company, - RulesengineUser? user, - RulesengineFlag? flag, - CancellationToken cancellationToken = default) - { - var resp = new CheckFlagResult - { - Reason = ReasonNoRulesMatched, - FlagKey = "", - }; - - if (flag == null) - { - resp.Reason = ReasonFlagNotFound; - resp.Error = Errors.ErrorFlagNotFound; - return resp; - } - - resp.FlagId = flag.Id; - resp.FlagKey = flag.Key; - resp.Value = flag.DefaultValue; - - if (company != null) - { - resp.CompanyId = company.Id; - var entitlement = company.Entitlements?.FirstOrDefault(e => e != null && e.FeatureKey == flag.Key); - if (entitlement != null) - { - resp.Entitlement = entitlement; - } - } - - if (user != null) - { - resp.UserId = user.Id; - } - - // Filter company and user rules to only include those for this flag - var companyRules = company?.Rules? - .Where(r => r?.FlagId != null && r.FlagId == flag.Id) - .ToList(); - var userRules = user?.Rules? - .Where(r => r?.FlagId != null && r.FlagId == flag.Id) - .ToList(); - - var ruleChecker = RuleCheckService.NewRuleCheckService(); - // The wire format may send `"rules": null` for flags with no rules; treat that as empty. - var flagRules = flag.Rules?.ToList() ?? new List(); - foreach (var group in GroupRulesByPriority(flagRules, companyRules, userRules)) - { - foreach (var rule in group) - { - if (rule == null) - { - continue; - } - - try - { - var checkRuleResp = await ruleChecker.Check(new CheckScope - { - Company = company, - Rule = rule, - User = user - }, cancellationToken); - - if (checkRuleResp == null) - { - resp.Error = Errors.ErrorUnexpected; - return resp; - } - - if (checkRuleResp.Match) - { - resp.Value = rule.Value; - resp.Reason = $"Matched {rule.RuleType.DisplayName()} rule \"{rule.Name}\" ({rule.Id})"; - resp.SetRuleFields(company, rule); - return resp; - } - } - catch (Exception ex) - { - resp.Error = ex; - return resp; - } - } - } - - return resp; - } - - public static List> GroupRulesByPriority(params List?[] ruleSlices) - { - var allRules = new List(); - foreach (var rules in ruleSlices) - { - if (rules != null && rules.Count > 0) - { - allRules.AddRange(rules); - } - } - - if (allRules.Count == 0) - { - return new List>(); - } - - // Group rules by their type (convert to internal enum for grouping) - var grouped = allRules.GroupBy(rule => rule.RuleType) - .ToDictionary(g => g.Key, g => g.ToList()); - - // Prioritize rules within each type group - foreach (var ruleType in grouped.Keys.ToList()) - { - var rulesForType = grouped[ruleType]; - switch (ruleType.PrioritizationMethod()) - { - case RulePrioritizationMethod.Priority: - grouped[ruleType] = rulesForType.OrderBy(r => r.Priority).ToList(); - break; - case RulePrioritizationMethod.Optimistic: - grouped[ruleType] = rulesForType.OrderByDescending(r => r.Value).ToList(); - break; - } - } - - // Prioritize type groups relative to one another - var prioritizedGroups = new List>(); - foreach (var ruleType in RulesengineRuleTypeExtensions.RuleTypePriority) - { - if (grouped.TryGetValue(ruleType, out var rules2)) - { - prioritizedGroups.Add(rules2); - } - } - - return prioritizedGroups; - } - } - - /// - /// Response from CheckFlagWithEntitlement containing flag check result with entitlement information. - /// This is a subset of CheckFlagResponseData with deprecated fields removed. - /// - public class CheckFlagWithEntitlementResponse - { - /// - /// If company keys were provided and matched a company, its ID - /// - public string? CompanyId { get; set; } - - /// - /// Entitlement information for the feature, if applicable - /// - public RulesengineFeatureEntitlement? Entitlement { get; set; } - - /// - /// If a flag was found, its ID - /// - public string? FlagId { get; set; } - - /// - /// The key used to check the flag - /// - public required string FlagKey { get; set; } - - /// - /// A human-readable explanation of the result - /// - public required string Reason { get; set; } - - /// - /// If a rule was found, its ID - /// - public string? RuleId { get; set; } - - /// - /// If a rule was found, its type - /// - public string? RuleType { get; set; } - - /// - /// If user keys were provided and matched a user, its ID - /// - public string? UserId { get; set; } - - /// - /// A boolean flag check result; for feature entitlements, this represents whether further consumption is permitted - /// - public bool Value { get; set; } - - /// - /// Create a CheckFlagWithEntitlementResponse from a rules engine CheckFlagResult - /// - public static CheckFlagWithEntitlementResponse FromCheckFlagResult(CheckFlagResult result) - { - return new CheckFlagWithEntitlementResponse - { - CompanyId = result.CompanyId, - Entitlement = result.Entitlement, - FlagId = result.FlagId, - FlagKey = result.FlagKey, - Reason = result.Reason, - RuleId = result.RuleId, - RuleType = result.RuleType?.Value, - UserId = result.UserId, - Value = result.Value - }; - } - - /// - /// Create a CheckFlagWithEntitlementResponse from an API CheckFlagResponseData - /// - public static CheckFlagWithEntitlementResponse FromApiResponse(CheckFlagResponseData data, string flagKey) - { - return new CheckFlagWithEntitlementResponse - { - CompanyId = data.CompanyId, - Entitlement = ToRulesEngineEntitlement(data.Entitlement), - FlagId = data.FlagId, - FlagKey = flagKey, - Reason = data.Reason, - RuleId = data.RuleId, - RuleType = data.RuleType?.Value, - UserId = data.UserId, - Value = data.Value - }; - } - - /// - /// Convert API FeatureEntitlement to RulesengineFeatureEntitlement - /// - private static RulesengineFeatureEntitlement? ToRulesEngineEntitlement(FeatureEntitlement? entitlement) - { - if (entitlement == null) - return null; - - return new RulesengineFeatureEntitlement - { - Allocation = entitlement.Allocation, - CreditId = entitlement.CreditId, - CreditRemaining = entitlement.CreditRemaining, - CreditTotal = entitlement.CreditTotal, - CreditUsed = entitlement.CreditUsed, - EventName = entitlement.EventName, - FeatureId = entitlement.FeatureId, - FeatureKey = entitlement.FeatureKey, - MetricPeriod = entitlement.MetricPeriod.HasValue - ? new RulesengineMetricPeriod(entitlement.MetricPeriod.Value.Value) - : null, - MetricResetAt = entitlement.MetricResetAt, - MonthReset = entitlement.MonthReset.HasValue - ? new RulesengineMetricPeriodMonthReset(entitlement.MonthReset.Value.Value) - : null, - SoftLimit = entitlement.SoftLimit, - Usage = entitlement.Usage, - ValueType = new RulesengineEntitlementValueType(entitlement.ValueType.Value) - }; - } - } -} diff --git a/src/SchematicHQ.Client/RulesEngine/Metrics.cs b/src/SchematicHQ.Client/RulesEngine/Metrics.cs deleted file mode 100644 index 49c42b7d..00000000 --- a/src/SchematicHQ.Client/RulesEngine/Metrics.cs +++ /dev/null @@ -1,214 +0,0 @@ -namespace SchematicHQ.Client.RulesEngine -{ - public static class Metrics - { - - public static DateTime? GetCurrentMetricPeriodStartForCalendarMetricPeriod(RulesengineMetricPeriod? metricPeriod) - { - if (metricPeriod == null) return null; - - var now = DateTime.UtcNow; - - if (metricPeriod == RulesengineMetricPeriod.CurrentDay) - // UTC midnight for the current day - return now.Date; - - if (metricPeriod == RulesengineMetricPeriod.CurrentWeek) - { - // UTC midnight for the current week's Monday - int daysSinceMonday = ((int)now.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7; - return now.Date.AddDays(-daysSinceMonday); - } - - if (metricPeriod == RulesengineMetricPeriod.CurrentMonth) - // UTC midnight for the first day of current month - return new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc); - - return null; - } - - /// - /// Given a company, determine the current metric period start based on the company's billing subscription. - /// - public static DateTime? GetCurrentMetricPeriodStartForCompanyBillingSubscription(RulesengineCompany? company) - { - // If no subscription exists, we use calendar month reset - if (company == null || company.Subscription == null) - { - return GetCurrentMetricPeriodStartForCalendarMetricPeriod(RulesengineMetricPeriod.CurrentMonth); - } - - var now = DateTime.UtcNow; - var periodStart = company.Subscription.PeriodStart; - var periodEnd = company.Subscription.PeriodEnd; - - // If the start period is in the future, the metric period is from the start of the current calendar month until either - // the end of the current calendar month or the start of the billing period, whichever comes first - if (periodStart > now) - { - DateTime? startOfNextMonth = GetCurrentMetricPeriodStartForCalendarMetricPeriod(RulesengineMetricPeriod.CurrentMonth); - if (periodStart > startOfNextMonth) - { - return startOfNextMonth; - } - return periodStart; - } - - // Month metric period will reset on the same day/hour/minute/second as the subscription started every month; get that timestamp for the current month - var currentPeriodStart = new DateTime( - now.Year, - now.Month, - Math.Min(periodStart.Day, DateTime.DaysInMonth(now.Year, now.Month)), - periodStart.Hour, - periodStart.Minute, - periodStart.Second, - periodStart.Millisecond, - DateTimeKind.Utc); - - // If we've already passed this month's reset date, move to next month - if (currentPeriodStart < now) - { - currentPeriodStart = currentPeriodStart.AddMonths(1); - - // Handle day adjustment when going back from 30/31 to a month with fewer days - var daysInMonth = DateTime.DaysInMonth(currentPeriodStart.Year, currentPeriodStart.Month); - if (periodStart.Day > daysInMonth) - { - currentPeriodStart = new DateTime( - currentPeriodStart.Year, - currentPeriodStart.Month, - daysInMonth, - periodStart.Hour, - periodStart.Minute, - periodStart.Second, - periodStart.Millisecond, - DateTimeKind.Utc); - } - } - - // If the next reset is after the end of the billing period, use the end of the billing period instead - if (currentPeriodStart > periodEnd) - { - return periodEnd; - } - - return currentPeriodStart; - } - - public static DateTime? GetNextMetricPeriodStartForCalendarMetricPeriod(RulesengineMetricPeriod? metricPeriod) - { - if (metricPeriod == null) return null; - - if (metricPeriod == RulesengineMetricPeriod.CurrentDay) - // UTC midnight for upcoming day - return DateTime.UtcNow.Date.AddDays(1); - - if (metricPeriod == RulesengineMetricPeriod.CurrentWeek) - { - // UTC midnight for upcoming Monday (C# uses Monday as first day, Go example used Sunday) - var now = DateTime.UtcNow; - int daysUntilMonday = ((int)DayOfWeek.Monday - (int)now.DayOfWeek + 7) % 7; - if (daysUntilMonday == 0) - daysUntilMonday = 7; // If today is Monday, get next Monday - return now.Date.AddDays(daysUntilMonday); - } - - if (metricPeriod == RulesengineMetricPeriod.CurrentMonth) - { - // UTC midnight for the first day of next month - var currentDate = DateTime.UtcNow; - return new DateTime(currentDate.Year, currentDate.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(1); - } - - return null; - } - - - /// - /// Given a company, determine the next metric period start based on the company's billing subscription. - /// - public static DateTime? GetNextMetricPeriodStartForCompanyBillingSubscription(RulesengineCompany? company) - { - // If no subscription exists, we use calendar month reset - if (company == null || company.Subscription == null) - { - return GetNextMetricPeriodStartForCalendarMetricPeriod(RulesengineMetricPeriod.CurrentMonth); - } - - var now = DateTime.UtcNow; - var periodEnd = company.Subscription.PeriodEnd; - var periodStart = company.Subscription.PeriodStart; - - // If the start period is in the future, the metric period is from the start of the current calendar month until either - // the end of the current calendar month or the start of the billing period, whichever comes first - if (periodStart > now) - { - var startOfNextMonth = GetNextMetricPeriodStartForCalendarMetricPeriod(RulesengineMetricPeriod.CurrentMonth); - if (periodStart > startOfNextMonth) - { - return startOfNextMonth; - } - - return periodStart; - } - - // Month metric period will reset on the same day/hour/minute/second as the subscription started every month; - // Get that timestamp for the current month - var nextReset = new DateTime( - now.Year, - now.Month, - periodStart.Day, - periodStart.Hour, - periodStart.Minute, - periodStart.Second, - periodStart.Millisecond, - DateTimeKind.Utc); - - // If we've already passed this month's reset date, move to next month - if (nextReset <= now) - { - nextReset = nextReset.AddMonths(1); - } - - // If the next reset is after the end of the billing period, use the end of the billing period instead - if (nextReset > periodEnd) - { - return periodEnd; - } - - return nextReset; - } - - /// - /// Given a rule condition and a company, determine the next metric period start. - /// Will return null if the condition is not a metric condition. - /// - public static DateTime? GetNextMetricPeriodStartFromCondition(RulesengineCondition? condition, RulesengineCompany? company) - { - // Only metric conditions have a metric period that can reset - if (condition == null || condition.ConditionType != RulesengineConditionType.Metric || condition.MetricPeriod == null) - { - return null; - } - - var metricPeriod = condition.MetricPeriod; - - // If the metric period is all-time, no reset - if (metricPeriod == RulesengineMetricPeriod.AllTime) - { - return null; - } - - // Metric period current month with billing cycle reset - if (metricPeriod == RulesengineMetricPeriod.CurrentMonth && - condition.MetricPeriodMonthReset.HasValue && - condition.MetricPeriodMonthReset == RulesengineMetricPeriodMonthReset.BillingCycle) - { - return GetNextMetricPeriodStartForCompanyBillingSubscription(company); - } - - // Calendar-based metric periods - return GetNextMetricPeriodStartForCalendarMetricPeriod(metricPeriod); - } - } -} diff --git a/src/SchematicHQ.Client/RulesEngine/RuleCheck.cs b/src/SchematicHQ.Client/RulesEngine/RuleCheck.cs deleted file mode 100644 index 43856ff6..00000000 --- a/src/SchematicHQ.Client/RulesEngine/RuleCheck.cs +++ /dev/null @@ -1,350 +0,0 @@ -using SchematicHQ.Client.RulesEngine.Utils; - -namespace SchematicHQ.Client.RulesEngine -{ - public class CheckScope - { - public RulesengineCompany? Company { get; set; } - public RulesengineRule? Rule { get; set; } - public RulesengineUser? User { get; set; } - } - - public class CheckResult - { - public CheckScope? CheckScope { get; set; } - public bool Match { get; set; } - } - - public class RuleCheckService - { - public static RuleCheckService NewRuleCheckService() - { - return new RuleCheckService(); - } - - public async Task Check(CheckScope scope, CancellationToken cancellationToken = default) - { - var res = new CheckResult - { - CheckScope = scope - }; - - if (scope.Rule == null) - { - return res; - } - - if (scope.Rule.RuleType == RulesengineRuleType.Default.Value || scope.Rule.RuleType == RulesengineRuleType.GlobalOverride.Value) - { - res.Match = true; - return res; - } - - bool match; - foreach (var condition in scope.Rule.Conditions ?? Enumerable.Empty()) - { - match = await CheckCondition(scope.Company, scope.User, condition, cancellationToken); - if (!match) - { - return res; - } - } - - foreach (var group in scope.Rule.ConditionGroups ?? Enumerable.Empty()) - { - match = await CheckConditionGroup(scope.Company, scope.User, group, cancellationToken); - if (!match) - { - return res; - } - } - - res.Match = true; - return res; - } - - private async Task CheckCondition(RulesengineCompany? company, RulesengineUser? user, RulesengineCondition condition, CancellationToken cancellationToken) - { - if (condition == null) - { - return false; - } - - if (condition.ConditionType == RulesengineConditionType.Company) - return await CheckCompanyCondition(company, condition, cancellationToken); - if (condition.ConditionType == RulesengineConditionType.Metric.Value) - return await CheckMetricCondition(company, condition, cancellationToken); - if (condition.ConditionType == RulesengineConditionType.BasePlan.Value) - return await CheckBasePlanCondition(company, condition, cancellationToken); - if (condition.ConditionType == RulesengineConditionType.Plan.Value) - return await CheckPlanCondition(company, condition, cancellationToken); - if (condition.ConditionType == RulesengineConditionType.Trait.Value) - return await CheckTraitCondition(company, user, condition, cancellationToken); - if (condition.ConditionType == RulesengineConditionType.User.Value) - return await CheckUserCondition(user, condition, cancellationToken); - if (condition.ConditionType == RulesengineConditionType.BillingProduct.Value) - return await CheckBillingProductCondition(company, condition, cancellationToken); - if (condition.ConditionType == RulesengineConditionType.Credit.Value) - return await CheckCreditBalanceCondition(company, condition, cancellationToken); - if (condition.ConditionType == RulesengineConditionType.PlanVersion.Value) - return await CheckPlanVersionCondition(company, condition, cancellationToken); - - return false; - } - - private async Task CheckConditionGroup(RulesengineCompany? company, RulesengineUser? user, RulesengineConditionGroup group, CancellationToken cancellationToken) - { - if (group == null || !group.Conditions.Any()) - { - return false; - } - - foreach (var condition in group.Conditions) - { - if (await CheckCondition(company, user, condition, cancellationToken)) - { - return true; - } - } - - return false; - } - - private Task CheckCompanyCondition(RulesengineCompany? company, RulesengineCondition condition, CancellationToken cancellationToken) - { - if (condition.ConditionType != RulesengineConditionType.Company || company == null) - { - return Task.FromResult(false); - } - - var resourceMatch = Set.NewSet(condition.ResourceIds.ToArray()).Contains(company.Id); - if (condition.Operator == ComparableOperator.Ne) - { - return Task.FromResult(!resourceMatch); - } - - return Task.FromResult(resourceMatch); - } - - private Task CheckBillingProductCondition(RulesengineCompany? company, RulesengineCondition condition, CancellationToken cancellationToken) - { - if (condition.ConditionType != RulesengineConditionType.BillingProduct || company == null) - { - return Task.FromResult(false); - } - - var companyBillingProductIds = Set.NewSet(company.BillingProductIds.ToArray()); - var resourceMatch = Set.NewSet(condition.ResourceIds.ToArray()).Intersection(companyBillingProductIds).Len > 0; - if (condition.Operator == ComparableOperator.Ne) - { - return Task.FromResult(!resourceMatch); - } - - return Task.FromResult(resourceMatch); - } - - private Task CheckCreditBalanceCondition(RulesengineCompany? company, RulesengineCondition condition, CancellationToken cancellationToken) - { - if (condition.ConditionType != RulesengineConditionType.Credit || company == null || string.IsNullOrEmpty(condition.CreditId)) - { - return Task.FromResult(false); - } - - double consumptionCost = 1.0; - if (condition.ConsumptionRate.HasValue) - { - consumptionCost = condition.ConsumptionRate.Value; - } - - double creditBalance = 0.0; - if (company.CreditBalances != null && company.CreditBalances.TryGetValue(condition.CreditId, out double balance)) - { - creditBalance = balance; - } - - return Task.FromResult(creditBalance >= consumptionCost); - } - - private Task CheckPlanCondition(RulesengineCompany? company, RulesengineCondition condition, CancellationToken cancellationToken) - { - if (condition.ConditionType != RulesengineConditionType.Plan || company == null) - { - return Task.FromResult(false); - } - - var companyPlanIds = Set.NewSet(company.PlanIds.ToArray()); - var resourceMatch = Set.NewSet(condition.ResourceIds.ToArray()).Intersection(companyPlanIds).Len > 0; - if (condition.Operator == ComparableOperator.Ne) - { - return Task.FromResult(!resourceMatch); - } - - return Task.FromResult(resourceMatch); - } - - private Task CheckBasePlanCondition(RulesengineCompany? company, RulesengineCondition condition, CancellationToken cancellationToken) - { - if (condition.ConditionType != RulesengineConditionType.BasePlan || company == null) - { - return Task.FromResult(false); - } - - var op = condition.Operator; - - if (op == ComparableOperator.IsEmpty) - { - return Task.FromResult(company.BasePlanId == null); - } - - if (op == ComparableOperator.NotEmpty) - { - return Task.FromResult(company.BasePlanId != null); - } - - var resourceIds = Set.NewSet(condition.ResourceIds.ToArray()); - var resourceMatch = company.BasePlanId != null && resourceIds.Contains(company.BasePlanId); - if (op == ComparableOperator.Ne) - { - return Task.FromResult(company.BasePlanId == null || !resourceIds.Contains(company.BasePlanId)); - } - - return Task.FromResult(resourceMatch); - } - - private Task CheckMetricCondition(RulesengineCompany? company, RulesengineCondition condition, CancellationToken cancellationToken) - { - if (condition == null || condition.ConditionType != RulesengineConditionType.Metric || company == null || string.IsNullOrEmpty(condition.EventSubtype)) - { - return Task.FromResult(false); - } - - long leftVal = 0; - var metric = CompanyMetricExtensions.Find(company.Metrics, condition.EventSubtype, condition.MetricPeriod, condition.MetricPeriodMonthReset); - if (metric != null) - { - leftVal = metric.Value; - } - - if (!condition.MetricValue.HasValue) - { - throw new InvalidOperationException($"Expected metric value for condition: {condition.Id}, but received null"); - } - - long rightVal = condition.MetricValue.Value; - if (condition.ComparisonTraitDefinition != null) - { - var comparisonTrait = FindTrait(condition.ComparisonTraitDefinition, company.Traits); - if (comparisonTrait == null) - { - rightVal = 0; - } - else - { - rightVal = TypeConverter.StringToInt64(comparisonTrait.Value); - } - } - - bool resourceMatch = TypeConverter.CompareInt64(leftVal, rightVal, condition.Operator); - - return Task.FromResult(resourceMatch); - } - - private Task CheckTraitCondition(RulesengineCompany? company, RulesengineUser? user, RulesengineCondition condition, CancellationToken cancellationToken) - { - if (condition == null || condition.ConditionType != RulesengineConditionType.Trait || condition.TraitDefinition == null) - { - return Task.FromResult(false); - } - - var traitDef = condition.TraitDefinition; - RulesengineTrait? trait; - RulesengineTrait? comparisonTrait; - - if (traitDef.EntityType == RulesengineEntityType.Company && company != null) - { - trait = FindTrait(traitDef, company.Traits); - comparisonTrait = FindTrait(condition.ComparisonTraitDefinition, company.Traits); - } - else if (traitDef.EntityType == RulesengineEntityType.User && user != null) - { - trait = FindTrait(traitDef, user.Traits); - comparisonTrait = FindTrait(condition.ComparisonTraitDefinition, user.Traits); - } - else - { - return Task.FromResult(false); - } - - bool resourceMatch = CompareTraits(condition, trait, comparisonTrait); - - return Task.FromResult(resourceMatch); - } - - private Task CheckUserCondition(RulesengineUser? user, RulesengineCondition condition, CancellationToken cancellationToken) - { - if (condition.ConditionType != RulesengineConditionType.User || user == null) - { - return Task.FromResult(false); - } - - var resourceMatch = Set.NewSet(condition.ResourceIds.ToArray()).Contains(user.Id); - if (condition.Operator == ComparableOperator.Ne) - { - return Task.FromResult(!resourceMatch); - } - - return Task.FromResult(resourceMatch); - } - - private Task CheckPlanVersionCondition(RulesengineCompany? company, RulesengineCondition condition, CancellationToken cancellationToken) - { - if (condition.ConditionType != RulesengineConditionType.PlanVersion || company == null) - { - return Task.FromResult(false); - } - - var companyPlanVersionIds = Set.NewSet(company.PlanVersionIds.ToArray()); - var resourceMatch = Set.NewSet(condition.ResourceIds.ToArray()).Intersection(companyPlanVersionIds).Len > 0; - if (condition.Operator == ComparableOperator.Ne) - { - return Task.FromResult(!resourceMatch); - } - - return Task.FromResult(resourceMatch); - } - - static private bool CompareTraits(RulesengineCondition condition, RulesengineTrait? trait, RulesengineTrait? comparisonTrait) - { - string leftVal = ""; - string rightVal = condition.TraitValue ?? ""; - - if (trait != null) - { - leftVal = trait.Value; - } - - if (comparisonTrait != null) - { - rightVal = comparisonTrait.Value; - } - - var comparableType = ComparableType.String; - if (trait != null && trait.TraitDefinition != null) - { - comparableType = trait.TraitDefinition.ComparableType.ToComparableType(); - } - - return TypeConverter.Compare(leftVal, rightVal, comparableType, condition.Operator); - } - - static private RulesengineTrait? FindTrait(RulesengineTraitDefinition? traitDef, IEnumerable? traits) - { - if (traitDef == null || traits == null) - { - return null; - } - - return traits.FirstOrDefault(t => t.TraitDefinition?.Id == traitDef.Id); - } - } -} diff --git a/src/SchematicHQ.Client/RulesEngine/RulesEngineExtensions.cs b/src/SchematicHQ.Client/RulesEngine/RulesEngineExtensions.cs deleted file mode 100644 index 3a619f6e..00000000 --- a/src/SchematicHQ.Client/RulesEngine/RulesEngineExtensions.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Runtime.CompilerServices; - -namespace SchematicHQ.Client.RulesEngine -{ - public static class RulesengineCompanyExtensions - { - private static readonly ConditionalWeakTable MetricsLocks = new ConditionalWeakTable(); - - public static RulesengineTrait? GetTraitByDefinitionId(this RulesengineCompany company, string definitionId) - { - return company.Traits?.FirstOrDefault(t => t.TraitDefinition?.Id == definitionId); - } - - public static void AddMetric(this RulesengineCompany company, RulesengineCompanyMetric? metric) - { - if (metric == null) - { - return; - } - - var metricsList = company.Metrics as List; - if (metricsList == null) - { - metricsList = company.Metrics != null - ? new List(company.Metrics) - : new List(); - company.Metrics = metricsList; - } - - var metricsLock = MetricsLocks.GetOrCreateValue(company); - lock (metricsLock) - { - var existingMetricIndex = metricsList.FindIndex(m => - m.EventSubtype == metric.EventSubtype && - m.Period == metric.Period && - m.MonthReset == metric.MonthReset); - - if (existingMetricIndex != -1) - { - metricsList[existingMetricIndex] = metric; - } - else - { - metricsList.Add(metric); - } - } - } - } - - public static class CompanyMetricExtensions - { - public static RulesengineCompanyMetric? Find( - IEnumerable? metrics, - string eventSubtype, - RulesengineMetricPeriod? period, - RulesengineMetricPeriodMonthReset? monthReset) - { - if (metrics == null || string.IsNullOrEmpty(eventSubtype) || period == null) - return null; - - return metrics.FirstOrDefault(m => - m.EventSubtype == eventSubtype && - m.Period.Value == period.Value.Value && - (monthReset == null || m.MonthReset.Value == monthReset.Value.Value)); - } - } -} diff --git a/src/SchematicHQ.Client/RulesEngine/Utils/GeneratedModelHash.cs b/src/SchematicHQ.Client/RulesEngine/Utils/GeneratedModelHash.cs deleted file mode 100644 index 35657a3c..00000000 --- a/src/SchematicHQ.Client/RulesEngine/Utils/GeneratedModelHash.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Auto-generated code - do not modify -// This file is automatically generated based on model file changes - -namespace SchematicHQ.Client.RulesEngine.Utils -{ - public static class GeneratedModelHash - { - /// - /// Auto-generated hash of all model files. - /// This value changes whenever any model file is modified. - /// - public const string Value = "dec34ea6"; - } -} diff --git a/src/SchematicHQ.Client/RulesEngine/Utils/Set.cs b/src/SchematicHQ.Client/RulesEngine/Utils/Set.cs deleted file mode 100644 index 4ebf9549..00000000 --- a/src/SchematicHQ.Client/RulesEngine/Utils/Set.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace SchematicHQ.Client.RulesEngine.Utils -{ - public class Set - { - private HashSet _items = new HashSet(); - - public Set(params T[] items) - { - if (items != null) - { - foreach (var item in items) - { - _items.Add(item); - } - } - } - - public static Set NewSet(params T[] items) - { - return new Set(items); - } - - public bool Contains(T item) - { - return _items.Contains(item); - } - - public int Count => _items.Count; - - public Set Intersection(Set other) - { - var result = new Set(); - result._items = new HashSet(_items.Intersect(other._items)); - return result; - } - - public int Len => Count; - } -} \ No newline at end of file diff --git a/src/SchematicHQ.Client/RulesEngine/Utils/TypeConverter.cs b/src/SchematicHQ.Client/RulesEngine/Utils/TypeConverter.cs deleted file mode 100644 index ecac3bb1..00000000 --- a/src/SchematicHQ.Client/RulesEngine/Utils/TypeConverter.cs +++ /dev/null @@ -1,213 +0,0 @@ -using System.Globalization; -using System.Text.Json.Serialization; - -namespace SchematicHQ.Client.RulesEngine.Utils -{ - - [JsonConverter(typeof(SchematicHQ.Client.Cache.ComparableTypeConverter))] - public enum ComparableType - { - Unknown, - String, - Int, - Bool, - Date - } - - // Extension methods to convert from generated types to utility types - public static class ComparableTypeExtensions - { - public static ComparableType ToComparableType(this RulesengineTraitDefinitionComparableType comparableType) - { - return comparableType.Value switch - { - "bool" => ComparableType.Bool, - "date" => ComparableType.Date, - "int" => ComparableType.Int, - "string" => ComparableType.String, - _ => ComparableType.String - }; - } - } - - public static class TypeConverter - { - public static bool Compare(string a, string b, ComparableType comparableType, ComparableOperator op) - { - switch (comparableType) - { - case ComparableType.String: - return CompareString(a, b, op); - case ComparableType.Int: - return CompareInt64(StringToInt64(a), StringToInt64(b), op); - case ComparableType.Bool: - return CompareBool(StringToBool(a), StringToBool(b), op); - case ComparableType.Date: - var dateA = StringToDate(a); - var dateB = StringToDate(b); - if (dateA == null || dateB == null) - return false; - return CompareDate(dateA.Value, dateB.Value, op); - default: - return false; - } - } - - public static bool CompareString(string a, string b, ComparableOperator op) - { - switch (op.Value) - { - case ComparableOperator.Values.Eq: - return string.Equals(a, b, StringComparison.OrdinalIgnoreCase); - case ComparableOperator.Values.Ne: - return !string.Equals(a, b, StringComparison.OrdinalIgnoreCase); - case ComparableOperator.Values.Lt: - return string.Compare(a, b, StringComparison.OrdinalIgnoreCase) < 0; - case ComparableOperator.Values.Lte: - return string.Compare(a, b, StringComparison.OrdinalIgnoreCase) <= 0; - case ComparableOperator.Values.Gt: - return string.Compare(a, b, StringComparison.OrdinalIgnoreCase) > 0; - case ComparableOperator.Values.Gte: - return string.Compare(a, b, StringComparison.OrdinalIgnoreCase) >= 0; - case ComparableOperator.Values.IsEmpty: - return string.IsNullOrEmpty(a); - case ComparableOperator.Values.NotEmpty: - return !string.IsNullOrEmpty(a); - default: - return false; - } - } - - public static bool CompareInt64(long a, long b, ComparableOperator op) - { - switch (op.Value) - { - case ComparableOperator.Values.Eq: - return a == b; - case ComparableOperator.Values.Ne: - return a != b; - case ComparableOperator.Values.Lt: - return a < b; - case ComparableOperator.Values.Lte: - return a <= b; - case ComparableOperator.Values.Gt: - return a > b; - case ComparableOperator.Values.Gte: - return a >= b; - default: - return false; - } - } - - public static bool CompareFloat(double a, double b, ComparableOperator op) - { - switch (op.Value) - { - case ComparableOperator.Values.Eq: - return Math.Abs(a - b) < double.Epsilon; - case ComparableOperator.Values.Ne: - return Math.Abs(a - b) >= double.Epsilon; - case ComparableOperator.Values.Lt: - return a < b; - case ComparableOperator.Values.Lte: - return a <= b; - case ComparableOperator.Values.Gt: - return a > b; - case ComparableOperator.Values.Gte: - return a >= b; - default: - return false; - } - } - - public static bool CompareBool(bool a, bool b, ComparableOperator op) - { - switch (op.Value) - { - case ComparableOperator.Values.Eq: - return a == b; - case ComparableOperator.Values.Ne: - return a != b; - default: - return false; - } - } - - public static bool CompareDate(DateTime a, DateTime b, ComparableOperator op) - { - switch (op.Value) - { - case ComparableOperator.Values.Eq: - return a.Date == b.Date; - case ComparableOperator.Values.Ne: - return a.Date != b.Date; - case ComparableOperator.Values.Lt: - return a < b; - case ComparableOperator.Values.Lte: - return a <= b; - case ComparableOperator.Values.Gt: - return a > b; - case ComparableOperator.Values.Gte: - return a >= b; - default: - return false; - } - } - - public static string BoolToString(bool v) - { - return v ? "true" : "false"; - } - - public static bool Int64ToBool(long v) - { - return v != 0; - } - - public static string Int64ToString(long v) - { - return v.ToString(); - } - - public static bool StringToBool(string? v) - { - if (string.IsNullOrEmpty(v)) - return false; - - return v.ToLower() == "true" || v == "1"; - } - - public static long StringToInt64(string? v) - { - if (string.IsNullOrEmpty(v)) - return 0; - - if (long.TryParse(v, out long result)) - return result; - - return 0; - } - - public static double StringToFloat(string? v) - { - if (string.IsNullOrEmpty(v)) - return 0; - - if (double.TryParse(v, NumberStyles.Any, CultureInfo.InvariantCulture, out double result)) - return result; - - return 0; - } - - public static DateTime? StringToDate(string? v) - { - if (string.IsNullOrEmpty(v)) - return null; - - if (DateTime.TryParse(v, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime result)) - return result; - - return null; - } - } -} diff --git a/src/SchematicHQ.Client/RulesEngine/Utils/VersionGenerator.cs b/src/SchematicHQ.Client/RulesEngine/Utils/VersionGenerator.cs index 8067a189..f46113dd 100644 --- a/src/SchematicHQ.Client/RulesEngine/Utils/VersionGenerator.cs +++ b/src/SchematicHQ.Client/RulesEngine/Utils/VersionGenerator.cs @@ -1,12 +1,42 @@ +using System.Reflection; + namespace SchematicHQ.Client.RulesEngine.Utils; + +/// +/// Provides the canonical rules-engine schema version used to version the datastream cache. +/// public static class SchemaVersionGenerator { - /// - /// Gets the global version for all models - /// - public static string GetGlobalSchemaVersion() - { - // Use the pre-generated hash for all models - return GeneratedModelHash.Value; - } -} \ No newline at end of file + // Fern requires the generated RulesEngineSchemaVersion enum to carry a placeholder member + // alongside the single real member, which holds the canonical rules-engine schema version. + private const string FernPlaceholderValue = "placeholder-for-fern-compatibility"; + private const string FallbackVersion = "1"; + + private static readonly string _schemaVersion = ResolveSchemaVersion(); + + /// + /// Gets the global schema version for all rules-engine models. + /// + /// Sourced from the Fern-generated enum (the + /// canonical version emitted by codegen, which changes whenever the model schema changes). + /// Resolved by reflection rather than a direct symbol reference because Fern encodes the + /// version into the member name (e.g. V5B3E7220), so a direct reference + /// would break on every schema bump; reflecting for the non-placeholder value is stable. + /// + public static string GetGlobalSchemaVersion() => _schemaVersion; + + private static string ResolveSchemaVersion() + { + var versions = typeof(RulesEngineSchemaVersion) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.FieldType == typeof(RulesEngineSchemaVersion)) + .Select(f => ((RulesEngineSchemaVersion)f.GetValue(null)!).Value) + .Where(v => !string.IsNullOrEmpty(v) && v != FernPlaceholderValue) + .Distinct() + .ToList(); + + // Exactly one real value is expected. Anything else means Fern changed the enum's + // shape — fall back to a stable constant so cache keys remain well-formed. + return versions.Count == 1 ? versions[0] : FallbackVersion; + } +} diff --git a/src/SchematicHQ.Client/RulesEngine/Wasm/WasmRulesEngine.cs b/src/SchematicHQ.Client/RulesEngine/Wasm/WasmRulesEngine.cs new file mode 100644 index 00000000..88514425 --- /dev/null +++ b/src/SchematicHQ.Client/RulesEngine/Wasm/WasmRulesEngine.cs @@ -0,0 +1,344 @@ +using System; +using System.IO; +using System.Text; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using SchematicHQ.Client.Core; +using Wasmtime; + +#nullable enable + +namespace SchematicHQ.Client.RulesEngine +{ + /// + /// WASM-based rules engine for local flag evaluation. + /// + /// Loads the shared Schematic rules engine WASM binary (compiled from Rust) + /// and evaluates flags locally using cached flag definitions, company data, and + /// user data. Hosts the module with the Wasmtime .NET runtime — mirroring the + /// Python and Ruby SDKs (and the pure-Java Chicory host in schematic-java). + /// + /// Data flow: typed models are serialized to snake_case JSON (via each type's + /// JsonPropertyName attributes) into an envelope {flag, company, user}; + /// the WASM module returns camelCase JSON which is converted to snake_case and + /// deserialized into . + /// + internal sealed class WasmRulesEngine : IDisposable + { + private const string WasmResourceName = "rulesengine.wasm"; + + private readonly ILogger _logger; + private readonly object _lock = new(); + + private volatile bool _initialized; + + // Wasmtime objects — kept alive for the lifetime of the engine. + private Engine? _engine; + private Module? _module; + private Linker? _linker; + private Store? _store; + private Instance _instance; + private Memory? _memory; + + // Exported functions (see the Rust rules engine C-ABI). + private Func? _alloc; // alloc(len) -> ptr + private Action? _dealloc; // dealloc(ptr, len) + private Func? _checkFlag; // checkFlagCombined(ptr, len) -> resultLen + private Action? _setTime; // setCurrentTimeMillis(nowMillis) — optional + private Func? _getResultJson; // getResultJson() -> ptr + private Func? _getResultJsonLength; // getResultJsonLength() -> len + + public WasmRulesEngine(ILogger logger) + { + _logger = logger; + } + + public bool IsInitialized => _initialized; + + /// + /// Loads and instantiates the WASM module. Idempotent and thread-safe. + /// On failure the engine stays uninitialized so callers can degrade gracefully. + /// + public void Initialize() + { + if (_initialized) + { + return; + } + + lock (_lock) + { + if (_initialized) + { + return; + } + + using var wasmStream = + typeof(WasmRulesEngine).Assembly.GetManifestResourceStream(WasmResourceName) + ?? throw new InvalidOperationException( + $"Embedded WASM resource '{WasmResourceName}' not found. " + + "Ensure scripts/download-wasm.sh ran before the build."); + + _engine = new Engine(); + _module = Module.FromStream(_engine, WasmResourceName, wasmStream); + + _linker = new Linker(_engine); + _linker.DefineWasi(); + + _store = new Store(_engine); + // Inherit stdout/stderr so Rust panics / eprintln! surface for debugging. + _store.SetWasiConfiguration( + new WasiConfiguration() + .WithInheritedStandardOutput() + .WithInheritedStandardError() + ); + + _instance = _linker.Instantiate(_store, _module); + + _memory = + _instance.GetMemory("memory") + ?? throw new InvalidOperationException("WASM module does not export 'memory'"); + + _alloc = + _instance.GetFunction("alloc") + ?? throw new InvalidOperationException("WASM module does not export 'alloc'"); + _dealloc = + _instance.GetAction("dealloc") + ?? throw new InvalidOperationException("WASM module does not export 'dealloc'"); + _checkFlag = + _instance.GetFunction("checkFlagCombined") + ?? throw new InvalidOperationException( + "WASM module does not export 'checkFlagCombined'" + ); + _getResultJson = + _instance.GetFunction("getResultJson") + ?? throw new InvalidOperationException( + "WASM module does not export 'getResultJson'" + ); + _getResultJsonLength = + _instance.GetFunction("getResultJsonLength") + ?? throw new InvalidOperationException( + "WASM module does not export 'getResultJsonLength'" + ); + + // Optional export — resolved defensively so older wasm builds still load. + // Without setCurrentTimeMillis the clockless wasmtime host cannot compute + // metric-period reset timestamps (SCHY-471); with it, feature_usage_reset_at + // is populated. + _setTime = _instance.GetAction("setCurrentTimeMillis"); + + _initialized = true; + _logger.LogDebug("WASM rules engine initialized"); + } + } + + /// + /// Evaluates a flag using the WASM rules engine. + /// + public CheckFlagResult CheckFlag( + RulesengineCompany? company, + RulesengineUser? user, + RulesengineFlag flag + ) + { + if (!_initialized) + { + throw new InvalidOperationException("WASM rules engine not initialized"); + } + + var envelope = new JsonObject + { + ["flag"] = Sanitize(JsonUtils.SerializeToNode(flag), null), + }; + if (company != null) + { + envelope["company"] = Sanitize(JsonUtils.SerializeToNode(company), "company"); + } + if (user != null) + { + envelope["user"] = Sanitize(JsonUtils.SerializeToNode(user), "user"); + } + + var resultJson = CallWasm(envelope.ToJsonString()); + + // WASM returns camelCase JSON; the generated type expects snake_case. + var camelNode = + JsonNode.Parse(resultJson) + ?? throw new InvalidOperationException("WASM returned empty result JSON"); + var snakeJson = CamelToSnakeKeys(camelNode).ToJsonString(); + var result = JsonUtils.Deserialize(snakeJson); + + return ToCheckFlagResult(result); + } + + private static CheckFlagResult ToCheckFlagResult(RulesengineCheckFlagResult r) => + new() + { + CompanyId = r.CompanyId, + Entitlement = r.Entitlement, + Error = string.IsNullOrEmpty(r.Err) ? null : new Exception(r.Err), + FeatureAllocation = r.FeatureAllocation, + FeatureUsage = r.FeatureUsage, + FeatureUsageEvent = r.FeatureUsageEvent, + FeatureUsagePeriod = r.FeatureUsagePeriod, + FeatureUsageResetAt = r.FeatureUsageResetAt, + FlagId = r.FlagId, + FlagKey = r.FlagKey, + Reason = r.Reason, + RuleId = r.RuleId, + RuleType = r.RuleType, + UserId = r.UserId, + Value = r.Value, + }; + + public void Dispose() + { + _store?.Dispose(); + _linker?.Dispose(); + _module?.Dispose(); + _engine?.Dispose(); + } + + /// + /// Writes into WASM memory, invokes the engine, + /// and returns the result JSON. Serialized under a lock because the WASM + /// instance shares a single linear memory across calls. + /// + private string CallWasm(string inputJson) + { + var data = Encoding.UTF8.GetBytes(inputJson); + var length = data.Length; + + lock (_lock) + { + var ptr = _alloc!(length); + try + { + data.CopyTo(_memory!.GetSpan(ptr, length)); + + // Supply the host's current time so the engine can compute + // metric-period reset timestamps (SCHY-471). No-op on older wasm. + _setTime?.Invoke(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + + var resultLen = _checkFlag!(ptr, length); + if (resultLen < 0) + { + throw new InvalidOperationException( + $"WASM checkFlagCombined returned error code: {resultLen}" + ); + } + + var resultPtr = _getResultJson!(); + var actualLen = _getResultJsonLength!(); + + // Result buffer is owned by the WASM module (thread-local); no free needed. + return _memory.ReadString(resultPtr, actualLen, Encoding.UTF8); + } + finally + { + // Frees the input buffer only. + _dealloc!(ptr, length); + } + } + } + + /// + /// Recursively converts JSON object keys from camelCase to snake_case. + /// + private static JsonNode? CamelToSnakeKeys(JsonNode? node) + { + switch (node) + { + case JsonObject obj: + var result = new JsonObject(); + foreach (var kvp in obj) + { + result[CamelToSnake(kvp.Key)] = CamelToSnakeKeys(kvp.Value?.DeepClone()); + } + return result; + case JsonArray arr: + var newArr = new JsonArray(); + foreach (var element in arr) + { + newArr.Add(CamelToSnakeKeys(element?.DeepClone())); + } + return newArr; + default: + return node?.DeepClone(); + } + } + + private static string CamelToSnake(string name) + { + var sb = new StringBuilder(name.Length + 4); + for (var i = 0; i < name.Length; i++) + { + var c = name[i]; + if (char.IsUpper(c)) + { + if (i > 0) + { + sb.Append('_'); + } + sb.Append(char.ToLowerInvariant(c)); + } + else + { + sb.Append(c); + } + } + return sb.ToString(); + } + + /// + /// Recursively fixes any trait_definition.entity_type that is missing or + /// empty. The WASM engine's Rust EntityType enum only accepts "user" or + /// "company"; an empty string fails the entire parse with error code -1. + /// + /// When is non-null (company/user + /// subtree) we inject it; when null (flag subtree, where the entity is ambiguous) + /// we drop the optional trait_definition so the rule still evaluates. + /// Workaround for replicator data that writes entity_type: ""; a no-op + /// once the replicator writes correct values. + /// + private static JsonNode? Sanitize(JsonNode? node, string? defaultEntityType) + { + switch (node) + { + case JsonObject obj: + if (obj["trait_definition"] is JsonObject td) + { + var et = td["entity_type"]; + var missing = + et == null + || et.GetValueKind() != System.Text.Json.JsonValueKind.String + || string.IsNullOrEmpty(et.GetValue()); + if (missing) + { + if (defaultEntityType != null) + { + td["entity_type"] = defaultEntityType; + } + else + { + obj.Remove("trait_definition"); + } + } + } + foreach (var kvp in obj) + { + Sanitize(kvp.Value, defaultEntityType); + } + return node; + case JsonArray arr: + foreach (var element in arr) + { + Sanitize(element, defaultEntityType); + } + return node; + default: + return node; + } + } + } +} diff --git a/src/SchematicHQ.Client/RulesEngine/Wasm/rulesengine.wasm b/src/SchematicHQ.Client/RulesEngine/Wasm/rulesengine.wasm deleted file mode 100755 index 29bd0d48..00000000 Binary files a/src/SchematicHQ.Client/RulesEngine/Wasm/rulesengine.wasm and /dev/null differ diff --git a/src/SchematicHQ.Client/SchematicHQ.Client.Custom.props b/src/SchematicHQ.Client/SchematicHQ.Client.Custom.props index c8b28412..ff21a8b3 100644 --- a/src/SchematicHQ.Client/SchematicHQ.Client.Custom.props +++ b/src/SchematicHQ.Client/SchematicHQ.Client.Custom.props @@ -12,6 +12,19 @@ Configure additional MSBuild properties for your project in this file: + + + + + + + + rulesengine.wasm + @@ -21,17 +34,13 @@ Configure additional MSBuild properties for your project in this file: - - - - - $(MSBuildThisFileDirectory)RulesEngine/Utils/GeneratedModelHash.cs - - - - - - + + + diff --git a/src/SchematicHQ.Client/generate-schema-hash.sh b/src/SchematicHQ.Client/generate-schema-hash.sh deleted file mode 100755 index 87603529..00000000 --- a/src/SchematicHQ.Client/generate-schema-hash.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/bin/bash -# filepath: /Users/chrisbrady/workspace/schematic/generate-schema-hash.sh - -# Configuration -MODELS_DIR="$1" -OUTPUT_FILE="$2" - -if [ -z "$MODELS_DIR" ] || [ -z "$OUTPUT_FILE" ]; then - echo "Usage: $0 " - exit 1 -fi - -if [ ! -d "$MODELS_DIR" ]; then - echo "Error: Models directory not found: $MODELS_DIR" - exit 1 -fi - -# Ensure output directory exists -mkdir -p "$(dirname "$OUTPUT_FILE")" - -# Find all Rulesengine*.cs files in the directory, sort them for consistency -FILES=$(find "$MODELS_DIR" -name "Rulesengine*.cs" | sort) - -if [ -z "$FILES" ]; then - echo "Error: No Rulesengine*.cs files found in: $MODELS_DIR" - exit 1 -fi - -# Hash creation -echo "Hashing files in $MODELS_DIR..." - -# Generate a combined hash of all model files -if [[ "$OSTYPE" == "darwin"* ]]; then - # macOS version using shasum - HASH=$(cat $FILES | shasum -a 256 | cut -c1-8) -else - # Linux version using sha256sum - HASH=$(cat $FILES | sha256sum | cut -c1-8) -fi - -# Generate the C# file -echo "Generating file: $OUTPUT_FILE with hash: $HASH" - -cat > "$OUTPUT_FILE" << EOF -// Auto-generated code - do not modify -// This file is automatically generated based on model file changes - -namespace SchematicHQ.Client.RulesEngine.Utils -{ - public static class GeneratedModelHash - { - /// - /// Auto-generated hash of all model files. - /// This value changes whenever any model file is modified. - /// - public const string Value = "$HASH"; - } -} -EOF - -echo "Schema hash generation complete: $HASH" \ No newline at end of file