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)); } }