124 lines
13 KiB
C#

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<string,string?> { ["Mongo:ConnectionString"] = uri, ["Mongo:Database"] = dbName }).Build());
await store.Initialize();
try
{
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<Hotel>(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<Conversation>(a.Id)).Count == 1);
Check("Inbox queries enforce hotel boundary", (await store.List<Conversation>(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<Hotel>(a.Id,a.Id))?.Name == "Updated");
await store.Delete<Conversation>(b.Id,row.Id);
Check("Other hotel cannot delete message", await store.Get<Conversation>(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<string,string?> { ["Mongo:ConnectionString"] = uri, ["Mongo:Database"] = dbName }).Build());
Check("Settings survive new database client", (await reloaded.Get<Hotel>(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<Mailbox>(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<Mailbox>(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<JsonElement> 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);
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);
}