115 lines
12 KiB
C#

using GuestOps.Web;
using Microsoft.Extensions.Configuration;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
static class PmsTests
{
public static async Task Run(Action<string,bool> check,IStore store)
{
var hotel=new Hotel{PmsUpdatesEnabled=true};hotel.HotelId=hotel.Id;await store.Insert(hotel);
var prefix="Pms:Hotels:"+hotel.Id+":";
var config=new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?>{[prefix+"BaseUrl"]="https://ohip.example.invalid",[prefix+"HotelCode"]="TEST01",[prefix+"ClientId"]="client",[prefix+"ClientSecret"]="fixture-secret",[prefix+"AppKey"]="fixture-app-key",[prefix+"WritesEnabled"]="true"}).Build();
var handler=new Fixture();var tokens=new OhipTokens();var client=new OhipClient(new HttpClient(handler),tokens);var work=new PmsWork(store,client,config);
async Task<bool> Blocked(Func<Task> action){try{await action();return false;}catch(PmsConflict){return true;}catch(PmsInvalid){return true;}}
var snapshot=await work.Lookup(hotel.Id,"CONF123",default);
check("OHIP exact lookup returns typed reservation and primary guest",snapshot.ReservationId=="RES123"&&snapshot.GuestName=="Alex Guest"&&snapshot.Arrival=="2030-10-12");
await work.Lookup(hotel.Id,"CONF123",default);check("OHIP access token is cached within the hotel binding",handler.TokenCalls==1);
handler.WrongDetailId=true;check("OHIP mismatched detail identity fails closed",await Blocked(()=>work.Lookup(hotel.Id,"CONF123",default)));handler.WrongDetailId=false;
handler.Ambiguous=true;check("OHIP ambiguous exact confirmations are rejected",await Blocked(()=>work.Lookup(hotel.Id,"CONF123",default)));handler.Ambiguous=false;
handler.HasMore=true;check("OHIP incomplete search page is not assumed unique",await Blocked(()=>work.Lookup(hotel.Id,"CONF123",default)));handler.HasMore=false;
handler.WrongProperty=true;check("OHIP foreign property response is rejected",await Blocked(()=>work.Lookup(hotel.Id,"CONF123",default)));handler.WrongProperty=false;
check("Lookup validates confirmation before provider access",await Blocked(()=>work.Lookup(hotel.Id,"x&hotelId=OTHER",default)));
var proposal=await work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"AddNote","","","Guest arrives late."));
check("PMS proposal is durable without an external write",handler.Writes==0&&(await store.Get<PmsChange>(hotel.Id,proposal.Id))?.State=="Review");
check("One active proposal per hotel reservation",await Blocked(()=>work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"AddNote","","","Duplicate"))));
check("Another hotel cannot read pending PMS changes",await store.Get<PmsChange>("other",proposal.Id)==null);
check("PMS approval rejects stale version",await Blocked(()=>work.Apply(proposal,3,"owner",false,default))&&handler.Writes==0);
var one=(await store.Get<PmsChange>(hotel.Id,proposal.Id))!;var two=(await store.Get<PmsChange>(hotel.Id,proposal.Id))!;
await Task.WhenAll(Blocked(()=>work.Apply(one,0,"owner",false,default)),Blocked(()=>work.Apply(two,0,"owner",false,default)));
var applied=(await store.Get<PmsChange>(hotel.Id,proposal.Id))!;
check("Concurrent approval submits one PMS write",handler.Writes==1&&applied.State=="Applied");
var payload=JsonNode.Parse(handler.LastPayload!)!;
check("OHIP mutation uses documented PUT wrapper and preserves prior notes",handler.LastMethod=="PUT"&&payload["reservations"]![0]!["hotelId"]!.GetValue<string>()=="TEST01"&&payload["reservations"]![0]!["comments"]!.AsArray().Count==2&&payload["reservations"]![0]!["comments"]![0]!["comment"]!["text"]!["value"]!.GetValue<string>()=="Original note");
check("Completed PMS operation cannot be replayed",await Blocked(()=>work.Apply(applied,applied.Version,"owner",false,default))&&handler.Writes==1);
snapshot=await work.Lookup(hotel.Id,"CONF123",default);
var dates=await work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"StayDates","2030-10-15","2030-10-18",""));
check("Date changes require availability and price acknowledgement",await Blocked(()=>work.Apply(dates,0,"owner",false,default))&&handler.Writes==1);
handler.Current["lastModifyDateTime"]="2030-01-02T12:00:00";
var stale=await work.Apply(dates,0,"owner",true,default);
check("Fresh preflight blocks a changed PMS reservation",stale.State=="NotApplied"&&handler.Writes==1);
snapshot=await work.Lookup(hotel.Id,"CONF123",default);
dates=await work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"StayDates","2030-10-15","2030-10-18",""));
var moved=await work.Apply(dates,0,"owner",true,default);
check("Stay date update is verified by a fresh PMS read",moved.State=="Applied"&&moved.After?.Arrival=="2030-10-15"&&handler.Writes==2);
check("Date update does not invent rates or use forced overlay",!handler.LastPayload!.Contains("roomRates")&&!handler.LastPayload.Contains("reservationNotification"));
snapshot=await work.Lookup(hotel.Id,"CONF123",default);
var uncertain=await work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"AddNote","","","Test uncertain result"));
handler.TimeoutAfterWrite=true;uncertain=await work.Apply(uncertain,0,"owner",false,default);handler.TimeoutAfterWrite=false;int writes=handler.Writes;
check("Uncertain PMS timeout is held without replay",uncertain.State=="NeedsReview"&&await Blocked(()=>work.Apply(uncertain,uncertain.Version,"owner",false,default))&&handler.Writes==writes);
check("Uncertain PMS operation blocks a replacement proposal",await Blocked(()=>work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"AddNote","","","Replacement"))));
var verified=await work.Verify(uncertain,uncertain.Version,default);
check("PMS reconciliation reads state without issuing a write",verified.State=="Applied"&&handler.Writes==writes);
snapshot=await work.Lookup(hotel.Id,"CONF123",default);
var noEffect=await work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"AddNote","","","Missing note"));
handler.IgnoreWrite=true;noEffect=await work.Apply(noEffect,0,"owner",false,default);handler.IgnoreWrite=false;
check("HTTP success without intended PMS state requires review",noEffect.State=="NeedsReview");
writes=handler.Writes;var held=await work.Verify(noEffect,noEffect.Version,default);check("Unconfirmed reconciliation stays held",held.State=="NeedsReview"&&handler.Writes==writes);
config[prefix+"WritesEnabled"]="false";held=await work.Verify(held,held.Version,default);check("PMS reconciliation remains available with server writes disabled",held.State=="NeedsReview"&&handler.Writes==writes);config[prefix+"WritesEnabled"]="true";
// A different reservation identity allows independent recovery tests.
handler.ReservationId="RES456";handler.Current["reservationIdList"]![0]!["id"]="RES456";
snapshot=await work.Lookup(hotel.Id,"CONF123",default);
var interrupted=await work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"AddNote","","","Interrupted note"));
interrupted.State="Applying";interrupted.UpdatedAt=DateTime.UtcNow.AddMinutes(-6);interrupted.Version=1;await store.Replace(hotel.Id,interrupted.Id,0,interrupted);
var recovered=await work.Verify((await store.Get<PmsChange>(hotel.Id,interrupted.Id))!,1,default);
check("Interrupted PMS change is only reconciled after restart",recovered.State=="NeedsReview"&&handler.Writes==writes);
handler.ReservationId="RES789";handler.Current["reservationIdList"]![0]!["id"]="RES789";
snapshot=await work.Lookup(hotel.Id,"CONF123",default);
var cancelled=await work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"AddNote","","","Cancel me"));cancelled=await work.Cancel(cancelled,0);
check("Unapplied proposal can be cancelled without PMS write",cancelled.State=="Cancelled"&&handler.Writes==writes);
var expired=await work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"AddNote","","","Expired"));expired.ExpiresAt=DateTime.UtcNow.AddMinutes(-1);expired.Version=1;await store.Replace(hotel.Id,expired.Id,0,expired);
check("Expired PMS approval is blocked",await Blocked(()=>work.Apply(expired,1,"owner",false,default)));await work.Cancel(expired,1);
hotel.PmsUpdatesEnabled=false;hotel.Version=1;await store.Replace(hotel.Id,hotel.Id,0,hotel);
var disabled=await work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"AddNote","","","Disabled"));check("Hotel PMS stop control blocks writes",await Blocked(()=>work.Apply(disabled,0,"owner",false,default))&&handler.Writes==writes);
var profile=work.Profile(hotel.Id);config[prefix+"ClientSecret"]="rotated-fixture";
check("OHIP credential rotation changes connection identity",work.Profile(hotel.Id).Revision!=profile.Revision);
await client.Lookup(hotel.Id,work.Profile(hotel.Id),"CONF123",default);check("Rotated OHIP credentials do not reuse cached token",handler.TokenCalls==2);
check("Missing hotel binding never uses another hotel credentials",OhipProfile.Read(config,Guid.NewGuid().ToString("N"))==null);
config[prefix+"BaseUrl"]="http://127.0.0.1";check("OHIP configuration rejects insecure local origins",await Blocked(()=>{work.Profile(hotel.Id);return Task.CompletedTask;}));
}
sealed class Fixture:HttpMessageHandler
{
public string ReservationId="RES123";public int Writes,TokenCalls;public bool WrongDetailId,WrongProperty,Ambiguous,HasMore,TimeoutAfterWrite,IgnoreWrite;public string? LastPayload,LastMethod;
public JsonNode Current=JsonNode.Parse("""
{"reservationIdList":[{"id":"RES123","type":"Reservation"},{"id":"CONF123","type":"Confirmation"}],"hotelId":"TEST01","reservationStatus":"Reserved","lastModifyDateTime":"2030-01-01T12:00:00","roomStay":{"arrivalDate":"2030-10-12","departureDate":"2030-10-14","roomRates":[{"roomType":"KING"}],"total":{"amountAfterTax":240,"currencyCode":"GBP"}},"reservationGuests":[{"primary":true,"profileInfo":{"profile":{"customer":{"personName":[{"givenName":"Alex","surname":"Guest","nameType":"Primary"}]}}}}],"comments":[{"id":"NOTE1","type":"Comment","comment":{"text":{"value":"Original note"},"type":"RESERVATION","notificationLocation":"GEN","internal":true}}]}
""")!;
static HttpResponseMessage Json(object value)=>new(HttpStatusCode.OK){Content=new StringContent(JsonSerializer.Serialize(value),Encoding.UTF8,"application/json")};
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken ct)
{
if(request.RequestUri!.Host!="ohip.example.invalid")throw new Exception("Fixture cannot access real OHIP");
if(request.RequestUri.AbsolutePath=="/oauth/v1/tokens"){TokenCalls++;return Json(new{access_token="fixture-token",expires_in=3600});}
if(!request.Headers.TryGetValues("x-hotelid",out var codes)||codes.Single()!="TEST01")throw new Exception("Wrong hotel header");
if(request.Method==HttpMethod.Put)
{
Writes++;LastMethod=request.Method.Method;LastPayload=await request.Content!.ReadAsStringAsync(ct);
if(!IgnoreWrite)
{
var update=JsonNode.Parse(LastPayload)!["reservations"]![0]!;
if(update["comments"] is {} comments)Current["comments"]=comments.DeepClone();
if(update["roomStay"] is {} stay){Current["roomStay"]!["arrivalDate"]=stay["arrivalDate"]!.DeepClone();Current["roomStay"]!["departureDate"]=stay["departureDate"]!.DeepClone();}
Current["lastModifyDateTime"]="2030-01-03T12:00:"+Writes.ToString("00");
}
if(TimeoutAfterWrite)throw new TaskCanceledException("Fixture timeout after provider committed update");
return Json(new{});
}
var row=Current.DeepClone();
if(WrongDetailId&&request.RequestUri.AbsolutePath.EndsWith('/'+ReservationId))row["reservationIdList"]![0]!["id"]="WRONG";
if(WrongProperty)row["hotelId"]="OTHER";
var rows=new JsonArray(row);if(Ambiguous)rows.Add(row.DeepClone());
return Json(new JsonObject{["reservations"]=new JsonObject{["reservation"]=rows,["hasMore"]=HasMore}});
}
}
}