Files
jobtrackingapp/JobTrackerApi.Tests/JobQueryParityTests.cs
T
2026-09-29 17:26:08 +02:00

108 lines
6.0 KiB
C#

using System.Security.Claims;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
// Real SQLite: EF InMemory cannot prove LIKE, relational ordering or tenant joins.
public sealed class JobQueryParityTests
{
[Theory]
[InlineData("all")]
[InlineData("search")]
[InlineData("notes")]
[InlineData("correspondence")]
[InlineData("standard")]
[InlineData("custom")]
[InlineData("combined")]
[InlineData("none")]
[InlineData("company")]
[InlineData("foreign-company")]
[InlineData("location")]
[InlineData("follow-up")]
[InlineData("needs-work")]
[InlineData("interview")]
[InlineData("deleted")]
[InlineData("include-deleted")]
public async Task Table_pages_and_board_have_identical_owned_membership(string scenario)
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options;
var user = new Mock<ICurrentUserService>();
user.SetupGet(x => x.UserId).Returns("owner");
await using var db = new JobTrackerContext(options, user.Object);
await db.Database.EnsureCreatedAsync();
db.Users.AddRange(new ApplicationUser { Id = "owner", UserName = "owner" }, new ApplicationUser { Id = "other", UserName = "other" });
await db.SaveChangesAsync();
var company = new Company { Name = "Nordlys", OwnerUserId = "owner" };
var jobs = Enumerable.Range(0, 62).Select(i => new JobApplication
{
Company = company, OwnerUserId = "owner", JobTitle = i == 0 ? "Seniorrådgiver" : $"Board {i:D2}",
Status = i == 1 ? "Custom review" : i % 3 == 0 ? "Interview" : "Applied",
Location = i % 2 == 0 ? "Tromsø" : "Oslo", IsDeleted = i == 61,
DateApplied = DateTime.UtcNow.Date.AddDays(-20), Notes = i == 2 ? "note-needle" : null,
}).ToList();
db.JobApplications.AddRange(jobs);
await db.SaveChangesAsync();
db.Correspondences.Add(new Correspondence { JobApplicationId = jobs[3].Id, Content = "mail-needle", From = "Company", Date = DateTime.UtcNow.AddDays(-19) });
await db.SaveChangesAsync();
user.SetupGet(x => x.UserId).Returns("other");
var foreign = new JobApplication { Company = new Company { Name = "Foreign", OwnerUserId = "other" }, OwnerUserId = "other", JobTitle = "Board private", Status = "Applied" };
db.JobApplications.Add(foreign);
await db.SaveChangesAsync();
user.SetupGet(x => x.UserId).Returns("owner");
var controller = new JobApplicationsController(db, Mock.Of<ISummarizerService>(), TestHostFactory.CreateUserManager().Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "owner") }, "test")) } }
};
var q = scenario switch { "search" or "combined" => " Board ", "notes" => "note-needle", "correspondence" => "mail-needle", "none" => "absent", _ => null };
var status = scenario switch { "standard" or "combined" => "Applied", "custom" => "Custom review", _ => null };
int? companyId = scenario == "company" ? company.Id : scenario == "foreign-company" ? foreign.CompanyId : null;
var location = scenario == "location" ? "Tromsø" : null;
var follow = scenario == "follow-up";
var readiness = scenario is "needs-work" or "interview" ? scenario : null;
var deleted = scenario == "deleted";
var includeDeleted = deleted || scenario == "include-deleted";
var board = Assert.IsType<List<JobApplication>>(Assert.IsType<OkObjectResult>((await controller.GetBoard(
includeDeleted: includeDeleted, q: q, status: status, companyId: companyId, location: location,
needsFollowUp: follow, readiness: readiness, deletedOnly: deleted, sortBy: "dateApplied", sortDir: "desc")).Result).Value);
var ids = new List<int>();
var page = 1;
int total;
do
{
var result = await controller.GetAll(page: page++, pageSize: 15, q: q, status: status, companyId: companyId,
location: location, needsFollowUp: follow, readiness: readiness, includeDeleted: includeDeleted,
deletedOnly: deleted, sortBy: "dateApplied", sortDir: "desc");
var data = Assert.IsType<PagedResult<JobApplicationDto>>(Assert.IsType<OkObjectResult>(result.Result).Value);
Assert.InRange(data.Items.Count, 0, 15);
total = data.Total;
if (ids.Count < total) Assert.NotEmpty(data.Items); // fail rather than loop on a broken page
ids.AddRange(data.Items.Select(x => x.Id));
} while (ids.Count < total);
Assert.Equal(total, board.Count);
Assert.Equal(ids, board.Select(x => x.Id)); // includes stable ordering across tied dates/pages
Assert.Equal(ids.Count, ids.Distinct().Count());
Assert.DoesNotContain(foreign.Id, ids);
if (scenario is "none" or "foreign-company") Assert.Empty(ids);
else Assert.NotEmpty(ids); // parity must not pass vacuously for derived filters
if (scenario == "all") Assert.Equal(61, total);
if (scenario == "search") Assert.Equal(60, total);
if (scenario is "notes" or "correspondence" or "custom" or "deleted") Assert.Single(ids);
if (scenario == "include-deleted") Assert.Equal(62, total);
Assert.IsType<BadRequestObjectResult>((await controller.GetBoard(readiness: "invented")).Result);
Assert.IsType<BadRequestObjectResult>((await controller.GetAll(readiness: "invented")).Result);
}
}