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 check,IStore store) { var config=new ConfigurationBuilder().AddInMemoryCollection(new Dictionary{{"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="",Subject="Parking question",Body="Is parking available?",AutoReplyHeadersEligible=true}; async Task Process(Conversation m){await store.Insert(m);await work.Process(m);return (await store.Get(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 evaluation=await work.Evaluate(hotel.Id,new[]{new AutoEvaluationCase("allowed","Parking","Is parking available?",true,answer.Id),new AutoEvaluationCase("extra-request","Parking","Is parking available? Also cancel my stay.",false)}); check("FAQ batch evaluation reports expected matches and exclusions",evaluation.Length==2&&evaluation.All(x=>x.Passed)&&evaluation[0].ActualKnowledgeId==answer.Id); 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(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(hotel.Id,one.Id))!; await Task.WhenAll(work.Process(one),work.Process(two));m=(await store.Get(hotel.Id,one.Id))!; check("Concurrent FAQ evaluators queue one immutable delivery",m.Delivery?.Automatic==true&&(await store.List(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(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(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(hotel.Id,stop.Id))!,stop.Version)&&(await store.Get(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(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(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(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(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",""),("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 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."); } } }