diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/TravelRequestsAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/TravelRequestsAPI.Page.al index 09524c86fb8..51aeaf7021e 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/TravelRequestsAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/TravelRequestsAPI.Page.al @@ -48,6 +48,11 @@ page 7134 "Travel Requests API" { Caption = 'Requested By'; ToolTip = 'Specifies the employee who created the request. This value can be set only when creating the request.'; + + trigger OnValidate() + begin + RequestedByProvided := true; + end; } field(status; Rec.Status) { @@ -98,6 +103,8 @@ page 7134 "Travel Requests API" trigger OnValidate() begin + // Mark the record changed while deferring validation until the complete date pair is available. + Rec."Expected Start Date" := ExpectedStartDate; ExpectedStartDateProvided := true; end; } @@ -108,6 +115,7 @@ page 7134 "Travel Requests API" trigger OnValidate() begin + Rec."Expected End Date" := ExpectedEndDate; ExpectedEndDateProvided := true; end; } @@ -240,12 +248,10 @@ page 7134 "Travel Requests API" end; trigger OnNewRecord(BelowxRec: Boolean) - var - OwnerEmployeeNo: Code[20]; begin - OwnerEmployeeNo := ProcessOwnerFilter(); - if OwnerEmployeeNo <> '' then - Rec."Requested By" := OwnerEmployeeNo; + ProcessOwnerFilter(); + Clear(Rec."Requested By"); + RequestedByProvided := false; Clear(CurrencyCodeDisplay); Clear(ExpectedStartDate); Clear(ExpectedEndDate); @@ -340,6 +346,9 @@ page 7134 "Travel Requests API" trigger OnInsertRecord(BelowxRec: Boolean): Boolean begin Rec."Document Type" := Rec."Document Type"::"Travel Request"; + // Default the owner only at insertion so an owner-only POST remains a record change. + if not RequestedByProvided then + Rec."Requested By" := ProcessOwnerFilter(); Rec.TestField("Requested By"); CheckOwnerScope(); Rec.SetExpectedDatesForAPIInsert(ExpectedStartDate, ExpectedEndDate, ExpectedStartDateProvided, ExpectedEndDateProvided); @@ -426,6 +435,7 @@ page 7134 "Travel Requests API" ExpectedEndDate: Date; ExpectedStartDateProvided: Boolean; ExpectedEndDateProvided: Boolean; + RequestedByProvided: Boolean; StatusCannotBeChangedErr: Label 'can be changed only by submitting, approving, or rejecting the travel request'; RequestedByCannotBeChangedErr: Label 'cannot be changed'; TravelRequestMustBeApprovedErr: Label 'Travel request %1 must be approved before an expense report can be created.', Comment = '%1 = Travel Request No.'; diff --git a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseActivityLogAPITest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseActivityLogAPITest.Codeunit.al index 07c3351e5b9..c25201f0a2d 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseActivityLogAPITest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseActivityLogAPITest.Codeunit.al @@ -446,6 +446,7 @@ codeunit 148343 "Expense Activity Log API Test" CreateTestExpenseUser(ApproverExpenseUser); ApproverExpenseUser."Can Approve" := true; + ApproverExpenseUser.Validate("Unlimited Approval", true); ApproverExpenseUser."User Id For Approvals" := CopyStr('APPROVER-' + RunToken, 1, MaxStrLen(ApproverExpenseUser."User Id For Approvals")); ApproverExpenseUser.Modify(); diff --git a/src/Apps/W1/ExpenseAgent/test/src/API/TravelRequestsAPITest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/API/TravelRequestsAPITest.Codeunit.al index 3016033f0af..6f2e9a9ab6d 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/API/TravelRequestsAPITest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/API/TravelRequestsAPITest.Codeunit.al @@ -10,9 +10,9 @@ using Microsoft.Finance.GeneralLedger.Setup; using Microsoft.Finance.SpendRequest; using Microsoft.HumanResources.Employee; -// These HTTP tests are excluded in Expense_Agent_Tests.DisabledTest.json per the PR review. -// Re-enable them after BCApps CI provisions an authenticated OData endpoint and a dedicated -// test company with committed fixtures and disabled test isolation, then remove the exclusions. +// These HTTP tests remain excluded in Expense_Agent_Tests.DisabledTest.json until the +// authentication prerequisite provides an authenticated OData endpoint and a dedicated +// test company with committed fixtures and disabled test isolation. // In-process employee filtering, traveler mapping/navigation, lifecycle, date, and scope coverage // in "Spend Request Test", and restrictive role coverage in "Expense Permissions Test", remain enabled. // Only the HTTP scenarios are excluded. @@ -102,6 +102,7 @@ codeunit 148347 "Travel Requests API Test" TargetURL := LibraryGraphMgt.CreateTargetURL( Format(TravelRequest.SystemId), Page::"Travel Requests API", TravelRequestsServiceNameTok); TargetURL := AppendPathToAPIURL(TargetURL, '/' + TravelersServiceNameTok); + Clear(ResponseText); LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 201); // [THEN] The Traveler stores the corresponding Expense User number. @@ -134,6 +135,7 @@ codeunit 148347 "Travel Requests API Test" TargetURL += '&$expand=travelers,employees' else TargetURL += '?$expand=travelers,employees'; + Clear(ResponseText); LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); // [THEN] Each traveler itself returns the correct mapping, not just a nested employee entity. @@ -173,10 +175,7 @@ codeunit 148347 "Travel Requests API Test" Employee: Record Employee; ExpenseUser: Record "Expense User"; TravelRequest: Record "Spend Request"; - ErrorResponse: JsonToken; - ErrorMessage: JsonToken; Request: JsonObject; - Response: JsonObject; RequestBody: Text; ResponseText: Text; TargetURL: Text; @@ -196,16 +195,13 @@ codeunit 148347 "Travel Requests API Test" TargetURL := LibraryGraphMgt.CreateTargetURL( Format(TravelRequest.SystemId), Page::"Travel Requests API", TravelRequestsServiceNameTok); TargetURL := AppendPathToAPIURL(TargetURL, '/' + TravelersServiceNameTok); + Clear(ResponseText); asserterror LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 400); // [THEN] The API identifies the employee without an Expense User. Assert.ExpectedError(BadRequestResponseErr); - Response.ReadFrom(ResponseText); - Response.Get('error', ErrorResponse); - ErrorResponse.AsObject().Get('message', ErrorMessage); - Assert.AreNotEqual( - 0, StrPos(ErrorMessage.AsValue().AsText(), StrSubstNo(ExpenseUserNotLinkedErr, Employee."No.")), - 'The response must identify the employee without an Expense User.'); + Assert.ExpectedErrorCode('Dialog'); + Assert.ExpectedError(StrSubstNo(ExpenseUserNotLinkedErr, Employee."No.")); end; [Test] @@ -238,6 +234,7 @@ codeunit 148347 "Travel Requests API Test" TargetURL := AppendPathToAPIURL( TargetURL, '/' + TravelRequestsServiceNameTok + '(' + LibraryGraphMgt.StripBrackets(Format(TravelRequest.SystemId)) + ')/' + CreateExpenseReportActionTok); + Clear(ResponseText); LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(TargetURL, '{}', ResponseText, 201); // [THEN] A new report is linked to the request and its Expense User. @@ -275,6 +272,7 @@ codeunit 148347 "Travel Requests API Test" Format(TravelRequest.SystemId), Page::"Travel Requests API", TravelRequestsServiceNameTok, ApproveTravelRequestActionTok); RequestBody := StrSubstNo(ApproveTravelRequestBodyLbl, ApproverExpenseUser."No."); + Clear(ResponseText); LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 200); // [THEN] The request is approved and a report is created for Requested For. @@ -336,11 +334,7 @@ codeunit 148347 "Travel Requests API Test" procedure TravelRequestsAPIPreservesAndUpdatesDates() var ExpenseUser: Record "Expense User"; - TravelRequest: Record "Spend Request"; Request: JsonObject; - Response: JsonObject; - ErrorResponse: JsonToken; - ErrorMessage: JsonToken; RequestSystemId: Guid; StartDate: Date; EndDate: Date; @@ -368,16 +362,15 @@ codeunit 148347 "Travel Requests API Test" TargetURL := LibraryGraphMgt.CreateTargetURL( Format(ExpenseUser.SystemId), Page::"Expense Users API", ExpenseUsersServiceNameTok); TargetURL := AppendPathToAPIURL(TargetURL, '/' + TravelRequestsServiceNameTok); + Clear(ResponseText); LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 201); // [THEN] POST, GET, and storage retain the supplied dates and identity. - AssertAPIDates(ResponseText, StartDate, EndDate); - TravelRequest.GetBySystemId(RequestSystemId); - Assert.AreEqual(StartDate, TravelRequest."Expected Start Date", 'The API start date must be persisted.'); - Assert.AreEqual(EndDate, TravelRequest."Expected End Date", 'The API end date must be persisted.'); + VerifyTravelRequestDates(ResponseText, RequestSystemId, StartDate, EndDate); RecordURL := AppendPathToAPIURL(TargetURL, '(' + LibraryGraphMgt.StripBrackets(Format(RequestSystemId)) + ')'); + Clear(ResponseText); LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, RecordURL, 200); - AssertAPIDates(ResponseText, StartDate, EndDate); + VerifyTravelRequestDates(ResponseText, RequestSystemId, StartDate, EndDate); // [WHEN] A start-first PATCH moves the range beyond the old end. StartDate += 30; @@ -386,10 +379,11 @@ codeunit 148347 "Travel Requests API Test" Request.Add('expectedStartDate', Format(StartDate, 0, 9)); Request.Add('expectedEndDate', Format(EndDate, 0, 9)); Request.WriteTo(RequestBody); + Clear(ResponseText); LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(RecordURL, RequestBody, ResponseText, 200); // [THEN] The complete later range is accepted. - AssertAPIDates(ResponseText, StartDate, EndDate); + VerifyTravelRequestDates(ResponseText, RequestSystemId, StartDate, EndDate); // [WHEN] An end-first PATCH moves the range before the old start. StartDate := WorkDate() - 33; @@ -398,35 +392,37 @@ codeunit 148347 "Travel Requests API Test" Request.Add('expectedEndDate', Format(EndDate, 0, 9)); Request.Add('expectedStartDate', Format(StartDate, 0, 9)); Request.WriteTo(RequestBody); + Clear(ResponseText); LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(RecordURL, RequestBody, ResponseText, 200); // [THEN] The complete earlier range is accepted. - AssertAPIDates(ResponseText, StartDate, EndDate); + VerifyTravelRequestDates(ResponseText, RequestSystemId, StartDate, EndDate); // [WHEN] Only the end date is changed. EndDate += 1; Clear(Request); Request.Add('expectedEndDate', Format(EndDate, 0, 9)); Request.WriteTo(RequestBody); + Clear(ResponseText); LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(RecordURL, RequestBody, ResponseText, 200); // [THEN] The omitted start remains unchanged. - AssertAPIDates(ResponseText, StartDate, EndDate); + VerifyTravelRequestDates(ResponseText, RequestSystemId, StartDate, EndDate); // [WHEN] A start-only PATCH would exceed the stored end. Clear(Request); Request.Add('expectedStartDate', Format(EndDate + 1, 0, 9)); Request.WriteTo(RequestBody); + Clear(ResponseText); asserterror LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(RecordURL, RequestBody, ResponseText, 400); // [THEN] The date-range error is returned and the previous valid pair remains stored. Assert.ExpectedError(BadRequestResponseErr); - Response.ReadFrom(ResponseText); - Response.Get('error', ErrorResponse); - ErrorResponse.AsObject().Get('message', ErrorMessage); - Assert.AreNotEqual(0, StrPos(ErrorMessage.AsValue().AsText(), InvalidTravelRequestDatesErr), 'The invalid date range must cause the rejection.'); + Assert.ExpectedErrorCode('Dialog'); + Assert.ExpectedError(InvalidTravelRequestDatesErr); + Clear(ResponseText); LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, RecordURL, 200); - AssertAPIDates(ResponseText, StartDate, EndDate); + VerifyTravelRequestDates(ResponseText, RequestSystemId, StartDate, EndDate); end; [Test] @@ -461,6 +457,7 @@ codeunit 148347 "Travel Requests API Test" TargetURL += '&$expand=travelRequests' else TargetURL += '?$expand=travelRequests'; + Clear(ResponseText); LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); ResponseText := LowerCase(ResponseText); TravelRequestIdTxt := LowerCase(LibraryGraphMgt.StripBrackets(Format(TravelRequest.SystemId))); @@ -477,6 +474,7 @@ codeunit 148347 "Travel Requests API Test" // [WHEN] The owner is renamed and the same GUID-based URL is requested. ExpenseUser.Rename(CopyStr(Format(CreateGuid()), 1, MaxStrLen(ExpenseUser."No."))); Commit(); + Clear(ResponseText); LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); ResponseText := LowerCase(ResponseText); @@ -520,6 +518,7 @@ codeunit 148347 "Travel Requests API Test" TargetURL += '&$expand=travelRequest' else TargetURL += '?$expand=travelRequest'; + Clear(ResponseText); LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); // [THEN] The report's GUID and expanded request identify the originating travel request. @@ -562,6 +561,7 @@ codeunit 148347 "Travel Requests API Test" // [WHEN] The detail is retrieved through the API. TargetURL := LibraryGraphMgt.CreateTargetURL( Format(TravelRequestDetail.SystemId), Page::"Travel Request Details API", TravelRequestDetailsServiceNameTok); + Clear(ResponseText); LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); // [THEN] The payload includes the stored type and expense category. @@ -611,6 +611,7 @@ codeunit 148347 "Travel Requests API Test" TargetURL += '&$expand=travelRequests' else TargetURL += '?$expand=travelRequests'; + Clear(ResponseText); LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); ResponseText := LowerCase(ResponseText); AssignedTravelRequestIdTxt := LowerCase(LibraryGraphMgt.StripBrackets(Format(AssignedTravelRequest.SystemId))); @@ -633,8 +634,6 @@ codeunit 148347 "Travel Requests API Test" TravelRequest: Record "Spend Request"; Response: JsonObject; RequestId: JsonToken; - ErrorResponse: JsonToken; - ErrorMessage: JsonToken; TravelRequestSystemId: Guid; RequestBody: Text; ResponseText: Text; @@ -655,25 +654,24 @@ codeunit 148347 "Travel Requests API Test" // [WHEN] POST attempts to assign another employee under this user's GUID. RequestBody := StrSubstNo(RequestedByRequestBodyLbl, OtherExpenseUser."Employee No."); + Clear(ResponseText); asserterror LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 400); // [THEN] The owner mismatch is rejected before a request can be inserted. Assert.ExpectedError(BadRequestResponseErr); - Response.ReadFrom(ResponseText); - Response.Get('error', ErrorResponse); - ErrorResponse.AsObject().Get('message', ErrorMessage); - Assert.AreNotEqual( - 0, StrPos(ErrorMessage.AsValue().AsText(), TravelRequest.FieldCaption("Requested By")), - 'The rejection must identify the owner mismatch.'); + Assert.ExpectedErrorCode('Dialog'); + Assert.ExpectedError(TravelRequest.FieldCaption("Requested By")); // [WHEN] POST supplies the employee matching this user's GUID. RequestBody := StrSubstNo(RequestedByRequestBodyLbl, ExpenseUser."Employee No."); + Clear(ResponseText); LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 201); // [THEN] The new travel request stores the supplied owner. Response.ReadFrom(ResponseText); Response.Get('id', RequestId); Evaluate(TravelRequestSystemId, RequestId.AsValue().AsText()); + SelectLatestVersion(); TravelRequest.GetBySystemId(TravelRequestSystemId); Assert.AreEqual(ExpenseUser."Employee No.", TravelRequest."Requested By", 'POST must accept the travel request owner.'); Assert.AreEqual(TravelRequest."Document Type"::"Travel Request", TravelRequest."Document Type", 'POST must create a travel request.'); @@ -682,6 +680,10 @@ codeunit 148347 "Travel Requests API Test" // [THEN] PATCH succeeds without changing the owner. TargetURL := AppendPathToAPIURL(TargetURL, '(' + LibraryGraphMgt.StripBrackets(Format(TravelRequest.SystemId)) + ')'); AssertOwnerPreservingPatch(TargetURL, TravelRequest); + SelectLatestVersion(); + TravelRequest.GetBySystemId(TravelRequestSystemId); + Assert.AreEqual(ExpenseUser."Employee No.", TravelRequest."Requested By", 'PATCH must preserve the stored owner.'); + Assert.AreEqual('Updated business trip', TravelRequest.Purpose, 'PATCH must persist the updated purpose.'); end; [Test] @@ -691,10 +693,7 @@ codeunit 148347 "Travel Requests API Test" OtherExpenseUser: Record "Expense User"; TravelRequest: Record "Spend Request"; OriginalRequestedBy: Code[20]; - Response: JsonObject; - ErrorResponse: JsonToken; - ErrorCode: JsonToken; - ErrorMessage: JsonToken; + TravelRequestSystemId: Guid; RequestBody: Text; ResponseText: Text; TargetURL: Text; @@ -707,17 +706,20 @@ codeunit 148347 "Travel Requests API Test" LibraryExpense.CreateExpenseUser(OtherExpenseUser); CreateTravelRequest(TravelRequest, ExpenseUser."Employee No."); OriginalRequestedBy := TravelRequest."Requested By"; + TravelRequestSystemId := TravelRequest.SystemId; Commit(); // [WHEN] PATCH attempts to change the owner. TargetURL := LibraryGraphMgt.CreateTargetURL( Format(TravelRequest.SystemId), Page::"Travel Requests API", TravelRequestsServiceNameTok); RequestBody := StrSubstNo(RequestedByRequestBodyLbl, OtherExpenseUser."Employee No."); + Clear(ResponseText); asserterror LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 400); // [THEN] The API identifies the immutable owner and the affected request. Assert.ExpectedError(BadRequestResponseErr); - AssertOwnerChangeError(ResponseText, TravelRequest); + Assert.ExpectedErrorCode('Dialog'); + VerifyOwnerChangeError(TravelRequest); // [WHEN] PATCH attempts to change the status. Clear(ResponseText); @@ -726,13 +728,10 @@ codeunit 148347 "Travel Requests API Test" // [THEN] The API rejects the read-only status, leaving ownership and status unchanged. Assert.ExpectedError(BadRequestResponseErr); - Response.ReadFrom(ResponseText); - Response.Get('error', ErrorResponse); - ErrorResponse.AsObject().Get('code', ErrorCode); - ErrorResponse.AsObject().Get('message', ErrorMessage); - Assert.AreEqual('BadRequest', ErrorCode.AsValue().AsText(), 'The status update must be rejected by the OData read-only guard.'); - Assert.AreNotEqual(0, StrPos(ErrorMessage.AsValue().AsText(), StatusReadOnlyErr), 'The API error must identify the read-only status control.'); - TravelRequest.Get(TravelRequest."No."); + Assert.ExpectedErrorCode('Dialog'); + Assert.ExpectedError('Error code: BadRequest. Error message: ' + StatusReadOnlyErr); + SelectLatestVersion(); + TravelRequest.GetBySystemId(TravelRequestSystemId); Assert.AreEqual( OriginalRequestedBy, TravelRequest."Requested By", 'The Travel Requests API must not change the Travel Request owner.'); @@ -771,6 +770,7 @@ codeunit 148347 "Travel Requests API Test" OtherExpenseUser: Record "Expense User"; TravelRequest: Record "Spend Request"; OriginalRequestedBy: Code[20]; + TravelRequestSystemId: Guid; RequestBody: Text; ResponseText: Text; TargetURL: Text; @@ -783,18 +783,22 @@ codeunit 148347 "Travel Requests API Test" LibraryExpense.CreateExpenseUser(OtherExpenseUser); CreateTravelRequest(TravelRequest, ExpenseUser."Employee No."); OriginalRequestedBy := TravelRequest."Requested By"; + TravelRequestSystemId := TravelRequest.SystemId; Commit(); // [WHEN] PATCH through the legacy endpoint attempts to change the owner. TargetURL := LibraryGraphMgt.CreateTargetURL( Format(TravelRequest.SystemId), Page::"Spend Requests API", SpendRequestsServiceNameTok); RequestBody := StrSubstNo(RequestedByRequestBodyLbl, OtherExpenseUser."Employee No."); + Clear(ResponseText); asserterror LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 400); // [THEN] The owner change is rejected and the original owner is preserved. Assert.ExpectedError(BadRequestResponseErr); - AssertOwnerChangeError(ResponseText, TravelRequest); - TravelRequest.Get(TravelRequest."No."); + Assert.ExpectedErrorCode('Dialog'); + VerifyOwnerChangeError(TravelRequest); + SelectLatestVersion(); + TravelRequest.GetBySystemId(TravelRequestSystemId); Assert.AreEqual( OriginalRequestedBy, TravelRequest."Requested By", 'The legacy Spend Requests API must not change the Travel Request owner.'); @@ -820,7 +824,8 @@ codeunit 148347 "Travel Requests API Test" // [GIVEN] Configured LCY and a foreign currency with a non-unit exchange rate. GeneralLedgerSetup.Get(); GeneralLedgerSetup.TestField("LCY Code"); - ForeignCurrencyCode := LibraryERM.CreateCurrencyWithExchangeRate(Today(), 1, 2); + // The third argument is the adjustment rate; the relational rate is always 1. + ForeignCurrencyCode := LibraryERM.CreateCurrencyWithExchangeRate(Today(), 2, 2); Currency.Get(ForeignCurrencyCode); ForeignExchangeRate := Currency.GetExchangeRate(Today()); Assert.AreNotEqual(GeneralLedgerSetup."LCY Code", ForeignCurrencyCode, 'The fixture must use a foreign currency.'); @@ -840,6 +845,7 @@ codeunit 148347 "Travel Requests API Test" Commit(); // [WHEN] POST explicitly supplies the LCY ISO code. + Clear(ResponseText); LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 201); // [THEN] It is stored as blank and returned as the configured LCY code. @@ -849,6 +855,7 @@ codeunit 148347 "Travel Requests API Test" SelectedCurrencyURL := RecordURL + '?$select=currencyCode' else SelectedCurrencyURL := RecordURL + '&$select=currencyCode'; + Clear(ResponseText); LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, SelectedCurrencyURL, 200); AssertTravelRequestCurrency(ResponseText, SystemId, IsDetail, GeneralLedgerSetup."LCY Code", '', 1); @@ -862,6 +869,7 @@ codeunit 148347 "Travel Requests API Test" Clear(Request); Request.Add(AmountField, 200); Request.WriteTo(RequestBody); + Clear(ResponseText); LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(RecordURL, RequestBody, ResponseText, 200); // [THEN] Neither the foreign currency nor its exchange rate is reset to LCY. @@ -875,16 +883,19 @@ codeunit 148347 "Travel Requests API Test" // [THEN] Both explicit LCY and blank inputs return the canonical ISO code. AssertTravelRequestCurrency(ResponseText, SystemId, IsDetail, GeneralLedgerSetup."LCY Code", '', 1); + Clear(ResponseText); LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, SelectedCurrencyURL, 200); AssertTravelRequestCurrency(ResponseText, SystemId, IsDetail, GeneralLedgerSetup."LCY Code", '', 1); // [WHEN] PATCH supplies an unknown non-LCY code. // [THEN] Currency validation rejects it rather than treating it as local currency. AssertCurrencyPatchError(RecordURL, InvalidCurrencyCode, InvalidCurrencyCode); + Clear(ResponseText); LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, RecordURL, 200); AssertTravelRequestCurrency(ResponseText, SystemId, IsDetail, GeneralLedgerSetup."LCY Code", '', 1); // [GIVEN] The request is no longer Open. + SelectLatestVersion(); if IsDetail then begin TravelRequestDetail.GetBySystemId(SystemId); TravelRequest.Get(TravelRequestDetail."Spend Request No."); @@ -896,6 +907,7 @@ codeunit 148347 "Travel Requests API Test" // [WHEN] PATCH attempts a currency change. // [THEN] The existing Open-status validation rejects it without changing currency. AssertCurrencyPatchError(RecordURL, ForeignCurrencyCode, StatusNotOpenErr); + Clear(ResponseText); LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, RecordURL, 200); AssertTravelRequestCurrency(ResponseText, SystemId, IsDetail, GeneralLedgerSetup."LCY Code", '', 1); end; @@ -907,26 +919,23 @@ codeunit 148347 "Travel Requests API Test" begin Request.Add('currencyCode', CurrencyCode); Request.WriteTo(RequestBody); + Clear(ResponseText); LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 200); end; local procedure AssertCurrencyPatchError(TargetURL: Text; CurrencyCode: Code[10]; ExpectedError: Text) var Request: JsonObject; - Response: JsonObject; - ErrorResponse: JsonToken; - ErrorMessage: JsonToken; RequestBody: Text; ResponseText: Text; begin Request.Add('currencyCode', CurrencyCode); Request.WriteTo(RequestBody); + Clear(ResponseText); asserterror LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 400); Assert.ExpectedError(BadRequestResponseErr); - Response.ReadFrom(ResponseText); - Response.Get('error', ErrorResponse); - ErrorResponse.AsObject().Get('message', ErrorMessage); - Assert.AreNotEqual(0, StrPos(ErrorMessage.AsValue().AsText(), ExpectedError), 'The expected table validation must cause the rejection.'); + Assert.ExpectedErrorCode('Dialog'); + Assert.ExpectedError(ExpectedError); end; local procedure AssertTravelRequestCurrency(ResponseText: Text; SystemId: Guid; IsDetail: Boolean; APICurrencyCode: Code[10]; StoredCurrencyCode: Code[10]; ExchangeRate: Decimal) @@ -939,6 +948,8 @@ codeunit 148347 "Travel Requests API Test" Response.ReadFrom(ResponseText); Response.Get('currencyCode', CurrencyCode); Assert.AreEqual(APICurrencyCode, CurrencyCode.AsValue().AsText(), 'The API must expose the canonical currency code.'); + // HTTP writes run in another session; do not verify a cached record image. + SelectLatestVersion(); if IsDetail then begin TravelRequestDetail.GetBySystemId(SystemId); Assert.AreEqual(StoredCurrencyCode, TravelRequestDetail."Currency Code", 'The detail must store the BC currency representation.'); @@ -968,6 +979,7 @@ codeunit 148347 "Travel Requests API Test" ResponseText: Text; begin TargetURL := LibraryGraphMgt.CreateTargetURL(Format(TravelerSystemId), Page::"Travelers API", TravelersServiceNameTok); + Clear(ResponseText); LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); Response.ReadFrom(ResponseText); AssertTravelerEmployeeNumber(Response, ExpectedEmployeeNo); @@ -995,6 +1007,24 @@ codeunit 148347 "Travel Requests API Test" Assert.AreEqual(EndDate, EndDateToken.AsValue().AsDate(), 'The API must return the effective end date.'); end; + local procedure VerifyTravelRequestDates(ResponseText: Text; RequestSystemId: Guid; StartDate: Date; EndDate: Date) + var + TravelRequest: Record "Spend Request"; + Response: JsonObject; + ResponseId: JsonToken; + ResponseSystemId: Guid; + begin + AssertAPIDates(ResponseText, StartDate, EndDate); + Response.ReadFrom(ResponseText); + Assert.IsTrue(Response.Get('id', ResponseId), 'The travel request response must contain its persisted identity.'); + Evaluate(ResponseSystemId, ResponseId.AsValue().AsText()); + Assert.AreEqual(RequestSystemId, ResponseSystemId, 'The API must preserve the supplied travel request identity.'); + SelectLatestVersion(); + TravelRequest.GetBySystemId(RequestSystemId); + Assert.AreEqual(StartDate, TravelRequest."Expected Start Date", 'The API start date must be persisted.'); + Assert.AreEqual(EndDate, TravelRequest."Expected End Date", 'The API end date must be persisted.'); + end; + local procedure AssertOwnerPreservingPatch(TargetURL: Text; TravelRequest: Record "Spend Request") var Request: JsonObject; @@ -1009,6 +1039,7 @@ codeunit 148347 "Travel Requests API Test" Request.Add('requestedBy', TravelRequest."Requested By"); Request.Add('purpose', ExpectedPurpose); Request.WriteTo(RequestBody); + Clear(ResponseText); LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 200); Response.ReadFrom(ResponseText); @@ -1018,20 +1049,11 @@ codeunit 148347 "Travel Requests API Test" Assert.AreEqual(ExpectedPurpose, Purpose.AsValue().AsText(), 'PATCH must update the purpose when the owner is unchanged.'); end; - local procedure AssertOwnerChangeError(ResponseText: Text; TravelRequest: Record "Spend Request") - var - Response: JsonObject; - ErrorResponse: JsonToken; - ErrorMessage: JsonToken; - MessageText: Text; + local procedure VerifyOwnerChangeError(TravelRequest: Record "Spend Request") begin - Response.ReadFrom(ResponseText); - Response.Get('error', ErrorResponse); - ErrorResponse.AsObject().Get('message', ErrorMessage); - MessageText := ErrorMessage.AsValue().AsText(); - Assert.AreNotEqual(0, StrPos(MessageText, RequestedByCannotBeChangedErr), 'The API must reject the owner change.'); - Assert.AreNotEqual(0, StrPos(MessageText, TravelRequest.FieldCaption("Requested By")), 'The error must identify Requested By.'); - Assert.AreNotEqual(0, StrPos(MessageText, TravelRequest."No."), 'The error must identify the travel request.'); + Assert.ExpectedError(RequestedByCannotBeChangedErr); + Assert.ExpectedError(TravelRequest.FieldCaption("Requested By")); + Assert.ExpectedError(TravelRequest."No."); end; local procedure CreateTravelRequest(var TravelRequest: Record "Spend Request"; EmployeeNo: Code[20]) diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpensePermissionsTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpensePermissionsTest.Codeunit.al index 1ed3faad2b1..aa05633221e 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpensePermissionsTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpensePermissionsTest.Codeunit.al @@ -510,6 +510,17 @@ codeunit 148338 "Expense Permissions Test" ExpenseTeam: Record "Expense Team"; ExpenseApprovalSetup: Record "Expense Approval Setup"; ExpenseReportHeader: Record "Expense Report Header"; + SpendRequestCanRead: Boolean; + SpendRequestDetailCanRead: Boolean; + SpendRequestToGLLinkCanRead: Boolean; + SpendRequestCanWrite: Boolean; + SpendRequestDetailCanWrite: Boolean; + ExpenseUserCanRead: Boolean; + ExpenseReportHeaderCanRead: Boolean; + ExpenseUserCanWrite: Boolean; + ExpenseTeamCanWrite: Boolean; + ExpenseApprovalSetupCanWrite: Boolean; + ExpenseReportHeaderCanWrite: Boolean; begin Initialize(); @@ -518,20 +529,32 @@ codeunit 148338 "Expense Permissions Test" LibraryLowerPermissions.SetExactPermissionSet(PermissionSetId); // [WHEN] The effective table permissions are evaluated. - // [THEN] BaseApp rights are not added to these roles; app-owned rights follow the role level. - Assert.IsFalse(SpendRequest.ReadPermission(), 'The role must not grant direct BaseApp request access.'); - Assert.IsFalse(SpendRequestDetail.ReadPermission(), 'The role must not grant direct BaseApp detail access.'); - Assert.IsFalse(SpendRequestToGLLink.ReadPermission(), 'The role must not grant direct BaseApp ledger-link access.'); - Assert.IsFalse(SpendRequest.WritePermission(), 'The role must not grant direct BaseApp request writes.'); - Assert.IsFalse(SpendRequestDetail.WritePermission(), 'The role must not grant direct BaseApp detail writes.'); - Assert.IsTrue(ExpenseUser.ReadPermission(), 'The role must retain read access to app-owned expense users.'); - Assert.IsTrue(ExpenseReportHeader.ReadPermission(), 'The role must retain read access to app-owned reports.'); - Assert.AreEqual(CanMaintainSetup, ExpenseUser.WritePermission(), 'Expense user write access must follow the role level.'); - Assert.AreEqual(CanMaintainSetup, ExpenseTeam.WritePermission(), 'Expense team write access must follow the role level.'); - Assert.AreEqual(CanMaintainSetup, ExpenseApprovalSetup.WritePermission(), 'Expense approval setup write access must follow the role level.'); - Assert.AreEqual(CanEdit, ExpenseReportHeader.WritePermission(), 'Expense report write access must follow the role level.'); + SpendRequestCanRead := SpendRequest.ReadPermission(); + SpendRequestDetailCanRead := SpendRequestDetail.ReadPermission(); + SpendRequestToGLLinkCanRead := SpendRequestToGLLink.ReadPermission(); + SpendRequestCanWrite := SpendRequest.WritePermission(); + SpendRequestDetailCanWrite := SpendRequestDetail.WritePermission(); + ExpenseUserCanRead := ExpenseUser.ReadPermission(); + ExpenseReportHeaderCanRead := ExpenseReportHeader.ReadPermission(); + ExpenseUserCanWrite := ExpenseUser.WritePermission(); + ExpenseTeamCanWrite := ExpenseTeam.WritePermission(); + ExpenseApprovalSetupCanWrite := ExpenseApprovalSetup.WritePermission(); + ExpenseReportHeaderCanWrite := ExpenseReportHeader.WritePermission(); RestoreFullPermissions(); LibraryLowerPermissions.StopLoggingNAVPermissions(); + + // [THEN] BaseApp rights are not added to these roles; app-owned rights follow the role level. + Assert.IsFalse(SpendRequestCanRead, 'The role must not grant direct BaseApp request access.'); + Assert.IsFalse(SpendRequestDetailCanRead, 'The role must not grant direct BaseApp detail access.'); + Assert.IsFalse(SpendRequestToGLLinkCanRead, 'The role must not grant direct BaseApp ledger-link access.'); + Assert.IsFalse(SpendRequestCanWrite, 'The role must not grant direct BaseApp request writes.'); + Assert.IsFalse(SpendRequestDetailCanWrite, 'The role must not grant direct BaseApp detail writes.'); + Assert.IsTrue(ExpenseUserCanRead, 'The role must retain read access to app-owned expense users.'); + Assert.IsTrue(ExpenseReportHeaderCanRead, 'The role must retain read access to app-owned reports.'); + Assert.AreEqual(CanMaintainSetup, ExpenseUserCanWrite, 'Expense user write access must follow the role level.'); + Assert.AreEqual(CanMaintainSetup, ExpenseTeamCanWrite, 'Expense team write access must follow the role level.'); + Assert.AreEqual(CanMaintainSetup, ExpenseApprovalSetupCanWrite, 'Expense approval setup write access must follow the role level.'); + Assert.AreEqual(CanEdit, ExpenseReportHeaderCanWrite, 'Expense report write access must follow the role level.'); end; local procedure VerifyTravelRequestDetailUpdateIndirectly(PermissionSetId: Code[20]) diff --git a/src/Apps/W1/ExpenseAgent/test/src/SpendRequestTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/SpendRequestTest.Codeunit.al index bd1070a3334..d16b5fad6d5 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/SpendRequestTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/SpendRequestTest.Codeunit.al @@ -8,19 +8,24 @@ using Microsoft.ExpenseAgent; using Microsoft.Finance.GeneralLedger.Preview; using Microsoft.Finance.GeneralLedger.Setup; using Microsoft.Finance.SpendRequest; +using Microsoft.Foundation.AuditCodes; using Microsoft.HumanResources.Employee; +using Microsoft.HumanResources.Setup; using System.Security.AccessControl; codeunit 148339 "Spend Request Test" { Subtype = Test; - TestType = IntegrationTest; + TestType = UnitTest; TestPermissions = Disabled; var Assert: Codeunit "Assert"; LibraryExpense: Codeunit "Library - Expense"; + LibraryERMCountryData: Codeunit "Library - ERM Country Data"; + LibraryHumanResource: Codeunit "Library - Human Resource"; LibraryRandom: Codeunit "Library - Random"; + LibrarySetupStorage: Codeunit "Library - Setup Storage"; LibraryTestInitialize: Codeunit "Library - Test Initialize"; IsInitialized: Boolean; CloseConfirmReply: Boolean; @@ -96,7 +101,6 @@ codeunit 148339 "Spend Request Test" ExpenseUser: Record "Expense User"; Employee: Record Employee; UnlinkedEmployee: Record Employee; - LibraryHumanResource: Codeunit "Library - Human Resource"; begin // [SCENARIO] The API source field filters both linked and unlinked employees without HTTP. Initialize(); @@ -145,7 +149,6 @@ codeunit 148339 "Spend Request Test" SpendRequest: Record "Spend Request"; Employee: Record Employee; Traveler: Record Traveler; - LibraryHumanResource: Codeunit "Library - Human Resource"; begin // [SCENARIO] An employee without an Expense User cannot be added as a traveler. Initialize(); @@ -619,7 +622,7 @@ codeunit 148339 "Spend Request Test" // [SCENARIO] Posted report references prevent deletion even when the net spent amount is zero. Initialize(); - // [GIVEN] A normally posted report with offsetting amounts and a header-level request link. + // [GIVEN] A normally posted zero-amount report with a header-level request link. CreatePostedTravelRequestReport(SpendRequest, PostedExpenseReportHeader, true); PostedExpenseReportHeader.TestField("Spend Request No.", SpendRequest."No."); Commit(); @@ -1961,10 +1964,10 @@ codeunit 148339 "Spend Request Test" // [GIVEN] An expense user whose posting group has an expense account set up. LibraryExpense.CreateExpenseUser(ExpenseUser); Employee.Get(ExpenseUser."Employee No."); - LibraryExpense.UpdateExpenseAccountInEmployeePostingGroup(Employee."Employee Posting Group"); + CreateEmployeePostingSetup(Employee); // [GIVEN] An expense category and a payment method. - LibraryExpense.CreateExpenseCategoryWithSubCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" ", true); + CreateExpenseCategoryWithSubCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" ", true); LibraryExpense.FindExpensePaymentMethod(ExpensePaymentMethod, ExpensePaymentMethod."Reimbursement Type"::"Employee Paid"); // [GIVEN] Two approved spend requests (header and line) with the user as traveler on both. @@ -2058,7 +2061,7 @@ codeunit 148339 "Spend Request Test" // [GIVEN] An open travel request and an active expense category. LibraryExpense.CreateSpendRequest(SpendRequest); - LibraryExpense.CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); + CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); // [GIVEN] A line whose type is Category. LibraryExpense.CreateSpendRequestDetail(SpendRequestDetail, SpendRequest."No.", 0); @@ -2085,7 +2088,7 @@ codeunit 148339 "Spend Request Test" // [GIVEN] An open travel request, an expense category, and a Lump Sum line. LibraryExpense.CreateSpendRequest(SpendRequest); - LibraryExpense.CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); + CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); LibraryExpense.CreateSpendRequestDetail(SpendRequestDetail, SpendRequest."No.", 0); SpendRequestDetail.Validate(Type, SpendRequestDetail.Type::"Lump Sum"); @@ -2109,7 +2112,7 @@ codeunit 148339 "Spend Request Test" // [GIVEN] A Category line with an expense category assigned. LibraryExpense.CreateSpendRequest(SpendRequest); - LibraryExpense.CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); + CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); LibraryExpense.CreateSpendRequestDetail(SpendRequestDetail, SpendRequest."No.", 0); SpendRequestDetail.Validate(Type, SpendRequestDetail.Type::Category); SpendRequestDetail.Validate("Expense Category Code", ExpenseCategory.Code); @@ -2136,7 +2139,7 @@ codeunit 148339 "Spend Request Test" // [GIVEN] An open travel request and an expense category. LibraryExpense.CreateSpendRequest(SpendRequest); - LibraryExpense.CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); + CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); // [GIVEN] A Category line with a category. LibraryExpense.CreateSpendRequestDetail(CategoryLine, SpendRequest."No.", 0); @@ -2329,14 +2332,19 @@ codeunit 148339 "Spend Request Test" local procedure Initialize() var ExpenseApprovalSetup: Record "Expense Approval Setup"; + ExpensePaymentMethod: Record "Expense Payment Method"; GeneralLedgerSetup: Record "General Ledger Setup"; - LibraryERMCountryData: Codeunit "Library - ERM Country Data"; begin LibraryTestInitialize.OnTestInitialize(Codeunit::"Spend Request Test"); + LibrarySetupStorage.Restore(); // Clear approval mappings together with the expense users between tests. ExpenseApprovalSetup.DeleteAll(); LibraryExpense.CleanUpBeforeTesting(); LibraryExpense.CleanTransactionalData(); + // Reimbursement types are unique; reuse only methods created within the current test. + ExpensePaymentMethod.SetFilter( + "Reimbursement Type", '%1|%2', ExpensePaymentMethod."Reimbursement Type"::"Employee Paid", ExpensePaymentMethod."Reimbursement Type"::"Company Paid"); + ExpensePaymentMethod.DeleteAll(); CloseConfirmCount := 0; CloseConfirmReply := false; SpendReqPreviewShown := false; @@ -2351,21 +2359,48 @@ codeunit 148339 "Spend Request Test" exit; LibraryTestInitialize.OnBeforeTestSuiteInitialize(Codeunit::"Spend Request Test"); - LibraryERMCountryData.CreateVATData(); - LibraryERMCountryData.UpdateGeneralPostingSetup(); - LibraryERMCountryData.CreateGeneralPostingSetupData(); - LibraryERMCountryData.UpdatePurchasesPayablesSetup(); - LibraryERMCountryData.UpdateVATPostingSetup(); LibraryERMCountryData.UpdateJournalTemplMandatory(false); LibraryExpense.SetupNumberSeriesInExpenseMgmt(); LibraryExpense.InitializeExpenseSourceCode(); LibraryExpense.UpdateEnableApprovalWorkflowInAgentSetup(false); LibraryExpense.UpdateUseRulesInAgentSetup(false); + LibrarySetupStorage.Save(Database::"General Ledger Setup"); + LibrarySetupStorage.Save(Database::"Human Resources Setup"); + LibrarySetupStorage.Save(Database::"Source Code Setup"); + LibrarySetupStorage.Save(Database::"Expense Agent Setup"); IsInitialized := true; LibraryTestInitialize.OnAfterTestSuiteInitialize(Codeunit::"Spend Request Test"); end; + local procedure CreateEmployeePostingSetup(var Employee: Record Employee) + var + EmployeePostingGroup: Record "Employee Posting Group"; + begin + LibraryHumanResource.CreateEmployeePostingGroup(EmployeePostingGroup); + LibraryExpense.UpdateExpenseAccountInEmployeePostingGroup(EmployeePostingGroup.Code); + Employee.Validate("Employee Posting Group", EmployeePostingGroup.Code); + Employee.Modify(true); + end; + + local procedure CreateExpenseCategory(var ExpenseCategory: Record "Expense Category"; ReimbursementType: Enum "Expense Reimbursement Type"; ExpenseDetailRequired: Enum "Expense Detail Needed") + var + ExpensePostingGroup: Record "Expense Posting Group"; + begin + LibraryExpense.CreateExpensePostingGroup(ExpensePostingGroup); + LibraryExpense.CreateExpenseCategory(ExpenseCategory, ReimbursementType, ExpenseDetailRequired); + ExpenseCategory.Validate("Posting Group", ExpensePostingGroup.Code); + ExpenseCategory.Modify(true); + end; + + local procedure CreateExpenseCategoryWithSubCategory(var ExpenseCategory: Record "Expense Category"; ReimbursementType: Enum "Expense Reimbursement Type"; ExpenseDetailRequired: Enum "Expense Detail Needed"; Refundable: Boolean) + var + ExpenseSubcategory: Record "Expense Subcategory"; + begin + CreateExpenseCategory(ExpenseCategory, ReimbursementType, ExpenseDetailRequired); + LibraryExpense.CreateExpenseSubCategory(ExpenseSubcategory, ExpenseCategory.Code, Refundable); + end; + local procedure CreateExpenseReportWithRefundableLine(var ExpenseReportLine: Record "Expense Report Line"; var ExpenseUser: Record "Expense User"; Refundable: Boolean) var ExpenseCategory: Record "Expense Category"; @@ -2373,7 +2408,7 @@ codeunit 148339 "Spend Request Test" ExpenseReportHeader: Record "Expense Report Header"; begin LibraryExpense.CreateExpenseUser(ExpenseUser); - LibraryExpense.CreateExpenseCategoryWithSubCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" ", true); + CreateExpenseCategoryWithSubCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" ", true); LibraryExpense.FindExpensePaymentMethod(ExpensePaymentMethod, ExpensePaymentMethod."Reimbursement Type"::"Employee Paid"); LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', ''); LibraryExpense.CreateExpenseReportLine(ExpenseReportLine, ExpenseReportHeader, ExpenseUser."No.", ExpenseCategory.Code, ExpensePaymentMethod.Code, Refundable, '', LibraryRandom.RandIntInRange(100, 1000)); @@ -2511,9 +2546,9 @@ codeunit 148339 "Spend Request Test" PostedExpenseReportLine.SetRange("Document No.", PostedExpenseReportHeader."No."); PostedExpenseReportLine.SetRange("Spend Request No.", SpendRequest."No."); PostedExpenseReportLine.SetRange("Expense User No.", SpendRequest."Requested For"); - Assert.RecordCount(PostedExpenseReportLine, 2); + Assert.RecordCount(PostedExpenseReportLine, 1); PostedExpenseReportLine.CalcSums("Amount (LCY)"); - Assert.AreEqual(0, PostedExpenseReportLine."Amount (LCY)", 'Both offsetting posted lines must remain linked to the same request and expense user.'); + Assert.AreEqual(0, PostedExpenseReportLine."Amount (LCY)", 'The posted zero-amount line must remain linked to the same request and expense user.'); end; local procedure VerifyReapprovedTravelRequest(SpendRequest: Record "Spend Request"; PostedExpenseReportHeader: Record "Posted Expense Report Header"; SubmitterExpenseUserNo: Code[20]; ApproverExpenseUserNo: Code[20]) @@ -2532,7 +2567,6 @@ codeunit 148339 "Spend Request Test" var ExpenseReportHeader: Record "Expense Report Header"; ExpenseReportLine: Record "Expense Report Line"; - BalancingExpenseReportLine: Record "Expense Report Line"; ExpenseReportPost: Codeunit "Expense Report-Post"; begin if AssignOnHeader then @@ -2542,21 +2576,17 @@ codeunit 148339 "Spend Request Test" ExpenseReportLine.SetRange("Document No.", ExpenseReportHeader."No."); ExpenseReportLine.FindFirst(); - LibraryExpense.CreateExpenseReportLine( - BalancingExpenseReportLine, ExpenseReportHeader, ExpenseReportHeader."Expense User No.", - ExpenseReportLine."Expense Category", ExpenseReportLine."Payment Method Code", true, - ExpenseReportLine."Expense Currency Code", -ExpenseReportLine.Amount); - if not AssignOnHeader then begin - BalancingExpenseReportLine.Validate("Spend Request No.", SpendRequest."No."); - BalancingExpenseReportLine.Modify(true); - end; + // Negative non-correction entries do not reverse recorded spend. + // Normal posting of a zero-amount line still creates genuine posted history. + ExpenseReportLine.Validate(Amount, 0); + ExpenseReportLine.Modify(true); ExpenseReportHeader.PerformManualRelease(); ExpenseReportPost.PostExpenseReport(ExpenseReportHeader); PostedExpenseReportHeader.Get(ExpenseReportHeader."Last Posting No."); SpendRequest.Get(SpendRequest."No."); SpendRequest.CalcFields("Total Spent Amount (LCY)"); - Assert.AreEqual(0, SpendRequest."Total Spent Amount (LCY)", 'Offsetting posted amounts must leave zero net spend.'); + Assert.AreEqual(0, SpendRequest."Total Spent Amount (LCY)", 'The posted zero-amount report must leave zero net spend.'); end; local procedure CreateAndPostExpenseReportWithSpendRequest(var ExpenseReportHeader: Record "Expense Report Header"; var SpendRequest: Record "Spend Request"; NumberOfLines: Integer) @@ -2570,9 +2600,9 @@ codeunit 148339 "Spend Request Test" begin CreateReleasableSpendRequest(SpendRequest, ExpenseUser); Employee.Get(ExpenseUser."Employee No."); - LibraryExpense.UpdateExpenseAccountInEmployeePostingGroup(Employee."Employee Posting Group"); + CreateEmployeePostingSetup(Employee); - LibraryExpense.CreateExpenseCategoryWithSubCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" ", true); + CreateExpenseCategoryWithSubCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" ", true); LibraryExpense.FindExpensePaymentMethod(ExpensePaymentMethod, ExpensePaymentMethod."Reimbursement Type"::"Employee Paid"); LibraryExpense.CreateSpendRequestDetail(SpendRequest."No.", LibraryRandom.RandIntInRange(100000, 100000)); @@ -2597,9 +2627,9 @@ codeunit 148339 "Spend Request Test" begin CreateReleasableSpendRequest(SpendRequest, ExpenseUser); Employee.Get(ExpenseUser."Employee No."); - LibraryExpense.UpdateExpenseAccountInEmployeePostingGroup(Employee."Employee Posting Group"); + CreateEmployeePostingSetup(Employee); - LibraryExpense.CreateExpenseCategoryWithSubCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" ", true); + CreateExpenseCategoryWithSubCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" ", true); LibraryExpense.FindExpensePaymentMethod(ExpensePaymentMethod, ExpensePaymentMethod."Reimbursement Type"::"Employee Paid"); LibraryExpense.CreateSpendRequestDetail(SpendRequest."No.", LibraryRandom.RandIntInRange(100000, 100000)); @@ -2625,10 +2655,10 @@ codeunit 148339 "Spend Request Test" // An expense user whose posting group has an expense account set up. LibraryExpense.CreateExpenseUser(ExpenseUser); Employee.Get(ExpenseUser."Employee No."); - LibraryExpense.UpdateExpenseAccountInEmployeePostingGroup(Employee."Employee Posting Group"); + CreateEmployeePostingSetup(Employee); // A refundable category and a payment method. - LibraryExpense.CreateExpenseCategoryWithSubCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" ", true); + CreateExpenseCategoryWithSubCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" ", true); LibraryExpense.FindExpensePaymentMethod(ExpensePaymentMethod, ExpensePaymentMethod."Reimbursement Type"::"Employee Paid"); // An approved spend request with the user as a traveler. @@ -2650,12 +2680,8 @@ codeunit 148339 "Spend Request Test" NonRefundableCategory: Record "Expense Category"; ExpensePaymentMethod: Record "Expense Payment Method"; ExpenseReportLine: Record "Expense Report Line"; - ExpensePostingGroup: Record "Expense Posting Group"; begin - LibraryExpense.CreateExpensePostingGroup(ExpensePostingGroup); - LibraryExpense.CreateExpenseCategoryWithSubCategory(NonRefundableCategory, NonRefundableCategory."Reimbursement Type"::"Company Paid", NonRefundableCategory."Expense Detail Required"::" ", false); - NonRefundableCategory.Validate("Posting Group", ExpensePostingGroup.Code); - NonRefundableCategory.Modify(true); + CreateExpenseCategoryWithSubCategory(NonRefundableCategory, NonRefundableCategory."Reimbursement Type"::"Company Paid", NonRefundableCategory."Expense Detail Required"::" ", false); LibraryExpense.FindExpensePaymentMethod(ExpensePaymentMethod, ExpensePaymentMethod."Reimbursement Type"::"Company Paid"); LibraryExpense.CreateExpenseReportLine(ExpenseReportLine, ExpenseReportHeader, ExpenseUserNo, NonRefundableCategory.Code, ExpensePaymentMethod.Code, false, '', LibraryRandom.RandIntInRange(100, 1000)); end; diff --git a/src/DisabledTests/Expense_Agent_Tests/Expense_Agent_Tests.DisabledTest.json b/src/DisabledTests/Expense_Agent_Tests/Expense_Agent_Tests.DisabledTest.json index b76af3e4384..75e9eceba14 100644 --- a/src/DisabledTests/Expense_Agent_Tests/Expense_Agent_Tests.DisabledTest.json +++ b/src/DisabledTests/Expense_Agent_Tests/Expense_Agent_Tests.DisabledTest.json @@ -293,86 +293,5 @@ "codeunitId": 148348, "codeunitName": "Expense VAT Spec. API Test", "method": "AgentVATSpecificationIsInsertedThroughAPI" - }, - { - "bugId": 650277, - "codeunitId": 148339, - "codeunitName": "Spend Request Test", - "method": "DeleteTravelRequestWithPostedReportIsBlocked" - }, - { - "bugId": 650277, - "codeunitId": 148339, - "codeunitName": "Spend Request Test", - "method": "DeleteTravelRequestWithPostedLineIsBlocked" - }, - { - "bugId": 650277, - "codeunitId": 148339, - "codeunitName": "Spend Request Test", - "method": "CreateExpenseReportRejectsPostedHeader" - }, - { - "bugId": 650277, - "codeunitId": 148339, - "codeunitName": "Spend Request Test", - "method": "CreateExpenseReportRejectsPostedLine" - }, - { - "bugId": 650277, - "codeunitId": 148339, - "codeunitName": "Spend Request Test", - "method": "CreateExpenseReportAllowsOtherPostedTraveler" - }, - { - "bugId": 650277, - "codeunitId": 148339, - "codeunitName": "Spend Request Test", - "method": "ReapproveTravelRequestWithPostedHeader" - }, - { - "bugId": 650277, - "codeunitId": 148339, - "codeunitName": "Spend Request Test", - "method": "ReapproveTravelRequestWithPostedLine" - }, - { - "bugId": 650277, - "codeunitId": 148339, - "codeunitName": "Spend Request Test", - "method": "AutoReapproveTravelRequestWithPostedHistory" - }, - { - "codeunitId": 148339, - "codeunitName": "Spend Request Test", - "method": "ApproveTravelRequestPageAction" - }, - { - "codeunitId": 148339, - "codeunitName": "Spend Request Test", - "method": "SubmitTravelRequestPageAction" - }, - { - "codeunitId": 148339, - "codeunitName": "Spend Request Test", - "method": "RejectTravelRequestPageAction" - }, - { - "bugId": 650245, - "codeunitId": 148338, - "codeunitName": "Expense Permissions Test", - "method": "ExpenseMgmtReadRetainsAppPermissions" - }, - { - "bugId": 650245, - "codeunitId": 148338, - "codeunitName": "Expense Permissions Test", - "method": "ExpenseMgmtEditRetainsAppPermissions" - }, - { - "bugId": 650245, - "codeunitId": 148338, - "codeunitName": "Expense Permissions Test", - "method": "ExpenseMgmtAdminRetainsAppPermissions" } ]