diff --git a/src/Services/Controllers/DataStoresController.cs b/src/Services/Controllers/DataStoresController.cs index d5395d2..15c3aac 100644 --- a/src/Services/Controllers/DataStoresController.cs +++ b/src/Services/Controllers/DataStoresController.cs @@ -231,6 +231,19 @@ public async Task Update(int id, [FromBody] UpdateDataStoreRequest await context.SaveChangesAsync(); + // Cached providers hold the connection string, tracking mode and change retention they were + // built with, and none of those require unpublishing to edit. Drop every endpoint on this + // data store so the next sync connects with the saved settings. + var endpointIds = await context.Endpoints + .Where(e => e.DataStoreConfiguration!.DataStoreId == id) + .Select(e => e.Id) + .ToListAsync(); + + foreach (var endpointId in endpointIds) + { + syncProviderCache.Invalidate(endpointId); + } + return NoContent(); } @@ -292,8 +305,9 @@ public async Task PublishEndpoint(int id, Guid endpointId, [FromBo endpoint.IsPublished = request.IsPublished; await context.SaveChangesAsync(); - // Drop any cached sync provider so the next sync rebuilds it from the current configuration - // (the table config can only change while unpublished, so this toggle is the safe seam). + // Drop any cached sync provider so the next sync rebuilds it from the current configuration. + // Table rules can only change while unpublished, so this toggle covers them; repointing the + // endpoint and editing the data store invalidate on their own. syncProviderCache.Invalidate(endpointId); return NoContent(); @@ -321,6 +335,11 @@ public async Task UpdateEndpointConfiguration(int id, Guid endpoin endpoint.DataStoreConfigurationId = request.DataStoreConfigurationId; await context.SaveChangesAsync(); + // The cached provider was built from the previous configuration's tables. Repointing is + // allowed while published (it is how a new configuration goes live), so without this the + // endpoint kept serving the old tables until the next publish toggle or restart. + syncProviderCache.Invalidate(endpointId); + return NoContent(); } } diff --git a/tests/CoreSyncServer.Tests/Infrastructure/AuthenticatedHttpClientExtensions.cs b/tests/CoreSyncServer.Tests/Infrastructure/AuthenticatedHttpClientExtensions.cs index 8d2580f..296e106 100644 --- a/tests/CoreSyncServer.Tests/Infrastructure/AuthenticatedHttpClientExtensions.cs +++ b/tests/CoreSyncServer.Tests/Infrastructure/AuthenticatedHttpClientExtensions.cs @@ -20,8 +20,20 @@ public static HttpClient CreateAuthenticatedClient( this CustomWebApplicationFactory factory, string? userId = null, string? userName = null) + => factory.CreateAuthenticatedFactory(userId, userName).CreateAuthenticatedClient(); + + /// + /// The host an authenticated client talks to. It is a separate host from + /// , with its own singletons, so a test that needs to see server + /// state (e.g. the sync provider cache) must resolve it from here rather than from the + /// fixture's Services. + /// + public static WebApplicationFactory CreateAuthenticatedFactory( + this CustomWebApplicationFactory factory, + string? userId = null, + string? userName = null) { - var client = factory.WithWebHostBuilder(builder => + return factory.WithWebHostBuilder(builder => { builder.ConfigureTestServices(services => { @@ -37,7 +49,12 @@ public static HttpClient CreateAuthenticatedClient( userId ?? DefaultUserId, userName ?? DefaultUserName)); }); - }).CreateClient(new WebApplicationFactoryClientOptions + }); + } + + public static HttpClient CreateAuthenticatedClient(this WebApplicationFactory authenticatedFactory) + { + var client = authenticatedFactory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); diff --git a/tests/CoreSyncServer.Tests/SyncProviderCacheInvalidationTests.cs b/tests/CoreSyncServer.Tests/SyncProviderCacheInvalidationTests.cs new file mode 100644 index 0000000..6c69f6d --- /dev/null +++ b/tests/CoreSyncServer.Tests/SyncProviderCacheInvalidationTests.cs @@ -0,0 +1,151 @@ +using System.Net; +using System.Net.Http.Json; +using System.Reflection; +using CoreSync; +using CoreSyncServer.Controllers; +using CoreSyncServer.Data; +using CoreSyncServer.Services; +using CoreSyncServer.Tests.Infrastructure; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace CoreSyncServer.Tests; + +/// +/// The sync provider cache never expires on its own, so every action that changes what a cached +/// provider was built from has to evict it. These cover the two that do not require unpublishing: +/// repointing an endpoint at another configuration, and editing the data store. +/// +public class SyncProviderCacheInvalidationTests : IClassFixture +{ + // SyncController keys an endpoint with no agent as "none". + private const string NoAgent = "none"; + + private readonly CustomWebApplicationFactory _factory; + + public SyncProviderCacheInvalidationTests(CustomWebApplicationFactory factory) + { + _factory = factory; + _host = factory.CreateAuthenticatedFactory(); + } + + private async Task<(int DataStoreId, int TargetConfigId, Guid EndpointId)> SeedPublishedEndpointAsync() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var project = new Project { Name = $"Cache Test {Guid.NewGuid():N}", CreatedDate = DateTime.UtcNow, IsEnabled = true }; + db.Projects.Add(project); + await db.SaveChangesAsync(); + + var dataStore = new SqliteDataStore + { + Name = "Cache Test DB", + FilePath = ":memory:", + ProjectId = project.Id, + Type = DataStoreType.SQLite + }; + db.DataStores.Add(dataStore); + await db.SaveChangesAsync(); + + var endpointId = Guid.NewGuid(); + var current = new DataStoreConfiguration + { + Name = "Current", + DataStoreId = dataStore.Id, + Endpoints = + { + new Endpoint + { + Id = endpointId, + Name = "Field devices", + IsPublished = true, + Authentication = new ApiKeyAuthentication { ApiKey = "key" } + } + } + }; + var next = new DataStoreConfiguration { Name = "Next", DataStoreId = dataStore.Id }; + db.DataStoreConfigurations.AddRange(current, next); + await db.SaveChangesAsync(); + + return (dataStore.Id, next.Id, endpointId); + } + + // The authenticated client runs against its own host, so the cache it invalidates is that + // host's singleton - not the one on the fixture. + private readonly WebApplicationFactory _host; + + private ISyncProviderCache Cache => _host.Services.GetRequiredService(); + + private void SeedCachedProvider(Guid endpointId) => + Cache.Set(endpointId, NoAgent, SentinelProvider.Create()); + + [Fact] + public async Task RepointingEndpoint_EvictsCachedProvider() + { + var (dataStoreId, targetConfigId, endpointId) = await SeedPublishedEndpointAsync(); + SeedCachedProvider(endpointId); + var client = _host.CreateAuthenticatedClient(); + + var response = await client.PutAsJsonAsync( + $"api/datastores/{dataStoreId}/endpoints/{endpointId}/configuration", + new DataStoresController.UpdateEndpointConfigurationRequest(targetConfigId)); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + Cache.TryGet(endpointId, NoAgent, out _).Should().BeFalse( + "the cached provider was built from the configuration the endpoint no longer points at"); + } + + [Fact] + public async Task UpdatingDataStore_EvictsCachedProvidersOfItsEndpoints() + { + var (dataStoreId, _, endpointId) = await SeedPublishedEndpointAsync(); + SeedCachedProvider(endpointId); + var client = _host.CreateAuthenticatedClient(); + + var response = await client.PutAsJsonAsync( + $"api/datastores/{dataStoreId}", + new DataStoresController.UpdateDataStoreRequest("Cache Test DB", null, "other.db", null, null, null, null)); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + Cache.TryGet(endpointId, NoAgent, out _).Should().BeFalse( + "the cached provider still holds the previous connection settings"); + } + + [Fact] + public async Task RepointingEndpoint_LeavesOtherEndpointsCached() + { + var (dataStoreId, targetConfigId, endpointId) = await SeedPublishedEndpointAsync(); + var (_, _, otherEndpointId) = await SeedPublishedEndpointAsync(); + SeedCachedProvider(endpointId); + SeedCachedProvider(otherEndpointId); + var client = _host.CreateAuthenticatedClient(); + + await client.PutAsJsonAsync( + $"api/datastores/{dataStoreId}/endpoints/{endpointId}/configuration", + new DataStoresController.UpdateEndpointConfigurationRequest(targetConfigId)); + + Cache.TryGet(otherEndpointId, NoAgent, out _).Should().BeTrue(); + } + + [Fact] + public void Invalidate_EvictsTheEndpointsEntry() + { + var endpointId = Guid.NewGuid(); + SeedCachedProvider(endpointId); + + Cache.Invalidate(endpointId); + + Cache.TryGet(endpointId, NoAgent, out _).Should().BeFalse(); + } + + /// Stands in for a real provider; the cache only stores the reference. + public class SentinelProvider : DispatchProxy + { + public static ISyncProvider Create() => Create(); + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) => + throw new NotSupportedException("Sentinel provider is never used for syncing."); + } +}