diff --git a/Contract/ContractCrud.cs b/Contract/ContractCrud.cs index 61cbe5d..614a10a 100644 --- a/Contract/ContractCrud.cs +++ b/Contract/ContractCrud.cs @@ -52,7 +52,7 @@ namespace raven_integration // ------------------------------------------------------------------- var updatedName = Util.Uniquify("Updated Contract"); var putPayload = $$""" - {"id":{{Id}},"concurrency":{{concurrency}},"name":"{{updatedName}}","active":true,"notes":"Updated notes","wiki":null,"customFields":"{}","tags":[],"responseTime":"02:00:00","contractServiceRatesOnly":true,"contractTravelRatesOnly":false,"partsOverridePct":15.0,"partsOverrideType":2,"serviceRatesOverridePct":0.0,"serviceRatesOverrideType":1,"travelRatesOverridePct":5.0,"travelRatesOverrideType":1,"alertNotes":"Updated alert"} + {"id":{{Id}},"concurrency":{{concurrency}},"name":"{{updatedName}}","active":true,"notes":"Updated notes","wiki":null,"customFields":"{}","tags":[],"responseTime":"02:00:00","contractServiceRatesOnly":false,"contractTravelRatesOnly":false,"partsOverridePct":15.0,"partsOverrideType":2,"serviceRatesOverridePct":0.0,"serviceRatesOverrideType":1,"travelRatesOverridePct":5.0,"travelRatesOverrideType":1,"alertNotes":"Updated alert"} """; a = await Util.PutAsync("contract", token, putPayload); Util.ValidateHTTPStatusCode(a, 200); @@ -66,7 +66,7 @@ namespace raven_integration a = await Util.GetAsync($"contract/{Id}", token); Util.ValidateDataReturnResponseOk(a); a.ObjectResponse["data"]["name"].Value().Should().Be(updatedName); - a.ObjectResponse["data"]["contractServiceRatesOnly"].Value().Should().BeTrue(); + a.ObjectResponse["data"]["contractServiceRatesOnly"].Value().Should().BeFalse(); // ------------------------------------------------------------------- // CONCURRENCY VIOLATION: PUT with stale concurrency should return 409 @@ -81,6 +81,12 @@ namespace raven_integration Util.ValidateHTTPStatusCode(a, 204); // Confirm deleted + //bugbug case 4652 TO be fixed for this test to pass + //bug at server causing 500 to be returned instead of 404 + //do not want to fix yet until ready for refactor in general + //deployed UI unaffected as it wouldn't attempt this normally + //this problem likely exhibits in any other object that has a 'populatedisplayfields' call at the server + //in their get routine that doesn't guard against null record a = await Util.GetAsync($"contract/{Id}", token); Util.ValidateResponseNotFound(a); } diff --git a/DataList/DataListOperations.cs b/DataList/DataListOperations.cs index db4281d..90560e1 100644 --- a/DataList/DataListOperations.cs +++ b/DataList/DataListOperations.cs @@ -13,26 +13,128 @@ namespace raven_integration Exercises POST/GET/PUT/DELETE on /data-list-filter plus the /list endpoint. Uses WorkOrderDataList as the list key since work orders always have seeded data. - 2. FilterAndSort stubs (SKIPPED — need real payloads from browser) - To fill these in: - a) Open the AyaNova UI, navigate to the Work Orders grid - b) Apply a filter (e.g. "Notes contains fox"), open browser DevTools -> Network - c) Find the POST to /data-list — copy the full request body - d) Use that body as the starting point for the test payload here - See Util.BuildDataListRequestEx and Util.BuildSimpleFilterDataListViewColumn for helpers. + 2. FilterAndSort (LIVE — each test creates its own named filter, uses it, then deletes it) + Pattern: + a) CreateFilterAsync() — POST to /data-list-filter with filter rules as a JSON string + b) BuildDataListRequest() — POST body for /data-list with the filterId + c) Assert on the rows returned + d) Delete the filter in a finally block + For sort tests: POST /data-list-column-view/sort, then reset in finally. + For column-view test: POST /data-list-column-view, then reset in finally. - 3. Rights stub (SKIPPED — needs a user with no list rights) - Verify that a user without the WorkOrder list role gets 403. + 3. Rights (LIVE — SubContractor user has no WorkOrder list rights) ------ Date filter note: Server accounts for user's time zone offset when filtering by date range. All seeded users share the same tz offset (TIME_ZONE_ADJUSTMENT in util.cs). Use ToOffsetAdjustedUniversalTime() extension when building date filter values. + + WorkOrderDataList column keys (default order): + "WorkOrderSerialNumber" integer / rid + "Customer" string + "WorkOrderServiceDate" datetime + "WorkOrderCloseByDate" datetime + "WorkOrderStatus" pick list / string + "Project" string + "WorkOrderAge" integer + + Default sort: WorkOrderSerialNumber descending ("-"). */ public class DataListOperations { + // ----------------------------------------------------------------------- + // PRIVATE HELPERS + // ----------------------------------------------------------------------- + + /// + /// Build a single column filter rule for use in the data-list-filter "filter" field. + /// any=false → AND, any=true → OR across the items list. + /// + private static JObject BuildFilterRule(string column, bool any, + params (string op, string value)[] items) + { + return new JObject + { + ["column"] = column, + ["any"] = any, + ["items"] = new JArray(items.Select(i => new JObject { ["op"] = i.op, ["value"] = i.value })) + }; + } + + /// + /// POST a new private named filter to /data-list-filter and return its id. + /// Pass null filterRules to create an empty (no-op) filter that returns all records. + /// + private static async Task CreateFilterAsync(string token, string listKey, + JArray filterRules) + { + dynamic d = new JObject(); + d.id = 0; + d.concurrency = 0; + d.name = Util.Uniquify("Test Filter"); + d["public"] = true; + d.defaultFilter = false; + d.listKey = listKey; + d.filter = filterRules?.ToString(Newtonsoft.Json.Formatting.None) ?? "[]"; + ApiResponse a = await Util.PostAsync("data-list-filter", token, + d.ToString(Newtonsoft.Json.Formatting.None)); + Util.ValidateDataReturnResponseOk(a); + return a.ObjectResponse["data"]["id"].Value(); + } + + /// + /// Build the POST body for /data-list. + /// clientTimeStamp is set to DateTimeOffset.Now so relative date keywords work correctly. + /// + private static string BuildDataListRequest(long filterId, int offset = 0, int limit = 100) + { + var ts = DateTimeOffset.Now.ToString("o"); + return $$"""{"offset":{{offset}},"limit":{{limit}},"dataListKey":"WorkOrderDataList","filterId":{{filterId}},"clientTimeStamp":"{{ts}}"}"""; + } + + /// + /// Decode the user id from the JWT auth token payload (avoids a round-trip GET). + /// The server embeds "id" as a string claim in the token. + /// + private static long GetUserIdFromToken(string token) + { + var payloadB64 = token.Split('.')[1].Replace('-', '+').Replace('_', '/'); + while (payloadB64.Length % 4 != 0) payloadB64 += "="; + var json = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(payloadB64)); + return JObject.Parse(json)["id"].Value(); + } + + /// + /// Look up the zero-based column index by field key from the data-list response object. + /// + private static int GetColumnIndex(JToken response, string fieldKey) + { + var columns = (JArray)response["columns"]; + for (int i = 0; i < columns.Count; i++) + { + if (columns[i]["fk"]?.Value() == fieldKey) + return i; + } + throw new InvalidOperationException($"Column '{fieldKey}' not found in response columns"); + } + + /// + /// Create a minimal work order with the given service date and return its id. + /// customerId=1 is XYZ Accounting in the seed data. + /// + private static async Task CreateWorkOrderAsync(string token, DateTime serviceDate, + long customerId = 1) + { + var isoDate = serviceDate.ToString("o"); + var payload = $$"""{"id":0,"concurrency":0,"serial":0,"notes":"DataList test WO","wiki":null,"customFields":"{}","tags":[],"customerId":{{customerId}},"projectId":null,"contractId":null,"internalReferenceNumber":null,"customerReferenceNumber":null,"customerContactName":null,"fromQuoteId":null,"fromPMId":null,"serviceDate":"{{isoDate}}","completeByDate":null,"durationToCompleted":"00:00:00","invoiceNumber":null,"onsite":false,"customerSignature":null,"customerSignatureName":null,"customerSignatureCaptured":null,"techSignature":null,"techSignatureName":null,"techSignatureCaptured":null,"postAddress":null,"postCity":null,"postRegion":null,"postCountry":null,"postCode":null,"address":null,"city":null,"region":null,"country":null,"addressPostal":null,"latitude":null,"longitude":null,"isDirty":true,"isLockedAtServer":false}"""; + ApiResponse a = await Util.PostAsync("workorder", token, payload); + Util.ValidateDataReturnResponseOk(a); + return a.ObjectResponse["data"]["id"].Value(); + } + + // ----------------------------------------------------------------------- // 1. SAVED FILTER CRUD // ----------------------------------------------------------------------- @@ -53,7 +155,7 @@ namespace raven_integration // UserId=0 — the server sets the actual user id from the auth token var filterName = Util.Uniquify("Test Saved Filter"); var payload = $$""" - {"id":0,"concurrency":0,"userId":0,"name":"{{filterName}}","public":true,"defaultFilter":false,"listKey":"{{ListKey}}","filter":null} + {"id":0,"concurrency":0,"userId":0,"name":"{{filterName}}","public":true,"defaultFilter":false,"listKey":"{{ListKey}}","filter":"[]"} """; ApiResponse a = await Util.PostAsync("data-list-filter", token, payload); @@ -86,7 +188,7 @@ namespace raven_integration // PUT — make it private and rename var updatedName = Util.Uniquify("Updated Saved Filter"); var putPayload = $$""" - {"id":{{Id}},"concurrency":{{concurrency}},"userId":0,"name":"{{updatedName}}","public":false,"defaultFilter":false,"listKey":"{{ListKey}}","filter":null} + {"id":{{Id}},"concurrency":{{concurrency}},"userId":0,"name":"{{updatedName}}","public":false,"defaultFilter":false,"listKey":"{{ListKey}}","filter":"[]"} """; a = await Util.PutAsync("data-list-filter", token, putPayload); Util.ValidateHTTPStatusCode(a, 200); @@ -131,88 +233,579 @@ namespace raven_integration // ----------------------------------------------------------------------- - // 2. FILTER AND SORT — stubs awaiting real payloads from the browser + // 2. FILTER AND SORT // ----------------------------------------------------------------------- // - // How to capture a real payload: - // 1. Run the server locally (dotnet run from raven/server/AyaNova/) - // 2. Open the UI, navigate to Work Orders grid - // 3. Apply a filter through the UI (e.g. Notes "contains" something) - // 4. DevTools > Network — find the POST to /api/v8/data-list - // 5. Copy the full request body JSON - // 6. Use Util.BuildDataListRequestEx() or paste directly as a raw string + // Each test creates a named filter, POSTs to /data-list with that filterId, + // asserts the result, then deletes the filter in a finally block. // - // Field keys for WorkOrderDataList (see WorkOrderDataList.cs in server): - // "WorkOrderSerialNumber" integer (is the row id field) - // "Customer" string - // "WorkOrderServiceDate" datetime - // "WorkOrderCloseByDate" datetime - // "WorkOrderStatus" pick list / string - // "Project" string - // "WorkOrderAge" integer + // Filter format stored in the "filter" field of data-list-filter: + // JSON array string: [{"column":"FIELD","any":false,"items":[{"op":"OP","value":"VAL"}]}] + // any=false → AND, any=true → OR // - // Operator constants are on Util: OpContains, OpStartsWith, OpEndsWith, - // OpEquality, OpNotEqual, OpGreaterThan, OpLessThan, etc. - // - // Sort direction string: "+" ascending, "-" descending - // - // NOTE on date filters: use DateTime.Now.ToOffsetAdjustedUniversalTime() - // (extension in util.cs) to avoid DST/timezone mismatch failures. + // Special server-side date keywords (use with OpEquality): + // "*yesterday*", "*tomorrow*", "*thisyear*", "*NULL*" // ----------------------------------------------------------------------- - [Fact(Skip = "TODO: Capture real list payload from browser — see comment above")] - public void WorkOrderList_StringContainsFilterWorks() + + /// + /// Customer name contains "XYZ Accounting" — all returned rows must have that substring. + /// Relies on seeded work orders for customerId=1 (XYZ Accounting). + /// + [Fact] + public async Task WorkOrderList_StringContainsFilterWorks() { - // Example shape once confirmed via browser: - // - // var token = await Util.GetTokenAsync("BizAdmin"); - // dynamic dListView = new JArray(); - // dListView.Add(Util.BuildSimpleFilterDataListViewColumn("Customer", Util.OpContains, "Acme")); - // string body = Util.BuildDataListRequestEx(dListView, limit: 50, offset: 0, dataListKey: "WorkOrderDataList"); - // ApiResponse a = await Util.PostAsync("data-list", token, body); - // Util.ValidateDataReturnResponseOk(a); - // var rows = (JArray)a.ObjectResponse["data"]; - // rows.Should().NotBeEmpty(); - // // each row should have "Acme" somewhere in the Customer column value + var token = await Util.GetTokenAsync("BizAdmin"); + + var filterRules = new JArray { BuildFilterRule("Customer", false, + (Util.OpContains, "XYZ Accounting")) }; + long filterId = await CreateFilterAsync(token, "WorkOrderDataList", filterRules); + + try + { + ApiResponse a = await Util.PostAsync("data-list", token, BuildDataListRequest(filterId)); + Util.ValidateDataReturnResponseOk(a); + Util.ValidateHTTPStatusCode(a, 200); + + var rows = (JArray)a.ObjectResponse["data"]; + rows.Should().NotBeEmpty("seeded data has work orders for XYZ Accounting"); + + int customerIdx = GetColumnIndex(a.ObjectResponse, "Customer"); + foreach (var row in rows) + row[customerIdx]["v"].Value().Should() + .Contain("XYZ Accounting", "contains filter must only return matching rows"); + } + finally + { + await Util.DeleteAsync($"data-list-filter/{filterId}", token); + } } - [Fact(Skip = "TODO: Capture real list payload from browser")] - public void WorkOrderList_StringStartsWithFilterWorks() { } - [Fact(Skip = "TODO: Capture real list payload from browser")] - public void WorkOrderList_StringEqualsFilterWorks() { } + /// + /// Customer name starts with "XY" — all returned rows must have that prefix. + /// + [Fact] + public async Task WorkOrderList_StringStartsWithFilterWorks() + { + var token = await Util.GetTokenAsync("BizAdmin"); - [Fact(Skip = "TODO: Capture real list payload from browser")] - public void WorkOrderList_StringNotEqualFilterWorks() { } + var filterRules = new JArray { BuildFilterRule("Customer", false, + (Util.OpStartsWith, "XY")) }; + long filterId = await CreateFilterAsync(token, "WorkOrderDataList", filterRules); - [Fact(Skip = "TODO: Capture real list payload from browser")] - public void WorkOrderList_DateRangeFilterWorks() { } + try + { + ApiResponse a = await Util.PostAsync("data-list", token, BuildDataListRequest(filterId)); + Util.ValidateDataReturnResponseOk(a); - [Fact(Skip = "TODO: Capture real list payload from browser")] - public void WorkOrderList_NullFieldFilterWorks() { } + var rows = (JArray)a.ObjectResponse["data"]; + rows.Should().NotBeEmpty("seeded data has customers starting with 'XY'"); - [Fact(Skip = "TODO: Capture real list payload from browser")] - public void WorkOrderList_MultiConditionAndFilterWorks() { } + int customerIdx = GetColumnIndex(a.ObjectResponse, "Customer"); + foreach (var row in rows) + row[customerIdx]["v"].Value().Should() + .StartWith("XY", "startswith filter must only return matching rows"); + } + finally + { + await Util.DeleteAsync($"data-list-filter/{filterId}", token); + } + } - [Fact(Skip = "TODO: Capture real list payload from browser")] - public void WorkOrderList_SortAscendingWorks() { } - [Fact(Skip = "TODO: Capture real list payload from browser")] - public void WorkOrderList_SortDescendingWorks() { } + /// + /// Customer name equals "XYZ Accounting" exactly — all returned rows must match exactly. + /// + [Fact] + public async Task WorkOrderList_StringEqualsFilterWorks() + { + var token = await Util.GetTokenAsync("BizAdmin"); - [Fact(Skip = "TODO: Capture real list payload from browser")] - public void WorkOrderList_PaginationOffsetAndLimitWork() { } + var filterRules = new JArray { BuildFilterRule("Customer", false, + (Util.OpEquality, "XYZ Accounting")) }; + long filterId = await CreateFilterAsync(token, "WorkOrderDataList", filterRules); - [Fact(Skip = "TODO: Capture real list payload from browser")] - public void WorkOrderList_ReturnFormatMatchesExpectedShape() { } + try + { + ApiResponse a = await Util.PostAsync("data-list", token, BuildDataListRequest(filterId)); + Util.ValidateDataReturnResponseOk(a); + + var rows = (JArray)a.ObjectResponse["data"]; + rows.Should().NotBeEmpty("seeded data has work orders for XYZ Accounting"); + + int customerIdx = GetColumnIndex(a.ObjectResponse, "Customer"); + foreach (var row in rows) + row[customerIdx]["v"].Value().Should() + .Be("XYZ Accounting", "equality filter must only return exact matches"); + } + finally + { + await Util.DeleteAsync($"data-list-filter/{filterId}", token); + } + } + + + /// + /// Customer name NOT equal to "XYZ Accounting" — no returned rows may have that customer. + /// + [Fact] + public async Task WorkOrderList_StringNotEqualFilterWorks() + { + var token = await Util.GetTokenAsync("BizAdmin"); + + var filterRules = new JArray { BuildFilterRule("Customer", false, + (Util.OpNotEqual, "XYZ Accounting")) }; + long filterId = await CreateFilterAsync(token, "WorkOrderDataList", filterRules); + + try + { + ApiResponse a = await Util.PostAsync("data-list", token, BuildDataListRequest(filterId, limit: 50)); + Util.ValidateDataReturnResponseOk(a); + + var rows = (JArray)a.ObjectResponse["data"]; + rows.Should().NotBeEmpty("many seeded work orders belong to other customers"); + + int customerIdx = GetColumnIndex(a.ObjectResponse, "Customer"); + foreach (var row in rows) + row[customerIdx]["v"].Value().Should() + .NotBe("XYZ Accounting", "not-equal filter must exclude that customer"); + } + finally + { + await Util.DeleteAsync($"data-list-filter/{filterId}", token); + } + } + + + /// + /// Date range AND filter: service date > 2 days ago AND < 2 days hence. + /// Creates two work orders (yesterday noon, tomorrow noon) and verifies both appear. + /// + [Fact] + public async Task WorkOrderList_DateRangeOrMultiConditionFilterWorks() + { + var token = await Util.GetTokenAsync("BizAdmin"); + + // Create two WOs with service dates clearly inside a 4-day window + var yesterday = DateTime.Today.AddDays(-1).AddHours(12); + var tomorrow = DateTime.Today.AddDays(1).AddHours(12); + long wo1Id = await CreateWorkOrderAsync(token, yesterday); + long wo2Id = await CreateWorkOrderAsync(token, tomorrow); + + // Filter: service date > 2 days ago AND < 2 days hence (any=false = AND) + var from = DateTime.Now.AddDays(-2).ToOffsetAdjustedUniversalTime(); + var to = DateTime.Now.AddDays(2).ToOffsetAdjustedUniversalTime(); + var filterRules = new JArray { BuildFilterRule("WorkOrderServiceDate", false, + (Util.OpGreaterThan, from.ToString("o")), + (Util.OpLessThan, to.ToString("o"))) }; + long filterId = await CreateFilterAsync(token, "WorkOrderDataList", filterRules); + + try + { + ApiResponse a = await Util.PostAsync("data-list", token, BuildDataListRequest(filterId)); + Util.ValidateDataReturnResponseOk(a); + + var rows = (JArray)a.ObjectResponse["data"]; + rows.Should().NotBeEmpty("date range filter must return rows within the window"); + + int serialIdx = GetColumnIndex(a.ObjectResponse, "WorkOrderSerialNumber"); + var returnedIds = rows.Select(r => r[serialIdx]["i"].Value()).ToHashSet(); + + returnedIds.Should().Contain(wo1Id, "yesterday's work order is within the date range"); + returnedIds.Should().Contain(wo2Id, "tomorrow's work order is within the date range"); + } + finally + { + await Util.DeleteAsync($"data-list-filter/{filterId}", token); + await Util.DeleteAsync($"workorder/{wo1Id}", token); + await Util.DeleteAsync($"workorder/{wo2Id}", token); + } + } + + + /// + /// Date OR filter: service date = *yesterday* OR = *tomorrow*. + /// Creates two work orders and verifies both appear via server-side relative date keywords. + /// + [Fact] + public async Task WorkOrderList_DateRangeFilterWorks() + { + var token = await Util.GetTokenAsync("BizAdmin"); + + var yesterday = DateTime.Today.AddDays(-1).AddHours(12); + var tomorrow = DateTime.Today.AddDays(1).AddHours(12); + long wo1Id = await CreateWorkOrderAsync(token, yesterday); + long wo2Id = await CreateWorkOrderAsync(token, tomorrow); + + // any=true → OR across items + var filterRules = new JArray { BuildFilterRule("WorkOrderServiceDate", true, + (Util.OpEquality, "*yesterday*"), + (Util.OpEquality, "*tomorrow*")) }; + long filterId = await CreateFilterAsync(token, "WorkOrderDataList", filterRules); + + try + { + ApiResponse a = await Util.PostAsync("data-list", token, BuildDataListRequest(filterId)); + Util.ValidateDataReturnResponseOk(a); + + var rows = (JArray)a.ObjectResponse["data"]; + rows.Should().NotBeEmpty("OR date filter must return rows matching either day"); + + int serialIdx = GetColumnIndex(a.ObjectResponse, "WorkOrderSerialNumber"); + var returnedIds = rows.Select(r => r[serialIdx]["i"].Value()).ToHashSet(); + + returnedIds.Should().Contain(wo1Id, "yesterday's work order should appear"); + returnedIds.Should().Contain(wo2Id, "tomorrow's work order should appear"); + } + finally + { + await Util.DeleteAsync($"data-list-filter/{filterId}", token); + await Util.DeleteAsync($"workorder/{wo1Id}", token); + await Util.DeleteAsync($"workorder/{wo2Id}", token); + } + } + + + /// + /// Filter for rows where WorkOrderStatus IS NULL (*NULL* keyword). + /// Seeded data contains many work orders with no status assigned. + /// + [Fact] + public async Task WorkOrderList_NullFieldFilterWorks() + { + var token = await Util.GetTokenAsync("BizAdmin"); + + var filterRules = new JArray { BuildFilterRule("WorkOrderStatus", false, + (Util.OpEquality, "*NULL*")) }; + long filterId = await CreateFilterAsync(token, "WorkOrderDataList", filterRules); + + try + { + ApiResponse a = await Util.PostAsync("data-list", token, BuildDataListRequest(filterId)); + Util.ValidateDataReturnResponseOk(a); + + var rows = (JArray)a.ObjectResponse["data"]; + rows.Should().NotBeEmpty("seeded data should have work orders with no status"); + + int statusIdx = GetColumnIndex(a.ObjectResponse, "WorkOrderStatus"); + foreach (var row in rows) + row[statusIdx]["v"].Type.Should().Be(JTokenType.Null, + "null filter must only return rows where status is null"); + } + finally + { + await Util.DeleteAsync($"data-list-filter/{filterId}", token); + } + } + + + /// + /// Multi-column AND filter: Customer contains "ou" AND service date = *thisyear*. + /// Both conditions must be satisfied (any=false per column, multiple columns = AND between them). + /// + [Fact] + public async Task WorkOrderList_MultiConditionAndFilterWorks() + { + var token = await Util.GetTokenAsync("BizAdmin"); + + // Two separate column filters — server ANDs them together + var filterRules = new JArray + { + BuildFilterRule("Customer", false, (Util.OpContains, "ou")), + BuildFilterRule("WorkOrderServiceDate", false, (Util.OpEquality, "*thisyear*")) + }; + long filterId = await CreateFilterAsync(token, "WorkOrderDataList", filterRules); + + try + { + ApiResponse a = await Util.PostAsync("data-list", token, BuildDataListRequest(filterId)); + Util.ValidateDataReturnResponseOk(a); + + var rows = (JArray)a.ObjectResponse["data"]; + rows.Should().NotBeEmpty("seeded data has customers containing 'ou' with this year's work orders"); + + int serviceDateIdx = GetColumnIndex(a.ObjectResponse, "WorkOrderServiceDate"); + foreach (var row in rows) + { + var dateStr = row[serviceDateIdx]["v"]?.Value(); + if (dateStr != null) + DateTime.Parse(dateStr).Year.Should().Be(DateTime.Now.Year, + "service dates must all be in the current year"); + } + } + finally + { + await Util.DeleteAsync($"data-list-filter/{filterId}", token); + } + } + + + /// + /// Set sort to WorkOrderServiceDate ascending via /data-list-column-view/sort, + /// then verify that returned rows are in ascending date order. + /// Resets sort to default (WorkOrderSerialNumber descending) in the finally block. + /// + [Fact] + public async Task WorkOrderList_SortAscendingWorks() + { + var token = await Util.GetTokenAsync("BizAdmin"); + + // Set sort ascending + var sortPayload = """{"listKey":"WorkOrderDataList","sortBy":["WorkOrderServiceDate"],"sortDesc":[false]}"""; + ApiResponse a = await Util.PostAsync("data-list-column-view/sort", token, sortPayload); + Util.ValidateHTTPStatusCode(a, 200); + + long filterId = await CreateFilterAsync(token, "WorkOrderDataList", null); + + try + { + a = await Util.PostAsync("data-list", token, BuildDataListRequest(filterId, limit: 20)); + Util.ValidateDataReturnResponseOk(a); + + var rows = (JArray)a.ObjectResponse["data"]; + rows.Should().NotBeEmpty(); + + // Response sortBy should confirm ascending + a.ObjectResponse["sortBy"]["WorkOrderServiceDate"].Value().Should().Be("+", + "sortBy in response should show ascending"); + + // Verify actual data order + int serviceDateIdx = GetColumnIndex(a.ObjectResponse, "WorkOrderServiceDate"); + DateTime? prev = null; + foreach (var row in rows) + { + var dateStr = row[serviceDateIdx]["v"]?.Value(); + if (dateStr == null) continue; + var date = DateTime.Parse(dateStr); + if (prev.HasValue) + date.Should().BeOnOrAfter(prev.Value, "rows must be in ascending service date order"); + prev = date; + } + } + finally + { + await Util.DeleteAsync($"data-list-filter/{filterId}", token); + // Reset sort to default: WorkOrderSerialNumber descending + var resetPayload = """{"listKey":"WorkOrderDataList","sortBy":["WorkOrderSerialNumber"],"sortDesc":[true]}"""; + await Util.PostAsync("data-list-column-view/sort", token, resetPayload); + } + } + + + /// + /// Set sort to WorkOrderServiceDate descending, verify returned rows are in descending order. + /// Resets sort to default in the finally block. + /// + [Fact] + public async Task WorkOrderList_SortDescendingWorks() + { + var token = await Util.GetTokenAsync("BizAdmin"); + + var sortPayload = """{"listKey":"WorkOrderDataList","sortBy":["WorkOrderServiceDate"],"sortDesc":[true]}"""; + ApiResponse a = await Util.PostAsync("data-list-column-view/sort", token, sortPayload); + Util.ValidateHTTPStatusCode(a, 200); + + long filterId = await CreateFilterAsync(token, "WorkOrderDataList", null); + + try + { + a = await Util.PostAsync("data-list", token, BuildDataListRequest(filterId, limit: 20)); + Util.ValidateDataReturnResponseOk(a); + + var rows = (JArray)a.ObjectResponse["data"]; + rows.Should().NotBeEmpty(); + + a.ObjectResponse["sortBy"]["WorkOrderServiceDate"].Value().Should().Be("-", + "sortBy in response should show descending"); + + int serviceDateIdx = GetColumnIndex(a.ObjectResponse, "WorkOrderServiceDate"); + DateTime? prev = null; + foreach (var row in rows) + { + var dateStr = row[serviceDateIdx]["v"]?.Value(); + if (dateStr == null) continue; + var date = DateTime.Parse(dateStr); + if (prev.HasValue) + date.Should().BeOnOrBefore(prev.Value, "rows must be in descending service date order"); + prev = date; + } + } + finally + { + await Util.DeleteAsync($"data-list-filter/{filterId}", token); + var resetPayload = """{"listKey":"WorkOrderDataList","sortBy":["WorkOrderSerialNumber"],"sortDesc":[true]}"""; + await Util.PostAsync("data-list-column-view/sort", token, resetPayload); + } + } + + + /// + /// Verify that offset and limit produce non-overlapping pages with a consistent totalRecordCount. + /// Uses WorkOrderSerialNumber ascending sort so pages are deterministic. + /// + [Fact] + public async Task WorkOrderList_PaginationOffsetAndLimitWork() + { + var token = await Util.GetTokenAsync("BizAdmin"); + + // Sort ascending so pages are predictable + var sortPayload = """{"listKey":"WorkOrderDataList","sortBy":["WorkOrderSerialNumber"],"sortDesc":[false]}"""; + await Util.PostAsync("data-list-column-view/sort", token, sortPayload); + + long filterId = await CreateFilterAsync(token, "WorkOrderDataList", null); + + try + { + // Page 1: offset=0, limit=5 + ApiResponse a = await Util.PostAsync("data-list", token, + BuildDataListRequest(filterId, offset: 0, limit: 5)); + Util.ValidateDataReturnResponseOk(a); + + var page1Rows = (JArray)a.ObjectResponse["data"]; + page1Rows.Count.Should().Be(5, "page 1 should return exactly 5 rows"); + + long totalCount = a.ObjectResponse["totalRecordCount"].Value(); + totalCount.Should().BeGreaterThan(5, "total record count must exceed one page"); + + int serialIdx = GetColumnIndex(a.ObjectResponse, "WorkOrderSerialNumber"); + var page1Ids = page1Rows.Select(r => r[serialIdx]["i"].Value()).ToHashSet(); + + // Page 2: offset=5, limit=5 + a = await Util.PostAsync("data-list", token, + BuildDataListRequest(filterId, offset: 5, limit: 5)); + Util.ValidateDataReturnResponseOk(a); + + var page2Rows = (JArray)a.ObjectResponse["data"]; + page2Rows.Count.Should().Be(5, "page 2 should return exactly 5 rows"); + a.ObjectResponse["totalRecordCount"].Value().Should().Be(totalCount, + "totalRecordCount must be the same across pages"); + + var page2Ids = page2Rows.Select(r => r[serialIdx]["i"].Value()).ToHashSet(); + page1Ids.Should().NotIntersectWith(page2Ids, "page 1 and page 2 must not overlap"); + } + finally + { + await Util.DeleteAsync($"data-list-filter/{filterId}", token); + // Reset sort to default + var resetPayload = """{"listKey":"WorkOrderDataList","sortBy":["WorkOrderSerialNumber"],"sortDesc":[true]}"""; + await Util.PostAsync("data-list-column-view/sort", token, resetPayload); + } + } + + + /// + /// Verify that the data-list response has the expected shape: + /// data, totalRecordCount, columns, sortBy, filter, hiddenAffectiveColumns. + /// Also verifies that a custom column order set via /data-list-column-view is reflected. + /// Resets column view to default order in the finally block. + /// + [Fact] + public async Task WorkOrderList_ReturnFormatMatchesExpectedShape() + { + var token = await Util.GetTokenAsync("BizAdmin"); + + // Set a custom column order: Customer moves to position 0 + long userId = GetUserIdFromToken(token); + dynamic cvPayload = new JObject(); + cvPayload.userId = userId; + cvPayload.listKey = "WorkOrderDataList"; + cvPayload.columns = "[\"Customer\",\"WorkOrderSerialNumber\",\"WorkOrderServiceDate\",\"WorkOrderCloseByDate\",\"WorkOrderStatus\",\"Project\",\"WorkOrderAge\"]"; + cvPayload.sort = "{\"WorkOrderSerialNumber\":\"+\"}"; + ApiResponse a = await Util.PostAsync("data-list-column-view", token, + cvPayload.ToString(Newtonsoft.Json.Formatting.None)); + Util.ValidateHTTPStatusCode(a, 200); + + long filterId = await CreateFilterAsync(token, "WorkOrderDataList", null); + + try + { + a = await Util.PostAsync("data-list", token, + BuildDataListRequest(filterId, offset: 0, limit: 10)); + Util.ValidateDataReturnResponseOk(a); + Util.ValidateHTTPStatusCode(a, 200); + + // Top-level keys must all be present + a.ObjectResponse["data"].Should().NotBeNull(); + a.ObjectResponse["totalRecordCount"].Should().NotBeNull(); + a.ObjectResponse["columns"].Should().NotBeNull(); + a.ObjectResponse["sortBy"].Should().NotBeNull(); + a.ObjectResponse["filter"].Should().NotBeNull(); + a.ObjectResponse["hiddenAffectiveColumns"].Should().NotBeNull(); + + var rows = (JArray)a.ObjectResponse["data"]; + var columns = (JArray)a.ObjectResponse["columns"]; + + // Customer should now be in the first column position + columns[0]["fk"].Value().Should().Be("Customer", + "custom column order should put Customer first"); + + // Every row must have exactly as many values as there are columns + foreach (var row in rows) + ((JArray)row).Count.Should().Be(columns.Count, + "row value count must match column definition count"); + + a.ObjectResponse["totalRecordCount"].Value().Should().BeGreaterThan(0); + } + finally + { + await Util.DeleteAsync($"data-list-filter/{filterId}", token); + // Reset column view to default order + dynamic resetCv = new JObject(); + resetCv.userId = userId; + resetCv.listKey = "WorkOrderDataList"; + resetCv.columns = "[\"WorkOrderSerialNumber\",\"Customer\",\"WorkOrderServiceDate\",\"WorkOrderCloseByDate\",\"WorkOrderStatus\",\"Project\",\"WorkOrderAge\"]"; + resetCv.sort = "{\"WorkOrderSerialNumber\":\"-\"}"; + await Util.PostAsync("data-list-column-view", token, + resetCv.ToString(Newtonsoft.Json.Formatting.None)); + } + } // ----------------------------------------------------------------------- // 3. RIGHTS // ----------------------------------------------------------------------- - [Fact(Skip = "TODO: Identify a seeded user with no WorkOrder list rights and use their login")] - public void WorkOrderList_UserWithoutListRightsGets403() { } + /// + /// A user with no roles (roles:0) must receive 403 when requesting WorkOrderDataList. + /// Creates a temporary user for isolation, deletes it in the finally block. + /// + [Fact] + public async Task WorkOrderList_UserWithoutListRightsGets403() + { + var adminToken = await Util.GetTokenAsync("BizAdmin"); + + // Create a temporary user with no roles (no spaces allowed in login names) + var login = Util.Uniquify("norights").Replace(" ", ""); + dynamic userPayload = new JObject(); + userPayload.name = login; + userPayload.active = true; + userPayload.allowLogin = true; + userPayload.login = login; + userPayload.password = login; + userPayload.roles = 0; + userPayload.userType = 2; + userPayload.notes = "temp test user"; + userPayload.customFields = Util.UserRequiredCustomFieldsJsonString(); + ApiResponse a = await Util.PostAsync("user", adminToken, userPayload.ToString(Newtonsoft.Json.Formatting.None)); + Util.ValidateDataReturnResponseOk(a); + long tempUserId = a.ObjectResponse["data"]["id"].Value(); + + // Create a public filter for the no-rights user to reference + long filterId = await CreateFilterAsync(adminToken, "WorkOrderDataList", null); + + try + { + var noRightsToken = await Util.GetTokenAsync(login); + a = await Util.PostAsync("data-list", noRightsToken, BuildDataListRequest(filterId)); + Util.ValidateErrorCodeResponse(a, 2004, 403); + } + finally + { + await Util.DeleteAsync($"data-list-filter/{filterId}", adminToken); + await Util.DeleteAsync($"user/{tempUserId}", adminToken); + } + } }//eoc }//eons