using System.Collections.Concurrent; using System.Text.Json; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class BackgroundWorkerTenantTests { [Fact] public async Task Runner_enters_each_owner_filter_and_isolates_owner_failures() { await using var fixture = await Fixture.CreateAsync(); await fixture.SeedJobsAsync(); var seen = new ConcurrentDictionary(); var result = await fixture.Runner.RunForJobOwnersAsync("test", async (services, cancellationToken) => { var db = services.GetRequiredService(); var owner = Assert.IsType(db.CurrentUserId); seen[owner] = await db.JobApplications.Select(job => job.OwnerUserId!).Distinct().ToArrayAsync(cancellationToken); if (owner == "user-1") throw new InvalidOperationException("synthetic owner failure"); }, default); Assert.Equal(new BackgroundWorkerRunResult(true, 2, 1, 1), result); Assert.Equal(new[] { "user-1" }, seen["user-1"]); Assert.Equal(new[] { "user-2" }, seen["user-2"]); } [Fact] public void Background_owner_cannot_replace_an_http_identity_context() { var accessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() }; var currentUser = new CurrentUserService(accessor); Assert.Throws(() => currentUser.UseBackgroundUser("user-1")); } [Fact] public async Task Rules_worker_uses_each_owners_settings_and_is_idempotent() { await using var fixture = await Fixture.CreateAsync(new Dictionary { ["Workers:RulesEnabled"] = "true" }); await fixture.SeedJobsAsync(); await using (var scope = fixture.Provider.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.UserRuleSettings.AddRange( new UserRuleSettings { OwnerUserId = "user-1", AppliedFollowUpDays = 1, AppliedGhostDays = 5 }, new UserRuleSettings { OwnerUserId = "user-2", AppliedFollowUpDays = 40, AppliedGhostDays = 60 }); await db.SaveChangesAsync(); } var worker = new RulesHostedService( fixture.Runner, fixture.Configuration, NullLogger.Instance, Mock.Of()); Assert.Equal(new BackgroundWorkerRunResult(true, 2, 2, 0), await worker.RunOnceAsync(default)); Assert.Equal(new BackgroundWorkerRunResult(true, 2, 2, 0), await worker.RunOnceAsync(default)); await using var verificationScope = fixture.Provider.CreateAsyncScope(); var jobs = await verificationScope.ServiceProvider.GetRequiredService() .JobApplications.IgnoreQueryFilters().AsNoTracking().OrderBy(job => job.OwnerUserId).ToListAsync(); Assert.Equal("Ghosted", jobs[0].Status); Assert.Equal("Applied", jobs[1].Status); } [Fact] public async Task Affected_workers_are_disabled_by_default() { var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); var runner = new BackgroundTenantRunner(Mock.Of(), NullLogger.Instance); var readiness = Mock.Of(); var root = Path.Combine(Path.GetTempPath(), $"jobtracker-worker-defaults-{Guid.NewGuid():N}"); var environment = new Mock(); environment.SetupGet(x => x.ContentRootPath).Returns(root); try { var rules = new RulesHostedService(runner, configuration, NullLogger.Instance, readiness); var reminders = new FollowUpReminderHostedService(runner, configuration, NullLogger.Instance, readiness, ExternalOrigin.Parse(null, false)); var exports = new DailyExportHostedService(runner, NullLogger.Instance, configuration, new AppPaths(configuration, environment.Object), readiness); var enrichment = new JobEnrichmentHostedService(runner, configuration, NullLogger.Instance, readiness); Assert.Equal(BackgroundWorkerRunResult.Disabled, await rules.RunOnceAsync(default)); Assert.Equal(BackgroundWorkerRunResult.Disabled, await reminders.RunOnceAsync(default)); Assert.Equal(BackgroundWorkerRunResult.Disabled, await exports.RunOnceAsync(default)); Assert.Equal(BackgroundWorkerRunResult.Disabled, await enrichment.RunOnceAsync(default)); } finally { if (Directory.Exists(root)) Directory.Delete(root, true); } } [Fact] public async Task Daily_export_writes_one_isolated_atomic_file_per_owner() { var root = Path.Combine(Path.GetTempPath(), $"jobtracker-worker-export-{Guid.NewGuid():N}"); await using var fixture = await Fixture.CreateAsync(new Dictionary { ["Workers:DailyExportEnabled"] = "true", ["Exports:DailyEnabled"] = "true", ["Data:Root"] = root, }); await fixture.SeedJobsAsync(); await using (var seedScope = fixture.Provider.CreateAsyncScope()) { var db = seedScope.ServiceProvider.GetRequiredService(); var jobs = await db.JobApplications.IgnoreQueryFilters().OrderBy(job => job.OwnerUserId).ToListAsync(); db.EmailSendAttempts.AddRange( new EmailSendAttempt { Id = Guid.NewGuid(), OwnerUserId = "user-1", JobApplicationId = jobs[0].Id, Provider = "gmail", ClientRequestId = Guid.NewGuid().ToString(), PayloadHash = new string('a', 64), Status = EmailSendStatuses.Sent, CreatedAtUtc = DateTime.UtcNow }, new EmailSendAttempt { Id = Guid.NewGuid(), OwnerUserId = "user-2", JobApplicationId = jobs[1].Id, Provider = "microsoft", ClientRequestId = Guid.NewGuid().ToString(), PayloadHash = new string('b', 64), Status = EmailSendStatuses.Failed, FailureCategory = "rejected", CreatedAtUtc = DateTime.UtcNow }); db.EmailDrafts.AddRange( Draft("user-1", jobs[0].Id, "one@example.test", "Private draft one"), Draft("user-2", jobs[1].Id, "two@example.test", "Private draft two")); await db.SaveChangesAsync(); } var environment = new Mock(); environment.SetupGet(x => x.ContentRootPath).Returns(root); try { var worker = new DailyExportHostedService( fixture.Runner, NullLogger.Instance, fixture.Configuration, new AppPaths(fixture.Configuration, environment.Object), Mock.Of()); Assert.Equal(new BackgroundWorkerRunResult(true, 2, 2, 0), await worker.RunOnceAsync(default)); var files = Directory.GetFiles(Path.Combine(root, "exports"), "*.json"); Assert.Equal(2, files.Length); Assert.DoesNotContain(files, path => path.Contains("user-1", StringComparison.Ordinal) || path.Contains("user-2", StringComparison.Ordinal)); var owners = new List(); foreach (var path in files) { using var document = JsonDocument.Parse(System.IO.File.ReadAllText(path)); var rootElement = document.RootElement; var owner = rootElement.GetProperty("OwnerUserId").GetString(); owners.Add(owner); var attempt = Assert.Single(rootElement.GetProperty("EmailSendAttempts").EnumerateArray()); Assert.Equal(owner == "user-1" ? "gmail" : "microsoft", attempt.GetProperty("Provider").GetString()); Assert.False(attempt.TryGetProperty("PayloadHash", out _)); var draft = Assert.Single(rootElement.GetProperty("EmailDrafts").EnumerateArray()); Assert.Equal(owner == "user-1" ? "one@example.test" : "two@example.test", draft.GetProperty("To").GetString()); Assert.Equal(owner == "user-1" ? "Private draft one" : "Private draft two", draft.GetProperty("BodyText").GetString()); Assert.True(Guid.TryParse(draft.GetProperty("ClientRequestId").GetString(), out _)); Assert.Equal(1, draft.GetProperty("Revision").GetInt64()); } owners.Sort(StringComparer.Ordinal); Assert.Equal(new[] { "user-1", "user-2" }, owners); Assert.Empty(Directory.GetFiles(Path.Combine(root, "exports"), "*.tmp")); } finally { if (Directory.Exists(root)) Directory.Delete(root, true); } } private static EmailDraft Draft(string ownerUserId, int jobApplicationId, string recipient, string body) => new() { Id = Guid.NewGuid(), OwnerUserId = ownerUserId, JobApplicationId = jobApplicationId, Provider = ownerUserId == "user-1" ? "gmail" : "microsoft", To = recipient, Subject = "Synthetic export subject", BodyText = body, ThreadId = "synthetic-thread", ClientRequestId = Guid.NewGuid().ToString("D"), CreatedAtUtc = DateTime.UtcNow.AddMinutes(-5), UpdatedAtUtc = DateTime.UtcNow, }; [Fact] public async Task Enrichment_processes_both_owners_only_through_fake_ai() { var summarizer = new Mock(); summarizer.Setup(x => x.SummarizeAsync(It.IsAny(), 160, 60)) .ReturnsAsync((string text, int _, int _) => $"summary:{text}"); await using var fixture = await Fixture.CreateAsync( new Dictionary { ["Workers:JobEnrichmentEnabled"] = "true" }, services => services.AddSingleton(summarizer.Object)); await fixture.SeedJobsAsync(includeUsers: true); await using (var roleScope = fixture.Provider.CreateAsyncScope()) { var roles = roleScope.ServiceProvider.GetRequiredService>(); var users = roleScope.ServiceProvider.GetRequiredService>(); Assert.True((await roles.CreateAsync(new IdentityRole("Premium"))).Succeeded); foreach (var id in new[] { "user-1", "user-2" }) Assert.True((await users.AddToRoleAsync((await users.FindByIdAsync(id))!, "Premium")).Succeeded); } var worker = new JobEnrichmentHostedService( fixture.Runner, fixture.Configuration, NullLogger.Instance, Mock.Of()); Assert.Equal(new BackgroundWorkerRunResult(true, 2, 2, 0), await worker.RunOnceAsync(default)); await using var scope = fixture.Provider.CreateAsyncScope(); var jobs = await scope.ServiceProvider.GetRequiredService().JobApplications.IgnoreQueryFilters().AsNoTracking().ToListAsync(); Assert.Contains(jobs, job => job.OwnerUserId == "user-1" && job.ShortSummary == "summary:description-user-1"); Assert.Contains(jobs, job => job.OwnerUserId == "user-2" && job.ShortSummary == "summary:description-user-2"); summarizer.Verify(x => x.SummarizeAsync(It.IsAny(), 160, 60), Times.Exactly(2)); } [Fact] public async Task Enrichment_does_not_call_ai_for_free_owners() { var summarizer = new Mock(); await using var fixture = await Fixture.CreateAsync( new Dictionary { ["Workers:JobEnrichmentEnabled"] = "true" }, services => services.AddSingleton(summarizer.Object)); await fixture.SeedJobsAsync(includeUsers: true); var worker = new JobEnrichmentHostedService( fixture.Runner, fixture.Configuration, NullLogger.Instance, Mock.Of()); Assert.Equal(new BackgroundWorkerRunResult(true, 2, 2, 0), await worker.RunOnceAsync(default)); summarizer.Verify(x => x.SummarizeAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Reminder_worker_sends_only_to_each_confirmed_owner_through_fake_email() { var email = new Mock(); await using var fixture = await Fixture.CreateAsync( new Dictionary { ["Workers:FollowUpRemindersEnabled"] = "true", ["Email:FollowUpReminders:Enabled"] = "true", ["App:PublicBaseUrl"] = "http://localhost:3000", }, services => services.AddSingleton(email.Object)); await fixture.SeedJobsAsync(includeUsers: true); var worker = new FollowUpReminderHostedService( fixture.Runner, fixture.Configuration, NullLogger.Instance, Mock.Of(), ExternalOrigin.FromConfiguration(fixture.Configuration)); Assert.Equal(new BackgroundWorkerRunResult(true, 2, 2, 0), await worker.RunOnceAsync(default)); email.Verify(x => x.SendAsync("one@example.test", It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); email.Verify(x => x.SendAsync("two@example.test", It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); await using var scope = fixture.Provider.CreateAsyncScope(); var jobs = await scope.ServiceProvider.GetRequiredService().JobApplications.IgnoreQueryFilters().AsNoTracking().ToListAsync(); Assert.All(jobs, job => Assert.NotNull(job.LastReminderEmailSentAt)); } private sealed class Fixture : IAsyncDisposable { private readonly SqliteConnection _connection; public ServiceProvider Provider { get; } public IConfiguration Configuration { get; } public BackgroundTenantRunner Runner => Provider.GetRequiredService(); private Fixture(SqliteConnection connection, ServiceProvider provider, IConfiguration configuration) { _connection = connection; Provider = provider; Configuration = configuration; } public static async Task CreateAsync(Dictionary? settings = null, Action? configure = null) { var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings ?? new()).Build(); var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var services = new ServiceCollection(); services.AddSingleton(configuration); services.AddLogging(); services.AddHttpContextAccessor(); services.AddScoped(); services.AddScoped(provider => provider.GetRequiredService()); services.AddDbContext((_, options) => options.UseSqlite(connection)); services.AddIdentityCore().AddRoles().AddEntityFrameworkStores(); services.AddSingleton(); configure?.Invoke(services); var provider = services.BuildServiceProvider(); await using var scope = provider.CreateAsyncScope(); await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync(); return new Fixture(connection, provider, configuration); } public async Task SeedJobsAsync(bool includeUsers = false) { await using var scope = Provider.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); if (includeUsers) { db.Users.AddRange( new ApplicationUser { Id = "user-1", UserName = "one@example.test", NormalizedUserName = "ONE@EXAMPLE.TEST", Email = "one@example.test", NormalizedEmail = "ONE@EXAMPLE.TEST", EmailConfirmed = true }, new ApplicationUser { Id = "user-2", UserName = "two@example.test", NormalizedUserName = "TWO@EXAMPLE.TEST", Email = "two@example.test", NormalizedEmail = "TWO@EXAMPLE.TEST", EmailConfirmed = true }); } var companies = new[] { new Company { OwnerUserId = "user-1", Name = "One" }, new Company { OwnerUserId = "user-2", Name = "Two" }, }; db.Companies.AddRange(companies); await db.SaveChangesAsync(); db.JobApplications.AddRange( new JobApplication { OwnerUserId = "user-1", CompanyId = companies[0].Id, JobTitle = "One", Status = "Applied", DateApplied = DateTime.Now.AddDays(-30), Description = "description-user-1" }, new JobApplication { OwnerUserId = "user-2", CompanyId = companies[1].Id, JobTitle = "Two", Status = "Applied", DateApplied = DateTime.Now.AddDays(-30), Description = "description-user-2" }); await db.SaveChangesAsync(); } public async ValueTask DisposeAsync() { await Provider.DisposeAsync(); await _connection.DisposeAsync(); } } }