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 { ["Workers:SavedJobSearchAlertsEnabled"] = "true", ["JobDiscovery:SavedSearchIntervalHours"] = "6" }).Build(); var services = new ServiceCollection(); var databaseName = Guid.NewGuid().ToString(); services.AddLogging(); services.AddHttpContextAccessor(); services.AddSingleton(configuration); services.AddSingleton(clock); services.AddSingleton(discovery); services.AddScoped(); services.AddScoped(provider => provider.GetRequiredService()); services.AddDbContext((_, options) => options.UseInMemoryDatabase(databaseName)); services.AddScoped(); services.AddSingleton(); await using var provider = services.BuildServiceProvider(); await using (var scope = provider.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); 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(), configuration, NullLogger.Instance, Mock.Of(), clock); Assert.Equal(new BackgroundWorkerRunResult(true, 1, 1, 0), await worker.RunOnceAsync(default)); await using (var scope = provider.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); 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() .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 Ids { get; } = [.. ids]; public Task> SearchAsync(string? query, string? location, CancellationToken cancellationToken) => Task.FromResult>(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; } }