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().UseSqlite(connection).Options; var user = new Mock(); 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(), 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>(Assert.IsType((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(); 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>(Assert.IsType(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((await controller.GetBoard(readiness: "invented")).Result); Assert.IsType((await controller.GetAll(readiness: "invented")).Result); } }