using GuestOps.Web; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Configuration; using System.Net; using System.Text; using System.Text.Json; static class ReplyTests { public static async Task Run(Action check, IStore store) { var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Google:ClientId"]="fixture-client", ["Google:ClientSecret"]="fixture-secret", ["Google:EnableSending"]="true", ["Ai:ApiKey"]="fixture-only", ["Ai:Model"]="fixture-model" }).Build(); var protection = new EphemeralDataProtectionProvider(); var hotel = new Hotel { StaffSendingEnabled=true, AiDraftsEnabled=true }; hotel.HotelId=hotel.Id; await store.Insert(hotel); var mailbox = new Mailbox { HotelId=hotel.Id, Email=$"hotel-{hotel.Id}@example.invalid", CanSend=true, ProtectedRefreshToken=protection.CreateProtector("GoogleMailbox.refresh.v1").Protect("fixture-refresh") }; await store.Insert(mailbox); Conversation Message()=>new() { HotelId=hotel.Id, MailboxId=mailbox.Id, ProviderMessageId=Guid.NewGuid().ToString("N"), ProviderThreadId="ab123", RfcMessageId="", ReplyAddress="guest@example.invalid", Subject="Parking question", Draft="Parking is available.", Delivery=new() { Recipient="guest@example.invalid", Body="Parking is available.", ApprovedBy="fixture-owner" } }; var handler=new Fixture(); var google=new GoogleMailbox(new HttpClient(handler),config,store,protection); var worker=new ReplyDelivery(store,google); var message=Message(); await store.Insert(message); var stale=(await store.Get(hotel.Id,message.Id))!; await Task.WhenAll(worker.Process(message,default),worker.Process(stale,default)); var saved=await store.Get(hotel.Id,message.Id); check("Competing workers send one approved reply only",handler.Sends==1&&saved!.Delivery!.State=="Sent"); await worker.Process(saved!,default); check("Completed delivery is never replayed",handler.Sends==1); var raw=Encoding.UTF8.GetString(Convert.FromBase64String(handler.Raw!.Replace('-','+').Replace('_','/').PadRight((handler.Raw.Length+3)/4*4,'='))); check("Gmail payload has stable identity and reply headers",raw.Contains(message.Delivery!.MessageId)&&raw.Contains("In-Reply-To: ")&&handler.Thread=="ab123"&&raw.Contains("To: guest@example.invalid")); check("Multiple or injected recipients are rejected",ReplyMime.Address("a@example.invalid,b@example.invalid")==""&&ReplyMime.Address("a@example.invalid\r\nBcc: b@example.invalid")==""); var malicious=Message();malicious.RfcMessageId="\r\nBcc: bad@example.invalid";bool blocked=false;try{ReplyMime.Build(malicious,mailbox);}catch{blocked=true;}check("Reply header injection is blocked",blocked); handler.FailSend=true;var uncertain=Message();await store.Insert(uncertain);await worker.Process(uncertain,default); saved=await store.Get(hotel.Id,uncertain.Id);int sends=handler.Sends;await worker.Process(saved!,default); check("Timeout after send is held without retry",saved!.Delivery!.State=="NeedsReview"&&handler.Sends==sends); handler.FailSend=false;handler.FailToken=true;var rejected=Message();await store.Insert(rejected);await worker.Process(rejected,default); check("Token failure is recorded before any send",(await store.Get(hotel.Id,rejected.Id))!.Delivery!.State=="Rejected"&&handler.Sends==sends); handler.FailToken=false;var interrupted=Message();interrupted.Delivery!.State="Sending";interrupted.Delivery.UpdatedAt=DateTime.UtcNow.AddMinutes(-6);await store.Insert(interrupted);await worker.Process(interrupted,default); check("Interrupted send after restart requires verification",(await store.Get(hotel.Id,interrupted.Id))!.Delivery!.State=="NeedsReview"&&handler.Sends==sends); check("Other hotels cannot read delivery evidence",await store.Get("other-hotel",interrupted.Id)==null); var disabled=Message();hotel.StaffSendingEnabled=false;hotel.Version++;await store.Replace(hotel.Id,hotel.Id,0,hotel);await store.Insert(disabled);await worker.Process(disabled,default); check("Hotel stop control blocks queued delivery",(await store.Get(hotel.Id,disabled.Id))!.Delivery!.State=="Rejected"&&handler.Sends==sends); handler.FoundMessageId=uncertain.Delivery!.MessageId;handler.FoundRecipient=uncertain.ReplyAddress;handler.FoundFrom=mailbox.Email; check("Uncertain delivery verifies matching Gmail sent record",await google.FindSent(uncertain,mailbox,default)=="sent-found"); handler.FoundRecipient="someone-else@example.invalid";check("Mismatched Gmail recipient cannot reconcile delivery",await google.FindSent(uncertain,mailbox,default)==null); var knowledge=new[] { new KnowledgeEntry { Id="approved",HotelId=hotel.Id,Title="Parking",Answer="Parking is available.",Approved=true },new KnowledgeEntry { Id="unapproved",HotelId=hotel.Id,Title="Parking",Answer="SECRET DRAFT",Approved=false },new KnowledgeEntry { Id="foreign",HotelId="other-hotel",Title="Parking",Answer="OTHER HOTEL",Approved=true } }; var sources=AiDrafts.SelectSources(message,knowledge);check("AI retrieval excludes unapproved and foreign hotel answers",sources.Length==1&&sources[0].Id=="approved"); var ai=new AiDrafts(new HttpClient(handler),config); var suggestion=await ai.Generate(message,sources,default); check("Structured AI result preserves validated evidence",suggestion.Draft=="Parking is available."&&suggestion.SourceIds.SequenceEqual(new[]{"approved"})); check("AI request disables storage and excludes hidden knowledge",handler.AiPayload!.Contains("\"store\":false")&&!handler.AiPayload.Contains("SECRET DRAFT")&&!handler.AiPayload.Contains("OTHER HOTEL")); handler.AiSource="foreign";blocked=false;try{await ai.Generate(message,sources,default);}catch{blocked=true;}check("Invented AI citations fail closed",blocked); handler.AiSource="approved";handler.AiEscalate=true;suggestion=await ai.Generate(message,sources,default);check("AI escalation never yields a sendable generated reply",suggestion.NeedsReview&&suggestion.Draft==""); var requests=handler.AiRequests; suggestion=await ai.Generate(message,[],default);check("Missing hotel knowledge escalates without an API call",suggestion.NeedsReview&&handler.AiRequests==requests); handler.AiIncomplete=true;blocked=false;try{await ai.Generate(message,sources,default);}catch{blocked=true;}check("Incomplete AI output cannot become a draft",blocked); } sealed class Fixture : HttpMessageHandler { public int Sends,AiRequests;public bool FailSend,FailToken,AiEscalate,AiIncomplete;public string AiSource="approved";public string? Raw,Thread,AiPayload,FoundMessageId,FoundRecipient,FoundFrom; static HttpResponseMessage Json(object value)=>new(HttpStatusCode.OK){Content=new StringContent(JsonSerializer.Serialize(value),Encoding.UTF8,"application/json")}; protected override async Task SendAsync(HttpRequestMessage request,CancellationToken ct) { var url=request.RequestUri!.AbsoluteUri; if(url=="https://oauth2.googleapis.com/token") return FailToken?new(HttpStatusCode.Unauthorized):Json(new{access_token="fixture-access"}); if(url=="https://gmail.googleapis.com/gmail/v1/users/me/messages/send") { Interlocked.Increment(ref Sends);using var json=JsonDocument.Parse(await request.Content!.ReadAsStringAsync(ct));Raw=json.RootElement.GetProperty("raw").GetString();Thread=json.RootElement.GetProperty("threadId").GetString(); if(FailSend)throw new TaskCanceledException("Simulated uncertain delivery");return Json(new{id="sent-fixture"}); } if(url.Contains("/messages?"))return Json(new{messages=new[]{new{id="sent-found"}}}); if(url.Contains("/messages/sent-found?"))return Json(new{labelIds=new[]{"SENT"},payload=new{headers=new[]{new{name="Message-ID",value=FoundMessageId},new{name="To",value=FoundRecipient},new{name="From",value=FoundFrom}}}}); if(url=="https://api.openai.com/v1/responses") { AiRequests++;AiPayload=await request.Content!.ReadAsStringAsync(ct); return Json(new{status=AiIncomplete?"incomplete":"completed",output=new[]{new{type="message",content=new[]{new{type="output_text",text=JsonSerializer.Serialize(new{draft="Parking is available.",needsReview=AiEscalate,reason="Check approved parking information.",sourceIds=new[]{AiSource}})}}}}}); } throw new InvalidOperationException("Unexpected fixture URL: "+url); } } }