This commit is contained in:
2026-03-02 12:06:52 -08:00
parent db593886a3
commit d248753984
2 changed files with 670 additions and 71 deletions

View File

@@ -52,7 +52,7 @@ namespace raven_integration
// ------------------------------------------------------------------- // -------------------------------------------------------------------
var updatedName = Util.Uniquify("Updated Contract"); var updatedName = Util.Uniquify("Updated Contract");
var putPayload = $$""" 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); a = await Util.PutAsync("contract", token, putPayload);
Util.ValidateHTTPStatusCode(a, 200); Util.ValidateHTTPStatusCode(a, 200);
@@ -66,7 +66,7 @@ namespace raven_integration
a = await Util.GetAsync($"contract/{Id}", token); a = await Util.GetAsync($"contract/{Id}", token);
Util.ValidateDataReturnResponseOk(a); Util.ValidateDataReturnResponseOk(a);
a.ObjectResponse["data"]["name"].Value<string>().Should().Be(updatedName); a.ObjectResponse["data"]["name"].Value<string>().Should().Be(updatedName);
a.ObjectResponse["data"]["contractServiceRatesOnly"].Value<bool>().Should().BeTrue(); a.ObjectResponse["data"]["contractServiceRatesOnly"].Value<bool>().Should().BeFalse();
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// CONCURRENCY VIOLATION: PUT with stale concurrency should return 409 // CONCURRENCY VIOLATION: PUT with stale concurrency should return 409
@@ -81,6 +81,12 @@ namespace raven_integration
Util.ValidateHTTPStatusCode(a, 204); Util.ValidateHTTPStatusCode(a, 204);
// Confirm deleted // 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); a = await Util.GetAsync($"contract/{Id}", token);
Util.ValidateResponseNotFound(a); Util.ValidateResponseNotFound(a);
} }

View File

@@ -13,26 +13,128 @@ namespace raven_integration
Exercises POST/GET/PUT/DELETE on /data-list-filter plus the /list endpoint. 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. Uses WorkOrderDataList as the list key since work orders always have seeded data.
2. FilterAndSort stubs (SKIPPED — need real payloads from browser) 2. FilterAndSort (LIVE — each test creates its own named filter, uses it, then deletes it)
To fill these in: Pattern:
a) Open the AyaNova UI, navigate to the Work Orders grid a) CreateFilterAsync() — POST to /data-list-filter with filter rules as a JSON string
b) Apply a filter (e.g. "Notes contains fox"), open browser DevTools -> Network b) BuildDataListRequest() — POST body for /data-list with the filterId
c) Find the POST to /data-list — copy the full request body c) Assert on the rows returned
d) Use that body as the starting point for the test payload here d) Delete the filter in a finally block
See Util.BuildDataListRequestEx and Util.BuildSimpleFilterDataListViewColumn for helpers. 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) 3. Rights (LIVE — SubContractor user has no WorkOrder list rights)
Verify that a user without the WorkOrder list role gets 403.
------ ------
Date filter note: Server accounts for user's time zone offset when filtering by 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). date range. All seeded users share the same tz offset (TIME_ZONE_ADJUSTMENT in util.cs).
Use ToOffsetAdjustedUniversalTime() extension when building date filter values. 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 public class DataListOperations
{ {
// -----------------------------------------------------------------------
// PRIVATE HELPERS
// -----------------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
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 }))
};
}
/// <summary>
/// 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.
/// </summary>
private static async Task<long> 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<long>();
}
/// <summary>
/// Build the POST body for /data-list.
/// clientTimeStamp is set to DateTimeOffset.Now so relative date keywords work correctly.
/// </summary>
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}}"}""";
}
/// <summary>
/// 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.
/// </summary>
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<long>();
}
/// <summary>
/// Look up the zero-based column index by field key from the data-list response object.
/// </summary>
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<string>() == fieldKey)
return i;
}
throw new InvalidOperationException($"Column '{fieldKey}' not found in response columns");
}
/// <summary>
/// Create a minimal work order with the given service date and return its id.
/// customerId=1 is XYZ Accounting in the seed data.
/// </summary>
private static async Task<long> 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<long>();
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// 1. SAVED FILTER CRUD // 1. SAVED FILTER CRUD
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -53,7 +155,7 @@ namespace raven_integration
// UserId=0 — the server sets the actual user id from the auth token // UserId=0 — the server sets the actual user id from the auth token
var filterName = Util.Uniquify("Test Saved Filter"); var filterName = Util.Uniquify("Test Saved Filter");
var payload = $$""" 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); ApiResponse a = await Util.PostAsync("data-list-filter", token, payload);
@@ -86,7 +188,7 @@ namespace raven_integration
// PUT — make it private and rename // PUT — make it private and rename
var updatedName = Util.Uniquify("Updated Saved Filter"); var updatedName = Util.Uniquify("Updated Saved Filter");
var putPayload = $$""" 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); a = await Util.PutAsync("data-list-filter", token, putPayload);
Util.ValidateHTTPStatusCode(a, 200); 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: // Each test creates a named filter, POSTs to /data-list with that filterId,
// 1. Run the server locally (dotnet run from raven/server/AyaNova/) // asserts the result, then deletes the filter in a finally block.
// 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
// //
// Field keys for WorkOrderDataList (see WorkOrderDataList.cs in server): // Filter format stored in the "filter" field of data-list-filter:
// "WorkOrderSerialNumber" integer (is the row id field) // JSON array string: [{"column":"FIELD","any":false,"items":[{"op":"OP","value":"VAL"}]}]
// "Customer" string // any=false → AND, any=true → OR
// "WorkOrderServiceDate" datetime
// "WorkOrderCloseByDate" datetime
// "WorkOrderStatus" pick list / string
// "Project" string
// "WorkOrderAge" integer
// //
// Operator constants are on Util: OpContains, OpStartsWith, OpEndsWith, // Special server-side date keywords (use with OpEquality):
// OpEquality, OpNotEqual, OpGreaterThan, OpLessThan, etc. // "*yesterday*", "*tomorrow*", "*thisyear*", "*NULL*"
//
// Sort direction string: "+" ascending, "-" descending
//
// NOTE on date filters: use DateTime.Now.ToOffsetAdjustedUniversalTime()
// (extension in util.cs) to avoid DST/timezone mismatch failures.
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
[Fact(Skip = "TODO: Capture real list payload from browser — see comment above")]
public void WorkOrderList_StringContainsFilterWorks() /// <summary>
/// Customer name contains "XYZ Accounting" — all returned rows must have that substring.
/// Relies on seeded work orders for customerId=1 (XYZ Accounting).
/// </summary>
[Fact]
public async Task WorkOrderList_StringContainsFilterWorks()
{ {
// Example shape once confirmed via browser: var token = await Util.GetTokenAsync("BizAdmin");
//
// var token = await Util.GetTokenAsync("BizAdmin"); var filterRules = new JArray { BuildFilterRule("Customer", false,
// dynamic dListView = new JArray(); (Util.OpContains, "XYZ Accounting")) };
// dListView.Add(Util.BuildSimpleFilterDataListViewColumn("Customer", Util.OpContains, "Acme")); long filterId = await CreateFilterAsync(token, "WorkOrderDataList", filterRules);
// string body = Util.BuildDataListRequestEx(dListView, limit: 50, offset: 0, dataListKey: "WorkOrderDataList");
// ApiResponse a = await Util.PostAsync("data-list", token, body); try
// Util.ValidateDataReturnResponseOk(a); {
// var rows = (JArray)a.ObjectResponse["data"]; ApiResponse a = await Util.PostAsync("data-list", token, BuildDataListRequest(filterId));
// rows.Should().NotBeEmpty(); Util.ValidateDataReturnResponseOk(a);
// // each row should have "Acme" somewhere in the Customer column value 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<string>().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")] /// <summary>
public void WorkOrderList_StringEqualsFilterWorks() { } /// Customer name starts with "XY" — all returned rows must have that prefix.
/// </summary>
[Fact]
public async Task WorkOrderList_StringStartsWithFilterWorks()
{
var token = await Util.GetTokenAsync("BizAdmin");
[Fact(Skip = "TODO: Capture real list payload from browser")] var filterRules = new JArray { BuildFilterRule("Customer", false,
public void WorkOrderList_StringNotEqualFilterWorks() { } (Util.OpStartsWith, "XY")) };
long filterId = await CreateFilterAsync(token, "WorkOrderDataList", filterRules);
[Fact(Skip = "TODO: Capture real list payload from browser")] try
public void WorkOrderList_DateRangeFilterWorks() { } {
ApiResponse a = await Util.PostAsync("data-list", token, BuildDataListRequest(filterId));
Util.ValidateDataReturnResponseOk(a);
[Fact(Skip = "TODO: Capture real list payload from browser")] var rows = (JArray)a.ObjectResponse["data"];
public void WorkOrderList_NullFieldFilterWorks() { } rows.Should().NotBeEmpty("seeded data has customers starting with 'XY'");
[Fact(Skip = "TODO: Capture real list payload from browser")] int customerIdx = GetColumnIndex(a.ObjectResponse, "Customer");
public void WorkOrderList_MultiConditionAndFilterWorks() { } foreach (var row in rows)
row[customerIdx]["v"].Value<string>().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")] /// <summary>
public void WorkOrderList_SortDescendingWorks() { } /// Customer name equals "XYZ Accounting" exactly — all returned rows must match exactly.
/// </summary>
[Fact]
public async Task WorkOrderList_StringEqualsFilterWorks()
{
var token = await Util.GetTokenAsync("BizAdmin");
[Fact(Skip = "TODO: Capture real list payload from browser")] var filterRules = new JArray { BuildFilterRule("Customer", false,
public void WorkOrderList_PaginationOffsetAndLimitWork() { } (Util.OpEquality, "XYZ Accounting")) };
long filterId = await CreateFilterAsync(token, "WorkOrderDataList", filterRules);
[Fact(Skip = "TODO: Capture real list payload from browser")] try
public void WorkOrderList_ReturnFormatMatchesExpectedShape() { } {
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<string>().Should()
.Be("XYZ Accounting", "equality filter must only return exact matches");
}
finally
{
await Util.DeleteAsync($"data-list-filter/{filterId}", token);
}
}
/// <summary>
/// Customer name NOT equal to "XYZ Accounting" — no returned rows may have that customer.
/// </summary>
[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<string>().Should()
.NotBe("XYZ Accounting", "not-equal filter must exclude that customer");
}
finally
{
await Util.DeleteAsync($"data-list-filter/{filterId}", token);
}
}
/// <summary>
/// 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.
/// </summary>
[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<long>()).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);
}
}
/// <summary>
/// Date OR filter: service date = *yesterday* OR = *tomorrow*.
/// Creates two work orders and verifies both appear via server-side relative date keywords.
/// </summary>
[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<long>()).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);
}
}
/// <summary>
/// Filter for rows where WorkOrderStatus IS NULL (*NULL* keyword).
/// Seeded data contains many work orders with no status assigned.
/// </summary>
[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);
}
}
/// <summary>
/// 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).
/// </summary>
[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<string>();
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);
}
}
/// <summary>
/// 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.
/// </summary>
[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<string>().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<string>();
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);
}
}
/// <summary>
/// Set sort to WorkOrderServiceDate descending, verify returned rows are in descending order.
/// Resets sort to default in the finally block.
/// </summary>
[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<string>().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<string>();
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);
}
}
/// <summary>
/// Verify that offset and limit produce non-overlapping pages with a consistent totalRecordCount.
/// Uses WorkOrderSerialNumber ascending sort so pages are deterministic.
/// </summary>
[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<long>();
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<long>()).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<long>().Should().Be(totalCount,
"totalRecordCount must be the same across pages");
var page2Ids = page2Rows.Select(r => r[serialIdx]["i"].Value<long>()).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);
}
}
/// <summary>
/// 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.
/// </summary>
[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<string>().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<long>().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 // 3. RIGHTS
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
[Fact(Skip = "TODO: Identify a seeded user with no WorkOrder list rights and use their login")] /// <summary>
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.
/// </summary>
[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<long>();
// 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 }//eoc
}//eons }//eons