using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using JobTrackerApi.Tests.TestSupport; using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Moq; using Xunit; 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")] [InlineData("detail")] [InlineData("reminders")] [InlineData("readiness")] public async Task Saved_owner_thresholds_are_visible_on_the_next_read(string surface) { await using var fixture = await Fixture.CreateAsync(); await using var read = fixture.Context("owner-a"); var controller = fixture.Controller(read); Assert.False(await FollowUp(controller, fixture.JobA, surface)); await using (var write = fixture.Context("owner-a")) Assert.IsType(await new RulesController(write).Update(Settings(1), default)); // Reuse the reader after the committed write: tracked entities must not mask persistence. Assert.True(await FollowUp(controller, fixture.JobA, surface)); } [Fact] public async Task Saving_owner_a_preserves_owner_b_and_existing_readers_see_subsequent_writes() { await using var fixture = await Fixture.CreateAsync(); await using var a = fixture.Context("owner-a"); await using var b = fixture.Context("owner-b"); var ca = fixture.Controller(a); var cb = fixture.Controller(b); Assert.False(await FollowUp(ca, fixture.JobA, "list")); Assert.False(await FollowUp(cb, fixture.JobB, "list")); await using (var write = fixture.Context("owner-a")) await new RulesController(write).Update(Settings(1), default); Assert.True(await FollowUp(ca, fixture.JobA, "list")); Assert.False(await FollowUp(cb, fixture.JobB, "list")); // A later write in the opposite direction must also be visible through both old/new controllers. await using (var write = fixture.Context("owner-a")) await new RulesController(write).Update(Settings(30), default); await using var fresh = fixture.Context("owner-a"); Assert.False(await FollowUp(fixture.Controller(fresh), fixture.JobA, "list")); Assert.False(await FollowUp(ca, fixture.JobA, "list")); } [Fact] public async Task Completing_an_older_request_cannot_restore_old_rules_after_a_save() { await using var fixture = await Fixture.CreateAsync(); await using var oldRead = fixture.Context("owner-a"); var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var users = TestHostFactory.CreateUserManager(); users.Setup(x => x.FindByIdAsync("owner-a")).Returns(async () => { // GetById has already loaded/evaluated the old rules; no model is called. reached.TrySetResult(true); await release.Task; return (ApplicationUser?)null; }); var older = new JobApplicationsController(oldRead, Mock.Of(), users.Object) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "owner-a") }, "test")), } }, }; var pending = FollowUp(older, fixture.JobA, "detail"); try { await reached.Task.WaitAsync(TimeSpan.FromSeconds(10)); await using (var write = fixture.Context("owner-a")) await new RulesController(write).Update(Settings(1), default); await using var freshRead = fixture.Context("owner-a"); var fresh = fixture.Controller(freshRead); Assert.True(await FollowUp(fresh, fixture.JobA, "list")); release.TrySetResult(true); Assert.False(await pending); // A request begun before the save may return its earlier snapshot. Assert.True(await FollowUp(fresh, fixture.JobA, "list")); } finally { release.TrySetResult(true); await pending; } } [Fact] public async Task Global_save_refreshes_fallback_but_preserves_explicit_owner_override() { await using var fixture = await Fixture.CreateAsync(); await using (var setup = fixture.Context("owner-a")) { setup.UserRuleSettings.Remove(await setup.UserRuleSettings.SingleAsync()); await setup.SaveChangesAsync(); } await using var fallback = fixture.Context("owner-a"); await using var overridden = fixture.Context("owner-b"); var ca = fixture.Controller(fallback); var cb = fixture.Controller(overridden); Assert.False(await FollowUp(ca, fixture.JobA, "list")); Assert.False(await FollowUp(cb, fixture.JobB, "list")); await using (var global = fixture.Context(null)) Assert.IsType(await new RulesController(global).Update(Settings(1), default)); Assert.True(await FollowUp(ca, fixture.JobA, "list")); Assert.False(await FollowUp(cb, fixture.JobB, "list")); } private static RuleSettings Settings(int followUp) => new() { AppliedFollowUpDays = followUp, AppliedGhostDays = 365, OfferFollowUpDays = 30, OfferGhostDays = 365, FeedbackFollowUpDays = 30, FeedbackGhostDays = 365, }; private static async Task FollowUp(JobApplicationsController controller, int id, string surface) { if (surface == "readiness") { var result = await controller.GetReadiness(id, default); return Assert.IsType(Assert.IsType(result.Result).Value).WorkflowSignal.NeedsFollowUpAction; } if (surface == "detail") { var result = await controller.GetById(id, default); return Assert.IsType(Assert.IsType(result.Result).Value).NeedsFollowUp; } if (surface == "list") { var result = await controller.GetAll(); return Assert.Single(Assert.IsType>(Assert.IsType(result.Result).Value).Items).NeedsFollowUp; } if (surface == "board") { var result = await controller.GetBoard(needsFollowUp: true); return Assert.IsType>(Assert.IsType(result.Result).Value).Any(job => job.Id == id); } var response = await controller.GetReminders(); var jobs = Assert.IsType>(Assert.IsType(response.Result).Value); return jobs.SingleOrDefault(job => job.Id == id)?.NeedsFollowUp ?? false; } private sealed class Fixture : IAsyncDisposable { private readonly SqliteConnection _connection = new("Data Source=:memory:"); public int JobA { get; private set; } public int JobB { get; private set; } public JobTrackerContext Context(string? owner, IInterceptor? interceptor = null) { var user = new Mock(); user.SetupGet(x => x.UserId).Returns(owner); 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) => new(db, Mock.Of(), TestHostFactory.CreateUserManager().Object); public static async Task CreateAsync() { var fixture = new Fixture(); await fixture._connection.OpenAsync(); await using var db = fixture.Context("owner-a"); await db.Database.EnsureCreatedAsync(); db.Users.AddRange(new ApplicationUser { Id = "owner-a", UserName = "owner-a" }, new ApplicationUser { Id = "owner-b", UserName = "owner-b" }); var global = await db.RuleSettings.SingleAsync(); global.AppliedFollowUpDays = 30; global.AppliedGhostDays = 365; foreach (var owner in new[] { "owner-a", "owner-b" }) { await using var owned = fixture.Context(owner); owned.UserRuleSettings.Add(new UserRuleSettings { OwnerUserId = owner, AppliedFollowUpDays = 30, AppliedGhostDays = 365 }); // Complete package avoids unrelated package-gap reminders masking the rule decision. var job = new JobApplication { OwnerUserId = owner, Company = new Company { Name = owner, OwnerUserId = owner }, JobTitle = "Synthetic engineer", Status = "Applied", DateApplied = DateTime.Now.AddDays(-10), FollowUpAt = DateTime.Now.AddDays(100), TailoredCvText = "Synthetic CV", Notes = "<<>>\nSynthetic answer\n<<>>", }; owned.JobApplications.Add(job); // Users must exist before owner-scoped rows are inserted. await db.SaveChangesAsync(); await owned.SaveChangesAsync(); if (owner == "owner-a") fixture.JobA = job.Id; else fixture.JobB = job.Id; } return fixture; } public async ValueTask DisposeAsync() { await _connection.DisposeAsync(); } } }