using System.Reflection; using System.Security.Claims; using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using JobTrackerApi.Services.EmailProviders; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class EmailDraftsControllerTests { [Fact] public void Draft_routes_require_local_authentication() { var authorization = Assert.Single(typeof(EmailDraftsController) .GetCustomAttributes()); Assert.Equal("local", authorization.AuthenticationSchemes); Assert.True(string.IsNullOrWhiteSpace(authorization.Policy)); } [Fact] public async Task Create_accepts_incomplete_owned_drafts_but_rejects_invalid_or_foreign_inputs() { await using var fixture = await Fixture.CreateAsync(); await using var db = fixture.Context("user-1"); var controller = Controller(db, "user-1"); var createdResult = await controller.Create( new EmailDraftsController.CreateDraftRequest(fixture.UserOneJobId, "GMAIL", "", "", "", null), default); var created = Assert.IsType( Assert.IsType(createdResult.Result).Value); Assert.Equal("gmail", created.Provider); Assert.Equal(1, created.Revision); Assert.Empty(created.To); Assert.True(Guid.TryParse(created.ClientRequestId, out _)); Assert.IsType((await controller.Create( new EmailDraftsController.CreateDraftRequest(fixture.UserOneJobId, "unknown", "", "", "", null), default)).Result); Assert.IsType((await controller.Create( new EmailDraftsController.CreateDraftRequest(fixture.UserOneJobId, "gmail", "not-an-address", "", "", null), default)).Result); Assert.IsType((await controller.Create( new EmailDraftsController.CreateDraftRequest(fixture.UserTwoJobId, "gmail", "", "", "", null), default)).Result); } [Fact] public async Task Direct_ids_and_job_lists_do_not_cross_tenants() { await using var fixture = await Fixture.CreateAsync(seedDrafts: true); await using var db = fixture.Context("user-1"); var controller = Controller(db, "user-1"); Assert.IsType((await controller.Get(fixture.UserTwoDraftId, default)).Result); var foreignList = Assert.IsAssignableFrom>( Assert.IsType((await controller.List(fixture.UserTwoJobId, default)).Result).Value); Assert.Empty(foreignList); var ownList = Assert.IsAssignableFrom>( Assert.IsType((await controller.List(fixture.UserOneJobId, default)).Result).Value); Assert.Equal(fixture.UserOneDraftId, Assert.Single(ownList).Id); var allOwnDrafts = Assert.IsAssignableFrom>( Assert.IsType((await controller.List(null, default)).Result).Value); Assert.Equal(fixture.UserOneDraftId, Assert.Single(allOwnDrafts).Id); } [Fact] public async Task Revision_conflicts_prevent_lost_updates_and_cross_tenant_deletes() { await using var fixture = await Fixture.CreateAsync(seedDrafts: true); await using (var userOneDb = fixture.Context("user-1")) { var userOne = Controller(userOneDb, "user-1"); var originalRequestId = (await userOneDb.EmailDrafts.AsNoTracking().SingleAsync()).ClientRequestId; var updatedResult = await userOne.Update(fixture.UserOneDraftId, new EmailDraftsController.UpdateDraftRequest(1, "new@example.test", "Updated", "Updated body"), default); var updated = Assert.IsType(Assert.IsType(updatedResult.Result).Value); Assert.Equal(2, updated.Revision); Assert.Equal("Updated body", updated.BodyText); Assert.Equal(originalRequestId, updated.ClientRequestId); Assert.IsType((await userOne.Update(fixture.UserOneDraftId, new EmailDraftsController.UpdateDraftRequest(1, "stale@example.test", "Stale", "Stale body"), default)).Result); Assert.IsType(await userOne.Delete(fixture.UserOneDraftId, 1, default)); } await using (var userTwoDb = fixture.Context("user-2")) { var userTwo = Controller(userTwoDb, "user-2"); Assert.IsType((await userTwo.Update(fixture.UserOneDraftId, new EmailDraftsController.UpdateDraftRequest(2, "other@example.test", "Other", "Other body"), default)).Result); Assert.IsType(await userTwo.Delete(fixture.UserOneDraftId, 2, default)); } await using (var userOneDb = fixture.Context("user-1")) Assert.IsType(await Controller(userOneDb, "user-1").Delete(fixture.UserOneDraftId, 2, default)); await using var verify = fixture.Context(null); var remaining = Assert.Single(await verify.EmailDrafts.IgnoreQueryFilters().AsNoTracking().ToListAsync()); Assert.Equal("user-2", remaining.OwnerUserId); } [Fact] public async Task New_attempt_identity_requires_the_matching_definitive_failure() { await using var fixture = await Fixture.CreateAsync(seedDrafts: true); await using var db = fixture.Context("user-1"); var controller = Controller(db, "user-1"); var original = await db.EmailDrafts.AsNoTracking().SingleAsync(); var refused = await controller.NewAttempt(original.Id, new EmailDraftsController.NewAttemptRequest(1), default); Assert.IsType(refused.Result); var failedAttempt = new EmailSendAttempt { Id = Guid.NewGuid(), OwnerUserId = "user-1", JobApplicationId = original.JobApplicationId, Provider = original.Provider, ClientRequestId = original.ClientRequestId, PayloadHash = new string('a', 64), Status = EmailSendStatuses.Failed, FailureCategory = "provider_rejected", CreatedAtUtc = DateTime.UtcNow, CompletedAtUtc = DateTime.UtcNow, }; db.EmailSendAttempts.Add(failedAttempt); await db.SaveChangesAsync(); var rotatedResult = await controller.NewAttempt(original.Id, new EmailDraftsController.NewAttemptRequest(1), default); var rotated = Assert.IsType(Assert.IsType(rotatedResult.Result).Value); Assert.Equal(2, rotated.Revision); Assert.NotEqual(original.ClientRequestId, rotated.ClientRequestId); Assert.True(Guid.TryParse(rotated.ClientRequestId, out _)); Assert.IsType((await controller.NewAttempt(original.Id, new EmailDraftsController.NewAttemptRequest(1), default)).Result); await using var otherDb = fixture.Context("user-2"); Assert.IsType((await Controller(otherDb, "user-2").NewAttempt(original.Id, new EmailDraftsController.NewAttemptRequest(2), default)).Result); await using var verify = fixture.Context("user-1"); var persisted = await verify.EmailDrafts.AsNoTracking().SingleAsync(); AssertDraftContentUnchanged(original, persisted); Assert.Equal(rotated.ClientRequestId, persisted.ClientRequestId); Assert.Equal(2, persisted.Revision); var readback = Assert.IsType( Assert.IsType((await Controller(verify, "user-1").Get(original.Id, default)).Result).Value); Assert.Equal(rotated, readback); AssertAttemptUnchanged(failedAttempt, Assert.Single(await verify.EmailSendAttempts.AsNoTracking().ToListAsync())); } [Theory] [InlineData(EmailSendStatuses.Pending)] [InlineData(EmailSendStatuses.Sending)] [InlineData(EmailSendStatuses.Uncertain)] [InlineData(EmailSendStatuses.Sent)] public async Task Nonfailed_attempts_cannot_rotate_identity_or_change_draft_and_history(string status) { await using var fixture = await Fixture.CreateAsync(seedDrafts: true); await using var db = fixture.Context("user-1"); var original = await db.EmailDrafts.AsNoTracking().SingleAsync(); var attempt = new EmailSendAttempt { Id = Guid.NewGuid(), OwnerUserId = original.OwnerUserId, JobApplicationId = original.JobApplicationId, Provider = original.Provider, ClientRequestId = original.ClientRequestId, PayloadHash = new string('a', 64), Status = status, ProviderMessageId = status == EmailSendStatuses.Sent ? "sent-message" : null, FailureCategory = status == EmailSendStatuses.Uncertain ? "delivery_unknown" : null, CreatedAtUtc = original.CreatedAtUtc, StartedAtUtc = status == EmailSendStatuses.Pending ? null : original.UpdatedAtUtc, CompletedAtUtc = status is EmailSendStatuses.Uncertain or EmailSendStatuses.Sent ? original.UpdatedAtUtc : null, }; db.EmailSendAttempts.Add(attempt); await db.SaveChangesAsync(); var result = await Controller(db, "user-1").NewAttempt(original.Id, new EmailDraftsController.NewAttemptRequest(original.Revision), default); Assert.IsType(result.Result); await using var verify = fixture.Context("user-1"); var persisted = await verify.EmailDrafts.AsNoTracking().SingleAsync(); AssertDraftContentUnchanged(original, persisted); Assert.Equal(original.ClientRequestId, persisted.ClientRequestId); Assert.Equal(original.Revision, persisted.Revision); Assert.Equal(original.UpdatedAtUtc, persisted.UpdatedAtUtc); AssertAttemptUnchanged(attempt, Assert.Single(await verify.EmailSendAttempts.AsNoTracking().ToListAsync())); } [Theory] [InlineData(true)] [InlineData(false)] public async Task Failures_with_another_owner_or_request_key_cannot_rotate_or_change_either_tenant(bool wrongOwner) { await using var fixture = await Fixture.CreateAsync(seedDrafts: true); await using var seed = fixture.Context(null); var originals = await seed.EmailDrafts.IgnoreQueryFilters().AsNoTracking().OrderBy(draft => draft.OwnerUserId).ToListAsync(); var ownDraft = originals.Single(draft => draft.OwnerUserId == "user-1"); var foreignDraft = originals.Single(draft => draft.OwnerUserId == "user-2"); var attempt = new EmailSendAttempt { Id = Guid.NewGuid(), OwnerUserId = wrongOwner ? foreignDraft.OwnerUserId : ownDraft.OwnerUserId, JobApplicationId = wrongOwner ? foreignDraft.JobApplicationId : ownDraft.JobApplicationId, Provider = ownDraft.Provider, ClientRequestId = wrongOwner ? ownDraft.ClientRequestId : foreignDraft.ClientRequestId, PayloadHash = new string('b', 64), Status = EmailSendStatuses.Failed, FailureCategory = "provider_rejected", CreatedAtUtc = ownDraft.CreatedAtUtc, StartedAtUtc = ownDraft.UpdatedAtUtc, CompletedAtUtc = ownDraft.UpdatedAtUtc, }; seed.EmailSendAttempts.Add(attempt); await seed.SaveChangesAsync(); await using var db = fixture.Context("user-1"); var result = await Controller(db, "user-1").NewAttempt(ownDraft.Id, new EmailDraftsController.NewAttemptRequest(ownDraft.Revision), default); Assert.IsType(result.Result); await using var verify = fixture.Context(null); var persistedDrafts = await verify.EmailDrafts.IgnoreQueryFilters().AsNoTracking().OrderBy(draft => draft.OwnerUserId).ToListAsync(); Assert.Equal(originals.Count, persistedDrafts.Count); foreach (var original in originals) { var persisted = persistedDrafts.Single(draft => draft.Id == original.Id); AssertDraftContentUnchanged(original, persisted); Assert.Equal(original.ClientRequestId, persisted.ClientRequestId); Assert.Equal(original.Revision, persisted.Revision); Assert.Equal(original.UpdatedAtUtc, persisted.UpdatedAtUtc); } AssertAttemptUnchanged(attempt, Assert.Single(await verify.EmailSendAttempts.IgnoreQueryFilters().AsNoTracking().ToListAsync())); } [Fact] public async Task Draft_DTO_timestamps_keep_UTC_shape_and_exact_ticks_after_SQLite_roundtrips() { await using var fixture = await Fixture.CreateAsync(); await using var createDb = fixture.Context("user-1"); var created = Assert.IsType( Assert.IsType((await Controller(createDb, "user-1").Create( new EmailDraftsController.CreateDraftRequest(fixture.UserOneJobId, "gmail", "one@example.test", "UTC subject", "UTC body", "utc-thread"), default)).Result).Value); AssertUtcTimestamps(created); await using var readDb = fixture.Context("user-1"); var beforeReads = await readDb.EmailDrafts.AsNoTracking().SingleAsync(); var reader = Controller(readDb, "user-1"); var retrieved = Assert.IsType( Assert.IsType((await reader.Get(created.Id, default)).Result).Value); var listed = Assert.Single(Assert.IsAssignableFrom>( Assert.IsType((await reader.List(fixture.UserOneJobId, default)).Result).Value)); await using var verifyReads = fixture.Context("user-1"); var afterReads = await verifyReads.EmailDrafts.AsNoTracking().SingleAsync(); AssertDraftContentUnchanged(beforeReads, afterReads); Assert.Equal(beforeReads.ClientRequestId, afterReads.ClientRequestId); Assert.Equal(beforeReads.Revision, afterReads.Revision); Assert.Equal(beforeReads.UpdatedAtUtc, afterReads.UpdatedAtUtc); Assert.Equal(beforeReads.CreatedAtUtc.Kind, afterReads.CreatedAtUtc.Kind); Assert.Equal(beforeReads.UpdatedAtUtc.Kind, afterReads.UpdatedAtUtc.Kind); Assert.Empty(await verifyReads.EmailSendAttempts.AsNoTracking().ToListAsync()); Assert.Equal(created, retrieved); Assert.Equal(created, listed); Assert.Equal(created.CreatedAtUtc.Ticks, retrieved.CreatedAtUtc.Ticks); Assert.Equal(created.UpdatedAtUtc.Ticks, retrieved.UpdatedAtUtc.Ticks); Assert.Equal(created.CreatedAtUtc.Ticks, listed.CreatedAtUtc.Ticks); Assert.Equal(created.UpdatedAtUtc.Ticks, listed.UpdatedAtUtc.Ticks); AssertUtcTimestamps(retrieved); AssertUtcTimestamps(listed); var updated = Assert.IsType( Assert.IsType((await reader.Update(created.Id, new EmailDraftsController.UpdateDraftRequest(created.Revision, "one@example.test", "Updated UTC subject", "Updated UTC body"), default)).Result).Value); AssertUtcTimestamps(updated); Assert.Equal(created.CreatedAtUtc.Ticks, updated.CreatedAtUtc.Ticks); Assert.Equal(created.ClientRequestId, updated.ClientRequestId); Assert.Equal(2, updated.Revision); var failedAttempt = new EmailSendAttempt { Id = Guid.NewGuid(), OwnerUserId = "user-1", JobApplicationId = updated.JobApplicationId, Provider = updated.Provider, ClientRequestId = updated.ClientRequestId, PayloadHash = new string('c', 64), Status = EmailSendStatuses.Failed, FailureCategory = "provider_rejected", CreatedAtUtc = updated.CreatedAtUtc, CompletedAtUtc = updated.UpdatedAtUtc, }; readDb.EmailSendAttempts.Add(failedAttempt); await readDb.SaveChangesAsync(); var rotated = Assert.IsType( Assert.IsType((await reader.NewAttempt(updated.Id, new EmailDraftsController.NewAttemptRequest(updated.Revision), default)).Result).Value); AssertUtcTimestamps(rotated); Assert.Equal(updated.CreatedAtUtc.Ticks, rotated.CreatedAtUtc.Ticks); Assert.NotEqual(updated.ClientRequestId, rotated.ClientRequestId); Assert.Equal(3, rotated.Revision); await using var verifyRotation = fixture.Context("user-1"); var finalReader = Controller(verifyRotation, "user-1"); var finalRetrieved = Assert.IsType( Assert.IsType((await finalReader.Get(rotated.Id, default)).Result).Value); var finalListed = Assert.Single(Assert.IsAssignableFrom>( Assert.IsType((await finalReader.List(null, default)).Result).Value)); Assert.Equal(rotated, finalRetrieved); Assert.Equal(rotated, finalListed); AssertUtcTimestamps(finalRetrieved); AssertUtcTimestamps(finalListed); AssertAttemptUnchanged(failedAttempt, Assert.Single(await verifyRotation.EmailSendAttempts.AsNoTracking().ToListAsync())); } private static void AssertUtcTimestamps(EmailDraftsController.DraftDto draft) { Assert.Equal(DateTimeKind.Utc, draft.CreatedAtUtc.Kind); Assert.Equal(DateTimeKind.Utc, draft.UpdatedAtUtc.Kind); } private static void AssertDraftContentUnchanged(EmailDraft expected, EmailDraft actual) { Assert.Equal(expected.Id, actual.Id); Assert.Equal(expected.OwnerUserId, actual.OwnerUserId); Assert.Equal(expected.JobApplicationId, actual.JobApplicationId); Assert.Equal(expected.Provider, actual.Provider); Assert.Equal(expected.To, actual.To); Assert.Equal(expected.Subject, actual.Subject); Assert.Equal(expected.BodyText, actual.BodyText); Assert.Equal(expected.ThreadId, actual.ThreadId); Assert.Equal(expected.CreatedAtUtc, actual.CreatedAtUtc); } private static void AssertAttemptUnchanged(EmailSendAttempt expected, EmailSendAttempt actual) { Assert.Equal(expected.Id, actual.Id); Assert.Equal(expected.OwnerUserId, actual.OwnerUserId); Assert.Equal(expected.JobApplicationId, actual.JobApplicationId); Assert.Equal(expected.Provider, actual.Provider); Assert.Equal(expected.ClientRequestId, actual.ClientRequestId); Assert.Equal(expected.PayloadHash, actual.PayloadHash); Assert.Equal(expected.Status, actual.Status); Assert.Equal(expected.ProviderMessageId, actual.ProviderMessageId); Assert.Equal(expected.FailureCategory, actual.FailureCategory); Assert.Equal(expected.CreatedAtUtc, actual.CreatedAtUtc); Assert.Equal(expected.StartedAtUtc, actual.StartedAtUtc); Assert.Equal(expected.CompletedAtUtc, actual.CompletedAtUtc); } private static EmailDraftsController Controller(JobTrackerContext db, string userId) { var controller = new EmailDraftsController( db, new EmailProviderRegistry(new[] { new FakeProvider() }), TimeProvider.System); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity( new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "test")), }, }; return controller; } private sealed class FakeProvider : IEmailProvider { public string ProviderKey => "gmail"; public Task GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken) => Task.FromResult(null); public Task> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task SendAsync(string ownerUserId, EmailDeliveryRequest request, CancellationToken cancellationToken) => throw new NotSupportedException(); } private sealed class Fixture : IAsyncDisposable { private readonly SqliteConnection _connection; private readonly DbContextOptions _options; private Fixture(SqliteConnection connection, DbContextOptions options) { _connection = connection; _options = options; } public int UserOneJobId { get; private set; } public int UserTwoJobId { get; private set; } public Guid UserOneDraftId { get; private set; } public Guid UserTwoDraftId { get; private set; } public static async Task CreateAsync(bool seedDrafts = false) { var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; var fixture = new Fixture(connection, options); await using var db = fixture.Context(null); await db.Database.EnsureCreatedAsync(); var companyOne = new Company { Name = "One", OwnerUserId = "user-1" }; var companyTwo = new Company { Name = "Two", OwnerUserId = "user-2" }; db.Companies.AddRange(companyOne, companyTwo); await db.SaveChangesAsync(); var jobOne = new JobApplication { JobTitle = "One", CompanyId = companyOne.Id, OwnerUserId = "user-1" }; var jobTwo = new JobApplication { JobTitle = "Two", CompanyId = companyTwo.Id, OwnerUserId = "user-2" }; db.JobApplications.AddRange(jobOne, jobTwo); await db.SaveChangesAsync(); fixture.UserOneJobId = jobOne.Id; fixture.UserTwoJobId = jobTwo.Id; if (seedDrafts) { var one = Draft("user-1", jobOne.Id, "one@example.test"); var two = Draft("user-2", jobTwo.Id, "two@example.test"); db.EmailDrafts.AddRange(one, two); await db.SaveChangesAsync(); fixture.UserOneDraftId = one.Id; fixture.UserTwoDraftId = two.Id; } return fixture; } public JobTrackerContext Context(string? userId) { var currentUser = new Mock(); currentUser.SetupGet(service => service.UserId).Returns(userId); return new JobTrackerContext(_options, currentUser.Object); } public ValueTask DisposeAsync() => _connection.DisposeAsync(); private static EmailDraft Draft(string ownerUserId, int jobApplicationId, string recipient) => new() { Id = Guid.NewGuid(), OwnerUserId = ownerUserId, JobApplicationId = jobApplicationId, Provider = "gmail", To = recipient, Subject = "Synthetic", BodyText = "Synthetic private draft.", ThreadId = "thread-1", ClientRequestId = Guid.NewGuid().ToString("D"), CreatedAtUtc = DateTime.UtcNow, UpdatedAtUtc = DateTime.UtcNow, }; } }