using System.Net; using System.Net.Http.Json; using System.Text.Json; using GuestOps.Web; using GuestOps.Models; using GuestOps.Services; using Microsoft.Extensions.Configuration; using MongoDB.Driver; int passed = 0; void Check(string name, bool value) { if (!value) throw new Exception("FAIL " + name); Console.WriteLine("PASS " + name); passed++; } var uri = Environment.GetEnvironmentVariable("MONGO_TEST_URI"); var dbName = "guestops_test_" + Guid.NewGuid().ToString("N"); IStore store = uri == null ? new PreviewStore() : new MongoStore(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Mongo:ConnectionString"] = uri, ["Mongo:Database"] = dbName }).Build()); await store.Initialize(); try { await TeamTests.Run(Check, store); await ReplyTests.Run(Check, store); await PmsTests.Run(Check, store); await PaymentTests.Run(Check, store); await AutoReplyTests.Run(Check, store); var a = new Hotel { Name = "Hotel A" }; a.HotelId = a.Id; var b = new Hotel { Name = "Hotel B" }; b.HotelId = b.Id; await store.Insert(a); await store.Insert(b); Check("Hotels cannot read each other's settings", await store.Get(b.Id, a.Id) == null); var row = new Conversation { HotelId = a.Id, MailboxId = "mailbox", ProviderMessageId = "message-1", Subject = "Parking" }; await store.Import(row); await store.Import(row); Check("Duplicate email import is idempotent", (await store.List(a.Id)).Count == 1); Check("Inbox queries enforce hotel boundary", (await store.List(b.Id)).Count == 0); var altered = new Hotel { Id = a.Id, HotelId = a.Id, Name = "Updated", Version = 1 }; Check("Settings update succeeds with current version", await store.Replace(a.Id,a.Id,0,altered)); Check("Concurrent stale edit rejected", !await store.Replace(a.Id,a.Id,0,altered)); Check("Saved settings read back", (await store.Get(a.Id,a.Id))?.Name == "Updated"); await store.Delete(b.Id,row.Id); Check("Other hotel cannot delete message", await store.Get(a.Id,row.Id) != null); var state = new OAuthRequest { HotelId = a.Id, UserId = "owner", ExpiresAt = DateTime.UtcNow.AddMinutes(1) }; await store.Insert(state); Check("OAuth state bound to hotel and user", await store.ConsumeOAuth(state.Id,b.Id,"owner") == null && await store.ConsumeOAuth(state.Id,a.Id,"intruder") == null); Check("OAuth state accepted exactly once", await store.ConsumeOAuth(state.Id,a.Id,"owner") != null && await store.ConsumeOAuth(state.Id,a.Id,"owner") == null); var expired = new OAuthRequest { HotelId = a.Id, UserId = "owner", ExpiresAt = DateTime.UtcNow.AddMinutes(-1) }; await store.Insert(expired); Check("Expired OAuth state rejected", await store.ConsumeOAuth(expired.Id,a.Id,"owner") == null); if (uri != null) { Check("Worker lease excludes a second worker", await store.TryLease("mailbox","one") && !await store.TryLease("mailbox","two")); await store.ReleaseLease("mailbox","two"); Check("Only owner can release worker lease", !await store.TryLease("mailbox","two")); await store.ReleaseLease("mailbox","one"); Check("Worker lease can be acquired after release", await store.TryLease("mailbox","two")); var reloaded = new MongoStore(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Mongo:ConnectionString"] = uri, ["Mongo:Database"] = dbName }).Build()); Check("Settings survive new database client", (await reloaded.Get(a.Id,a.Id))?.Name == "Updated"); var mailbox = new Mailbox { HotelId = a.Id, Email = "hotel@example.invalid" }; await store.SaveMailbox(mailbox); bool denied = false; try { await store.SaveMailbox(new Mailbox { HotelId = b.Id, Email = mailbox.Email }); } catch (MongoWriteException) { denied = true; } Check("Mailbox cannot be connected to two hotels", denied); var oldMailbox = await store.Get(a.Id, mailbox.Id); mailbox.ProtectedRefreshToken = "new-protected-token"; await store.SaveMailbox(mailbox); oldMailbox!.SyncError = "stale worker failure"; await store.SaveSync(oldMailbox); var newMailbox = await store.Get(a.Id, mailbox.Id); Check("Stale worker cannot overwrite reconnected credentials", newMailbox?.ProtectedRefreshToken == "new-protected-token" && newMailbox.SyncError == ""); } var parsed = AiExtractionPrompt.BuildRowsFromJson("{\"action\":\"CreateBooking\",\"first_name\":\"Guest {test}\"}", new ParsedBooking()); Check("Migrated parser preserves braces in JSON strings", parsed.Count > 0 && parsed[0].GuestFirstName == "Guest {test}"); Check("Migrated booking validation rejects multiple rooms", BookingValidation.Stay(new ParsedBooking { Rooms = 2 }) != null); Check("Migrated redactor removes OAuth secrets", !SecretRedactor.Redact("{\"refresh_token\":\"secret-value\"}").Contains("secret-value")); var baseUrl = Environment.GetEnvironmentVariable("TEST_API_URL"); if (baseUrl != null) { using var one = new HttpClient(new HttpClientHandler { CookieContainer = new CookieContainer(), AllowAutoRedirect = false }) { BaseAddress = new Uri(baseUrl) }; using var two = new HttpClient(new HttpClientHandler { CookieContainer = new CookieContainer(), AllowAutoRedirect = false }) { BaseAddress = new Uri(baseUrl) }; async Task Read(HttpClient h,string path) => JsonDocument.Parse(await h.GetStringAsync(path)).RootElement.Clone(); async Task SetCsrf(HttpClient h) { var s=await Read(h,"/api/session");h.DefaultRequestHeaders.Remove("X-CSRF-TOKEN");h.DefaultRequestHeaders.Add("X-CSRF-TOKEN",s.GetProperty("csrfToken").GetString()); } Check("Anonymous inbox access blocked", (await one.GetAsync("/api/conversations")).StatusCode == HttpStatusCode.Unauthorized); Check("Unsafe requests require CSRF token", (await one.PostAsJsonAsync("/api/preview/start",new {})).StatusCode == HttpStatusCode.BadRequest); await SetCsrf(one); await SetCsrf(two); Check("Preview creates signed-in workspace", (await one.PostAsJsonAsync("/api/preview/start",new {})).IsSuccessStatusCode); await two.PostAsJsonAsync("/api/preview/start",new {}); await SetCsrf(one); await SetCsrf(two); var h1=await Read(one,"/api/hotel");var h2=await Read(two,"/api/hotel"); Check("Different preview sessions have different hotels", h1.GetProperty("id").GetString()!=h2.GetProperty("id").GetString()); var messages=await Read(one,"/api/conversations"); var id=messages[0].GetProperty("id").GetString(); Check("Guessed message ID cannot be edited across hotels", (await two.PutAsJsonAsync($"/api/conversations/{id}/draft",new {draft="stolen",version=0})).StatusCode==HttpStatusCode.NotFound); Check("Draft save succeeds", (await one.PutAsJsonAsync($"/api/conversations/{id}/draft",new {draft="Hello guest",version=0})).IsSuccessStatusCode); Check("Stale API draft save returns conflict", (await one.PutAsJsonAsync($"/api/conversations/{id}/draft",new {draft="old",version=0})).StatusCode==HttpStatusCode.Conflict); Check("Preview cannot connect real Gmail", !(await one.PostAsJsonAsync("/api/integrations/google/connect",new {})).IsSuccessStatusCode); Check("Preview cannot enable paid AI", (await one.PutAsJsonAsync("/api/reply-controls",new {version=0,aiDraftsEnabled=true,staffSendingEnabled=false})).StatusCode==HttpStatusCode.BadRequest); Check("Cross-hotel reply approval is blocked", (await two.PostAsJsonAsync($"/api/conversations/{id}/send",new {version=1,recipient="guest@example.invalid"})).StatusCode==HttpStatusCode.NotFound); Check("Cross-hotel AI generation is blocked", (await two.PostAsJsonAsync($"/api/conversations/{id}/generate",new {version=1})).StatusCode==HttpStatusCode.NotFound); Check("Preview cannot send real mail", (await one.PostAsJsonAsync($"/api/conversations/{id}/send",new {version=1,recipient="guest@example.invalid"})).StatusCode==HttpStatusCode.BadRequest); Check("Cross-hotel delivery status is blocked", (await two.GetAsync($"/api/conversations/{id}")).StatusCode==HttpStatusCode.NotFound); Check("PMS status exposes no credentials", !(await one.GetStringAsync("/api/pms/status")).Contains("clientSecret")); Check("Preview cannot access real PMS lookup", (await one.PostAsJsonAsync("/api/pms/lookup",new{confirmation="CONF123"})).StatusCode==HttpStatusCode.BadRequest); Check("Preview cannot enable PMS updates", (await one.PutAsJsonAsync("/api/pms/controls",new{version=0,enabled=true})).StatusCode==HttpStatusCode.BadRequest); Check("Unknown PMS snapshot cannot be proposed", (await two.PostAsJsonAsync("/api/pms/changes",new{snapshotId="foreign",kind="AddNote",arrival="",departure="",note="test"})).StatusCode==HttpStatusCode.NotFound); Check("Unknown PMS operation cannot be applied", (await two.PostAsJsonAsync("/api/pms/changes/foreign/apply",new{version=0,availabilityAndPriceChecked=true})).StatusCode==HttpStatusCode.NotFound); var paymentStatus=await Read(one,"/api/payments/status"); Check("Preview payment status is disabled and contains no secret", !paymentStatus.GetProperty("configured").GetBoolean()&&!paymentStatus.GetRawText().Contains("securityKey")); Check("Preview cannot enable invoice creation",(await one.PutAsJsonAsync("/api/payments/controls",new{version=0,enabled=true})).StatusCode==HttpStatusCode.BadRequest); Check("Preview cannot prepare a real payment",(await one.PostAsJsonAsync("/api/payments/requests",new{reference="TEST",email="guest@example.invalid",description="Deposit",amount=80,currency="GBP"})).StatusCode==HttpStatusCode.BadRequest); var payments=await Read(one,"/api/payments/requests");var paymentId=payments[0].GetProperty("id").GetString(); Check("Foreign payment cannot be approved",(await two.PostAsJsonAsync($"/api/payments/requests/{paymentId}/create",new{version=0,emailAndAmountApproved=true})).StatusCode==HttpStatusCode.NotFound); Check("Foreign payment cannot be reconciled",(await two.PostAsJsonAsync($"/api/payments/requests/{paymentId}/check",new{version=0})).StatusCode==HttpStatusCode.NotFound); Check("Preview sample invoice cannot be created",(await one.PostAsJsonAsync($"/api/payments/requests/{paymentId}/create",new{version=0,emailAndAmountApproved=true})).StatusCode==HttpStatusCode.BadRequest); var autoStatus=await Read(one,"/api/auto-replies/status"); Check("Preview cannot enable FAQ live sending",!autoStatus.GetProperty("liveConfigured").GetBoolean()&&(await one.PutAsJsonAsync("/api/auto-replies/mode",new{mode="Live",version=0,acceptanceConfirmed=true})).StatusCode==HttpStatusCode.BadRequest); Check("FAQ test mode can be saved without sending",(await one.PutAsJsonAsync("/api/auto-replies/mode",new{mode="Test",version=0,acceptanceConfirmed=false})).IsSuccessStatusCode); var ownKnowledge=await Read(one,"/api/knowledge");var answerId=ownKnowledge[0].GetProperty("id").GetString(); Check("FAQ rule rejects another hotel's knowledge",(await two.PutAsJsonAsync("/api/auto-replies/rules/3",new{question="Is parking available?",knowledgeId=answerId,enabled=true,version=0})).StatusCode==HttpStatusCode.BadRequest); Check("Owner can save a reviewed FAQ rule",(await one.PutAsJsonAsync("/api/auto-replies/rules/3",new{question="Is parking available?",knowledgeId=answerId,enabled=true,version=0})).IsSuccessStatusCode); var autoTest=await one.PostAsJsonAsync("/api/auto-replies/test",new{subject="Parking",body="Is parking available?"}); Check("FAQ content tester returns a match without a delivery",autoTest.IsSuccessStatusCode&&JsonDocument.Parse(await autoTest.Content.ReadAsStringAsync()).RootElement.GetProperty("matches").GetBoolean()); Check("FAQ rule updates reject stale versions",(await one.PutAsJsonAsync("/api/auto-replies/rules/3",new{question="Is parking available?",knowledgeId=answerId,enabled=false,version=0})).StatusCode==HttpStatusCode.Conflict); Check("Uncertain or foreign replies cannot be released",(await two.PostAsJsonAsync($"/api/conversations/{id}/delivery/release",new{version=0})).StatusCode==HttpStatusCode.NotFound); await TeamTests.Http(Check,one,two,baseUrl); var cookie=(await one.GetAsync("/api/session")).Headers; Check("Session response is not cacheable", cookie.CacheControl?.NoStore==true); await one.PostAsJsonAsync("/api/auth/logout",new {}); Check("Logout removes inbox access", (await one.GetAsync("/api/conversations")).StatusCode==HttpStatusCode.Unauthorized); } Console.WriteLine($"{passed} checks passed. MongoDB integration: {uri!=null}; HTTP integration: {baseUrl!=null}."); } finally { // This suite owns only a fresh, randomly named database under a fixed test prefix. if (uri != null && dbName.StartsWith("guestops_test_")) await new MongoClient(uri).DropDatabaseAsync(dbName); }