72 lines
10 KiB
C#
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.");
|
|
}
|
|
}
|
|
}
|