using System; using Xunit; using Newtonsoft.Json.Linq; using FluentAssertions; namespace raven_integration { /* DataList tests — three layers: 1. SavedFilterCrud (LIVE — runs now) 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 (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 (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 // ----------------------------------------------------------------------- /// /// Create, retrieve, list, update, and delete a saved filter. /// Also verifies public/private visibility rules: /// - a public filter IS visible to other users /// - a private filter is NOT visible to other users /// [Fact] public async Task SavedFilterCRUD() { var token = await Util.GetTokenAsync("BizAdmin"); const string ListKey = "WorkOrderDataList"; // CREATE a public saved filter // 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":"[]"} """; ApiResponse a = await Util.PostAsync("data-list-filter", token, payload); Util.ValidateDataReturnResponseOk(a); long Id = a.ObjectResponse["data"]["id"].Value(); Id.Should().BeGreaterThan(0); a.ObjectResponse["data"]["name"].Value().Should().Be(filterName); a.ObjectResponse["data"]["public"].Value().Should().BeTrue(); // GET by id a = await Util.GetAsync($"data-list-filter/{Id}", token); Util.ValidateDataReturnResponseOk(a); a.ObjectResponse["data"]["name"].Value().Should().Be(filterName); a.ObjectResponse["data"]["listKey"].Value().Should().Be(ListKey); var concurrency = a.ObjectResponse["data"]["concurrency"].Value(); // A different user should see a PUBLIC filter var otherToken = await Util.GetTokenAsync("SubContractorRestricted"); a = await Util.GetAsync($"data-list-filter/{Id}", otherToken); Util.ValidateDataReturnResponseOk(a); a.ObjectResponse["data"]["name"].Value().Should().Be(filterName); // LIST — filter should appear in the list for its key a = await Util.GetAsync($"data-list-filter/list?ListKey={ListKey}", token); Util.ValidateDataReturnResponseOk(a); ((JArray)a.ObjectResponse["data"]).Should().Contain( z => z["id"].Value() == Id, "the saved filter should appear in the list for its key"); // 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":"[]"} """; a = await Util.PutAsync("data-list-filter", token, putPayload); Util.ValidateHTTPStatusCode(a, 200); // Verify the update a = await Util.GetAsync($"data-list-filter/{Id}", token); Util.ValidateDataReturnResponseOk(a); a.ObjectResponse["data"]["name"].Value().Should().Be(updatedName); a.ObjectResponse["data"]["public"].Value().Should().BeFalse(); var newConcurrency = a.ObjectResponse["data"]["concurrency"].Value(); newConcurrency.Should().NotBe(concurrency, "concurrency should have been bumped"); // The other user should NO LONGER see the now-private filter a = await Util.GetAsync($"data-list-filter/{Id}", otherToken); Util.ValidateResponseNotFound(a); // DELETE a = await Util.DeleteAsync($"data-list-filter/{Id}", token); Util.ValidateHTTPStatusCode(a, 204); // Confirm deleted a = await Util.GetAsync($"data-list-filter/{Id}", token); Util.ValidateResponseNotFound(a); } /// /// A saved filter with DefaultFilter=true must be rejected — only the server /// can create default filters internally. /// [Fact] public async Task SavedFilter_DefaultFilterCannotBeCreatedByClient() { var token = await Util.GetTokenAsync("BizAdmin"); var payload = """ {"id":0,"concurrency":0,"userId":0,"name":"Should Fail","public":false,"defaultFilter":true,"listKey":"WorkOrderDataList","filter":null} """; ApiResponse a = await Util.PostAsync("data-list-filter", token, payload); Util.ValidateHTTPStatusCode(a, 400); a.ObjectResponse["error"].Should().NotBeNull(); } // ----------------------------------------------------------------------- // 2. FILTER AND SORT // ----------------------------------------------------------------------- // // Each test creates a named filter, POSTs to /data-list with that filterId, // asserts the result, then deletes the filter in a finally block. // // 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 // // Special server-side date keywords (use with OpEquality): // "*yesterday*", "*tomorrow*", "*thisyear*", "*NULL*" // ----------------------------------------------------------------------- /// /// 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() { 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); } } /// /// Customer name starts with "XY" — all returned rows must have that prefix. /// [Fact] public async Task WorkOrderList_StringStartsWithFilterWorks() { var token = await Util.GetTokenAsync("BizAdmin"); var filterRules = new JArray { BuildFilterRule("Customer", false, (Util.OpStartsWith, "XY")) }; 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 starting with 'XY'"); 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); } } /// /// Customer name equals "XYZ Accounting" exactly — all returned rows must match exactly. /// [Fact] public async Task WorkOrderList_StringEqualsFilterWorks() { var token = await Util.GetTokenAsync("BizAdmin"); var filterRules = new JArray { BuildFilterRule("Customer", false, (Util.OpEquality, "XYZ Accounting")) }; 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 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 // ----------------------------------------------------------------------- /// /// 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