using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class EmailSendAttemptStoreTests { [Fact] public async Task Creation_is_idempotent_and_rejects_payload_reuse() { await using var fixture = await Fixture.CreateAsync(); var store = fixture.Store("user-1"); var requestId = Guid.NewGuid().ToString(); var first = await store.CreateAsync(new(fixture.JobId, "gmail", requestId, Hash('a')), default); var duplicate = await store.CreateAsync(new(fixture.JobId, "gmail", requestId, Hash('a')), default); Assert.True(first.Created); Assert.False(duplicate.Created); Assert.Equal(first.Attempt.Id, duplicate.Attempt.Id); await Assert.ThrowsAsync(() => store.CreateAsync(new(fixture.JobId, "gmail", requestId, Hash('b')), default)); } [Fact] public async Task State_machine_never_restarts_terminal_or_uncertain_attempts() { await using var fixture = await Fixture.CreateAsync(); var store = fixture.Store("user-1"); var created = await store.CreateAsync(new(fixture.JobId, "microsoft", Guid.NewGuid().ToString(), Hash('c')), default); Assert.Equal(1, await store.BeginAsync(created.Attempt.Id, default)); Assert.Equal(0, await store.BeginAsync(created.Attempt.Id, default)); Assert.Equal(1, await store.MarkUncertainAsync(created.Attempt.Id, "transport_interrupted", default)); Assert.Equal(0, await store.MarkSentAsync(created.Attempt.Id, "late-message", default)); var stored = await store.GetAsync(created.Attempt.Id, default); Assert.Equal(EmailSendStatuses.Uncertain, stored!.Status); Assert.Equal("transport_interrupted", stored.FailureCategory); Assert.NotNull(stored.CompletedAtUtc); } [Fact] public async Task Direct_attempt_ids_are_tenant_scoped_and_store_no_message_content() { await using var fixture = await Fixture.CreateAsync(); var ownerStore = fixture.Store("user-1"); var created = await ownerStore.CreateAsync(new(fixture.JobId, "gmail", Guid.NewGuid().ToString(), Hash('d')), default); Assert.Null(await fixture.Store("user-2").GetAsync(created.Attempt.Id, default)); Assert.DoesNotContain(typeof(EmailSendAttempt).GetProperties(), property => property.Name.Contains("Subject", StringComparison.OrdinalIgnoreCase) || property.Name.Contains("Body", StringComparison.OrdinalIgnoreCase) || property.Name.Contains("Recipient", StringComparison.OrdinalIgnoreCase)); } [Fact] public async Task Hard_job_deletion_cascades_only_that_jobs_attempts() { await using var fixture = await Fixture.CreateAsync(); var ownerOne = fixture.Store("user-1"); var ownerTwo = fixture.Store("user-2"); var mine = await ownerOne.CreateAsync(new(fixture.JobId, "gmail", Guid.NewGuid().ToString(), Hash('3')), default); var theirs = await ownerTwo.CreateAsync(new(fixture.OtherJobId, "microsoft", Guid.NewGuid().ToString(), Hash('4')), default); await fixture.HardDeleteJobAsync("user-1", fixture.JobId); Assert.Null(await ownerOne.GetAsync(mine.Attempt.Id, default)); Assert.NotNull(await ownerTwo.GetAsync(theirs.Attempt.Id, default)); Assert.Equal(1, await fixture.AttemptCountIgnoringFiltersAsync()); } [Fact] public async Task Restart_recovery_is_tenant_visible_idempotent_and_never_requeues_delivery() { await using var fixture = await Fixture.CreateAsync(); var ownerOne = fixture.Store("user-1"); var ownerTwo = fixture.Store("user-2"); var oldSending = await ownerOne.CreateAsync(new(fixture.JobId, "gmail", Guid.NewGuid().ToString(), Hash('e')), default); var oldPending = await ownerTwo.CreateAsync(new(fixture.OtherJobId, "microsoft", Guid.NewGuid().ToString(), Hash('f')), default); Assert.Equal(1, await ownerOne.BeginAsync(oldSending.Attempt.Id, default)); fixture.Time.Advance(EmailSendAttemptStore.AbandonedAge + TimeSpan.FromSeconds(1)); var freshPending = await ownerOne.CreateAsync(new(fixture.JobId, "gmail", Guid.NewGuid().ToString(), Hash('1')), default); var freshSending = await ownerTwo.CreateAsync(new(fixture.OtherJobId, "microsoft", Guid.NewGuid().ToString(), Hash('2')), default); Assert.Equal(1, await ownerTwo.BeginAsync(freshSending.Attempt.Id, default)); var recovered = await fixture.Store(null).ReconcileAbandonedAsync(default); var repeated = await fixture.Store(null).ReconcileAbandonedAsync(default); Assert.Equal(new EmailSendAttemptRecoveryResult(1, 1), recovered); Assert.Equal(new EmailSendAttemptRecoveryResult(0, 0), repeated); Assert.Equal(EmailSendStatuses.Uncertain, (await ownerOne.GetAsync(oldSending.Attempt.Id, default))!.Status); Assert.Equal(EmailSendStatuses.Failed, (await ownerTwo.GetAsync(oldPending.Attempt.Id, default))!.Status); Assert.Equal(EmailSendStatuses.Pending, (await ownerOne.GetAsync(freshPending.Attempt.Id, default))!.Status); Assert.Equal(EmailSendStatuses.Sending, (await ownerTwo.GetAsync(freshSending.Attempt.Id, default))!.Status); var userOneNotifications = await fixture.Notifications("user-1").ListAsync(10, default); var userTwoNotifications = await fixture.Notifications("user-2").ListAsync(10, default); Assert.Single(userOneNotifications); Assert.Single(userTwoNotifications); Assert.Equal("email.send.uncertain", userOneNotifications[0].Kind); Assert.Equal("email.send.stopped", userTwoNotifications[0].Kind); Assert.DoesNotContain("gmail", userOneNotifications[0].Message, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("microsoft", userTwoNotifications[0].Message, StringComparison.OrdinalIgnoreCase); } private static string Hash(char value) => new(value, 64); private sealed class Fixture(SqliteConnection connection, DbContextOptions options, int jobId, int otherJobId) : IAsyncDisposable { private readonly List contexts = new(); public ManualTimeProvider Time { get; } = new(new DateTimeOffset(2026, 8, 10, 12, 0, 0, TimeSpan.Zero)); public int JobId { get; } = jobId; public int OtherJobId { get; } = otherJobId; public static async Task CreateAsync() { var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; await using var db = CreateDb(options, "user-1"); await db.Database.EnsureCreatedAsync(); var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; db.Companies.Add(company); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Backend", CompanyId = company.Id, OwnerUserId = "user-1" }; db.JobApplications.Add(job); await db.SaveChangesAsync(); await using var otherDb = CreateDb(options, "user-2"); var otherCompany = new Company { Name = "Other", OwnerUserId = "user-2" }; otherDb.Companies.Add(otherCompany); await otherDb.SaveChangesAsync(); var otherJob = new JobApplication { JobTitle = "Frontend", CompanyId = otherCompany.Id, OwnerUserId = "user-2" }; otherDb.JobApplications.Add(otherJob); await otherDb.SaveChangesAsync(); return new Fixture(connection, options, job.Id, otherJob.Id); } public EmailSendAttemptStore Store(string? userId) { var db = CreateDb(options, userId); contexts.Add(db); return new EmailSendAttemptStore(db, Time); } public UserNotificationStore Notifications(string userId) { var db = CreateDb(options, userId); contexts.Add(db); return new UserNotificationStore(db, Time); } public async Task HardDeleteJobAsync(string userId, int jobId) { await using var db = CreateDb(options, userId); var job = await db.JobApplications.SingleAsync(item => item.Id == jobId); db.JobApplications.Remove(job); await db.SaveChangesAsync(); } public async Task AttemptCountIgnoringFiltersAsync() { await using var db = CreateDb(options, null); return await db.EmailSendAttempts.IgnoreQueryFilters().CountAsync(); } private static JobTrackerContext CreateDb(DbContextOptions options, string? userId) { var currentUser = new Mock(); currentUser.SetupGet(service => service.UserId).Returns(userId); return new JobTrackerContext(options, currentUser.Object); } public async ValueTask DisposeAsync() { foreach (var context in contexts) await context.DisposeAsync(); await connection.DisposeAsync(); } } private sealed class ManualTimeProvider(DateTimeOffset now) : TimeProvider { public DateTimeOffset UtcNow { get; private set; } = now; public override DateTimeOffset GetUtcNow() => UtcNow; public void Advance(TimeSpan duration) => UtcNow += duration; } }