207 lines
9.6 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|