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 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<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
@@ -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);
}

View File

@@ -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
// -----------------------------------------------------------------------
/// <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
// -----------------------------------------------------------------------
@@ -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()
/// <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");
// 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<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")]
public void WorkOrderList_StringEqualsFilterWorks() { }
/// <summary>
/// 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")]
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<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")]
public void WorkOrderList_SortDescendingWorks() { }
/// <summary>
/// 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")]
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<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
// -----------------------------------------------------------------------
[Fact(Skip = "TODO: Identify a seeded user with no WorkOrder list rights and use their login")]
public void WorkOrderList_UserWithoutListRightsGets403() { }
/// <summary>
/// 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
}//eons