812 lines
37 KiB
C#
812 lines
37 KiB
C#
using System;
|
|
using Xunit;
|
|
using Newtonsoft.Json.Linq;
|
|
using FluentAssertions;
|
|
|
|
namespace raven_integration
|
|
{
|
|
|
|
/*
|
|
DataList tests — three layers:
|
|
|
|
1. SavedFilterCrud (LIVE — runs now)
|
|
Exercises POST/GET/PUT/DELETE on /data-list-filter plus the /list endpoint.
|
|
Uses WorkOrderDataList as the list key since work orders always have seeded data.
|
|
|
|
2. FilterAndSort (LIVE — each test creates its own named filter, uses it, then deletes it)
|
|
Pattern:
|
|
a) CreateFilterAsync() — POST to /data-list-filter with filter rules as a JSON string
|
|
b) BuildDataListRequest() — POST body for /data-list with the filterId
|
|
c) Assert on the rows returned
|
|
d) Delete the filter in a finally block
|
|
For sort tests: POST /data-list-column-view/sort, then reset in finally.
|
|
For column-view test: POST /data-list-column-view, then reset in finally.
|
|
|
|
3. Rights (LIVE — SubContractor user has no WorkOrder list rights)
|
|
|
|
------
|
|
Date filter note: Server accounts for user's time zone offset when filtering by
|
|
date range. All seeded users share the same tz offset (TIME_ZONE_ADJUSTMENT in util.cs).
|
|
Use ToOffsetAdjustedUniversalTime() extension when building date filter values.
|
|
|
|
WorkOrderDataList column keys (default order):
|
|
"WorkOrderSerialNumber" integer / rid
|
|
"Customer" string
|
|
"WorkOrderServiceDate" datetime
|
|
"WorkOrderCloseByDate" datetime
|
|
"WorkOrderStatus" pick list / string
|
|
"Project" string
|
|
"WorkOrderAge" integer
|
|
|
|
Default sort: WorkOrderSerialNumber descending ("-").
|
|
*/
|
|
|
|
public class DataListOperations
|
|
{
|
|
|
|
// -----------------------------------------------------------------------
|
|
// PRIVATE HELPERS
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// <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
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Create, retrieve, list, update, and delete a saved filter.
|
|
/// Also verifies public/private visibility rules:
|
|
/// - a public filter IS visible to other users
|
|
/// - a private filter is NOT visible to other users
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task SavedFilterCRUD()
|
|
{
|
|
var token = await Util.GetTokenAsync("BizAdmin");
|
|
const string ListKey = "WorkOrderDataList";
|
|
|
|
// CREATE a public saved filter
|
|
// UserId=0 — the server sets the actual user id from the auth token
|
|
var filterName = Util.Uniquify("Test Saved Filter");
|
|
var payload = $$"""
|
|
{"id":0,"concurrency":0,"userId":0,"name":"{{filterName}}","public":true,"defaultFilter":false,"listKey":"{{ListKey}}","filter":"[]"}
|
|
""";
|
|
|
|
ApiResponse a = await Util.PostAsync("data-list-filter", token, payload);
|
|
Util.ValidateDataReturnResponseOk(a);
|
|
long Id = a.ObjectResponse["data"]["id"].Value<long>();
|
|
Id.Should().BeGreaterThan(0);
|
|
a.ObjectResponse["data"]["name"].Value<string>().Should().Be(filterName);
|
|
a.ObjectResponse["data"]["public"].Value<bool>().Should().BeTrue();
|
|
|
|
// GET by id
|
|
a = await Util.GetAsync($"data-list-filter/{Id}", token);
|
|
Util.ValidateDataReturnResponseOk(a);
|
|
a.ObjectResponse["data"]["name"].Value<string>().Should().Be(filterName);
|
|
a.ObjectResponse["data"]["listKey"].Value<string>().Should().Be(ListKey);
|
|
var concurrency = a.ObjectResponse["data"]["concurrency"].Value<uint>();
|
|
|
|
// A different user should see a PUBLIC filter
|
|
var otherToken = await Util.GetTokenAsync("SubContractorRestricted");
|
|
a = await Util.GetAsync($"data-list-filter/{Id}", otherToken);
|
|
Util.ValidateDataReturnResponseOk(a);
|
|
a.ObjectResponse["data"]["name"].Value<string>().Should().Be(filterName);
|
|
|
|
// LIST — filter should appear in the list for its key
|
|
a = await Util.GetAsync($"data-list-filter/list?ListKey={ListKey}", token);
|
|
Util.ValidateDataReturnResponseOk(a);
|
|
((JArray)a.ObjectResponse["data"]).Should().Contain(
|
|
z => z["id"].Value<long>() == Id,
|
|
"the saved filter should appear in the list for its key");
|
|
|
|
// PUT — make it private and rename
|
|
var updatedName = Util.Uniquify("Updated Saved Filter");
|
|
var putPayload = $$"""
|
|
{"id":{{Id}},"concurrency":{{concurrency}},"userId":0,"name":"{{updatedName}}","public":false,"defaultFilter":false,"listKey":"{{ListKey}}","filter":"[]"}
|
|
""";
|
|
a = await Util.PutAsync("data-list-filter", token, putPayload);
|
|
Util.ValidateHTTPStatusCode(a, 200);
|
|
|
|
// Verify the update
|
|
a = await Util.GetAsync($"data-list-filter/{Id}", token);
|
|
Util.ValidateDataReturnResponseOk(a);
|
|
a.ObjectResponse["data"]["name"].Value<string>().Should().Be(updatedName);
|
|
a.ObjectResponse["data"]["public"].Value<bool>().Should().BeFalse();
|
|
var newConcurrency = a.ObjectResponse["data"]["concurrency"].Value<uint>();
|
|
newConcurrency.Should().NotBe(concurrency, "concurrency should have been bumped");
|
|
|
|
// The other user should NO LONGER see the now-private filter
|
|
a = await Util.GetAsync($"data-list-filter/{Id}", otherToken);
|
|
Util.ValidateResponseNotFound(a);
|
|
|
|
// DELETE
|
|
a = await Util.DeleteAsync($"data-list-filter/{Id}", token);
|
|
Util.ValidateHTTPStatusCode(a, 204);
|
|
|
|
// Confirm deleted
|
|
a = await Util.GetAsync($"data-list-filter/{Id}", token);
|
|
Util.ValidateResponseNotFound(a);
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// A saved filter with DefaultFilter=true must be rejected — only the server
|
|
/// can create default filters internally.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task SavedFilter_DefaultFilterCannotBeCreatedByClient()
|
|
{
|
|
var token = await Util.GetTokenAsync("BizAdmin");
|
|
var payload = """
|
|
{"id":0,"concurrency":0,"userId":0,"name":"Should Fail","public":false,"defaultFilter":true,"listKey":"WorkOrderDataList","filter":null}
|
|
""";
|
|
ApiResponse a = await Util.PostAsync("data-list-filter", token, payload);
|
|
Util.ValidateHTTPStatusCode(a, 400);
|
|
a.ObjectResponse["error"].Should().NotBeNull();
|
|
}
|
|
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 2. FILTER AND SORT
|
|
// -----------------------------------------------------------------------
|
|
//
|
|
// Each test creates a named filter, POSTs to /data-list with that filterId,
|
|
// asserts the result, then deletes the filter in a finally block.
|
|
//
|
|
// Filter format stored in the "filter" field of data-list-filter:
|
|
// JSON array string: [{"column":"FIELD","any":false,"items":[{"op":"OP","value":"VAL"}]}]
|
|
// any=false → AND, any=true → OR
|
|
//
|
|
// Special server-side date keywords (use with OpEquality):
|
|
// "*yesterday*", "*tomorrow*", "*thisyear*", "*NULL*"
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
|
/// <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()
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
|
|
|
|
/// <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");
|
|
|
|
var filterRules = new JArray { BuildFilterRule("Customer", false,
|
|
(Util.OpStartsWith, "XY")) };
|
|
long filterId = await CreateFilterAsync(token, "WorkOrderDataList", filterRules);
|
|
|
|
try
|
|
{
|
|
ApiResponse a = await Util.PostAsync("data-list", token, BuildDataListRequest(filterId));
|
|
Util.ValidateDataReturnResponseOk(a);
|
|
|
|
var rows = (JArray)a.ObjectResponse["data"];
|
|
rows.Should().NotBeEmpty("seeded data has customers starting with 'XY'");
|
|
|
|
int customerIdx = GetColumnIndex(a.ObjectResponse, "Customer");
|
|
foreach (var row in rows)
|
|
row[customerIdx]["v"].Value<string>().Should()
|
|
.StartWith("XY", "startswith filter must only return matching rows");
|
|
}
|
|
finally
|
|
{
|
|
await Util.DeleteAsync($"data-list-filter/{filterId}", token);
|
|
}
|
|
}
|
|
|
|
|
|
/// <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");
|
|
|
|
var filterRules = new JArray { BuildFilterRule("Customer", false,
|
|
(Util.OpEquality, "XYZ Accounting")) };
|
|
long filterId = await CreateFilterAsync(token, "WorkOrderDataList", filterRules);
|
|
|
|
try
|
|
{
|
|
ApiResponse a = await Util.PostAsync("data-list", token, BuildDataListRequest(filterId));
|
|
Util.ValidateDataReturnResponseOk(a);
|
|
|
|
var rows = (JArray)a.ObjectResponse["data"];
|
|
rows.Should().NotBeEmpty("seeded data has work orders for XYZ Accounting");
|
|
|
|
int customerIdx = GetColumnIndex(a.ObjectResponse, "Customer");
|
|
foreach (var row in rows)
|
|
row[customerIdx]["v"].Value<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
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// <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
|