4648
This commit is contained in:
@@ -10,7 +10,8 @@
|
||||
"Bash(find /c/data/code/raven-test-integration -name \"*.cs\" -type f ! -path \"*/obj/*\" -exec grep -l \"public class.*Test\" {} ;)",
|
||||
"Bash(xargs grep \"public class\")",
|
||||
"Read(//c/data/code/raven-test-integration/**)",
|
||||
"Bash(sed 's/.cs$//')"
|
||||
"Bash(sed 's/.cs$//')",
|
||||
"Bash(dotnet build raven-integration.csproj)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
89
Contract/ContractCrud.cs
Normal file
89
Contract/ContractCrud.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using Xunit;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace raven_integration
|
||||
{
|
||||
|
||||
public class ContractCrud
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Full CRUD for a Contract, including a concurrency violation check.
|
||||
/// Contract has a richer required-field set than most objects (billing override
|
||||
/// type enums, response time, etc.) so this test exercises the full model round-trip.
|
||||
///
|
||||
/// ContractOverrideType enum: PriceDiscount = 1, CostMarkup = 2
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CRUD()
|
||||
{
|
||||
var token = await Util.GetTokenAsync("BizAdmin");
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// CREATE
|
||||
// responseTime: TimeSpan JSON format is "HH:MM:SS"
|
||||
// partsOverrideType / serviceRatesOverrideType / travelRatesOverrideType:
|
||||
// 1 = PriceDiscount, 2 = CostMarkup
|
||||
// -------------------------------------------------------------------
|
||||
var name = Util.Uniquify("Test Contract");
|
||||
var payload = $$"""
|
||||
{"id":0,"concurrency":0,"name":"{{name}}","active":true,"notes":"The quick brown fox jumped over the six lazy dogs!","wiki":null,"customFields":"{}","tags":[],"responseTime":"01:00:00","contractServiceRatesOnly":false,"contractTravelRatesOnly":false,"partsOverridePct":10.0,"partsOverrideType":1,"serviceRatesOverridePct":5.0,"serviceRatesOverrideType":1,"travelRatesOverridePct":0.0,"travelRatesOverrideType":1,"alertNotes":"Contract alert text"}
|
||||
""";
|
||||
|
||||
ApiResponse a = await Util.PostAsync("contract", 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(name);
|
||||
a.ObjectResponse["data"]["partsOverrideType"].Value<int>().Should().Be(1);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// GET
|
||||
// -------------------------------------------------------------------
|
||||
a = await Util.GetAsync($"contract/{Id}", token);
|
||||
Util.ValidateDataReturnResponseOk(a);
|
||||
a.ObjectResponse["data"]["name"].Value<string>().Should().Be(name);
|
||||
a.ObjectResponse["data"]["alertNotes"].Value<string>().Should().Be("Contract alert text");
|
||||
var concurrency = a.ObjectResponse["data"]["concurrency"].Value<uint>();
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// PUT — Contract PUT returns the full updated object (not just concurrency)
|
||||
// -------------------------------------------------------------------
|
||||
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"}
|
||||
""";
|
||||
a = await Util.PutAsync("contract", token, putPayload);
|
||||
Util.ValidateHTTPStatusCode(a, 200);
|
||||
// PUT returns the full updated contract object
|
||||
a.ObjectResponse["data"]["name"].Value<string>().Should().Be(updatedName);
|
||||
a.ObjectResponse["data"]["partsOverrideType"].Value<int>().Should().Be(2, "should have changed to CostMarkup");
|
||||
var newConcurrency = a.ObjectResponse["data"]["concurrency"].Value<uint>();
|
||||
newConcurrency.Should().NotBe(concurrency, "concurrency should have been bumped");
|
||||
|
||||
// Verify update persisted
|
||||
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();
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// CONCURRENCY VIOLATION: PUT with stale concurrency should return 409
|
||||
// -------------------------------------------------------------------
|
||||
a = await Util.PutAsync("contract", token, putPayload); // putPayload has the old concurrency
|
||||
Util.ValidateConcurrencyError(a);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// DELETE
|
||||
// -------------------------------------------------------------------
|
||||
a = await Util.DeleteAsync($"contract/{Id}", token);
|
||||
Util.ValidateHTTPStatusCode(a, 204);
|
||||
|
||||
// Confirm deleted
|
||||
a = await Util.GetAsync($"contract/{Id}", token);
|
||||
Util.ValidateResponseNotFound(a);
|
||||
}
|
||||
|
||||
}//eoc
|
||||
}//eons
|
||||
114
Customer/CustomerCrud.cs
Normal file
114
Customer/CustomerCrud.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using System;
|
||||
using Xunit;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace raven_integration
|
||||
{
|
||||
|
||||
public class CustomerCrud
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Full CRUD for a Customer, including concurrency violation and alert retrieval.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CRUD()
|
||||
{
|
||||
var token = await Util.GetTokenAsync("BizAdmin");
|
||||
|
||||
// CREATE
|
||||
var name = Util.Uniquify("Test Customer");
|
||||
var payload = $$"""
|
||||
{"id":0,"concurrency":0,"name":"{{name}}","active":true,"notes":"The quick brown fox jumped over the six lazy dogs!","wiki":null,"customFields":"{}","tags":[],"webAddress":null,"alertNotes":"Test alert text for this customer","billHeadOffice":false,"headOfficeId":null,"techNotes":"Tech-only notes","accountNumber":"ACC-001","contractId":null,"contractExpires":null,"phone1":"555-1234","phone2":null,"phone3":null,"phone4":null,"phone5":null,"emailAddress":"test@example.com","postAddress":null,"postCity":null,"postRegion":null,"postCountry":null,"postCode":null,"address":null,"city":null,"region":null,"country":null,"addressPostal":null,"latitude":null,"longitude":null}
|
||||
""";
|
||||
|
||||
ApiResponse a = await Util.PostAsync("customer", 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(name);
|
||||
|
||||
// GET
|
||||
a = await Util.GetAsync($"customer/{Id}", token);
|
||||
Util.ValidateDataReturnResponseOk(a);
|
||||
a.ObjectResponse["data"]["name"].Value<string>().Should().Be(name);
|
||||
a.ObjectResponse["data"]["phone1"].Value<string>().Should().Be("555-1234");
|
||||
var concurrency = a.ObjectResponse["data"]["concurrency"].Value<uint>();
|
||||
|
||||
// GET ALERT
|
||||
a = await Util.GetAsync($"customer/alert/{Id}", token);
|
||||
Util.ValidateDataReturnResponseOk(a);
|
||||
a.ObjectResponse["data"].Value<string>().Should().Be("Test alert text for this customer");
|
||||
|
||||
// PUT (update name and phone)
|
||||
var updatedName = Util.Uniquify("Updated Customer");
|
||||
var putPayload = $$"""
|
||||
{"id":{{Id}},"concurrency":{{concurrency}},"name":"{{updatedName}}","active":true,"notes":"Updated notes","wiki":null,"customFields":"{}","tags":[],"webAddress":null,"alertNotes":"Updated alert text","billHeadOffice":false,"headOfficeId":null,"techNotes":null,"accountNumber":null,"contractId":null,"contractExpires":null,"phone1":"555-9999","phone2":null,"phone3":null,"phone4":null,"phone5":null,"emailAddress":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}
|
||||
""";
|
||||
a = await Util.PutAsync("customer", token, putPayload);
|
||||
Util.ValidateHTTPStatusCode(a, 200);
|
||||
var newConcurrency = a.ObjectResponse["data"]["concurrency"].Value<uint>();
|
||||
newConcurrency.Should().NotBe(concurrency, "concurrency should have been bumped by the update");
|
||||
|
||||
// Verify the update was persisted
|
||||
a = await Util.GetAsync($"customer/{Id}", token);
|
||||
Util.ValidateDataReturnResponseOk(a);
|
||||
a.ObjectResponse["data"]["name"].Value<string>().Should().Be(updatedName);
|
||||
a.ObjectResponse["data"]["phone1"].Value<string>().Should().Be("555-9999");
|
||||
|
||||
// CONCURRENCY VIOLATION: PUT with stale concurrency should return 409
|
||||
a = await Util.PutAsync("customer", token, putPayload); // putPayload still has old concurrency
|
||||
Util.ValidateConcurrencyError(a);
|
||||
|
||||
// DELETE
|
||||
a = await Util.DeleteAsync($"customer/{Id}", token);
|
||||
Util.ValidateHTTPStatusCode(a, 204);
|
||||
|
||||
// Confirm deleted
|
||||
a = await Util.GetAsync($"customer/{Id}", token);
|
||||
Util.ValidateResponseNotFound(a);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A customer that has at least one work order associated with it should not
|
||||
/// be deletable — referential integrity must be enforced.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CustomerWithWorkOrders_CannotBeDeleted()
|
||||
{
|
||||
var token = await Util.GetTokenAsync("BizAdmin");
|
||||
|
||||
// Create a dedicated customer so we control what is linked to it
|
||||
var name = Util.Uniquify("RefInt Customer");
|
||||
var payload = $$"""
|
||||
{"id":0,"concurrency":0,"name":"{{name}}","active":true,"notes":null,"wiki":null,"customFields":"{}","tags":[],"webAddress":null,"alertNotes":null,"billHeadOffice":false,"headOfficeId":null,"techNotes":null,"accountNumber":null,"contractId":null,"contractExpires":null,"phone1":null,"phone2":null,"phone3":null,"phone4":null,"phone5":null,"emailAddress":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}
|
||||
""";
|
||||
ApiResponse a = await Util.PostAsync("customer", token, payload);
|
||||
Util.ValidateDataReturnResponseOk(a);
|
||||
long customerId = a.ObjectResponse["data"]["id"].Value<long>();
|
||||
|
||||
// Create a work order linked to this customer
|
||||
var isoNow = DateTime.UtcNow.ToString("o");
|
||||
var woPayload = $$"""
|
||||
{"id":0,"concurrency":0,"serial":0,"notes":"RefInt test WO","wiki":null,"customFields":"{}","tags":[],"customerId":{{customerId}},"projectId":null,"contractId":null,"internalReferenceNumber":null,"customerReferenceNumber":null,"customerContactName":null,"fromQuoteId":null,"fromPMId":null,"serviceDate":"{{isoNow}}","completeByDate":null,"durationToCompleted":"00:00:00","invoiceNumber":null,"onsite":true,"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}
|
||||
""";
|
||||
a = await Util.PostAsync("workorder", token, woPayload);
|
||||
Util.ValidateDataReturnResponseOk(a);
|
||||
long woId = a.ObjectResponse["data"]["id"].Value<long>();
|
||||
|
||||
// Attempt to delete the customer — should be blocked by referential integrity
|
||||
a = await Util.DeleteAsync($"customer/{customerId}", token);
|
||||
Util.ValidateViolatesReferentialIntegrityError(a);
|
||||
|
||||
// Clean up: delete the work order first, then the customer
|
||||
a = await Util.DeleteAsync($"workorder/{woId}", token);
|
||||
Util.ValidateHTTPStatusCode(a, 204);
|
||||
|
||||
a = await Util.DeleteAsync($"customer/{customerId}", token);
|
||||
Util.ValidateHTTPStatusCode(a, 204);
|
||||
}
|
||||
|
||||
}//eoc
|
||||
}//eons
|
||||
@@ -1,38 +1,218 @@
|
||||
using FluentAssertions;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using Xunit;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace raven_integration
|
||||
{
|
||||
|
||||
/*
|
||||
Make fresh data list tests in here, exercise sorting, filtering, crud, rights saved filters, whatever could break
|
||||
To save time instead of doing it the old way from before laboriously, just copy payloads from the client doing these things
|
||||
with fresh seeded data and ensure anything important doesn't break on refactor
|
||||
There could be some date issues from vague memory and note below so keeping that here for reference
|
||||
and keeping old tests until I implement this as a walkthrough to confirm if there is something I might have missed to add here
|
||||
DataList tests — three layers:
|
||||
|
||||
Just need to know datalists won't break on refactor, nothing fancy here but it was such a black hole of bugs in v7 I think I went overboard with the original
|
||||
integration tests but the v8 system is more bulletproof, likely don't need as many tests as I used during development.
|
||||
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 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.
|
||||
|
||||
3. Rights stub (SKIPPED — needs a user with no list rights)
|
||||
Verify that a user without the WorkOrder list role gets 403.
|
||||
|
||||
------
|
||||
old potential BUGBUG: Server takes into account user's time zone offset when filtering lists by date range but here the local test runner just uses the windows system offset instead of the defined offset in the User account at the server
|
||||
Fix: Since seeder uses same time zone for all users it generates then can simply fetch one single users' tz offset and use that centerally to calculate a relative now and relative today
|
||||
same as the server does but in a central location here for all tests to use.
|
||||
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
|
||||
public class DataListOperations
|
||||
{
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 1. SAVED FILTER CRUD
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact(Skip = "TODO: Implement validation test for DataLists")]
|
||||
public void DataLists_ShouldSortFilterAllTypesAndSaveUpdate()
|
||||
/// <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":null}
|
||||
""";
|
||||
|
||||
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":null}
|
||||
""";
|
||||
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 — stubs awaiting real payloads from the browser
|
||||
// -----------------------------------------------------------------------
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// 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.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact(Skip = "TODO: Capture real list payload from browser — see comment above")]
|
||||
public void 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
|
||||
}
|
||||
|
||||
[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() { }
|
||||
|
||||
[Fact(Skip = "TODO: Capture real list payload from browser")]
|
||||
public void WorkOrderList_StringNotEqualFilterWorks() { }
|
||||
|
||||
[Fact(Skip = "TODO: Capture real list payload from browser")]
|
||||
public void WorkOrderList_DateRangeFilterWorks() { }
|
||||
|
||||
[Fact(Skip = "TODO: Capture real list payload from browser")]
|
||||
public void WorkOrderList_NullFieldFilterWorks() { }
|
||||
|
||||
[Fact(Skip = "TODO: Capture real list payload from browser")]
|
||||
public void WorkOrderList_MultiConditionAndFilterWorks() { }
|
||||
|
||||
[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() { }
|
||||
|
||||
[Fact(Skip = "TODO: Capture real list payload from browser")]
|
||||
public void WorkOrderList_PaginationOffsetAndLimitWork() { }
|
||||
|
||||
[Fact(Skip = "TODO: Capture real list payload from browser")]
|
||||
public void WorkOrderList_ReturnFormatMatchesExpectedShape() { }
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 3. RIGHTS
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact(Skip = "TODO: Identify a seeded user with no WorkOrder list rights and use their login")]
|
||||
public void WorkOrderList_UserWithoutListRightsGets403() { }
|
||||
|
||||
}//eoc
|
||||
}//eons
|
||||
|
||||
129
PM/PMCrud.cs
Normal file
129
PM/PMCrud.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using Xunit;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace raven_integration
|
||||
{
|
||||
|
||||
public class PMCrud
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Full CRUD for a Preventive Maintenance header + PMItem.
|
||||
/// Also tests concurrency violation and id-from-number lookup.
|
||||
/// PM is structurally parallel to WorkOrder (header → items → sub-types)
|
||||
/// so failures here flag regressions if the refactor consolidates those patterns.
|
||||
///
|
||||
/// Key enum values used:
|
||||
/// PMTimeUnit: Minutes=2, Hours=3, Days=4, Months=6, Years=7
|
||||
/// AyaDaysOfWeek (flags): Monday=1, Tuesday=2, Wednesday=4, Thursday=8,
|
||||
/// Friday=16, Saturday=32, Sunday=64
|
||||
/// excludeDaysOfWeek=96 means Saturday(32)|Sunday(64)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CRUD()
|
||||
{
|
||||
var token = await Util.GetTokenAsync("BizAdmin");
|
||||
// nextServiceDate must be a future UTC timestamp
|
||||
var isoNextService = DateTime.UtcNow.AddDays(7).ToString("o");
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// CREATE PM HEADER
|
||||
// customerId=1 is a seeded customer; serial=0 means server assigns it.
|
||||
// -------------------------------------------------------------------
|
||||
var payload = $$"""
|
||||
{"id":0,"concurrency":0,"serial":0,"notes":"Test PM — the quick brown fox jumped over the six lazy dogs!","wiki":null,"customFields":"{}","tags":[],"copyWiki":false,"copyAttachments":true,"stopGeneratingDate":null,"excludeDaysOfWeek":96,"active":true,"nextServiceDate":"{{isoNextService}}","repeatUnit":6,"generateBeforeUnit":4,"repeatInterval":1,"generateBeforeInterval":3,"customerId":1,"projectId":null,"internalReferenceNumber":"PM-INT-001","customerReferenceNumber":null,"customerContactName":null,"onsite":true,"contractId":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}
|
||||
""";
|
||||
|
||||
ApiResponse a = await Util.PostAsync("pm", token, payload);
|
||||
Util.ValidateDataReturnResponseOk(a);
|
||||
long PMId = a.ObjectResponse["data"]["id"].Value<long>();
|
||||
long PMSerial = a.ObjectResponse["data"]["serial"].Value<long>();
|
||||
PMId.Should().BeGreaterThan(0);
|
||||
PMSerial.Should().BeGreaterThan(0, "server should have assigned a non-zero serial");
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// GET PM
|
||||
// -------------------------------------------------------------------
|
||||
a = await Util.GetAsync($"pm/{PMId}", token);
|
||||
Util.ValidateDataReturnResponseOk(a);
|
||||
a.ObjectResponse["data"]["id"].Value<long>().Should().Be(PMId);
|
||||
a.ObjectResponse["data"]["repeatUnit"].Value<int>().Should().Be(6, "should be Months");
|
||||
a.ObjectResponse["data"]["excludeDaysOfWeek"].Value<int>().Should().Be(96, "should be Sat+Sun flags");
|
||||
a.ObjectResponse["data"]["internalReferenceNumber"].Value<string>().Should().Be("PM-INT-001");
|
||||
var headerConcurrency = a.ObjectResponse["data"]["concurrency"].Value<uint>();
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// ID-FROM-NUMBER LOOKUP
|
||||
// -------------------------------------------------------------------
|
||||
a = await Util.GetAsync($"pm/id-from-number/{PMSerial}", token);
|
||||
Util.ValidateDataReturnResponseOk(a);
|
||||
a.ObjectResponse["data"].Value<long>().Should().Be(PMId);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// CREATE PM ITEM
|
||||
// -------------------------------------------------------------------
|
||||
payload = $$"""
|
||||
{"id":0,"concurrency":0,"notes":"Test PM item summary","wiki":null,"customFields":"{}","tags":[],"pmId":{{PMId}},"techNotes":"Tech notes for PM item","workOrderItemStatusId":null,"workOrderItemPriorityId":null,"requestDate":null,"warrantyService":false,"sequence":1}
|
||||
""";
|
||||
a = await Util.PostAsync("pm/items", token, payload);
|
||||
Util.ValidateDataReturnResponseOk(a);
|
||||
long PMItemId = a.ObjectResponse["data"]["id"].Value<long>();
|
||||
PMItemId.Should().BeGreaterThan(0);
|
||||
|
||||
// GET PM ITEM
|
||||
a = await Util.GetAsync($"pm/items/{PMItemId}", token);
|
||||
Util.ValidateDataReturnResponseOk(a);
|
||||
a.ObjectResponse["data"]["notes"].Value<string>().Should().Be("Test PM item summary");
|
||||
var itemConcurrency = a.ObjectResponse["data"]["concurrency"].Value<uint>();
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// UPDATE PM ITEM
|
||||
// -------------------------------------------------------------------
|
||||
payload = $$"""
|
||||
{"id":{{PMItemId}},"concurrency":{{itemConcurrency}},"notes":"Updated PM item notes","wiki":null,"customFields":"{}","tags":[],"pmId":{{PMId}},"techNotes":"Updated tech notes","workOrderItemStatusId":null,"workOrderItemPriorityId":null,"requestDate":null,"warrantyService":false,"sequence":1}
|
||||
""";
|
||||
a = await Util.PutAsync("pm/items/", token, payload);
|
||||
Util.ValidateHTTPStatusCode(a, 200);
|
||||
var newItemConcurrency = a.ObjectResponse["data"]["concurrency"].Value<uint>();
|
||||
newItemConcurrency.Should().NotBe(itemConcurrency, "concurrency should increment on update");
|
||||
|
||||
// CONCURRENCY VIOLATION on item
|
||||
a = await Util.PutAsync("pm/items/", token, payload); // stale concurrency
|
||||
Util.ValidateConcurrencyError(a);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// UPDATE PM HEADER
|
||||
// -------------------------------------------------------------------
|
||||
var isoNewNextService = DateTime.UtcNow.AddDays(14).ToString("o");
|
||||
payload = $$"""
|
||||
{"id":{{PMId}},"concurrency":{{headerConcurrency}},"serial":{{PMSerial}},"notes":"Updated PM notes","wiki":null,"customFields":"{}","tags":[],"copyWiki":true,"copyAttachments":true,"stopGeneratingDate":null,"excludeDaysOfWeek":0,"active":true,"nextServiceDate":"{{isoNewNextService}}","repeatUnit":4,"generateBeforeUnit":4,"repeatInterval":2,"generateBeforeInterval":5,"customerId":1,"projectId":null,"internalReferenceNumber":"PM-INT-002","customerReferenceNumber":null,"customerContactName":null,"onsite":false,"contractId":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}
|
||||
""";
|
||||
a = await Util.PutAsync("pm", token, payload);
|
||||
Util.ValidateHTTPStatusCode(a, 200);
|
||||
a.ObjectResponse["data"]["internalReferenceNumber"].Value<string>().Should().Be("PM-INT-002");
|
||||
a.ObjectResponse["data"]["repeatUnit"].Value<int>().Should().Be(4, "should now be Days");
|
||||
var newHeaderConcurrency = a.ObjectResponse["data"]["concurrency"].Value<uint>();
|
||||
newHeaderConcurrency.Should().NotBe(headerConcurrency);
|
||||
|
||||
// CONCURRENCY VIOLATION on header
|
||||
a = await Util.PutAsync("pm", token, payload); // stale concurrency
|
||||
Util.ValidateConcurrencyError(a);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// DELETE in bottom-up order: item → header
|
||||
// -------------------------------------------------------------------
|
||||
a = await Util.DeleteAsync($"pm/items/{PMItemId}", token);
|
||||
Util.ValidateHTTPStatusCode(a, 204);
|
||||
|
||||
a = await Util.DeleteAsync($"pm/{PMId}", token);
|
||||
Util.ValidateHTTPStatusCode(a, 204);
|
||||
|
||||
// Confirm header is gone
|
||||
a = await Util.GetAsync($"pm/{PMId}", token);
|
||||
Util.ValidateResponseNotFound(a);
|
||||
}
|
||||
|
||||
}//eoc
|
||||
}//eons
|
||||
134
Quote/QuoteCrud.cs
Normal file
134
Quote/QuoteCrud.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using System;
|
||||
using Xunit;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace raven_integration
|
||||
{
|
||||
|
||||
public class QuoteCrud
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Full CRUD for a Quote header + QuoteItem + QuoteItemLabor sub-type.
|
||||
/// Also tests concurrency violation on the header and id-from-number lookup.
|
||||
/// Mirrors the WorkOrderCrud structure — if a refactor consolidates WO/Quote
|
||||
/// patterns, failures here flag the regression.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CRUD()
|
||||
{
|
||||
var token = await Util.GetTokenAsync("BizAdmin");
|
||||
var isoNow = DateTime.UtcNow.ToString("o");
|
||||
var isoOneHourFromNow = DateTime.UtcNow.AddHours(1).ToString("o");
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// CREATE QUOTE HEADER
|
||||
// customerId=1 is a seeded customer; serial=0 means server assigns it.
|
||||
// -------------------------------------------------------------------
|
||||
var payload = $$"""
|
||||
{"id":0,"concurrency":0,"serial":0,"notes":"Test quote — the quick brown fox jumped over the six lazy dogs!","wiki":null,"customFields":"{}","tags":[],"preparedById":null,"introduction":"Test introduction","requested":null,"validUntil":null,"submitted":null,"approved":null,"customerId":1,"projectId":null,"internalReferenceNumber":"INT-001","customerReferenceNumber":null,"customerContactName":null,"onsite":true,"contractId":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}
|
||||
""";
|
||||
|
||||
ApiResponse a = await Util.PostAsync("quote", token, payload);
|
||||
Util.ValidateDataReturnResponseOk(a);
|
||||
long QuoteId = a.ObjectResponse["data"]["id"].Value<long>();
|
||||
long QuoteSerial = a.ObjectResponse["data"]["serial"].Value<long>();
|
||||
QuoteId.Should().BeGreaterThan(0);
|
||||
QuoteSerial.Should().BeGreaterThan(0, "server should have assigned a non-zero serial");
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// GET QUOTE
|
||||
// -------------------------------------------------------------------
|
||||
a = await Util.GetAsync($"quote/{QuoteId}", token);
|
||||
Util.ValidateDataReturnResponseOk(a);
|
||||
a.ObjectResponse["data"]["id"].Value<long>().Should().Be(QuoteId);
|
||||
a.ObjectResponse["data"]["internalReferenceNumber"].Value<string>().Should().Be("INT-001");
|
||||
var headerConcurrency = a.ObjectResponse["data"]["concurrency"].Value<uint>();
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// ID-FROM-NUMBER LOOKUP
|
||||
// -------------------------------------------------------------------
|
||||
a = await Util.GetAsync($"quote/id-from-number/{QuoteSerial}", token);
|
||||
Util.ValidateDataReturnResponseOk(a);
|
||||
a.ObjectResponse["data"].Value<long>().Should().Be(QuoteId);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// CREATE QUOTE ITEM
|
||||
// -------------------------------------------------------------------
|
||||
payload = $$"""
|
||||
{"id":0,"concurrency":0,"notes":"Test quote item summary","wiki":null,"customFields":"{}","tags":[],"quoteId":{{QuoteId}},"techNotes":"Tech notes for item","workOrderItemStatusId":null,"workOrderItemPriorityId":null,"requestDate":null,"warrantyService":false,"sequence":1}
|
||||
""";
|
||||
a = await Util.PostAsync("quote/items", token, payload);
|
||||
Util.ValidateDataReturnResponseOk(a);
|
||||
long QuoteItemId = a.ObjectResponse["data"]["id"].Value<long>();
|
||||
QuoteItemId.Should().BeGreaterThan(0);
|
||||
|
||||
// GET QUOTE ITEM
|
||||
a = await Util.GetAsync($"quote/items/{QuoteItemId}", token);
|
||||
Util.ValidateDataReturnResponseOk(a);
|
||||
a.ObjectResponse["data"]["notes"].Value<string>().Should().Be("Test quote item summary");
|
||||
var itemConcurrency = a.ObjectResponse["data"]["concurrency"].Value<uint>();
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// CREATE QUOTE ITEM LABOR (nested sub-type)
|
||||
// -------------------------------------------------------------------
|
||||
payload = $$"""
|
||||
{"id":0,"concurrency":0,"userId":null,"serviceStartDate":"{{isoNow}}","serviceStopDate":"{{isoOneHourFromNow}}","serviceRateId":null,"serviceDetails":"Test labor service details","serviceRateQuantity":1,"noChargeQuantity":0,"taxCodeSaleId":null,"priceOverride":null,"quoteItemId":{{QuoteItemId}}}
|
||||
""";
|
||||
a = await Util.PostAsync("quote/items/labors", token, payload);
|
||||
Util.ValidateDataReturnResponseOk(a);
|
||||
long QuoteItemLaborId = a.ObjectResponse["data"]["id"].Value<long>();
|
||||
QuoteItemLaborId.Should().BeGreaterThan(0);
|
||||
a.ObjectResponse["data"]["serviceDetails"].Value<string>().Should().Be("Test labor service details");
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// UPDATE QUOTE ITEM
|
||||
// -------------------------------------------------------------------
|
||||
payload = $$"""
|
||||
{"id":{{QuoteItemId}},"concurrency":{{itemConcurrency}},"notes":"Updated item notes","wiki":null,"customFields":"{}","tags":[],"quoteId":{{QuoteId}},"techNotes":"Updated tech notes","workOrderItemStatusId":null,"workOrderItemPriorityId":null,"requestDate":null,"warrantyService":false,"sequence":1}
|
||||
""";
|
||||
a = await Util.PutAsync("quote/items/", token, payload);
|
||||
Util.ValidateHTTPStatusCode(a, 200);
|
||||
var newItemConcurrency = a.ObjectResponse["data"]["concurrency"].Value<uint>();
|
||||
newItemConcurrency.Should().NotBe(itemConcurrency);
|
||||
|
||||
// CONCURRENCY VIOLATION on item
|
||||
a = await Util.PutAsync("quote/items/", token, payload); // still has stale concurrency
|
||||
Util.ValidateConcurrencyError(a);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// UPDATE QUOTE HEADER
|
||||
// -------------------------------------------------------------------
|
||||
payload = $$"""
|
||||
{"id":{{QuoteId}},"concurrency":{{headerConcurrency}},"serial":{{QuoteSerial}},"notes":"Updated quote notes","wiki":null,"customFields":"{}","tags":[],"preparedById":null,"introduction":"Updated introduction","requested":null,"validUntil":null,"submitted":null,"approved":null,"customerId":1,"projectId":null,"internalReferenceNumber":"INT-002","customerReferenceNumber":null,"customerContactName":null,"onsite":false,"contractId":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}
|
||||
""";
|
||||
a = await Util.PutAsync("quote", token, payload);
|
||||
Util.ValidateHTTPStatusCode(a, 200);
|
||||
a.ObjectResponse["data"]["internalReferenceNumber"].Value<string>().Should().Be("INT-002");
|
||||
var newHeaderConcurrency = a.ObjectResponse["data"]["concurrency"].Value<uint>();
|
||||
newHeaderConcurrency.Should().NotBe(headerConcurrency);
|
||||
|
||||
// CONCURRENCY VIOLATION on header
|
||||
a = await Util.PutAsync("quote", token, payload); // stale concurrency
|
||||
Util.ValidateConcurrencyError(a);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// DELETE in bottom-up order: labor → item → header
|
||||
// -------------------------------------------------------------------
|
||||
a = await Util.DeleteAsync($"quote/items/labors/{QuoteItemLaborId}", token);
|
||||
Util.ValidateHTTPStatusCode(a, 204);
|
||||
|
||||
a = await Util.DeleteAsync($"quote/items/{QuoteItemId}", token);
|
||||
Util.ValidateHTTPStatusCode(a, 204);
|
||||
|
||||
a = await Util.DeleteAsync($"quote/{QuoteId}", token);
|
||||
Util.ValidateHTTPStatusCode(a, 204);
|
||||
|
||||
// Confirm header is gone
|
||||
a = await Util.GetAsync($"quote/{QuoteId}", token);
|
||||
Util.ValidateResponseNotFound(a);
|
||||
}
|
||||
|
||||
}//eoc
|
||||
}//eons
|
||||
52
memory/MEMORY.md
Normal file
52
memory/MEMORY.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# raven-test-integration memory
|
||||
|
||||
## Project purpose
|
||||
Integration test project for AyaNova v8 API. Target: net8.0. Run with:
|
||||
dotnet test raven-integration.csproj
|
||||
|
||||
## Key constants / config
|
||||
- API base: http://localhost:7575/api/v8/ (util.cs line 31)
|
||||
- Seeded logins: superuser (password: l3tm3in), BizAdmin, SubContractorRestricted
|
||||
- Seeded customer id=1 is always safe to reference in payloads
|
||||
- Time zone adjustment for date filters: -7 (util.cs TIME_ZONE_ADJUSTMENT)
|
||||
|
||||
## Known good enum values used in tests
|
||||
- ContractOverrideType: PriceDiscount=1, CostMarkup=2
|
||||
- PMTimeUnit: Minutes=2, Hours=3, Days=4, Months=6, Years=7
|
||||
- AyaDaysOfWeek (flags): Mon=1,Tue=2,Wed=4,Thu=8,Fri=16,Sat=32,Sun=64
|
||||
— 96 = Saturday|Sunday (used in PM tests)
|
||||
|
||||
## DataList key names
|
||||
Class names in AyaNova.DataList namespace ARE the list keys.
|
||||
Examples: WorkOrderDataList, CustomerDataList, QuoteDataList, PMDataList, ContractDataList
|
||||
|
||||
## API response conventions
|
||||
- POST: 201, {data: full object}
|
||||
- GET: 200, {data: full object}
|
||||
- DELETE: 204, no body
|
||||
- Customer/QuoteItem/PMItem PUT: 200, {data: {Concurrency: N}} (just concurrency)
|
||||
- Quote/PM/Contract/WorkOrder header PUT: 200, {data: full object}
|
||||
- Concurrency conflict: 409
|
||||
- Referential integrity violation: 400, error.code = 2200
|
||||
- Not found: 404, error.code = 2010
|
||||
|
||||
## Files written (Feb 2026 session)
|
||||
- Customer/CustomerCrud.cs — CRUD + concurrency + alert + referential integrity (2 tests)
|
||||
- Quote/QuoteCrud.cs — header CRUD + QuoteItem + QuoteItemLabor + concurrency + id-from-number
|
||||
- Contract/ContractCrud.cs — CRUD + concurrency (contract PUT returns full object)
|
||||
- PM/PMCrud.cs — header CRUD + PMItem + concurrency + id-from-number
|
||||
- DataList/DataListOperations.cs — SavedFilterCRUD (live) + filter/sort stubs (skipped)
|
||||
|
||||
## DataList saved filter route
|
||||
POST/GET/PUT/DELETE: /data-list-filter (NOT data-list-view — that was old)
|
||||
List endpoint: GET /data-list-filter/list?ListKey=WorkOrderDataList
|
||||
userId=0 in payload is fine; server assigns from auth token
|
||||
|
||||
## Todo.md coverage status
|
||||
Tier 1: DataList saved filter CRUD done; filter/sort stubs need browser payload capture
|
||||
Tier 1: Quote CRUD done
|
||||
Tier 1: Customer CRUD done
|
||||
Tier 2: Contract CRUD done
|
||||
Tier 2: PM CRUD done
|
||||
Tier 2: Auth roles, Schedule, Part — not yet started
|
||||
Tier 3: All not yet started
|
||||
Reference in New Issue
Block a user