test(workers): prove clock and restart safety
This commit is contained in:
@@ -18,6 +18,8 @@ namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class BackgroundWorkerTenantTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 8, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task Runner_enters_each_owner_filter_and_isolates_owner_failures()
|
||||
{
|
||||
@@ -64,7 +66,8 @@ public sealed class BackgroundWorkerTenantTests
|
||||
fixture.Runner,
|
||||
fixture.Configuration,
|
||||
NullLogger<RulesHostedService>.Instance,
|
||||
Mock.Of<IStartupReadiness>());
|
||||
Mock.Of<IStartupReadiness>(),
|
||||
TimeProvider.System);
|
||||
|
||||
Assert.Equal(new BackgroundWorkerRunResult(true, 2, 2, 0), await worker.RunOnceAsync(default));
|
||||
Assert.Equal(new BackgroundWorkerRunResult(true, 2, 2, 0), await worker.RunOnceAsync(default));
|
||||
@@ -87,10 +90,10 @@ public sealed class BackgroundWorkerTenantTests
|
||||
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);
|
||||
var rules = new RulesHostedService(runner, configuration, NullLogger<RulesHostedService>.Instance, readiness, TimeProvider.System);
|
||||
var reminders = new FollowUpReminderHostedService(runner, configuration, NullLogger<FollowUpReminderHostedService>.Instance, readiness, ExternalOrigin.Parse(null, false), TimeProvider.System);
|
||||
var exports = new DailyExportHostedService(runner, NullLogger<DailyExportHostedService>.Instance, configuration, new AppPaths(configuration, environment.Object), readiness, TimeProvider.System);
|
||||
var enrichment = new JobEnrichmentHostedService(runner, configuration, NullLogger<JobEnrichmentHostedService>.Instance, readiness, TimeProvider.System);
|
||||
|
||||
Assert.Equal(BackgroundWorkerRunResult.Disabled, await rules.RunOnceAsync(default));
|
||||
Assert.Equal(BackgroundWorkerRunResult.Disabled, await reminders.RunOnceAsync(default));
|
||||
@@ -114,6 +117,7 @@ public sealed class BackgroundWorkerTenantTests
|
||||
["Data:Root"] = root,
|
||||
});
|
||||
await fixture.SeedJobsAsync();
|
||||
var clock = new MutableTimeProvider(FixedNow);
|
||||
await using (var seedScope = fixture.Provider.CreateAsyncScope())
|
||||
{
|
||||
var db = seedScope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
@@ -135,9 +139,18 @@ public sealed class BackgroundWorkerTenantTests
|
||||
NullLogger<DailyExportHostedService>.Instance,
|
||||
fixture.Configuration,
|
||||
new AppPaths(fixture.Configuration, environment.Object),
|
||||
Mock.Of<IStartupReadiness>());
|
||||
Mock.Of<IStartupReadiness>(),
|
||||
clock);
|
||||
|
||||
Assert.Equal(new BackgroundWorkerRunResult(true, 2, 2, 0), await worker.RunOnceAsync(default));
|
||||
var restartedWorker = new DailyExportHostedService(
|
||||
fixture.Runner,
|
||||
NullLogger<DailyExportHostedService>.Instance,
|
||||
fixture.Configuration,
|
||||
new AppPaths(fixture.Configuration, environment.Object),
|
||||
Mock.Of<IStartupReadiness>(),
|
||||
clock);
|
||||
Assert.Equal(new BackgroundWorkerRunResult(true, 2, 2, 0), await restartedWorker.RunOnceAsync(default));
|
||||
var files = Directory.GetFiles(Path.Combine(root, "exports"), "*.json", SearchOption.AllDirectories);
|
||||
Assert.Equal(2, files.Length);
|
||||
Assert.DoesNotContain(files, path => path.Contains("user-1", StringComparison.Ordinal) || path.Contains("user-2", StringComparison.Ordinal));
|
||||
@@ -148,6 +161,7 @@ public sealed class BackgroundWorkerTenantTests
|
||||
{
|
||||
using var document = JsonDocument.Parse(System.IO.File.ReadAllText(path));
|
||||
var rootElement = document.RootElement;
|
||||
Assert.Equal(FixedNow.DateTime, rootElement.GetProperty("CreatedAt").GetDateTime());
|
||||
var owner = rootElement.GetProperty("OwnerUserId").GetString();
|
||||
owners.Add(owner);
|
||||
var attempt = Assert.Single(rootElement.GetProperty("EmailSendAttempts").EnumerateArray());
|
||||
@@ -206,7 +220,8 @@ public sealed class BackgroundWorkerTenantTests
|
||||
fixture.Runner,
|
||||
fixture.Configuration,
|
||||
NullLogger<JobEnrichmentHostedService>.Instance,
|
||||
Mock.Of<IStartupReadiness>());
|
||||
Mock.Of<IStartupReadiness>(),
|
||||
TimeProvider.System);
|
||||
|
||||
Assert.Equal(new BackgroundWorkerRunResult(true, 2, 2, 0), await worker.RunOnceAsync(default));
|
||||
|
||||
@@ -229,7 +244,8 @@ public sealed class BackgroundWorkerTenantTests
|
||||
fixture.Runner,
|
||||
fixture.Configuration,
|
||||
NullLogger<JobEnrichmentHostedService>.Instance,
|
||||
Mock.Of<IStartupReadiness>());
|
||||
Mock.Of<IStartupReadiness>(),
|
||||
TimeProvider.System);
|
||||
|
||||
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);
|
||||
@@ -248,20 +264,76 @@ public sealed class BackgroundWorkerTenantTests
|
||||
},
|
||||
services => services.AddSingleton(email.Object));
|
||||
await fixture.SeedJobsAsync(includeUsers: true);
|
||||
var clock = new MutableTimeProvider(FixedNow);
|
||||
var worker = new FollowUpReminderHostedService(
|
||||
fixture.Runner,
|
||||
fixture.Configuration,
|
||||
NullLogger<FollowUpReminderHostedService>.Instance,
|
||||
Mock.Of<IStartupReadiness>(),
|
||||
ExternalOrigin.FromConfiguration(fixture.Configuration));
|
||||
ExternalOrigin.FromConfiguration(fixture.Configuration),
|
||||
clock);
|
||||
|
||||
Assert.Equal(new BackgroundWorkerRunResult(true, 2, 2, 0), await worker.RunOnceAsync(default));
|
||||
var restartedWorker = new FollowUpReminderHostedService(
|
||||
fixture.Runner,
|
||||
fixture.Configuration,
|
||||
NullLogger<FollowUpReminderHostedService>.Instance,
|
||||
Mock.Of<IStartupReadiness>(),
|
||||
ExternalOrigin.FromConfiguration(fixture.Configuration),
|
||||
clock);
|
||||
Assert.Equal(new BackgroundWorkerRunResult(true, 2, 2, 0), await restartedWorker.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));
|
||||
Assert.All(jobs, job => Assert.Equal(FixedNow.DateTime, job.LastReminderEmailSentAt));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rules_worker_uses_injected_clock_across_threshold_and_restart()
|
||||
{
|
||||
var clock = new MutableTimeProvider(FixedNow.AddMinutes(-1));
|
||||
await using var fixture = await Fixture.CreateAsync(new Dictionary<string, string?> { ["Workers:RulesEnabled"] = "true" });
|
||||
await fixture.SeedJobsAsync(appliedAt: FixedNow.DateTime.AddDays(-5));
|
||||
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 = 1, AppliedGhostDays = 5 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var beforeBoundary = new RulesHostedService(
|
||||
fixture.Runner,
|
||||
fixture.Configuration,
|
||||
NullLogger<RulesHostedService>.Instance,
|
||||
Mock.Of<IStartupReadiness>(),
|
||||
clock);
|
||||
Assert.Equal(new BackgroundWorkerRunResult(true, 2, 2, 0), await beforeBoundary.RunOnceAsync(default));
|
||||
|
||||
await using (var scope = fixture.Provider.CreateAsyncScope())
|
||||
{
|
||||
var statuses = await scope.ServiceProvider.GetRequiredService<JobTrackerContext>()
|
||||
.JobApplications.IgnoreQueryFilters().Select(job => job.Status).ToListAsync();
|
||||
Assert.All(statuses, status => Assert.Equal("Applied", status));
|
||||
}
|
||||
|
||||
clock.SetUtcNow(FixedNow);
|
||||
var afterRestart = new RulesHostedService(
|
||||
fixture.Runner,
|
||||
fixture.Configuration,
|
||||
NullLogger<RulesHostedService>.Instance,
|
||||
Mock.Of<IStartupReadiness>(),
|
||||
clock);
|
||||
Assert.Equal(new BackgroundWorkerRunResult(true, 2, 2, 0), await afterRestart.RunOnceAsync(default));
|
||||
Assert.Equal(new BackgroundWorkerRunResult(true, 2, 2, 0), await afterRestart.RunOnceAsync(default));
|
||||
|
||||
await using var verificationScope = fixture.Provider.CreateAsyncScope();
|
||||
var finalStatuses = await verificationScope.ServiceProvider.GetRequiredService<JobTrackerContext>()
|
||||
.JobApplications.IgnoreQueryFilters().Select(job => job.Status).ToListAsync();
|
||||
Assert.All(finalStatuses, status => Assert.Equal("Ghosted", status));
|
||||
}
|
||||
|
||||
private sealed class Fixture : IAsyncDisposable
|
||||
@@ -299,7 +371,7 @@ public sealed class BackgroundWorkerTenantTests
|
||||
return new Fixture(connection, provider, configuration);
|
||||
}
|
||||
|
||||
public async Task SeedJobsAsync(bool includeUsers = false)
|
||||
public async Task SeedJobsAsync(bool includeUsers = false, DateTime? appliedAt = null)
|
||||
{
|
||||
await using var scope = Provider.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
@@ -317,8 +389,8 @@ public sealed class BackgroundWorkerTenantTests
|
||||
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" });
|
||||
new JobApplication { OwnerUserId = "user-1", CompanyId = companies[0].Id, JobTitle = "One", Status = "Applied", DateApplied = appliedAt ?? DateTime.Now.AddDays(-30), Description = "description-user-1" },
|
||||
new JobApplication { OwnerUserId = "user-2", CompanyId = companies[1].Id, JobTitle = "Two", Status = "Applied", DateApplied = appliedAt ?? DateTime.Now.AddDays(-30), Description = "description-user-2" });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -328,4 +400,15 @@ public sealed class BackgroundWorkerTenantTests
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MutableTimeProvider(DateTimeOffset utcNow) : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow = utcNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
|
||||
|
||||
public void SetUtcNow(DateTimeOffset value) => _utcNow = value;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user