Files
jobtrackingapp/JobTrackerApi.Tests/RuleSettingsFreshnessTests.cs
T

207 lines
9.6 KiB
C#

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 Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class RuleSettingsFreshnessTests
{
[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<NoContentResult>(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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(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<ISummarizerService>(), 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<NoContentResult>(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<bool> FollowUp(JobApplicationsController controller, int id, string surface)
{
if (surface == "readiness")
{
var result = await controller.GetReadiness(id, default);
return Assert.IsType<ReadinessDto>(Assert.IsType<OkObjectResult>(result.Result).Value).WorkflowSignal.NeedsFollowUpAction;
}
if (surface == "detail")
{
var result = await controller.GetById(id, default);
return Assert.IsType<JobApplicationDto>(Assert.IsType<OkObjectResult>(result.Result).Value).NeedsFollowUp;
}
if (surface == "list")
{
var result = await controller.GetAll();
return Assert.Single(Assert.IsType<PagedResult<JobApplicationDto>>(Assert.IsType<OkObjectResult>(result.Result).Value).Items).NeedsFollowUp;
}
if (surface == "board")
{
var result = await controller.GetBoard(needsFollowUp: true);
return Assert.IsType<List<JobApplication>>(Assert.IsType<OkObjectResult>(result.Result).Value).Any(job => job.Id == id);
}
var response = await controller.GetReminders();
var jobs = Assert.IsType<List<JobApplicationDto>>(Assert.IsType<OkObjectResult>(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)
{
var user = new Mock<ICurrentUserService>();
user.SetupGet(x => x.UserId).Returns(owner);
return new JobTrackerContext(new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(_connection).Options, user.Object);
}
public JobApplicationsController Controller(JobTrackerContext db) =>
new(db, Mock.Of<ISummarizerService>(), TestHostFactory.CreateUserManager().Object);
public static async Task<Fixture> 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 = "<<<APPLICATION_ANSWER_DRAFT>>>\nSynthetic answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>",
};
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();
}
}
}