Files
jobtrackingapp/JobTrackerApi.Tests/SavedJobSearchSqliteTests.cs
T

227 lines
12 KiB
C#

using System.Security.Claims;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
using Xunit.Abstractions;
namespace JobTrackerApi.Tests;
public sealed class SavedJobSearchSqliteTests(ITestOutputHelper output)
{
[Fact]
public async Task Empty_saved_search_list_succeeds_without_discovery_calls()
{
await using var fixture = await SqliteFixture.CreateAsync();
Assert.Empty(await List(fixture.Controller));
fixture.Discovery.VerifyNoOtherCalls();
}
[Fact]
public async Task List_reads_owned_persisted_summaries_in_descending_instant_order()
{
await using var fixture = await SqliteFixture.CreateAsync();
// Local clock text sorts oppositely to the instants; preserve DateTimeOffset ordering.
var older = Search("owner-1", "Older", new(2026, 10, 9, 12, 0, 0, TimeSpan.FromHours(3)));
older.IsActive = false;
var newer = Search("owner-1", "Newer", new(2026, 10, 9, 10, 30, 0, TimeSpan.FromHours(-3)));
newer.LastRunAtUtc = newer.UpdatedAtUtc.AddHours(-1);
newer.Results = [Result("seen", false), Result("dismissed-1", true), Result("dismissed-2", true)];
var foreign = Search("owner-2", "Private foreign search", newer.UpdatedAtUtc.AddDays(1));
foreign.Results = [Result("private-result", true)];
fixture.Db.SavedJobSearches.AddRange(older, newer, foreign);
await fixture.Db.SaveChangesAsync();
fixture.Db.ChangeTracker.Clear();
fixture.Sql.Clear();
var listed = await List(fixture.Controller);
Assert.Equal(new[]
{
new JobDiscoveryController.SavedSearchDto(newer.Id, newer.Name, newer.Query, newer.Location, true, newer.LastRunAtUtc, 3, 2),
new JobDiscoveryController.SavedSearchDto(older.Id, older.Name, older.Query, older.Location, false, null, 0, 0)
}, listed);
Assert.Empty(fixture.Db.ChangeTracker.Entries());
var sql = Assert.Single(fixture.Sql);
output.WriteLine(sql);
Assert.Contains("COUNT(*)", sql);
Assert.Contains("WHERE", sql);
Assert.Contains("\"OwnerUserId\" =", sql);
Assert.DoesNotContain("\"FirstSeenAtUtc\"", sql);
fixture.Discovery.VerifyNoOtherCalls();
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Foreign_and_missing_search_mutations_are_denied_without_provider_or_writes(bool missing)
{
await using var fixture = await SqliteFixture.CreateAsync();
var foreign = Search("owner-2", "Private foreign search", DateTimeOffset.UtcNow.AddDays(-1));
foreign.Results = [Result("private-result", false)];
fixture.Db.SavedJobSearches.Add(foreign);
await fixture.Db.SaveChangesAsync();
fixture.Db.ChangeTracker.Clear();
var id = missing ? foreign.Id + 1 : foreign.Id;
Assert.IsType<NotFoundResult>((await fixture.Controller.UpdateSavedSearch(id, new(false), default)).Result);
Assert.IsType<NotFoundResult>(await fixture.Controller.DeleteSavedSearch(id, default));
Assert.IsType<NotFoundResult>((await fixture.Controller.RunSavedSearch(id, default)).Result);
Assert.IsType<NotFoundResult>(await fixture.Controller.DismissSavedSearchResult(id, "private-result", new(true), default));
var retained = Assert.Single(await fixture.Db.SavedJobSearches.IgnoreQueryFilters().AsNoTracking().ToListAsync());
Assert.True(retained.IsActive);
Assert.Null(retained.LastRunAtUtc);
Assert.Equal(foreign.UpdatedAtUtc, retained.UpdatedAtUtc);
Assert.False(Assert.Single(await fixture.Db.SavedJobSearchResults.IgnoreQueryFilters().AsNoTracking().ToListAsync()).IsDismissed);
fixture.Discovery.VerifyNoOtherCalls();
}
[Fact]
public async Task Saved_search_endpoints_require_a_current_owner()
{
await using var fixture = await SqliteFixture.CreateAsync(owner: null);
Assert.IsType<UnauthorizedResult>((await fixture.Controller.ListSavedSearches(default)).Result);
Assert.IsType<UnauthorizedResult>((await fixture.Controller.CreateSavedSearch(new("Name", "query", "Oslo"), default)).Result);
Assert.IsType<UnauthorizedResult>((await fixture.Controller.UpdateSavedSearch(1, new(false), default)).Result);
Assert.IsType<UnauthorizedResult>(await fixture.Controller.DeleteSavedSearch(1, default));
Assert.IsType<UnauthorizedResult>((await fixture.Controller.RunSavedSearch(1, default)).Result);
Assert.IsType<UnauthorizedResult>(await fixture.Controller.DismissSavedSearchResult(1, "job-1", new(true), default));
Assert.Empty(await fixture.Db.SavedJobSearches.IgnoreQueryFilters().ToListAsync());
fixture.Discovery.VerifyNoOtherCalls();
}
[Fact]
public async Task Saved_search_filters_run_state_alert_toggle_dismiss_and_delete_survive_sqlite_readback()
{
await using var fixture = await SqliteFixture.CreateAsync();
fixture.Discovery.SetupSequence(x => x.SearchAsync("backend", "Oslo", It.IsAny<CancellationToken>()))
.ReturnsAsync(new[] { Job("job-1") })
.ReturnsAsync(new[] { Job("job-1"), Job("job-2") });
var created = Assert.IsType<JobDiscoveryController.SavedSearchDto>(Assert.IsType<CreatedAtActionResult>(
(await fixture.Controller.CreateSavedSearch(new(" Backend Oslo ", " backend ", " Oslo "), default)).Result).Value);
Assert.Equal("Backend Oslo", created.Name);
Assert.Equal("backend", created.Query);
Assert.Equal("Oslo", created.Location);
Assert.True(created.IsActive);
fixture.Db.ChangeTracker.Clear();
var first = Assert.IsType<JobDiscoveryController.SavedSearchRunDto>(Assert.IsType<OkObjectResult>(
(await fixture.Controller.RunSavedSearch(created.Id, default)).Result).Value);
Assert.True(Assert.Single(first.Jobs).IsNew);
Assert.NotNull(first.Search.LastRunAtUtc);
Assert.IsType<NoContentResult>(await fixture.Controller.DismissSavedSearchResult(created.Id, "job-1", new(true), default));
fixture.Db.ChangeTracker.Clear();
var second = Assert.IsType<JobDiscoveryController.SavedSearchRunDto>(Assert.IsType<OkObjectResult>(
(await fixture.Controller.RunSavedSearch(created.Id, default)).Result).Value);
Assert.Collection(second.Jobs,
job => { Assert.False(job.IsNew); Assert.True(job.IsDismissed); },
job => { Assert.True(job.IsNew); Assert.False(job.IsDismissed); });
var paused = Assert.IsType<JobDiscoveryController.SavedSearchDto>(Assert.IsType<OkObjectResult>(
(await fixture.Controller.UpdateSavedSearch(created.Id, new(false), default)).Result).Value);
Assert.False(paused.IsActive);
fixture.Db.ChangeTracker.Clear();
Assert.Equal(paused, Assert.Single(await List(fixture.Controller)));
Assert.IsType<NoContentResult>(await fixture.Controller.DismissSavedSearchResult(created.Id, "job-1", new(false), default));
var resumed = Assert.IsType<JobDiscoveryController.SavedSearchDto>(Assert.IsType<OkObjectResult>(
(await fixture.Controller.UpdateSavedSearch(created.Id, new(true), default)).Result).Value);
Assert.True(resumed.IsActive);
Assert.Equal(2, resumed.ResultCount);
Assert.Equal(0, resumed.DismissedCount);
fixture.Db.ChangeTracker.Clear();
Assert.Equal(resumed, Assert.Single(await List(fixture.Controller)));
Assert.IsType<NoContentResult>(await fixture.Controller.DeleteSavedSearch(created.Id, default));
Assert.Empty(await fixture.Db.SavedJobSearches.IgnoreQueryFilters().ToListAsync());
Assert.Empty(await fixture.Db.SavedJobSearchResults.IgnoreQueryFilters().ToListAsync());
fixture.Discovery.Verify(x => x.SearchAsync("backend", "Oslo", It.IsAny<CancellationToken>()), Times.Exactly(2));
fixture.Discovery.VerifyNoOtherCalls();
}
[Theory]
[InlineData("name-empty")]
[InlineData("name-long")]
[InlineData("query-long")]
[InlineData("location-long")]
public async Task Invalid_saved_search_filters_do_not_write(string invalid)
{
await using var fixture = await SqliteFixture.CreateAsync();
var request = new JobDiscoveryController.SavedSearchRequest(
invalid == "name-empty" ? " " : invalid == "name-long" ? new string('n', 256) : "Name",
invalid == "query-long" ? new string('q', 501) : "query",
invalid == "location-long" ? new string('l', 256) : "Oslo");
var result = Assert.IsAssignableFrom<ObjectResult>((await fixture.Controller.CreateSavedSearch(request, default)).Result);
Assert.Equal("Provide a name up to 255 characters and valid search filters.", Assert.IsType<ValidationProblemDetails>(result.Value).Detail);
Assert.Empty(await fixture.Db.SavedJobSearches.IgnoreQueryFilters().ToListAsync());
fixture.Discovery.VerifyNoOtherCalls();
}
private static async Task<IReadOnlyList<JobDiscoveryController.SavedSearchDto>> List(JobDiscoveryController controller) =>
Assert.IsAssignableFrom<IReadOnlyList<JobDiscoveryController.SavedSearchDto>>(
Assert.IsType<OkObjectResult>((await controller.ListSavedSearches(default)).Result).Value);
private static SavedJobSearch Search(string owner, string name, DateTimeOffset updated) => new()
{
OwnerUserId = owner, Name = name, Query = "backend", Location = "Oslo", CreatedAtUtc = updated.AddDays(-1), UpdatedAtUtc = updated
};
private static SavedJobSearchResult Result(string id, bool dismissed) => new()
{
ExternalJobId = id, IsDismissed = dismissed, FirstSeenAtUtc = DateTimeOffset.UtcNow.AddDays(-1), LastSeenAtUtc = DateTimeOffset.UtcNow
};
private static DiscoveredJob Job(string id) => new(id, "Backend developer", "Acme", "Oslo", DateTimeOffset.UtcNow, null,
$"https://example.test/{id}", "nav", "NAV Arbeidsplassen", "searched", DateTimeOffset.UtcNow, "NO");
private sealed class SqliteFixture(SqliteConnection connection, JobTrackerContext db, JobDiscoveryController controller,
Mock<IJobDiscoverySearchService> discovery, MemoryCache cache, List<string> sql) : IAsyncDisposable
{
public JobTrackerContext Db { get; } = db;
public JobDiscoveryController Controller { get; } = controller;
public Mock<IJobDiscoverySearchService> Discovery { get; } = discovery;
public List<string> Sql { get; } = sql;
public static async Task<SqliteFixture> CreateAsync(string? owner = "owner-1")
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var http = new DefaultHttpContext();
if (owner is not null)
http.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, owner)], "local"));
var accessor = new Mock<IHttpContextAccessor>();
accessor.SetupGet(x => x.HttpContext).Returns(http);
var currentUser = new CurrentUserService(accessor.Object);
var sql = new List<string>();
var options = new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection)
.LogTo(sql.Add, [DbLoggerCategory.Database.Command.Name], LogLevel.Information).Options;
var db = new JobTrackerContext(options, currentUser);
await db.Database.EnsureCreatedAsync();
var discovery = new Mock<IJobDiscoverySearchService>(MockBehavior.Strict);
var cache = new MemoryCache(new MemoryCacheOptions());
var controller = new JobDiscoveryController(Mock.Of<IHttpClientFactory>(), new ConfigurationBuilder().Build(), cache,
db, currentUser, discovery.Object) { ControllerContext = new ControllerContext { HttpContext = http } };
return new SqliteFixture(connection, db, controller, discovery, cache, sql);
}
public async ValueTask DisposeAsync()
{
await Db.DisposeAsync();
await connection.DisposeAsync();
cache.Dispose();
}
}
}