This commit is contained in:
2026-02-28 19:53:34 -08:00
parent 59cb886adb
commit db593886a3
7 changed files with 716 additions and 17 deletions

View File

@@ -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
View 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
View 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

View File

@@ -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
View 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
View 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
View 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