feat(email): add durable send ledger
Tracks only idempotency and delivery metadata; recipient, subject, and body are excluded. No provider send path is enabled.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
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<EmailSendConflictException>(() =>
|
||||
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));
|
||||
}
|
||||
|
||||
private static string Hash(char value) => new(value, 64);
|
||||
|
||||
private sealed class Fixture(SqliteConnection connection, DbContextOptions<JobTrackerContext> options, int jobId) : IAsyncDisposable
|
||||
{
|
||||
private readonly List<JobTrackerContext> contexts = new();
|
||||
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;
|
||||
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();
|
||||
return new Fixture(connection, options, job.Id);
|
||||
}
|
||||
|
||||
public EmailSendAttemptStore Store(string userId)
|
||||
{
|
||||
var db = CreateDb(options, userId);
|
||||
contexts.Add(db);
|
||||
return new EmailSendAttemptStore(db, TimeProvider.System);
|
||||
}
|
||||
|
||||
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 connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user