perf(applications): bound correspondence reads to the current page
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using System.Data.Common;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
@@ -8,6 +9,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
@@ -16,6 +18,78 @@ namespace JobTrackerApi.Tests;
|
||||
// Real SQLite: EF InMemory cannot prove LIKE, relational ordering or tenant joins.
|
||||
public sealed class JobQueryParityTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(250)]
|
||||
[InlineData(2500)]
|
||||
public async Task Ordinary_table_materialises_correspondence_groups_only_for_the_returned_page(int size)
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var aggregate = new CorrespondenceAggregateRecorder();
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).AddInterceptors(aggregate).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" });
|
||||
var company = new Company { Name = "Synthetic", OwnerUserId = "owner" };
|
||||
var jobs = Enumerable.Range(0, size).Select(i => new JobApplication
|
||||
{
|
||||
Company = company, OwnerUserId = "owner", JobTitle = $"Application {i:D4}", Status = "Applied",
|
||||
DateApplied = new DateTime(2026, 10, 1),
|
||||
}).ToList();
|
||||
db.JobApplications.AddRange(jobs);
|
||||
await db.SaveChangesAsync();
|
||||
db.Correspondences.AddRange(jobs.Select(job => new Correspondence
|
||||
{
|
||||
JobApplicationId = job.Id, Content = "Synthetic mail", From = "Company", Date = new DateTime(2026, 10, 2),
|
||||
}));
|
||||
await db.SaveChangesAsync();
|
||||
user.SetupGet(x => x.UserId).Returns("other");
|
||||
var foreign = new JobApplication { Company = new Company { Name = "Foreign", OwnerUserId = "other" }, OwnerUserId = "other", JobTitle = "Foreign", Status = "Applied" };
|
||||
db.JobApplications.Add(foreign);
|
||||
await db.SaveChangesAsync();
|
||||
db.Correspondences.Add(new Correspondence { JobApplicationId = foreign.Id, Content = "Foreign mail", From = "Company", Date = new DateTime(2026, 10, 3) });
|
||||
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 Claim(ClaimTypes.NameIdentifier, "owner")], "test")) } }
|
||||
};
|
||||
|
||||
var result = await controller.GetAll(page: 2, pageSize: 15, sortBy: "jobTitle", sortDir: "asc");
|
||||
var page = Assert.IsType<PagedResult<JobApplicationDto>>(Assert.IsType<OkObjectResult>(result.Result).Value);
|
||||
Assert.Equal(size, page.Total);
|
||||
var expected = jobs.Skip(15).Take(15).Select(job => job.Id).ToArray();
|
||||
Assert.Equal(expected, page.Items.Select(job => job.Id));
|
||||
var (sql, parameters) = Assert.Single(aggregate.Commands);
|
||||
await using var replay = connection.CreateCommand();
|
||||
replay.CommandText = sql;
|
||||
foreach (var parameter in parameters) replay.Parameters.AddWithValue(parameter.Key, parameter.Value ?? DBNull.Value);
|
||||
var materialised = new List<int>();
|
||||
await using (var reader = await replay.ExecuteReaderAsync())
|
||||
while (await reader.ReadAsync()) materialised.Add(reader.GetInt32(0));
|
||||
Assert.Equal(15, materialised.Count);
|
||||
Assert.Equal(expected.Order(), materialised.Order());
|
||||
|
||||
aggregate.Commands.Clear();
|
||||
await controller.GetBoard();
|
||||
await controller.GetAll(q: "absent");
|
||||
Assert.Empty(aggregate.Commands);
|
||||
}
|
||||
|
||||
private sealed class CorrespondenceAggregateRecorder : DbCommandInterceptor
|
||||
{
|
||||
public List<(string Sql, Dictionary<string, object?> Parameters)> Commands { get; } = [];
|
||||
|
||||
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (command.CommandText.Contains("MAX(", StringComparison.OrdinalIgnoreCase) && command.CommandText.Contains("\"Correspondences\"", StringComparison.Ordinal))
|
||||
Commands.Add((command.CommandText, command.Parameters.Cast<DbParameter>().ToDictionary(p => p.ParameterName, p => p.Value)));
|
||||
return ValueTask.FromResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("all")]
|
||||
[InlineData("search")]
|
||||
|
||||
Reference in New Issue
Block a user