diff --git a/JobTrackerApi.Tests/RuleSettingsFreshnessTests.cs b/JobTrackerApi.Tests/RuleSettingsFreshnessTests.cs index b58273a4..5c4e3fb7 100644 --- a/JobTrackerApi.Tests/RuleSettingsFreshnessTests.cs +++ b/JobTrackerApi.Tests/RuleSettingsFreshnessTests.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Moq; using Xunit; @@ -15,6 +16,43 @@ namespace JobTrackerApi.Tests; public sealed class RuleSettingsFreshnessTests { + [Fact] + public async Task Failed_owner_save_preserves_committed_rules_and_a_later_success_refreshes_existing_readers() + { + await using var fixture = await Fixture.CreateAsync(); + await using var readA = fixture.Context("owner-a"); + await using var readB = fixture.Context("owner-b"); + var ownerA = fixture.Controller(readA); + var ownerB = fixture.Controller(readB); + Assert.False(await FollowUp(ownerA, fixture.JobA, "list")); + Assert.False(await FollowUp(ownerB, fixture.JobB, "list")); + + var failure = new SaveFailureInterceptor(); + await using var write = fixture.Context("owner-a", failure); + await Assert.ThrowsAsync(() => new RulesController(write).Update(Settings(1), default)); + Assert.Equal(1, write.UserRuleSettings.Local.Single().AppliedFollowUpDays); + Assert.Equal(30, (await RulesEngine.GetSettings(write, default)).AppliedFollowUpDays); + Assert.False(await FollowUp(ownerA, fixture.JobA, "list")); + Assert.False(await FollowUp(ownerB, fixture.JobB, "list")); + + failure.RejectWrites = false; + Assert.IsType(await new RulesController(write).Update(Settings(1), default)); + Assert.True(await FollowUp(ownerA, fixture.JobA, "list")); + Assert.False(await FollowUp(ownerB, fixture.JobB, "list")); + } + + private sealed class SaveFailureInterceptor : SaveChangesInterceptor + { + public bool RejectWrites { get; set; } = true; + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + if (RejectWrites) throw new DbUpdateException("Synthetic settings storage failure"); + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + } + [Theory] [InlineData("list")] [InlineData("board")] @@ -158,11 +196,13 @@ public sealed class RuleSettingsFreshnessTests public int JobA { get; private set; } public int JobB { get; private set; } - public JobTrackerContext Context(string? owner) + public JobTrackerContext Context(string? owner, IInterceptor? interceptor = null) { var user = new Mock(); user.SetupGet(x => x.UserId).Returns(owner); - return new JobTrackerContext(new DbContextOptionsBuilder().UseSqlite(_connection).Options, user.Object); + var options = new DbContextOptionsBuilder().UseSqlite(_connection); + if (interceptor is not null) options.AddInterceptors(interceptor); + return new JobTrackerContext(options.Options, user.Object); } public JobApplicationsController Controller(JobTrackerContext db) =>