using GerbilManagerWebAPI.Inbox;
using GerbilManagerWebAPI.Models;
using MailKit.Security;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
namespace GerbilManager.Tests;
/// INBOX-3: threaded reply send — correct In-Reply-To/References/Subject, status
/// transition to Answered, and the not-configured / auth-failure error paths. Fake SMTP
/// sender (live Gmail gated on Julian's App Password).
public class SendReplyServiceTests
{
private sealed class CapturingSender : IGmailMailSender
{
public OutgoingMail? Last;
public bool ThrowAuth;
public Task SendAsync(MailConnection c, OutgoingMail m, CancellationToken ct = default)
{
if (ThrowAuth) throw new AuthenticationException("bad app password");
Last = m;
return Task.CompletedTask;
}
}
private static ApplicationContext NewDb()
{
var opts = new DbContextOptionsBuilder()
.UseInMemoryDatabase("send-" + Guid.NewGuid().ToString("N")).Options;
var db = new ApplicationContext(opts);
db.Database.EnsureCreated();
return db;
}
private static async Task<(ApplicationContext db, SendReplyService svc, CapturingSender sender, Request req)>
SetupAsync(bool configured = true, string subject = "Anfrage Rennmäuse", string? refs = " ")
{
var db = NewDb();
var settings = new MailSettingsService(db, new EphemeralDataProtectionProvider());
var s = await settings.GetAsync();
if (configured) { s.GmailAddress = "zucht@gmail.com"; settings.SetPassword(s, "app-pw"); }
var req = new Request
{
Id = Guid.NewGuid(),
GmailMessageId = "",
ReferencesHeader = refs,
FromAddress = "kunde@web.de",
Subject = subject,
Status = RequestStatus.New,
ReceivedAt = DateTimeOffset.UtcNow,
};
db.Requests.Add(req);
await db.SaveChangesAsync();
var sender = new CapturingSender();
return (db, new SendReplyService(db, sender, settings), sender, req);
}
[Fact]
public async Task Send_threads_and_marks_answered()
{
var (db, svc, sender, req) = await SetupAsync();
var outcome = await svc.SendAsync(req, "Hallo, sehr gerne!");
Assert.True(outcome.Sent);
Assert.Null(outcome.Error);
Assert.NotNull(sender.Last);
Assert.Equal("kunde@web.de", sender.Last!.To);
Assert.Equal("Re: Anfrage Rennmäuse", sender.Last.Subject);
Assert.Equal("", sender.Last.InReplyTo);
Assert.Equal(" ", sender.Last.References); // chain + original
var saved = await db.Requests.SingleAsync();
Assert.Equal(RequestStatus.Answered, saved.Status);
Assert.NotNull(saved.AnsweredAt);
Assert.Equal("Hallo, sehr gerne!", saved.DraftReply);
}
[Fact]
public async Task Send_does_not_double_prefix_re_subject()
{
var (_, svc, sender, req) = await SetupAsync(subject: "Re: läuft schon", refs: null);
await svc.SendAsync(req, "ok");
Assert.Equal("Re: läuft schon", sender.Last!.Subject);
Assert.Equal("", sender.Last.References); // no prior chain -> just the original
}
[Fact]
public async Task Send_without_config_fails_and_keeps_status()
{
var (db, svc, sender, req) = await SetupAsync(configured: false);
var outcome = await svc.SendAsync(req, "ok");
Assert.Equal("MailNotConfigured", outcome.Error);
Assert.Null(sender.Last);
Assert.Equal(RequestStatus.New, (await db.Requests.SingleAsync()).Status);
}
[Fact]
public async Task Send_auth_failure_does_not_mark_answered()
{
var (db, svc, sender, req) = await SetupAsync();
sender.ThrowAuth = true;
var outcome = await svc.SendAsync(req, "ok");
Assert.Equal("MailAuthFailed", outcome.Error);
Assert.Equal(RequestStatus.New, (await db.Requests.SingleAsync()).Status);
}
[Fact]
public void BuildMimeMessage_sets_threading_headers()
{
var msg = GmailMailSender.BuildMimeMessage("zucht@gmail.com",
new OutgoingMail("kunde@web.de", "Re: Test", "Hallo", "", " "));
Assert.Equal("Re: Test", msg.Subject);
Assert.Contains("kunde@web.de", msg.To.ToString());
Assert.Contains("orig@gmail", msg.InReplyTo);
Assert.Contains("orig@gmail", string.Join(' ', msg.References));
Assert.Contains("a@x", string.Join(' ', msg.References));
}
}