227 lines
12 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|