diff --git a/.github/AL-Go-Settings.json b/.github/AL-Go-Settings.json index 5b6b1b2721c..e805b45f586 100644 --- a/.github/AL-Go-Settings.json +++ b/.github/AL-Go-Settings.json @@ -15,6 +15,7 @@ "repoVersion": "30.0", "memoryLimit": "16G", "numberOfTenantsForTesting": 4, + "enableCleanTestCodeunitExecution": true, "maxTestAppReruns": 2, "failOn": "newWarning", "workspaceCompilation": { diff --git a/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Codeunits/TravelRequestApproval.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Codeunits/TravelRequestApproval.Codeunit.al index b8ee5762dc7..8b290df2331 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Codeunits/TravelRequestApproval.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Codeunits/TravelRequestApproval.Codeunit.al @@ -6,7 +6,6 @@ namespace Microsoft.ExpenseAgent; using Microsoft.Finance.SpendRequest; using System.Telemetry; -using System.Text; codeunit 7133 "Travel Request Approval" { @@ -318,13 +317,17 @@ codeunit 7133 "Travel Request Approval" var ExpenseApprovalSetup: Record "Expense Approval Setup"; ExpenseAgentSetup: Record "Expense Agent Setup"; - SelectionFilterManagement: Codeunit SelectionFilterManagement; - RecRef: RecordRef; + AssignedFilter: TextBuilder; begin ExpenseApprovalSetup.SetCurrentKey("Approver No."); ExpenseApprovalSetup.SetRange("Approver No.", ApproverExpenseUserNo); - RecRef.GetTable(ExpenseApprovalSetup); - RequestedForFilter := SelectionFilterManagement.GetSelectionFilter(RecRef, ExpenseApprovalSetup.FieldNo("Expense User No.")); + ExpenseApprovalSetup.SetLoadFields("Expense User No."); + // Ranges between setup rows could include expense users without any approval setup. + if ExpenseApprovalSetup.FindSet() then + repeat + AppendSubmitterFilter(AssignedFilter, ExpenseApprovalSetup."Expense User No."); + until ExpenseApprovalSetup.Next() = 0; + RequestedForFilter := AssignedFilter.ToText(); ExpenseAgentSetup.GetRecordOnce(); if ExpenseAgentSetup."Default Approver No." = ApproverExpenseUserNo then @@ -337,7 +340,6 @@ codeunit 7133 "Travel Request Approval" local procedure AppendDefaultSubmitters(var RequestedForFilter: Text) var ExpenseUser: Record "Expense User"; - SelectionFilterManagement: Codeunit SelectionFilterManagement; DefaultFilter: TextBuilder; begin if RequestedForFilter <> '' then @@ -348,14 +350,23 @@ codeunit 7133 "Travel Request Approval" ExpenseUser.SetLoadFields("No."); if ExpenseUser.FindSet() then repeat - if DefaultFilter.Length > 0 then - DefaultFilter.Append('|'); - DefaultFilter.Append(SelectionFilterManagement.AddQuotes(ExpenseUser."No.")); + AppendSubmitterFilter(DefaultFilter, ExpenseUser."No."); until ExpenseUser.Next() = 0; RequestedForFilter := DefaultFilter.ToText(); end; + local procedure AppendSubmitterFilter(var SubmitterFilter: TextBuilder; ExpenseUserNo: Code[20]) + var + ExpenseUserFilter: Record "Expense User"; + begin + // Serialize an exact value so filter operators in user numbers remain literal. + ExpenseUserFilter.SetRange("No.", ExpenseUserNo); + if SubmitterFilter.Length > 0 then + SubmitterFilter.Append('|'); + SubmitterFilter.Append(ExpenseUserFilter.GetFilter("No.")); + end; + var FeatureTelemetry: Codeunit "Feature Telemetry"; ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt."; 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 86303ef065c..ec9b195a627 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseActivityLogAPITest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseActivityLogAPITest.Codeunit.al @@ -23,7 +23,6 @@ codeunit 148343 "Expense Activity Log API Test" LibraryGraphMgt: Codeunit "Library - Graph Mgt"; LibraryTestInitialize: Codeunit "Library - Test Initialize"; LibrarySetupStorage: Codeunit "Library - Setup Storage"; - APITestAuthHelper: Codeunit "Expense API Test Auth Helper"; IsInitialized: Boolean; ServiceNameTok: Label 'expenseActivityLogEntries', Locked = true; ExpenseReportsServiceNameTok: Label 'expenseReports', Locked = true; @@ -477,11 +476,14 @@ codeunit 148343 "Expense Activity Log API Test" RequestBody.WriteTo(RequestText); ReportURL := LibraryGraphMgt.CreateTargetURLWithSubpage( Format(SubmitterExpenseUser.SystemId), Page::"Expense Users API", ExpenseUsersServiceNameTok, ExpenseReportsServiceNameTok); - ReportURL += '(' + LibraryGraphMgt.StripBrackets(Format(ExpenseReportHeader.SystemId)) + ')'; + ReportURL := LibraryGraphMgt.AppendPathToTargetURL( + ReportURL, '(' + LibraryGraphMgt.StripBrackets(Format(ExpenseReportHeader.SystemId)) + ')'); Commit(); // [WHEN] The standard submission action is requested through the submitting user. - LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(ReportURL + '/' + SubmitWithCommentActionTok, RequestText, ResponseText, 200); + Clear(ResponseText); + LibraryGraphMgt.PostToWebServiceAndCheckResponseCode( + LibraryGraphMgt.AppendPathToTargetURL(ReportURL, '/' + SubmitWithCommentActionTok), RequestText, ResponseText, 200); // [THEN] Exactly one structured snapshot is visible without a new callback or capability. ExpenseActivityLogEntry.SetRange("Subject System ID", ExpenseReportHeader.SystemId); @@ -489,7 +491,8 @@ codeunit 148343 "Expense Activity Log API Test" Assert.RecordCount(ExpenseActivityLogEntry, 1); ExpenseActivityLogEntry.FindFirst(); Clear(ResponseText); - LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, ReportURL + '/' + ServiceNameTok, 200); + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode( + ResponseText, LibraryGraphMgt.AppendPathToTargetURL(ReportURL, '/' + ServiceNameTok), 200); VerifyPolicyCategoryCode(ResponseText, ExpenseCategory.Code); ResponseText := LowerCase(ResponseText); Assert.AreNotEqual(0, StrPos(ResponseText, '"eventtype":"policyevaluated"'), 'The event type must identify the policy snapshot.'); @@ -503,7 +506,8 @@ codeunit 148343 "Expense Activity Log API Test" HistoryURL := LibraryGraphMgt.CreateTargetURLWithSubpage( Format(SubmitterExpenseUser.SystemId), Page::"Expense Users API", ExpenseUsersServiceNameTok, ServiceNameTok); Clear(ResponseText); - LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, HistoryURL + '?$filter=historyActorRole eq ''Submitter''', 200); + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode( + ResponseText, LibraryGraphMgt.AppendQueryParameterToTargetURL(HistoryURL, '$filter=historyActorRole eq ''Submitter'''), 200); // [THEN] The real submitter sees the agent snapshot in the complete timeline. Assert.AreNotEqual(0, StrPos(LowerCase(ResponseText), LowerCase(LibraryGraphMgt.StripBrackets(Format(ExpenseActivityLogEntry.SystemId)))), 'The submitter timeline must contain the snapshot.'); @@ -512,7 +516,8 @@ codeunit 148343 "Expense Activity Log API Test" HistoryURL := LibraryGraphMgt.CreateTargetURLWithSubpage( Format(ApproverExpenseUser.SystemId), Page::"Expense Users API", ExpenseUsersServiceNameTok, ServiceNameTok); Clear(ResponseText); - LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, HistoryURL + '?$filter=historyActorRole eq ''Approver''', 200); + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode( + ResponseText, LibraryGraphMgt.AppendQueryParameterToTargetURL(HistoryURL, '$filter=historyActorRole eq ''Approver'''), 200); // [THEN] The agent event must not grant approver participation. Assert.AreEqual(0, StrPos(LowerCase(ResponseText), LowerCase(LibraryGraphMgt.StripBrackets(Format(ExpenseReportHeader.SystemId)))), 'The agent snapshot must not grant approver history access.'); @@ -804,7 +809,6 @@ codeunit 148343 "Expense Activity Log API Test" if IsInitialized then exit; - BindSubscription(APITestAuthHelper); LibraryTestInitialize.OnBeforeTestSuiteInitialize(Codeunit::"Expense Activity Log API Test"); LibraryERMCountryData.UpdateGeneralLedgerSetup(); if not ExpenseAgentSetup.Get() then begin diff --git a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseCapabilitiesAPITest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseCapabilitiesAPITest.Codeunit.al index 5d7ceb83f2d..822bf798e1f 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseCapabilitiesAPITest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseCapabilitiesAPITest.Codeunit.al @@ -19,7 +19,6 @@ codeunit 148318 "Expense Capabilities API Test" LibraryExpenseAgent: Codeunit "Library - Expense Agent"; LibraryGraphMgt: Codeunit "Library - Graph Mgt"; LibraryTestInitialize: Codeunit "Library - Test Initialize"; - APITestAuthHelper: Codeunit "Expense API Test Auth Helper"; IsInitialized: Boolean; ServiceNameTok: Label 'expenseCapabilities', Locked = true; ActivityLogCapabilityNameTok: Label 'activityLog', Locked = true; @@ -105,8 +104,6 @@ codeunit 148318 "Expense Capabilities API Test" TargetURL: Text; ResponseText: Text; begin - // Excluded in BCApps CI until authenticated OData execution in a dedicated test company - // with disabled isolation is available; then remove this method's DisabledTest.json entry. // [SCENARIO] Travel requests are advertised when the supporting APIs are installed. Initialize(); @@ -249,7 +246,6 @@ codeunit 148318 "Expense Capabilities API Test" if IsInitialized then exit; - BindSubscription(APITestAuthHelper); LibraryTestInitialize.OnBeforeTestSuiteInitialize(Codeunit::"Expense Capabilities API Test"); IsInitialized := true; Commit(); diff --git a/src/Apps/W1/ExpenseAgent/test/src/API/ExpensePerDiemLocationsTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/API/ExpensePerDiemLocationsTest.Codeunit.al index bea8a98efa6..ac41f8d1005 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/API/ExpensePerDiemLocationsTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/API/ExpensePerDiemLocationsTest.Codeunit.al @@ -18,7 +18,6 @@ codeunit 148349 "Expense PerDiem Locations Test" LibraryUtility: Codeunit "Library - Utility"; LibraryGraphMgt: Codeunit "Library - Graph Mgt"; LibraryTestInitialize: Codeunit "Library - Test Initialize"; - APITestAuthHelper: Codeunit "Expense API Test Auth Helper"; IsInitialized: Boolean; [Test] @@ -83,11 +82,11 @@ codeunit 148349 "Expense PerDiem Locations Test" local procedure Initialize() begin + LibraryGraphMgt.SetLicenseSafeWorkDate(); LibraryTestInitialize.OnTestInitialize(Codeunit::"Expense PerDiem Locations Test"); if IsInitialized then exit; - BindSubscription(APITestAuthHelper); LibraryTestInitialize.OnBeforeTestSuiteInitialize(Codeunit::"Expense PerDiem Locations Test"); IsInitialized := true; Commit(); diff --git a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseProjectsAPITest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseProjectsAPITest.Codeunit.al index 632c3d77459..93d563eb35d 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseProjectsAPITest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseProjectsAPITest.Codeunit.al @@ -25,7 +25,6 @@ codeunit 148331 "Expense Projects API Test" LibraryHumanResource: Codeunit "Library - Human Resource"; LibraryGraphMgt: Codeunit "Library - Graph Mgt"; LibraryTestInitialize: Codeunit "Library - Test Initialize"; - APITestAuthHelper: Codeunit "Expense API Test Auth Helper"; IsInitialized: Boolean; ExpenseUsersServiceNameTok: Label 'expenseUsers', Locked = true; ProjectsServiceNameTok: Label 'expenseProjects', Locked = true; @@ -724,7 +723,6 @@ codeunit 148331 "Expense Projects API Test" if IsInitialized then exit; - BindSubscription(APITestAuthHelper); LibraryTestInitialize.OnBeforeTestSuiteInitialize(Codeunit::"Expense Projects API Test"); if not ExpenseAgentSetup.Get() then begin ExpenseAgentSetup.Init(); diff --git a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseUsersAPITest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseUsersAPITest.Codeunit.al index 00fafa3e091..7ed4334beac 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseUsersAPITest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseUsersAPITest.Codeunit.al @@ -22,7 +22,6 @@ codeunit 148315 "Expense Users API Test" LibraryUtility: Codeunit "Library - Utility"; LibraryGraphMgt: Codeunit "Library - Graph Mgt"; LibraryTestInitialize: Codeunit "Library - Test Initialize"; - APITestAuthHelper: Codeunit "Expense API Test Auth Helper"; IsInitialized: Boolean; ServiceNameTok: Label 'expenseUsers', Locked = true; EmployeesServiceNameTok: Label 'employees', Locked = true; @@ -126,7 +125,6 @@ codeunit 148315 "Expense Users API Test" if IsInitialized then exit; - BindSubscription(APITestAuthHelper); LibraryTestInitialize.OnBeforeTestSuiteInitialize(Codeunit::"Expense Users API Test"); if not ExpenseAgentSetup.Get() then begin ExpenseAgentSetup.Init(); diff --git a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseVATSpecAPITest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseVATSpecAPITest.Codeunit.al index 8a57c7495e5..d036cc53c87 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseVATSpecAPITest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseVATSpecAPITest.Codeunit.al @@ -12,6 +12,7 @@ codeunit 148348 "Expense VAT Spec. API Test" Subtype = Test; TestHttpRequestPolicy = AllowAllOutboundRequests; TestType = IntegrationTest; + RequiredTestIsolation = Disabled; TestPermissions = Disabled; var @@ -20,7 +21,6 @@ codeunit 148348 "Expense VAT Spec. API Test" LibraryERM: Codeunit "Library - ERM"; LibraryGraphMgt: Codeunit "Library - Graph Mgt"; LibraryTestInitialize: Codeunit "Library - Test Initialize"; - APITestAuthHelper: Codeunit "Expense API Test Auth Helper"; IsInitialized: Boolean; ServiceNameTok: Label 'expenseVATSpecifications', Locked = true; @@ -90,7 +90,6 @@ codeunit 148348 "Expense VAT Spec. API Test" if IsInitialized then exit; - BindSubscription(APITestAuthHelper); LibraryTestInitialize.OnBeforeTestSuiteInitialize(Codeunit::"Expense VAT Spec. API Test"); LibraryExpense.SetupNumberSeriesInExpenseMgmt(); LibraryERM.CreateVATBusinessPostingGroup(VATBusinessPostingGroup); diff --git a/src/Apps/W1/ExpenseAgent/test/src/API/PoliciesToEvaluateAPITest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/API/PoliciesToEvaluateAPITest.Codeunit.al index 2824ca06a1a..36726ffbccd 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/API/PoliciesToEvaluateAPITest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/API/PoliciesToEvaluateAPITest.Codeunit.al @@ -19,7 +19,6 @@ codeunit 148345 "Policies To Evaluate API Test" LibraryExpense: Codeunit "Library - Expense"; LibraryGraphMgt: Codeunit "Library - Graph Mgt"; LibraryTestInitialize: Codeunit "Library - Test Initialize"; - APITestAuthHelper: Codeunit "Expense API Test Auth Helper"; IsInitialized: Boolean; ExpenseReportLinesServiceNameTok: Label 'expenseReportLines', Locked = true; @@ -118,7 +117,6 @@ codeunit 148345 "Policies To Evaluate API Test" if IsInitialized then exit; - BindSubscription(APITestAuthHelper); LibraryTestInitialize.OnBeforeTestSuiteInitialize(Codeunit::"Policies To Evaluate API Test"); LibraryERMCountryData.CreateVATData(); LibraryERMCountryData.UpdateGeneralPostingSetup(); diff --git a/src/Apps/W1/ExpenseAgent/test/src/API/PolicyEvaluationsAPITest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/API/PolicyEvaluationsAPITest.Codeunit.al index bdc039bde58..f2f7e47f2b7 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/API/PolicyEvaluationsAPITest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/API/PolicyEvaluationsAPITest.Codeunit.al @@ -19,7 +19,6 @@ codeunit 148344 "Policy Evaluations API Test" LibraryExpense: Codeunit "Library - Expense"; LibraryGraphMgt: Codeunit "Library - Graph Mgt"; LibraryTestInitialize: Codeunit "Library - Test Initialize"; - APITestAuthHelper: Codeunit "Expense API Test Auth Helper"; IsInitialized: Boolean; ServiceNameTok: Label 'expensePolicyEvaluations', Locked = true; BadRequestResponseErr: Label 'Response code is 400', Locked = true; @@ -122,7 +121,6 @@ codeunit 148344 "Policy Evaluations API Test" if IsInitialized then exit; - BindSubscription(APITestAuthHelper); LibraryTestInitialize.OnBeforeTestSuiteInitialize(Codeunit::"Policy Evaluations API Test"); LibraryERMCountryData.CreateVATData(); LibraryERMCountryData.UpdateGeneralPostingSetup(); 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 7b802b24cb5..5d845c9a9e3 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/API/TravelRequestsAPITest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/API/TravelRequestsAPITest.Codeunit.al @@ -10,12 +10,6 @@ using Microsoft.Finance.GeneralLedger.Setup; using Microsoft.Finance.SpendRequest; using Microsoft.HumanResources.Employee; -// 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. codeunit 148347 "Travel Requests API Test" { Subtype = Test; @@ -32,7 +26,6 @@ codeunit 148347 "Travel Requests API Test" LibraryHumanResource: Codeunit "Library - Human Resource"; LibraryRandom: Codeunit "Library - Random"; LibraryTestInitialize: Codeunit "Library - Test Initialize"; - APITestAuthHelper: Codeunit "Expense API Test Auth Helper"; IsInitialized: Boolean; ExpenseUsersServiceNameTok: Label 'expenseUsers', Locked = true; #if not CLEAN30 @@ -1292,7 +1285,6 @@ codeunit 148347 "Travel Requests API Test" if IsInitialized then exit; - BindSubscription(APITestAuthHelper); LibraryTestInitialize.OnBeforeTestSuiteInitialize(Codeunit::"Travel Requests API Test"); if not ExpenseAgentSetup.Get() then begin ExpenseAgentSetup.Init(); diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpensePermissionsTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpensePermissionsTest.Codeunit.al index 16d7e7ba931..8184e201491 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpensePermissionsTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpensePermissionsTest.Codeunit.al @@ -32,6 +32,9 @@ codeunit 148338 "Expense Permissions Test" AutomationPermissionSetTok: Label 'Exp. Auto Test', Locked = true; D365BasicPermissionSetTok: Label 'D365 BASIC', Locked = true; ExpenseAgentPermissionSetTok: Label 'Expense Agent', Locked = true; + DetailOnlyPermissionSetTok: Label 'Exp. Detail Test', Locked = true; + HeaderModifyPermissionErr: Label 'TableData %1 %2 IndirectModify', Comment = '%1 = Spend Request table ID, %2 = Spend Request table caption', Locked = true; + RequestMustBeOpenErr: Label 'The %1 %2 must have the status %3.', Comment = '%1 = document type description, %2 = document number, %3 = Open status'; ExpenseAgentAppIdTok: Label '66efe10c-8033-403b-a86d-77c0887178ba', Locked = true; ExpenseMgmtAdminPermissionSetTok: Label 'Expense Mgmt. Admin', Locked = true; SecurityPermissionSetTok: Label 'SECURITY', Locked = true; @@ -63,15 +66,100 @@ codeunit 148338 "Expense Permissions Test" [Test] procedure D365BasicCanUpdateTravelRequestDetailsIndirectly() begin + // [SCENARIO 646383] D365 BASIC applies successive detail amount deltas without direct header or detail writes. + Initialize(); VerifyTravelRequestDetailUpdateIndirectly(D365BasicPermissionSetTok); end; [Test] procedure ExpenseAgentCanUpdateTravelRequestDetailsIndirectly() begin + // [SCENARIO 646383] Expense Agent applies successive detail amount deltas without direct header or detail writes. + Initialize(); VerifyTravelRequestDetailUpdateIndirectly(ExpenseAgentPermissionSetTok); end; + [Test] + procedure DetailAmountUpdateFailsWithoutHeaderModifyPermission() + var + SpendRequest: Record "Spend Request"; + SpendRequestDetail: Record "Spend Request Detail"; + TravelRequestSubform: TestPage "Travel Request Subform"; + SpendRequestCanWrite: Boolean; + SpendRequestDetailCanWrite: Boolean; + PermissionErrorCode: Text; + PermissionErrorText: Text; + begin + // [SCENARIO 646383] A caller with only header read and indirect detail modify cannot update the header through an amount change. + Initialize(); + + // [GIVEN] An open request "R" with a committed detail "D" worth 10. + LibraryExpense.CreateSpendRequest(SpendRequest); + LibraryExpense.CreateSpendRequestDetail(SpendRequestDetail, SpendRequest."No.", 10); + VerifyTravelRequestAmounts(SpendRequest, SpendRequestDetail, 10); + Commit(); + LibraryLowerPermissions.StartLoggingNAVPermissions(); + LibraryLowerPermissions.SetExactPermissionSet(DetailOnlyPermissionSetTok); + SpendRequestCanWrite := SpendRequest.WritePermission(); + SpendRequestDetailCanWrite := SpendRequestDetail.WritePermission(); + TravelRequestSubform.OpenEdit(); + TravelRequestSubform.GoToRecord(SpendRequestDetail); + + // [WHEN] The caller changes the amount through the public page. + asserterror TravelRequestSubform.Amount.SetValue(20); + PermissionErrorCode := GetLastErrorCode(); + PermissionErrorText := GetLastErrorText(); + TravelRequestSubform.Close(); + RestoreFullPermissions(); + LibraryLowerPermissions.StopLoggingNAVPermissions(); + + // [THEN] The page reports the specific header modify denial and neither record changes. + VerifyCapturedPageError(PermissionErrorCode, PermissionErrorText, + StrSubstNo(HeaderModifyPermissionErr, Database::"Spend Request", SpendRequest.TableCaption())); + Assert.IsFalse(SpendRequestCanWrite, 'The caller must not have direct request write permission.'); + Assert.IsFalse(SpendRequestDetailCanWrite, 'The caller must not have direct detail write permission.'); + VerifyTravelRequestAmounts(SpendRequest, SpendRequestDetail, 10); + end; + + [Test] + procedure ReleasedTravelRequestDetailAmountCannotBeUpdatedIndirectly() + var + SpendRequest: Record "Spend Request"; + SpendRequestDetail: Record "Spend Request Detail"; + TravelRequestSubform: TestPage "Travel Request Subform"; + ValidationErrorCode: Text; + ValidationErrorText: Text; + begin + // [SCENARIO 646383] Indirect header modification does not bypass the Open status guard. + Initialize(); + + // [GIVEN] A released request "R" with a committed detail "D" worth 10. + LibraryExpense.CreateSpendRequest(SpendRequest); + LibraryExpense.CreateSpendRequestDetail(SpendRequestDetail, SpendRequest."No.", 10); + VerifyTravelRequestAmounts(SpendRequest, SpendRequestDetail, 10); + LibraryExpense.SetSpendRequestStatus(SpendRequest, SpendRequest.Status::Released); + Commit(); + LibraryLowerPermissions.StartLoggingNAVPermissions(); + LibraryLowerPermissions.SetExactPermissionSet(ExpenseAgentPermissionSetTok); + TravelRequestSubform.OpenEdit(); + TravelRequestSubform.GoToRecord(SpendRequestDetail); + + // [WHEN] The caller changes the amount through the public page. + asserterror TravelRequestSubform.Amount.SetValue(20); + ValidationErrorCode := GetLastErrorCode(); + ValidationErrorText := GetLastErrorText(); + TravelRequestSubform.Close(); + RestoreFullPermissions(); + LibraryLowerPermissions.StopLoggingNAVPermissions(); + + // [THEN] The status guard rejects the change and the released request retains both amounts. + VerifyCapturedPageError(ValidationErrorCode, ValidationErrorText, + StrSubstNo(RequestMustBeOpenErr, SpendRequest.GetDocumentTypeDescription(), SpendRequest."No.", SpendRequest.Status::Open)); + VerifyTravelRequestAmounts(SpendRequest, SpendRequestDetail, 10); + SpendRequest.Get(SpendRequest."No."); + Assert.AreEqual(SpendRequest.Status::Released, SpendRequest.Status, 'The denied amount change must preserve the Released status.'); + end; + [Test] procedure D365BasicCanInsertActivityIndirectly() begin @@ -190,20 +278,29 @@ codeunit 148338 "Expense Permissions Test" ExpenseUser: Record "Expense User"; Approver: Record "Expense User"; TravelRequestApproval: Codeunit "Travel Request Approval"; + ExpenseUserCanRead: Boolean; + PermissionErrorCode: Text; + PermissionErrorText: Text; begin // [SCENARIO] An employee-only caller cannot approve requests without access to Expense User data. Initialize(); CreateTravelRequestApprovalScenario(SpendRequest, ExpenseUser, Approver); + // Preserve the fixture for the post-denial checks when asserterror rolls back. Commit(); LibraryLowerPermissions.StartLoggingNAVPermissions(); - SetCallerPermissions(EmployeeOnlyPermissionSetTok, ExpenseUser); + LibraryLowerPermissions.SetExactPermissionSet(EmployeeOnlyPermissionSetTok); + ExpenseUserCanRead := ExpenseUser.ReadPermission(); asserterror TravelRequestApproval.Approve(SpendRequest, Approver."No."); - Assert.ExpectedErrorCode('DB:ClientReadDenied'); - Assert.ExpectedError(ExpenseUser.TableCaption()); + // Capture the denial before permission cleanup can change the last-error state. + PermissionErrorCode := GetLastErrorCode(); + PermissionErrorText := GetLastErrorText(); RestoreFullPermissions(); LibraryLowerPermissions.StopLoggingNAVPermissions(); + Assert.AreEqual('DB:ClientReadDenied', PermissionErrorCode, 'Approval must fail because Expense User read access is denied.'); + Assert.ExpectedMessage(ExpenseUser.TableCaption(), PermissionErrorText); + Assert.IsFalse(ExpenseUserCanRead, 'The caller must not have direct access to Expense User.'); SpendRequest.Get(SpendRequest."No."); Assert.AreEqual(SpendRequest.Status::Released, SpendRequest.Status, 'A denied approval must preserve the request status.'); ExpenseReportHeader.SetRange("Spend Request No.", SpendRequest."No."); @@ -614,8 +711,7 @@ codeunit 148338 "Expense Permissions Test" SpendRequestDetail: Record "Spend Request Detail"; TravelRequestSubform: TestPage "Travel Request Subform"; begin - // [SCENARIO] Editing a detail through its page can update both the line and its header total. - Initialize(); + // [GIVEN] An open request "R" with a detail "D" worth 10. LibraryExpense.CreateSpendRequest(SpendRequest); LibraryExpense.CreateSpendRequestDetail(SpendRequestDetail, SpendRequest."No.", 10); @@ -634,10 +730,41 @@ codeunit 148338 "Expense Permissions Test" LibraryLowerPermissions.StopLoggingNAVPermissions(); // [THEN] Both the line change and the base table's header update are persisted. + VerifyTravelRequestAmounts(SpendRequest, SpendRequestDetail, 20); + + // [GIVEN] The same exact role still has no direct writes after the first update. + LibraryLowerPermissions.StartLoggingNAVPermissions(); + LibraryLowerPermissions.SetExactPermissionSet(PermissionSetId); + Assert.IsFalse(SpendRequest.WritePermission(), 'The caller must not have direct request write permission.'); + Assert.IsFalse(SpendRequestDetail.WritePermission(), 'The caller must not have direct detail write permission.'); + + // [WHEN] The persisted detail amount is increased again from 20 to 30. + Clear(TravelRequestSubform); + TravelRequestSubform.OpenEdit(); + TravelRequestSubform.GoToRecord(SpendRequestDetail); + TravelRequestSubform.Amount.SetValue(30); + TravelRequestSubform.Close(); + RestoreFullPermissions(); + LibraryLowerPermissions.StopLoggingNAVPermissions(); + + // [THEN] The second update applies only its delta, not the entire new amount. + VerifyTravelRequestAmounts(SpendRequest, SpendRequestDetail, 30); + end; + + local procedure VerifyTravelRequestAmounts(var SpendRequest: Record "Spend Request"; var SpendRequestDetail: Record "Spend Request Detail"; ExpectedAmount: Decimal) + begin SpendRequestDetail.Get(SpendRequestDetail."Spend Request No.", SpendRequestDetail."Line No."); SpendRequest.Get(SpendRequest."No."); - Assert.AreEqual(20, SpendRequestDetail."Expected Amount", 'The detail amount must be updated through indirect permissions.'); - Assert.AreEqual(20, SpendRequest."Total Expected Amount (LCY)", 'The detail update must also update the header total.'); + Assert.AreEqual(ExpectedAmount, SpendRequestDetail."Expected Amount", 'The detail amount must match the page operation.'); + Assert.AreEqual(ExpectedAmount, SpendRequestDetail."Expected Amount (LCY)", 'The LCY detail amount must match the page operation.'); + Assert.AreEqual(ExpectedAmount, SpendRequest."Total Expected Amount", 'The request amount must reflect the detail delta exactly once.'); + Assert.AreEqual(ExpectedAmount, SpendRequest."Total Expected Amount (LCY)", 'The LCY request amount must reflect the detail delta exactly once.'); + end; + + local procedure VerifyCapturedPageError(ErrorCode: Text; ErrorText: Text; ExpectedErrorText: Text) + begin + Assert.AreEqual('TestValidation', ErrorCode, 'The amount field must fail through the TestPage validation wrapper.'); + Assert.ExpectedMessage(ExpectedErrorText, ErrorText); end; local procedure VerifyCompanyEmailSynchronization(PermissionSetId: Code[20]) diff --git a/src/Apps/W1/ExpenseAgent/test/src/Helper/ExpenseAPITestAuthHelper.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/Helper/ExpenseAPITestAuthHelper.Codeunit.al deleted file mode 100644 index 22caab107b3..00000000000 --- a/src/Apps/W1/ExpenseAgent/test/src/Helper/ExpenseAPITestAuthHelper.Codeunit.al +++ /dev/null @@ -1,66 +0,0 @@ -// ------------------------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See License.txt in the project root for license information. -// ------------------------------------------------------------------------------------------------ -namespace Microsoft.Test.ExpenseAgent; - -using System.Azure.KeyVault; -using System.Environment; -using System.Security.AccessControl; -using System.Text; - -/// -/// Test-only authentication helper for the API test codeunits in this app. -/// -codeunit 148332 "Expense API Test Auth Helper" -{ - Access = Internal; - EventSubscriberInstance = Manual; - - var - NavServerUserPasswordKeyTok: Label 'NavServerUserPassword', Locked = true; - - [EventSubscriber(ObjectType::Codeunit, Codeunit::"Library - Graph Mgt", OnAfterInitializeWebRequestWithURL, '', false, false)] - local procedure OnAfterInitializeWebRequest(var HttpRequestMessage: HttpRequestMessage) - var - Base64Convert: Codeunit "Base64 Convert"; - RequestHeaders: HttpHeaders; - Password: SecretText; - begin - if not ShouldInjectBasicAuth() then - exit; - if not TryGetPassword(Password) then - exit; - HttpRequestMessage.GetHeaders(RequestHeaders); - if RequestHeaders.Contains('Authorization') or RequestHeaders.ContainsSecret('Authorization') then - RequestHeaders.Remove('Authorization'); - RequestHeaders.Add('Authorization', SecretStrSubstNo('Basic %1', Base64Convert.ToBase64(SecretStrSubstNo('%1:%2', UserId(), Password)))); - end; - - local procedure ShouldInjectBasicAuth(): Boolean - var - User: Record User; - EnvironmentInfo: Codeunit "Environment Information"; - begin - if EnvironmentInfo.IsSaaSInfrastructure() then - exit(false); - - // If the current user authenticates via Windows - if not User.Get(UserSecurityId()) then - exit(false); - if User."Windows Security ID" <> '' then - exit(false); - - // Inject Basic auth when using username password authentication. - exit(true); - end; - - [TryFunction] - local procedure TryGetPassword(var Password: SecretText) - var - AzureKeyVault: Codeunit "Azure Key Vault"; - begin - AzureKeyVault.GetAzureKeyVaultSecret(NavServerUserPasswordKeyTok, Password); - end; -} - diff --git a/src/Apps/W1/ExpenseAgent/test/src/Permissions/ExpDetailTest.PermissionSet.al b/src/Apps/W1/ExpenseAgent/test/src/Permissions/ExpDetailTest.PermissionSet.al new file mode 100644 index 00000000000..9b2f1c33517 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/test/src/Permissions/ExpDetailTest.PermissionSet.al @@ -0,0 +1,20 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.Test.ExpenseAgent; + +using Microsoft.Finance.SpendRequest; +using System.Security.AccessControl; + +permissionset 148303 "Exp. Detail Test" +{ + Access = Internal; + Assignable = false; + IncludedPermissionSets = "D365 READ"; + + // Keep the header read-only, while allowing the page to modify a detail indirectly. + Permissions = tabledata "Spend Request Detail" = m, + codeunit Assert = X, + codeunit "Library - Lower Permissions" = X; +} diff --git a/src/Apps/W1/ExpenseAgent/test/src/SpendRequestTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/SpendRequestTest.Codeunit.al index b18c4701d05..c93fa9338d1 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/SpendRequestTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/SpendRequestTest.Codeunit.al @@ -62,6 +62,8 @@ codeunit 148339 "Spend Request Test" UnassignedTravelRequestHiddenMsg: Label 'The approver should not see a travel request assigned to another approver.'; DefaultTravelRequestVisibleMsg: Label 'The default approver should see travel requests without an assigned approver.'; ApproverWithoutRequestsMsg: Label 'An approver without assigned travel requests should receive an empty result.'; + ApproverFilterExpectedRequestErr: Label '%1 Requested For filter: %2. Expected request: %3.', Comment = '%1 = assertion message, %2 = Requested For filter, %3 = expected request number'; + ApproverFilterUnexpectedRequestErr: Label '%1 Requested For filter: %2. Expected request: %3. Unauthorized request: %4.', Comment = '%1 = assertion message, %2 = Requested For filter, %3 = expected request number, %4 = unauthorized request number'; SpendReqNoSetMsg: Label 'The Spend Request No. should be assigned to the expense report line.'; HeaderSpendReqNoSetMsg: Label 'The Spend Request No. should be assigned to the expense report header.'; HeaderCloseFlagMsg: Label 'The header should store the confirmed close flag.'; @@ -1877,21 +1879,108 @@ codeunit 148339 "Spend Request Test" procedure ApproverFilterReturnsDefaultApproverTravelRequests() begin // [SCENARIO] The default approver sees requests without an explicit approval assignment. - VerifyDefaultApproverFilter('', ''); + Initialize(); + + VerifyDefaultApproverFilter('', '', true); end; [Test] procedure DefaultApproverFilterQuotesWildcardUserNo() begin // [SCENARIO] A literal wildcard user number must not expose another approver's requests. - VerifyDefaultApproverFilter('*', 'TR-OTHER'); + Initialize(); + + VerifyDefaultApproverFilter('*', 'TR-OTHER', true); end; [Test] procedure DefaultApproverFilterQuotesPipeUserNo() begin // [SCENARIO] A pipe in a user number must not become an OR filter for other users. - VerifyDefaultApproverFilter('TR-A|TR-B', 'TR-A'); + Initialize(); + + VerifyDefaultApproverFilter('TR-A|TR-B', 'TR-A', true); + end; + + [Test] + procedure ApproverFilterQuotesExplicitWildcardUserNo() + begin + // [SCENARIO] An explicitly assigned literal wildcard user must not expose another approver's requests. + Initialize(); + + VerifyDefaultApproverFilter('*', 'TR-OTHER', false); + end; + + [Test] + procedure ApproverFilterQuotesExplicitPipeUserNo() + begin + // [SCENARIO] An explicitly assigned user with a pipe in its number must not expose another approver's requests. + Initialize(); + + VerifyDefaultApproverFilter('TR-A|TR-B', 'TR-A', false); + end; + + [Test] + procedure ApproverFilterDoesNotIncludeUnassignedUserBetweenAssignedUsers() + var + FirstTravelRequest: Record "Spend Request"; + LastTravelRequest: Record "Spend Request"; + UnassignedTravelRequest: Record "Spend Request"; + FilteredTravelRequest: Record "Spend Request"; + FirstExpenseUser: Record "Expense User"; + LastExpenseUser: Record "Expense User"; + UnassignedExpenseUser: Record "Expense User"; + AssignedApprover: Record "Expense User"; + DefaultApprover: Record "Expense User"; + ExpenseApprovalSetup: Record "Expense Approval Setup"; + TravelRequestApproval: Codeunit "Travel Request Approval"; + begin + // [SCENARIO] Adjacent approval setups must not expose a user between them who belongs to another default approver. + Initialize(); + + // [GIVEN] Approver "A" and default approver "B", with their user numbers outside the tested range. + ExpenseApprovalSetup.DeleteAll(); + LibraryExpense.UpdateEnableAgentInAgentSetup(true); + CreateApprover(AssignedApprover); + AssignedApprover.Rename('FILTER-APP-A'); + CreateApprover(DefaultApprover); + DefaultApprover.Rename('FILTER-APP-B'); + SetDefaultApprover(DefaultApprover."No."); + + // [GIVEN] Released requests for "TR-A" and "TR-C" explicitly assigned to "A". + CreateReleasableSpendRequest(FirstTravelRequest, FirstExpenseUser); + FirstExpenseUser.Rename('TR-A'); + FirstTravelRequest.Get(FirstTravelRequest."No."); + LibraryExpense.SetSpendRequestStatus(FirstTravelRequest, FirstTravelRequest.Status::Released); + ExpenseApprovalSetup.Get(FirstExpenseUser."No."); + ExpenseApprovalSetup.Validate("Approver No.", AssignedApprover."No."); + ExpenseApprovalSetup.Modify(true); + CreateReleasableSpendRequest(LastTravelRequest, LastExpenseUser); + LastExpenseUser.Rename('TR-C'); + LastTravelRequest.Get(LastTravelRequest."No."); + LibraryExpense.SetSpendRequestStatus(LastTravelRequest, LastTravelRequest.Status::Released); + ExpenseApprovalSetup.Get(LastExpenseUser."No."); + ExpenseApprovalSetup.Validate("Approver No.", AssignedApprover."No."); + ExpenseApprovalSetup.Modify(true); + + // [GIVEN] A released request for "TR-B" with no setup, so it belongs to default approver "B". + CreateReleasableSpendRequest(UnassignedTravelRequest, UnassignedExpenseUser); + UnassignedExpenseUser.Rename('TR-B'); + UnassignedTravelRequest.Get(UnassignedTravelRequest."No."); + LibraryExpense.SetSpendRequestStatus(UnassignedTravelRequest, UnassignedTravelRequest.Status::Released); + ExpenseApprovalSetup.Get(UnassignedExpenseUser."No."); + ExpenseApprovalSetup.Delete(true); + VerifyApproverFilterRangeGapSetup( + FirstTravelRequest, LastTravelRequest, UnassignedTravelRequest, AssignedApprover."No.", DefaultApprover."No."); + + // [WHEN] "A"'s filter is applied to pending travel requests. + FilteredTravelRequest.SetRange("Document Type", FilteredTravelRequest."Document Type"::"Travel Request"); + FilteredTravelRequest.SetRange(Status, FilteredTravelRequest.Status::Released); + TravelRequestApproval.ApplyApproverFilter(FilteredTravelRequest, AssignedApprover.SystemId); + + // [THEN] The requests for "TR-A" and "TR-C" are visible, but the request for "TR-B" is hidden. + VerifyApproverFilterRangeGapResults( + FilteredTravelRequest, FirstTravelRequest."No.", LastTravelRequest."No.", UnassignedTravelRequest."No."); end; [Test] @@ -3173,7 +3262,7 @@ codeunit 148339 "Spend Request Test" ExpenseAgentSetup.Modify(true); end; - local procedure VerifyDefaultApproverFilter(DefaultExpenseUserNo: Code[20]; OtherExpenseUserNo: Code[20]) + local procedure VerifyDefaultApproverFilter(DefaultExpenseUserNo: Code[20]; OtherExpenseUserNo: Code[20]; UseDefaultFallback: Boolean) var DefaultTravelRequest: Record "Spend Request"; OtherTravelRequest: Record "Spend Request"; @@ -3182,11 +3271,13 @@ codeunit 148339 "Spend Request Test" OtherExpenseUser: Record "Expense User"; DefaultApprover: Record "Expense User"; OtherApprover: Record "Expense User"; + ExpenseApprovalSetup: Record "Expense Approval Setup"; TravelRequestApproval: Codeunit "Travel Request Approval"; + ExpectedApproverNo: Code[20]; begin - Initialize(); - - // [GIVEN] A default approver, an unassigned request, and a request assigned to another approver. + // [GIVEN] Default approver "A", a request for "U", and another request assigned to approver "B". + // Initialize deletes expense users without their OnDelete trigger, leaving approval setups behind. + ExpenseApprovalSetup.DeleteAll(); LibraryExpense.UpdateEnableAgentInAgentSetup(true); CreateApprover(DefaultApprover); SetDefaultApprover(DefaultApprover."No."); @@ -3194,28 +3285,93 @@ codeunit 148339 "Spend Request Test" if DefaultExpenseUserNo <> '' then begin DefaultExpenseUser.Rename(DefaultExpenseUserNo); DefaultTravelRequest.Get(DefaultTravelRequest."No."); - DefaultTravelRequest.TestField("Requested For", DefaultExpenseUserNo); + Assert.AreEqual(DefaultExpenseUserNo, DefaultTravelRequest."Requested For", 'The request must retain the literal renamed user number.'); end; LibraryExpense.SetSpendRequestStatus(DefaultTravelRequest, DefaultTravelRequest.Status::Released); CreateReleasableSpendRequest(OtherTravelRequest, OtherExpenseUser); if OtherExpenseUserNo <> '' then begin OtherExpenseUser.Rename(OtherExpenseUserNo); OtherTravelRequest.Get(OtherTravelRequest."No."); - OtherTravelRequest.TestField("Requested For", OtherExpenseUserNo); + Assert.AreEqual(OtherExpenseUserNo, OtherTravelRequest."Requested For", 'The other request must retain the literal renamed user number.'); end; CreateApproverForExpenseUser(OtherApprover, OtherExpenseUser); LibraryExpense.SetSpendRequestStatus(OtherTravelRequest, OtherTravelRequest.Status::Released); + // [GIVEN] "U" is either explicitly assigned to "A" or has no setup and must use the default fallback. + // Both setting the default approver and inserting expense users create explicit assignments. + ExpenseApprovalSetup.Get(DefaultExpenseUser."No."); + Assert.AreEqual(DefaultApprover."No.", ExpenseApprovalSetup."Approver No.", 'The initial fixture must exercise the explicit-assignment path.'); + ExpectedApproverNo := DefaultApprover."No."; + if UseDefaultFallback then begin + ExpenseApprovalSetup.Delete(true); + ExpectedApproverNo := ''; + end; + DefaultExpenseUser.CalcFields("Approver No."); + Assert.AreEqual(ExpectedApproverNo, DefaultExpenseUser."Approver No.", 'The fixture must select the intended explicit-assignment or default-fallback path.'); + ExpenseApprovalSetup.Get(OtherExpenseUser."No."); + Assert.AreEqual(OtherApprover."No.", ExpenseApprovalSetup."Approver No.", 'The other request must remain explicitly assigned to another approver.'); + // [WHEN] The default approver's filter is applied to pending travel requests. FilteredTravelRequest.SetRange("Document Type", FilteredTravelRequest."Document Type"::"Travel Request"); FilteredTravelRequest.SetRange(Status, FilteredTravelRequest.Status::Released); TravelRequestApproval.ApplyApproverFilter(FilteredTravelRequest, DefaultApprover.SystemId); - // [THEN] Only the literal unassigned user's request is visible, not the other approver's request. + // [THEN] Only "U"'s literal request is visible, not the request assigned to "B". FilteredTravelRequest.SetRange("No.", DefaultTravelRequest."No."); - Assert.IsFalse(FilteredTravelRequest.IsEmpty(), DefaultTravelRequestVisibleMsg); + Assert.IsFalse( + FilteredTravelRequest.IsEmpty(), + StrSubstNo(ApproverFilterExpectedRequestErr, + DefaultTravelRequestVisibleMsg, FilteredTravelRequest.GetFilter("Requested For"), DefaultTravelRequest."No.")); FilteredTravelRequest.SetRange("No.", OtherTravelRequest."No."); - Assert.IsTrue(FilteredTravelRequest.IsEmpty(), UnassignedTravelRequestHiddenMsg); + Assert.IsTrue( + FilteredTravelRequest.IsEmpty(), + StrSubstNo(ApproverFilterUnexpectedRequestErr, + UnassignedTravelRequestHiddenMsg, FilteredTravelRequest.GetFilter("Requested For"), + DefaultTravelRequest."No.", OtherTravelRequest."No.")); + end; + + local procedure VerifyApproverFilterRangeGapSetup(FirstTravelRequest: Record "Spend Request"; LastTravelRequest: Record "Spend Request"; UnassignedTravelRequest: Record "Spend Request"; AssignedApproverNo: Code[20]; DefaultApproverNo: Code[20]) + var + ExpenseApprovalSetup: Record "Expense Approval Setup"; + ExpenseAgentSetup: Record "Expense Agent Setup"; + ExpenseUser: Record "Expense User"; + begin + Assert.AreEqual('TR-A', FirstTravelRequest."Requested For", 'The first request must use the lower range endpoint.'); + Assert.AreEqual('TR-C', LastTravelRequest."Requested For", 'The last request must use the upper range endpoint.'); + Assert.AreEqual('TR-B', UnassignedTravelRequest."Requested For", 'The unassigned request must lie between the assigned users.'); + ExpenseApprovalSetup.Get(FirstTravelRequest."Requested For"); + Assert.AreEqual(AssignedApproverNo, ExpenseApprovalSetup."Approver No.", 'The first user must be explicitly assigned.'); + ExpenseApprovalSetup.Get(LastTravelRequest."Requested For"); + Assert.AreEqual(AssignedApproverNo, ExpenseApprovalSetup."Approver No.", 'The last user must have the same explicit approver.'); + Assert.IsFalse(ExpenseApprovalSetup.Get(UnassignedTravelRequest."Requested For"), 'The middle user must have no approval setup row.'); + ExpenseUser.Get(UnassignedTravelRequest."Requested For"); + ExpenseUser.CalcFields("Approver No."); + Assert.AreEqual('', ExpenseUser."Approver No.", 'The middle user must use the default approver fallback.'); + ExpenseAgentSetup.Get(); + Assert.AreEqual(DefaultApproverNo, ExpenseAgentSetup."Default Approver No.", 'The middle user must belong to the other default approver.'); + Assert.AreNotEqual(AssignedApproverNo, DefaultApproverNo, 'The explicit and default approvers must differ.'); + ExpenseApprovalSetup.SetRange("Expense User No.", FirstTravelRequest."Requested For", LastTravelRequest."Requested For"); + Assert.AreEqual(2, ExpenseApprovalSetup.Count(), 'Only the two assigned setup rows may exist in the tested range.'); + end; + + local procedure VerifyApproverFilterRangeGapResults(var FilteredTravelRequest: Record "Spend Request"; FirstTravelRequestNo: Code[20]; LastTravelRequestNo: Code[20]; UnassignedTravelRequestNo: Code[20]) + begin + FilteredTravelRequest.SetRange("No.", FirstTravelRequestNo); + Assert.IsFalse( + FilteredTravelRequest.IsEmpty(), + StrSubstNo(ApproverFilterExpectedRequestErr, + AssignedTravelRequestVisibleMsg, FilteredTravelRequest.GetFilter("Requested For"), FirstTravelRequestNo)); + FilteredTravelRequest.SetRange("No.", LastTravelRequestNo); + Assert.IsFalse( + FilteredTravelRequest.IsEmpty(), + StrSubstNo(ApproverFilterExpectedRequestErr, + AssignedTravelRequestVisibleMsg, FilteredTravelRequest.GetFilter("Requested For"), LastTravelRequestNo)); + FilteredTravelRequest.SetRange("No.", UnassignedTravelRequestNo); + Assert.IsTrue( + FilteredTravelRequest.IsEmpty(), + StrSubstNo(ApproverFilterUnexpectedRequestErr, + UnassignedTravelRequestHiddenMsg, FilteredTravelRequest.GetFilter("Requested For"), + FirstTravelRequestNo, UnassignedTravelRequestNo)); end; local procedure VerifyPostedTravelRequestHistory(SpendRequest: Record "Spend Request"; ExpectedPostedExpenseReportHeader: Record "Posted Expense Report Header") 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 b23129476de..27a0e065b84 100644 --- a/src/DisabledTests/Expense_Agent_Tests/Expense_Agent_Tests.DisabledTest.json +++ b/src/DisabledTests/Expense_Agent_Tests/Expense_Agent_Tests.DisabledTest.json @@ -1,254 +1,14 @@ [ - { - "codeunitId": 148315, - "codeunitName": "Expense Users API Test", - "method": "EmployeesAPICanFilterExpenseUsers" - }, - { - "codeunitId": 148315, - "codeunitName": "Expense Users API Test", - "method": "UnlinkedExpenseUserIsHiddenFromAPI" - }, - { - "codeunitId": 148347, - "codeunitName": "Travel Requests API Test", - "method": "TravelersAPIMapsEmployeeNumberToExpenseUser" - }, - { - "codeunitId": 148347, - "codeunitName": "Travel Requests API Test", - "method": "TravelersAPIRejectsEmployeeWithoutExpenseUser" - }, - { - "codeunitId": 148347, - "codeunitName": "Travel Requests API Test", - "method": "CreateExpenseReportActionRecreatesDeletedReport" - }, - { - "codeunitId": 148347, - "codeunitName": "Travel Requests API Test", - "method": "ApproveTravelRequestActionCreatesExpenseReport" - }, - { - "codeunitId": 148347, - "codeunitName": "Travel Requests API Test", - "method": "TravelRequestsAPINormalizesCurrency" - }, - { - "codeunitId": 148347, - "codeunitName": "Travel Requests API Test", - "method": "TravelRequestDetailsAPINormalizesCurrency" - }, - { - "codeunitId": 148347, - "codeunitName": "Travel Requests API Test", - "method": "TravelRequestsAPIPreservesAndUpdatesDates" - }, - { - "codeunitId": 148347, - "codeunitName": "Travel Requests API Test", - "method": "TravelRequestsAreScopedByEmployeeNumber" - }, - { - "codeunitId": 148347, - "codeunitName": "Travel Requests API Test", - "method": "ExpenseReportAPIExposesLinkedTravelRequest" - }, - { - "codeunitId": 148347, - "codeunitName": "Travel Requests API Test", - "method": "TravelRequestDetailsAPIExposesTypeAndCategory" - }, - { - "codeunitId": 148347, - "codeunitName": "Travel Requests API Test", - "method": "ApproverViewReturnsOnlyAssignedTravelRequests" - }, - { - "codeunitId": 148347, - "codeunitName": "Travel Requests API Test", - "method": "TravelRequestsAPIAllowsOwnerOnInsertAndUnchangedPatch" - }, - { - "codeunitId": 148347, - "codeunitName": "Travel Requests API Test", - "method": "TravelRequestsAPIRejectsLifecycleFieldChanges" - }, - { - "codeunitId": 148347, - "codeunitName": "Travel Requests API Test", - "method": "TravelRequestsAPIRejectsRequestedForChange" - }, - { - "codeunitId": 148347, - "codeunitName": "Travel Requests API Test", - "method": "LegacySpendRequestsAPIAllowsUnchangedOwner" - }, - { - "codeunitId": 148347, - "codeunitName": "Travel Requests API Test", - "method": "LegacySpendRequestsAPIRejectsTravelRequestOwnerChange" - }, { "codeunitId": 148318, "codeunitName": "Expense Capabilities API Test", "method": "CapabilitiesProjectsEnabledViaAPI" }, - { - "codeunitId": 148318, - "codeunitName": "Expense Capabilities API Test", - "method": "CapabilitiesProjectsDisabledViaAPI" - }, { "codeunitId": 148318, "codeunitName": "Expense Capabilities API Test", "method": "CapabilitiesConsolidatedProjectsFollowsProjectFieldsViaAPI" }, - { - "codeunitId": 148318, - "codeunitName": "Expense Capabilities API Test", - "method": "ActivityLogCapabilityEnabledViaAPI" - }, - { - "codeunitId": 148318, - "codeunitName": "Expense Capabilities API Test", - "method": "TravelRequestCapabilityEnabledViaAPI" - }, - { - "codeunitId": 148343, - "codeunitName": "Expense Activity Log API Test", - "method": "ActivityLogEntryIsExposedThroughReadOnlyAPI" - }, - { - "codeunitId": 148343, - "codeunitName": "Expense Activity Log API Test", - "method": "ActivityLogAPIRejectsWrites" - }, - { - "codeunitId": 148343, - "codeunitName": "Expense Activity Log API Test", - "method": "NonSnapshotActivityDoesNotExposeCurrencies" - }, - { - "codeunitId": 148343, - "codeunitName": "Expense Activity Log API Test", - "method": "UnscopedActivityAPIHidesEntries" - }, - { - "codeunitId": 148343, - "codeunitName": "Expense Activity Log API Test", - "method": "SubmitterHistoryReturnsCompleteTimelinesForSubmittedReports" - }, - { - "codeunitId": 148343, - "codeunitName": "Expense Activity Log API Test", - "method": "ApproverHistoryReturnsCompleteTimelinesForActedOnReports" - }, - { - "codeunitId": 148343, - "codeunitName": "Expense Activity Log API Test", - "method": "E2EActivityLogScenario" - }, - { - "codeunitId": 148333, - "codeunitName": "Expense Agent Setup API Test", - "method": "EmailAddressFieldIsExposedThroughAPI" - }, - { - "codeunitId": 148349, - "codeunitName": "Expense PerDiem Locations Test", - "method": "PerDiemLocationsReturnsLinkedLocationViaAPI" - }, - { - "codeunitId": 148349, - "codeunitName": "Expense PerDiem Locations Test", - "method": "PerDiemLocationsExcludesNonPerDiemCategoryViaAPI" - }, - { - "codeunitId": 148331, - "codeunitName": "Expense Projects API Test", - "method": "AssignedByPersonResponsibleReturnsWholeProjectViaAPI" - }, - { - "codeunitId": 148331, - "codeunitName": "Expense Projects API Test", - "method": "AssignedByProjectManagerReturnsWholeProjectViaAPI" - }, - { - "codeunitId": 148331, - "codeunitName": "Expense Projects API Test", - "method": "AssignedByProjectLevelResourceReturnsWholeProjectViaAPI" - }, - { - "codeunitId": 148331, - "codeunitName": "Expense Projects API Test", - "method": "AssignedByTaskLevelResourceReturnsOnlyThatTaskViaAPI" - }, - { - "codeunitId": 148331, - "codeunitName": "Expense Projects API Test", - "method": "AssignedByPlanningLineReturnsOnlyThatTaskViaAPI" - }, - { - "codeunitId": 148331, - "codeunitName": "Expense Projects API Test", - "method": "WholeProjectSignalSupersedesTaskLevelViaAPI" - }, - { - "codeunitId": 148331, - "codeunitName": "Expense Projects API Test", - "method": "OverlappingSignalsForSameResourceAreDeduplicatedViaAPI" - }, - { - "codeunitId": 148331, - "codeunitName": "Expense Projects API Test", - "method": "CompletedProjectExcludedEvenWhenAssignedViaAPI" - }, - { - "codeunitId": 148331, - "codeunitName": "Expense Projects API Test", - "method": "EmptyResourceUserMatchesNothingViaAPI" - }, - { - "codeunitId": 148331, - "codeunitName": "Expense Projects API Test", - "method": "BlankUserIdDoesNotMatchBlankProjectManagerViaAPI" - }, - { - "codeunitId": 148331, - "codeunitName": "Expense Projects API Test", - "method": "ProjectManagerMatchWithBlankResourceReturnsProjectViaAPI" - }, - { - "codeunitId": 148331, - "codeunitName": "Expense Projects API Test", - "method": "TasklessProjectExcludedViaAPI" - }, - { - "codeunitId": 148331, - "codeunitName": "Expense Projects API Test", - "method": "WholeProjectAndTaskLevelAcrossProjectsNoLeakageViaAPI" - }, - { - "codeunitId": 148331, - "codeunitName": "Expense Projects API Test", - "method": "AllProjectsVisibilityReturnsAllProjectsViaAPI" - }, - { - "codeunitId": 148331, - "codeunitName": "Expense Projects API Test", - "method": "AllProjectsVisibilityIncludesActiveStatusesAndPostingTasksViaAPI" - }, - { - "codeunitId": 148331, - "codeunitName": "Expense Projects API Test", - "method": "AssignedProjectsRequiresScopeFilterViaAPI" - }, - { - "codeunitId": 148331, - "codeunitName": "Expense Projects API Test", - "method": "AssignedProjectsBlockedWhenProjectFieldsDisabledViaAPI" - }, { "codeunitId": 148305, "codeunitName": "Expense Test", diff --git a/src/DisabledTests/_Exclude_APIV1__Tests/_Exclude_APIV1__Tests.DisabledTest.json b/src/DisabledTests/_Exclude_APIV1__Tests/_Exclude_APIV1__Tests.DisabledTest.json index ad692000a56..a4a9415f3f1 100644 --- a/src/DisabledTests/_Exclude_APIV1__Tests/_Exclude_APIV1__Tests.DisabledTest.json +++ b/src/DisabledTests/_Exclude_APIV1__Tests/_Exclude_APIV1__Tests.DisabledTest.json @@ -1453,5 +1453,680 @@ "codeunitId": 139745, "codeunitName": "APIV1 - Journal Lines E2E", "method": "TestCreateJournalLineWithOverridenDefaultDimension" + }, + { + "codeunitId": 139702, + "codeunitName": "APIV1 - Customers E2E", + "method": "TestGetSimpleCustomer" + }, + { + "codeunitId": 139702, + "codeunitName": "APIV1 - Customers E2E", + "method": "TestGetCustomerWithComplexType" + }, + { + "codeunitId": 139702, + "codeunitName": "APIV1 - Customers E2E", + "method": "TestGetCustomerWithAddressAndSpecialCharacters" + }, + { + "codeunitId": 139702, + "codeunitName": "APIV1 - Customers E2E", + "method": "TestCreateDetailedCustomer" + }, + { + "codeunitId": 139702, + "codeunitName": "APIV1 - Customers E2E", + "method": "TestCreateCustomerWithComplexType" + }, + { + "codeunitId": 139702, + "codeunitName": "APIV1 - Customers E2E", + "method": "TestCreateCustomerWithTemplate" + }, + { + "codeunitId": 139702, + "codeunitName": "APIV1 - Customers E2E", + "method": "TestDeleteCustomer" + }, + { + "codeunitId": 139702, + "codeunitName": "APIV1 - Customers E2E", + "method": "TestModifyCustomer" + }, + { + "codeunitId": 139702, + "codeunitName": "APIV1 - Customers E2E", + "method": "TestModifyCustomerWithComplexType" + }, + { + "codeunitId": 139702, + "codeunitName": "APIV1 - Customers E2E", + "method": "TestRemoveComplexTypeFromCustomer" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionWithDimensionCodeOnCustomer" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionWithDimensionCodeOnVendor" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionWithDimensionCodeOnItem" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionOnCustomer" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionOnVendor" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionOnItem" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithoutDimensionOnCustomer" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithoutDimensionOnVendor" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithoutDimensionOnItem" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithMismatchingDimensionsOnCustomer" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithMismatchingDimensionsOnVendor" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithMismatchingDimensionsOnItem" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithBlockedDimensionOnCustomer" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithBlockedDimensionOnVendor" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithBlockedDimensionOnItem" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithBlockedDimensionValueOnCustomer" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithBlockedDimensionValueOnVendor" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithBlockedDimensionValueOnItem" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestDeleteDefaultDimensionOnCustomer" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestDeleteDefaultDimensionOnVendor" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestDeleteDefaultDimensionOnItem" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestGetDefaultDimensionOnCustomer" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestGetDefaultDimensionOnVendor" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestGetDefaultDimensionOnItem" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestPatchDefaultDimensionOnCustomer" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestPatchDefaultDimensionOnVendor" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestPatchDefaultDimensionOnItem" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestPatchDefaultDimensionFailsWithBlockedValueOnCustomer" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestPatchDefaultDimensionFailsWithBlockedValueOnVendor" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestPatchDefaultDimensionFailsWithBlockedValueOnItem" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestPatchDefaultDimensionFailsWhenChangingDimensionCodeOnCustomer" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestPatchDefaultDimensionFailsWhenChangingDimensionCodeOnVendor" + }, + { + "codeunitId": 139732, + "codeunitName": "APIV1 - Default Dimensions E2E", + "method": "TestPatchDefaultDimensionFailsWhenChangingDimensionCodeOnItem" + }, + { + "codeunitId": 139725, + "codeunitName": "APIV1 - Dimension Lines E2E", + "method": "TestCreateDimensionLine" + }, + { + "codeunitId": 139725, + "codeunitName": "APIV1 - Dimension Lines E2E", + "method": "TestCreateDimensionLineFailsWithoutParentId" + }, + { + "codeunitId": 139725, + "codeunitName": "APIV1 - Dimension Lines E2E", + "method": "TestCreateDoesntWorkWithAlreadyExistingCode" + }, + { + "codeunitId": 139725, + "codeunitName": "APIV1 - Dimension Lines E2E", + "method": "TestGetDimensionLinesOfJournalLines" + }, + { + "codeunitId": 139725, + "codeunitName": "APIV1 - Dimension Lines E2E", + "method": "TestGetDimensionLinesFailsWithoutFilter" + }, + { + "codeunitId": 139725, + "codeunitName": "APIV1 - Dimension Lines E2E", + "method": "TestGetDimensionLinesFailsWithNonSupportedEntity" + }, + { + "codeunitId": 139725, + "codeunitName": "APIV1 - Dimension Lines E2E", + "method": "TestModifyDimensionLine" + }, + { + "codeunitId": 139725, + "codeunitName": "APIV1 - Dimension Lines E2E", + "method": "TestModifyOfDimensionCodeDoesntWork" + }, + { + "codeunitId": 139725, + "codeunitName": "APIV1 - Dimension Lines E2E", + "method": "TestDeleteDimensionLine" + }, + { + "codeunitId": 139739, + "codeunitName": "APIV1 - Item Variants E2E", + "method": "TestCreateItemVariantWithItemId" + }, + { + "codeunitId": 139739, + "codeunitName": "APIV1 - Item Variants E2E", + "method": "TestCreateItemVariantWithNonExistingItemId" + }, + { + "codeunitId": 139739, + "codeunitName": "APIV1 - Item Variants E2E", + "method": "TestCreateItemVariantWithItemNumber" + }, + { + "codeunitId": 139739, + "codeunitName": "APIV1 - Item Variants E2E", + "method": "TestCreateItemVariantWithNonExistingItemNumber" + }, + { + "codeunitId": 139739, + "codeunitName": "APIV1 - Item Variants E2E", + "method": "TestCreateItemVariantWithItemNumberAndItemId" + }, + { + "codeunitId": 139739, + "codeunitName": "APIV1 - Item Variants E2E", + "method": "TestCreateItemVariantWithMismatchingItemNumberAndItemId" + }, + { + "codeunitId": 139739, + "codeunitName": "APIV1 - Item Variants E2E", + "method": "TestCreateItemVariantWithExistingItemNumberAndCode" + }, + { + "codeunitId": 139739, + "codeunitName": "APIV1 - Item Variants E2E", + "method": "TestGetItemVariant" + }, + { + "codeunitId": 139739, + "codeunitName": "APIV1 - Item Variants E2E", + "method": "TestModifyItemVariant" + }, + { + "codeunitId": 139739, + "codeunitName": "APIV1 - Item Variants E2E", + "method": "TestModifyItemVariantItemId" + }, + { + "codeunitId": 139739, + "codeunitName": "APIV1 - Item Variants E2E", + "method": "TestModifyItemVariantItemNumber" + }, + { + "codeunitId": 139739, + "codeunitName": "APIV1 - Item Variants E2E", + "method": "TestDeleteItemVariant" + }, + { + "codeunitId": 139739, + "codeunitName": "APIV1 - Item Variants E2E", + "method": "TestDeleteInUse" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestCreateSimpleItem" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestCreateItemWithInventoryAmount" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestCreateItemWithCategoryId" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestCreateItemWithCategoryCode" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestCreateItemWithComplexType" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestCreateItemWithTemplate" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestCreateItemWithTemplateIgnoresFieldsSet" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestGetSimpleItem" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestSuccessfulTemplateSelection" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestSuccessfulTemplateSelectionWithSelectionCriteria" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestTempcodeCannotbeblankOrInvalidValue" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestGetItemWithCategory" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestGetItemWithComplexType" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestModifyItem" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestModifyItemInventory" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestModifyItemWithComplexType" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestRemoveComplexTypeFromItemUsingNull" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestRemoveComplexTypeFromItemUsingBlankValue" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestDeleteItem" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestPostItemWithUoMIdCreatesItemUoM" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestPatchItemWithUoMIdCreatesItemUoM" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestPatchItemWithCategoryCode" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestPatchItemWithCategoryId" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestPostItemWithUoMComplexTypeCreatesItemUoM" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestPatchItemWithUoMComplexTypeCreatesItemUoM" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestUoMSync" + }, + { + "codeunitId": 139700, + "codeunitName": "APIV1 - Items E2E", + "method": "TestUoMSyncErrors" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestGetPictureValueCustomer" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestGetPictureMetadataCustomer" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestGetPictureValueCustomerWithoutPicture" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestGetPictureMetadataCustomerWithoutPicture" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestPictureUploadCustomer" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestPictureReplaceCustomer" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestPictureDeleteCustomer" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestGetPictureValueVendor" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestGetPictureMetadataVendor" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestGetPictureValueVendorWithoutPicture" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestGetPictureMetadataVendorWithoutPicture" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestPictureUploadVendor" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestPictureReplaceVendor" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestPictureDeleteVendor" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestGetPictureValueEmployee" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestGetPictureMetadataEmployee" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestGetPictureValueEmployeeWithoutPicture" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestGetPictureMetadataEmployeeWithoutPicture" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestPictureUploadEmployee" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestPictureReplaceEmployee" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestPictureDeleteEmployee" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestGetPictureValueItem" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestGetPictureMetadataItem" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestGetPictureValueItemWithoutPicture" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestGetPictureMetadatItemWithoutPicture" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestPictureUploadItem" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestPictureReplaceItem" + }, + { + "codeunitId": 139742, + "codeunitName": "APIV1 - Pictures E2E", + "method": "TestPictureDeleteItem" + }, + { + "codeunitId": 139726, + "codeunitName": "APIV1 - Tax Areas E2E", + "method": "TestVerifyIDandLastDateModified" + }, + { + "codeunitId": 139726, + "codeunitName": "APIV1 - Tax Areas E2E", + "method": "TestGetTaxAreas" + }, + { + "codeunitId": 139726, + "codeunitName": "APIV1 - Tax Areas E2E", + "method": "TestGetTaxArea" + }, + { + "codeunitId": 139726, + "codeunitName": "APIV1 - Tax Areas E2E", + "method": "TestCreateTaxArea" + }, + { + "codeunitId": 139726, + "codeunitName": "APIV1 - Tax Areas E2E", + "method": "TestModifyTaxArea" + }, + { + "codeunitId": 139726, + "codeunitName": "APIV1 - Tax Areas E2E", + "method": "TestDeleteTaxArea" + }, + { + "codeunitId": 139703, + "codeunitName": "APIV1 - Vendors E2E", + "method": "TestGetSimpleVendor" + }, + { + "codeunitId": 139703, + "codeunitName": "APIV1 - Vendors E2E", + "method": "TestGetVendorWithComplexType" + }, + { + "codeunitId": 139703, + "codeunitName": "APIV1 - Vendors E2E", + "method": "TestCreateSimpleVendor" + }, + { + "codeunitId": 139703, + "codeunitName": "APIV1 - Vendors E2E", + "method": "TestCreateVendorWithComplexType" + }, + { + "codeunitId": 139703, + "codeunitName": "APIV1 - Vendors E2E", + "method": "TestCreateVendorWithTemplate" + }, + { + "codeunitId": 139703, + "codeunitName": "APIV1 - Vendors E2E", + "method": "TestModifyVendor" + }, + { + "codeunitId": 139703, + "codeunitName": "APIV1 - Vendors E2E", + "method": "TestVendorModifyWithComplexTypes" + }, + { + "codeunitId": 139703, + "codeunitName": "APIV1 - Vendors E2E", + "method": "TestRemoveComplexTypeFromVendor" + }, + { + "codeunitId": 139703, + "codeunitName": "APIV1 - Vendors E2E", + "method": "TestDeleteVendor" } ] diff --git a/src/DisabledTests/_Exclude_APIV2__Tests/_Exclude_APIV2__Tests.DisabledTest.json b/src/DisabledTests/_Exclude_APIV2__Tests/_Exclude_APIV2__Tests.DisabledTest.json index 2a73423912e..66e7f95e2e0 100644 --- a/src/DisabledTests/_Exclude_APIV2__Tests/_Exclude_APIV2__Tests.DisabledTest.json +++ b/src/DisabledTests/_Exclude_APIV2__Tests/_Exclude_APIV2__Tests.DisabledTest.json @@ -3583,5 +3583,295 @@ "codeunitId": 139928, "method": "TestExpandPdfDocument", "codeunitName": "APIV2 - Purch. Ret. Shpts. E2E" + }, + { + "codeunitId": 139802, + "codeunitName": "APIV2 - Customers E2E", + "method": "TestGetSimpleCustomer" + }, + { + "codeunitId": 139802, + "codeunitName": "APIV2 - Customers E2E", + "method": "TestGetCustomerWithAddressAndSpecialCharacters" + }, + { + "codeunitId": 139802, + "codeunitName": "APIV2 - Customers E2E", + "method": "TestCreateDetailedCustomer" + }, + { + "codeunitId": 139802, + "codeunitName": "APIV2 - Customers E2E", + "method": "TestCreateCustomerWithTemplate" + }, + { + "codeunitId": 139802, + "codeunitName": "APIV2 - Customers E2E", + "method": "TestDeleteCustomer" + }, + { + "codeunitId": 139802, + "codeunitName": "APIV2 - Customers E2E", + "method": "TestModifyCustomer" + }, + { + "codeunitId": 139802, + "codeunitName": "APIV2 - Customers E2E", + "method": "TestModifyCustomerWithAddress" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionWithDimensionCodeOnCustomer" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionWithDimensionCodeOnVendor" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionWithDimensionCodeOnItem" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionWithDimensionCodeOnEmployee" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionOnCustomer" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionOnVendor" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionOnItem" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionOnEmployee" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithMismatchingDimensionsOnCustomer" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithMismatchingDimensionsOnVendor" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithMismatchingDimensionsOnItem" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithMismatchingDimensionsOnEmployee" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithBlockedDimensionOnCustomer" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithBlockedDimensionOnVendor" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithBlockedDimensionOnItem" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithBlockedDimensionOnEmployee" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithBlockedDimensionValueOnCustomer" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithBlockedDimensionValueOnVendor" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithBlockedDimensionValueOnItem" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestCreateDefaultDimensionFailsWithBlockedDimensionValueOnEmployee" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestDeleteDefaultDimensionOnCustomer" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestDeleteDefaultDimensionOnVendor" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestDeleteDefaultDimensionOnItem" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestDeleteDefaultDimensionOnEmployee" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestGetDefaultDimensionOnCustomer" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestGetDefaultDimensionOnVendor" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestGetDefaultDimensionOnItem" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestGetDefaultDimensionOnEmployee" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestPatchDefaultDimensionOnCustomer" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestPatchDefaultDimensionOnVendor" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestPatchDefaultDimensionOnItem" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestPatchDefaultDimensionOnEmployee" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestPatchDefaultDimensionFailsWithBlockedValueOnCustomer" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestPatchDefaultDimensionFailsWithBlockedValueOnVendor" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestPatchDefaultDimensionFailsWithBlockedValueOnItem" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestPatchDefaultDimensionFailsWithBlockedValueOnEmployee" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestPatchDefaultDimensionFailsWhenChangingDimensionCodeOnCustomer" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestPatchDefaultDimensionFailsWhenChangingDimensionCodeOnVendor" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestPatchDefaultDimensionFailsWhenChangingDimensionCodeOnItem" + }, + { + "codeunitId": 139832, + "codeunitName": "APIV2 - Default Dimensions E2E", + "method": "TestPatchDefaultDimensionFailsWhenChangingDimensionCodeOnEmployee" + }, + { + "codeunitId": 139826, + "codeunitName": "APIV2 - Tax Areas E2E", + "method": "TestVerifyIDandLastDateModified" + }, + { + "codeunitId": 139826, + "codeunitName": "APIV2 - Tax Areas E2E", + "method": "TestGetTaxAreas" + }, + { + "codeunitId": 139826, + "codeunitName": "APIV2 - Tax Areas E2E", + "method": "TestGetTaxArea" + }, + { + "codeunitId": 139826, + "codeunitName": "APIV2 - Tax Areas E2E", + "method": "TestCreateTaxArea" + }, + { + "codeunitId": 139826, + "codeunitName": "APIV2 - Tax Areas E2E", + "method": "TestModifyTaxArea" + }, + { + "codeunitId": 139826, + "codeunitName": "APIV2 - Tax Areas E2E", + "method": "TestDeleteTaxArea" + }, + { + "codeunitId": 139803, + "codeunitName": "APIV2 - Vendors E2E", + "method": "TestGetSimpleVendor" + }, + { + "codeunitId": 139803, + "codeunitName": "APIV2 - Vendors E2E", + "method": "TestCreateSimpleVendor" + }, + { + "codeunitId": 139803, + "codeunitName": "APIV2 - Vendors E2E", + "method": "TestCreateVendorWithTemplate" + }, + { + "codeunitId": 139803, + "codeunitName": "APIV2 - Vendors E2E", + "method": "TestModifyVendor" + }, + { + "codeunitId": 139803, + "codeunitName": "APIV2 - Vendors E2E", + "method": "TestDeleteVendor" } ] diff --git a/src/Layers/W1/Tests/ERM/SpendRequestTests.Codeunit.al b/src/Layers/W1/Tests/ERM/SpendRequestTests.Codeunit.al index 11f5d08109a..ea889fe6e72 100644 --- a/src/Layers/W1/Tests/ERM/SpendRequestTests.Codeunit.al +++ b/src/Layers/W1/Tests/ERM/SpendRequestTests.Codeunit.al @@ -341,6 +341,37 @@ codeunit 134242 "Spend Request Tests" 'Deleting detail should reduce header total.'); end; + [Test] + procedure DeleteDetailLineDecrementsHeaderTotalExactlyOnce() + var + SpendRequest: Record "Spend Request"; + FirstSpendRequestDetail: Record "Spend Request Detail"; + RemainingSpendRequestDetail: Record "Spend Request Detail"; + begin + // [SCENARIO 646383] Real detail deletion applies each LCY delta exactly once without removing the other detail. + Initialize(); + + // [GIVEN] An open LCY request "R" initially has zero total and two details "D1" and "D2" worth 10 and 30. + CreateSpendRequest(SpendRequest); + VerifyDetailDeletionTotals(SpendRequest."No.", 0, 0); + CreateSpendRequestDetail(FirstSpendRequestDetail, SpendRequest."No.", 10); + CreateSpendRequestDetail(RemainingSpendRequestDetail, SpendRequest."No.", 30); + VerifyDetailDeletionTotals(SpendRequest."No.", 40, 2); + + // [WHEN] Deleting "D1" runs the production table trigger, not a page action or a permission test. + FirstSpendRequestDetail.Delete(true); + + // [THEN] "D2" survives and both its amount and the header total are 30. + RemainingSpendRequestDetail.Get(RemainingSpendRequestDetail."Spend Request No.", RemainingSpendRequestDetail."Line No."); + VerifyDetailDeletionTotals(SpendRequest."No.", 30, 1); + + // [WHEN] The remaining detail "D2" is deleted through its production trigger. + RemainingSpendRequestDetail.Delete(true); + + // [THEN] No details remain and the header total is exactly zero. + VerifyDetailDeletionTotals(SpendRequest."No.", 0, 0); + end; + [Test] [Scope('OnPrem')] procedure CannotInsertDetailOnReleasedRequest() @@ -827,4 +858,21 @@ codeunit 134242 "Spend Request Tests" // [THEN] An error is raised. Assert.ExpectedError('must have the status ' + Format(SpendRequest.Status::Open)); end; + + local procedure VerifyDetailDeletionTotals(SpendRequestNo: Code[20]; ExpectedAmount: Decimal; ExpectedLineCount: Integer) + var + SpendRequest: Record "Spend Request"; + SpendRequestDetail: Record "Spend Request Detail"; + begin + SpendRequest.Get(SpendRequestNo); + Assert.AreEqual(SpendRequest.Status::Open, SpendRequest.Status, 'Detail deletion must preserve the Open status.'); + Assert.AreEqual('', SpendRequest."Currency Code", 'The request must use LCY.'); + Assert.AreEqual(ExpectedAmount, SpendRequest."Total Expected Amount", 'The header amount must reflect each deleted detail exactly once.'); + Assert.AreEqual(ExpectedAmount, SpendRequest."Total Expected Amount (LCY)", 'The LCY header amount must reflect each deleted detail exactly once.'); + SpendRequestDetail.SetRange("Spend Request No.", SpendRequestNo); + Assert.AreEqual(ExpectedLineCount, SpendRequestDetail.Count(), 'Only the intended details must be deleted.'); + SpendRequestDetail.CalcSums("Expected Amount", "Expected Amount (LCY)"); + Assert.AreEqual(ExpectedAmount, SpendRequestDetail."Expected Amount", 'The remaining detail amounts must be unchanged.'); + Assert.AreEqual(ExpectedAmount, SpendRequestDetail."Expected Amount (LCY)", 'The remaining LCY detail amounts must be unchanged.'); + end; } \ No newline at end of file