From bcc959904915e381ef6bfe5c236d9626021681d4 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 9 Oct 2026 14:44:36 +0200 Subject: [PATCH] fix(discovery): restore SQLite saved-search listing --- .../SavedJobSearchSqliteTests.cs | 226 ++++++++++++++++++ .../Controllers/JobDiscoveryController.cs | 15 +- 2 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 JobTrackerApi.Tests/SavedJobSearchSqliteTests.cs diff --git a/JobTrackerApi.Tests/SavedJobSearchSqliteTests.cs b/JobTrackerApi.Tests/SavedJobSearchSqliteTests.cs new file mode 100644 index 0000000..98edbe0 --- /dev/null +++ b/JobTrackerApi.Tests/SavedJobSearchSqliteTests.cs @@ -0,0 +1,226 @@ +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((await fixture.Controller.UpdateSavedSearch(id, new(false), default)).Result); + Assert.IsType(await fixture.Controller.DeleteSavedSearch(id, default)); + Assert.IsType((await fixture.Controller.RunSavedSearch(id, default)).Result); + Assert.IsType(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((await fixture.Controller.ListSavedSearches(default)).Result); + Assert.IsType((await fixture.Controller.CreateSavedSearch(new("Name", "query", "Oslo"), default)).Result); + Assert.IsType((await fixture.Controller.UpdateSavedSearch(1, new(false), default)).Result); + Assert.IsType(await fixture.Controller.DeleteSavedSearch(1, default)); + Assert.IsType((await fixture.Controller.RunSavedSearch(1, default)).Result); + Assert.IsType(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())) + .ReturnsAsync(new[] { Job("job-1") }) + .ReturnsAsync(new[] { Job("job-1"), Job("job-2") }); + var created = Assert.IsType(Assert.IsType( + (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(Assert.IsType( + (await fixture.Controller.RunSavedSearch(created.Id, default)).Result).Value); + Assert.True(Assert.Single(first.Jobs).IsNew); + Assert.NotNull(first.Search.LastRunAtUtc); + Assert.IsType(await fixture.Controller.DismissSavedSearchResult(created.Id, "job-1", new(true), default)); + fixture.Db.ChangeTracker.Clear(); + var second = Assert.IsType(Assert.IsType( + (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(Assert.IsType( + (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(await fixture.Controller.DismissSavedSearchResult(created.Id, "job-1", new(false), default)); + var resumed = Assert.IsType(Assert.IsType( + (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(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()), 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((await fixture.Controller.CreateSavedSearch(request, default)).Result); + + Assert.Equal("Provide a name up to 255 characters and valid search filters.", Assert.IsType(result.Value).Detail); + Assert.Empty(await fixture.Db.SavedJobSearches.IgnoreQueryFilters().ToListAsync()); + fixture.Discovery.VerifyNoOtherCalls(); + } + + private static async Task> List(JobDiscoveryController controller) => + Assert.IsAssignableFrom>( + Assert.IsType((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 discovery, MemoryCache cache, List sql) : IAsyncDisposable + { + public JobTrackerContext Db { get; } = db; + public JobDiscoveryController Controller { get; } = controller; + public Mock Discovery { get; } = discovery; + public List Sql { get; } = sql; + + public static async Task 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(); + accessor.SetupGet(x => x.HttpContext).Returns(http); + var currentUser = new CurrentUserService(accessor.Object); + var sql = new List(); + var options = new DbContextOptionsBuilder().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(MockBehavior.Strict); + var cache = new MemoryCache(new MemoryCacheOptions()); + var controller = new JobDiscoveryController(Mock.Of(), 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(); + } + } +} diff --git a/JobTrackerApi/Controllers/JobDiscoveryController.cs b/JobTrackerApi/Controllers/JobDiscoveryController.cs index a0dd39d..0c8a9c8 100644 --- a/JobTrackerApi/Controllers/JobDiscoveryController.cs +++ b/JobTrackerApi/Controllers/JobDiscoveryController.cs @@ -51,9 +51,18 @@ public sealed class JobDiscoveryController : ControllerBase public async Task>> ListSavedSearches(CancellationToken cancellationToken) { if (!TryGetPersistence(out var db, out var owner)) return Unauthorized(); - var searches = await db.SavedJobSearches.AsNoTracking().Include(x => x.Results) - .Where(x => x.OwnerUserId == owner).OrderByDescending(x => x.UpdatedAtUtc).ToListAsync(cancellationToken); - return Ok(searches.Select(ToDto).ToList()); + var query = db.SavedJobSearches.AsNoTracking().Where(x => x.OwnerUserId == owner) + .Select(x => new + { + x.UpdatedAtUtc, + Summary = new SavedSearchDto(x.Id, x.Name, x.Query, x.Location, x.IsActive, x.LastRunAtUtc, + x.Results.Count, x.Results.Count(result => result.IsDismissed)) + }); + // SQLite cannot order DateTimeOffset; sort owned summaries after SQL filtering/counts. + var searches = db.Database.IsSqlite() + ? (await query.ToListAsync(cancellationToken)).OrderByDescending(x => x.UpdatedAtUtc).ToList() + : await query.OrderByDescending(x => x.UpdatedAtUtc).ToListAsync(cancellationToken); + return Ok(searches.Select(x => x.Summary).ToList()); } [HttpPost("saved-searches")]