227 lines
11 KiB
C#
227 lines
11 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.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<AuthorizeAttribute>()
|
|
.ToList();
|
|
var sendAuthorization = typeof(EmailController).GetMethod(nameof(EmailController.Send))!
|
|
.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
|
|
.Cast<AuthorizeAttribute>()
|
|
.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<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();
|
|
}
|
|
}
|
|
}
|