feat(discovery): schedule saved-search alerts

This commit is contained in:
cesnimda
2026-08-31 22:12:52 +02:00
parent 19a89018a0
commit b7029faaec
16 changed files with 423 additions and 113 deletions
@@ -3,6 +3,7 @@ using System.Net;
using System.Text;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -25,7 +26,7 @@ public sealed class JobDiscoveryControllerTests
var result = await controller.Search("backend", "oslo", CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var job = Assert.Single(Assert.IsAssignableFrom<IReadOnlyList<JobDiscoveryController.DiscoveredJob>>(ok.Value));
var job = Assert.Single(Assert.IsAssignableFrom<IReadOnlyList<DiscoveredJob>>(ok.Value));
Assert.Equal("Backend Developer", job.Title);
Assert.Equal("nav", job.Source);
Assert.Equal("NAV Arbeidsplassen", job.SourceName);
@@ -46,7 +47,7 @@ public sealed class JobDiscoveryControllerTests
var result = await controller.Search(null, null, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var job = Assert.Single(Assert.IsAssignableFrom<IReadOnlyList<JobDiscoveryController.DiscoveredJob>>(ok.Value));
var job = Assert.Single(Assert.IsAssignableFrom<IReadOnlyList<DiscoveredJob>>(ok.Value));
Assert.Equal("1", job.Id);
Assert.Equal("Current title", job.Title);
}
@@ -71,6 +72,8 @@ public sealed class JobDiscoveryControllerTests
Assert.IsType<NoContentResult>(await controller.DismissSavedSearchResult(created.Id, "job-1", new(true), default));
var listed = Assert.IsAssignableFrom<IReadOnlyList<JobDiscoveryController.SavedSearchDto>>(Assert.IsType<OkObjectResult>((await controller.ListSavedSearches(default)).Result).Value);
Assert.Equal(1, Assert.Single(listed).DismissedCount);
var paused = Assert.IsType<JobDiscoveryController.SavedSearchDto>(Assert.IsType<OkObjectResult>((await controller.UpdateSavedSearch(created.Id, new(false), default)).Result).Value);
Assert.False(paused.IsActive);
}
private sealed class ClientFactory(HttpClient client) : IHttpClientFactory
@@ -0,0 +1,98 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class SavedJobSearchAlertTests
{
[Fact]
public async Task Worker_establishes_baseline_then_notifies_only_for_later_results()
{
var clock = new MutableTimeProvider(new DateTimeOffset(2026, 8, 31, 8, 0, 0, TimeSpan.Zero));
var discovery = new FakeDiscovery(clock, "job-1");
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Workers:SavedJobSearchAlertsEnabled"] = "true",
["JobDiscovery:SavedSearchIntervalHours"] = "6"
}).Build();
var services = new ServiceCollection();
var databaseName = Guid.NewGuid().ToString();
services.AddLogging();
services.AddHttpContextAccessor();
services.AddSingleton<IConfiguration>(configuration);
services.AddSingleton<TimeProvider>(clock);
services.AddSingleton<IJobDiscoverySearchService>(discovery);
services.AddScoped<CurrentUserService>();
services.AddScoped<ICurrentUserService>(provider => provider.GetRequiredService<CurrentUserService>());
services.AddDbContext<JobTrackerContext>((_, options) => options.UseInMemoryDatabase(databaseName));
services.AddScoped<SavedJobSearchRunner>();
services.AddSingleton<BackgroundTenantRunner>();
await using var provider = services.BuildServiceProvider();
await using (var scope = provider.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
db.SavedJobSearches.Add(new SavedJobSearch
{
OwnerUserId = "owner-without-jobs",
Name = "Backend Oslo",
Query = "backend",
Location = "Oslo",
CreatedAtUtc = clock.GetUtcNow(),
UpdatedAtUtc = clock.GetUtcNow()
});
await db.SaveChangesAsync();
}
var worker = new SavedJobSearchAlertHostedService(
provider.GetRequiredService<BackgroundTenantRunner>(),
configuration,
NullLogger<SavedJobSearchAlertHostedService>.Instance,
Mock.Of<IStartupReadiness>(),
clock);
Assert.Equal(new BackgroundWorkerRunResult(true, 1, 1, 0), await worker.RunOnceAsync(default));
await using (var scope = provider.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
Assert.Empty(await db.UserNotifications.IgnoreQueryFilters().ToListAsync());
Assert.Single(await db.SavedJobSearchResults.IgnoreQueryFilters().ToListAsync());
}
clock.SetUtcNow(clock.GetUtcNow().AddHours(7));
discovery.Ids.Add("job-2");
Assert.Equal(new BackgroundWorkerRunResult(true, 1, 1, 0), await worker.RunOnceAsync(default));
await using var verification = provider.CreateAsyncScope();
var notification = Assert.Single(await verification.ServiceProvider.GetRequiredService<JobTrackerContext>()
.UserNotifications.IgnoreQueryFilters().AsNoTracking().ToListAsync());
Assert.Equal("owner-without-jobs", notification.OwnerUserId);
Assert.Equal("saved_search_results", notification.Kind);
Assert.Equal("/discover", notification.LinkPath);
Assert.Contains("Backend Oslo", notification.Message);
}
private sealed class FakeDiscovery(MutableTimeProvider clock, params string[] ids) : IJobDiscoverySearchService
{
public List<string> Ids { get; } = [.. ids];
public Task<IReadOnlyList<DiscoveredJob>> SearchAsync(string? query, string? location, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<DiscoveredJob>>(Ids.Select(id => new DiscoveredJob(
id, "Backend Developer", "Acme", "Oslo", clock.GetUtcNow(), null,
$"https://example.test/{id}", "nav", "NAV Arbeidsplassen", "searched", clock.GetUtcNow(), "NO")).ToList());
}
private sealed class MutableTimeProvider(DateTimeOffset now) : TimeProvider
{
private DateTimeOffset _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public void SetUtcNow(DateTimeOffset value) => _now = value;
}
}