287 lines
15 KiB
C#
287 lines
15 KiB
C#
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<string, string[]>();
|
|
|
|
var result = await fixture.Runner.RunForJobOwnersAsync("test", async (services, cancellationToken) =>
|
|
{
|
|
var db = services.GetRequiredService<JobTrackerContext>();
|
|
var owner = Assert.IsType<string>(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<InvalidOperationException>(() => 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<string, string?> { ["Workers:RulesEnabled"] = "true" });
|
|
await fixture.SeedJobsAsync();
|
|
await using (var scope = fixture.Provider.CreateAsyncScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
|
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<RulesHostedService>.Instance,
|
|
Mock.Of<IStartupReadiness>());
|
|
|
|
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<JobTrackerContext>()
|
|
.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<IServiceScopeFactory>(), NullLogger<BackgroundTenantRunner>.Instance);
|
|
var readiness = Mock.Of<IStartupReadiness>();
|
|
var root = Path.Combine(Path.GetTempPath(), $"jobtracker-worker-defaults-{Guid.NewGuid():N}");
|
|
var environment = new Mock<IHostEnvironment>();
|
|
environment.SetupGet(x => x.ContentRootPath).Returns(root);
|
|
try
|
|
{
|
|
var rules = new RulesHostedService(runner, configuration, NullLogger<RulesHostedService>.Instance, readiness);
|
|
var reminders = new FollowUpReminderHostedService(runner, configuration, NullLogger<FollowUpReminderHostedService>.Instance, readiness, ExternalOrigin.Parse(null, false));
|
|
var exports = new DailyExportHostedService(runner, NullLogger<DailyExportHostedService>.Instance, configuration, new AppPaths(configuration, environment.Object), readiness);
|
|
var enrichment = new JobEnrichmentHostedService(runner, configuration, NullLogger<JobEnrichmentHostedService>.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<string, string?>
|
|
{
|
|
["Workers:DailyExportEnabled"] = "true",
|
|
["Exports:DailyEnabled"] = "true",
|
|
["Data:Root"] = root,
|
|
});
|
|
await fixture.SeedJobsAsync();
|
|
var environment = new Mock<IHostEnvironment>();
|
|
environment.SetupGet(x => x.ContentRootPath).Returns(root);
|
|
try
|
|
{
|
|
var worker = new DailyExportHostedService(
|
|
fixture.Runner,
|
|
NullLogger<DailyExportHostedService>.Instance,
|
|
fixture.Configuration,
|
|
new AppPaths(fixture.Configuration, environment.Object),
|
|
Mock.Of<IStartupReadiness>());
|
|
|
|
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 = files.Select(path => JsonDocument.Parse(System.IO.File.ReadAllText(path)).RootElement.GetProperty("OwnerUserId").GetString()).Order().ToArray();
|
|
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);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enrichment_processes_both_owners_only_through_fake_ai()
|
|
{
|
|
var summarizer = new Mock<ISummarizerService>();
|
|
summarizer.Setup(x => x.SummarizeAsync(It.IsAny<string>(), 160, 60))
|
|
.ReturnsAsync((string text, int _, int _) => $"summary:{text}");
|
|
await using var fixture = await Fixture.CreateAsync(
|
|
new Dictionary<string, string?> { ["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<RoleManager<IdentityRole>>();
|
|
var users = roleScope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
|
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<JobEnrichmentHostedService>.Instance,
|
|
Mock.Of<IStartupReadiness>());
|
|
|
|
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<JobTrackerContext>().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<string>(), 160, 60), Times.Exactly(2));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enrichment_does_not_call_ai_for_free_owners()
|
|
{
|
|
var summarizer = new Mock<ISummarizerService>();
|
|
await using var fixture = await Fixture.CreateAsync(
|
|
new Dictionary<string, string?> { ["Workers:JobEnrichmentEnabled"] = "true" },
|
|
services => services.AddSingleton(summarizer.Object));
|
|
await fixture.SeedJobsAsync(includeUsers: true);
|
|
var worker = new JobEnrichmentHostedService(
|
|
fixture.Runner,
|
|
fixture.Configuration,
|
|
NullLogger<JobEnrichmentHostedService>.Instance,
|
|
Mock.Of<IStartupReadiness>());
|
|
|
|
Assert.Equal(new BackgroundWorkerRunResult(true, 2, 2, 0), await worker.RunOnceAsync(default));
|
|
summarizer.Verify(x => x.SummarizeAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reminder_worker_sends_only_to_each_confirmed_owner_through_fake_email()
|
|
{
|
|
var email = new Mock<IAppEmailSender>();
|
|
await using var fixture = await Fixture.CreateAsync(
|
|
new Dictionary<string, string?>
|
|
{
|
|
["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<FollowUpReminderHostedService>.Instance,
|
|
Mock.Of<IStartupReadiness>(),
|
|
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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
email.Verify(x => x.SendAsync("two@example.test", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
|
|
await using var scope = fixture.Provider.CreateAsyncScope();
|
|
var jobs = await scope.ServiceProvider.GetRequiredService<JobTrackerContext>().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<BackgroundTenantRunner>();
|
|
|
|
private Fixture(SqliteConnection connection, ServiceProvider provider, IConfiguration configuration)
|
|
{
|
|
_connection = connection;
|
|
Provider = provider;
|
|
Configuration = configuration;
|
|
}
|
|
|
|
public static async Task<Fixture> CreateAsync(Dictionary<string, string?>? settings = null, Action<IServiceCollection>? 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<IConfiguration>(configuration);
|
|
services.AddLogging();
|
|
services.AddHttpContextAccessor();
|
|
services.AddScoped<CurrentUserService>();
|
|
services.AddScoped<ICurrentUserService>(provider => provider.GetRequiredService<CurrentUserService>());
|
|
services.AddDbContext<JobTrackerContext>((_, options) => options.UseSqlite(connection));
|
|
services.AddIdentityCore<ApplicationUser>().AddRoles<IdentityRole>().AddEntityFrameworkStores<JobTrackerContext>();
|
|
services.AddSingleton<BackgroundTenantRunner>();
|
|
configure?.Invoke(services);
|
|
var provider = services.BuildServiceProvider();
|
|
await using var scope = provider.CreateAsyncScope();
|
|
await scope.ServiceProvider.GetRequiredService<JobTrackerContext>().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<JobTrackerContext>();
|
|
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();
|
|
}
|
|
}
|
|
}
|