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(); } } }