diff --git a/.claude/skills/pr-review/SKILL.md b/.claude/skills/pr-review/SKILL.md new file mode 100644 index 00000000..42a0add4 --- /dev/null +++ b/.claude/skills/pr-review/SKILL.md @@ -0,0 +1,129 @@ +--- +name: pr-review +description: Review code changes on the current branch for quality, bugs, performance, and security +disable-model-invocation: true +argument-hint: "[optional: LINEAR-TICKET-ID]" +allowed-tools: Read, Grep, Glob, Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(git branch:*), Bash(gh pr:*), Bash(gh api:*), Bash(~/.claude/scripts/fetch-github-pr.sh:*), Bash(~/.claude/scripts/fetch-sentry-data.sh:*), Bash(~/.claude/scripts/fetch-slack-thread.sh:*) +--- + +# Code Review + +You are reviewing code changes on the current branch. Your review must be based on the **current state of the code right now**, not on anything you've seen earlier in this conversation. + +## CRITICAL: Always Use Fresh Data + +**IGNORE any file contents, diffs, or line numbers you may have seen earlier in this conversation.** They may be stale. You MUST re-fetch everything from scratch using the commands below. + +## Step 1: Get the Current Diff and PR Context + +Run ALL of these commands to get a fresh view: + +```bash +# The authoritative diff -- only review what's in HERE +git diff main...HEAD + +# Recent commits on this branch +git log --oneline main..HEAD + +# PR description and comments +gh pr view --json number,title,body,comments,reviews,reviewRequests +``` + +Also fetch PR review comments (inline code comments): + +```bash +# Get the PR number +PR_NUMBER=$(gh pr view --json number -q '.number') + +# Fetch all review comments (inline comments on specific lines) +gh api repos/{owner}/{repo}/pulls/$PR_NUMBER/comments --jq '.[] | {path: .path, line: .line, body: .body, user: .user.login, created_at: .created_at}' + +# Fetch review-level comments (general review comments) +gh api repos/{owner}/{repo}/pulls/$PR_NUMBER/reviews --jq '.[] | {state: .state, body: .body, user: .user.login}' +``` + +## Step 2: Understand Context from PR Comments + +Before reviewing, read through the PR comments and review comments. Note **who** said what (by username). + +- **Already-addressed feedback**: If a reviewer pointed out an issue and the author has already fixed it (the fix is visible in the current diff), do NOT re-raise it. +- **Ongoing discussions**: Note any unresolved threads -- your review should take these into account. +- **Previous approvals/requests for changes**: Understand what reviewers have already looked at. + +**IMPORTANT**: Your review is YOUR independent review. Do not take credit for or reference other reviewers' findings as if they were yours. If another reviewer already flagged something, you can note "as [reviewer] pointed out" but do not present their feedback as your own prior review. Your verdict should be based solely on your own analysis of the current code. + +## Step 3: Get Requirements Context + +Check if a Linear ticket ID was provided as an argument ($ARGUMENTS). If not, try to extract it from the branch name (pattern: `{username}/{linear-ticket}-{title}`). + +If a Linear ticket is found: +- Use Linear MCP tools (`get_issue`) to get the issue details and comments +- **Check for a parent ticket**: If the issue has a parent issue, fetch the parent too. Our pattern is to have a parent ticket with project-wide requirements and sub-tickets for specific tasks (often one per repo/PR). The parent ticket will contain the full scope of the project, while the sub-ticket scopes what this specific PR should cover. Use both to assess completeness — the PR should fulfill the sub-ticket's scope, and that scope should be a reasonable subset of the parent's backend-related requirements. +- Look for Sentry links in the description/comments; if found, use Sentry MCP tools to get error details +- Assess whether the changes fulfill the ticket requirements + +If no ticket is found, check the PR description for context on what the changes are meant to accomplish. + +## Step 4: Review the Code + +Review ONLY the changed lines (from `git diff main...HEAD`). Do not comment on unchanged code. + +**When referencing code, always use the file path and quote the actual code snippet** rather than citing line numbers, since line numbers shift as the branch evolves. + +### Code Quality +- Is the code well-structured and maintainable? +- Does it follow CLAUDE.md conventions? (import grouping, error handling with lib/errors, naming, alphabetization, etc.) +- Any AI-generated slop? (excessive comments, unnecessary abstractions, over-engineering) + +### Performance +- N+1 queries, inefficient loops, missing indexes for new queries +- Unbuffered writes in hot paths (especially ClickHouse) +- Missing LIMIT clauses on potentially large result sets + +### Bugs +- Nil pointer risks (especially on struct pointer params and optional relations) +- Functions returning `nil, nil` (violates convention) +- Missing error handling +- Race conditions in concurrent code paths + +### Security +- Hardcoded secrets or sensitive data exposure +- Missing input validation on service request structs + +### Tests +- Are there tests for the new/changed code? +- Do the tests cover edge cases and error paths? +- Are test assertions specific (not just "no error")? + +## Step 5: Present the Review + +Structure your review as: + +``` +## Summary +[1-2 sentences: what this PR does and overall assessment] + +## Requirements Check +[Does the PR fulfill the Linear ticket / PR description requirements? Any gaps?] + +## Issues +### Critical (must fix before merge) +- [blocking issues] + +### Suggestions (nice to have) +- [non-blocking improvements] + +## Prior Review Activity +[Summarize what other reviewers have flagged, attributed by name. Note which of their concerns have been addressed in the current code and which remain open.] + +## Verdict +[LGTM / Needs changes / Needs discussion -- based on YOUR analysis, not other reviewers' findings] +``` + +## Guidelines + +- Be concise. Don't pad with praise or filler. +- Only raise issues that matter. Don't nitpick formatting (that's what linters are for). +- Quote code snippets rather than referencing line numbers. +- If PR comments show a discussion was already resolved, don't reopen it. +- If you're unsure about something, flag it as a question rather than a definitive issue. diff --git a/.fernignore b/.fernignore index ac6d20b6..f4488e7a 100644 --- a/.fernignore +++ b/.fernignore @@ -1,5 +1,5 @@ # Specify files that shouldn't be modified by Fern -.claude/settings.json +.claude/ .github/CODEOWNERS .github/workflows/claude-code.yml CLAUDE.md diff --git a/.gitignore b/.gitignore index 11014f2b..f1730f2f 100644 --- a/.gitignore +++ b/.gitignore @@ -482,3 +482,6 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp + +# Claude Code local settings +.claude/settings.local.json diff --git a/examples/DatastreamTestServer/Program.cs b/examples/DatastreamTestServer/Program.cs index 6498d7f0..38011208 100644 --- a/examples/DatastreamTestServer/Program.cs +++ b/examples/DatastreamTestServer/Program.cs @@ -72,7 +72,7 @@ var redisConfig = new RedisCacheConfig { Endpoints = new List { redisUrl }, - KeyPrefix = redisKeyPrefix + KeyPrefix = "" }; var datastreamOptions = new DatastreamOptions diff --git a/src/SchematicHQ.Client.Test/Datastream/CompanyMetricsTests.cs b/src/SchematicHQ.Client.Test/Datastream/CompanyMetricsTests.cs index b1a3917f..65c3dc3a 100644 --- a/src/SchematicHQ.Client.Test/Datastream/CompanyMetricsTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/CompanyMetricsTests.cs @@ -7,7 +7,6 @@ using SchematicHQ.Client.Test.Datastream.Mocks; using SchematicHQ.Client.Core; using SchematicHQ.Client.Datastream; -using SchematicHQ.Client.RulesEngine.Models; namespace SchematicHQ.Client.Test.Datastream { @@ -30,7 +29,7 @@ public class CompanyMetricsTests : IDisposable // Client and dependencies private DatastreamClient _client; - private ICacheProvider _companyCache; + private ICacheProvider _companyCache; private ICacheProvider _companyLookupCache; [SetUp] @@ -41,7 +40,7 @@ public void Setup() _client = client; // Use reflection to get the private cache fields var cacheField = typeof(DatastreamClient).GetField("_companyCache", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - _companyCache = (ICacheProvider?)cacheField?.GetValue(_client) ?? throw new Exception("Could not get company cache"); + _companyCache = (ICacheProvider?)cacheField?.GetValue(_client) ?? throw new Exception("Could not get company cache"); var lookupCacheField = typeof(DatastreamClient).GetField("_companyLookupCache", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); _companyLookupCache = (ICacheProvider?)lookupCacheField?.GetValue(_client) ?? throw new Exception("Could not get company lookup cache"); @@ -64,7 +63,7 @@ private string ResourceKeyToCacheKey(string keyName, string keyValue) { var method = typeof(DatastreamClient).GetMethod("ResourceKeyToCacheKey", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var genericMethod = method!.MakeGenericMethod(typeof(Company)); + var genericMethod = method!.MakeGenericMethod(typeof(RulesengineCompany)); return (string)genericMethod.Invoke(_client, new object[] { CacheKeyPrefixCompany, keyName, keyValue })!; } @@ -83,7 +82,7 @@ private string CompanyIdCacheKey(string id) /// private void SetupCompanyInCache(string companyJson) { - var company = JsonSerializer.Deserialize(companyJson, new JsonSerializerOptions + var company = JsonSerializer.Deserialize(companyJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); @@ -108,7 +107,7 @@ private void SetupCompanyInCache(string companyJson) /// /// Helper method to get a company from cache using two-step lookup /// - private Company? GetCompanyFromCache(Dictionary keys) + private RulesengineCompany? GetCompanyFromCache(Dictionary keys) { if (keys.Count == 0) return null; @@ -181,6 +180,9 @@ public void UpdateCompanyMetrics_WithNoMatchingMetric_ReturnsFalse() ], ""metrics"": [ { + ""account_id"": ""acc123"", + ""company_id"": ""company123"", + ""environment_id"": ""env123"", ""event_subtype"": ""metric1"", ""period"": ""all_time"", ""month_reset"": ""first_of_month"", @@ -223,6 +225,9 @@ public void UpdateCompanyMetrics_WithMatchingMetric_UpdatesValueWithDefaultQuant ], ""metrics"": [ { + ""account_id"": ""acc123"", + ""company_id"": ""company123"", + ""environment_id"": ""env123"", ""event_subtype"": ""metric1"", ""period"": ""all_time"", ""month_reset"": ""first_of_month"", @@ -271,6 +276,9 @@ public void UpdateCompanyMetrics_WithSpecificQuantity_UpdatesMetricValue() ], ""metrics"": [ { + ""account_id"": ""acc123"", + ""company_id"": ""company123"", + ""environment_id"": ""env123"", ""event_subtype"": ""metric1"", ""period"": ""all_time"", ""month_reset"": ""first_of_month"", @@ -320,6 +328,9 @@ public void UpdateCompanyMetrics_WithMultipleKeys_UpdatesAllCacheEntries() ], ""metrics"": [ { + ""account_id"": ""acc123"", + ""company_id"": ""company123"", + ""environment_id"": ""env123"", ""event_subtype"": ""metric1"", ""period"": ""all_time"", ""month_reset"": ""first_of_month"", @@ -378,6 +389,9 @@ public void UpdateCompanyMetrics_WithMultipleMetrics_UpdatesAllMatchingMetrics() ], ""metrics"": [ { + ""account_id"": ""acc123"", + ""company_id"": ""company123"", + ""environment_id"": ""env123"", ""event_subtype"": ""metric1"", ""period"": ""all_time"", ""month_reset"": ""first_of_month"", @@ -385,6 +399,9 @@ public void UpdateCompanyMetrics_WithMultipleMetrics_UpdatesAllMatchingMetrics() ""created_at"": ""2023-01-01T00:00:00Z"" }, { + ""account_id"": ""acc123"", + ""company_id"": ""company123"", + ""environment_id"": ""env123"", ""event_subtype"": ""metric1"", ""period"": ""current_month"", ""month_reset"": ""first_of_month"", @@ -392,6 +409,9 @@ public void UpdateCompanyMetrics_WithMultipleMetrics_UpdatesAllMatchingMetrics() ""created_at"": ""2023-01-01T00:00:00Z"" }, { + ""account_id"": ""acc123"", + ""company_id"": ""company123"", + ""environment_id"": ""env123"", ""event_subtype"": ""metric2"", ""period"": ""all_time"", ""month_reset"": ""first_of_month"", diff --git a/src/SchematicHQ.Client.Test/Datastream/DatastreamCacheTests.cs b/src/SchematicHQ.Client.Test/Datastream/DatastreamCacheTests.cs index ea6b3cf0..425ef0f8 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamCacheTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamCacheTests.cs @@ -1,6 +1,5 @@ using System.Text.Json; using NUnit.Framework; -using SchematicHQ.Client.RulesEngine.Models; using SchematicHQ.Client.Datastream; using SchematicHQ.Client.Test.Datastream.Mocks; @@ -22,14 +21,14 @@ public void Setup() _mockLogger = testSetup.Logger; } - private void PopulateTwoLayerCompanyCache(Company company) + private void PopulateTwoLayerCompanyCache(RulesengineCompany company) { var method = typeof(DatastreamClient).GetMethod("CacheCompanyForKeys", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); method!.Invoke(_client, new object[] { company }); } - private void PopulateTwoLayerUserCache(User user) + private void PopulateTwoLayerUserCache(RulesengineUser user) { var method = typeof(DatastreamClient).GetMethod("CacheUserForKeys", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); @@ -41,7 +40,7 @@ public void ExpiredCache_RequestsResourcesAgain() { var companyKey = "company-123"; - var company = new Company + var company = new RulesengineCompany { AccountId = "acc_123", EnvironmentId = "env_123", @@ -66,7 +65,7 @@ public void ExpiredCache_RequestsResourcesAgain() [Test] public void TwoStepCompanyLookup_CacheAndRetrieve() { - var company = new Company + var company = new RulesengineCompany { AccountId = "acc_123", EnvironmentId = "env_123", @@ -87,7 +86,7 @@ public void TwoStepCompanyLookup_CacheAndRetrieve() [Test] public void TwoStepUserLookup_CacheAndRetrieve() { - var user = new User + var user = new RulesengineUser { AccountId = "acc_123", EnvironmentId = "env_123", @@ -108,7 +107,7 @@ public void TwoStepUserLookup_CacheAndRetrieve() [Test] public void MultipleResourceKeys_ResolveToSameObject() { - var company = new Company + var company = new RulesengineCompany { AccountId = "acc_123", EnvironmentId = "env_123", diff --git a/src/SchematicHQ.Client.Test/Datastream/DatastreamClientAdapterTests.cs b/src/SchematicHQ.Client.Test/Datastream/DatastreamClientAdapterTests.cs index 899ff0b8..b3e19b96 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamClientAdapterTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamClientAdapterTests.cs @@ -209,7 +209,7 @@ public async Task CheckFlag_WhenNotConnected_ThrowsException() var flagsCache = flagsCacheField!.GetValue(client); // Create a test flag - var flag = new SchematicHQ.Client.RulesEngine.Models.Flag + var flag = new RulesengineFlag { Id = "flag_123", Key = "test-flag", diff --git a/src/SchematicHQ.Client.Test/Datastream/DatastreamClientTests.cs b/src/SchematicHQ.Client.Test/Datastream/DatastreamClientTests.cs index 5d0d446d..352aad17 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamClientTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamClientTests.cs @@ -2,7 +2,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using NUnit.Framework; -using SchematicHQ.Client.RulesEngine.Models; using SchematicHQ.Client.RulesEngine.Utils; using SchematicHQ.Client.Datastream; using SchematicHQ.Client.Test.Datastream.Mocks; @@ -16,7 +15,7 @@ public class DatastreamClientTests private MockSchematicLogger _mockLogger; private DatastreamClient _client; private JsonSerializerOptions _jsonOptions; - + [SetUp] public void Setup() { @@ -24,7 +23,7 @@ public void Setup() _client = testSetup.Client; _mockWebSocket = testSetup.WebSocket; _mockLogger = testSetup.Logger; - + _jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, @@ -37,22 +36,22 @@ public void TearDown() { _mockWebSocket?.SentMessages?.Clear(); } - + [Test] public void CheckFlag_WhenFlagIsNotInCache_ReturnsFalse() { // Arrange - create an adapter that will use our mock client var options = new DatastreamOptions(); var adapter = new DatastreamClientAdapter("wss://test.example.com", _mockLogger, "test-api-key", options); - + // Get the private _client field from adapter and replace it with our mock client - var clientField = typeof(DatastreamClientAdapter).GetField("_client", + var clientField = typeof(DatastreamClientAdapter).GetField("_client", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); clientField?.SetValue(adapter, _client); - + // Set flag to null to simulate flag not in cache _client.Start(); - + var request = new CheckFlagRequestBody { Company = new Dictionary @@ -60,42 +59,42 @@ public void CheckFlag_WhenFlagIsNotInCache_ReturnsFalse() { "id", "company-123" } } }; - + // Act var result = adapter.CheckFlag(request, "non-existent-flag").GetAwaiter().GetResult(); - + // Assert Assert.That(result.Value, Is.False); } - + [Test] public void CheckFlag_WhenFlagExists_EvaluatesCorrectly() { // Arrange SetupFlagsResponse(); - + // Start the client to process the response _client.Start(); - + // Create a test company - var testCompany = new Company + var testCompany = new RulesengineCompany { Id = "comp_123", AccountId = "acc_123", EnvironmentId = "env_123", - Keys = new Dictionary - { - { "id", "company-123" } + Keys = new Dictionary + { + { "id", "company-123" } }, - Traits = new List + Traits = new List { - new Trait { - Value = "pro", - TraitDefinition = new TraitDefinition { + new RulesengineTrait { + Value = "pro", + TraitDefinition = new RulesengineTraitDefinition { Id = "trait_123", - ComparableType = TraitDefinitionComparableType.String, - EntityType = EntityType.Company - } + ComparableType = RulesengineTraitDefinitionComparableType.String, + EntityType = RulesengineEntityType.Company + } } } }; @@ -109,135 +108,135 @@ public void CheckFlag_WhenFlagExists_EvaluatesCorrectly() var companyKeys = new Dictionary { { "id", "company-123" } }; var company = _client.GetCompanyFromCache(companyKeys); Assert.That(company, Is.Not.Null, "Company should be in cache after directly adding it"); - + // Add the flag directly to the cache - var flagsCacheField = typeof(DatastreamClient).GetField("_flagsCache", + var flagsCacheField = typeof(DatastreamClient).GetField("_flagsCache", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); var flagsCache = flagsCacheField!.GetValue(_client); - + // Create a test flag - var testFlag = new SchematicHQ.Client.RulesEngine.Models.Flag + var testFlag = new RulesengineFlag { Id = "flag_456", Key = "another-feature", AccountId = "acc_123", EnvironmentId = "env_123", DefaultValue = true, - Rules = new List() + Rules = new List() }; - + // Generate the cache key for the flag - var flagCacheKeyMethod = typeof(DatastreamClient).GetMethod("FlagCacheKey", + var flagCacheKeyMethod = typeof(DatastreamClient).GetMethod("FlagCacheKey", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); var flagCacheKey = flagCacheKeyMethod!.Invoke(_client, new object[] { "another-feature" }) as string; - + // Set the flag in cache var flagSetMethod = flagsCache!.GetType().GetMethod("Set"); flagSetMethod!.Invoke(flagsCache, new object[] { flagCacheKey!, testFlag, Type.Missing }); - + // Verify flag is in cache now - var getFlagMethod = typeof(DatastreamClient).GetMethod("GetFlag", + var getFlagMethod = typeof(DatastreamClient).GetMethod("GetFlag", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var flag = getFlagMethod!.Invoke(_client, new object[] { "another-feature" }) as SchematicHQ.Client.RulesEngine.Models.Flag; + var flag = getFlagMethod!.Invoke(_client, new object[] { "another-feature" }) as RulesengineFlag; Assert.That(flag, Is.Not.Null, "Flag should be in cache after setup"); - + // Now call CheckFlag directly on the client var result = _client.CheckFlag(company, null, flag!).GetAwaiter().GetResult(); - + // Assert Assert.That(result.Value, Is.True); - + // No need to check WebSocket messages since we added directly to cache } - + [Test] public async Task CachedResources_AreReusedWithoutWebSocketRequests() { // Arrange SetupFlagsResponse(); - + // Create an adapter that will use our mock client var options = new DatastreamOptions(); var adapter = new DatastreamClientAdapter("wss://test.example.com", _mockLogger, "test-api-key", options); - + // Get the private _client field from adapter and replace it with our mock client - var clientField = typeof(DatastreamClientAdapter).GetField("_client", + var clientField = typeof(DatastreamClientAdapter).GetField("_client", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); clientField?.SetValue(adapter, _client); - + // Get the private _connectionTracker field and set its IsConnected property to true - var trackerField = typeof(DatastreamClientAdapter).GetField("_connectionTracker", + var trackerField = typeof(DatastreamClientAdapter).GetField("_connectionTracker", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); var tracker = trackerField?.GetValue(adapter); - var updateMethod = tracker?.GetType().GetMethod("UpdateConnectionState", + var updateMethod = tracker?.GetType().GetMethod("UpdateConnectionState", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); updateMethod?.Invoke(tracker, new object[] { true }); - + // Start the client _client.Start(); - + var companyResponse = new DataStreamResponse { MessageType = MessageType.Full, EntityType = SchematicHQ.Client.Datastream.EntityType.Company, - Data = JsonDocument.Parse(JsonSerializer.Serialize(new Company + Data = JsonDocument.Parse(JsonSerializer.Serialize(new RulesengineCompany { Id = "comp_123", AccountId = "acc_123", EnvironmentId = "env_123", - Keys = new Dictionary - { - { "id", "company-123" } + Keys = new Dictionary + { + { "id", "company-123" } } }, _jsonOptions)).RootElement }; - + // Setup fake WebSocket responses _mockWebSocket.SetupToReceive(JsonSerializer.Serialize(companyResponse)); - + var request = new CheckFlagRequestBody { Company = new Dictionary { { "id", "company-123" } } }; - + // Act - First request should fetch from WebSocket adapter.CheckFlag(request, "new-event-name").GetAwaiter().GetResult(); - + // Wait a bit to ensure any async WebSocket operations complete await Task.Delay(50); - + int initialRequestCount = _mockWebSocket.SentMessages.Count; Assert.That(initialRequestCount, Is.GreaterThan(0), "First request should generate at least one WebSocket message"); - + // Clear sent messages to track new ones _mockWebSocket.SentMessages.Clear(); - + // Act - Second request should use cache adapter.CheckFlag(request, "new-event-name").GetAwaiter().GetResult(); - + // Wait a bit to ensure any async WebSocket operations complete await Task.Delay(50); - + // Assert - No new messages should be sent when using cached data Assert.That(_mockWebSocket.SentMessages.Count, Is.EqualTo(0), "No new WebSocket messages should be sent when using cached data"); } - + [Test] public void Dispose_ClosesWebSocketConnection() { // Act _client.Dispose(); - + // Assert Assert.That(_mockLogger.HasLogEntry(LogLevel.Info, "Connected to Schematic WebSocket"), Is.False); } - + private void SetupFlagsResponse() { // Arrange - First set up flags - var mockFlags = new List + var mockFlags = new List { - new Flag + new RulesengineFlag { AccountId = "acc_123", EnvironmentId = "env_123", @@ -245,39 +244,39 @@ private void SetupFlagsResponse() Key = "new-event-name", DefaultValue = false, - Rules = new List + Rules = new List { - new Rule + new RulesengineRule { AccountId = "acc_123", EnvironmentId = "env_123", Id = "rule_123", Name = "Test Rule", - RuleType = RuleRuleType.Standard, + RuleType = RulesengineRuleRuleType.Standard, Priority = 1, - Conditions = new List(), + Conditions = new List(), Value = true } } }, - new Flag + new RulesengineFlag { AccountId = "acc_123", EnvironmentId = "env_123", Id = "flag_456", Key = "another-feature", DefaultValue = true, - Rules = new List() + Rules = new List() } }; - + var flagsResponse = new DataStreamResponse { MessageType = MessageType.Full, EntityType = SchematicHQ.Client.Datastream.EntityType.Flags, Data = JsonDocument.Parse(JsonSerializer.Serialize(mockFlags)).RootElement }; - + _mockWebSocket.SetupToReceive(JsonSerializer.Serialize(flagsResponse)); } } diff --git a/src/SchematicHQ.Client.Test/Datastream/DatastreamConcurrencyTests.cs b/src/SchematicHQ.Client.Test/Datastream/DatastreamConcurrencyTests.cs index 46bdaba3..b40d6527 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamConcurrencyTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamConcurrencyTests.cs @@ -2,7 +2,6 @@ using System.Text.Json; using NUnit.Framework; using SchematicHQ.Client.RulesEngine; -using SchematicHQ.Client.RulesEngine.Models; using SchematicHQ.Client.RulesEngine.Utils; using SchematicHQ.Client.Datastream; using SchematicHQ.Client.Test.Datastream.Mocks; @@ -15,7 +14,7 @@ public class DatastreamConcurrencyTests private MockWebSocket _mockWebSocket; private MockSchematicLogger _mockLogger; private DatastreamClient _client; - + [SetUp] public void Setup() { @@ -24,23 +23,23 @@ public void Setup() _mockWebSocket = testSetup.WebSocket; _mockLogger = testSetup.Logger; } - + [Test] public void MultipleConcurrentRequests_ForSameCompany_OnlySendsOneWebSocketRequest() { // This test verifies the concurrency behavior of DatastreamClient // by simulating multiple concurrent requests for the same company - + // Use a simpler approach that doesn't depend on the actual client implementation // but instead directly tests the concurrency behavior - + // Create a mock request tracker to count WebSocket requests int requestCount = 0; string? lastRequestKey = null; - + // Lock for thread safety when updating the request count var requestLock = new object(); - + // Function to simulate making a request for a company // Returns true if this is the first time this key has been requested Func simulateRequest = (string key) => { @@ -48,25 +47,25 @@ public void MultipleConcurrentRequests_ForSameCompany_OnlySendsOneWebSocketReque { // Check if this is a new request or a duplicate bool isNewRequest = lastRequestKey != key; - + if (isNewRequest) { requestCount++; lastRequestKey = key; } - + return isNewRequest; } }; - + // Create multiple threads to simulate concurrent requests const int threadCount = 10; const string companyKey = "company-123"; var barrier = new Barrier(threadCount); - + var tasks = new Task[threadCount]; var results = new bool[threadCount]; - + // Start all threads that will make "concurrent" requests for (int i = 0; i < threadCount; i++) { @@ -74,24 +73,24 @@ public void MultipleConcurrentRequests_ForSameCompany_OnlySendsOneWebSocketReque tasks[i] = Task.Run(() => { // Wait for all threads to reach this point before proceeding barrier.SignalAndWait(); - + // All threads make the request for the same company at the same time results[index] = simulateRequest(companyKey); }); } - + // Wait for all tasks to complete Task.WaitAll(tasks); - + // Verify that exactly one request was made - Assert.That(requestCount, Is.EqualTo(1), + Assert.That(requestCount, Is.EqualTo(1), "Only one request should have been made for the same company key"); - + // Verify that only one thread considered its request "new" Assert.That(results.Count(r => r), Is.EqualTo(1), "Only one thread should have been considered the 'first' request"); } - + [Test] public async Task EmptyResponse_LogsWarning() { @@ -102,93 +101,93 @@ public async Task EmptyResponse_LogsWarning() EntityType = Client.Datastream.EntityType.Company, Data = null }; - + // Setup fake WebSocket response _mockWebSocket.SetupToReceive(JsonSerializer.Serialize(companyResponse)); - + // Act _client.Start(); - + // Wait for message processing await Task.Delay(100); - + // Assert Assert.That(_mockLogger.HasLogEntry(LogLevel.Warn, "Received empty company data"), Is.True); } - + [Test] public void MessageDeserialization_HandlesErrors() { // Arrange - Set up an invalid JSON response _mockWebSocket.SetupToReceive("{ invalid json }"); - + // Act _client.Start(); - + // Wait for message processing Task.Delay(100).Wait(); - + // Assert Assert.That(_mockLogger.HasLogEntry(LogLevel.Error, "Failed to process WebSocket message"), Is.True); } - + [Test] public void RequestsPendingWhenResourceArrives_AllAreNotified() { // Create a focused test that simulates core behavior without relying on DatastreamClient internals // This tests that when a resource arrives, all pending tasks are completed - + // We'll use a simplified version of the notification pattern used in DatastreamClient const int taskCount = 3; - var completionSignals = new List>(); - + var completionSignals = new List>(); + // Create completion sources that will be notified for (int i = 0; i < taskCount; i++) { - completionSignals.Add(new TaskCompletionSource()); + completionSignals.Add(new TaskCompletionSource()); } - + // Create the company object that will be used to complete the tasks - var company = new Company + var company = new RulesengineCompany { Id = "comp_123", AccountId = "acc_123", EnvironmentId = "env_123", Keys = new Dictionary { { "id", "company-123" } } }; - + // Create tasks that will wait on the completion sources var tasks = new Task[taskCount]; var taskCompletedFlags = new bool[taskCount]; - + for (int i = 0; i < taskCount; i++) { int taskIndex = i; tasks[i] = Task.Run(() => { // Wait for the completion source to be set var result = completionSignals[taskIndex].Task.Result; - + // Mark this task as completed taskCompletedFlags[taskIndex] = true; - + // Verify the result is correct - Assert.That(result.Id, Is.EqualTo("comp_123"), + Assert.That(result.Id, Is.EqualTo("comp_123"), $"Task {taskIndex} should receive the correct company ID"); }); } - + // Act - Complete all completion sources with the same company foreach (var tcs in completionSignals) { tcs.SetResult(company); } - + // Give the tasks time to complete bool allTasksCompleted = Task.WaitAll(tasks, 1000); - + // Assert - All tasks should have completed Assert.That(allTasksCompleted, Is.True, "All tasks should complete when notified"); - + // Verify each task was marked as completed for (int i = 0; i < taskCount; i++) { diff --git a/src/SchematicHQ.Client.Test/Datastream/DatastreamConnectionMonitoringTests.cs b/src/SchematicHQ.Client.Test/Datastream/DatastreamConnectionMonitoringTests.cs index fa890267..38a98395 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamConnectionMonitoringTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamConnectionMonitoringTests.cs @@ -4,7 +4,6 @@ using System.Threading.Tasks; using NUnit.Framework; using SchematicHQ.Client.Datastream; -using SchematicHQ.Client.RulesEngine.Models; using SchematicHQ.Client.Test.Datastream.Mocks; namespace SchematicHQ.Client.Test.Datastream diff --git a/src/SchematicHQ.Client.Test/Datastream/DatastreamRedisIntegrationTests.cs b/src/SchematicHQ.Client.Test/Datastream/DatastreamRedisIntegrationTests.cs index f2f1b672..e0446e41 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamRedisIntegrationTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamRedisIntegrationTests.cs @@ -150,7 +150,7 @@ public void RulesEngineAssembly_CanBeLoaded() try { // This is the exact type that was failing to load in the customer's issue - companyType = Type.GetType("SchematicHQ.Client.RulesEngine.Models.Company, SchematicHQ.Client"); + companyType = Type.GetType("SchematicHQ.Client.RulesengineCompany, SchematicHQ.Client"); // If the type is null, try to load from any loaded assembly if (companyType == null) @@ -160,7 +160,7 @@ public void RulesEngineAssembly_CanBeLoaded() if (clientAssembly != null) { - companyType = clientAssembly.GetType("SchematicHQ.Client.RulesEngine.Models.Company"); + companyType = clientAssembly.GetType("SchematicHQ.Client.RulesengineCompany"); } } } @@ -173,16 +173,16 @@ public void RulesEngineAssembly_CanBeLoaded() Assert.That(loadException, Is.Null, $"Should not throw exception when loading RulesEngine types: {loadException?.Message}"); Assert.That(companyType, Is.Not.Null, - "Should be able to load RulesEngine.Models.Company type"); + "Should be able to load RulesengineCompany type"); // Verify we can also load other RulesEngine types that DatastreamClient might use - var userType = Type.GetType("SchematicHQ.Client.RulesEngine.Models.User, SchematicHQ.Client") ?? + var userType = Type.GetType("SchematicHQ.Client.RulesengineUser, SchematicHQ.Client") ?? AppDomain.CurrentDomain.GetAssemblies() .FirstOrDefault(a => a.GetName().Name == "SchematicHQ.Client") - ?.GetType("SchematicHQ.Client.RulesEngine.Models.User"); + ?.GetType("SchematicHQ.Client.RulesengineUser"); Assert.That(userType, Is.Not.Null, - "Should be able to load SchematicHQ.Client.RulesEngine.Models.User type"); + "Should be able to load SchematicHQ.Client.RulesengineUser type"); } } diff --git a/src/SchematicHQ.Client.Test/Datastream/Mocks/DatastreamClientTestHelpers.cs b/src/SchematicHQ.Client.Test/Datastream/Mocks/DatastreamClientTestHelpers.cs index 22ea571c..a0490756 100644 --- a/src/SchematicHQ.Client.Test/Datastream/Mocks/DatastreamClientTestHelpers.cs +++ b/src/SchematicHQ.Client.Test/Datastream/Mocks/DatastreamClientTestHelpers.cs @@ -1,5 +1,4 @@ using SchematicHQ.Client.Datastream; -using SchematicHQ.Client.RulesEngine.Models; using System.Text.Json; using System.Text.Json.Serialization; @@ -13,7 +12,7 @@ public static class DatastreamClientTestHelpers /// /// Provides a way to get a company from a MockWebSocket's cache - only for testing! /// - public static Company? GetCompanyFromTestCache(MockWebSocket mockSocket, Dictionary keys) + public static RulesengineCompany? GetCompanyFromTestCache(MockWebSocket mockSocket, Dictionary keys) { foreach (var key in keys) { @@ -25,11 +24,11 @@ public static class DatastreamClientTestHelpers PropertyNameCaseInsensitive = true, Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower, false) } }; - - return JsonSerializer.Deserialize(companyJson, options); + + return JsonSerializer.Deserialize(companyJson, options); } } - + return null; } } diff --git a/src/SchematicHQ.Client.Test/RulesEngine/CompanyTests.cs b/src/SchematicHQ.Client.Test/RulesEngine/CompanyTests.cs index 9ffe15d5..7e217689 100644 --- a/src/SchematicHQ.Client.Test/RulesEngine/CompanyTests.cs +++ b/src/SchematicHQ.Client.Test/RulesEngine/CompanyTests.cs @@ -1,4 +1,3 @@ -using SchematicHQ.Client.RulesEngine.Models; using SchematicHQ.Client.RulesEngine; using System.Diagnostics; using NUnit.Framework; @@ -13,14 +12,14 @@ public void AddMetric_Adds_New_Metric_When_None_Exists_With_Same_Constraints() { // Arrange var company = TestHelpers.CreateTestCompany(); - int initialCount = company.Metrics.Count; + int initialCount = company.Metrics.Count(); // Act - var metric = TestHelpers.CreateTestMetric(company, "test-event", ConditionMetricPeriod.AllTime, 5); + var metric = TestHelpers.CreateTestMetric(company, "test-event", RulesengineConditionMetricPeriod.AllTime, 5); company.AddMetric(metric); // Assert - Assert.That(company.Metrics.Count, Is.EqualTo(initialCount + 1)); + Assert.That(company.Metrics.Count(), Is.EqualTo(initialCount + 1)); Assert.That(company.Metrics, Does.Contain(metric)); } @@ -32,12 +31,12 @@ public void AddMetric_Replaces_Existing_Metric_With_Same_Constraints() // Add initial metric string eventSubtype = "test-event"; - var period = ConditionMetricPeriod.AllTime; - ConditionMetricPeriodMonthReset monthReset = ConditionMetricPeriodMonthReset.FirstOfMonth; + var period = RulesengineConditionMetricPeriod.AllTime; + RulesengineConditionMetricPeriodMonthReset monthReset = RulesengineConditionMetricPeriodMonthReset.FirstOfMonth; var initialMetric = TestHelpers.CreateTestMetric(company, eventSubtype, period, 5); company.AddMetric(initialMetric); - int initialCount = company.Metrics.Count; + int initialCount = company.Metrics.Count(); // Act - Add metric with same constraints but different value var newMetric = TestHelpers.CreateTestMetric(company, eventSubtype, period, 10); @@ -45,12 +44,12 @@ public void AddMetric_Replaces_Existing_Metric_With_Same_Constraints() // 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.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 = CompanyMetric.Find(company.Metrics, eventSubtype, period, monthReset); + var foundMetric = CompanyMetricExtensions.Find(company.Metrics, eventSubtype, period, monthReset); Assert.That(foundMetric, Is.Not.Null); Assert.That(foundMetric!.Value, Is.EqualTo(10)); } @@ -73,7 +72,7 @@ public void AddMetric_Handles_Concurrent_Updates_Safely() { // 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, ConditionMetricPeriod.AllTime, index); + var metric = TestHelpers.CreateTestMetric(company, uniqueSubtype, RulesengineConditionMetricPeriod.AllTime, index); // Add the metric company.AddMetric(metric); @@ -87,15 +86,25 @@ public void AddMetric_Handles_Concurrent_Updates_Safely() Task.WaitAll(tasks.ToArray()); // Assert - Assert.That(company.Metrics.Count, Is.GreaterThanOrEqualTo(numTasks)); + Assert.That(company.Metrics.Count(), Is.GreaterThanOrEqualTo(numTasks)); } [Test] public void AddMetric_NullCompany_DoesNotThrow() { // Arrange - Company? company = null; - var metric = new CompanyMetric { EventSubtype = "test" }; + RulesengineCompany? company = null; + var metric = new RulesengineCompanyMetric + { + AccountId = "acct", + EnvironmentId = "env", + CompanyId = "comp", + EventSubtype = "test", + Value = 0, + Period = new RulesengineCompanyMetricPeriod("all_time"), + MonthReset = RulesengineCompanyMetricMonthReset.FirstOfMonth, + CreatedAt = DateTime.UtcNow + }; // Act & Assert Assert.DoesNotThrow(() => company?.AddMetric(metric)); @@ -115,7 +124,7 @@ public void AddMetric_NullMetric_DoesNotThrow() public void AddMetric_CompanyWithNoMetrics_DoesNotThrow() { // Arrange - var company = new Company + var company = new RulesengineCompany { Id = TestHelpers.GenerateTestId("comp"), AccountId = TestHelpers.GenerateTestId("acct"), @@ -123,8 +132,8 @@ public void AddMetric_CompanyWithNoMetrics_DoesNotThrow() 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 Subscription + Traits = new List(), + Subscription = new RulesengineSubscription { Id = TestHelpers.GenerateTestId("bilsub"), PeriodStart = DateTime.UtcNow.AddDays(-30), @@ -134,12 +143,12 @@ public void AddMetric_CompanyWithNoMetrics_DoesNotThrow() Metrics = null! }; - var metric = TestHelpers.CreateTestMetric(company, "foo", ConditionMetricPeriod.AllTime, 1); + var metric = TestHelpers.CreateTestMetric(company, "foo", RulesengineConditionMetricPeriod.AllTime, 1); // Act & Assert Assert.DoesNotThrow(() => company.AddMetric(metric)); Assert.That(company.Metrics, Is.Not.Null); - Assert.That(company.Metrics.Count, Is.EqualTo(1)); + Assert.That(company.Metrics.Count(), Is.EqualTo(1)); } } -} \ No newline at end of file +} diff --git a/src/SchematicHQ.Client.Test/RulesEngine/EnumResilienceTests.cs b/src/SchematicHQ.Client.Test/RulesEngine/EnumResilienceTests.cs index 166ceeda..beb016e1 100644 --- a/src/SchematicHQ.Client.Test/RulesEngine/EnumResilienceTests.cs +++ b/src/SchematicHQ.Client.Test/RulesEngine/EnumResilienceTests.cs @@ -34,7 +34,7 @@ public void RuleType_UnknownValue_ShouldFallbackToUnknown() // Act & Assert Assert.DoesNotThrow(() => { - var result = JsonSerializer.Deserialize(unknownRuleTypeJson, _options); + var result = JsonSerializer.Deserialize(unknownRuleTypeJson, _options); Assert.That(result.Value, Is.EqualTo("unknown_rule_type")); // Should preserve unknown value }); } @@ -74,10 +74,10 @@ public void RuleType_ValidValue_ShouldParseCorrectly() string validRuleTypeJson = "\"standard\""; // Act - var result = JsonSerializer.Deserialize(validRuleTypeJson, _options); + var result = JsonSerializer.Deserialize(validRuleTypeJson, _options); // Assert - Assert.That(result, Is.EqualTo(RuleRuleType.Standard)); + Assert.That(result, Is.EqualTo(RulesengineRuleRuleType.Standard)); } [Test] @@ -100,10 +100,10 @@ public void RuleType_SnakeCaseValue_ShouldParseCorrectly() string snakeCaseRuleTypeJson = "\"global_override\""; // Act - var result = JsonSerializer.Deserialize(snakeCaseRuleTypeJson, _options); + var result = JsonSerializer.Deserialize(snakeCaseRuleTypeJson, _options); // Assert - Assert.That(result, Is.EqualTo(RuleRuleType.GlobalOverride)); + Assert.That(result, Is.EqualTo(RulesengineRuleRuleType.GlobalOverride)); } [Test] @@ -126,7 +126,7 @@ public void RuleType_InvalidValue_ShouldFallbackToUnknown() // Act & Assert Assert.DoesNotThrow(() => { - var result = JsonSerializer.Deserialize("\"invalid_rule\"", _options); + var result = JsonSerializer.Deserialize("\"invalid_rule\"", _options); Assert.That(result.Value, Is.EqualTo("invalid_rule")); // Should preserve unknown value }); } @@ -137,7 +137,7 @@ public void RuleType_NonexistentValue_ShouldFallbackToUnknown() // Act & Assert Assert.DoesNotThrow(() => { - var result = JsonSerializer.Deserialize("\"nonexistent\"", _options); + var result = JsonSerializer.Deserialize("\"nonexistent\"", _options); Assert.That(result.Value, Is.EqualTo("nonexistent")); // Should preserve unknown value }); } @@ -148,7 +148,7 @@ public void RuleType_EmptyValue_ShouldFallbackToUnknown() // Act & Assert Assert.DoesNotThrow(() => { - var result = JsonSerializer.Deserialize("\"\"", _options); + var result = JsonSerializer.Deserialize("\"\"", _options); Assert.That(result.Value, Is.EqualTo("")); // Empty string is preserved }); } @@ -170,7 +170,7 @@ public void ComparableType_InvalidValues_ShouldFallbackToDefault(string json, Co public void RuleType_Serialization_ShouldUseSnakeCase() { // Arrange - var ruleType = RuleRuleType.GlobalOverride; + var ruleType = RulesengineRuleRuleType.GlobalOverride; // Act var json = JsonSerializer.Serialize(ruleType, _options); @@ -183,7 +183,7 @@ public void RuleType_Serialization_ShouldUseSnakeCase() public void RuleType_UnknownSerialization_ShouldSerializeAsEmptyString() { // Arrange - var ruleType = RuleRuleType.FromCustom(""); + var ruleType = RulesengineRuleRuleType.FromCustom(""); // Act var json = JsonSerializer.Serialize(ruleType, _options); @@ -205,4 +205,4 @@ public void ComparableType_Serialization_ShouldUseSnakeCase() Assert.That(json, Is.EqualTo("\"string\"")); } } -} \ No newline at end of file +} diff --git a/src/SchematicHQ.Client.Test/RulesEngine/FlagCheckTests.cs b/src/SchematicHQ.Client.Test/RulesEngine/FlagCheckTests.cs index 7a1e11f3..83726739 100644 --- a/src/SchematicHQ.Client.Test/RulesEngine/FlagCheckTests.cs +++ b/src/SchematicHQ.Client.Test/RulesEngine/FlagCheckTests.cs @@ -1,5 +1,4 @@ using SchematicHQ.Client.RulesEngine.Utils; -using SchematicHQ.Client.RulesEngine.Models; using SchematicHQ.Client.RulesEngine; using NUnit.Framework; @@ -49,17 +48,16 @@ public async Task GlobalOverride_TakesPrecedence() // Create a standard rule that matches var standardRule = TestHelpers.CreateTestRule(); standardRule.Value = false; - var standardCondition = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var standardCondition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); standardCondition.ResourceIds = new List { company.Id }; - standardRule.Conditions = new List { standardCondition }; + standardRule.Conditions = new List { standardCondition }; // Create a global override rule var overrideRule = TestHelpers.CreateTestRule(); - overrideRule.RuleType = RuleRuleType.GlobalOverride; + overrideRule.RuleType = RulesengineRuleRuleType.GlobalOverride; overrideRule.Value = true; - flag.Rules.Add(standardRule); - flag.Rules.Add(overrideRule); + flag.Rules = new List { standardRule, overrideRule }; // Act var result = await FlagCheckService.CheckFlag(company, null, flag); @@ -80,18 +78,18 @@ public async Task Rules_Evaluated_In_Priority_Order() var rule1 = TestHelpers.CreateTestRule(); rule1.Priority = 2; rule1.Value = false; - var condition1 = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); condition1.ResourceIds = new List { company.Id }; - rule1.Conditions = new List { condition1 }; + rule1.Conditions = new List { condition1 }; var rule2 = TestHelpers.CreateTestRule(); rule2.Priority = 1; // Lower priority number = higher priority rule2.Value = true; - var condition2 = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var condition2 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); condition2.ResourceIds = new List { company.Id }; - rule2.Conditions = new List { condition2 }; + rule2.Conditions = new List { condition2 }; - flag.Rules = new List { rule1, rule2 }; + flag.Rules = new List { rule1, rule2 }; // Act var result = await FlagCheckService.CheckFlag(company, null, flag); @@ -112,19 +110,19 @@ public async Task Condition_Group_Matches_When_Any_Condition_Matches() rule.Value = true; // Create condition group with two conditions - var condition1 = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); condition1.ResourceIds = new List { "non-matching-id" }; - var condition2 = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var condition2 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); condition2.ResourceIds = new List { company.Id }; - var group = new ConditionGroup + var group = new RulesengineConditionGroup { - Conditions = new List { condition1, condition2 } + Conditions = new List { condition1, condition2 } }; - rule.ConditionGroups = new List { group }; - flag.Rules = new List { rule }; + rule.ConditionGroups = new List { group }; + flag.Rules = new List { rule }; // Act var result = await FlagCheckService.CheckFlag(company, null, flag); @@ -144,20 +142,20 @@ public async Task Sets_Usage_And_Allocation_For_Metric_Condition() // Create entitlement rule with metric condition string eventSubtype = "test-event"; var rule = TestHelpers.CreateTestRule(); - rule.RuleType = RuleRuleType.PlanEntitlement; + rule.RuleType = RulesengineRuleRuleType.PlanEntitlement; rule.Value = true; - var condition = TestHelpers.CreateTestCondition(ConditionConditionType.Metric); + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Metric); condition.EventSubtype = eventSubtype; condition.MetricValue = 10; - condition.Operator = ConditionOperator.Lte; + condition.Operator = RulesengineConditionOperator.Lte; - rule.Conditions = new List { condition }; - flag.Rules = new List { rule }; + 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 }; + company.Metrics = new List { metric }; // Act var result = await FlagCheckService.CheckFlag(company, null, flag); @@ -179,22 +177,22 @@ public async Task Sets_Usage_And_Allocation_For_Trait_Condition() var flag = TestHelpers.CreateTestFlag(); // Create trait - var traitDef = TestHelpers.CreateTestTraitDefinition(TraitDefinitionComparableType.Int, EntityType.Company); + var traitDef = TestHelpers.CreateTestTraitDefinition(RulesengineTraitDefinitionComparableType.Int, RulesengineEntityType.Company); var trait = TestHelpers.CreateTestTrait("5", traitDef); - company.Traits = new List { trait }; + company.Traits = new List { trait }; // Create entitlement rule with trait condition var rule = TestHelpers.CreateTestRule(); - rule.RuleType = RuleRuleType.PlanEntitlement; + rule.RuleType = RulesengineRuleRuleType.PlanEntitlement; rule.Value = true; - var condition = TestHelpers.CreateTestCondition(ConditionConditionType.Trait); + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Trait); condition.TraitDefinition = traitDef; condition.TraitValue = "10"; - condition.Operator = ConditionOperator.Lte; + condition.Operator = RulesengineConditionOperator.Lte; - rule.Conditions = new List { condition }; - flag.Rules = new List { rule }; + rule.Conditions = new List { condition }; + flag.Rules = new List { rule }; // Act var result = await FlagCheckService.CheckFlag(company, null, flag); @@ -218,11 +216,11 @@ public async Task Matches_User_Specific_Conditions() var rule = TestHelpers.CreateTestRule(); rule.Value = true; - var condition = TestHelpers.CreateTestCondition(ConditionConditionType.User); + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.User); condition.ResourceIds = new List { user.Id }; - rule.Conditions = new List { condition }; + rule.Conditions = new List { condition }; - flag.Rules = new List { rule }; + flag.Rules = new List { rule }; // Act var result = await FlagCheckService.CheckFlag(null, user, flag); @@ -238,21 +236,21 @@ public async Task Checks_User_Traits() { // Arrange var user = TestHelpers.CreateTestUser(); - var traitDef = TestHelpers.CreateTestTraitDefinition(TraitDefinitionComparableType.String, EntityType.User); + var traitDef = TestHelpers.CreateTestTraitDefinition(RulesengineTraitDefinitionComparableType.String, RulesengineEntityType.User); var trait = TestHelpers.CreateTestTrait("test-value", traitDef); - user.Traits = new List { trait }; + user.Traits = new List { trait }; var flag = TestHelpers.CreateTestFlag(); var rule = TestHelpers.CreateTestRule(); rule.Value = true; - var condition = TestHelpers.CreateTestCondition(ConditionConditionType.Trait); + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Trait); condition.TraitDefinition = traitDef; condition.TraitValue = "test-value"; - condition.Operator = ConditionOperator.Eq; + condition.Operator = RulesengineConditionOperator.Eq; - rule.Conditions = new List { condition }; - flag.Rules = new List { rule }; + rule.Conditions = new List { condition }; + flag.Rules = new List { rule }; // Act var result = await FlagCheckService.CheckFlag(null, user, flag); @@ -268,40 +266,40 @@ public async Task Handles_Multiple_Condition_Types_And_Groups() // Arrange var company = TestHelpers.CreateTestCompany(); var trait = TestHelpers.CreateTestTrait("test-value", null); - company.Traits = new List { trait }; + company.Traits = new List { trait }; var flag = TestHelpers.CreateTestFlag(); var rule = TestHelpers.CreateTestRule(); rule.Value = true; // Add direct conditions - var condition1 = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); condition1.ResourceIds = new List { company.Id }; - var condition2 = TestHelpers.CreateTestCondition(ConditionConditionType.Trait); + var condition2 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Trait); condition2.TraitDefinition = trait.TraitDefinition; condition2.TraitValue = "test-value"; - condition2.Operator = ConditionOperator.Eq; + condition2.Operator = RulesengineConditionOperator.Eq; - rule.Conditions = new List { condition1, condition2 }; + rule.Conditions = new List { condition1, condition2 }; // Add condition group - var groupCondition1 = TestHelpers.CreateTestCondition(ConditionConditionType.Plan); - groupCondition1.ResourceIds = new List { company.PlanIds[0] }; + var groupCondition1 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Plan); + groupCondition1.ResourceIds = new List { company.PlanIds.First() }; - var groupCondition2 = TestHelpers.CreateTestCondition(ConditionConditionType.BasePlan); + var groupCondition2 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.BasePlan); if (!string.IsNullOrEmpty(company.BasePlanId)) { groupCondition2.ResourceIds = new List { company.BasePlanId }; } - var group = new ConditionGroup + var group = new RulesengineConditionGroup { - Conditions = new List { groupCondition1, groupCondition2 } + Conditions = new List { groupCondition1, groupCondition2 } }; - rule.ConditionGroups = new List { group }; - flag.Rules = new List { rule }; + rule.ConditionGroups = new List { group }; + flag.Rules = new List { rule }; // Act var result = await FlagCheckService.CheckFlag(company, null, flag); @@ -320,22 +318,22 @@ public async Task Handles_Missing_Or_Invalid_Data_Gracefully() var rule = TestHelpers.CreateTestRule(); // Add condition with null fields - var condition = new Condition + var condition = new RulesengineCondition { Id = "", AccountId = "", EnvironmentId = "", - ConditionType = ConditionConditionType.Metric, - Operator = ConditionOperator.Eq, + ConditionType = RulesengineConditionConditionType.Metric, + Operator = RulesengineConditionOperator.Eq, TraitValue = "" }; - rule.Conditions = new List { condition }; + rule.Conditions = new List { condition }; // Add empty condition group - var group = new ConditionGroup(); - rule.ConditionGroups = new List { group }; + var group = new RulesengineConditionGroup(); + rule.ConditionGroups = new List { group }; - flag.Rules = new List { rule }; + flag.Rules = new List { rule }; // Act var result = await FlagCheckService.CheckFlag(company, null, flag); @@ -357,11 +355,11 @@ public async Task CompanyProvidedRule_IsEvaluatedAlongWithFlagRules() var companyRule = TestHelpers.CreateTestRule(); companyRule.FlagId = flag.Id; companyRule.Value = true; - var condition = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); condition.ResourceIds = new List { company.Id }; - companyRule.Conditions = new List { condition }; + companyRule.Conditions = new List { condition }; - company.Rules = new List { companyRule }; + company.Rules = new List { companyRule }; // Act var result = await FlagCheckService.CheckFlag(company, null, flag); @@ -382,21 +380,21 @@ public async Task CompanyProvidedRule_RespectsPriorityOrdering() var flagRule = TestHelpers.CreateTestRule(); flagRule.Priority = 2; flagRule.Value = false; - var condition1 = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); condition1.ResourceIds = new List { company.Id }; - flagRule.Conditions = new List { condition1 }; + flagRule.Conditions = new List { condition1 }; // Create company rule with higher priority var companyRule = TestHelpers.CreateTestRule(); companyRule.FlagId = flag.Id; companyRule.Priority = 1; companyRule.Value = true; - var condition2 = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var condition2 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); condition2.ResourceIds = new List { company.Id }; - companyRule.Conditions = new List { condition2 }; + companyRule.Conditions = new List { condition2 }; - flag.Rules = new List { flagRule }; - company.Rules = new List { companyRule }; + flag.Rules = new List { flagRule }; + company.Rules = new List { companyRule }; // Act var result = await FlagCheckService.CheckFlag(company, null, flag); @@ -416,18 +414,18 @@ public async Task CompanyProvidedRule_WithGlobalOverrideTakesPrecedence() // Create standard flag rule var flagRule = TestHelpers.CreateTestRule(); flagRule.Value = false; - var condition1 = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); condition1.ResourceIds = new List { company.Id }; - flagRule.Conditions = new List { condition1 }; + flagRule.Conditions = new List { condition1 }; // Create company rule with global override var companyRule = TestHelpers.CreateTestRule(); companyRule.FlagId = flag.Id; - companyRule.RuleType = RuleRuleType.GlobalOverride; + companyRule.RuleType = RulesengineRuleRuleType.GlobalOverride; companyRule.Value = true; - flag.Rules = new List { flagRule }; - company.Rules = new List { companyRule }; + flag.Rules = new List { flagRule }; + company.Rules = new List { companyRule }; // Act var result = await FlagCheckService.CheckFlag(company, null, flag); @@ -450,19 +448,19 @@ public async Task MultipleCompanyProvidedRules_AreAllEvaluated() companyRule1.FlagId = flag.Id; companyRule1.Priority = 1; companyRule1.Value = true; - var condition1 = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); condition1.ResourceIds = new List { "non-matching-id" }; - companyRule1.Conditions = new List { condition1 }; + companyRule1.Conditions = new List { condition1 }; var companyRule2 = TestHelpers.CreateTestRule(); companyRule2.FlagId = flag.Id; companyRule2.Priority = 2; companyRule2.Value = true; - var condition2 = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var condition2 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); condition2.ResourceIds = new List { company.Id }; - companyRule2.Conditions = new List { condition2 }; + companyRule2.Conditions = new List { condition2 }; - company.Rules = new List { companyRule1, companyRule2 }; + company.Rules = new List { companyRule1, companyRule2 }; // Act var result = await FlagCheckService.CheckFlag(company, null, flag); @@ -484,11 +482,11 @@ public async Task UserProvidedRule_IsEvaluatedAlongWithFlagRules() var userRule = TestHelpers.CreateTestRule(); userRule.FlagId = flag.Id; userRule.Value = true; - var condition = TestHelpers.CreateTestCondition(ConditionConditionType.User); + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.User); condition.ResourceIds = new List { user.Id }; - userRule.Conditions = new List { condition }; + userRule.Conditions = new List { condition }; - user.Rules = new List { userRule }; + user.Rules = new List { userRule }; // Act var result = await FlagCheckService.CheckFlag(null, user, flag); @@ -509,21 +507,21 @@ public async Task UserProvidedRule_RespectsPriorityOrdering() var flagRule = TestHelpers.CreateTestRule(); flagRule.Priority = 2; flagRule.Value = false; - var condition1 = TestHelpers.CreateTestCondition(ConditionConditionType.User); + var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.User); condition1.ResourceIds = new List { user.Id }; - flagRule.Conditions = new List { condition1 }; + flagRule.Conditions = new List { condition1 }; // Create user rule with higher priority var userRule = TestHelpers.CreateTestRule(); userRule.FlagId = flag.Id; userRule.Priority = 1; userRule.Value = true; - var condition2 = TestHelpers.CreateTestCondition(ConditionConditionType.User); + var condition2 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.User); condition2.ResourceIds = new List { user.Id }; - userRule.Conditions = new List { condition2 }; + userRule.Conditions = new List { condition2 }; - flag.Rules = new List { flagRule }; - user.Rules = new List { userRule }; + flag.Rules = new List { flagRule }; + user.Rules = new List { userRule }; // Act var result = await FlagCheckService.CheckFlag(null, user, flag); @@ -543,18 +541,18 @@ public async Task UserProvidedRule_WithGlobalOverrideTakesPrecedence() // Create standard flag rule var flagRule = TestHelpers.CreateTestRule(); flagRule.Value = false; - var condition1 = TestHelpers.CreateTestCondition(ConditionConditionType.User); + var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.User); condition1.ResourceIds = new List { user.Id }; - flagRule.Conditions = new List { condition1 }; + flagRule.Conditions = new List { condition1 }; // Create user rule with global override var userRule = TestHelpers.CreateTestRule(); userRule.FlagId = flag.Id; - userRule.RuleType = RuleRuleType.GlobalOverride; + userRule.RuleType = RulesengineRuleRuleType.GlobalOverride; userRule.Value = true; - flag.Rules = new List { flagRule }; - user.Rules = new List { userRule }; + flag.Rules = new List { flagRule }; + user.Rules = new List { userRule }; // Act var result = await FlagCheckService.CheckFlag(null, user, flag); @@ -578,21 +576,21 @@ public async Task BothCompanyAndUserRules_AreEvaluated() companyRule.FlagId = flag.Id; companyRule.Priority = 1; companyRule.Value = true; - var condition1 = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); condition1.ResourceIds = new List { "non-matching-id" }; - companyRule.Conditions = new List { condition1 }; + companyRule.Conditions = new List { condition1 }; // Create user rule that matches var userRule = TestHelpers.CreateTestRule(); userRule.FlagId = flag.Id; userRule.Priority = 2; userRule.Value = true; - var condition2 = TestHelpers.CreateTestCondition(ConditionConditionType.User); + var condition2 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.User); condition2.ResourceIds = new List { user.Id }; - userRule.Conditions = new List { condition2 }; + userRule.Conditions = new List { condition2 }; - company.Rules = new List { companyRule }; - user.Rules = new List { userRule }; + company.Rules = new List { companyRule }; + user.Rules = new List { userRule }; // Act var result = await FlagCheckService.CheckFlag(company, user, flag); @@ -615,29 +613,29 @@ public async Task AllThreeRuleSources_EvaluatedWithCorrectPriority() var flagRule = TestHelpers.CreateTestRule(); flagRule.Priority = 2; flagRule.Value = true; - var condition1 = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); condition1.ResourceIds = new List { company.Id }; - flagRule.Conditions = new List { condition1 }; + flagRule.Conditions = new List { condition1 }; var companyRule = TestHelpers.CreateTestRule(); companyRule.FlagId = flag.Id; companyRule.Priority = 3; companyRule.Value = true; - var condition2 = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var condition2 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); condition2.ResourceIds = new List { company.Id }; - companyRule.Conditions = new List { condition2 }; + companyRule.Conditions = new List { condition2 }; var userRule = TestHelpers.CreateTestRule(); userRule.FlagId = flag.Id; userRule.Priority = 1; // Highest priority userRule.Value = true; - var condition3 = TestHelpers.CreateTestCondition(ConditionConditionType.User); + var condition3 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.User); condition3.ResourceIds = new List { user.Id }; - userRule.Conditions = new List { condition3 }; + userRule.Conditions = new List { condition3 }; - flag.Rules = new List { flagRule }; - company.Rules = new List { companyRule }; - user.Rules = new List { userRule }; + flag.Rules = new List { flagRule }; + company.Rules = new List { companyRule }; + user.Rules = new List { userRule }; // Act var result = await FlagCheckService.CheckFlag(company, user, flag); @@ -661,11 +659,11 @@ public async Task CompanyProvidedRule_WithWrongFlagId_IsNotEvaluated() var companyRule = TestHelpers.CreateTestRule(); companyRule.FlagId = TestHelpers.GenerateTestId("flag"); // Different flag ID companyRule.Value = true; - var condition = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); condition.ResourceIds = new List { company.Id }; - companyRule.Conditions = new List { condition }; + companyRule.Conditions = new List { condition }; - company.Rules = new List { companyRule }; + company.Rules = new List { companyRule }; // Act var result = await FlagCheckService.CheckFlag(company, null, flag); @@ -687,11 +685,11 @@ public async Task UserProvidedRule_WithWrongFlagId_IsNotEvaluated() var userRule = TestHelpers.CreateTestRule(); userRule.FlagId = TestHelpers.GenerateTestId("flag"); // Different flag ID userRule.Value = true; - var condition = TestHelpers.CreateTestCondition(ConditionConditionType.User); + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.User); condition.ResourceIds = new List { user.Id }; - userRule.Conditions = new List { condition }; + userRule.Conditions = new List { condition }; - user.Rules = new List { userRule }; + user.Rules = new List { userRule }; // Act var result = await FlagCheckService.CheckFlag(null, user, flag); @@ -713,11 +711,11 @@ public async Task CompanyProvidedRule_WithNullFlagId_IsNotEvaluated() var companyRule = TestHelpers.CreateTestRule(); companyRule.FlagId = null; companyRule.Value = true; - var condition = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); condition.ResourceIds = new List { company.Id }; - companyRule.Conditions = new List { condition }; + companyRule.Conditions = new List { condition }; - company.Rules = new List { companyRule }; + company.Rules = new List { companyRule }; // Act var result = await FlagCheckService.CheckFlag(company, null, flag); @@ -739,11 +737,11 @@ public async Task UserProvidedRule_WithNullFlagId_IsNotEvaluated() var userRule = TestHelpers.CreateTestRule(); userRule.FlagId = null; userRule.Value = true; - var condition = TestHelpers.CreateTestCondition(ConditionConditionType.User); + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.User); condition.ResourceIds = new List { user.Id }; - userRule.Conditions = new List { condition }; + userRule.Conditions = new List { condition }; - user.Rules = new List { userRule }; + user.Rules = new List { userRule }; // Act var result = await FlagCheckService.CheckFlag(null, user, flag); @@ -753,4 +751,4 @@ public async Task UserProvidedRule_WithNullFlagId_IsNotEvaluated() Assert.That(result.Reason, Is.EqualTo(FlagCheckService.ReasonNoRulesMatched)); } } -} \ No newline at end of file +} diff --git a/src/SchematicHQ.Client.Test/RulesEngine/Helpers.cs b/src/SchematicHQ.Client.Test/RulesEngine/Helpers.cs index dae01681..2cf2392e 100644 --- a/src/SchematicHQ.Client.Test/RulesEngine/Helpers.cs +++ b/src/SchematicHQ.Client.Test/RulesEngine/Helpers.cs @@ -1,6 +1,5 @@ using SchematicHQ.Client; using SchematicHQ.Client.RulesEngine; -using SchematicHQ.Client.RulesEngine.Models; using SchematicHQ.Client.RulesEngine.Utils; namespace SchematicHQ.Client.Test.RulesEngine @@ -14,113 +13,116 @@ public static string GenerateTestId(string prefix) return $"{prefix}_{Guid.NewGuid().ToString().Replace("-", "").Substring(0, 8)}"; } - public static Company CreateTestCompany() + public static RulesengineCompany CreateTestCompany() { - return new Company + return new RulesengineCompany { Id = GenerateTestId("comp"), AccountId = GenerateTestId("acct"), EnvironmentId = GenerateTestId("env"), PlanIds = new List { GenerateTestId("plan") }, - Traits = new List(), - Metrics = new List(), + Traits = new List(), + Metrics = new List(), BillingProductIds = new List() }; } - public static User CreateTestUser() + public static RulesengineUser CreateTestUser() { - return new User + return new RulesengineUser { Id = GenerateTestId("user"), AccountId = GenerateTestId("acct"), EnvironmentId = GenerateTestId("env"), - Traits = new List() + Traits = new List() }; } - public static Rule CreateTestRule() + public static RulesengineRule CreateTestRule() { - return new Rule + return new RulesengineRule { Id = GenerateTestId("rule"), AccountId = GenerateTestId("acct"), EnvironmentId = GenerateTestId("env"), - RuleType = RuleRuleType.Standard, + RuleType = RulesengineRuleRuleType.Standard, Name = $"Test Rule {Random.Next(1000)}", Priority = 1, - Conditions = new List(), - ConditionGroups = new List(), + Conditions = new List(), + ConditionGroups = new List(), Value = true }; } - public static Flag CreateTestFlag() + public static RulesengineFlag CreateTestFlag() { - return new Flag + return new RulesengineFlag { Id = GenerateTestId("flag"), AccountId = GenerateTestId("acct"), EnvironmentId = GenerateTestId("env"), Key = $"test_flag_{Random.Next(1000)}", - Rules = new List(), + Rules = new List(), DefaultValue = Random.Next(2) == 0 // Random boolean }; } - public static Condition CreateTestCondition(ConditionConditionType conditionType) + public static RulesengineCondition CreateTestCondition(RulesengineConditionConditionType conditionType) { - var condition = new Condition + var condition = new RulesengineCondition { Id = GenerateTestId("cond"), AccountId = GenerateTestId("acct"), EnvironmentId = GenerateTestId("env"), ConditionType = conditionType, ResourceIds = new List(), - Operator = ConditionOperator.Eq, + Operator = RulesengineConditionOperator.Eq, TraitValue = "" }; - if (conditionType == ConditionConditionType.Metric) + if (conditionType == RulesengineConditionConditionType.Metric) { condition.EventSubtype = $"test_event_{Random.Next(1000)}"; - condition.MetricPeriod = ConditionMetricPeriod.AllTime; + condition.MetricPeriod = RulesengineConditionMetricPeriod.AllTime; condition.MetricValue = 10; } return condition; } - public static CompanyMetric CreateTestMetric(Company company, string eventSubtype, ConditionMetricPeriod period, long value) + public static RulesengineCompanyMetric CreateTestMetric(RulesengineCompany company, string eventSubtype, RulesengineConditionMetricPeriod period, int value) { - return new CompanyMetric + return new RulesengineCompanyMetric { + AccountId = company.AccountId, + EnvironmentId = company.EnvironmentId, + CompanyId = company.Id, EventSubtype = eventSubtype, - Period = period, - MonthReset = ConditionMetricPeriodMonthReset.FirstOfMonth, + Period = new RulesengineCompanyMetricPeriod(period.Value), + MonthReset = RulesengineCompanyMetricMonthReset.FirstOfMonth, Value = value, CreatedAt = DateTime.UtcNow, }; } - public static Trait CreateTestTrait(string value, TraitDefinition? traitDefinition) + public static RulesengineTrait CreateTestTrait(string value, RulesengineTraitDefinition? traitDefinition) { if (traitDefinition == null) { - traitDefinition = CreateTestTraitDefinition(TraitDefinitionComparableType.String, EntityType.Company); + traitDefinition = CreateTestTraitDefinition(RulesengineTraitDefinitionComparableType.String, RulesengineEntityType.Company); } - return new Trait + return new RulesengineTrait { Value = value, TraitDefinition = traitDefinition, }; } - public static TraitDefinition CreateTestTraitDefinition(TraitDefinitionComparableType comparableType, EntityType entityType) + public static RulesengineTraitDefinition CreateTestTraitDefinition(RulesengineTraitDefinitionComparableType comparableType, RulesengineEntityType entityType) { - return new TraitDefinition + return new RulesengineTraitDefinition { Id = GenerateTestId("traitdef"), ComparableType = comparableType, diff --git a/src/SchematicHQ.Client.Test/RulesEngine/JsonDeserializationTests.cs b/src/SchematicHQ.Client.Test/RulesEngine/JsonDeserializationTests.cs index 40734d43..2f90928c 100644 --- a/src/SchematicHQ.Client.Test/RulesEngine/JsonDeserializationTests.cs +++ b/src/SchematicHQ.Client.Test/RulesEngine/JsonDeserializationTests.cs @@ -2,7 +2,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using NUnit.Framework; -using SchematicHQ.Client.RulesEngine.Models; using SchematicHQ.Client.RulesEngine.Utils; using SchematicHQ.Client.RulesEngine; using SchematicHQ.Client.Cache; @@ -17,11 +16,11 @@ public class JsonDeserializationTests [SetUp] public void SetUp() { - _jsonOptions = new JsonSerializerOptions - { + _jsonOptions = new JsonSerializerOptions + { WriteIndented = false, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, - Converters = { + Converters = { new ComparableTypeConverter(), new ResilientEnumConverter(), new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower, true) @@ -76,14 +75,14 @@ public void Flag_WithRuleTypeEnum_ShouldDeserializeCorrectly() }"; // Act - var flag = JsonSerializer.Deserialize(flagJson, _jsonOptions); + var flag = JsonSerializer.Deserialize(flagJson, _jsonOptions); // Assert Assert.That(flag, Is.Not.Null); Assert.That(flag!.Id, Is.EqualTo("flag_6SB8FaJPR8C")); Assert.That(flag.Key, Is.EqualTo("analyze-clicks")); - Assert.That(flag.Rules, Has.Count.EqualTo(1)); - Assert.That(flag.Rules[0].RuleType, Is.EqualTo(RuleRuleType.PlanEntitlement)); + Assert.That(flag.Rules.Count(), Is.EqualTo(1)); + Assert.That(flag.Rules.First().RuleType, Is.EqualTo(RulesengineRuleRuleType.PlanEntitlement)); } [Test] @@ -138,17 +137,17 @@ public void Company_WithComparableTypeEnums_ShouldDeserializeCorrectly() }"; // Act - var company = JsonSerializer.Deserialize(companyJson, _jsonOptions); + var company = JsonSerializer.Deserialize(companyJson, _jsonOptions); // Assert Assert.That(company, Is.Not.Null); Assert.That(company!.Id, Is.EqualTo("comp_DopLPhkmGHU")); - Assert.That(company.Traits, Has.Count.EqualTo(3)); - + Assert.That(company.Traits.Count(), Is.EqualTo(3)); + // Check that empty string comparable_type is handled correctly foreach (var trait in company.Traits) { - // Empty string should create a TraitDefinitionComparableType with empty string value + // Empty string should create a RulesengineTraitDefinitionComparableType with empty string value Assert.That(trait.TraitDefinition?.ComparableType.Value, Is.EqualTo("")); } } @@ -176,7 +175,7 @@ public void TraitDefinitionComparableType_String_ShouldDeserializeCorrectly() var testObj = JsonSerializer.Deserialize(json, _jsonOptions); // Assert - Assert.That(testObj!.ComparableType, Is.EqualTo(TraitDefinitionComparableType.String)); + Assert.That(testObj!.ComparableType, Is.EqualTo(RulesengineTraitDefinitionComparableType.String)); } [Test] @@ -189,7 +188,7 @@ public void TraitDefinitionComparableType_Int_ShouldDeserializeCorrectly() var testObj = JsonSerializer.Deserialize(json, _jsonOptions); // Assert - Assert.That(testObj!.ComparableType, Is.EqualTo(TraitDefinitionComparableType.Int)); + Assert.That(testObj!.ComparableType, Is.EqualTo(RulesengineTraitDefinitionComparableType.Int)); } [Test] @@ -202,7 +201,7 @@ public void TraitDefinitionComparableType_Bool_ShouldDeserializeCorrectly() var testObj = JsonSerializer.Deserialize(json, _jsonOptions); // Assert - Assert.That(testObj!.ComparableType, Is.EqualTo(TraitDefinitionComparableType.Bool)); + Assert.That(testObj!.ComparableType, Is.EqualTo(RulesengineTraitDefinitionComparableType.Bool)); } [Test] @@ -215,7 +214,7 @@ public void TraitDefinitionComparableType_Date_ShouldDeserializeCorrectly() var testObj = JsonSerializer.Deserialize(json, _jsonOptions); // Assert - Assert.That(testObj!.ComparableType, Is.EqualTo(TraitDefinitionComparableType.Date)); + Assert.That(testObj!.ComparableType, Is.EqualTo(RulesengineTraitDefinitionComparableType.Date)); } [Test] @@ -228,7 +227,7 @@ public void RuleType_PlanEntitlement_ShouldDeserializeCorrectly() var testObj = JsonSerializer.Deserialize(json, _jsonOptions); // Assert - Assert.That(testObj!.RuleType, Is.EqualTo(RuleRuleType.PlanEntitlement)); + Assert.That(testObj!.RuleType, Is.EqualTo(RulesengineRuleRuleType.PlanEntitlement)); } [Test] @@ -241,7 +240,7 @@ public void RuleType_GlobalOverride_ShouldDeserializeCorrectly() var testObj = JsonSerializer.Deserialize(json, _jsonOptions); // Assert - Assert.That(testObj!.RuleType, Is.EqualTo(RuleRuleType.GlobalOverride)); + Assert.That(testObj!.RuleType, Is.EqualTo(RulesengineRuleRuleType.GlobalOverride)); } [Test] @@ -254,7 +253,7 @@ public void RuleType_CompanyOverride_ShouldDeserializeCorrectly() var testObj = JsonSerializer.Deserialize(json, _jsonOptions); // Assert - Assert.That(testObj!.RuleType, Is.EqualTo(RuleRuleType.CompanyOverride)); + Assert.That(testObj!.RuleType, Is.EqualTo(RulesengineRuleRuleType.CompanyOverride)); } [Test] @@ -267,7 +266,7 @@ public void RuleType_Standard_ShouldDeserializeCorrectly() var testObj = JsonSerializer.Deserialize(json, _jsonOptions); // Assert - Assert.That(testObj!.RuleType, Is.EqualTo(RuleRuleType.Standard)); + Assert.That(testObj!.RuleType, Is.EqualTo(RulesengineRuleRuleType.Standard)); } [Test] @@ -280,7 +279,7 @@ public void RuleType_Default_ShouldDeserializeCorrectly() var testObj = JsonSerializer.Deserialize(json, _jsonOptions); // Assert - Assert.That(testObj!.RuleType, Is.EqualTo(RuleRuleType.Default)); + Assert.That(testObj!.RuleType, Is.EqualTo(RulesengineRuleRuleType.Default)); } [Test] @@ -338,13 +337,13 @@ private class TestComparableTypeObject private class TestTraitDefinitionComparableTypeObject { [JsonPropertyName("comparable_type")] - public TraitDefinitionComparableType ComparableType { get; set; } + public RulesengineTraitDefinitionComparableType ComparableType { get; set; } } private class TestRuleTypeObject { [JsonPropertyName("rule_type")] - public RuleRuleType RuleType { get; set; } + public RulesengineRuleRuleType RuleType { get; set; } } } -} \ No newline at end of file +} diff --git a/src/SchematicHQ.Client.Test/RulesEngine/MetricsTests.cs b/src/SchematicHQ.Client.Test/RulesEngine/MetricsTests.cs index 974d74c6..0ce7c75e 100644 --- a/src/SchematicHQ.Client.Test/RulesEngine/MetricsTests.cs +++ b/src/SchematicHQ.Client.Test/RulesEngine/MetricsTests.cs @@ -1,4 +1,3 @@ -using SchematicHQ.Client.RulesEngine.Models; using SchematicHQ.Client.RulesEngine; using NUnit.Framework; @@ -12,7 +11,7 @@ public void TestGetCurrentMetricPeriodStartForCalendarMetricPeriod() { // Test for CurrentDay { - var result = Metrics.GetCurrentMetricPeriodStartForCalendarMetricPeriod(ConditionMetricPeriod.CurrentDay); + var result = Metrics.GetCurrentMetricPeriodStartForCalendarMetricPeriod(RulesengineConditionMetricPeriod.CurrentDay); Assert.That(result, Is.Not.Null); var expected = DateTime.UtcNow.Date; @@ -26,7 +25,7 @@ public void TestGetCurrentMetricPeriodStartForCalendarMetricPeriod() // Test for CurrentWeek { - var result = Metrics.GetCurrentMetricPeriodStartForCalendarMetricPeriod(ConditionMetricPeriod.CurrentWeek); + var result = Metrics.GetCurrentMetricPeriodStartForCalendarMetricPeriod(RulesengineConditionMetricPeriod.CurrentWeek); Assert.That(result, Is.Not.Null); var now = DateTime.UtcNow; @@ -43,7 +42,7 @@ public void TestGetCurrentMetricPeriodStartForCalendarMetricPeriod() // Test for CurrentMonth { - var result = Metrics.GetCurrentMetricPeriodStartForCalendarMetricPeriod(ConditionMetricPeriod.CurrentMonth); + var result = Metrics.GetCurrentMetricPeriodStartForCalendarMetricPeriod(RulesengineConditionMetricPeriod.CurrentMonth); Assert.That(result, Is.Not.Null); var now = DateTime.UtcNow; @@ -59,7 +58,7 @@ public void TestGetCurrentMetricPeriodStartForCalendarMetricPeriod() // Test for AllTime { - var result = Metrics.GetCurrentMetricPeriodStartForCalendarMetricPeriod(ConditionMetricPeriod.AllTime); + var result = Metrics.GetCurrentMetricPeriodStartForCalendarMetricPeriod(RulesengineConditionMetricPeriod.AllTime); Assert.That(result, Is.Null); } } @@ -96,7 +95,7 @@ public void TestGetCurrentMetricPeriodStartForCompanyBillingSubscription() // Test for subscription period start in future { var company = TestHelpers.CreateTestCompany(); - company.Subscription = new Subscription + company.Subscription = new RulesengineSubscription { Id = "test-subscription", PeriodStart = DateTime.UtcNow.AddDays(7), @@ -125,7 +124,7 @@ public void TestGetCurrentMetricPeriodStartForCompanyBillingSubscription() futureDay = 5; } - company.Subscription = new Subscription + company.Subscription = new RulesengineSubscription { Id = "test-subscription", PeriodStart = new DateTime( @@ -166,7 +165,7 @@ public void TestGetCurrentMetricPeriodStartForCompanyBillingSubscription() 1, // January futureDay, 12, 0, 0, - DateTimeKind.Utc); + DateTimeKind.Utc); } } @@ -188,7 +187,7 @@ public void TestGetCurrentMetricPeriodStartForCompanyBillingSubscription() pastDay = 1; } - company.Subscription = new Subscription + company.Subscription = new RulesengineSubscription { Id = "test-subscription", PeriodStart = new DateTime( @@ -210,7 +209,7 @@ public void TestGetCurrentMetricPeriodStartForCompanyBillingSubscription() pastDay, 12, 0, 0, DateTimeKind.Utc); - + if (now.Month == 12) // December { expected = new DateTime( @@ -234,7 +233,7 @@ public void TestGetCurrentMetricPeriodStartForCompanyBillingSubscription() var periodStart = now.AddMonths(-6); // Set a recent subscription start date (10 days ago) - company.Subscription = new Subscription + company.Subscription = new RulesengineSubscription { Id = "test-subscription", PeriodStart = periodStart, @@ -257,7 +256,7 @@ public void TestGetNextMetricPeriodStartForCalendarMetricPeriod() { // Test for CurrentDay { - var result = Metrics.GetNextMetricPeriodStartForCalendarMetricPeriod(ConditionMetricPeriod.CurrentDay); + var result = Metrics.GetNextMetricPeriodStartForCalendarMetricPeriod(RulesengineConditionMetricPeriod.CurrentDay); Assert.That(result, Is.Not.Null); var expected = DateTime.UtcNow.Date.AddDays(1); @@ -271,7 +270,7 @@ public void TestGetNextMetricPeriodStartForCalendarMetricPeriod() // Test for CurrentWeek { - var result = Metrics.GetNextMetricPeriodStartForCalendarMetricPeriod(ConditionMetricPeriod.CurrentWeek); + var result = Metrics.GetNextMetricPeriodStartForCalendarMetricPeriod(RulesengineConditionMetricPeriod.CurrentWeek); Assert.That(result, Is.Not.Null); var now = DateTime.UtcNow; @@ -290,7 +289,7 @@ public void TestGetNextMetricPeriodStartForCalendarMetricPeriod() // Test for CurrentMonth { - var result = Metrics.GetNextMetricPeriodStartForCalendarMetricPeriod(ConditionMetricPeriod.CurrentMonth); + var result = Metrics.GetNextMetricPeriodStartForCalendarMetricPeriod(RulesengineConditionMetricPeriod.CurrentMonth); Assert.That(result, Is.Not.Null); var now = DateTime.UtcNow; @@ -307,7 +306,7 @@ public void TestGetNextMetricPeriodStartForCalendarMetricPeriod() // Test for AllTime { - var result = Metrics.GetNextMetricPeriodStartForCalendarMetricPeriod(ConditionMetricPeriod.AllTime); + var result = Metrics.GetNextMetricPeriodStartForCalendarMetricPeriod(RulesengineConditionMetricPeriod.AllTime); Assert.That(result, Is.Null); } } @@ -349,7 +348,7 @@ public void TestGetNextMetricPeriodStartForCompanyBillingSubscription() var now = DateTime.UtcNow; // Set subscription to start 7 days from now - company.Subscription = new Subscription + company.Subscription = new RulesengineSubscription { Id = "test-subscription", PeriodStart = now.AddDays(7), @@ -381,7 +380,7 @@ public void TestGetNextMetricPeriodStartForCompanyBillingSubscription() var now = DateTime.UtcNow; // Set subscription to have started some time ago - company.Subscription = new Subscription + company.Subscription = new RulesengineSubscription { Id = "test-subscription", PeriodStart = now.AddMonths(-6), @@ -407,7 +406,7 @@ public void TestGetNextMetricPeriodStartForCompanyBillingSubscription() pastDay = 1; } - company.Subscription = new Subscription + company.Subscription = new RulesengineSubscription { Id = "test-subscription", PeriodStart = new DateTime( @@ -462,8 +461,8 @@ public void TestGetNextMetricPeriodStartForCompanyBillingSubscription() // 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 Subscription + + company.Subscription = new RulesengineSubscription { Id = "test-subscription", PeriodStart = new DateTime( @@ -506,7 +505,7 @@ public void TestGetNextMetricPeriodStartForCompanyBillingSubscription() } else { - company.Subscription = new Subscription + company.Subscription = new RulesengineSubscription { Id = "test-subscription", PeriodStart = new DateTime( @@ -548,14 +547,14 @@ public void TestGetNextMetricPeriodStartFromCondition() // Test for condition that is not metric type { - var condition = TestHelpers.CreateTestCondition(ConditionConditionType.Trait); + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Trait); var result = Metrics.GetNextMetricPeriodStartFromCondition(condition, null); Assert.That(result, Is.Null); } // Test for metric period is null { - var condition = TestHelpers.CreateTestCondition(ConditionConditionType.Metric); + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Metric); condition.MetricPeriod = null; var result = Metrics.GetNextMetricPeriodStartFromCondition(condition, null); Assert.That(result, Is.Null); @@ -563,8 +562,8 @@ public void TestGetNextMetricPeriodStartFromCondition() // Test for metric period is all time { - var condition = TestHelpers.CreateTestCondition(ConditionConditionType.Metric); - condition.MetricPeriod = ConditionMetricPeriod.AllTime; + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Metric); + condition.MetricPeriod = RulesengineConditionMetricPeriod.AllTime; var result = Metrics.GetNextMetricPeriodStartFromCondition(condition, null); Assert.That(result, Is.Null); } @@ -572,16 +571,16 @@ public void TestGetNextMetricPeriodStartFromCondition() // Test for metric period is current month with billing cycle reset { var company = TestHelpers.CreateTestCompany(); - company.Subscription = new Subscription + company.Subscription = new RulesengineSubscription { Id = "test-subscription", PeriodStart = DateTime.UtcNow.AddMonths(-1), PeriodEnd = DateTime.UtcNow.AddMonths(1) }; - var condition = TestHelpers.CreateTestCondition(ConditionConditionType.Metric); - condition.MetricPeriod = ConditionMetricPeriod.CurrentMonth; - condition.MetricPeriodMonthReset = ConditionMetricPeriodMonthReset.BillingCycle; + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Metric); + condition.MetricPeriod = RulesengineConditionMetricPeriod.CurrentMonth; + condition.MetricPeriodMonthReset = RulesengineConditionMetricPeriodMonthReset.BillingCycle; var result = Metrics.GetNextMetricPeriodStartFromCondition(condition, company); var expected = Metrics.GetNextMetricPeriodStartForCompanyBillingSubscription(company); @@ -592,15 +591,15 @@ public void TestGetNextMetricPeriodStartFromCondition() // Test for metric period is calendar-based { - var condition = TestHelpers.CreateTestCondition(ConditionConditionType.Metric); - condition.MetricPeriod = ConditionMetricPeriod.CurrentDay; - + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Metric); + condition.MetricPeriod = RulesengineConditionMetricPeriod.CurrentDay; + var result = Metrics.GetNextMetricPeriodStartFromCondition(condition, null); - var expected = Metrics.GetNextMetricPeriodStartForCalendarMetricPeriod(ConditionMetricPeriod.CurrentDay); + var expected = Metrics.GetNextMetricPeriodStartForCalendarMetricPeriod(RulesengineConditionMetricPeriod.CurrentDay); Assert.That(result, Is.Not.Null); Assert.That(result, Is.EqualTo(expected)); } } } -} \ No newline at end of file +} diff --git a/src/SchematicHQ.Client.Test/RulesEngine/RuleCheckTests.cs b/src/SchematicHQ.Client.Test/RulesEngine/RuleCheckTests.cs index 9eb2c9c1..fb60023c 100644 --- a/src/SchematicHQ.Client.Test/RulesEngine/RuleCheckTests.cs +++ b/src/SchematicHQ.Client.Test/RulesEngine/RuleCheckTests.cs @@ -1,6 +1,5 @@ using NUnit.Framework; using SchematicHQ.Client.RulesEngine; -using SchematicHQ.Client.RulesEngine.Models; using SchematicHQ.Client.RulesEngine.Utils; namespace SchematicHQ.Client.Test.RulesEngine @@ -15,7 +14,7 @@ public async Task Check_ReturnsTrue_ForGlobalOverrideRules() var svc = RuleCheckService.NewRuleCheckService(); var company = TestHelpers.CreateTestCompany(); var rule = TestHelpers.CreateTestRule(); - rule.RuleType = RuleRuleType.GlobalOverride; + rule.RuleType = RulesengineRuleRuleType.GlobalOverride; // Act var result = await svc.Check(new CheckScope @@ -55,7 +54,7 @@ public async Task Check_ReturnsTrue_ForDefaultRules() var svc = RuleCheckService.NewRuleCheckService(); var company = TestHelpers.CreateTestCompany(); var rule = TestHelpers.CreateTestRule(); - rule.RuleType = RuleRuleType.Default; + rule.RuleType = RulesengineRuleRuleType.Default; // Act var result = await svc.Check(new CheckScope @@ -78,9 +77,9 @@ public async Task Rule_Matches_SpecificCompany() var rule = TestHelpers.CreateTestRule(); // Create condition targeting the company - var condition = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); condition.ResourceIds = new List { company.Id }; - rule.Conditions = new List { condition }; + rule.Conditions = new List { condition }; // Act var result = await svc.Check(new CheckScope @@ -103,14 +102,14 @@ public async Task Rule_Matches_When_Metric_Within_Limit() string eventSubtype = "test-event"; var rule = TestHelpers.CreateTestRule(); - var condition = TestHelpers.CreateTestCondition(ConditionConditionType.Metric); + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Metric); condition.EventSubtype = eventSubtype; condition.MetricValue = 10; - condition.Operator = ConditionOperator.Lte; - rule.Conditions = new List { condition }; + condition.Operator = RulesengineConditionOperator.Lte; + rule.Conditions = new List { condition }; var metric = TestHelpers.CreateTestMetric(company, eventSubtype, condition.MetricPeriod!.Value, 5); - company.Metrics = new List { metric }; + company.Metrics = new List { metric }; // Act var result = await svc.Check(new CheckScope @@ -133,14 +132,14 @@ public async Task Rule_DoesNotMatch_When_Metric_Exceeds_Limit() string eventSubtype = "test-event"; var rule = TestHelpers.CreateTestRule(); - var condition = TestHelpers.CreateTestCondition(ConditionConditionType.Metric); + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Metric); condition.EventSubtype = eventSubtype; condition.MetricValue = 5; - condition.Operator = ConditionOperator.Lte; - rule.Conditions = new List { condition }; + condition.Operator = RulesengineConditionOperator.Lte; + rule.Conditions = new List { condition }; var metric = TestHelpers.CreateTestMetric(company, eventSubtype, condition.MetricPeriod!.Value, 6); // Value exceeds limit - company.Metrics = new List { metric }; + company.Metrics = new List { metric }; // Act var result = await svc.Check(new CheckScope @@ -161,14 +160,14 @@ public async Task Trait_Evaluation_MatchesWhenValueMatches() var svc = RuleCheckService.NewRuleCheckService(); var company = TestHelpers.CreateTestCompany(); var trait = TestHelpers.CreateTestTrait("test-value", null); - company.Traits.Add(trait); + company.Traits = new List { trait }; var rule = TestHelpers.CreateTestRule(); - var condition = TestHelpers.CreateTestCondition(ConditionConditionType.Trait); + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Trait); condition.TraitDefinition = trait.TraitDefinition; condition.TraitValue = "test-value"; - condition.Operator = ConditionOperator.Eq; - rule.Conditions = new List { condition }; + condition.Operator = RulesengineConditionOperator.Eq; + rule.Conditions = new List { condition }; // Act var result = await svc.Check(new CheckScope @@ -190,15 +189,15 @@ public async Task ConditionGroup_Matches_When_Any_Condition_Matches() var company = TestHelpers.CreateTestCompany(); var rule = TestHelpers.CreateTestRule(); - var condition1 = TestHelpers.CreateTestCondition(ConditionConditionType.Company); - var condition2 = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); + var condition2 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); condition2.ResourceIds = new List { company.Id }; - var group = new ConditionGroup + var group = new RulesengineConditionGroup { - Conditions = new List { condition1, condition2 } + Conditions = new List { condition1, condition2 } }; - rule.ConditionGroups = new List { group }; + rule.ConditionGroups = new List { group }; // Act var result = await svc.Check(new CheckScope @@ -220,15 +219,15 @@ public async Task ConditionGroup_DoesNotMatch_When_No_Conditions_Match() var company = TestHelpers.CreateTestCompany(); var rule = TestHelpers.CreateTestRule(); - var condition1 = TestHelpers.CreateTestCondition(ConditionConditionType.Company); - var condition2 = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); + var condition2 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); // No matching condition added to the group - var group = new ConditionGroup + var group = new RulesengineConditionGroup { - Conditions = new List { condition1, condition2 } + Conditions = new List { condition1, condition2 } }; - rule.ConditionGroups = new List { group }; + rule.ConditionGroups = new List { group }; // Act var result = await svc.Check(new CheckScope @@ -248,16 +247,16 @@ public async Task User_Trait_Evaluation() // Arrange var svc = RuleCheckService.NewRuleCheckService(); var user = TestHelpers.CreateTestUser(); - var traitDef = TestHelpers.CreateTestTraitDefinition(TraitDefinitionComparableType.String, EntityType.User); + var traitDef = TestHelpers.CreateTestTraitDefinition(RulesengineTraitDefinitionComparableType.String, RulesengineEntityType.User); var trait = TestHelpers.CreateTestTrait("user-trait-value", traitDef); - user.Traits.Add(trait); + user.Traits = new List { trait }; var rule = TestHelpers.CreateTestRule(); - var condition = TestHelpers.CreateTestCondition(ConditionConditionType.Trait); + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Trait); condition.TraitDefinition = traitDef; condition.TraitValue = "user-trait-value"; - condition.Operator = ConditionOperator.Eq; - rule.Conditions = new List { condition }; + condition.Operator = RulesengineConditionOperator.Eq; + rule.Conditions = new List { condition }; // Act var result = await svc.Check(new CheckScope @@ -278,21 +277,21 @@ public async Task Multiple_Conditions_All_Must_Match() var svc = RuleCheckService.NewRuleCheckService(); var company = TestHelpers.CreateTestCompany(); var trait = TestHelpers.CreateTestTrait("test-value", null); - company.Traits.Add(trait); + company.Traits = new List { trait }; var rule = TestHelpers.CreateTestRule(); - + // Company condition - var condition1 = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); condition1.ResourceIds = new List { company.Id }; - + // Trait condition - var condition2 = TestHelpers.CreateTestCondition(ConditionConditionType.Trait); + var condition2 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Trait); condition2.TraitDefinition = trait.TraitDefinition; condition2.TraitValue = "test-value"; - condition2.Operator = ConditionOperator.Eq; - - rule.Conditions = new List { condition1, condition2 }; + condition2.Operator = RulesengineConditionOperator.Eq; + + rule.Conditions = new List { condition1, condition2 }; // Act var result = await svc.Check(new CheckScope @@ -313,21 +312,21 @@ public async Task Multiple_Conditions_Fail_If_Any_Does_Not_Match() var svc = RuleCheckService.NewRuleCheckService(); var company = TestHelpers.CreateTestCompany(); var trait = TestHelpers.CreateTestTrait("test-value", null); - company.Traits.Add(trait); + company.Traits = new List { trait }; var rule = TestHelpers.CreateTestRule(); - + // Company condition - var condition1 = TestHelpers.CreateTestCondition(ConditionConditionType.Company); + var condition1 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Company); condition1.ResourceIds = new List { company.Id }; - + // Trait condition that doesn't match - var condition2 = TestHelpers.CreateTestCondition(ConditionConditionType.Trait); + var condition2 = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Trait); condition2.TraitDefinition = trait.TraitDefinition; condition2.TraitValue = "different-value"; - condition2.Operator = ConditionOperator.Eq; - - rule.Conditions = new List { condition1, condition2 }; + condition2.Operator = RulesengineConditionOperator.Eq; + + rule.Conditions = new List { condition1, condition2 }; // Act var result = await svc.Check(new CheckScope @@ -346,7 +345,7 @@ 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"; @@ -354,13 +353,13 @@ public async Task Rule_Matches_WithSufficientCreditBalance() { { 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(ConditionConditionType.Credit); + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Credit); condition.CreditId = creditId; condition.ConsumptionRate = 50.0; // Consumption rate less than the balance - rule.Conditions = new List { condition }; + rule.Conditions = new List { condition }; // Act var result = await svc.Check(new CheckScope @@ -379,7 +378,7 @@ 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"; @@ -387,13 +386,13 @@ public async Task Rule_DoesNotMatch_WithInsufficientCreditBalance() { { 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(ConditionConditionType.Credit); + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Credit); condition.CreditId = creditId; condition.ConsumptionRate = 50.0; // Consumption rate more than the balance - rule.Conditions = new List { condition }; + rule.Conditions = new List { condition }; // Act var result = await svc.Check(new CheckScope @@ -412,7 +411,7 @@ 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"; @@ -420,13 +419,13 @@ public async Task Rule_Matches_WithDefaultConsumptionRate() { { 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(ConditionConditionType.Credit); + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Credit); condition.CreditId = creditId; condition.ConsumptionRate = null; // Default consumption rate is 1.0 - rule.Conditions = new List { condition }; + rule.Conditions = new List { condition }; // Act var result = await svc.Check(new CheckScope @@ -445,20 +444,20 @@ 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(ConditionConditionType.Credit); + var condition = TestHelpers.CreateTestCondition(RulesengineConditionConditionType.Credit); condition.CreditId = "non-existent-credit-id"; // Different from the one in company condition.ConsumptionRate = 1.0; - rule.Conditions = new List { condition }; + rule.Conditions = new List { condition }; // Act var result = await svc.Check(new CheckScope @@ -472,4 +471,4 @@ public async Task Rule_DoesNotMatch_WithNonExistentCreditId() Assert.That(result.Match, Is.False); } } -} \ No newline at end of file +} diff --git a/src/SchematicHQ.Client.Test/TestClient.cs b/src/SchematicHQ.Client.Test/TestClient.cs index e018c023..3ec6e3d3 100644 --- a/src/SchematicHQ.Client.Test/TestClient.cs +++ b/src/SchematicHQ.Client.Test/TestClient.cs @@ -7,6 +7,7 @@ using OneOf; using SchematicHQ.Client.Core; using SchematicHQ.Client.Cache; +using SchematicHQ.Client.RulesEngine; namespace SchematicHQ.Client.Test { @@ -36,6 +37,30 @@ private HttpResponseMessage CreateCheckFlagResponse(HttpStatusCode code, bool fl }; } + private HttpResponseMessage CreateCheckFlagResponseWithEntitlement(HttpStatusCode code, bool flagValue, FeatureEntitlement? entitlement = null, string? companyId = null, string? userId = null, string? flagId = null, string? ruleId = null, string? ruleType = null, string? reason = null) + { + var response = new CheckFlagResponse + { + Data = new CheckFlagResponseData + { + Flag = "test_flag", + Reason = reason ?? "matched entitlement rule", + Value = flagValue, + Entitlement = entitlement, + CompanyId = companyId, + UserId = userId, + FlagId = flagId, + RuleId = ruleId, + RuleType = ruleType + } + }; + var serializedResponse = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true }); + return new HttpResponseMessage(code) + { + Content = new StringContent(serializedResponse, Encoding.UTF8, "application/json") + }; + } + private HttpResponseMessage CreateEventBatchResponse(HttpStatusCode code) { var response = new CreateEventBatchResponse{ @@ -59,7 +84,7 @@ private void SetupSchematicTestClient(bool isOffline, HttpResponseMessage respon Offline = isOffline, FlagDefaults = flagDefaults ?? new Dictionary(), DefaultEventBufferPeriod = TimeSpan.FromSeconds(_defaultEventBufferPeriod), - CacheProviders = new List> { new LocalCache() } + CacheProviders = new List> { new LocalCache() } }; if (!_options.Offline) @@ -98,7 +123,9 @@ public async Task CheckFlag_CachesResultIfNotCached() Assert.That(result, Is.True); foreach (var cacheProvider in _options.CacheProviders) { - Assert.That(cacheProvider.Get(flagKey), Is.EqualTo(true)); + var cached = cacheProvider.Get(flagKey); + Assert.That(cached, Is.Not.Null); + Assert.That(cached!.Value, Is.True); } } @@ -111,7 +138,7 @@ public async Task CheckFlag_StoreCorrectCacheKey() string cacheKey = "test_flag:c-name=test_company:u-id=unique_id"; foreach (var cacheProvider in _options.CacheProviders) { - cacheProvider.Set(cacheKey, true); + cacheProvider.Set(cacheKey, new CheckFlagWithEntitlementResponse { FlagKey = flagKey, Value = true, Reason = "cache" }); } // Act @@ -133,7 +160,7 @@ public async Task CheckFlag_ReturnsCachedValueIfExists() string flagKey = "test_flag"; foreach (var cacheProvider in _options.CacheProviders) { - cacheProvider.Set(flagKey, true); + cacheProvider.Set(flagKey, new CheckFlagWithEntitlementResponse { FlagKey = flagKey, Value = true, Reason = "cache" }); } // Act @@ -264,5 +291,131 @@ public async Task CheckFlag_OfflineModeReturnsDefault() // Assert Assert.That(result, Is.True); } + + [Test] + public async Task CheckFlagWithEntitlement_ReturnsResponseWithEntitlement() + { + // Arrange + var entitlement = new FeatureEntitlement + { + FeatureId = "feat_123", + FeatureKey = "test_feature", + ValueType = EntitlementValueType.Numeric, + Allocation = 100, + Usage = 50 + }; + SetupSchematicTestClient( + isOffline: false, + response: CreateCheckFlagResponseWithEntitlement( + HttpStatusCode.OK, + true, + entitlement: entitlement, + companyId: "comp_123", + flagId: "flag_123", + ruleId: "rule_123", + ruleType: "entitlement" + ) + ); + + // Act + var result = await _schematic.CheckFlagWithEntitlement("test_flag"); + + // Assert + Assert.That(result.Value, Is.True); + Assert.That(result.FlagKey, Is.EqualTo("test_flag")); + Assert.That(result.Reason, Is.EqualTo("matched entitlement rule")); + Assert.That(result.CompanyId, Is.EqualTo("comp_123")); + Assert.That(result.FlagId, Is.EqualTo("flag_123")); + Assert.That(result.RuleId, Is.EqualTo("rule_123")); + Assert.That(result.RuleType, Is.EqualTo("entitlement")); + Assert.That(result.Entitlement, Is.Not.Null); + Assert.That(result.Entitlement!.FeatureId, Is.EqualTo("feat_123")); + Assert.That(result.Entitlement.FeatureKey, Is.EqualTo("test_feature")); + Assert.That(result.Entitlement.Allocation, Is.EqualTo(100)); + Assert.That(result.Entitlement.Usage, Is.EqualTo(50)); + } + + [Test] + public async Task CheckFlagWithEntitlement_OfflineModeReturnsDefault() + { + // Arrange + SetupSchematicTestClient( + isOffline: true, + response: CreateCheckFlagResponse(HttpStatusCode.OK, false), + flagDefaults: new Dictionary { { "test_flag_key", true } } + ); + + // Act + var result = await _schematic.CheckFlagWithEntitlement("test_flag_key"); + + // Assert + Assert.That(result.Value, Is.True); + Assert.That(result.FlagKey, Is.EqualTo("test_flag_key")); + Assert.That(result.Reason, Is.EqualTo("offline mode")); + Assert.That(result.Entitlement, Is.Null); + } + + [Test] + public async Task CheckFlagWithEntitlement_ReturnsResponseWithNoEntitlement() + { + // Arrange + SetupSchematicTestClient( + isOffline: false, + response: CreateCheckFlagResponseWithEntitlement(HttpStatusCode.OK, false) + ); + + // Act + var result = await _schematic.CheckFlagWithEntitlement("test_flag"); + + // Assert + Assert.That(result.Value, Is.False); + Assert.That(result.FlagKey, Is.EqualTo("test_flag")); + Assert.That(result.Entitlement, Is.Null); + } + + [Test] + public async Task CheckFlagWithEntitlement_ReturnsDefaultOnError() + { + // Arrange + string flagKey = "error_flag"; + SetupSchematicTestClient( + isOffline: false, + response: CreateCheckFlagResponse(HttpStatusCode.InternalServerError, false), + flagDefaults: new Dictionary { { flagKey, true } } + ); + + // Act + var result = await _schematic.CheckFlagWithEntitlement(flagKey); + + // Assert + Assert.That(result.Value, Is.True); + Assert.That(result.FlagKey, Is.EqualTo(flagKey)); + Assert.That(result.Entitlement, Is.Null); + } + + [Test] + public async Task CheckFlagWithEntitlement_CachesAndReturnsCachedResponse() + { + // Arrange + SetupSchematicTestClient( + isOffline: false, + response: CreateCheckFlagResponseWithEntitlement(HttpStatusCode.OK, true) + ); + string flagKey = "test_flag"; + + // First call should hit the API and cache + var result1 = await _schematic.CheckFlagWithEntitlement(flagKey); + Assert.That(result1.Value, Is.True); + + // Verify cache was populated with full response + foreach (var cacheProvider in _options.CacheProviders) + { + var cached = cacheProvider.Get(flagKey); + Assert.That(cached, Is.Not.Null); + Assert.That(cached!.Value, Is.True); + Assert.That(cached.FlagKey, Is.EqualTo(flagKey)); + Assert.That(cached.Reason, Is.EqualTo("matched entitlement rule")); + } + } } } diff --git a/src/SchematicHQ.Client/Cache/CacheConfiguration.cs b/src/SchematicHQ.Client/Cache/CacheConfiguration.cs index 5104707e..2123a276 100644 --- a/src/SchematicHQ.Client/Cache/CacheConfiguration.cs +++ b/src/SchematicHQ.Client/Cache/CacheConfiguration.cs @@ -1,3 +1,5 @@ +using SchematicHQ.Client.RulesEngine; + #nullable enable namespace SchematicHQ.Client.Cache @@ -41,6 +43,6 @@ public class CacheConfiguration /// /// Cache capacity for local cache /// - public int LocalCacheCapacity { get; set; } = LocalCache.DEFAULT_CACHE_CAPACITY; + public int LocalCacheCapacity { get; set; } = LocalCache.DEFAULT_CACHE_CAPACITY; } } diff --git a/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs b/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs index 3c1f7b0f..08ba8fbe 100644 --- a/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs +++ b/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs @@ -1,6 +1,7 @@ using System.Net.Http; using SchematicHQ.Client.Core; using SchematicHQ.Client.Cache; +using SchematicHQ.Client.RulesEngine; #nullable enable @@ -10,7 +11,7 @@ public partial class ClientOptions { public Dictionary FlagDefaults { get; set; } = new Dictionary(); public ISchematicLogger Logger { get; set; } = new ConsoleLogger(); - public List> CacheProviders { get; set; } = new List>(); + public List> CacheProviders { get; set; } = new List>(); public CacheConfiguration? CacheConfiguration { get; set; } public bool Offline { get; set; } @@ -99,7 +100,7 @@ public static ClientOptions WithRedisCache( /// Updated client options public static ClientOptions WithLocalCache( this ClientOptions options, - int capacity = Cache.LocalCache.DEFAULT_CACHE_CAPACITY, + int capacity = Cache.LocalCache.DEFAULT_CACHE_CAPACITY, TimeSpan? ttl = null) { options.CacheConfiguration = new Cache.CacheConfiguration diff --git a/src/SchematicHQ.Client/Datastream/Client.cs b/src/SchematicHQ.Client/Datastream/Client.cs index 0b258d5d..08f09aad 100644 --- a/src/SchematicHQ.Client/Datastream/Client.cs +++ b/src/SchematicHQ.Client/Datastream/Client.cs @@ -7,7 +7,6 @@ using Microsoft.Extensions.Options; using OneOf.Types; using SchematicHQ.Client.RulesEngine; -using SchematicHQ.Client.RulesEngine.Models; using SchematicHQ.Client.RulesEngine.Utils; using SchematicHQ.Client; using SchematicHQ.Client.Cache; @@ -29,9 +28,9 @@ public class DatastreamClient : IDisposable private CancellationTokenSource _readCancellationSource = new CancellationTokenSource(); // Cache providers - private readonly ICacheProvider _flagsCache; - private readonly ICacheProvider _companyCache; - private readonly ICacheProvider _userCache; + private readonly ICacheProvider _flagsCache; + private readonly ICacheProvider _companyCache; + private readonly ICacheProvider _userCache; private readonly ICacheProvider _companyLookupCache; private readonly ICacheProvider _userLookupCache; @@ -39,8 +38,8 @@ public class DatastreamClient : IDisposable private readonly Func? _cacheVersionProvider; // Pending request tracking - private readonly Dictionary>> _pendingCompanyRequests = new Dictionary>>(); - private readonly Dictionary>> _pendingUserRequests = new Dictionary>>(); + private readonly Dictionary>> _pendingCompanyRequests = new Dictionary>>(); + private readonly Dictionary>> _pendingUserRequests = new Dictionary>>(); private TaskCompletionSource? _pendingFlagRequest; private readonly object _pendingRequestsLock = new object(); @@ -115,32 +114,32 @@ public DatastreamClient( { _logger.Info("Initializing Redis cache for Datastream company, user and flag data"); // We need to use the Cache namespace version, but cast it to the Client namespace interface - _companyCache = new RedisCache(options.RedisConfig); - _userCache = new RedisCache(options.RedisConfig); + _companyCache = new RedisCache(options.RedisConfig); + _userCache = new RedisCache(options.RedisConfig); _companyLookupCache = new RedisCache(options.RedisConfig); _userLookupCache = new RedisCache(options.RedisConfig); var flagConfig = options.RedisConfig; flagConfig.CacheTTL = flagTTL; // Set TTL for flags cache - _flagsCache = new RedisCache(flagConfig); + _flagsCache = new RedisCache(flagConfig); } catch (Exception ex) { _logger.Error("Failed to initialize Redis cache: {0}. Falling back to local cache.", ex.Message); - _companyCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); - _userCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); + _companyCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); + _userCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); _companyLookupCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); _userLookupCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); - _flagsCache = new LocalCache(options.LocalCacheCapacity, flagTTL); + _flagsCache = new LocalCache(options.LocalCacheCapacity, flagTTL); } } else { // Use local cache (default) - _companyCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); - _userCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); + _companyCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); + _userCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); _companyLookupCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); _userLookupCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); - _flagsCache = new LocalCache(options.LocalCacheCapacity, flagTTL); + _flagsCache = new LocalCache(options.LocalCacheCapacity, flagTTL); } _webSocket = webSocket ?? new StandardWebSocketClient(); @@ -476,7 +475,7 @@ private void HandleFlagsMessage(DataStreamResponse response) }; var jsonString = response.Data.ToString() ?? string.Empty; - var flags = JsonSerializer.Deserialize>(jsonString, options); + var flags = JsonSerializer.Deserialize>(jsonString, options); var cacheKeys = new List(); if (flags == null || flags.Count == 0) @@ -570,7 +569,7 @@ private void HandleFlagMessage(DataStreamResponse response) } // Handle single flag creation/update - var flag = JsonSerializer.Deserialize(jsonString, options); + var flag = JsonSerializer.Deserialize(jsonString, options); if (flag == null) { @@ -644,7 +643,7 @@ private async void HandleCompanyMessage(DataStreamResponse response) }; var jsonString = response.Data.ToString() ?? string.Empty; - var company = JsonSerializer.Deserialize(jsonString, options); + var company = JsonSerializer.Deserialize(jsonString, options); if (company == null) { @@ -673,7 +672,7 @@ private async void HandleCompanyMessage(DataStreamResponse response) _companyCache.Delete(idKey); foreach (var key in company.Keys) { - var resourceKey = ResourceKeyToCacheKey(CacheKeyPrefixCompany, key.Key, key.Value); + var resourceKey = ResourceKeyToCacheKey(CacheKeyPrefixCompany, key.Key, key.Value); _companyLookupCache.Delete(resourceKey); } @@ -722,7 +721,7 @@ private void HandleUserMessage(DataStreamResponse response) } }; var jsonString = response.Data.ToString() ?? string.Empty; - var user = JsonSerializer.Deserialize(jsonString, options); + var user = JsonSerializer.Deserialize(jsonString, options); if (user == null) { @@ -737,7 +736,7 @@ private void HandleUserMessage(DataStreamResponse response) _userCache.Delete(idKey); foreach (var key in user.Keys) { - var resourceKey = ResourceKeyToCacheKey(CacheKeyPrefixUser, key.Key, key.Value); + var resourceKey = ResourceKeyToCacheKey(CacheKeyPrefixUser, key.Key, key.Value); _userLookupCache.Delete(resourceKey); _logger.Debug("Deleted user from cache with key: {0}", resourceKey); } @@ -782,10 +781,10 @@ private void HandleErrorMessage(DataStreamResponse response) switch (error.EntityType.Value) { case EntityType.Company: - NotifyPendingRequests(null, error.Keys, CacheKeyPrefixCompany, _pendingCompanyRequests); + NotifyPendingRequests(null, error.Keys, CacheKeyPrefixCompany, _pendingCompanyRequests); break; case EntityType.User: - NotifyPendingRequests(null, error.Keys, CacheKeyPrefixUser, _pendingUserRequests); + NotifyPendingRequests(null, error.Keys, CacheKeyPrefixUser, _pendingUserRequests); break; default: _logger.Warn("Received error for unsupported entity type: {0}", error.EntityType.Value); @@ -800,7 +799,7 @@ private void HandleErrorMessage(DataStreamResponse response) } } - internal async Task CheckFlag(Company? company, User? user, Flag flag, CancellationToken cancellationToken = default) + internal async Task CheckFlag(RulesengineCompany? company, RulesengineUser? user, RulesengineFlag flag, CancellationToken cancellationToken = default) { try { @@ -820,10 +819,10 @@ internal async Task CheckFlag(Company? company, User? user, Fla } } - internal async Task GetCompanyAsync(Dictionary keys, CancellationToken cancellationToken) + internal async Task GetCompanyAsync(Dictionary keys, CancellationToken cancellationToken) { - var waitTask = new TaskCompletionSource(); + var waitTask = new TaskCompletionSource(); var cacheKeys = new List(); bool shouldSendRequest = true; @@ -831,7 +830,7 @@ internal async Task CheckFlag(Company? company, User? user, Fla { foreach (var key in keys) { - var cacheKey = ResourceKeyToCacheKey(CacheKeyPrefixCompany, key.Key, key.Value); + var cacheKey = ResourceKeyToCacheKey(CacheKeyPrefixCompany, key.Key, key.Value); cacheKeys.Add(cacheKey); if (_pendingCompanyRequests.TryGetValue(cacheKey, out var existingChannels)) @@ -841,7 +840,7 @@ internal async Task CheckFlag(Company? company, User? user, Fla } else { - _pendingCompanyRequests[cacheKey] = new List> { waitTask }; + _pendingCompanyRequests[cacheKey] = new List> { waitTask }; } } } @@ -882,11 +881,11 @@ internal async Task CheckFlag(Company? company, User? user, Fla } } - internal async Task GetUserAsync(Dictionary keys, CancellationToken cancellationToken) + internal async Task GetUserAsync(Dictionary keys, CancellationToken cancellationToken) { - var waitTask = new TaskCompletionSource(); + var waitTask = new TaskCompletionSource(); var cacheKeys = new List(); bool shouldSendRequest = true; @@ -894,7 +893,7 @@ internal async Task CheckFlag(Company? company, User? user, Fla { foreach (var key in keys) { - var cacheKey = ResourceKeyToCacheKey(CacheKeyPrefixUser, key.Key, key.Value); + var cacheKey = ResourceKeyToCacheKey(CacheKeyPrefixUser, key.Key, key.Value); cacheKeys.Add(cacheKey); if (_pendingUserRequests.TryGetValue(cacheKey, out var existingChannels)) @@ -904,7 +903,7 @@ internal async Task CheckFlag(Company? company, User? user, Fla } else { - _pendingUserRequests[cacheKey] = new List> { waitTask }; + _pendingUserRequests[cacheKey] = new List> { waitTask }; } } } @@ -999,17 +998,17 @@ private async Task GetAllFlagsAsync(CancellationToken cancellationToken) } } - internal Flag? GetFlag(string key) + internal RulesengineFlag? GetFlag(string key) { var flag = _flagsCache.Get(FlagCacheKey(key)); return flag; } - internal Company? GetCompanyFromCache(Dictionary keys) + internal RulesengineCompany? GetCompanyFromCache(Dictionary keys) { foreach (var key in keys) { - var resourceKey = ResourceKeyToCacheKey(CacheKeyPrefixCompany, key.Key, key.Value); + var resourceKey = ResourceKeyToCacheKey(CacheKeyPrefixCompany, key.Key, key.Value); var companyId = _companyLookupCache.Get(resourceKey); if (companyId != null) { @@ -1024,11 +1023,11 @@ private async Task GetAllFlagsAsync(CancellationToken cancellationToken) return null; } - internal User? GetUserFromCache(Dictionary keys) + internal RulesengineUser? GetUserFromCache(Dictionary keys) { foreach (var key in keys) { - var resourceKey = ResourceKeyToCacheKey(CacheKeyPrefixUser, key.Key, key.Value); + var resourceKey = ResourceKeyToCacheKey(CacheKeyPrefixUser, key.Key, key.Value); var userId = _userLookupCache.Get(resourceKey); if (userId != null) { @@ -1162,7 +1161,7 @@ public bool UpdateCompanyMetrics(EventBodyTrack eventBody) /// /// The company to copy /// A new independent copy of the company - private Company? DeepCopyCompany(Company? company) + private RulesengineCompany? DeepCopyCompany(RulesengineCompany? company) { if (company == null) { @@ -1170,29 +1169,14 @@ public bool UpdateCompanyMetrics(EventBodyTrack eventBody) } // Create a new company instance - var companyCopy = new Company - { - Id = company.Id, - AccountId = company.AccountId, - EnvironmentId = company.EnvironmentId, - BasePlanId = company.BasePlanId, - BillingProductIds = new List(company.BillingProductIds), - PlanIds = new List(company.PlanIds), - Subscription = company.Subscription != null ? new Subscription - { - Id = company.Subscription.Id, - PeriodStart = company.Subscription.PeriodStart, - PeriodEnd = company.Subscription.PeriodEnd - } : null, - Keys = new Dictionary(), - Metrics = new List(), - Traits = new List() - }; + var metricsCopy = new List(); + var traitsCopy = new List(); + var keysCopy = new Dictionary(); // Copy the keys dictionary foreach (var key in company.Keys) { - companyCopy.Keys[key.Key] = key.Value; + keysCopy[key.Key] = key.Value; } // Deep copy metrics @@ -1204,7 +1188,7 @@ public bool UpdateCompanyMetrics(EventBodyTrack eventBody) continue; } - var metricCopy = new CompanyMetric + var metricCopy = new RulesengineCompanyMetric { AccountId = metric.AccountId, EnvironmentId = metric.EnvironmentId, @@ -1217,7 +1201,7 @@ public bool UpdateCompanyMetrics(EventBodyTrack eventBody) ValidUntil = metric.ValidUntil }; - companyCopy.Metrics.Add(metricCopy); + metricsCopy.Add(metricCopy); } // Copy traits @@ -1228,22 +1212,41 @@ public bool UpdateCompanyMetrics(EventBodyTrack eventBody) // Skip null traits continue; } - + // Create a new trait instance - var traitCopy = new Trait + var traitCopy = new RulesengineTrait { Value = trait.Value, TraitDefinition = trait.TraitDefinition }; - - companyCopy.Traits.Add(traitCopy); + + traitsCopy.Add(traitCopy); } + var companyCopy = new RulesengineCompany + { + Id = company.Id, + AccountId = company.AccountId, + EnvironmentId = company.EnvironmentId, + BasePlanId = company.BasePlanId, + BillingProductIds = new List(company.BillingProductIds), + PlanIds = new List(company.PlanIds), + Subscription = company.Subscription != null ? new RulesengineSubscription + { + Id = company.Subscription.Id, + PeriodStart = company.Subscription.PeriodStart, + PeriodEnd = company.Subscription.PeriodEnd + } : null, + Keys = keysCopy, + Metrics = metricsCopy, + Traits = traitsCopy + }; + return companyCopy; } - private void CleanupPendingCompanyRequests(List cacheKeys, TaskCompletionSource waitTask) + private void CleanupPendingCompanyRequests(List cacheKeys, TaskCompletionSource waitTask) { lock (_pendingRequestsLock) { @@ -1261,7 +1264,7 @@ private void CleanupPendingCompanyRequests(List cacheKeys, TaskCompletio } } - private void CleanupPendingUserRequests(List cacheKeys, TaskCompletionSource waitTask) + private void CleanupPendingUserRequests(List cacheKeys, TaskCompletionSource waitTask) { lock (_pendingRequestsLock) { @@ -1349,7 +1352,7 @@ private string UserIdCacheKey(string id) return $"{CacheKeyPrefix}:{CacheKeyPrefixUser}:{schemaVersion}:{id}"; } - private void CacheCompanyForKeys(Company company) + private void CacheCompanyForKeys(RulesengineCompany company) { // Store the company object at the ID-based key var idKey = CompanyIdCacheKey(company.Id); @@ -1358,12 +1361,12 @@ private void CacheCompanyForKeys(Company company) // Store the company ID string at each resource key foreach (var key in company.Keys) { - var resourceKey = ResourceKeyToCacheKey(CacheKeyPrefixCompany, key.Key, key.Value); + var resourceKey = ResourceKeyToCacheKey(CacheKeyPrefixCompany, key.Key, key.Value); _companyLookupCache.Set(resourceKey, company.Id); } } - private void CacheUserForKeys(User user) + private void CacheUserForKeys(RulesengineUser user) { // Store the user object at the ID-based key var idKey = UserIdCacheKey(user.Id); @@ -1372,7 +1375,7 @@ private void CacheUserForKeys(User user) // Store the user ID string at each resource key foreach (var key in user.Keys) { - var resourceKey = ResourceKeyToCacheKey(CacheKeyPrefixUser, key.Key, key.Value); + var resourceKey = ResourceKeyToCacheKey(CacheKeyPrefixUser, key.Key, key.Value); _userLookupCache.Set(resourceKey, user.Id); } } diff --git a/src/SchematicHQ.Client/Datastream/DatastreamClientAdapter.cs b/src/SchematicHQ.Client/Datastream/DatastreamClientAdapter.cs index 4735d32a..477986dc 100644 --- a/src/SchematicHQ.Client/Datastream/DatastreamClientAdapter.cs +++ b/src/SchematicHQ.Client/Datastream/DatastreamClientAdapter.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.Logging; using SchematicHQ.Client.RulesEngine; -using SchematicHQ.Client.RulesEngine.Models; using System; using System.Collections.Generic; using System.Threading; @@ -202,8 +201,8 @@ public async Task CheckFlag(CheckFlagRequestBody request, strin // Always try to get cached resources first var cachedFlag = _client.GetFlag(flagKey); - Company? cachedCompany = null; - User? cachedUser = null; + RulesengineCompany? cachedCompany = null; + RulesengineUser? cachedUser = null; if (needsCompany && request.Company != null) { @@ -299,8 +298,8 @@ public async Task CheckFlag(CheckFlagRequestBody request, strin // Fetch missing company/user data from datastream try { - Company? company = cachedCompany; - User? user = cachedUser; + RulesengineCompany? company = cachedCompany; + RulesengineUser? user = cachedUser; if (needsCompany && company == null && request.Company != null) { diff --git a/src/SchematicHQ.Client/Datastream/Types.cs b/src/SchematicHQ.Client/Datastream/Types.cs index 7fda8b48..d275068d 100644 --- a/src/SchematicHQ.Client/Datastream/Types.cs +++ b/src/SchematicHQ.Client/Datastream/Types.cs @@ -1,7 +1,6 @@ using System.Text.Json.Serialization; using System.Text.Json; using System.Runtime.Serialization; -using SchematicHQ.Client.RulesEngine.Models; namespace SchematicHQ.Client.Datastream { diff --git a/src/SchematicHQ.Client/RulesEngine/Consts.cs b/src/SchematicHQ.Client/RulesEngine/Consts.cs index 42cbfa87..cfebbfa5 100644 --- a/src/SchematicHQ.Client/RulesEngine/Consts.cs +++ b/src/SchematicHQ.Client/RulesEngine/Consts.cs @@ -3,35 +3,32 @@ namespace SchematicHQ.Client.RulesEngine { - // Note: All enum types now use the generated types from SchematicHQ.Client namespace - // (RuleRuleType, ConditionConditionType, ConditionMetricPeriod, etc.) - - public static class RuleRuleTypeExtensions + public static class RulesengineRuleRuleTypeExtensions { - public static string DisplayName(this RuleRuleType ruleType) + public static string DisplayName(this RulesengineRuleRuleType ruleType) { return ruleType.Value.Replace("_", " "); } - public static bool IsEntitlement(this RuleRuleType ruleType) + public static bool IsEntitlement(this RulesengineRuleRuleType ruleType) { - return ruleType == RuleRuleType.PlanEntitlement.Value || - ruleType == RuleRuleType.PlanEntitlementUsageExceeded.Value || - ruleType == RuleRuleType.CompanyOverride.Value || - ruleType == RuleRuleType.CompanyOverrideUsageExceeded.Value; + return ruleType == RulesengineRuleRuleType.PlanEntitlement.Value || + ruleType == RulesengineRuleRuleType.PlanEntitlementUsageExceeded.Value || + ruleType == RulesengineRuleRuleType.CompanyOverride.Value || + ruleType == RulesengineRuleRuleType.CompanyOverrideUsageExceeded.Value; } - public static RulePrioritizationMethod PrioritizationMethod(this RuleRuleType ruleType) + public static RulePrioritizationMethod PrioritizationMethod(this RulesengineRuleRuleType ruleType) { - if (ruleType == RuleRuleType.Standard.Value) + if (ruleType == RulesengineRuleRuleType.Standard.Value) { return RulePrioritizationMethod.Priority; } - if (ruleType == RuleRuleType.CompanyOverride.Value || - ruleType == RuleRuleType.PlanEntitlement.Value || - ruleType == RuleRuleType.CompanyOverrideUsageExceeded.Value || - ruleType == RuleRuleType.PlanEntitlementUsageExceeded.Value) + if (ruleType == RulesengineRuleRuleType.CompanyOverride.Value || + ruleType == RulesengineRuleRuleType.PlanEntitlement.Value || + ruleType == RulesengineRuleRuleType.CompanyOverrideUsageExceeded.Value || + ruleType == RulesengineRuleRuleType.PlanEntitlementUsageExceeded.Value) { return RulePrioritizationMethod.Optimistic; } @@ -39,15 +36,15 @@ public static RulePrioritizationMethod PrioritizationMethod(this RuleRuleType ru return RulePrioritizationMethod.None; } - public static List RuleTypePriority = new List + public static List RuleTypePriority = new List { - RuleRuleType.GlobalOverride, - RuleRuleType.CompanyOverride, - RuleRuleType.PlanEntitlement, - RuleRuleType.CompanyOverrideUsageExceeded, - RuleRuleType.PlanEntitlementUsageExceeded, - RuleRuleType.Standard, - RuleRuleType.Default + RulesengineRuleRuleType.GlobalOverride, + RulesengineRuleRuleType.CompanyOverride, + RulesengineRuleRuleType.PlanEntitlement, + RulesengineRuleRuleType.CompanyOverrideUsageExceeded, + RulesengineRuleRuleType.PlanEntitlementUsageExceeded, + RulesengineRuleRuleType.Standard, + RulesengineRuleRuleType.Default }; } @@ -63,4 +60,4 @@ public enum RulePrioritizationMethod [JsonPropertyName("optimistic")] Optimistic } -} \ No newline at end of file +} diff --git a/src/SchematicHQ.Client/RulesEngine/FlagCheck.cs b/src/SchematicHQ.Client/RulesEngine/FlagCheck.cs index 6043d614..f71f65f2 100644 --- a/src/SchematicHQ.Client/RulesEngine/FlagCheck.cs +++ b/src/SchematicHQ.Client/RulesEngine/FlagCheck.cs @@ -1,4 +1,3 @@ -using SchematicHQ.Client.RulesEngine.Models; using SchematicHQ.Client.RulesEngine.Utils; namespace SchematicHQ.Client.RulesEngine @@ -6,20 +5,22 @@ 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 ConditionMetricPeriod? FeatureUsagePeriod { get; set; } + public string? FeatureUsageEvent { get; set; } + public RulesengineConditionMetricPeriod? 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 RuleRuleType? RuleType { get; set; } + public RulesengineRuleRuleType? RuleType { get; set; } public string? UserId { get; set; } public bool Value { get; set; } - public void SetRuleFields(Models.Company? company, Rule? rule) + public void SetRuleFields(RulesengineCompany? company, RulesengineRule? rule) { if (rule == null) { @@ -44,8 +45,8 @@ public void SetRuleFields(Models.Company? company, Rule? rule) // 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 == ConditionConditionType.Metric || c.ConditionType == ConditionConditionType.Trait)); - + c != null && (c.ConditionType == RulesengineConditionConditionType.Metric || c.ConditionType == RulesengineConditionConditionType.Trait)); + if (usageCondition == null) { return; @@ -55,11 +56,13 @@ public void SetRuleFields(Models.Company? company, Rule? rule) long usage = 0; long allocation = 0; - if (usageCondition.ConditionType == ConditionConditionType.Metric) + if (usageCondition.ConditionType == RulesengineConditionConditionType.Metric) { + FeatureUsageEvent = usageCondition.EventSubtype; + if (!string.IsNullOrEmpty(usageCondition.EventSubtype)) { - var usageMetric = Models.CompanyMetric.Find( + var usageMetric = CompanyMetricExtensions.Find( company.Metrics, usageCondition.EventSubtype, usageCondition.MetricPeriod, @@ -82,7 +85,7 @@ public void SetRuleFields(Models.Company? company, Rule? rule) FeatureUsagePeriod = metricPeriod; FeatureUsageResetAt = Metrics.GetNextMetricPeriodStartFromCondition(usageCondition, company); } - else if (usageCondition.ConditionType == ConditionConditionType.Trait) + else if (usageCondition.ConditionType == RulesengineConditionConditionType.Trait) { if (usageCondition.TraitDefinition != null) { @@ -128,9 +131,9 @@ public static class FlagCheckService public const string ReasonUserNotFound = "User not found"; public static async Task CheckFlag( - Models.Company? company, - Models.User? user, - Models.Flag? flag, + RulesengineCompany? company, + RulesengineUser? user, + RulesengineFlag? flag, CancellationToken cancellationToken = default) { var resp = new CheckFlagResult @@ -153,6 +156,11 @@ public static async Task CheckFlag( 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) @@ -169,7 +177,7 @@ public static async Task CheckFlag( .ToList(); var ruleChecker = RuleCheckService.NewRuleCheckService(); - foreach (var group in GroupRulesByPriority(flag.Rules, companyRules, userRules)) + foreach (var group in GroupRulesByPriority(flag.Rules.ToList(), companyRules, userRules)) { foreach (var rule in group) { @@ -212,9 +220,9 @@ public static async Task CheckFlag( return resp; } - public static List> GroupRulesByPriority(params List?[] ruleSlices) + public static List> GroupRulesByPriority(params List?[] ruleSlices) { - var allRules = new List(); + var allRules = new List(); foreach (var rules in ruleSlices) { if (rules != null && rules.Count > 0) @@ -225,7 +233,7 @@ public static List> GroupRulesByPriority(params List?[] ruleSli if (allRules.Count == 0) { - return new List>(); + return new List>(); } // Group rules by their type (convert to internal enum for grouping) @@ -248,8 +256,8 @@ public static List> GroupRulesByPriority(params List?[] ruleSli } // Prioritize type groups relative to one another - var prioritizedGroups = new List>(); - foreach (var ruleType in RuleRuleTypeExtensions.RuleTypePriority) + var prioritizedGroups = new List>(); + foreach (var ruleType in RulesengineRuleRuleTypeExtensions.RuleTypePriority) { if (grouped.TryGetValue(ruleType, out var rules2)) { @@ -260,4 +268,125 @@ public static List> GroupRulesByPriority(params List?[] ruleSli return prioritizedGroups; } } -} \ No newline at end of file + + /// + /// 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, + 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 RulesengineFeatureEntitlementMetricPeriod(entitlement.MetricPeriod.Value.Value) + : null, + MetricResetAt = entitlement.MetricResetAt, + MonthReset = entitlement.MonthReset.HasValue + ? new RulesengineFeatureEntitlementMonthReset(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 index 28679f3b..cafb647d 100644 --- a/src/SchematicHQ.Client/RulesEngine/Metrics.cs +++ b/src/SchematicHQ.Client/RulesEngine/Metrics.cs @@ -1,28 +1,26 @@ -using SchematicHQ.Client.RulesEngine.Models; - namespace SchematicHQ.Client.RulesEngine { public static class Metrics { - public static DateTime? GetCurrentMetricPeriodStartForCalendarMetricPeriod(ConditionMetricPeriod? metricPeriod) + public static DateTime? GetCurrentMetricPeriodStartForCalendarMetricPeriod(RulesengineConditionMetricPeriod? metricPeriod) { if (metricPeriod == null) return null; var now = DateTime.UtcNow; - if (metricPeriod == ConditionMetricPeriod.CurrentDay) + if (metricPeriod == RulesengineConditionMetricPeriod.CurrentDay) // UTC midnight for the current day return now.Date; - if (metricPeriod == ConditionMetricPeriod.CurrentWeek) + if (metricPeriod == RulesengineConditionMetricPeriod.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 == ConditionMetricPeriod.CurrentMonth) + if (metricPeriod == RulesengineConditionMetricPeriod.CurrentMonth) // UTC midnight for the first day of current month return new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc); @@ -32,12 +30,12 @@ public static class Metrics /// /// Given a company, determine the current metric period start based on the company's billing subscription. /// - public static DateTime? GetCurrentMetricPeriodStartForCompanyBillingSubscription(Models.Company? company) + public static DateTime? GetCurrentMetricPeriodStartForCompanyBillingSubscription(RulesengineCompany? company) { // If no subscription exists, we use calendar month reset if (company == null || company.Subscription == null) { - return GetCurrentMetricPeriodStartForCalendarMetricPeriod(ConditionMetricPeriod.CurrentMonth); + return GetCurrentMetricPeriodStartForCalendarMetricPeriod(RulesengineConditionMetricPeriod.CurrentMonth); } var now = DateTime.UtcNow; @@ -48,7 +46,7 @@ public static class Metrics // the end of the current calendar month or the start of the billing period, whichever comes first if (periodStart > now) { - DateTime? startOfNextMonth = GetCurrentMetricPeriodStartForCalendarMetricPeriod(ConditionMetricPeriod.CurrentMonth); + DateTime? startOfNextMonth = GetCurrentMetricPeriodStartForCalendarMetricPeriod(RulesengineConditionMetricPeriod.CurrentMonth); if (periodStart > startOfNextMonth) { return startOfNextMonth; @@ -97,15 +95,15 @@ public static class Metrics return currentPeriodStart; } - public static DateTime? GetNextMetricPeriodStartForCalendarMetricPeriod(ConditionMetricPeriod? metricPeriod) + public static DateTime? GetNextMetricPeriodStartForCalendarMetricPeriod(RulesengineConditionMetricPeriod? metricPeriod) { if (metricPeriod == null) return null; - if (metricPeriod == ConditionMetricPeriod.CurrentDay) + if (metricPeriod == RulesengineConditionMetricPeriod.CurrentDay) // UTC midnight for upcoming day return DateTime.UtcNow.Date.AddDays(1); - if (metricPeriod == ConditionMetricPeriod.CurrentWeek) + if (metricPeriod == RulesengineConditionMetricPeriod.CurrentWeek) { // UTC midnight for upcoming Monday (C# uses Monday as first day, Go example used Sunday) var now = DateTime.UtcNow; @@ -115,7 +113,7 @@ public static class Metrics return now.Date.AddDays(daysUntilMonday); } - if (metricPeriod == ConditionMetricPeriod.CurrentMonth) + if (metricPeriod == RulesengineConditionMetricPeriod.CurrentMonth) { // UTC midnight for the first day of next month var currentDate = DateTime.UtcNow; @@ -124,17 +122,17 @@ public static class Metrics return null; } - + /// /// Given a company, determine the next metric period start based on the company's billing subscription. /// - public static DateTime? GetNextMetricPeriodStartForCompanyBillingSubscription(Models.Company? company) + public static DateTime? GetNextMetricPeriodStartForCompanyBillingSubscription(RulesengineCompany? company) { // If no subscription exists, we use calendar month reset if (company == null || company.Subscription == null) { - return GetNextMetricPeriodStartForCalendarMetricPeriod(ConditionMetricPeriod.CurrentMonth); + return GetNextMetricPeriodStartForCalendarMetricPeriod(RulesengineConditionMetricPeriod.CurrentMonth); } var now = DateTime.UtcNow; @@ -145,7 +143,7 @@ public static class Metrics // the end of the current calendar month or the start of the billing period, whichever comes first if (periodStart > now) { - var startOfNextMonth = GetNextMetricPeriodStartForCalendarMetricPeriod(ConditionMetricPeriod.CurrentMonth); + var startOfNextMonth = GetNextMetricPeriodStartForCalendarMetricPeriod(RulesengineConditionMetricPeriod.CurrentMonth); if (periodStart > startOfNextMonth) { return startOfNextMonth; @@ -185,10 +183,10 @@ public static class Metrics /// 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(Condition? condition, Models.Company? company) + public static DateTime? GetNextMetricPeriodStartFromCondition(RulesengineCondition? condition, RulesengineCompany? company) { // Only metric conditions have a metric period that can reset - if (condition == null || condition.ConditionType != ConditionConditionType.Metric || condition.MetricPeriod == null) + if (condition == null || condition.ConditionType != RulesengineConditionConditionType.Metric || condition.MetricPeriod == null) { return null; } @@ -196,15 +194,15 @@ public static class Metrics var metricPeriod = condition.MetricPeriod; // If the metric period is all-time, no reset - if (metricPeriod == ConditionMetricPeriod.AllTime) + if (metricPeriod == RulesengineConditionMetricPeriod.AllTime) { return null; } // Metric period current month with billing cycle reset - if (metricPeriod == ConditionMetricPeriod.CurrentMonth && + if (metricPeriod == RulesengineConditionMetricPeriod.CurrentMonth && condition.MetricPeriodMonthReset.HasValue && - condition.MetricPeriodMonthReset == ConditionMetricPeriodMonthReset.BillingCycle) + condition.MetricPeriodMonthReset == RulesengineConditionMetricPeriodMonthReset.BillingCycle) { return GetNextMetricPeriodStartForCompanyBillingSubscription(company); } @@ -213,4 +211,4 @@ public static class Metrics return GetNextMetricPeriodStartForCalendarMetricPeriod(metricPeriod); } } -} \ No newline at end of file +} diff --git a/src/SchematicHQ.Client/RulesEngine/Models/Company.cs b/src/SchematicHQ.Client/RulesEngine/Models/Company.cs deleted file mode 100644 index a029b742..00000000 --- a/src/SchematicHQ.Client/RulesEngine/Models/Company.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Diagnostics; -using System.Text.Json.Serialization; - -namespace SchematicHQ.Client.RulesEngine.Models -{ - public class Company - { - [JsonPropertyName("id")] - public required string Id { get; set; } - - [JsonPropertyName("account_id")] - public required string AccountId { get; set; } - - [JsonPropertyName("environment_id")] - public required string EnvironmentId { get; set; } - - [JsonPropertyName("plan_ids")] - public List PlanIds { get; set; } = new List(); - - [JsonPropertyName("base_plan_id")] - public string? BasePlanId { get; set; } - - [JsonPropertyName("billing_product_ids")] - public List BillingProductIds { get; set; } = new List(); - [JsonPropertyName("credit_balances")] - public IDictionary CreditBalances { get; set; } = new Dictionary(); - - [JsonPropertyName("keys")] - public IDictionary Keys { get; set; } = new Dictionary(); - - [JsonPropertyName("traits")] - public List Traits { get; set; } = new List(); - - [JsonPropertyName("metrics")] - public List Metrics { get; set; } = new List(); - - [JsonPropertyName("subscription")] - public Subscription? Subscription { get; set; } - - [JsonPropertyName("rules")] - public List Rules { get; set; } = new List(); - - private readonly object _metricsLock = new object(); - - - public Trait? GetTraitByDefinitionId(string definitionId) - { - return Traits.Find(t => t.TraitDefinition?.Id == definitionId); - } - - public void AddMetric(CompanyMetric? metric) - { - if (metric == null) - { - return; - } - - if (Metrics == null) - { - Metrics = new List(); - } - - lock (_metricsLock) - { - var existingMetricIndex = Metrics.FindIndex(m => - m.EventSubtype == metric.EventSubtype && - m.Period == metric.Period && - m.MonthReset == metric.MonthReset); - - if (existingMetricIndex != -1) - { - Metrics[existingMetricIndex] = metric; - } - else - { - Metrics.Add(metric); - } - } - } - } -} \ No newline at end of file diff --git a/src/SchematicHQ.Client/RulesEngine/Models/CompanyMetric.cs b/src/SchematicHQ.Client/RulesEngine/Models/CompanyMetric.cs deleted file mode 100644 index 0cafc6fc..00000000 --- a/src/SchematicHQ.Client/RulesEngine/Models/CompanyMetric.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Text.Json.Serialization; - -namespace SchematicHQ.Client.RulesEngine.Models -{ - public class CompanyMetric - { - [JsonPropertyName("account_id")] - public string? AccountId { get; set; } - - [JsonPropertyName("environment_id")] - public string? EnvironmentId { get; set; } - - [JsonPropertyName("company_id")] - public string? CompanyId { get; set; } - - [JsonPropertyName("event_subtype")] - public required string EventSubtype { get; set; } - - [JsonPropertyName("period")] - public ConditionMetricPeriod Period { get; set; } - - [JsonPropertyName("month_reset")] - public ConditionMetricPeriodMonthReset MonthReset { get; set; } - - [JsonPropertyName("value")] - public long Value { get; set; } - - [JsonPropertyName("created_at")] - public DateTime CreatedAt { get; set; } - - [JsonPropertyName("valid_until")] - public DateTime? ValidUntil { get; set; } - - public static CompanyMetric? Find(List metrics, string eventSubtype, ConditionMetricPeriod? period, ConditionMetricPeriodMonthReset? monthReset) - { - if (metrics == null || string.IsNullOrEmpty(eventSubtype) || period == null) - return null; - - return metrics.Find(m => - m.EventSubtype == eventSubtype && - m.Period == period.Value && - (monthReset == null || m.MonthReset == monthReset)); - } - } -} diff --git a/src/SchematicHQ.Client/RulesEngine/Models/Flag.cs b/src/SchematicHQ.Client/RulesEngine/Models/Flag.cs deleted file mode 100644 index 1edaf0c3..00000000 --- a/src/SchematicHQ.Client/RulesEngine/Models/Flag.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Text.Json.Serialization; - -namespace SchematicHQ.Client.RulesEngine.Models -{ - public class Flag - { - [JsonPropertyName("id")] - public required string Id { get; set; } - - [JsonPropertyName("account_id")] - public required string AccountId { get; set; } - - [JsonPropertyName("environment_id")] - public required string EnvironmentId { get; set; } - - [JsonPropertyName("key")] - public required string Key { get; set; } - - [JsonPropertyName("rules")] - public List Rules { get; set; } = new List(); - - [JsonPropertyName("default_value")] - public bool DefaultValue { get; set; } - } -} \ No newline at end of file diff --git a/src/SchematicHQ.Client/RulesEngine/Models/Subscription.cs b/src/SchematicHQ.Client/RulesEngine/Models/Subscription.cs deleted file mode 100644 index a9a043d8..00000000 --- a/src/SchematicHQ.Client/RulesEngine/Models/Subscription.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Text.Json.Serialization; - -namespace SchematicHQ.Client.RulesEngine.Models -{ - public class Subscription - { - [JsonPropertyName("id")] - public required string Id { get; set; } - - [JsonPropertyName("period_start")] - public DateTime PeriodStart { get; set; } - - [JsonPropertyName("period_end")] - public DateTime PeriodEnd { get; set; } - } -} \ No newline at end of file diff --git a/src/SchematicHQ.Client/RulesEngine/Models/Trait.cs b/src/SchematicHQ.Client/RulesEngine/Models/Trait.cs deleted file mode 100644 index 2f6d2af6..00000000 --- a/src/SchematicHQ.Client/RulesEngine/Models/Trait.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Text.Json.Serialization; - -namespace SchematicHQ.Client.RulesEngine.Models -{ - public class Trait - { - - [JsonPropertyName("trait_definition")] - public TraitDefinition? TraitDefinition { get; set; } - - [JsonPropertyName("value")] - public required string Value { get; set; } - - } -} \ No newline at end of file diff --git a/src/SchematicHQ.Client/RulesEngine/Models/User.cs b/src/SchematicHQ.Client/RulesEngine/Models/User.cs deleted file mode 100644 index 6b92e9c0..00000000 --- a/src/SchematicHQ.Client/RulesEngine/Models/User.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Text.Json.Serialization; - -namespace SchematicHQ.Client.RulesEngine.Models -{ - public class User - { - [JsonPropertyName("id")] - public required string Id { get; set; } - - [JsonPropertyName("account_id")] - public required string AccountId { get; set; } - - [JsonPropertyName("environment_id")] - public required string EnvironmentId { get; set; } - - [JsonPropertyName("keys")] - public IDictionary Keys { get; set; } = new Dictionary(); - - [JsonPropertyName("traits")] - public List Traits { get; set; } = new List(); - - [JsonPropertyName("rules")] - public List Rules { get; set; } = new List(); - - } -} \ No newline at end of file diff --git a/src/SchematicHQ.Client/RulesEngine/RuleCheck.cs b/src/SchematicHQ.Client/RulesEngine/RuleCheck.cs index 8cf427d3..c5431240 100644 --- a/src/SchematicHQ.Client/RulesEngine/RuleCheck.cs +++ b/src/SchematicHQ.Client/RulesEngine/RuleCheck.cs @@ -1,13 +1,12 @@ -using SchematicHQ.Client.RulesEngine.Models; using SchematicHQ.Client.RulesEngine.Utils; namespace SchematicHQ.Client.RulesEngine { public class CheckScope { - public Models.Company? Company { get; set; } - public Rule? Rule { get; set; } - public Models.User? User { get; set; } + public RulesengineCompany? Company { get; set; } + public RulesengineRule? Rule { get; set; } + public RulesengineUser? User { get; set; } } public class CheckResult @@ -35,14 +34,14 @@ public async Task Check(CheckScope scope, CancellationToken cancell return res; } - if (scope.Rule.RuleType == RuleRuleType.Default.Value || scope.Rule.RuleType == RuleRuleType.GlobalOverride.Value) + if (scope.Rule.RuleType == RulesengineRuleRuleType.Default.Value || scope.Rule.RuleType == RulesengineRuleRuleType.GlobalOverride.Value) { res.Match = true; return res; } bool match; - foreach (var condition in scope.Rule.Conditions ?? Enumerable.Empty()) + foreach (var condition in scope.Rule.Conditions ?? Enumerable.Empty()) { match = await CheckCondition(scope.Company, scope.User, condition, cancellationToken); if (!match) @@ -51,7 +50,7 @@ public async Task Check(CheckScope scope, CancellationToken cancell } } - foreach (var group in scope.Rule.ConditionGroups ?? Enumerable.Empty()) + foreach (var group in scope.Rule.ConditionGroups ?? Enumerable.Empty()) { match = await CheckConditionGroup(scope.Company, scope.User, group, cancellationToken); if (!match) @@ -64,35 +63,36 @@ public async Task Check(CheckScope scope, CancellationToken cancell return res; } - private async Task CheckCondition(Models.Company? company, Models.User? user, Condition condition, CancellationToken cancellationToken) + private async Task CheckCondition(RulesengineCompany? company, RulesengineUser? user, RulesengineCondition condition, CancellationToken cancellationToken) { if (condition == null) { return false; } - // Use generated enum values directly - if (condition.ConditionType == ConditionConditionType.Company) + if (condition.ConditionType == RulesengineConditionConditionType.Company) return await CheckCompanyCondition(company, condition, cancellationToken); - if (condition.ConditionType == ConditionConditionType.Metric.Value) + if (condition.ConditionType == RulesengineConditionConditionType.Metric.Value) return await CheckMetricCondition(company, condition, cancellationToken); - if (condition.ConditionType == ConditionConditionType.BasePlan.Value) + if (condition.ConditionType == RulesengineConditionConditionType.BasePlan.Value) return await CheckBasePlanCondition(company, condition, cancellationToken); - if (condition.ConditionType == ConditionConditionType.Plan.Value) + if (condition.ConditionType == RulesengineConditionConditionType.Plan.Value) return await CheckPlanCondition(company, condition, cancellationToken); - if (condition.ConditionType == ConditionConditionType.Trait.Value) + if (condition.ConditionType == RulesengineConditionConditionType.Trait.Value) return await CheckTraitCondition(company, user, condition, cancellationToken); - if (condition.ConditionType == ConditionConditionType.User.Value) + if (condition.ConditionType == RulesengineConditionConditionType.User.Value) return await CheckUserCondition(user, condition, cancellationToken); - if (condition.ConditionType == ConditionConditionType.BillingProduct.Value) + if (condition.ConditionType == RulesengineConditionConditionType.BillingProduct.Value) return await CheckBillingProductCondition(company, condition, cancellationToken); - if (condition.ConditionType == ConditionConditionType.Credit.Value) + if (condition.ConditionType == RulesengineConditionConditionType.Credit.Value) return await CheckCreditBalanceCondition(company, condition, cancellationToken); + if (condition.ConditionType == RulesengineConditionConditionType.PlanVersion.Value) + return await CheckPlanVersionCondition(company, condition, cancellationToken); return false; } - private async Task CheckConditionGroup(Models.Company? company, Models.User? user, ConditionGroup group, CancellationToken cancellationToken) + private async Task CheckConditionGroup(RulesengineCompany? company, RulesengineUser? user, RulesengineConditionGroup group, CancellationToken cancellationToken) { if (group == null || !group.Conditions.Any()) { @@ -110,9 +110,9 @@ private async Task CheckConditionGroup(Models.Company? company, Models.Use return false; } - private Task CheckCompanyCondition(Models.Company? company, Condition condition, CancellationToken cancellationToken) + private Task CheckCompanyCondition(RulesengineCompany? company, RulesengineCondition condition, CancellationToken cancellationToken) { - if (condition.ConditionType != ConditionConditionType.Company || company == null) + if (condition.ConditionType != RulesengineConditionConditionType.Company || company == null) { return Task.FromResult(false); } @@ -126,9 +126,9 @@ private Task CheckCompanyCondition(Models.Company? company, Condition cond return Task.FromResult(resourceMatch); } - private Task CheckBillingProductCondition(Models.Company? company, Condition condition, CancellationToken cancellationToken) + private Task CheckBillingProductCondition(RulesengineCompany? company, RulesengineCondition condition, CancellationToken cancellationToken) { - if (condition.ConditionType != ConditionConditionType.BillingProduct || company == null) + if (condition.ConditionType != RulesengineConditionConditionType.BillingProduct || company == null) { return Task.FromResult(false); } @@ -143,9 +143,9 @@ private Task CheckBillingProductCondition(Models.Company? company, Conditi return Task.FromResult(resourceMatch); } - private Task CheckCreditBalanceCondition(Models.Company? company, Condition condition, CancellationToken cancellationToken) + private Task CheckCreditBalanceCondition(RulesengineCompany? company, RulesengineCondition condition, CancellationToken cancellationToken) { - if (condition.ConditionType != ConditionConditionType.Credit || company == null || string.IsNullOrEmpty(condition.CreditId)) + if (condition.ConditionType != RulesengineConditionConditionType.Credit || company == null || string.IsNullOrEmpty(condition.CreditId)) { return Task.FromResult(false); } @@ -165,9 +165,9 @@ private Task CheckCreditBalanceCondition(Models.Company? company, Conditio return Task.FromResult(creditBalance >= consumptionCost); } - private Task CheckPlanCondition(Models.Company? company, Condition condition, CancellationToken cancellationToken) + private Task CheckPlanCondition(RulesengineCompany? company, RulesengineCondition condition, CancellationToken cancellationToken) { - if (condition.ConditionType != ConditionConditionType.Plan || company == null) + if (condition.ConditionType != RulesengineConditionConditionType.Plan || company == null) { return Task.FromResult(false); } @@ -182,31 +182,44 @@ private Task CheckPlanCondition(Models.Company? company, Condition conditi return Task.FromResult(resourceMatch); } - private Task CheckBasePlanCondition(Models.Company? company, Condition condition, CancellationToken cancellationToken) + private Task CheckBasePlanCondition(RulesengineCompany? company, RulesengineCondition condition, CancellationToken cancellationToken) { - if (condition.ConditionType != ConditionConditionType.BasePlan || company == null || string.IsNullOrEmpty(company.BasePlanId)) + if (condition.ConditionType != RulesengineConditionConditionType.BasePlan || company == null) { return Task.FromResult(false); } - var resourceMatch = Set.NewSet(condition.ResourceIds.ToArray()).Contains(company.BasePlanId); - if (condition.Operator.ToComparableOperator() == ComparableOperator.Ne) + var op = condition.Operator.ToComparableOperator(); + + if (op == ComparableOperator.IsEmpty) { - return Task.FromResult(!resourceMatch); + 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(Models.Company? company, Condition condition, CancellationToken cancellationToken) + private Task CheckMetricCondition(RulesengineCompany? company, RulesengineCondition condition, CancellationToken cancellationToken) { - if (condition == null || condition.ConditionType != ConditionConditionType.Metric || company == null || string.IsNullOrEmpty(condition.EventSubtype)) + if (condition == null || condition.ConditionType != RulesengineConditionConditionType.Metric || company == null || string.IsNullOrEmpty(condition.EventSubtype)) { return Task.FromResult(false); } long leftVal = 0; - var metric = Models.CompanyMetric.Find(company.Metrics, condition.EventSubtype, condition.MetricPeriod, condition.MetricPeriodMonthReset); + var metric = CompanyMetricExtensions.Find(company.Metrics, condition.EventSubtype, condition.MetricPeriod, condition.MetricPeriodMonthReset); if (metric != null) { leftVal = metric.Value; @@ -236,23 +249,23 @@ private Task CheckMetricCondition(Models.Company? company, Condition condi return Task.FromResult(resourceMatch); } - private Task CheckTraitCondition(Models.Company? company, Models.User? user, Condition condition, CancellationToken cancellationToken) + private Task CheckTraitCondition(RulesengineCompany? company, RulesengineUser? user, RulesengineCondition condition, CancellationToken cancellationToken) { - if (condition == null || condition.ConditionType != ConditionConditionType.Trait || condition.TraitDefinition == null) + if (condition == null || condition.ConditionType != RulesengineConditionConditionType.Trait || condition.TraitDefinition == null) { return Task.FromResult(false); } var traitDef = condition.TraitDefinition; - Models.Trait? trait; - Models.Trait? comparisonTrait; + RulesengineTrait? trait; + RulesengineTrait? comparisonTrait; - if (traitDef.EntityType == EntityType.Company && company != null) + if (traitDef.EntityType == RulesengineEntityType.Company && company != null) { trait = FindTrait(traitDef, company.Traits); comparisonTrait = FindTrait(condition.ComparisonTraitDefinition, company.Traits); } - else if (traitDef.EntityType == EntityType.User && user != null) + else if (traitDef.EntityType == RulesengineEntityType.User && user != null) { trait = FindTrait(traitDef, user.Traits); comparisonTrait = FindTrait(condition.ComparisonTraitDefinition, user.Traits); @@ -267,9 +280,9 @@ private Task CheckTraitCondition(Models.Company? company, Models.User? use return Task.FromResult(resourceMatch); } - private Task CheckUserCondition(Models.User? user, Condition condition, CancellationToken cancellationToken) + private Task CheckUserCondition(RulesengineUser? user, RulesengineCondition condition, CancellationToken cancellationToken) { - if (condition.ConditionType != ConditionConditionType.User || user == null) + if (condition.ConditionType != RulesengineConditionConditionType.User || user == null) { return Task.FromResult(false); } @@ -283,7 +296,24 @@ private Task CheckUserCondition(Models.User? user, Condition condition, Ca return Task.FromResult(resourceMatch); } - static private bool CompareTraits(Condition condition, Models.Trait? trait, Models.Trait? comparisonTrait) + private Task CheckPlanVersionCondition(RulesengineCompany? company, RulesengineCondition condition, CancellationToken cancellationToken) + { + if (condition.ConditionType != RulesengineConditionConditionType.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.ToComparableOperator() == 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 ?? ""; @@ -307,14 +337,14 @@ static private bool CompareTraits(Condition condition, Models.Trait? trait, Mode return TypeConverter.Compare(leftVal, rightVal, comparableType, condition.Operator.ToComparableOperator()); } - static private Models.Trait? FindTrait(TraitDefinition? traitDef, List? traits) + static private RulesengineTrait? FindTrait(RulesengineTraitDefinition? traitDef, IEnumerable? traits) { if (traitDef == null || traits == null) { return null; } - return traits.Find(t => t.TraitDefinition?.Id == traitDef.Id); + 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 new file mode 100644 index 00000000..75c0ff23 --- /dev/null +++ b/src/SchematicHQ.Client/RulesEngine/RulesEngineExtensions.cs @@ -0,0 +1,67 @@ +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, + RulesengineConditionMetricPeriod? period, + RulesengineConditionMetricPeriodMonthReset? 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/SnakeCaseEnumConverter.cs b/src/SchematicHQ.Client/RulesEngine/SnakeCaseEnumConverter.cs deleted file mode 100644 index db1bbf35..00000000 --- a/src/SchematicHQ.Client/RulesEngine/SnakeCaseEnumConverter.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Globalization; - -namespace SchematicHQ.Client.RulesEngine -{ - public class SnakeCaseEnumConverter : JsonConverter where T : struct, Enum - { - public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var value = reader.GetString(); - if (value == null) - throw new JsonException(); - - // Convert snake_case to PascalCase - var pascalCase = string.Join("", value.Split('_', StringSplitOptions.RemoveEmptyEntries) - .Select(s => char.ToUpperInvariant(s[0]) + s.Substring(1))); - - if (Enum.TryParse(pascalCase, out var result)) - return result; - - throw new JsonException($"Unable to convert '{value}' to enum '{typeof(T).Name}'"); - } - - public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) - { - // Convert PascalCase to snake_case - var str = value.ToString(); - var snake = string.Concat(str.Select((x, i) => i > 0 && char.IsUpper(x) ? "_" + char.ToLowerInvariant(x) : char.ToLowerInvariant(x).ToString())); - writer.WriteStringValue(snake); - } - } -} diff --git a/src/SchematicHQ.Client/RulesEngine/Utils/GeneratedModelHash.cs b/src/SchematicHQ.Client/RulesEngine/Utils/GeneratedModelHash.cs index 484035a9..65b7ebf1 100644 --- a/src/SchematicHQ.Client/RulesEngine/Utils/GeneratedModelHash.cs +++ b/src/SchematicHQ.Client/RulesEngine/Utils/GeneratedModelHash.cs @@ -9,6 +9,6 @@ public static class GeneratedModelHash /// Auto-generated hash of all model files. /// This value changes whenever any model file is modified. /// - public const string Value = "da92e249"; + public const string Value = "3399f462"; } } diff --git a/src/SchematicHQ.Client/RulesEngine/Utils/TypeConverter.cs b/src/SchematicHQ.Client/RulesEngine/Utils/TypeConverter.cs index 96d4214e..e893e761 100644 --- a/src/SchematicHQ.Client/RulesEngine/Utils/TypeConverter.cs +++ b/src/SchematicHQ.Client/RulesEngine/Utils/TypeConverter.cs @@ -44,7 +44,7 @@ public enum ComparableOperator // Extension methods to convert from generated types to utility types public static class ComparableTypeExtensions { - public static ComparableOperator ToComparableOperator(this ConditionOperator op) + public static ComparableOperator ToComparableOperator(this RulesengineConditionOperator op) { return op.Value switch { @@ -60,7 +60,7 @@ public static ComparableOperator ToComparableOperator(this ConditionOperator op) }; } - public static ComparableType ToComparableType(this TraitDefinitionComparableType comparableType) + public static ComparableType ToComparableType(this RulesengineTraitDefinitionComparableType comparableType) { return comparableType.Value switch { diff --git a/src/SchematicHQ.Client/Schematic.cs b/src/SchematicHQ.Client/Schematic.cs index 6c9ff920..c1536ee9 100644 --- a/src/SchematicHQ.Client/Schematic.cs +++ b/src/SchematicHQ.Client/Schematic.cs @@ -7,6 +7,7 @@ using SchematicHQ.Client.Datastream; using SchematicHQ.Client.Cache; using SchematicHQ.Client.Core; +using SchematicHQ.Client.RulesEngine; #nullable enable @@ -17,7 +18,7 @@ public partial class Schematic private readonly ClientOptions _options; private readonly IEventBuffer _eventBuffer; private readonly ISchematicLogger _logger; - private readonly List> _flagCheckCacheProviders; + private readonly List> _flagCheckCacheProviders; private readonly bool _offline; private readonly DatastreamClientAdapter? _datastreamClient; private readonly bool _replicatorMode; @@ -75,7 +76,7 @@ public Schematic(string apiKey, ClientOptions? options = null) { foreach (var provider in _options.CacheProviders) { - if (provider is RedisCache) + if (provider is RedisCache) { hasRedisCache = true; break; @@ -128,7 +129,7 @@ public Schematic(string apiKey, ClientOptions? options = null) else if (_options.CacheConfiguration != null) { // Create cache providers based on configuration - _flagCheckCacheProviders = new List>(); + _flagCheckCacheProviders = new List>(); switch (_options.CacheConfiguration.ProviderType) { @@ -136,7 +137,7 @@ public Schematic(string apiKey, ClientOptions? options = null) if (_options.CacheConfiguration.RedisConfig == null) { _logger.Warn("Redis configuration not provided, falling back to local cache"); - _flagCheckCacheProviders.Add(new LocalCache()); + _flagCheckCacheProviders.Add(new LocalCache()); } else { @@ -146,14 +147,14 @@ public Schematic(string apiKey, ClientOptions? options = null) _options.CacheConfiguration.RedisConfig.CacheTTL = _options.CacheConfiguration.CacheTtl; } - RedisCache redisCache = new RedisCache(_options.CacheConfiguration.RedisConfig); + RedisCache redisCache = new RedisCache(_options.CacheConfiguration.RedisConfig); _flagCheckCacheProviders.Add(redisCache); } break; case CacheProviderType.Local: default: - _flagCheckCacheProviders.Add(new LocalCache( + _flagCheckCacheProviders.Add(new LocalCache( _options.CacheConfiguration.LocalCacheCapacity, _options.CacheConfiguration.CacheTtl )); @@ -163,9 +164,9 @@ public Schematic(string apiKey, ClientOptions? options = null) else { // Default to local cache - _flagCheckCacheProviders = new List> + _flagCheckCacheProviders = new List> { - new LocalCache() + new LocalCache() }; } @@ -282,14 +283,24 @@ public async Task Shutdown() } public async Task CheckFlag(string flagKey, Dictionary? company = null, Dictionary? user = null) + { + var resp = await CheckFlagWithEntitlement(flagKey, company, user); + return resp.Value; + } + + public async Task CheckFlagWithEntitlement(string flagKey, Dictionary? company = null, Dictionary? user = null) { if (_offline) - return GetFlagDefault(flagKey); + return new CheckFlagWithEntitlementResponse + { + FlagKey = flagKey, + Value = GetFlagDefault(flagKey), + Reason = "offline mode" + }; // Try datastream first if enabled if (_datastreamClient != null) { - try { var request = new CheckFlagRequestBody @@ -299,6 +310,8 @@ public async Task CheckFlag(string flagKey, Dictionary? co }; var flagResult = await _datastreamClient.CheckFlag(request, flagKey); + var response = CheckFlagWithEntitlementResponse.FromCheckFlagResult(flagResult); + // Submit flag check event for successful datastream evaluation SubmitFlagCheckEvent( flagKey, @@ -316,21 +329,21 @@ public async Task CheckFlag(string flagKey, Dictionary? co Reason = flagResult.Reason, Error = flagResult.Error?.Message }); - return flagResult.Value; + return response; } catch (Exception ex) { // Fall back to API if datastream fails _logger.Debug("Datastream flag check failed ({0}), falling back to API", ex.Message); - return await CheckFlagApi(flagKey, company, user); + return await CheckFlagWithEntitlementApi(flagKey, company, user); } } // Fall back to API request - return await CheckFlagApi(flagKey, company, user); + return await CheckFlagWithEntitlementApi(flagKey, company, user); } - private async Task CheckFlagApi(string flagKey, Dictionary? company, Dictionary? user) + private async Task CheckFlagWithEntitlementApi(string flagKey, Dictionary? company, Dictionary? user) { try { @@ -346,29 +359,37 @@ private async Task CheckFlagApi(string flagKey, Dictionary // Check cache first foreach (var provider in _flagCheckCacheProviders) { - if (provider.Get(cacheKey) is bool cachedValue) + var cachedResponse = provider.Get(cacheKey); + if (cachedResponse != null) { // Submit flag check event for cached value - SubmitFlagCheckEventForValue(flagKey, cachedValue, company, user, "cache"); - return cachedValue; + SubmitFlagCheckEventForValue(flagKey, cachedResponse.Value, company, user, "cache"); + return cachedResponse; } } // Make API request - var response = await API.Features.CheckFlagAsync(flagKey, requestBody); + var apiResponse = await API.Features.CheckFlagAsync(flagKey, requestBody); - if (response == null) + if (apiResponse == null) { // If the client was not initialized with an API key, we'll have a no-op here which returns an empty response - return GetFlagDefault(flagKey); + return new CheckFlagWithEntitlementResponse + { + FlagKey = flagKey, + Value = GetFlagDefault(flagKey), + Reason = "no response" + }; } + var result = CheckFlagWithEntitlementResponse.FromApiResponse(apiResponse.Data, flagKey); + // Cache the result foreach (var provider in _flagCheckCacheProviders) { try { - provider.Set(cacheKey, response.Data.Value); + provider.Set(cacheKey, result); } catch (Exception cacheEx) { @@ -376,12 +397,17 @@ private async Task CheckFlagApi(string flagKey, Dictionary } } - return response.Data.Value; + return result; } catch (Exception ex) { _logger.Error("Error checking flag via API: {0}", ex.Message); - return GetFlagDefault(flagKey); + return new CheckFlagWithEntitlementResponse + { + FlagKey = flagKey, + Value = GetFlagDefault(flagKey), + Reason = ex.Message + }; } } diff --git a/src/SchematicHQ.Client/SchematicHQ.Client.Custom.props b/src/SchematicHQ.Client/SchematicHQ.Client.Custom.props index 59edde39..6aa89d8b 100644 --- a/src/SchematicHQ.Client/SchematicHQ.Client.Custom.props +++ b/src/SchematicHQ.Client/SchematicHQ.Client.Custom.props @@ -19,7 +19,7 @@ Configure additional MSBuild properties for your project in this file: - + diff --git a/src/SchematicHQ.Client/generate-schema-hash.sh b/src/SchematicHQ.Client/generate-schema-hash.sh index 0eb9d9aa..87603529 100755 --- a/src/SchematicHQ.Client/generate-schema-hash.sh +++ b/src/SchematicHQ.Client/generate-schema-hash.sh @@ -18,8 +18,13 @@ fi # Ensure output directory exists mkdir -p "$(dirname "$OUTPUT_FILE")" -# Find all .cs files in the models directory, sort them for consistency -FILES=$(find "$MODELS_DIR" -name "*.cs" | sort) +# 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..."