using System.Net; using System.Text.RegularExpressions; namespace CartWise.Web.Tests; public class ShoppingListItemActionsTests : IClassFixture { private readonly CartWiseWebApplicationFactory _factory; public ShoppingListItemActionsTests(CartWiseWebApplicationFactory factory) { _factory = factory; } [Fact] public async Task TogglePurchased_MarksItemPurchased() { var client = _factory.CreateClient(); await WebTestHelpers.RegisterAsync(client, "toggle1@example.com", "Toggle User"); await WebTestHelpers.CreateHouseholdAsync(client, "Toggle Household"); await AddItemAsync(client, "Milk"); var listHtml = await client.GetStringAsync("/list"); var (id, rowVersion) = ExtractFirstItemInfo(listHtml); var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml); var response = await client.PostAsync($"/list/items/{id}/toggle", new FormUrlEncodedContent(new Dictionary { ["__RequestVerificationToken"] = token, ["rowVersion"] = rowVersion })); response.EnsureSuccessStatusCode(); var updatedHtml = await client.GetStringAsync("/list"); HtmlAssert.Contains("Purchased", updatedHtml); } [Fact] public async Task TogglePurchased_TwiceReturnsItemToNeeded() { var client = _factory.CreateClient(); await WebTestHelpers.RegisterAsync(client, "toggle2@example.com", "Toggle User 2"); await WebTestHelpers.CreateHouseholdAsync(client, "Toggle Household 2"); await AddItemAsync(client, "Milk"); var listHtml = await client.GetStringAsync("/list"); var (id, rowVersion) = ExtractFirstItemInfo(listHtml); var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml); await client.PostAsync($"/list/items/{id}/toggle", new FormUrlEncodedContent(new Dictionary { ["__RequestVerificationToken"] = token, ["rowVersion"] = rowVersion })); var midHtml = await client.GetStringAsync("/list"); var (_, secondRowVersion) = ExtractFirstItemInfo(midHtml); var midToken = WebTestHelpers.ExtractAntiForgeryToken(midHtml); await client.PostAsync($"/list/items/{id}/toggle", new FormUrlEncodedContent(new Dictionary { ["__RequestVerificationToken"] = midToken, ["rowVersion"] = secondRowVersion })); var finalHtml = await client.GetStringAsync("/list"); HtmlAssert.Contains("Needed", finalHtml); } [Fact] public async Task ToggleSkipped_MarksItemSkipped() { var client = _factory.CreateClient(); await WebTestHelpers.RegisterAsync(client, "skip1@example.com", "Skip User"); await WebTestHelpers.CreateHouseholdAsync(client, "Skip Household"); await AddItemAsync(client, "Bread"); var listHtml = await client.GetStringAsync("/list"); var (id, rowVersion) = ExtractFirstItemInfo(listHtml); var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml); var response = await client.PostAsync($"/list/items/{id}/skip", new FormUrlEncodedContent(new Dictionary { ["__RequestVerificationToken"] = token, ["rowVersion"] = rowVersion })); response.EnsureSuccessStatusCode(); var updatedHtml = await client.GetStringAsync("/list"); HtmlAssert.Contains("Skipped", updatedHtml); } [Fact] public async Task DeleteItem_RemovesItFromTheList() { var client = _factory.CreateClient(); await WebTestHelpers.RegisterAsync(client, "delete1@example.com", "Delete User"); await WebTestHelpers.CreateHouseholdAsync(client, "Delete Household"); await AddItemAsync(client, "Eggs"); var listHtml = await client.GetStringAsync("/list"); var (id, rowVersion) = ExtractFirstItemInfo(listHtml); var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml); var response = await client.PostAsync($"/list/items/{id}/delete", new FormUrlEncodedContent(new Dictionary { ["__RequestVerificationToken"] = token, ["rowVersion"] = rowVersion })); response.EnsureSuccessStatusCode(); var updatedHtml = await client.GetStringAsync("/list"); Assert.DoesNotContain("Eggs", updatedHtml); HtmlAssert.Contains("No items yet", updatedHtml); } [Fact] public async Task ToggleItem_FailsForAnItemInAnotherHousehold() { var clientA = _factory.CreateClient(); var clientB = _factory.CreateClient(); await WebTestHelpers.RegisterAsync(clientA, "isoalice@example.com", "Iso Alice"); await WebTestHelpers.RegisterAsync(clientB, "isobob@example.com", "Iso Bob"); await WebTestHelpers.CreateHouseholdAsync(clientA, "Iso Household A"); await WebTestHelpers.CreateHouseholdAsync(clientB, "Iso Household B"); await AddItemAsync(clientA, "Alice Only Item"); var listHtmlA = await clientA.GetStringAsync("/list"); var (id, rowVersion) = ExtractFirstItemInfo(listHtmlA); var listHtmlB = await clientB.GetStringAsync("/list"); var tokenB = WebTestHelpers.ExtractAntiForgeryToken(listHtmlB); var response = await clientB.PostAsync($"/list/items/{id}/toggle", new FormUrlEncodedContent(new Dictionary { ["__RequestVerificationToken"] = tokenB, ["rowVersion"] = rowVersion })); response.EnsureSuccessStatusCode(); var finalHtmlA = await clientA.GetStringAsync("/list"); Assert.True(HasStatusBadge(finalHtmlA, "Needed"), "Expected the item to still show a Needed status badge."); Assert.False(HasStatusBadge(finalHtmlA, "Purchased"), "The other household's toggle should not have changed this item's status."); } [Fact] public async Task ToggleItem_AjaxRequestWithStaleRowVersionReturnsConflict() { var client = _factory.CreateClient(); await WebTestHelpers.RegisterAsync(client, "conflict1@example.com", "Conflict User"); await WebTestHelpers.CreateHouseholdAsync(client, "Conflict Household"); await AddItemAsync(client, "Butter"); var listHtml = await client.GetStringAsync("/list"); var (id, _) = ExtractFirstItemInfo(listHtml); var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml); var request = new HttpRequestMessage(HttpMethod.Post, $"/list/items/{id}/toggle") { Content = new FormUrlEncodedContent(new Dictionary { ["__RequestVerificationToken"] = token, ["rowVersion"] = Guid.NewGuid().ToString() }) }; request.Headers.Add("X-Requested-With", "XMLHttpRequest"); var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); } private static async Task AddItemAsync(HttpClient client, string displayName) { var html = await client.GetStringAsync("/list"); var token = WebTestHelpers.ExtractAntiForgeryToken(html); var response = await client.PostAsync("/list/items", new FormUrlEncodedContent(new Dictionary { ["__RequestVerificationToken"] = token, ["NewItem.DisplayName"] = displayName })); response.EnsureSuccessStatusCode(); } private static bool HasStatusBadge(string html, string status) { return Regex.IsMatch(html, $"badge bg-secondary\">{Regex.Escape(status)}"); } private static (string Id, string RowVersion) ExtractFirstItemInfo(string html) { var idMatch = Regex.Match(html, "id=\"list-item-([0-9a-fA-F-]{36})\""); if (!idMatch.Success) { throw new InvalidOperationException("No list item found in HTML."); } var window = html.Substring(idMatch.Index, Math.Min(1200, html.Length - idMatch.Index)); var rowVersionMatch = Regex.Match(window, "name=\"rowVersion\" value=\"([^\"]+)\""); if (!rowVersionMatch.Success) { throw new InvalidOperationException("No rowVersion field found for the list item."); } return (idMatch.Groups[1].Value, rowVersionMatch.Groups[1].Value); } }