diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 0684040f85..b5c241a0a4 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -373,7 +373,6 @@ public RuntimeConfig( } SetupDataSourcesUsed(); - } /// @@ -529,12 +528,13 @@ Runtime is not null && Runtime.Host is not null /// /// Returns the ttl-seconds value for a given entity. - /// If the property is not set, returns the global default value set in the runtime config. - /// If the global default value is not set, the default value is used (5 seconds). + /// If the entity explicitly sets ttl-seconds, that value is used. + /// Otherwise, falls back to the global cache TTL setting. + /// Callers are responsible for checking whether caching is enabled before using the result. /// /// Name of the entity to check cache configuration. /// Number of seconds (ttl) that a cache entry should be valid before cache eviction. - /// Raised when an invalid entity name is provided or if the entity has caching disabled. + /// Raised when an invalid entity name is provided. public virtual int GetEntityCacheEntryTtl(string entityName) { if (!Entities.TryGetValue(entityName, out Entity? entityConfig)) @@ -545,31 +545,23 @@ public virtual int GetEntityCacheEntryTtl(string entityName) subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } - if (!entityConfig.IsCachingEnabled) - { - throw new DataApiBuilderException( - message: $"{entityName} does not have caching enabled.", - statusCode: HttpStatusCode.BadRequest, - subStatusCode: DataApiBuilderException.SubStatusCodes.NotSupported); - } - - if (entityConfig.Cache.UserProvidedTtlOptions) + if (entityConfig.Cache is not null && entityConfig.Cache.UserProvidedTtlOptions) { return entityConfig.Cache.TtlSeconds.Value; } - else - { - return GlobalCacheEntryTtl(); - } + + return GlobalCacheEntryTtl(); } /// /// Returns the cache level value for a given entity. - /// If the property is not set, returns the default (L1L2) for a given entity. + /// If the entity explicitly sets level, that value is used. + /// Otherwise, falls back to the global cache level or the default. + /// Callers are responsible for checking whether caching is enabled before using the result. /// /// Name of the entity to check cache configuration. /// Cache level that a cache entry should be stored in. - /// Raised when an invalid entity name is provided or if the entity has caching disabled. + /// Raised when an invalid entity name is provided. public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) { if (!Entities.TryGetValue(entityName, out Entity? entityConfig)) @@ -580,22 +572,12 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } - if (!entityConfig.IsCachingEnabled) - { - throw new DataApiBuilderException( - message: $"{entityName} does not have caching enabled.", - statusCode: HttpStatusCode.BadRequest, - subStatusCode: DataApiBuilderException.SubStatusCodes.NotSupported); - } - - if (entityConfig.Cache.UserProvidedLevelOptions) + if (entityConfig.Cache is not null && entityConfig.Cache.UserProvidedLevelOptions) { return entityConfig.Cache.Level.Value; } - else - { - return EntityCacheLevel.L1L2; - } + + return EntityCacheOptions.DEFAULT_LEVEL; } /// diff --git a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs index 1294c009da..d8cd279d95 100644 --- a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs +++ b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs @@ -416,4 +416,48 @@ private static string GetRawConfigJson(string globalCacheConfig, string entityCa return expectedRuntimeConfigJson.ToString(); } + + /// + /// Regression test: Validates that when global runtime cache is enabled but entity cache is disabled, + /// GetEntityCacheEntryTtl and GetEntityCacheEntryLevel do not throw and return sensible defaults. + /// Previously, these methods threw a DataApiBuilderException (BadRequest/NotSupported) when the entity + /// had caching disabled, which caused 400 errors for valid requests when the global cache was enabled. + /// These methods are now pure accessors that always return a value regardless of cache enablement. + /// + /// Global cache configuration JSON fragment. + /// Entity cache configuration JSON fragment. + /// Expected TTL returned by GetEntityCacheEntryTtl. + /// Expected cache level returned by GetEntityCacheEntryLevel. + [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 10 }", @",""cache"": { ""enabled"": false }", 10, EntityCacheLevel.L1L2, DisplayName = "Global cache enabled with custom TTL, entity cache disabled: entity returns global TTL and default level.")] + [DataRow(@",""cache"": { ""enabled"": true }", @",""cache"": { ""enabled"": false }", 5, EntityCacheLevel.L1L2, DisplayName = "Global cache enabled with default TTL, entity cache disabled: entity returns default TTL and default level.")] + [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 10 }", @"", 10, EntityCacheLevel.L1L2, DisplayName = "Global cache enabled with custom TTL, entity cache omitted: entity returns global TTL and default level.")] + [DataTestMethod] + public void GetEntityCacheEntryTtlAndLevel_DoesNotThrow_WhenRuntimeCacheEnabledAndEntityCacheDisabled( + string globalCacheConfig, + string entityCacheConfig, + int expectedTtl, + EntityCacheLevel expectedLevel) + { + // Arrange + string fullConfig = GetRawConfigJson(globalCacheConfig: globalCacheConfig, entityCacheConfig: entityCacheConfig); + RuntimeConfigLoader.TryParseConfig( + json: fullConfig, + out RuntimeConfig? config, + replacementSettings: null); + + Assert.IsNotNull(config, message: "Config must not be null, runtime config JSON deserialization failed."); + Assert.IsTrue(config.IsCachingEnabled, message: "Global caching should be enabled for this test scenario."); + + Entity entity = config.Entities.First().Value; + Assert.IsFalse(entity.IsCachingEnabled, message: "Entity caching should be disabled for this test scenario."); + + string entityName = config.Entities.First().Key; + + // Act & Assert - These calls must not throw. + int actualTtl = config.GetEntityCacheEntryTtl(entityName); + EntityCacheLevel actualLevel = config.GetEntityCacheEntryLevel(entityName); + + Assert.AreEqual(expected: expectedTtl, actual: actualTtl, message: "GetEntityCacheEntryTtl should return the global/default TTL when entity cache is disabled."); + Assert.AreEqual(expected: expectedLevel, actual: actualLevel, message: "GetEntityCacheEntryLevel should return the default level when entity cache is disabled."); + } } diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index aa12a7d465..51face4408 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -2903,7 +2903,7 @@ public async Task ValidateErrorMessageForMutationWithoutReadPermission() }"; string queryName = "stock_by_pk"; - ValidateMutationSucceededAtDbLayer(server, client, graphQLQuery, queryName, authToken, AuthorizationResolver.ROLE_AUTHENTICATED); + await ValidateMutationSucceededAtDbLayer(server, client, graphQLQuery, queryName, authToken, AuthorizationResolver.ROLE_AUTHENTICATED); } finally { @@ -3225,7 +3225,7 @@ public async Task ValidateInheritanceOfReadPermissionFromAnonymous() /// GraphQL query/mutation text /// GraphQL query/mutation name /// Auth token for the graphQL request - private static async void ValidateMutationSucceededAtDbLayer(TestServer server, HttpClient client, string query, string queryName, string authToken, string clientRoleHeader) + private static async Task ValidateMutationSucceededAtDbLayer(TestServer server, HttpClient client, string query, string queryName, string authToken, string clientRoleHeader) { JsonElement queryResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync( client, @@ -3237,6 +3237,7 @@ private static async void ValidateMutationSucceededAtDbLayer(TestServer server, clientRoleHeader: clientRoleHeader); Assert.IsNotNull(queryResponse); + Assert.AreNotEqual(JsonValueKind.Null, queryResponse.ValueKind, "Expected a JSON object response but received null."); Assert.IsFalse(queryResponse.TryGetProperty("errors", out _)); } diff --git a/src/Service.Tests/Configuration/Telemetry/AzureLogAnalyticsTests.cs b/src/Service.Tests/Configuration/Telemetry/AzureLogAnalyticsTests.cs index db6b58681b..dc35b1c4dd 100644 --- a/src/Service.Tests/Configuration/Telemetry/AzureLogAnalyticsTests.cs +++ b/src/Service.Tests/Configuration/Telemetry/AzureLogAnalyticsTests.cs @@ -120,9 +120,18 @@ public async Task TestAzureLogAnalyticsFlushServiceSucceed(string message, LogLe _ = Task.Run(() => flusherService.StartAsync(tokenSource.Token)); - await Task.Delay(2000); + // Poll until the log appears (the flusher service needs time to dequeue and upload) + int maxWaitMs = 10000; + int pollIntervalMs = 100; + int elapsed = 0; + while (customClient.LogAnalyticsLogs.Count == 0 && elapsed < maxWaitMs) + { + await Task.Delay(pollIntervalMs); + elapsed += pollIntervalMs; + } // Assert + Assert.IsTrue(customClient.LogAnalyticsLogs.Count > 0, $"Expected at least one log entry after waiting {elapsed}ms, but found none."); AzureLogAnalyticsLogs actualLog = customClient.LogAnalyticsLogs[0]; Assert.AreEqual(logLevel.ToString(), actualLog.LogLevel); Assert.AreEqual(message, actualLog.Message);