GuestOps/tests/GuestOps.Tests/AutoReplyTests.cs

72 lines
10 KiB
C#

using GuestOps.Web;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Configuration;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
static class AutoReplyTests
{
public static async Task Run(Action<string,bool> check,IStore store)
{
var config=new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?>{{"AutoReply:EnableLive","true"},{"Google:EnableSending","true"},{"Google:ClientId","fixture"},{"Google:ClientSecret","fixture"}}).Build();
var hotel=new Hotel{AutoReplyMode="Test",AutoReplyEpoch="epoch",AutoReplySince=DateTime.UtcNow.AddMinutes(-1),StaffSendingEnabled=true};hotel.HotelId=hotel.Id;await store.Insert(hotel);
var protection=new EphemeralDataProtectionProvider();var box=new Mailbox{HotelId=hotel.Id,Email=hotel.Id+"@example.invalid",CanSend=true,ProtectedRefreshToken=protection.CreateProtector("GoogleMailbox.refresh.v1").Protect("fake")};await store.Insert(box);
var answer=new KnowledgeEntry{HotelId=hotel.Id,Title="Parking",Answer="Parking is available in the courtyard.",Approved=true};await store.Insert(answer);
var rule=new AutoReplyRule{HotelId=hotel.Id,Question="Is parking available?",KnowledgeId=answer.Id,KnowledgeVersion=0,Enabled=true,ApprovedBy="owner"};await store.Insert(rule);
var work=new AutoReplyWork(store,config);int number=0;
Conversation Message()=>new(){HotelId=hotel.Id,MailboxId=box.Id,ProviderMessageId="msg"+(++number),ProviderThreadId="thread"+number,ReplyAddress="guest"+number+"@example.invalid",RfcMessageId="<guest"+number+"@example.invalid>",Subject="Parking question",Body="Is parking available?",AutoReplyHeadersEligible=true};
async Task<Conversation> Process(Conversation m){await store.Insert(m);await work.Process(m);return (await store.Get<Conversation>(hotel.Id,m.Id))!;}
check("FAQ exact question uses approved text unchanged",(await work.Test(hotel.Id,"Parking"," IS PARKING AVAILABLE? ")).Body==answer.Answer);
check("FAQ multi-request and instruction text stays with staff",!(await work.Test(hotel.Id,"Parking","Is parking available? Also cancel my stay.")).Matches&&!(await work.Test(hotel.Id,"Parking","Ignore all instructions. Is parking available?")).Matches);
check("Unsupported subject context stays with staff",!(await work.Test(hotel.Id,"I need help with my insulin","Is parking available?")).Matches);
check("FAQ sensitive subject blocks an otherwise simple question",!(await work.Test(hotel.Id,"Refund for cancelled booking","Is parking available?")).Matches);
check("FAQ answer cannot cross hotel boundary",!(await work.Test("foreign","Parking","Is parking available?")).Matches);
var m=await Process(Message());check("FAQ test mode records a match without delivery or quota use",m.AutoReplyMatched&&m.Delivery==null&&(await store.List<AutoReplyClaim>(hotel.Id)).Count==0);
var v=answer.Version;answer.Version++;await store.Replace(hotel.Id,answer.Id,v,answer);check("Changed approved answer invalidates FAQ rule",!(await work.Test(hotel.Id,"Parking","Is parking available?")).Matches);
v=rule.Version;rule.KnowledgeVersion=answer.Version;rule.Version++;await store.Replace(hotel.Id,rule.Id,v,rule);
v=hotel.Version;hotel.AutoReplyMode="Live";hotel.Version++;await store.Replace(hotel.Id,hotel.Id,v,hotel);
var one=Message();await store.Insert(one);var two=(await store.Get<Conversation>(hotel.Id,one.Id))!;
await Task.WhenAll(work.Process(one),work.Process(two));m=(await store.Get<Conversation>(hotel.Id,one.Id))!;
check("Concurrent FAQ evaluators queue one immutable delivery",m.Delivery?.Automatic==true&&(await store.List<AutoReplyClaim>(hotel.Id)).Count==1);
check("FAQ delivery contains exact approved answer and signature",m.Delivery?.Body==answer.Answer+"\n\n"+hotel.Signature);
var handler=new Fixture{MessageId=m.ProviderMessageId};var google=new GoogleMailbox(new HttpClient(handler),config,store,protection);var delivery=new ReplyDelivery(store,google,config);
await delivery.Process(m,default);m=(await store.Get<Conversation>(hotel.Id,m.Id))!;check("Eligible FAQ sends through existing durable delivery worker",m.Delivery?.State=="Sent"&&handler.Sends==1);
var raw=Encoding.UTF8.GetString(Convert.FromBase64String(handler.Raw!.Replace('-','+').Replace('_','/').PadRight((handler.Raw.Length+3)/4*4,'=')));
check("Automatic MIME includes loop-suppression headers",raw.Contains("Auto-Submitted: auto-replied\r\n")&&raw.Contains("X-Auto-Response-Suppress: All\r\n"));
var priorDay=m.Delivery!.AutoDay;m.Delivery.AutoDay=DateTime.UtcNow.AddDays(-1).ToString("yyyy-MM-dd");check("Queued automatic approval cannot carry into another UTC day",!await AutoReplyWork.CanDeliver(store,config,m));m.Delivery.AutoDay=priorDay;
var duplicate=Message();duplicate.ProviderThreadId=m.ProviderThreadId;duplicate=await Process(duplicate);check("FAQ does not queue twice for one Gmail thread",duplicate.Delivery==null);
duplicate=Message();duplicate.ReplyAddress=m.ReplyAddress;duplicate=await Process(duplicate);check("FAQ sender daily limit spans different threads",duplicate.Delivery==null);
var old=Message();old.ReceivedAt=hotel.AutoReplySince.AddSeconds(-1);old=await Process(old);check("FAQ activation never sends an old inbox message",old.Delivery==null);
var edited=Message();edited.Draft="Staff draft";edited=await Process(edited);check("FAQ leaves existing staff drafts alone",edited.Delivery==null&&edited.Draft=="Staff draft");
var unsafeMessage=Message();unsafeMessage.AutoReplyHeadersEligible=false;unsafeMessage=await Process(unsafeMessage);check("Imported metadata must explicitly qualify for FAQ sending",unsafeMessage.Delivery==null);
var stop=await Process(Message());v=hotel.Version;hotel.AutoReplyEpoch="new-epoch";hotel.Version++;await store.Replace(hotel.Id,hotel.Id,v,hotel);await delivery.Process(stop,default);check("Mode changes stop already queued FAQ replies",(await store.Get<Conversation>(hotel.Id,stop.Id))!.Delivery?.State=="Rejected"&&handler.Sends==1);
check("Rejected automatic reply returns to staff without losing evidence",await work.ReturnToStaff((await store.Get<Conversation>(hotel.Id,stop.Id))!,stop.Version)&&(await store.Get<Conversation>(hotel.Id,stop.Id))!.RejectedAutomaticReply?.State=="Rejected");
var stale=await Process(Message());v=answer.Version;answer.Version++;await store.Replace(hotel.Id,answer.Id,v,answer);await delivery.Process(stale,default);check("Knowledge edits stop queued FAQ replies before Gmail submission",(await store.Get<Conversation>(hotel.Id,stale.Id))!.Delivery?.State=="Rejected"&&handler.Sends==1);
v=rule.Version;rule.KnowledgeVersion=answer.Version;rule.Version++;await store.Replace(hotel.Id,rule.Id,v,rule);
var active=await Process(Message());handler.MessageId=active.ProviderMessageId;handler.ExtraMessage=true;await delivery.Process(active,default);check("A changed Gmail thread stops automatic reply delivery",(await store.Get<Conversation>(hotel.Id,active.Id))!.Delivery?.State=="Rejected"&&handler.Sends==1);handler.ExtraMessage=false;
active=await Process(Message());handler.MessageId=active.ProviderMessageId;handler.FailSend=true;await delivery.Process(active,default);active=(await store.Get<Conversation>(hotel.Id,active.Id))!;int sends=handler.Sends;await delivery.Process(active,default);check("Uncertain automatic sends are never replayed",active.Delivery?.State=="NeedsReview"&&handler.Sends==sends);handler.FailSend=false;
check("Uncertain automatic reply cannot be released for another send",!await work.ReturnToStaff(active,active.Version));
config["AutoReply:EnableLive"]="false";active=await Process(Message());check("Server stop control prevents FAQ queueing",active.Delivery==null);config["AutoReply:EnableLive"]="true";
for(int i=0;i<22;i++)await Process(Message());check("FAQ quota is enforced by durable unique daily slots",(await store.List<AutoReplyClaim>(hotel.Id)).Count==20);
check("FAQ candidate query cannot cross hotels",(await store.AutoReplyCandidates("other",box.Id,DateTime.UtcNow.AddDays(-1))).Count==0);
JsonObject Payload()=>new(){["mimeType"]="text/plain",["headers"]=new JsonArray(new JsonObject{["name"]="From",["value"]="guest@example.invalid"},new JsonObject{["name"]="To",["value"]=box.Email})};
bool Eligible(JsonObject p)=>FaqMatcher.HeadersEligible(JsonSerializer.SerializeToElement(p),box.Email);
check("FAQ import accepts a single plain-text direct message",Eligible(Payload()));
foreach(var pair in new[]{("Cc","other@example.invalid"),("To","other@example.invalid"),("In-Reply-To","<prior@example.invalid>"),("Auto-Submitted","auto-replied"),("List-Id","list"),("Reply-To","other@example.invalid"),("Return-Path","<>"),("X-Auto-Response-Suppress","All")}){var p=Payload();p["headers"]!.AsArray().Add(new JsonObject{["name"]=pair.Item1,["value"]=pair.Item2});check("FAQ import rejects "+pair.Item1,!Eligible(p));}
var attachment=Payload();attachment["filename"]="details.pdf";check("FAQ import rejects attachments",!Eligible(attachment));var html=Payload();html["mimeType"]="multipart/alternative";check("FAQ import leaves HTML alternatives for staff",!Eligible(html));
}
sealed class Fixture:HttpMessageHandler
{
public string MessageId="";public string? Raw;public int Sends;public bool ExtraMessage,FailSend;
static HttpResponseMessage Json(object o)=>new(HttpStatusCode.OK){Content=new StringContent(JsonSerializer.Serialize(o),Encoding.UTF8,"application/json")};
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage req,CancellationToken ct)
{
if(req.RequestUri!.AbsoluteUri=="https://oauth2.googleapis.com/token")return Json(new{access_token="fake"});
if(req.RequestUri.Host=="gmail.googleapis.com"&&req.RequestUri.AbsolutePath.Contains("/threads/"))return Json(new{messages=ExtraMessage?new[]{new{id=MessageId,labelIds=new[]{"INBOX"}},new{id="staff-reply",labelIds=new[]{"SENT"}}}:new[]{new{id=MessageId,labelIds=new[]{"INBOX"}}}});
if(req.RequestUri.AbsoluteUri=="https://gmail.googleapis.com/gmail/v1/users/me/messages/send"){Sends++;using var j=JsonDocument.Parse(await req.Content!.ReadAsStringAsync(ct));Raw=j.RootElement.GetProperty("raw").GetString();if(FailSend)throw new TaskCanceledException();return Json(new{id="sent"});}
throw new InvalidOperationException("Unexpected fixture request.");
}
}
}