4648
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user