Files
jobtrackingapp/JobTrackerApi.Tests/EmailSendControllerTests.cs
T
cesnimda 123fc5555a feat(email): add explicit send API
Require tenant-owned jobs, explicit confirmation, canonical request IDs, and rate limiting before provider delivery. Persist sent correspondence with a content-free idempotency ledger, and never retry uncertain outcomes automatically.
2026-08-09 23:57:38 +02:00

209 lines
10 KiB
C#

using System.Security.Claims;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Services.EmailProviders;
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 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<EmailController.SendResult>(Assert.IsType<OkObjectResult>(first.Result).Value).Status);
Assert.True(Assert.IsType<EmailController.SendResult>(Assert.IsType<OkObjectResult>(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<BadRequestObjectResult>(unconfirmed.Result);
var other = await fixture.Controller(provider, "user-2").Send(Request(fixture.JobId, confirmed: true), default);
Assert.IsType<NotFoundResult>(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<ObjectResult>((await controller.Send(request, default)).Result);
var duplicate = Assert.IsType<ConflictObjectResult>((await controller.Send(request, default)).Result);
Assert.Equal(StatusCodes.Status502BadGateway, failed.StatusCode);
Assert.Equal(EmailSendStatuses.Failed, Assert.IsType<EmailController.SendResult>(failed.Value).Status);
Assert.Equal(EmailSendStatuses.Failed, Assert.IsType<EmailController.SendResult>(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<ObjectResult>((await controller.Send(request, default)).Result);
var duplicate = Assert.IsType<ConflictObjectResult>((await controller.Send(request, default)).Result);
Assert.Equal(StatusCodes.Status409Conflict, uncertain.StatusCode);
Assert.Equal(EmailSendStatuses.Uncertain, Assert.IsType<EmailController.SendResult>(uncertain.Value).Status);
Assert.Equal(EmailSendStatuses.Uncertain, Assert.IsType<EmailController.SendResult>(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<BadRequestObjectResult>((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<ObjectResult>((await controller.Send(Request(fixture.JobId, confirmed: true), default)).Result);
Assert.Equal(StatusCodes.Status502BadGateway, failed.StatusCode);
Assert.Equal(EmailSendStatuses.Failed, Assert.IsType<EmailController.SendResult>(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<EmailDeliveryRequest, EmailDeliveryResult>? send = null, Exception? connectionError = null) : IEmailProvider
{
public int SendCount { get; private set; }
public string ProviderKey => "gmail";
public Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken) =>
connectionError is null
? Task.FromResult<EmailConnectionInfo?>(new("gmail", "owner@gmail.test", true))
: Task.FromException<EmailConnectionInfo?>(connectionError);
public Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<IReadOnlyList<EmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<EmailDeliveryResult> 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<JobTrackerContext> options, JobTrackerContext db, int jobId) : IAsyncDisposable
{
private readonly List<JobTrackerContext> contexts = new();
public JobTrackerContext Db { get; } = db;
public int JobId { get; } = jobId;
public static async Task<Fixture> CreateAsync()
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<JobTrackerContext>().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<EmailController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "test"))
}
}
};
}
private static JobTrackerContext CreateDb(DbContextOptions<JobTrackerContext> options, string userId)
{
var currentUser = new Mock<ICurrentUserService>();
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();
}
}
}