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 Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class EmailSendControllerTests { [Fact] public void Basic_email_send_requires_local_authentication_but_not_pro_entitlement() { var controllerAuthorization = typeof(EmailController).GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true) .Cast() .ToList(); var sendAuthorization = typeof(EmailController).GetMethod(nameof(EmailController.Send))! .GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true) .Cast() .ToList(); var authorization = Assert.Single(controllerAuthorization); Assert.Equal("local", authorization.AuthenticationSchemes); Assert.True(string.IsNullOrWhiteSpace(authorization.Policy)); Assert.Empty(sendAuthorization); } [Fact] public async Task Confirmed_send_is_recorded_once_and_duplicate_returns_existing_result() { await using var fixture = await Fixture.CreateAsync(); var provider = new FakeProvider(); var controller = fixture.Controller(provider, "user-1"); var request = Request(fixture.JobId, confirmed: true); var first = await controller.Send(request, default); var duplicate = await controller.Send(request, default); Assert.Equal(EmailSendStatuses.Sent, Assert.IsType(Assert.IsType(first.Result).Value).Status); Assert.True(Assert.IsType(Assert.IsType(duplicate.Result).Value).Duplicate); Assert.Equal(1, provider.SendCount); Assert.Equal(1, await fixture.Db.Correspondences.CountAsync()); Assert.Equal(1, await fixture.Db.JobEvents.CountAsync(item => item.Type == "EmailSent")); Assert.DoesNotContain("Body", (await fixture.Db.JobEvents.SingleAsync()).Note ?? string.Empty); Assert.Equal(EmailSendStatuses.Sent, (await fixture.Db.EmailSendAttempts.AsNoTracking().SingleAsync()).Status); } [Fact] public async Task Missing_confirmation_or_cross_tenant_job_never_reserves_or_sends() { await using var fixture = await Fixture.CreateAsync(); var provider = new FakeProvider(); var unconfirmed = await fixture.Controller(provider, "user-1").Send(Request(fixture.JobId, confirmed: false), default); Assert.IsType(unconfirmed.Result); var other = await fixture.Controller(provider, "user-2").Send(Request(fixture.JobId, confirmed: true), default); Assert.IsType(other.Result); Assert.Equal(0, provider.SendCount); Assert.Equal(0, await fixture.Db.EmailSendAttempts.IgnoreQueryFilters().CountAsync()); } [Fact] public async Task Known_rejection_is_failed_and_requires_a_new_request_id() { await using var fixture = await Fixture.CreateAsync(); var provider = new FakeProvider(_ => throw new EmailProviderDeliveryException("provider_rejected", false, "Rejected")); var controller = fixture.Controller(provider, "user-1"); var request = Request(fixture.JobId, confirmed: true); var failed = Assert.IsType((await controller.Send(request, default)).Result); var duplicate = Assert.IsType((await controller.Send(request, default)).Result); Assert.Equal(StatusCodes.Status502BadGateway, failed.StatusCode); Assert.Equal(EmailSendStatuses.Failed, Assert.IsType(failed.Value).Status); Assert.Equal(EmailSendStatuses.Failed, Assert.IsType(duplicate.Value).Status); Assert.Equal(1, provider.SendCount); Assert.Empty(await fixture.Db.Correspondences.ToListAsync()); } [Fact] public async Task Transport_interruption_is_uncertain_and_is_never_retried() { await using var fixture = await Fixture.CreateAsync(); var provider = new FakeProvider(_ => throw new EmailProviderDeliveryException("transport_interrupted", true, "Uncertain")); var controller = fixture.Controller(provider, "user-1"); var request = Request(fixture.JobId, confirmed: true); var uncertain = Assert.IsType((await controller.Send(request, default)).Result); var duplicate = Assert.IsType((await controller.Send(request, default)).Result); Assert.Equal(StatusCodes.Status409Conflict, uncertain.StatusCode); Assert.Equal(EmailSendStatuses.Uncertain, Assert.IsType(uncertain.Value).Status); Assert.Equal(EmailSendStatuses.Uncertain, Assert.IsType(duplicate.Value).Status); Assert.Equal(1, provider.SendCount); } [Fact] public async Task Invalid_or_missing_fields_never_reserve_or_send() { await using var fixture = await Fixture.CreateAsync(); var provider = new FakeProvider(); var controller = fixture.Controller(provider, "user-1"); var invalid = new[] { Request(fixture.JobId, confirmed: true) with { Provider = null }, Request(fixture.JobId, confirmed: true) with { ClientRequestId = null }, Request(fixture.JobId, confirmed: true) with { To = null }, Request(fixture.JobId, confirmed: true) with { Subject = null }, Request(fixture.JobId, confirmed: true) with { BodyText = null }, Request(fixture.JobId, confirmed: true) with { ThreadId = new string('x', 513) }, }; foreach (var request in invalid) Assert.IsType((await controller.Send(request, default)).Result); Assert.Equal(0, provider.SendCount); Assert.Equal(0, await fixture.Db.EmailSendAttempts.CountAsync()); } [Fact] public async Task Connection_check_failure_is_definite_and_never_calls_send() { await using var fixture = await Fixture.CreateAsync(); var provider = new FakeProvider(connectionError: new HttpRequestException("Synthetic connection failure")); var controller = fixture.Controller(provider, "user-1"); var failed = Assert.IsType((await controller.Send(Request(fixture.JobId, confirmed: true), default)).Result); Assert.Equal(StatusCodes.Status502BadGateway, failed.StatusCode); Assert.Equal(EmailSendStatuses.Failed, Assert.IsType(failed.Value).Status); Assert.Equal(0, provider.SendCount); Assert.Equal(EmailSendStatuses.Failed, (await fixture.Db.EmailSendAttempts.AsNoTracking().SingleAsync()).Status); } private static EmailController.SendRequest Request(int jobId, bool confirmed) => new( jobId, "gmail", "00000000-0000-0000-0000-000000000123", "recruiter@example.test", "Interview follow-up", "Synthetic body", "thread-1", confirmed); private sealed class FakeProvider(Func? send = null, Exception? connectionError = null) : IEmailProvider { public int SendCount { get; private set; } public string ProviderKey => "gmail"; public Task GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken) => connectionError is null ? Task.FromResult(new("gmail", "owner@gmail.test", true)) : Task.FromException(connectionError); 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) { SendCount++; return Task.FromResult(send?.Invoke(request) ?? new EmailDeliveryResult("message-1", request.ThreadId)); } } private sealed class Fixture(SqliteConnection connection, DbContextOptions options, JobTrackerContext db, int jobId) : IAsyncDisposable { private readonly List contexts = new(); public JobTrackerContext Db { get; } = db; public int JobId { get; } = jobId; public static async Task CreateAsync() { var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; 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(); return new Fixture(connection, options, db, job.Id); } public EmailController Controller(IEmailProvider provider, string userId) { var context = userId == "user-1" ? Db : CreateDb(options, userId); if (!ReferenceEquals(context, Db)) contexts.Add(context); return new EmailController(new EmailProviderRegistry(new[] { provider }), context, new EmailSendAttemptStore(context, TimeProvider.System), NullLogger.Instance) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "test")) } } }; } 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 Db.DisposeAsync(); await connection.DisposeAsync(); } } }