From 01aaabbd0753e1ed43e0b1322a6a4c351687d26e Mon Sep 17 00:00:00 2001 From: Prangshuman Das Date: Thu, 17 Sep 2026 16:43:30 +0200 Subject: [PATCH 01/13] WIP: checkpoint Expense Agent email lifecycle for model handoff Relates to AB#644162, AB#644157, AB#632913. Parent fork: 3fc21154a1d66c71138a2962ce04bdb2ff74f508 (PR #11541 unchanged). Production Default build passed with analyzers. An earlier full CLEAN build passed before the final endpoint seam; latest CLEAN remains to be rebuilt. Latest test Default build fails AL0122 at EAAgentDispatcherTest line 652 (Label to SecretText conversion). No runtime tests or publication: isolated test company is unavailable and administrative NAV dispatch is busy. This is a local, unverified-runtime checkpoint, not a completed fix. Copilot-Session: 8ddc01d7-bd46-41c6-af55-61adbebea2b3 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Integration/EAAgentDispatcher.Codeunit.al | 80 +- .../EAAgentErrorHandler.Codeunit.al | 8 +- .../Integration/EAAgentRecovery.Codeunit.al | 19 +- .../Integration/EAAgentScheduler.Codeunit.al | 164 +++- .../src/Integration/EAHttpClient.Codeunit.al | 19 + .../Integration/EARetrieveEmails.Codeunit.al | 14 +- .../Codeunits/ExpPrivacyNoticeReg.Codeunit.al | 3 + .../src/Setup/Pages/ExpenseAgentSetup.Page.al | 17 +- .../Pages/ExpenseAgentSetupWizard.Page.al | 99 +-- .../Setup/Tables/ExpenseAgentSetup.Table.al | 115 ++- .../notification-outbox-accepted.json | 4 + .../outbox-email-correlated.json | 7 + .../HttpResponseFiles/receipt-accepted.json | 5 + .../reminder-send-failed.json | 8 + .../HttpResponseFiles/reminder-skipped.json | 5 + src/Apps/W1/ExpenseAgent/test/app.json | 11 +- .../src/EAAgentDispatcherTest.Codeunit.al | 729 +++++++++++++++++- .../src/EAAgentSchedulingTest.Codeunit.al | 198 ++++- .../test/src/EAMailboxAccessTest.Codeunit.al | 712 ++++++++++++++++- .../src/WelcomeEmailQueueTest.Codeunit.al | 37 +- 20 files changed, 2006 insertions(+), 248 deletions(-) create mode 100644 src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/notification-outbox-accepted.json create mode 100644 src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/outbox-email-correlated.json create mode 100644 src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/receipt-accepted.json create mode 100644 src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/reminder-send-failed.json create mode 100644 src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/reminder-skipped.json diff --git a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentDispatcher.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentDispatcher.Codeunit.al index 99ef75264d0..81caffe9638 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentDispatcher.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentDispatcher.Codeunit.al @@ -40,13 +40,10 @@ codeunit 6938 "EA Agent Dispatcher" NoRecipientErr: Label 'At least one recipient must be specified in To line, Cc line, or Bcc line.', Comment = 'Shown when an outbox email has no recipients in any of the address lines.'; NoSetupErr: Label 'Expense Agent is not set up yet.'; AgentNotEnabledErr: Label 'Expense Agent is not enabled.'; - NoEmailAccErr: Label 'Expense Agent has no email account specified.'; CapabilityNotEnabledErr: Label 'The Expense Agent capability is not enabled.'; trigger OnRun() begin - MaxEmailSendRetryCount := 5; - MaxEmailSendsPerRun := 25; RunEAAgent(Rec); end; @@ -55,13 +52,16 @@ codeunit 6938 "EA Agent Dispatcher" EASchedulerTask: Record "EA Scheduler Task"; ExpenseAgentStatus: Record "Expense Agent Status"; EAEmailSetup: Codeunit "EA Email Setup"; - EARetrieveEmails: Codeunit "EA Retrieve Emails"; - RetrievalSuccess: Boolean; - TelemetryDimensions: Dictionary of [Text, Text]; - LastSync: DateTime; + CompletedTaskId: Guid; ErrorMessage: Text; begin - TelemetryDimensions.Add('EASetupId', Format(Setup.SystemId)); + if ExpenseAgentStatus.Get() then + CompletedTaskId := ExpenseAgentStatus."Agent Task ID"; + Setup.Get(); + if not Setup.ShouldScheduleAgentTask(Setup."Enable Agent") then begin + EAAgentScheduler.CompleteAgentTask(Setup, CompletedTaskId); + exit; + end; AddTask(EASchedulerTask); ExpenseAgentStatus.ReadIsolation(IsolationLevel::UpdLock); @@ -70,15 +70,41 @@ codeunit 6938 "EA Agent Dispatcher" ExpenseAgentStatus.Modify(); Commit(); - if not CanRunTask(Setup, ErrorMessage) then begin + if not ProcessCommunication(Setup, ErrorMessage) then begin EASchedulerTask.Status := EASchedulerTask.Status::Failed; EASchedulerTask."Error Message" := CopyStr(ErrorMessage, 1, MaxStrLen(EASchedulerTask."Error Message")); EASchedulerTask.Modify(); + Commit(); + EAAgentScheduler.CompleteAgentTask(Setup, CompletedTaskId); exit; end; + UpdateTaskSucceeded(EASchedulerTask); + EAEmailSetup.RemoveProcessedEmailsOutsideLast24hrs(); + RemoveSentEmailsOlderThan1Day(); + Commit(); + EAAgentScheduler.CompleteAgentTask(Setup, CompletedTaskId); + end; + + internal procedure ProcessCommunication(var Setup: Record "Expense Agent Setup"; var ErrorMessage: Text): Boolean + var + EARetrieveEmails: Codeunit "EA Retrieve Emails"; + RetrievalSuccess: Boolean; + TelemetryDimensions: Dictionary of [Text, Text]; + LastSync: DateTime; + begin + MaxEmailSendRetryCount := 5; + MaxEmailSendsPerRun := 25; + if not Setup.Get() then begin + ErrorMessage := NoSetupErr; + exit(false); + end; + if not CanRunTask(Setup, ErrorMessage) then + exit(false); + TelemetryDimensions.Add('EASetupId', Format(Setup.SystemId)); + // === Phase 1: Email Read === - if Setup."Enable Email with Receipts" and not IsNullGuid(Setup."Email Account ID") then begin + if Setup.IsIncomingCommunicationConfigured() then begin LastSync := CurrentDateTime(); RetrievalSuccess := EARetrieveEmails.Run(Setup); if RetrievalSuccess then begin @@ -99,12 +125,16 @@ codeunit 6938 "EA Agent Dispatcher" // "Enable Communication" toggle is on and a Noreply account is set. This is // independent of "Enable Email with Receipts", which only governs the inbound // receipts feature (Phase 1). Outbound emails are always sent from the Noreply account. + if not RefreshCommunicationSetup(Setup) then + exit(true); if Setup.IsOutgoingCommunicationConfigured() then begin SendPendingEmails(Setup); Commit(); end; // === Phase 3: Reminder Notifications === + if not RefreshCommunicationSetup(Setup) then + exit(true); if ShouldRunNotifications(Setup) then begin if not TrySendOpenReportReminders(Setup) then FeatureTelemetry.LogError('0000SJG', Setup.GetFeatureName(), 'Send notifications', TelemetryNotifReminderFailedLbl, GetLastErrorCallStack(), TelemetryDimensions) @@ -114,18 +144,21 @@ codeunit 6938 "EA Agent Dispatcher" end; // === Phase 4: Welcome Emails === + if not RefreshCommunicationSetup(Setup) then + exit(true); if Setup.IsOutgoingCommunicationConfigured() then SendQueuedWelcomeEmails(Setup, TelemetryDimensions); - // === Reschedule === - Setup.Get(); - EAAgentScheduler.ScheduleAgent(Setup); - Commit(); + exit(true); + end; - // === Cleanup === - UpdateTaskSucceeded(EASchedulerTask); - EAEmailSetup.RemoveProcessedEmailsOutsideLast24hrs(); - RemoveSentEmailsOlderThan1Day(); + local procedure RefreshCommunicationSetup(var Setup: Record "Expense Agent Setup"): Boolean + var + ErrorMessage: Text; + begin + if not Setup.Get() then + exit(false); + exit(CanRunTask(Setup, ErrorMessage)); end; local procedure CanRunTask(var Setup: Record "Expense Agent Setup"; var ErrorMessage: Text): Boolean @@ -147,11 +180,6 @@ codeunit 6938 "EA Agent Dispatcher" exit(false); end; - if Setup."Enable Email with Receipts" and IsNullGuid(Setup."Email Account ID") then begin - ErrorMessage := NoEmailAccErr; - exit(false); - end; - ErrorMessage := ''; exit(true); end; @@ -364,12 +392,10 @@ codeunit 6938 "EA Agent Dispatcher" ExpenseAgentStatus: Record "Expense Agent Status"; NextRunDT: DateTime; begin - if not Setup."Enable Communication" then + if not Setup.IsOutgoingCommunicationConfigured() then exit(false); if not Setup."Enable Open Report Notif." then exit(false); - if IsNullGuid(Setup."Noreply Email Account ID") then - exit(false); if Setup."Open Report Notif. Freq." = "Expense Report Frequency"::" " then exit(false); @@ -383,6 +409,7 @@ codeunit 6938 "EA Agent Dispatcher" end; NextRunDT := CalcNextRunDateTime(Setup, ExpenseAgentStatus."Last Notif. Run At"); + Commit(); if CurrentDateTime() < NextRunDT then exit(false); @@ -416,7 +443,6 @@ codeunit 6938 "EA Agent Dispatcher" TelemetryDimensions.Set('Sent', '0'); TelemetryDimensions.Set('Skipped', '0'); FeatureTelemetry.LogUsage('0000RNC', Setup.GetFeatureName(), TelemetryNotifRunCompleteLbl, TelemetryDimensions); - UpdateLastNotifRunAt(); exit; end; diff --git a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentErrorHandler.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentErrorHandler.Codeunit.al index 4eb2e132966..3cb37e0c090 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentErrorHandler.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentErrorHandler.Codeunit.al @@ -23,9 +23,11 @@ codeunit 6936 "EA Agent Error Handler" ExpenseAgentStatus: Record "Expense Agent Status"; EASchedulerTask: Record "EA Scheduler Task"; EAAgentScheduler: Codeunit "EA Agent Scheduler"; + CompletedTaskId: Guid; begin EASchedulerTask.ReadIsolation(IsolationLevel::UpdLock); - if ExpenseAgentStatus.Get() then + if ExpenseAgentStatus.Get() then begin + CompletedTaskId := ExpenseAgentStatus."Agent Task ID"; if ExpenseAgentStatus."EA Scheduler Task ID" <> 0 then if EASchedulerTask.Get(ExpenseAgentStatus."EA Scheduler Task ID") then begin EASchedulerTask.Status := EASchedulerTask.Status::Failed; @@ -34,7 +36,7 @@ codeunit 6936 "EA Agent Error Handler" EASchedulerTask.Modify(); Commit(); end; - Setup.Get(); - EAAgentScheduler.ScheduleAgent(Setup); + end; + EAAgentScheduler.CompleteAgentTask(Setup, CompletedTaskId); end; } diff --git a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentRecovery.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentRecovery.Codeunit.al index 17280a6f344..3267c8199f6 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentRecovery.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentRecovery.Codeunit.al @@ -5,8 +5,6 @@ namespace Microsoft.ExpenseAgent; -using System.Environment; - codeunit 6937 "EA Agent Recovery" { Access = Internal; @@ -21,19 +19,12 @@ codeunit 6937 "EA Agent Recovery" internal procedure RunEARecovery(var Setup: Record "Expense Agent Setup") var - ScheduledTask: Record "Scheduled Task"; + ExpenseAgentStatus: Record "Expense Agent Status"; EAAgentScheduler: Codeunit "EA Agent Scheduler"; + CompletedTaskId: Guid; begin - // Check if task exists - ScheduledTask.SetRange("Run Codeunit", Codeunit::"EA Agent Dispatcher"); - ScheduledTask.SetRange(Company, CompanyName()); - ScheduledTask.SetRange(Record, Setup.RecordId); - if not ScheduledTask.IsEmpty() then - exit; // Task already exists - - // Recover task - Setup.Get(); - EAAgentScheduler.ScheduleAgent(Setup); - Commit(); + if ExpenseAgentStatus.Get() then + CompletedTaskId := ExpenseAgentStatus."Agent Recovery Task ID"; + EAAgentScheduler.CompleteAgentTask(Setup, CompletedTaskId); end; } diff --git a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentScheduler.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentScheduler.Codeunit.al index 6971b5941b1..a79439267a3 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentScheduler.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentScheduler.Codeunit.al @@ -5,6 +5,7 @@ namespace Microsoft.ExpenseAgent; +using System.AI; using System.Email; using System.Security.AccessControl; using System.Telemetry; @@ -23,20 +24,53 @@ codeunit 6935 "EA Agent Scheduler" TelemetryAgentScheduledTaskCancelledLbl: Label 'Agent scheduled task cancelled.', Locked = true; TelemetryRecoveryScheduledTaskCancelledLbl: Label 'Recovery scheduled task cancelled.', Locked = true; TelemetryAgentScheduledLbl: Label 'Agent scheduled.', Locked = true; + TelemetryTaskCancellationFailedLbl: Label 'The task could not be cancelled. It may already be running; its identifier has been retained.', Locked = true; HasNoAccessControlErr: Label 'You do not have permission to configure the Expense Agent. Ask an administrator to grant you "%1" access on the %2 page.', Comment = '%1 = Can Configure Agent field caption, %2 = Expense Agent Setup page caption'; internal procedure ScheduleAgent(EASetup: Record "Expense Agent Setup") var + CompletedTaskId: Guid; + begin + ReconcileAgent(EASetup, CompletedTaskId); + Commit(); + end; + + internal procedure CompleteAgentTask(EASetup: Record "Expense Agent Setup"; CompletedTaskId: Guid) + begin + ReconcileAgent(EASetup, CompletedTaskId); + Commit(); + end; + + local procedure ReconcileAgent(RequestedSetup: Record "Expense Agent Setup"; CompletedTaskId: Guid) + var + EASetup: Record "Expense Agent Setup"; ExpenseAgentStatus: Record "Expense Agent Status"; ExpenseAgentAccessControl: Record "Expense Agent Access Control"; ExpenseAgentSetupPage: Page "Expense Agent Setup"; + AzureOpenAI: Codeunit "Azure OpenAI"; TelemetryDimensions: Dictionary of [Text, Text]; begin - if IsNullGuid(EASetup.SystemId) then begin + // Setup is always locked before access control and task status, including saves and deletion. + // Never use the caller's pre-HTTP or temporary configuration to create a successor. + EASetup.ReadIsolation(IsolationLevel::UpdLock); + if not EASetup.Get(RequestedSetup."Primary Key") then begin FeatureTelemetry.LogError('0000QL1', EASetup.GetFeatureName(), 'Invalid EA Setup', TelemetryEASetupRecordNotValidLbl, GetLastErrorCallStack(), TelemetryDimensions); exit; end; + if EASetup.RepairMissingEmailAccounts() then + EASetup.Modify(); + + if not EASetup.ShouldScheduleAgentTask(EASetup."Enable Agent") or + not AzureOpenAI.IsEnabled(Enum::"Copilot Capability"::"Expense Agent", true) + then begin + if GetTaskStatus(ExpenseAgentStatus) then begin + ReleaseCompletedTask(ExpenseAgentStatus, CompletedTaskId); + CancelPendingTasks(ExpenseAgentStatus); + end; + exit; + end; + if not TaskScheduler.CanCreateTask() then Error(CannotCreateTaskErr); @@ -51,44 +85,134 @@ codeunit 6935 "EA Agent Scheduler" ExpenseAgentAccessControl.Modify(); end; ExpenseAgentStatus.GetOrCreate(); - RemoveAgentTask(ExpenseAgentStatus); - - ExpenseAgentStatus."Agent Task ID" := TaskScheduler.CreateTask(Codeunit::"EA Agent Dispatcher", Codeunit::"EA Agent Error Handler", true, CompanyName(), CurrentDateTime() + ScheduleDelay(), EASetup.RecordId); - ExpenseAgentStatus."Agent Recovery Task ID" := TaskScheduler.CreateTask(Codeunit::"EA Agent Recovery", Codeunit::"EA Agent Recovery", true, CompanyName(), CurrentDateTime() + ScheduleRecoveryDelay(), EASetup.RecordId); + ReleaseCompletedTask(ExpenseAgentStatus, CompletedTaskId); + CancelPendingTasks(ExpenseAgentStatus); + + // A failed cancellation must not lose a running task or create a duplicate of it. + if IsNullGuid(ExpenseAgentStatus."Agent Task ID") then + ExpenseAgentStatus."Agent Task ID" := TaskScheduler.CreateTask(Codeunit::"EA Agent Dispatcher", Codeunit::"EA Agent Error Handler", true, CompanyName(), CurrentDateTime() + ScheduleDelay(), EASetup.RecordId); + if IsNullGuid(ExpenseAgentStatus."Agent Recovery Task ID") then + ExpenseAgentStatus."Agent Recovery Task ID" := TaskScheduler.CreateTask(Codeunit::"EA Agent Recovery", Codeunit::"EA Agent Recovery", true, CompanyName(), CurrentDateTime() + ScheduleRecoveryDelay(), EASetup.RecordId); ExpenseAgentStatus.Modify(); - Commit(); FeatureTelemetry.LogUsage('0000QL2', EASetup.GetFeatureName(), TelemetryAgentScheduledLbl, TelemetryDimensions); end; internal procedure RemoveAgentTasks() + begin + RemoveAgentTasksForCompany(CompanyName()); + end; + + internal procedure RemoveAgentTasksForCompany(TargetCompanyName: Text) var + ExpenseAgentSetup: Record "Expense Agent Setup"; ExpenseAgentStatus: Record "Expense Agent Status"; begin - ExpenseAgentStatus.GetOrCreate(); - if not IsNullGuid(ExpenseAgentStatus."Agent Task ID") or not IsNullGuid(ExpenseAgentStatus."Agent Recovery Task ID") then - RemoveAgentTask(ExpenseAgentStatus); + ExpenseAgentSetup.ChangeCompany(TargetCompanyName); + ExpenseAgentStatus.ChangeCompany(TargetCompanyName); + ExpenseAgentSetup.ReadIsolation(IsolationLevel::UpdLock); + if ExpenseAgentSetup.Get() then; + if GetTaskStatus(ExpenseAgentStatus) then + CancelPendingTasks(ExpenseAgentStatus); + end; + + local procedure GetTaskStatus(var ExpenseAgentStatus: Record "Expense Agent Status"): Boolean + begin + ExpenseAgentStatus.ReadIsolation(IsolationLevel::UpdLock); + exit(ExpenseAgentStatus.Get()); + end; + + local procedure ReleaseCompletedTask(var ExpenseAgentStatus: Record "Expense Agent Status"; CompletedTaskId: Guid) + begin + if IsNullGuid(CompletedTaskId) then + exit; + // Completion is not cancellation. Only retire the slot captured by this execution; + // a newer configuration save may already have installed a different successor. + if ExpenseAgentStatus."Agent Task ID" = CompletedTaskId then + Clear(ExpenseAgentStatus."Agent Task ID"); + if ExpenseAgentStatus."Agent Recovery Task ID" = CompletedTaskId then + Clear(ExpenseAgentStatus."Agent Recovery Task ID"); end; - internal procedure RemoveAgentTask(var ExpenseAgentStatus: Record "Expense Agent Status") + local procedure CancelPendingTasks(var ExpenseAgentStatus: Record "Expense Agent Status") + begin + CancelPendingTask(ExpenseAgentStatus."Agent Task ID", '0000QL3', TelemetryAgentScheduledTaskCancelledLbl); + CancelPendingTask(ExpenseAgentStatus."Agent Recovery Task ID", '0000QL4', TelemetryRecoveryScheduledTaskCancelledLbl); + ExpenseAgentStatus.Modify(); + end; + + local procedure CancelPendingTask(var TaskId: Guid; TelemetryId: Text; CancelledMessage: Text) var ExpenseAgentSetup: Record "Expense Agent Setup"; - NullGuid: Guid; TelemetryDimensions: Dictionary of [Text, Text]; begin - if TaskScheduler.TaskExists(ExpenseAgentStatus."Agent Task ID") then begin - if TaskScheduler.CancelTask(ExpenseAgentStatus."Agent Task ID") then; - FeatureTelemetry.LogUsage('0000QL3', ExpenseAgentSetup.GetFeatureName(), TelemetryAgentScheduledTaskCancelledLbl, TelemetryDimensions); + if IsNullGuid(TaskId) then + exit; + if not TaskScheduler.TaskExists(TaskId) then begin + Clear(TaskId); + exit; end; - if TaskScheduler.TaskExists(ExpenseAgentStatus."Agent Recovery Task ID") then begin - if TaskScheduler.CancelTask(ExpenseAgentStatus."Agent Recovery Task ID") then; - FeatureTelemetry.LogUsage('0000QL4', ExpenseAgentSetup.GetFeatureName(), TelemetryRecoveryScheduledTaskCancelledLbl, TelemetryDimensions); + TelemetryDimensions.Add('TaskId', Format(TaskId)); + if TaskScheduler.CancelTask(TaskId) then begin + Clear(TaskId); + FeatureTelemetry.LogUsage(TelemetryId, ExpenseAgentSetup.GetFeatureName(), CancelledMessage, TelemetryDimensions); + exit; end; - ExpenseAgentStatus."Agent Task ID" := NullGuid; - ExpenseAgentStatus."Agent Recovery Task ID" := NullGuid; - ExpenseAgentStatus.Modify(); + if not TaskScheduler.TaskExists(TaskId) then + Clear(TaskId) + else + FeatureTelemetry.LogError('', ExpenseAgentSetup.GetFeatureName(), 'Cancel task', TelemetryTaskCancellationFailedLbl, '', TelemetryDimensions); + end; + + [EventSubscriber(ObjectType::Table, Database::"Expense Agent Setup", 'OnAfterModifyEvent', '', false, false)] + local procedure OnAfterModifySetup(var Rec: Record "Expense Agent Setup"; var xRec: Record "Expense Agent Setup"; RunTrigger: Boolean) + var + CompletedTaskId: Guid; + begin + if Rec.IsTemporary() or not RunTrigger then + exit; + if Rec.HasSchedulingChanges(xRec) then + ReconcileAgent(Rec, CompletedTaskId); + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Email Account", 'OnAfterDeleteEmailAccount', '', false, false)] + local procedure OnAfterDeleteEmailAccount(EmailAccountId: Guid; EmailAccountConnector: Enum "Email Connector") + var + ExpenseAgentSetup: Record "Expense Agent Setup"; + ExpenseAgentStatus: Record "Expense Agent Status"; + EmailAccount: Codeunit "Email Account"; + Changed: Boolean; + begin + if IsNullGuid(EmailAccountId) then + exit; + if EmailAccount.IsAccountRegistered(EmailAccountId, EmailAccountConnector) then + exit; + ExpenseAgentSetup.ReadIsolation(IsolationLevel::UpdLock); + if not ExpenseAgentSetup.Get() then + exit; + if (ExpenseAgentSetup."Email Account ID" = EmailAccountId) and + (ExpenseAgentSetup."Email Connector" = EmailAccountConnector) + then begin + ExpenseAgentSetup.ClearIncomingMailbox(); + Changed := true; + end; + if (ExpenseAgentSetup."Noreply Email Account ID" = EmailAccountId) and + (ExpenseAgentSetup."Noreply Email Connector" = EmailAccountConnector) + then begin + ExpenseAgentSetup.ClearNoreplyMailbox(); + Changed := true; + end; + if not Changed then + exit; + + // Stay in the connector deletion transaction and current company. Never grant + // delegation, change the remaining channel's worker, or commit as the deleting actor. + ExpenseAgentSetup.Modify(); + if not ExpenseAgentSetup.ShouldScheduleAgentTask(ExpenseAgentSetup."Enable Agent") then + if GetTaskStatus(ExpenseAgentStatus) then + CancelPendingTasks(ExpenseAgentStatus); end; local procedure ScheduleDelay(): Integer diff --git a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAHttpClient.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAHttpClient.Codeunit.al index 47c52881691..333f4759afc 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAHttpClient.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAHttpClient.Codeunit.al @@ -109,6 +109,7 @@ codeunit 6941 "EA Http Client" RequestMessage.GetHeaders(Headers); Headers.Add('Accept', 'application/json'); Headers.Add('On-Behalf-Of', OnBehalfUser); + OnBeforeAddAuthHeaders(RequestMessage); AddAuthHeaders(Headers); IsSuccess := Client.Send(RequestMessage, ResponseMessage); @@ -166,6 +167,7 @@ codeunit 6941 "EA Http Client" Headers.Add('Accept', 'application/json'); Headers.Add('On-Behalf-Of', OnBehalfUser); AddCorrelationHeader(Headers, CorrelationId); + OnBeforeAddAuthHeaders(RequestMessage); AddAuthHeaders(Headers); IsSuccess := Client.Send(RequestMessage, ResponseMessage); @@ -219,6 +221,7 @@ codeunit 6941 "EA Http Client" Headers.Add('Accept', 'application/json'); Headers.Add('On-Behalf-Of', OnBehalfUser); AddCorrelationHeader(Headers, CorrelationId); + OnBeforeAddAuthHeaders(RequestMessage); AddAuthHeaders(Headers); IsSuccess := Client.Send(RequestMessage, ResponseMessage); @@ -267,6 +270,7 @@ codeunit 6941 "EA Http Client" Headers.Add('Accept', 'application/json'); Headers.Add('On-Behalf-Of', OnBehalfUser); AddCorrelationHeader(Headers, CorrelationId); + OnBeforeAddAuthHeaders(RequestMessage); AddAuthHeaders(Headers); IsSuccess := Client.Send(RequestMessage, ResponseMessage); @@ -283,6 +287,12 @@ codeunit 6941 "EA Http Client" exit(IsSuccess); end; + // Exposes the constructed application payload, before authentication headers are attached. + [InternalEvent(false, false)] + local procedure OnBeforeAddAuthHeaders(RequestMessage: HttpRequestMessage) + begin + end; + local procedure BuildReimbursementJson(PostedExpenseReportSystemId: Guid): Text var JsonObj: JsonObject; @@ -439,9 +449,18 @@ codeunit 6941 "EA Http Client" exit(false); end; + OnGetCommunicationBaseUrl(ExpenseAgentSetup."Use Canary Endpoint", BaseUrl); + if not BaseUrl.IsEmpty() then + exit(true); + exit(GetExpenseAgentBaseUrl(ExpenseAgentSetup."Use Canary Endpoint", BaseUrl)); end; + [InternalEvent(false, false)] + local procedure OnGetCommunicationBaseUrl(UseCanaryEndpoint: Boolean; var BaseUrl: SecretText) + begin + end; + local procedure GetExpenseAgentBaseUrl(UseCanaryEndpoint: Boolean; var BaseUrl: SecretText): Boolean var AzureKeyVault: Codeunit "Azure Key Vault"; diff --git a/src/Apps/W1/ExpenseAgent/app/src/Integration/EARetrieveEmails.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Integration/EARetrieveEmails.Codeunit.al index 92c41b6cfa3..a2cd7cd20f6 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Integration/EARetrieveEmails.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Integration/EARetrieveEmails.Codeunit.al @@ -42,7 +42,6 @@ codeunit 6940 "EA Retrieve Emails" Processed: Integer; ProcessLimit: Integer; TelemetryDimensions: Dictionary of [Text, Text]; - StartDateTime: DateTime; begin ProcessLimit := EAAgentScheduler.GetProcessLimitPerDay(EASetup); Processed := EAMailSetup.GetEmailCountProcessedWithin24hrs(); @@ -63,6 +62,7 @@ codeunit 6940 "EA Retrieve Emails" TempFilters."Folder Id" := EASetup."Email Folder Id"; TempFilters."Earliest Email" := ExpenseAgentStatus."Earliest Sync At"; TempFilters.Insert(); + Commit(); Email.RetrieveEmails(EASetup."Email Account ID", EASetup."Email Connector", EmailInbox, TempFilters); @@ -80,15 +80,9 @@ codeunit 6940 "EA Retrieve Emails" if not EAEmail.FindSet() then exit; - StartDateTime := CurrentDateTime(); repeat AddEmailToAgentTask(EASetup, EAEmail); EmailsProcessedCount += 1; - // Prevent locks from being held for too long - if CurrentDateTime() - StartDateTime > 25000 then begin - Commit(); - StartDateTime := CurrentDateTime(); - end; Processed += 1; if Processed >= ProcessLimit then begin @@ -163,8 +157,10 @@ codeunit 6940 "EA Retrieve Emails" EAEmail."Sent DateTime" := EmailInbox."Sent DateTime"; EAEmail."Received DateTime" := EmailInbox."Received DateTime"; - if EAEmail.Insert() then + if EAEmail.Insert() then begin + Commit(); Email.MarkAsRead(EASetup."Email Account ID", EASetup."Email Connector", EmailInbox."External Message Id"); + end; until EmailInbox.Next() = 0; end; @@ -186,6 +182,8 @@ codeunit 6940 "EA Retrieve Emails" // Prepare request data ConversationId := Format(CreateGuid()); + // Do not carry locks from the preceding email across the service request. + Commit(); // Send email to expense agent with attachments IsSuccess := EAHttpClient.SubmitExpenseWithAttachments( diff --git a/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/ExpPrivacyNoticeReg.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/ExpPrivacyNoticeReg.Codeunit.al index fbc56abd4c7..5abb6ead6a1 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/ExpPrivacyNoticeReg.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/ExpPrivacyNoticeReg.Codeunit.al @@ -106,15 +106,18 @@ codeunit 6951 "Exp. Privacy Notice Reg." var CompanyRec: Record Company; ExpenseAgentSetup: Record "Expense Agent Setup"; + EAAgentScheduler: Codeunit "EA Agent Scheduler"; begin CompanyRec.SetLoadFields(Name); if CompanyRec.FindSet() then repeat ExpenseAgentSetup.ChangeCompany(CompanyRec.Name); + ExpenseAgentSetup.ReadIsolation(IsolationLevel::UpdLock); if ExpenseAgentSetup.Get() then if ExpenseAgentSetup."Enable Agent" then begin ExpenseAgentSetup.Validate("Enable Agent", false); ExpenseAgentSetup.Modify(); + EAAgentScheduler.RemoveAgentTasksForCompany(CompanyRec.Name); end; until CompanyRec.Next() = 0; end; diff --git a/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetup.Page.al b/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetup.Page.al index a9e691bef60..20be8817a38 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetup.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetup.Page.al @@ -49,11 +49,11 @@ page 6996 "Expense Agent Setup" trigger OnAssistEdit() var - OldEmailAddress: Text[250]; + PreviousSetup: Record "Expense Agent Setup"; begin - OldEmailAddress := Rec."Email Address"; + PreviousSetup := Rec; Rec.AssistEditMailbox(); - if OldEmailAddress <> Rec."Email Address" then + if Rec.HasSchedulingChanges(PreviousSetup) then ScheduleAllTasks(); end; } @@ -139,7 +139,7 @@ page 6996 "Expense Agent Setup" field("Noreply Email Address"; Rec."Noreply Email Address") { Caption = 'Account'; - ToolTip = 'Specifies the email account used for all outgoing Expense Agent messages: pending-approval requests sent to approvers, approved/rejected notifications sent to submitters, reimbursement notifications, and the optional open report reminders. If empty, the main mailbox account is used instead. When no email account is registered, the messages fail silently after the configured number of retries.'; + ToolTip = 'Specifies the account used for outgoing Expense Agent messages. Outgoing communication pauses when this account is missing; the incoming mailbox is not used as a fallback.'; Editable = false; Visible = false; ObsoleteState = Pending; @@ -147,8 +147,13 @@ page 6996 "Expense Agent Setup" ObsoleteReason = 'Use Configure Expense Agent to set up outgoing communication.'; trigger OnAssistEdit() + var + PreviousSetup: Record "Expense Agent Setup"; begin + PreviousSetup := Rec; Rec.AssistEditNoreplyMailbox(); + if Rec.HasSchedulingChanges(PreviousSetup) then + ScheduleAllTasks(); end; } } @@ -592,8 +597,8 @@ page 6996 "Expense Agent Setup" var EAAgentScheduler: Codeunit "EA Agent Scheduler"; begin - if Rec."Enable Agent" then - EAAgentScheduler.ScheduleAgent(Rec); + Rec.Modify(); + EAAgentScheduler.ScheduleAgent(Rec); end; #endif } \ No newline at end of file diff --git a/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetupWizard.Page.al b/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetupWizard.Page.al index d99a610606a..fd26adb55f5 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetupWizard.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetupWizard.Page.al @@ -8,7 +8,6 @@ using Microsoft.Foundation.NoSeries; using Microsoft.Foundation.UOM; using System.Agents; using System.AI; -using System.Email; using System.Environment; using System.Environment.Configuration; using System.Telemetry; @@ -964,6 +963,7 @@ page 6991 "Expense Agent Setup Wizard" UpdateAgentSetupBuffer(); + UpdateControls(); if AgentBeingEnabled() then if not ConfirmMissingAccountWarnings() then exit(false); @@ -1176,12 +1176,14 @@ page 6991 "Expense Agent Setup Wizard" var ExpenseAgentSetup: Record "Expense Agent Setup"; begin + ExpenseAgentSetup.ReadIsolation := IsolationLevel::UpdLock; if not ExpenseAgentSetup.Get() then ExpenseAgentSetup.Insert(true); ExpenseAgentSetup.TransferFields(Rec, false); if not IsNullGuid(AgentSetupBuffer."User Security ID") then ExpenseAgentSetup."User Security ID" := AgentSetupBuffer."User Security ID"; - ExpenseAgentSetup.Modify(true); + // The wizard reconciles once in ApplyScheduleChange after saving state and defaults. + ExpenseAgentSetup.Modify(false); end; local procedure ResolveAgentUserSecurityID(): Guid @@ -1358,75 +1360,22 @@ page 6991 "Expense Agent Setup Wizard" local procedure UpdateControls() begin - ValidateSelectedMailboxExists(); - ValidateNoreplyMailboxExists(); - end; - - local procedure ConfigUpdated() - begin - IsConfigUpdated := true; - end; - - local procedure StateChanged(): Boolean - begin - exit(AgentSetupBuffer.State <> InitialState); - end; - - local procedure ValidateSelectedMailboxExists() - var - EmailAccount: Record "Email Account"; - EmailAccountCU: Codeunit "Email Account"; - begin - if IsNullGuid(Rec."Email Account ID") then - exit; - - EmailAccountCU.GetAllAccounts(false, EmailAccount); - EmailAccount.SetRange("Account Id", Rec."Email Account ID"); - EmailAccount.SetRange(Connector, Rec."Email Connector"); - if not EmailAccount.IsEmpty() then - exit; - - // Stage the repair only; validating Enable Agent here would cancel live tasks before Update. - Rec.ClearMailboxAndDependents(); - Rec.Modify(); - EnableMailboxChanged := true; - ConfigUpdated(); - end; - - local procedure ValidateNoreplyMailboxExists() - var - EmailAccount: Record "Email Account"; - EmailAccountCU: Codeunit "Email Account"; - begin - if IsNullGuid(Rec."Noreply Email Account ID") then - exit; - - EmailAccountCU.GetAllAccounts(false, EmailAccount); - EmailAccount.SetRange("Account Id", Rec."Noreply Email Account ID"); - EmailAccount.SetRange(Connector, Rec."Noreply Email Connector"); - if not EmailAccount.IsEmpty() then + if not Rec.RepairMissingEmailAccounts() then exit; - Rec."Noreply Email Address" := ''; - Clear(Rec."Noreply Email Account ID"); - Clear(Rec."Noreply Email Connector"); Rec.Modify(); EnableMailboxChanged := true; ConfigUpdated(); end; - local procedure ScheduleAllTasks() - var - EAAgentScheduler: Codeunit "EA Agent Scheduler"; + local procedure ConfigUpdated() begin - EAAgentScheduler.ScheduleAgent(Rec); + IsConfigUpdated := true; end; - local procedure CancelAllTasks() - var - EAAgentScheduler: Codeunit "EA Agent Scheduler"; + local procedure StateChanged(): Boolean begin - EAAgentScheduler.RemoveAgentTasks(); + exit(AgentSetupBuffer.State <> InitialState); end; local procedure ValidatePrivacyNoticeApproval() @@ -1532,13 +1481,14 @@ page 6991 "Expense Agent Setup Wizard" end; local procedure ApplyScheduleChange() + var + EAAgentScheduler: Codeunit "EA Agent Scheduler"; begin if not ScheduleAffectingChange() then exit; - if Rec.ShouldScheduleAgentTask(AgentBeingEnabled()) then - ScheduleAllTasks() - else - CancelAllTasks(); + + // Reconcile both enable and disable through the scheduler's locked, persisted setup. + EAAgentScheduler.ScheduleAgent(Rec); end; local procedure EnsureCurrentUserHasAccess() @@ -1571,11 +1521,11 @@ page 6991 "Expense Agent Setup Wizard" local procedure OnAssistEditMailbox() var - PrevEmailAddress: Text[250]; + PreviousSetup: Record "Expense Agent Setup" temporary; begin - PrevEmailAddress := Rec."Email Address"; + PreviousSetup := Rec; Rec.AssistEditMailbox(); - if Rec."Email Address" <> PrevEmailAddress then begin + if MailboxConfigurationChanged(PreviousSetup) then begin EnableMailboxChanged := true; ConfigUpdated(); end; @@ -1583,16 +1533,25 @@ page 6991 "Expense Agent Setup Wizard" local procedure OnAssistEditNoreplyMailbox() var - PrevNoreplyAddress: Text[250]; + PreviousSetup: Record "Expense Agent Setup" temporary; begin - PrevNoreplyAddress := Rec."Noreply Email Address"; + PreviousSetup := Rec; Rec.AssistEditNoreplyMailbox(); - if Rec."Noreply Email Address" <> PrevNoreplyAddress then begin + if MailboxConfigurationChanged(PreviousSetup) then begin EnableMailboxChanged := true; ConfigUpdated(); end; end; + local procedure MailboxConfigurationChanged(PreviousSetup: Record "Expense Agent Setup" temporary): Boolean + begin + exit(Rec.HasSchedulingChanges(PreviousSetup) or + (Rec."Email Address" <> PreviousSetup."Email Address") or + (Rec."Email Folder" <> PreviousSetup."Email Folder") or + (Rec."Email Folder Id" <> PreviousSetup."Email Folder Id") or + (Rec."Noreply Email Address" <> PreviousSetup."Noreply Email Address")); + end; + local procedure RegisterErpConfiguration(): Boolean var EAHttpClient: Codeunit "EA Http Client"; diff --git a/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al b/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al index b0af734cc7b..0e0f95024ed 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al @@ -62,8 +62,6 @@ table 6930 "Expense Agent Setup" trigger OnValidate() begin - if not "Enable Agent" then - RemoveAllScheduledTasks(); if Rec."Enable Agent" then CheckBeforeEnablingAgent(); @@ -728,53 +726,112 @@ table 6930 "Expense Agent Setup" /// /// Returns whether the agent background task should be scheduled for the current /// setup. The task runs when the agent is enabled and there is work with a usable - /// account: inbound receipt processing (receipts on + a mailbox) or outbound - /// communication (welcome/reimbursement/approval/reminders on + a noreply account). + /// registered account: inbound receipt processing or outbound communication. /// AgentEnabled is passed in so the setup wizard can use its pending enable/disable /// state. /// internal procedure ShouldScheduleAgentTask(AgentEnabled: Boolean): Boolean - var - InboundConfigured: Boolean; - OutboundConfigured: Boolean; begin if not AgentEnabled then exit(false); - InboundConfigured := Rec."Enable Email with Receipts" and not IsNullGuid(Rec."Email Account ID"); - OutboundConfigured := Rec."Enable Communication" and not IsNullGuid(Rec."Noreply Email Account ID"); + exit(IsIncomingCommunicationConfigured() or IsOutgoingCommunicationConfigured()); + end; - exit(InboundConfigured or OutboundConfigured); + /// + /// Compares scheduling inputs without reading accounts, persisting changes or reconciling tasks. + /// Callers that save these changes own reconciliation after persistence. + /// + internal procedure HasSchedulingChanges(PreviousSetup: Record "Expense Agent Setup"): Boolean + begin + exit((Rec."Enable Agent" <> PreviousSetup."Enable Agent") or + (Rec."Enable Email with Receipts" <> PreviousSetup."Enable Email with Receipts") or + (Rec."Enable Communication" <> PreviousSetup."Enable Communication") or + (Rec."Email Account ID" <> PreviousSetup."Email Account ID") or + (Rec."Email Connector" <> PreviousSetup."Email Connector") or + (Rec."Noreply Email Account ID" <> PreviousSetup."Noreply Email Account ID") or + (Rec."Noreply Email Connector" <> PreviousSetup."Noreply Email Connector")); end; /// - /// Returns whether outgoing communication is fully configured: the master toggle is - /// on and a no-reply sender account is set. Outbound emails (welcome, reimbursement, + /// Returns whether incoming receipts are enabled and their account is locally registered. + /// Account registration does not verify connectivity or the current user's mailbox access. + /// + internal procedure IsIncomingCommunicationConfigured(): Boolean + var + EmailAccount: Codeunit "Email Account"; + begin + if not Rec."Enable Email with Receipts" or IsNullGuid(Rec."Email Account ID") then + exit(false); + + exit(EmailAccount.IsAccountRegistered(Rec."Email Account ID", Rec."Email Connector")); + end; + + /// + /// Returns whether outgoing communication is enabled and its no-reply account is locally + /// registered, without probing mailbox access. Outbound emails (welcome, reimbursement, /// approval, reminders) are only sent from the no-reply account; there is no fallback. /// internal procedure IsOutgoingCommunicationConfigured(): Boolean + var + EmailAccount: Codeunit "Email Account"; begin - exit(Rec."Enable Communication" and not IsNullGuid(Rec."Noreply Email Account ID")); + if not Rec."Enable Communication" or IsNullGuid(Rec."Noreply Email Account ID") then + exit(false); + + exit(EmailAccount.IsAccountRegistered(Rec."Noreply Email Account ID", Rec."Noreply Email Connector")); end; internal procedure RemoveAllScheduledTasks() var - ExpenseAgentStatus: Record "Expense Agent Status"; EAAgentScheduler: Codeunit "EA Agent Scheduler"; begin - ExpenseAgentStatus.GetOrCreate(); - EAAgentScheduler.RemoveAgentTask(ExpenseAgentStatus); + if Rec.IsTemporary() then + exit; + + EAAgentScheduler.RemoveAgentTasks(); end; - internal procedure ClearMailboxAndDependents() + internal procedure ClearIncomingMailbox() begin Rec."Email Address" := ''; Clear(Rec."Email Account ID"); Clear(Rec."Email Connector"); Rec."Email Folder" := ''; Rec."Email Folder Id" := ''; - Rec."Enable Email with Receipts" := false; - Rec."Enable Open Report Notif." := false; + end; + + internal procedure ClearNoreplyMailbox() + begin + Rec."Noreply Email Address" := ''; + Clear(Rec."Noreply Email Account ID"); + Clear(Rec."Noreply Email Connector"); + end; + + /// + /// Clears only unavailable account references on this record buffer. The caller owns + /// persistence and scheduling; user preferences and native agent state remain unchanged. + /// + internal procedure RepairMissingEmailAccounts() Changed: Boolean + var + EmailAccount: Codeunit "Email Account"; + EmptyEmailConnector: Enum "Email Connector"; + begin + if not EmailAccount.IsAccountRegistered(Rec."Email Account ID", Rec."Email Connector") then + if not IsNullGuid(Rec."Email Account ID") or (Rec."Email Address" <> '') or + (Rec."Email Connector" <> EmptyEmailConnector) or (Rec."Email Folder" <> '') or (Rec."Email Folder Id" <> '') + then begin + ClearIncomingMailbox(); + Changed := true; + end; + + if not EmailAccount.IsAccountRegistered(Rec."Noreply Email Account ID", Rec."Noreply Email Connector") then + if not IsNullGuid(Rec."Noreply Email Account ID") or (Rec."Noreply Email Address" <> '') or + (Rec."Noreply Email Connector" <> EmptyEmailConnector) + then begin + ClearNoreplyMailbox(); + Changed := true; + end; end; var @@ -827,11 +884,9 @@ table 6930 "Expense Agent Setup" Rec."Noreply Email Address" := TempEmailAccount."Email Address"; Rec.Modify(); end else - if Rec."Noreply Email Address" <> '' then + if not IsNullGuid(Rec."Noreply Email Account ID") or (Rec."Noreply Email Address" <> '') then if Confirm(ClearNoreplyAccountQst) then begin - Clear(Rec."Noreply Email Account ID"); - Clear(Rec."Noreply Email Connector"); - Rec."Noreply Email Address" := ''; + ClearNoreplyMailbox(); Rec.Modify(); end; end; @@ -855,6 +910,8 @@ table 6930 "Expense Agent Setup" // Probe with the chosen account before mutating Rec, so a failed access // check leaves the previously selected mailbox intact in the page. CheckSelectedIncomingMailboxAccessOrError(TempEmailAccount); + if (Rec."Email Account ID" <> TempEmailAccount."Account Id") or (Rec."Email Connector" <> TempEmailAccount.Connector) then + ClearIncomingMailbox(); Rec."Email Account ID" := TempEmailAccount."Account Id"; Rec."Email Connector" := TempEmailAccount.Connector; Rec."Email Address" := TempEmailAccount."Email Address"; @@ -863,12 +920,11 @@ table 6930 "Expense Agent Setup" Rec."Noreply Email Connector" := Rec."Email Connector"; Rec."Noreply Email Address" := Rec."Email Address"; end; + Rec.Modify(); end else - if Rec."Email Address" <> '' then + if not IsNullGuid(Rec."Email Account ID") or (Rec."Email Address" <> '') then if Confirm(ClearMailboxAccountQst) then begin - Clear(Rec."Email Account ID"); - Clear(Rec."Email Connector"); - Rec."Email Address" := ''; + ClearIncomingMailbox(); Rec.Modify(); end; end; @@ -979,11 +1035,12 @@ table 6930 "Expense Agent Setup" /// Verifies the current user can access every mailbox the enabled features will use before /// the agent task is (re)scheduled: the receipts mailbox when incoming receipts are on, and /// the no-reply mailbox when outgoing communication is on. Each check is skipped when its - /// feature is off or its account is unset, and errors when an account is set but inaccessible. + /// feature is off or its account is no longer registered, and errors on registered but + /// inaccessible accounts. /// internal procedure CheckSchedulingMailboxAccessOrError() begin - if Rec."Enable Email with Receipts" and not IsNullGuid(Rec."Email Account ID") then + if IsIncomingCommunicationConfigured() then CheckIncomingMailboxAccessOrError(); if IsOutgoingCommunicationConfigured() then CheckNoreplyMailboxAccessOrError(); diff --git a/src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/notification-outbox-accepted.json b/src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/notification-outbox-accepted.json new file mode 100644 index 00000000000..e130db5c71d --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/notification-outbox-accepted.json @@ -0,0 +1,4 @@ +{ + "success": true, + "notification_sent": true +} diff --git a/src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/outbox-email-correlated.json b/src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/outbox-email-correlated.json new file mode 100644 index 00000000000..a44f0e7959d --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/outbox-email-correlated.json @@ -0,0 +1,7 @@ +{ + "toLine": "recipient@example.invalid", + "subject": "Test welcome", + "body": "

Test notification.

", + "correlationId": "__REQUEST_CORRELATION_GUID__", + "notificationType": "Welcome" +} diff --git a/src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/receipt-accepted.json b/src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/receipt-accepted.json new file mode 100644 index 00000000000..8998895cfd3 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/receipt-accepted.json @@ -0,0 +1,5 @@ +{ + "success": true, + "temp_tracking_id": "test-tracking-id", + "message": "Expense processing started successfully" +} diff --git a/src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/reminder-send-failed.json b/src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/reminder-send-failed.json new file mode 100644 index 00000000000..01d2db99ae5 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/reminder-send-failed.json @@ -0,0 +1,8 @@ +{ + "success": true, + "report_count": 1, + "skipped": false, + "email_generated": true, + "notification_sent": false, + "send_error": "RuntimeError" +} diff --git a/src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/reminder-skipped.json b/src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/reminder-skipped.json new file mode 100644 index 00000000000..5e2d04252d9 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/reminder-skipped.json @@ -0,0 +1,5 @@ +{ + "success": true, + "skipped": true, + "report_count": 0 +} diff --git a/src/Apps/W1/ExpenseAgent/test/app.json b/src/Apps/W1/ExpenseAgent/test/app.json index e85e871913c..599648a04e2 100644 --- a/src/Apps/W1/ExpenseAgent/test/app.json +++ b/src/Apps/W1/ExpenseAgent/test/app.json @@ -35,6 +35,12 @@ "name": "Business Foundation Test Libraries", "publisher": "Microsoft", "version": "30.0.0.0" + }, + { + "id": "9856ae4f-d1a7-46ef-89bb-6ef056398228", + "name": "System Application Test Library", + "publisher": "Microsoft", + "version": "30.0.0.0" } ], "platform": "30.0.0.0", @@ -61,5 +67,8 @@ "allowDownloadingSource": true, "includeSourceInSymbolFile": true }, - "target": "OnPrem" + "target": "OnPrem", + "resourceFolders": [ + "HttpResponseFiles" + ] } \ No newline at end of file diff --git a/src/Apps/W1/ExpenseAgent/test/src/EAAgentDispatcherTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EAAgentDispatcherTest.Codeunit.al index 671ced8cda3..c1be8f3c55a 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EAAgentDispatcherTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EAAgentDispatcherTest.Codeunit.al @@ -5,17 +5,727 @@ namespace Microsoft.Test.ExpenseAgent; using Microsoft.ExpenseAgent; +using System.AI; using System.Email; +using System.Environment; +using System.TestLibraries.Email; +using System.Utilities; codeunit 148314 "EA Agent Dispatcher Test" { Subtype = Test; TestPermissions = Disabled; + RequiredTestIsolation = Function; + TestHttpRequestPolicy = BlockOutboundRequests; + EventSubscriberInstance = Manual; var Assert: Codeunit Assert; + ConnectorMock: Codeunit "Connector Mock"; + ExpectedPath: Text; + ResponseResource: Text; + ResponseStatusCode: Integer; + HttpRequestCount: Integer; + ObservedRequestCount: Integer; + EndpointResolutionCount: Integer; + ExpectedUseCanaryEndpoint: Boolean; + RequestCorrelationId: Guid; + MultipartBody: Text; + MultipartContentType: Text; + UnexpectedRequest: Text; + ReceiptMessageId: Guid; + OutgoingMockAccountId: Guid; + FixtureMessageIds: List of [Guid]; + DisableOutgoingAfterSend: Boolean; + TestCompanyTok: Label 'EA Email Lifecycle Test', Locked = true; + ServiceBaseUrlTok: Label 'https://expense-agent.example.invalid', Locked = true; + RecipientEmailTok: Label 'recipient@example.invalid', Locked = true; OneOwnerMustBeDefinedErr: Label 'At least one user must be able to configure the Expense Agent.'; + [Test] + procedure OutgoingPassSendsMultipleRowsWithoutIncomingAccount() + var + Setup: Record "Expense Agent Setup"; + ExpenseUser: Record "Expense User"; + FirstOutboxEmail: Record "EA Outbox Email"; + SecondOutboxEmail: Record "EA Outbox Email"; + begin + InitializeCommunication(Setup, false, true); + CreateRecipient(ExpenseUser, false); + CreatePendingEmail(FirstOutboxEmail); + CreatePendingEmail(SecondOutboxEmail); + + RunCommunication(Setup); + + FirstOutboxEmail.Get(FirstOutboxEmail.Id); + SecondOutboxEmail.Get(SecondOutboxEmail.Id); + Assert.AreEqual(FirstOutboxEmail.Status::Sent, FirstOutboxEmail.Status, 'Outgoing must work with receipts enabled but no incoming account.'); + Assert.AreEqual(SecondOutboxEmail.Status::Sent, SecondOutboxEmail.Status, 'Direct passes must initialize the per-run limit above one.'); + Assert.IsFalse(IsNullGuid(ConnectorMock.GetEmailMessageID()), 'The real Email.Send path must reach the mock connector.'); + AssertNoIncomingProcessing(); + end; + + [Test] + [HandlerFunctions('ExpenseServiceHandler')] + procedure IncomingOnlySubmitsMultipartReceiptAndPreservesOutgoingWork() + var + Setup: Record "Expense Agent Setup"; + ExpenseUser: Record "Expense User"; + OutboxEmail: Record "EA Outbox Email"; + EAKPI: Record "EA KPI"; + FilesReceivedBefore: Integer; + begin + InitializeCommunication(Setup, true, false); + CreateRecipient(ExpenseUser, true); + CreatePendingEmail(OutboxEmail); + CreateReceiptInbox(Setup); + EAKPI.GetSafe(); + FilesReceivedBefore := EAKPI."File Received"; + ExpectService('/api/v1.0/expenses/process', 'receipt-accepted.json', 202); + + RunCommunication(Setup); + + Assert.AreEqual(1, HttpRequestCount, 'Incoming-only must submit one receipt.'); + AssertMultipartReceipt(); + AssertReceiptProcessed(); + EAKPI.GetSafe(); + Assert.AreEqual(FilesReceivedBefore + 2, EAKPI."File Received", '202 is acceptance of both attachments, not completed processing or delivery.'); + AssertOutgoingUnchanged(OutboxEmail, ExpenseUser); + end; + + [Test] + procedure MissingBothAccountsRunsNoCommunicationPhases() + var + Setup: Record "Expense Agent Setup"; + ExpenseUser: Record "Expense User"; + OutboxEmail: Record "EA Outbox Email"; + ExpenseAgentStatus: Record "Expense Agent Status"; + PreviousNotificationRun: DateTime; + begin + InitializeCommunication(Setup, false, false); + CreateRecipient(ExpenseUser, true); + CreatePendingEmail(OutboxEmail); + CreateEligibleReminder(Setup, ExpenseUser, PreviousNotificationRun); + ConnectorMock.FailOnRetrieveEmails(true); + ConnectorMock.FailOnSend(true); + + RunCommunication(Setup); + + AssertOutgoingUnchanged(OutboxEmail, ExpenseUser); + AssertNoIncomingProcessing(); + ExpenseAgentStatus.Get(); + Assert.AreEqual(PreviousNotificationRun, ExpenseAgentStatus."Last Notif. Run At", 'Missing outgoing must not advance reminder polling.'); + end; + + [Test] + [HandlerFunctions('ExpenseServiceHandler')] + procedure DirectPassRetriesUntilFifthConnectorFailure() + var + Setup: Record "Expense Agent Setup"; + ExpenseUser: Record "Expense User"; + OutboxEmail: Record "EA Outbox Email"; + Attempt: Integer; + begin + InitializeCommunication(Setup, false, true); + CreateRecipient(ExpenseUser, true); + ExpectService('/api/v1.0/notifications/welcome', 'notification-outbox-accepted.json', 200); + RunCommunication(Setup); + ExpenseUser.Get(ExpenseUser."No."); + Assert.AreEqual(ExpenseUser."Welcome Email Status"::"In Outbox", ExpenseUser."Welcome Email Status", 'Retries start after a real successful handoff.'); + InsertCorrelatedCallback(OutboxEmail, RequestCorrelationId); + ExpectNoService(); + ConnectorMock.FailOnSend(true); + + for Attempt := 1 to 5 do begin + RunCommunication(Setup); + OutboxEmail.Get(OutboxEmail.Id); + ExpenseUser.Get(ExpenseUser."No."); + Assert.AreEqual(Attempt, OutboxEmail."Retry Count", 'One failed connector delivery per pass is one retry.'); + if Attempt < 5 then begin + Assert.AreEqual(OutboxEmail.Status::Pending, OutboxEmail.Status, 'Attempts one through four remain pending.'); + Assert.AreEqual(ExpenseUser."Welcome Email Status"::"In Outbox", ExpenseUser."Welcome Email Status", 'Nonterminal failures must not complete the welcome.'); + end else begin + Assert.AreEqual(OutboxEmail.Status::Failed, OutboxEmail.Status, 'The fifth failed attempt is terminal.'); + Assert.AreEqual(ExpenseUser."Welcome Email Status"::Failed, ExpenseUser."Welcome Email Status", 'Terminal failure must correlate back to the welcome.'); + end; + end; + + RunCommunication(Setup); + OutboxEmail.Get(OutboxEmail.Id); + Assert.AreEqual(5, OutboxEmail."Retry Count", 'Terminal rows must not be retried.'); + end; + + [Test] + [HandlerFunctions('ExpenseServiceHandler')] + procedure WelcomeAcceptanceThenCorrelatedCallbackAndDelivery() + var + Setup: Record "Expense Agent Setup"; + ExpenseUser: Record "Expense User"; + OutboxEmail: Record "EA Outbox Email"; + EmailMessage: Codeunit "Email Message"; + begin + InitializeCommunication(Setup, false, true); + CreateRecipient(ExpenseUser, true); + ExpectService('/api/v1.0/notifications/welcome', 'notification-outbox-accepted.json', 200); + + RunCommunication(Setup); + + ExpenseUser.Get(ExpenseUser."No."); + Assert.AreEqual(1, HttpRequestCount, 'One real welcome request is expected.'); + Assert.IsFalse(IsNullGuid(RequestCorrelationId), 'The production request must carry a correlation header.'); + Assert.AreEqual(RequestCorrelationId, ExpenseUser."Welcome Correlation Id", 'Hop one must persist the actual request correlation.'); + Assert.AreEqual(ExpenseUser."Welcome Email Status"::"In Outbox", ExpenseUser."Welcome Email Status", '200 acknowledges outbox handoff, not connector delivery.'); + Assert.AreEqual(0DT, ExpenseUser."Welcome Email Sent At", 'HTTP acceptance alone is not Sent.'); + Assert.IsTrue(OutboxEmail.IsEmpty(), 'Returning HTTP 200 alone must not fabricate a callback.'); + + // Simulated BC writeback, outside the HTTP TryFunction. This is not OData/auth validation. + InsertCorrelatedCallback(OutboxEmail, RequestCorrelationId); + Assert.AreEqual(OutboxEmail.Status::Pending, OutboxEmail.Status, 'The callback inserts pending work.'); + ExpectNoService(); + RunCommunication(Setup); + + OutboxEmail.Get(OutboxEmail.Id); + ExpenseUser.Get(ExpenseUser."No."); + Assert.AreEqual(OutboxEmail.Status::Sent, OutboxEmail.Status, 'The second production pass must deliver via Email.Send.'); + Assert.AreEqual(ExpenseUser."Welcome Email Status"::Sent, ExpenseUser."Welcome Email Status", 'Real outbox correlation must complete the welcome.'); + Assert.AreNotEqual(0DT, ExpenseUser."Welcome Email Sent At", 'Mocked connector delivery stamps Sent.'); + Assert.IsTrue(EmailMessage.Get(ConnectorMock.GetEmailMessageID()), 'The mock connector must receive a persisted email.'); + Assert.AreEqual(OutboxEmail.Subject, EmailMessage.GetSubject(), 'The callback subject must reach the connector.'); + Assert.AreEqual(OutboxEmail.ReadBody(), EmailMessage.GetBody(), 'The callback body must reach the connector.'); + end; + + [Test] + [HandlerFunctions('ExpenseServiceHandler')] + procedure WelcomeGatewayFailureDoesNotCreateOutboxOrMarkSent() + var + Setup: Record "Expense Agent Setup"; + ExpenseUser: Record "Expense User"; + OutboxEmail: Record "EA Outbox Email"; + begin + InitializeCommunication(Setup, false, true); + CreateRecipient(ExpenseUser, true); + // Only the HTTP 502 boundary is asserted; no unverified service error-body contract is invented. + ExpectService('/api/v1.0/notifications/welcome', '', 502); + + RunCommunication(Setup); + + ExpenseUser.Get(ExpenseUser."No."); + Assert.AreEqual(1, HttpRequestCount, 'The failure must come from the real HTTP status boundary.'); + Assert.AreEqual(ExpenseUser."Welcome Email Status"::Failed, ExpenseUser."Welcome Email Status", 'HTTP 502 fails the handoff.'); + Assert.IsTrue(IsNullGuid(ExpenseUser."Welcome Correlation Id"), 'Failed handoff must clear the user correlation.'); + Assert.AreEqual(0DT, ExpenseUser."Welcome Email Sent At", 'Failed handoff is not delivery.'); + Assert.IsTrue(OutboxEmail.IsEmpty(), 'A failed service handoff must not insert an outbox callback.'); + end; + + [Test] + procedure MissingSetupSkipsEndpointOverrideAndHttp() + var + Setup: Record "Expense Agent Setup"; + EAHttpClient: Codeunit "EA Http Client"; + Success: Boolean; + begin + AssertIsolatedCompany(); + ExpectNoService(); + Setup.DeleteAll(); + Commit(); + BindSubscription(this); + Success := EAHttpClient.SendWelcomeEmailNotification(RecipientEmailTok, CreateGuid()); + UnbindSubscription(this); + + Assert.IsFalse(Success, 'The real HTTP wrapper must reject missing persisted setup.'); + Assert.AreEqual(0, EndpointResolutionCount, 'Missing setup must be checked before the endpoint override event.'); + Assert.AreEqual(0, ObservedRequestCount, 'Missing setup must not construct a service request.'); + Assert.AreEqual(0, HttpRequestCount, 'Missing setup must not reach HTTP.'); + end; + + [Test] + [HandlerFunctions('ExpenseServiceHandler')] + procedure SavedCanarySelectionReachesCommunicationEndpoint() + var + Setup: Record "Expense Agent Setup"; + ExpenseUser: Record "Expense User"; + begin + InitializeCommunication(Setup, false, true); + CreateRecipient(ExpenseUser, true); + ExpectService('/api/v1.0/notifications/welcome', 'notification-outbox-accepted.json', 200); + RunCommunication(Setup); + Assert.AreEqual(1, EndpointResolutionCount, 'The saved default selection must reach endpoint resolution.'); + Assert.AreEqual(1, HttpRequestCount, 'The default selection must execute the real HTTP wrapper.'); + + Setup.Get(); + Setup."Use Canary Endpoint" := true; + Setup.Modify(); + CreateRecipient(ExpenseUser, true); + ExpectService('/api/v1.0/notifications/welcome', 'notification-outbox-accepted.json', 200); + ExpectedUseCanaryEndpoint := true; + RunCommunication(Setup); + Assert.AreEqual(1, EndpointResolutionCount, 'The saved canary selection must reach endpoint resolution.'); + Assert.AreEqual(1, HttpRequestCount, 'The canary selection must execute the real HTTP wrapper with a safe mock endpoint.'); + end; + + [Test] + [HandlerFunctions('ExpenseServiceHandler')] + procedure EligibleReminderWithoutIncomingAcceptsSkippedResponse() + begin + VerifyReminderResponse('reminder-skipped.json'); + end; + + [Test] + [HandlerFunctions('ExpenseServiceHandler')] + procedure ReminderBodyFailurePreservesExistingHttpOnlyBoundary() + begin + // Current AL wrappers inspect HTTP status only; do not reinterpret the service response body. + VerifyReminderResponse('reminder-send-failed.json'); + end; + + [Test] + [HandlerFunctions('ExpenseServiceHandler')] + procedure BothChannelsProcessReceiptAndPendingOutbox() + var + Setup: Record "Expense Agent Setup"; + ExpenseUser: Record "Expense User"; + OutboxEmail: Record "EA Outbox Email"; + begin + InitializeCommunication(Setup, true, true); + CreateRecipient(ExpenseUser, false); + CreatePendingEmail(OutboxEmail); + CreateReceiptInbox(Setup); + ExpectService('/api/v1.0/expenses/process', 'receipt-accepted.json', 202); + + RunCommunication(Setup); + + Assert.AreEqual(1, HttpRequestCount, 'The incoming phase must submit the receipt.'); + AssertMultipartReceipt(); + AssertReceiptProcessed(); + OutboxEmail.Get(OutboxEmail.Id); + Assert.AreEqual(OutboxEmail.Status::Sent, OutboxEmail.Status, 'Outgoing must also run in the same production pass.'); + end; + + [Test] + procedure ReReadsPersistedSetupAfterCommittingOutboxPhase() + var + Setup: Record "Expense Agent Setup"; + ExpenseUser: Record "Expense User"; + OutboxEmail: Record "EA Outbox Email"; + ExpenseAgentStatus: Record "Expense Agent Status"; + PreviousNotificationRun: DateTime; + begin + InitializeCommunication(Setup, false, true); + CreateRecipient(ExpenseUser, true); + CreatePendingEmail(OutboxEmail); + CreateEligibleReminder(Setup, ExpenseUser, PreviousNotificationRun); + DisableOutgoingAfterSend := true; + + RunCommunication(Setup); + + Setup.Get(); + Assert.IsFalse(Setup."Enable Communication", 'The callback must persist the changed setup during the send phase.'); + OutboxEmail.Get(OutboxEmail.Id); + Assert.AreEqual(OutboxEmail.Status::Sent, OutboxEmail.Status, 'Already executing delivery completes.'); + ExpenseUser.Get(ExpenseUser."No."); + Assert.AreEqual(ExpenseUser."Welcome Email Status"::Queued, ExpenseUser."Welcome Email Status", 'Later phases must not use stale setup.'); + ExpenseAgentStatus.Get(); + Assert.AreEqual(PreviousNotificationRun, ExpenseAgentStatus."Last Notif. Run At", 'Reminders must use the saved disabled configuration.'); + end; + + local procedure InitializeCommunication(var Setup: Record "Expense Agent Setup"; IncomingAvailable: Boolean; OutgoingAvailable: Boolean) + var + OutboxEmail: Record "EA Outbox Email"; + EmailOutbox: Record "Email Outbox"; + ExpenseUser: Record "Expense User"; + ExpenseReportHeader: Record "Expense Report Header"; + EAEmail: Record "EA Email"; + ExpenseAgentStatus: Record "Expense Agent Status"; + TempEmailInbox: Record "Email Inbox" temporary; + TestEmailConnector: Codeunit "Test Email Connector v4"; + CopilotCapability: Codeunit "Copilot Capability"; + ExpenseAgentAppId: Guid; + begin + AssertIsolatedCompany(); + Evaluate(ExpenseAgentAppId, '66efe10c-8033-403b-a86d-77c0887178ba'); + Assert.IsTrue(CopilotCapability.IsCapabilityActive(Enum::"Copilot Capability"::"Expense Agent", ExpenseAgentAppId), + 'Expense Agent capability and required privacy approvals must already be enabled. These tests never change tenant-wide Copilot settings or approvals.'); + Assert.IsTrue(EmailOutbox.IsEmpty(), 'The disposable company must have no existing email outbox rows, including failed background work.'); + if ExpenseAgentStatus.Get() then begin + Assert.IsTrue(IsNullGuid(ExpenseAgentStatus."Agent Task ID"), 'The isolated fixture must not have a configured dispatcher.'); + Assert.IsTrue(IsNullGuid(ExpenseAgentStatus."Agent Recovery Task ID"), 'The isolated fixture must not have configured recovery.'); + end; + Clear(ReceiptMessageId); + Clear(OutgoingMockAccountId); + Clear(FixtureMessageIds); + Clear(DisableOutgoingAfterSend); + ExpectNoService(); + TestEmailConnector.SetEmailInbox(TempEmailInbox); + ConnectorMock.Initialize(); + OutboxEmail.DeleteAll(); + ExpenseUser.DeleteAll(); + ExpenseReportHeader.DeleteAll(); + EAEmail.DeleteAll(true); + ExpenseAgentStatus.DeleteAll(); + ExpenseAgentStatus.GetOrCreate(); + Setup.DeleteAll(); + Setup.Init(); + Setup."Enable Agent" := true; + Setup."Enable Email with Receipts" := true; + Setup."Enable Communication" := true; + Setup."Enable Open Report Notif." := false; + Setup."Use Canary Endpoint" := false; + if IncomingAvailable then begin + Setup."Email Account ID" := RegisterMockAccount('receipts@example.invalid'); + Setup."Email Connector" := Enum::"Email Connector"::"Test Email Connector v4"; + Setup."Email Address" := 'receipts@example.invalid'; + end; + if OutgoingAvailable then begin + Setup."Noreply Email Account ID" := RegisterMockAccount('noreply@example.invalid'); + OutgoingMockAccountId := Setup."Noreply Email Account ID"; + Setup."Noreply Email Connector" := Enum::"Email Connector"::"Test Email Connector v4"; + Setup."Noreply Email Address" := 'noreply@example.invalid'; + end; + Setup.Insert(); + + Commit(); + end; + + local procedure AssertIsolatedCompany() + var + EnvironmentInformation: Codeunit "Environment Information"; + begin + Assert.AreEqual(TestCompanyTok, CompanyName(), 'Run only in the dedicated disposable EA Email Lifecycle Test company, never CRONUS.'); + Assert.IsFalse(EnvironmentInformation.IsSaaS(), 'Mock integration tests require on-prem; SaaS authentication is not under test.'); + Assert.IsFalse(EnvironmentInformation.IsSaaSInfrastructure(), 'These tests must not use SaaS infrastructure.'); + end; + + local procedure RegisterMockAccount(Address: Text[250]): Guid + var + TestEmailAccount: Record "Test Email Account"; + TempEmailAccount: Record "Email Account" temporary; + EmailAccount: Codeunit "Email Account"; + begin + // This overload creates the matching connector's explicit zero (unlimited) rate-limit row. + ConnectorMock.AddAccount(TempEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); + TestEmailAccount.Get(TempEmailAccount."Account Id"); + TestEmailAccount.Email := Address; + TestEmailAccount.Name := 'Expense communication mock'; + TestEmailAccount.Modify(); + Assert.IsTrue(EmailAccount.IsAccountRegistered(TestEmailAccount.Id, TestEmailAccount.Connector), 'The native mock account must be registered.'); + exit(TestEmailAccount.Id); + end; + + local procedure RunCommunication(var Setup: Record "Expense Agent Setup") + var + TempEmailInbox: Record "Email Inbox" temporary; + Dispatcher: Codeunit "EA Agent Dispatcher"; + TestEmailConnector: Codeunit "Test Email Connector v4"; + ErrorMessage: Text; + Success: Boolean; + begin + // The endpoint override and read-only request observers are bound only for this production pass. + BindSubscription(this); + Commit(); + Success := Dispatcher.ProcessCommunication(Setup, ErrorMessage); + UnbindSubscription(this); + TestEmailConnector.SetEmailInbox(TempEmailInbox); + Assert.IsTrue(Success, 'The scheduler-free production pass failed: ' + ErrorMessage); + Assert.AreEqual('', ErrorMessage, 'Runnable channels must not report a missing-incoming error.'); + Assert.AreEqual('', UnexpectedRequest, 'Unexpected HTTP must fail even if production catches the handler error.'); + Assert.AreEqual(HttpRequestCount, ObservedRequestCount, 'Every observed production request must reach the native HTTP mock.'); + Assert.AreEqual(HttpRequestCount, EndpointResolutionCount, 'Each mocked request must resolve its endpoint through the real persisted-setup boundary.'); + end; + + local procedure CreateRecipient(var ExpenseUser: Record "Expense User"; QueueWelcome: Boolean) + begin + ExpenseUser.Init(); + ExpenseUser."No." := CopyStr(DelChr(Format(CreateGuid()), '=', '{}-'), 1, MaxStrLen(ExpenseUser."No.")); + ExpenseUser."E-mail" := RecipientEmailTok; + if QueueWelcome then + ExpenseUser."Welcome Email Status" := ExpenseUser."Welcome Email Status"::Queued; + ExpenseUser.Insert(); + end; + + local procedure CreatePendingEmail(var OutboxEmail: Record "EA Outbox Email") + begin + OutboxEmail.Init(); + OutboxEmail.Id := 0; + OutboxEmail.ToLine := RecipientEmailTok; + OutboxEmail.Subject := 'Isolated communication test'; + OutboxEmail.WriteBody('

Mock notification.

'); + OutboxEmail.Insert(); + end; + + local procedure CreateReceiptInbox(Setup: Record "Expense Agent Setup") + var + TempEmailInbox: Record "Email Inbox" temporary; + EmailMessage: Codeunit "Email Message"; + TestEmailConnector: Codeunit "Test Email Connector v4"; + TempBlob: Codeunit "Temp Blob"; + AttachmentInStream: InStream; + AttachmentOutStream: OutStream; + begin + EmailMessage.Create('receipts@example.invalid', 'Receipt € ø', '

Two receipts for processing.

', true); + TempBlob.CreateOutStream(AttachmentOutStream, TextEncoding::UTF8); + AttachmentOutStream.WriteText('mock-receipt-one'); + TempBlob.CreateInStream(AttachmentInStream); + EmailMessage.AddAttachment('receipt-one.pdf', 'application/pdf', AttachmentInStream); + Clear(TempBlob); + TempBlob.CreateOutStream(AttachmentOutStream, TextEncoding::UTF8); + AttachmentOutStream.WriteText('mock-receipt-two'); + TempBlob.CreateInStream(AttachmentInStream); + EmailMessage.AddAttachment('receipt-two.png', 'image/png', AttachmentInStream); + ReceiptMessageId := EmailMessage.GetId(); + TempEmailInbox.Id := 1; + TempEmailInbox."Account Id" := Setup."Email Account ID"; + TempEmailInbox.Connector := Setup."Email Connector"; + TempEmailInbox."Message Id" := ReceiptMessageId; + TempEmailInbox."Sender Address" := RecipientEmailTok; + TempEmailInbox."Sender Name" := 'Mock expense user'; + TempEmailInbox."Received DateTime" := CurrentDateTime(); + TempEmailInbox."Sent DateTime" := CurrentDateTime(); + TempEmailInbox."External Message Id" := Format(CreateGuid()); + TempEmailInbox.Insert(); + TestEmailConnector.SetEmailInbox(TempEmailInbox); + Commit(); + end; + + local procedure AssertMultipartReceipt() + var + Parts: List of [Text]; + begin + Assert.IsTrue(MultipartContentType.StartsWith('multipart/form-data; boundary='), 'Receipt requests must remain multipart.'); + Assert.IsTrue(MultipartBody.Contains('name="conversation_id"'), 'Multipart must carry the production conversation id.'); + Assert.IsTrue(MultipartBody.Contains('name="context"'), 'Multipart must carry context.'); + Assert.IsTrue(MultipartBody.Contains('Receipt € ø'), 'Context must preserve UTF-8 text.'); + Assert.IsTrue(MultipartBody.Contains('Two receipts for processing.'), 'Receipt context must contain the inbox body.'); + Parts := MultipartBody.Split('name="attachments"'); + Assert.AreEqual(3, Parts.Count(), 'Two attachments must use the same repeated multipart field name.'); + Assert.IsTrue(MultipartBody.Contains('filename="receipt-one.pdf"'), 'First attachment filename must be serialized.'); + Assert.IsTrue(MultipartBody.Contains('filename="receipt-two.png"'), 'Second attachment filename must be serialized.'); + Assert.IsTrue(MultipartBody.Contains('mock-receipt-one'), 'First attachment bytes must be serialized.'); + Assert.IsTrue(MultipartBody.Contains('mock-receipt-two'), 'Second attachment bytes must be serialized.'); + end; + + local procedure AssertReceiptProcessed() + var + EmailInbox: Record "Email Inbox"; + EAEmail: Record "EA Email"; + ExpenseAgentStatus: Record "Expense Agent Status"; + begin + EmailInbox.SetRange("Message Id", ReceiptMessageId); + Assert.IsTrue(EmailInbox.FindFirst(), 'The native mock inbox must have been retrieved.'); + EAEmail.Get(EmailInbox.Id); + Assert.IsTrue(EAEmail.Processed, 'The real receipt phase must mark the inbox item processed.'); + ExpenseAgentStatus.Get(); + Assert.AreNotEqual(0DT, ExpenseAgentStatus."Last Sync At", 'Successful incoming phase must update sync status.'); + end; + + local procedure AssertNoIncomingProcessing() + var + EAEmail: Record "EA Email"; + ExpenseAgentStatus: Record "Expense Agent Status"; + begin + Assert.IsTrue(EAEmail.IsEmpty(), 'Missing incoming must not retrieve any email.'); + ExpenseAgentStatus.Get(); + Assert.AreEqual(0DT, ExpenseAgentStatus."Last Sync At", 'Skipped incoming must not update sync status.'); + end; + + local procedure AssertOutgoingUnchanged(var OutboxEmail: Record "EA Outbox Email"; var ExpenseUser: Record "Expense User") + begin + OutboxEmail.Get(OutboxEmail.Id); + ExpenseUser.Get(ExpenseUser."No."); + Assert.AreEqual(OutboxEmail.Status::Pending, OutboxEmail.Status, 'Missing outgoing leaves outbox work pending.'); + Assert.AreEqual(0, OutboxEmail."Retry Count", 'A skipped channel must not consume retries.'); + Assert.AreEqual(ExpenseUser."Welcome Email Status"::Queued, ExpenseUser."Welcome Email Status", 'A skipped channel preserves welcome work.'); + Assert.IsTrue(IsNullGuid(ConnectorMock.GetEmailMessageID()), 'No delivery may reach the connector.'); + end; + + local procedure InsertCorrelatedCallback(var OutboxEmail: Record "EA Outbox Email"; CorrelationId: Guid) + var + Callback: JsonObject; + Value: JsonToken; + CallbackText: Text; + begin + CallbackText := NavApp.GetResourceAsText('outbox-email-correlated.json', TextEncoding::UTF8); + CallbackText := CallbackText.Replace('__REQUEST_CORRELATION_GUID__', Format(CorrelationId, 0, 4)); + Callback.ReadFrom(CallbackText); + OutboxEmail.Init(); + Callback.Get('toLine', Value); + OutboxEmail.ToLine := CopyStr(Value.AsValue().AsText(), 1, MaxStrLen(OutboxEmail.ToLine)); + Callback.Get('subject', Value); + OutboxEmail.Subject := CopyStr(Value.AsValue().AsText(), 1, MaxStrLen(OutboxEmail.Subject)); + Callback.Get('body', Value); + OutboxEmail.WriteBody(Value.AsValue().AsText()); + Callback.Get('correlationId', Value); + Evaluate(OutboxEmail."Correlation Id", Value.AsValue().AsText()); + Callback.Get('notificationType', Value); + Assert.AreEqual('Welcome', Value.AsValue().AsText(), 'Only a Welcome callback is exercised here.'); + OutboxEmail."Notification Type" := OutboxEmail."Notification Type"::Welcome; + OutboxEmail.Insert(); + end; + + local procedure CreateEligibleReminder(var Setup: Record "Expense Agent Setup"; ExpenseUser: Record "Expense User"; var PreviousRun: DateTime) + var + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseAgentStatus: Record "Expense Agent Status"; + begin + Setup."Enable Open Report Notif." := true; + Setup."Open Report Notif. Freq." := Enum::"Expense Report Frequency"::Daily; + Setup.Modify(); + ExpenseReportHeader.Init(); + ExpenseReportHeader."No." := CopyStr(DelChr(Format(CreateGuid()), '=', '{}-'), 1, MaxStrLen(ExpenseReportHeader."No.")); + ExpenseReportHeader."Expense User No." := ExpenseUser."No."; + ExpenseReportHeader.Status := Enum::"Expense Report Status"::Open; + ExpenseReportHeader.Insert(); + PreviousRun := CreateDateTime(Today() - 2, 090000T); + ExpenseAgentStatus.Get(); + ExpenseAgentStatus."Last Notif. Run At" := PreviousRun; + ExpenseAgentStatus.Modify(); + end; + + local procedure VerifyReminderResponse(ResourceName: Text) + var + Setup: Record "Expense Agent Setup"; + ExpenseUser: Record "Expense User"; + ExpenseAgentStatus: Record "Expense Agent Status"; + OutboxEmail: Record "EA Outbox Email"; + PreviousRun: DateTime; + begin + InitializeCommunication(Setup, false, true); + CreateRecipient(ExpenseUser, false); + CreateEligibleReminder(Setup, ExpenseUser, PreviousRun); + ExpectService('/api/v1.0/notifications/open-reports-reminder', ResourceName, 200); + + RunCommunication(Setup); + + Assert.AreEqual(1, HttpRequestCount, 'An eligible local open report must cause a real reminder request.'); + Assert.IsFalse(IsNullGuid(RequestCorrelationId), 'The reminder request carries a production correlation id.'); + Assert.IsTrue(OutboxEmail.IsEmpty(), 'Skipped/body-failed reminders have no callback and no outbox delivery.'); + ExpenseAgentStatus.Get(); + Assert.IsTrue(ExpenseAgentStatus."Last Notif. Run At" > PreviousRun, 'HTTP 200 advances the current HTTP-only polling boundary.'); + AssertNoIncomingProcessing(); + end; + + local procedure ExpectNoService() + begin + Clear(ExpectedPath); + Clear(ResponseResource); + Clear(ResponseStatusCode); + Clear(HttpRequestCount); + Clear(ObservedRequestCount); + Clear(EndpointResolutionCount); + Clear(ExpectedUseCanaryEndpoint); + Clear(RequestCorrelationId); + Clear(MultipartBody); + Clear(MultipartContentType); + Clear(UnexpectedRequest); + end; + + local procedure ExpectService(Path: Text; ResourceName: Text; StatusCode: Integer) + begin + ExpectNoService(); + ExpectedPath := ServiceBaseUrlTok + Path; + ResponseResource := ResourceName; + ResponseStatusCode := StatusCode; + end; + + [HttpClientHandler] + procedure ExpenseServiceHandler(Request: TestHttpRequestMessage; var Response: TestHttpResponseMessage): Boolean + begin + if (Request.RequestType <> HttpRequestType::POST) or (ExpectedPath = '') or + (Request.Path <> ExpectedPath) or (Request.QueryParameters.Count() <> 0) or Request.HasSecretUri() + then begin + UnexpectedRequest := Format(Request.RequestType) + ' ' + Request.Path; + Error('Unexpected Expense Agent HTTP request: %1', UnexpectedRequest); + end; + HttpRequestCount += 1; + if ResponseResource <> '' then + Response.Content.WriteFrom(NavApp.GetResourceAsText(ResponseResource, TextEncoding::UTF8)) + else + Response.Content.WriteFrom(''); + Response.HttpStatusCode := ResponseStatusCode; + exit(false); + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"EA Http Client", 'OnGetCommunicationBaseUrl', '', false, false)] + local procedure SetCommunicationBaseUrl(UseCanaryEndpoint: Boolean; var BaseUrl: SecretText) + begin + Assert.AreEqual(ExpectedUseCanaryEndpoint, UseCanaryEndpoint, 'Endpoint selection must use the saved company setup flag.'); + Assert.IsTrue(BaseUrl.IsEmpty(), 'The communication override must precede normal endpoint lookup.'); + BaseUrl := ServiceBaseUrlTok; + Assert.IsFalse(BaseUrl.IsEmpty(), 'The isolated mock endpoint must be nonempty.'); + EndpointResolutionCount += 1; + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"EA Http Client", 'OnBeforeAddAuthHeaders', '', false, false)] + local procedure ObserveServiceRequest(RequestMessage: HttpRequestMessage) + var + Headers: HttpHeaders; + HeaderValues: List of [Text]; + Content: HttpContent; + begin + // TestHttpRequestMessage exposes routing only, so this read-only observer checks the actual request. + ObservedRequestCount += 1; + Assert.AreEqual(ExpectedPath, RequestMessage.GetRequestUri(), 'Unexpected service host/path.'); + Assert.AreEqual('POST', RequestMessage.Method(), 'Expense requests must be POST.'); + RequestMessage.GetHeaders(Headers); + Assert.IsFalse(Headers.Contains('Authorization'), 'The observation boundary must not expose authorization headers.'); + Assert.IsTrue(Headers.GetValues('On-Behalf-Of', HeaderValues), 'The request must carry the intended expense user.'); + Assert.AreEqual(1, HeaderValues.Count(), 'Exactly one expense user is expected.'); + Assert.AreEqual(RecipientEmailTok, HeaderValues.Get(1), 'Only sanitized mock recipients are allowed.'); + Clear(HeaderValues); + if ExpectedPath.EndsWith('/expenses/process') then begin + Content := RequestMessage.Content(); + Content.ReadAs(MultipartBody); + Content.GetHeaders(Headers); + Headers.GetValues('Content-Type', HeaderValues); + MultipartContentType := HeaderValues.Get(1); + end else begin + Assert.IsTrue(Headers.GetValues('X-Correlation-Id', HeaderValues), 'Notification requests must carry a correlation id.'); + Assert.AreEqual(1, HeaderValues.Count(), 'Exactly one request correlation is expected.'); + Evaluate(RequestCorrelationId, HeaderValues.Get(1)); + end; + end; + + [EventSubscriber(ObjectType::Table, Database::"EA Outbox Email", 'OnAfterModifyEvent', '', false, false)] + local procedure DisableCommunicationAfterDelivery(var Rec: Record "EA Outbox Email"; var xRec: Record "EA Outbox Email"; RunTrigger: Boolean) + var + Setup: Record "Expense Agent Setup"; + begin + if not DisableOutgoingAfterSend or Rec.IsTemporary() or (Rec.Status <> Rec.Status::Sent) then + exit; + Setup.Get(); + Setup."Enable Communication" := false; + Setup.Modify(); + DisableOutgoingAfterSend := false; + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::Email, 'OnEnqueuedInOutbox', '', false, false)] + local procedure AssertForegroundSendCannotThrottle(MessageId: Guid) + var + EmailOutbox: Record "Email Outbox"; + LibraryEmailMock: Codeunit "Library - Email Mock"; + FoundCurrentMessage: Boolean; + begin + // This event precedes Email Dispatcher. Rate is explicitly zero; concurrency counts Processing rows only. + // Only this new Queued row and known Failed foreground attempts may exist, so the processing count is zero. + Assert.IsFalse(IsNullGuid(OutgoingMockAccountId), 'No email may be queued without the fixture outgoing account.'); + Assert.IsFalse(FixtureMessageIds.Contains(MessageId), 'Every synchronous attempt must use a fresh message.'); + if EmailOutbox.FindSet() then + repeat + Assert.AreEqual(OutgoingMockAccountId, EmailOutbox.GetAccountId(), 'Unknown account work must not reach the native dispatcher.'); + Assert.AreEqual(Enum::"Email Connector"::"Test Email Connector v4", EmailOutbox.GetConnector(), 'Only the native mock connector is allowed.'); + if EmailOutbox.GetMessageId() = MessageId then begin + FoundCurrentMessage := true; + Assert.IsTrue(LibraryEmailMock.CheckEmailOutBoxStatusWithMessageId(MessageId, Enum::"Email Status"::Queued), + 'The current foreground message must still be queued before dispatch.'); + end else begin + Assert.IsTrue(FixtureMessageIds.Contains(EmailOutbox.GetMessageId()), 'Pre-existing background or unrelated emails are forbidden.'); + Assert.IsTrue(LibraryEmailMock.CheckEmailOutBoxStatusWithMessageId(EmailOutbox.GetMessageId(), Enum::"Email Status"::Failed), + 'Earlier fixture attempts must be Failed, never Queued or Processing.'); + end; + until EmailOutbox.Next() = 0; + Assert.IsTrue(FoundCurrentMessage, 'The foreground email must have a native outbox row.'); + FixtureMessageIds.Add(MessageId); + end; + [Test] procedure GetSendEmailAccountReturnsMainAccountWhenNoreplyNotConfigured() var @@ -48,12 +758,12 @@ codeunit 148314 "EA Agent Dispatcher Test" InitSetupWithMainAccount(Setup, MainAccountID); Setup."Noreply Email Account ID" := NoreplyAccountID; Setup."Noreply Email Connector" := Enum::"Email Connector"::"Test Email Connector"; - Setup."Noreply Email Address" := 'noreply@contoso.com'; + Setup."Noreply Email Address" := 'noreply@example.invalid'; Setup.Modify(); // [THEN] Noreply account is set Assert.AreEqual(NoreplyAccountID, Setup."Noreply Email Account ID", 'Noreply Email Account ID should be set.'); - Assert.AreEqual('noreply@contoso.com', Setup."Noreply Email Address", 'Noreply Email Address should be set.'); + Assert.AreEqual('noreply@example.invalid', Setup."Noreply Email Address", 'Noreply Email Address should be set.'); end; [Test] @@ -64,6 +774,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // [SCENARIO] New installations have empty noreply fields by default (backward-compatible). // [GIVEN] A fresh setup record + AssertIsolatedCompany(); Setup.DeleteAll(); Setup.Init(); Setup.Insert(); @@ -86,7 +797,7 @@ codeunit 148314 "EA Agent Dispatcher Test" InitSetupWithMainAccount(Setup, CreateGuid()); Setup."Noreply Email Account ID" := NoreplyAccountID; Setup."Noreply Email Connector" := Enum::"Email Connector"::"Test Email Connector"; - Setup."Noreply Email Address" := 'noreply@contoso.com'; + Setup."Noreply Email Address" := 'noreply@example.invalid'; Setup.Modify(); // [WHEN] The noreply fields are cleared @@ -103,11 +814,12 @@ codeunit 148314 "EA Agent Dispatcher Test" local procedure InitSetupWithMainAccount(var Setup: Record "Expense Agent Setup"; AccountID: Guid) begin + AssertIsolatedCompany(); Setup.DeleteAll(); Setup.Init(); Setup."Email Account ID" := AccountID; Setup."Email Connector" := Enum::"Email Connector"::"Test Email Connector"; - Setup."Email Address" := 'expenses@contoso.com'; + Setup."Email Address" := 'expenses@example.invalid'; Setup.Insert(); end; @@ -120,6 +832,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // [SCENARIO] EA Scheduler Task supports the new Failed status and stores an error message. // [GIVEN] A scheduler task in progress + AssertIsolatedCompany(); EASchedulerTask.DeleteAll(); Clear(EASchedulerTask); EASchedulerTask.Status := EASchedulerTask.Status::"In Progress"; @@ -147,6 +860,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // [SCENARIO] The Expense Agent Status FlowFields read Status and Error Message from the linked scheduler task. // [GIVEN] A failed scheduler task + AssertIsolatedCompany(); EASchedulerTask.DeleteAll(); Clear(EASchedulerTask); EASchedulerTask.Status := EASchedulerTask.Status::Failed; @@ -176,6 +890,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // [SCENARIO] GetByUserSecurityID finds an existing access control row by user. // [GIVEN] An access control record for a user + AssertIsolatedCompany(); AccessControl.DeleteAll(); UserID := CreateGuid(); InsertAccessControl(AccessControl, UserID, true, true); @@ -196,6 +911,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // [SCENARIO] GetByUserSecurityID returns false when no row exists for the user. // [GIVEN] No access control rows for the queried user + AssertIsolatedCompany(); AccessControl.DeleteAll(); // [THEN] Lookup returns false @@ -213,6 +929,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // [SCENARIO] Clearing Can Configure Agent on one owner is allowed when another owner remains. // [GIVEN] Two users with Can Configure Agent set to true + AssertIsolatedCompany(); AccessControl.DeleteAll(); SetupSystemID := EmptyGuid(); UserA := CreateGuid(); @@ -239,6 +956,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // [SCENARIO] Clearing 'Can Configure' Agent on the only owner raises an error. // [GIVEN] A single user with Can Configure Agent set to true + AssertIsolatedCompany(); AccessControl.DeleteAll(); UserID := CreateGuid(); InsertAccessControl(AccessControl, UserID, true, true); @@ -261,6 +979,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // [SCENARIO] Deleting an owner is allowed when at least one other owner remains. // [GIVEN] Two users with Can Configure Agent + AssertIsolatedCompany(); AccessControl.DeleteAll(); UserA := CreateGuid(); UserB := CreateGuid(); @@ -284,6 +1003,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // [SCENARIO] Deleting the only owner raises an error. // [GIVEN] A single user with Can Configure Agent + AssertIsolatedCompany(); AccessControl.DeleteAll(); UserID := CreateGuid(); InsertAccessControl(AccessControl, UserID, true, true); @@ -306,6 +1026,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // [SCENARIO] Deleting a non-owner row does not raise the owner rule even when only one owner exists. // [GIVEN] One owner and one non-owner + AssertIsolatedCompany(); AccessControl.DeleteAll(); OwnerID := CreateGuid(); NonOwnerID := CreateGuid(); diff --git a/src/Apps/W1/ExpenseAgent/test/src/EAAgentSchedulingTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EAAgentSchedulingTest.Codeunit.al index dd4fb6e18cf..3a76c0e2dee 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EAAgentSchedulingTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EAAgentSchedulingTest.Codeunit.al @@ -5,6 +5,8 @@ namespace Microsoft.Test.ExpenseAgent; using Microsoft.ExpenseAgent; +using System.Email; +using System.TestLibraries.Email; codeunit 148335 "EA Agent Scheduling Test" { @@ -14,19 +16,19 @@ codeunit 148335 "EA Agent Scheduling Test" var Assert: Codeunit Assert; + ConnectorMock: Codeunit "Connector Mock"; + IsolatedTestCompanyLbl: Label 'EA Email Lifecycle Test', Locked = true; [Test] procedure DisabledAgentIsNeverScheduled() var - Setup: Record "Expense Agent Setup"; + Setup: Record "Expense Agent Setup" temporary; begin // [SCENARIO 636970] The task is never scheduled when the agent is disabled, even if everything else is configured. // [GIVEN] Receipts on with a mailbox and communication on with a noreply account. - Setup.Init(); + InitializeSetup(Setup); Setup."Enable Email with Receipts" := true; - Setup."Email Account ID" := CreateGuid(); Setup."Enable Communication" := true; - Setup."Noreply Email Account ID" := CreateGuid(); // [THEN] Passing AgentEnabled = false never schedules. Assert.IsFalse(Setup.ShouldScheduleAgentTask(false), 'Disabled agent must not be scheduled.'); @@ -35,13 +37,12 @@ codeunit 148335 "EA Agent Scheduling Test" [Test] procedure ReceiptsOnWithMailboxSchedules() var - Setup: Record "Expense Agent Setup"; + Setup: Record "Expense Agent Setup" temporary; begin // [SCENARIO 636970] Inbound receipt processing schedules the task when a mailbox is configured. // [GIVEN] Enabled agent, receipts on with a mailbox, communication off. - Setup.Init(); + InitializeSetup(Setup); Setup."Enable Email with Receipts" := true; - Setup."Email Account ID" := CreateGuid(); Setup."Enable Communication" := false; // [THEN] Scheduled. @@ -51,11 +52,11 @@ codeunit 148335 "EA Agent Scheduling Test" [Test] procedure ReceiptsOnWithoutMailboxDoesNotSchedule() var - Setup: Record "Expense Agent Setup"; + Setup: Record "Expense Agent Setup" temporary; begin // [SCENARIO 636970] Receipts on but no mailbox does not schedule (nothing usable to do). // [GIVEN] Enabled agent, receipts on, no email account, communication off. - Setup.Init(); + InitializeSetup(Setup); Setup."Enable Email with Receipts" := true; Clear(Setup."Email Account ID"); Setup."Enable Communication" := false; @@ -67,14 +68,13 @@ codeunit 148335 "EA Agent Scheduling Test" [Test] procedure CommunicationOnWithNoreplySchedulesWhenReceiptsOff() var - Setup: Record "Expense Agent Setup"; + Setup: Record "Expense Agent Setup" temporary; begin // [SCENARIO 636970] Outbound communication keeps the task alive even when receipts are off. // [GIVEN] Enabled agent, receipts off, communication on with a noreply account. - Setup.Init(); + InitializeSetup(Setup); Setup."Enable Email with Receipts" := false; Setup."Enable Communication" := true; - Setup."Noreply Email Account ID" := CreateGuid(); // [THEN] Scheduled (the welcome/outbox path needs the task). Assert.IsTrue(Setup.ShouldScheduleAgentTask(true), 'Communication on with a noreply account should schedule.'); @@ -83,15 +83,14 @@ codeunit 148335 "EA Agent Scheduling Test" [Test] procedure CommunicationOnWithoutNoreplyDoesNotSchedule() var - Setup: Record "Expense Agent Setup"; + Setup: Record "Expense Agent Setup" temporary; begin // [SCENARIO 636970] Outbound communication requires a dedicated noreply account; the main mailbox is not used as a fallback. // [GIVEN] Enabled agent, receipts off, communication on, only the main email account set (no noreply). - Setup.Init(); + InitializeSetup(Setup); Setup."Enable Email with Receipts" := false; Setup."Enable Communication" := true; Clear(Setup."Noreply Email Account ID"); - Setup."Email Account ID" := CreateGuid(); // [THEN] Not scheduled — a noreply account is required for outbound communication. Assert.IsFalse(Setup.ShouldScheduleAgentTask(true), 'Communication requires a noreply account; the main account is not a fallback.'); @@ -100,11 +99,11 @@ codeunit 148335 "EA Agent Scheduling Test" [Test] procedure CommunicationOnWithoutAnyAccountDoesNotSchedule() var - Setup: Record "Expense Agent Setup"; + Setup: Record "Expense Agent Setup" temporary; begin // [SCENARIO 636970] Communication on but no sender account does not schedule. // [GIVEN] Enabled agent, receipts off, communication on, no accounts. - Setup.Init(); + InitializeSetup(Setup); Setup."Enable Email with Receipts" := false; Setup."Enable Communication" := true; Clear(Setup."Noreply Email Account ID"); @@ -117,15 +116,13 @@ codeunit 148335 "EA Agent Scheduling Test" [Test] procedure ReceiptsAndCommunicationOffDoesNotSchedule() var - Setup: Record "Expense Agent Setup"; + Setup: Record "Expense Agent Setup" temporary; begin // [SCENARIO 636970] With both receipts and communication off, the task is stopped even if accounts exist. // [GIVEN] Enabled agent, both toggles off, but accounts configured. - Setup.Init(); + InitializeSetup(Setup); Setup."Enable Email with Receipts" := false; Setup."Enable Communication" := false; - Setup."Email Account ID" := CreateGuid(); - Setup."Noreply Email Account ID" := CreateGuid(); // [THEN] Not scheduled (no idle background task). Assert.IsFalse(Setup.ShouldScheduleAgentTask(true), 'Both toggles off must not schedule.'); @@ -134,15 +131,14 @@ codeunit 148335 "EA Agent Scheduling Test" [Test] procedure ReceiptsWithoutMailboxButCommunicationOnStillSchedules() var - Setup: Record "Expense Agent Setup"; + Setup: Record "Expense Agent Setup" temporary; begin // [SCENARIO 636970] Regression: turning off the inbound mailbox no longer stops the scheduler when communication is on. // [GIVEN] Enabled agent, receipts on but no mailbox, communication on with a noreply account. - Setup.Init(); + InitializeSetup(Setup); Setup."Enable Email with Receipts" := true; Clear(Setup."Email Account ID"); Setup."Enable Communication" := true; - Setup."Noreply Email Account ID" := CreateGuid(); // [THEN] Still scheduled via the outbound path. Assert.IsTrue(Setup.ShouldScheduleAgentTask(true), 'Communication must keep the scheduler alive without the inbound mailbox.'); @@ -151,16 +147,15 @@ codeunit 148335 "EA Agent Scheduling Test" [Test] procedure OutgoingCommunicationConfiguredRequiresToggleAndNoreplyAccount() var - Setup: Record "Expense Agent Setup"; + Setup: Record "Expense Agent Setup" temporary; begin // [SCENARIO 636970] Outgoing communication is only configured when the master toggle is on - // and a no-reply account is set; the no-reply account alone is not enough and there is no + // and a no-reply account is registered; the no-reply account alone is not enough and there is no // fallback to the inbound mailbox. - Setup.Init(); + InitializeSetup(Setup); // [GIVEN] Communication on with a no-reply account. [THEN] Configured. Setup."Enable Communication" := true; - Setup."Noreply Email Account ID" := CreateGuid(); Assert.IsTrue(Setup.IsOutgoingCommunicationConfigured(), 'Communication on with a noreply account is configured.'); // [GIVEN] Communication off (account still set). [THEN] Not configured. @@ -172,4 +167,151 @@ codeunit 148335 "EA Agent Scheduling Test" Clear(Setup."Noreply Email Account ID"); Assert.IsFalse(Setup.IsOutgoingCommunicationConfigured(), 'Communication on without a noreply account must not be configured.'); end; + + [Test] + procedure RegisteredChannelAvailabilityMatrix() + var + Setup: Record "Expense Agent Setup" temporary; + RegisteredSetup: Record "Expense Agent Setup" temporary; + IncomingState: Integer; + OutgoingState: Integer; + ReceiptsPreference: Integer; + CommunicationPreference: Integer; + IncomingAvailable: Boolean; + OutgoingAvailable: Boolean; + begin + InitializeSetup(RegisteredSetup); + + // Each channel is empty, stale, registered under another connector, or registered. + for IncomingState := 0 to 3 do + for OutgoingState := 0 to 3 do + for ReceiptsPreference := 0 to 1 do + for CommunicationPreference := 0 to 1 do begin + Setup := RegisteredSetup; + Setup."Enable Email with Receipts" := ReceiptsPreference = 1; + Setup."Enable Communication" := CommunicationPreference = 1; + SetIncomingAccountState(Setup, IncomingState); + SetOutgoingAccountState(Setup, OutgoingState); + IncomingAvailable := (ReceiptsPreference = 1) and (IncomingState = 3); + OutgoingAvailable := (CommunicationPreference = 1) and (OutgoingState = 3); + + Assert.AreEqual(IncomingAvailable, Setup.IsIncomingCommunicationConfigured(), 'Incoming availability must use preference, ID and connector registration.'); + Assert.AreEqual(OutgoingAvailable, Setup.IsOutgoingCommunicationConfigured(), 'Outgoing availability must use preference, ID and connector registration.'); + Assert.AreEqual(IncomingAvailable or OutgoingAvailable, Setup.ShouldScheduleAgentTask(true), 'An enabled agent requires at least one available channel.'); + Assert.IsFalse(Setup.ShouldScheduleAgentTask(false), 'No channel may schedule a disabled agent.'); + end; + end; + + [Test] + procedure RegisteredButInaccessibleAccountsRemainConfigured() + var + Setup: Record "Expense Agent Setup" temporary; + begin + InitializeSetup(Setup); + Setup."Enable Email with Receipts" := true; + Setup."Enable Communication" := true; + ConnectorMock.FailOnRetrieveEmails(true); + + Assert.IsTrue(Setup.IsIncomingCommunicationConfigured(), 'Mailbox access failure must not be treated as deleted incoming configuration.'); + Assert.IsTrue(Setup.IsOutgoingCommunicationConfigured(), 'Mailbox access failure must not be treated as deleted outgoing configuration.'); + Assert.IsTrue(Setup.ShouldScheduleAgentTask(true), 'Availability must use local registration, not a live mailbox probe.'); + Assert.IsFalse(Setup.RepairMissingEmailAccounts(), 'Registered but inaccessible accounts must not be cleared.'); + end; + + [Test] + procedure SchedulingChangesDetectEveryEligibilityInputInBothDirections() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + ChangedInput: Integer; + begin + PreviousSetup.Init(); + PreviousSetup."Email Address" := 'receipts@example.invalid'; + PreviousSetup."Noreply Email Address" := 'noreply@example.invalid'; + Assert.IsFalse(PreviousSetup.HasSchedulingChanges(PreviousSetup), 'Unchanged setup must not trigger reconciliation.'); + + for ChangedInput := 1 to 7 do begin + Setup := PreviousSetup; + case ChangedInput of + 1: + Setup."Enable Agent" := not Setup."Enable Agent"; + 2: + Setup."Enable Email with Receipts" := not Setup."Enable Email with Receipts"; + 3: + Setup."Enable Communication" := not Setup."Enable Communication"; + 4: + Setup."Email Account ID" := CreateGuid(); + 5: + Setup."Email Connector" := Enum::"Email Connector"::"Test Email Connector v4"; + 6: + Setup."Noreply Email Account ID" := CreateGuid(); + 7: + Setup."Noreply Email Connector" := Enum::"Email Connector"::"Test Email Connector v4"; + end; + + Assert.IsTrue(Setup.HasSchedulingChanges(PreviousSetup), 'Changing any eligibility input must require reconciliation even when the address stays the same.'); + Assert.IsTrue(PreviousSetup.HasSchedulingChanges(Setup), 'Reversing a change must also require reconciliation.'); + end; + end; + + [Test] + procedure OtherSetupChangesDoNotRequireSchedulingReconciliation() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + begin + PreviousSetup.Init(); + Setup := PreviousSetup; + Setup."Email Address" := 'receipts@example.invalid'; + Setup."Noreply Email Address" := 'noreply@example.invalid'; + Setup."Email Folder" := 'Receipts'; + Setup."Email Folder Id" := 'folder-id'; + Setup."Enable Open Report Notif." := not Setup."Enable Open Report Notif."; + Setup."Enable Approval Notif." := not Setup."Enable Approval Notif."; + Setup."Use Rules" := not Setup."Use Rules"; + Setup."No. Series Applied" := not Setup."No. Series Applied"; + + Assert.IsFalse(Setup.HasSchedulingChanges(PreviousSetup), 'Display values, notification preferences and accounting defaults do not change channel eligibility.'); + end; + + local procedure InitializeSetup(var Setup: Record "Expense Agent Setup" temporary) + var + TempEmailAccount: Record "Email Account" temporary; + begin + Assert.AreEqual(IsolatedTestCompanyLbl, CompanyName(), 'Email lifecycle tests must run only in their isolated test company.'); + ConnectorMock.Initialize(); + Setup.Init(); + ConnectorMock.AddAccount(TempEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); + Setup."Email Account ID" := TempEmailAccount."Account Id"; + Setup."Email Connector" := TempEmailAccount.Connector; + Setup."Email Address" := TempEmailAccount."Email Address"; + ConnectorMock.AddAccount(TempEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); + Setup."Noreply Email Account ID" := TempEmailAccount."Account Id"; + Setup."Noreply Email Connector" := TempEmailAccount.Connector; + Setup."Noreply Email Address" := TempEmailAccount."Email Address"; + end; + + local procedure SetIncomingAccountState(var Setup: Record "Expense Agent Setup" temporary; AccountState: Integer) + begin + case AccountState of + 0: + Setup.ClearIncomingMailbox(); + 1: + Setup."Email Account ID" := CreateGuid(); + 2: + Setup."Email Connector" := Enum::"Email Connector"::"Test Email Connector"; + end; + end; + + local procedure SetOutgoingAccountState(var Setup: Record "Expense Agent Setup" temporary; AccountState: Integer) + begin + case AccountState of + 0: + Setup.ClearNoreplyMailbox(); + 1: + Setup."Noreply Email Account ID" := CreateGuid(); + 2: + Setup."Noreply Email Connector" := Enum::"Email Connector"::"Test Email Connector"; + end; + end; } diff --git a/src/Apps/W1/ExpenseAgent/test/src/EAMailboxAccessTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EAMailboxAccessTest.Codeunit.al index 9391e74f6ed..b7637f588fd 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EAMailboxAccessTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EAMailboxAccessTest.Codeunit.al @@ -16,13 +16,15 @@ codeunit 148317 "EA Mailbox Access Test" TestPermissions = Disabled; var + SelectedEmailAccount: Record "Email Account" temporary; Assert: Codeunit Assert; ConnectorMock: Codeunit "Connector Mock"; + IsolatedTestCompanyLbl: Label 'EA Email Lifecycle Test', Locked = true; [Test] procedure ValidateMailboxAccessTrueWhenNoEmailAccountsAreConfigured() var - Setup: Record "Expense Agent Setup"; + Setup: Record "Expense Agent Setup" temporary; begin InitEmptySetup(Setup); @@ -33,7 +35,7 @@ codeunit 148317 "EA Mailbox Access Test" [Test] procedure CheckMailboxAccessOrErrorIsNoOpWhenNoEmailAccountsAreConfigured() var - Setup: Record "Expense Agent Setup"; + Setup: Record "Expense Agent Setup" temporary; begin InitEmptySetup(Setup); @@ -45,7 +47,7 @@ codeunit 148317 "EA Mailbox Access Test" [Test] procedure ValidateAccessFalseWhenRetrieveEmailsFails() var - Setup: Record "Expense Agent Setup"; + Setup: Record "Expense Agent Setup" temporary; TempEmailAccount: Record "Email Account" temporary; begin // The probe runs against a real test account; the connector is configured to fail @@ -67,7 +69,7 @@ codeunit 148317 "EA Mailbox Access Test" [Test] procedure DeactivationWarningProceedsWhenNoMailbox() var - Setup: Record "Expense Agent Setup"; + Setup: Record "Expense Agent Setup" temporary; begin // No mailbox -> warning skipped, deactivation proceeds. InitEmptySetup(Setup); @@ -78,7 +80,7 @@ codeunit 148317 "EA Mailbox Access Test" [HandlerFunctions('ConfirmYesHandler')] procedure DeactivationWarningProceedsWhenUserConfirms() var - Setup: Record "Expense Agent Setup"; + Setup: Record "Expense Agent Setup" temporary; TempEmailAccount: Record "Email Account" temporary; begin // Inaccessible mailbox -> warning shown; user clicks Yes -> proceed. @@ -97,7 +99,7 @@ codeunit 148317 "EA Mailbox Access Test" [HandlerFunctions('ConfirmNoHandler')] procedure DeactivationWarningCancelsWhenUserDeclines() var - Setup: Record "Expense Agent Setup"; + Setup: Record "Expense Agent Setup" temporary; TempEmailAccount: Record "Email Account" temporary; begin // Inaccessible mailbox -> warning shown; user clicks No -> cancel. @@ -115,7 +117,7 @@ codeunit 148317 "EA Mailbox Access Test" [Test] procedure SchedulingAccessCheckIsNoOpWhenNoAccountsConfigured() var - Setup: Record "Expense Agent Setup"; + Setup: Record "Expense Agent Setup" temporary; begin // [SCENARIO 636970] The scheduling access check does nothing when the enabled features have no mailbox. // [GIVEN] Receipts and communication on, but no accounts configured. @@ -132,7 +134,7 @@ codeunit 148317 "EA Mailbox Access Test" [Test] procedure SchedulingAccessCheckErrorsWhenReceiptsOnAndIncomingInaccessible() var - Setup: Record "Expense Agent Setup"; + Setup: Record "Expense Agent Setup" temporary; TempEmailAccount: Record "Email Account" temporary; begin // [SCENARIO 636970] Receipts on with an inaccessible incoming mailbox blocks scheduling. @@ -147,12 +149,13 @@ codeunit 148317 "EA Mailbox Access Test" // [THEN] The check errors so the task is not scheduled to fail silently. asserterror Setup.CheckSchedulingMailboxAccessOrError(); + Assert.ExpectedError('incoming receipts because the connection failed'); end; [Test] procedure SchedulingAccessCheckErrorsWhenCommunicationOnAndNoreplyInaccessible() var - Setup: Record "Expense Agent Setup"; + Setup: Record "Expense Agent Setup" temporary; TempEmailAccount: Record "Email Account" temporary; begin // [SCENARIO 636970] Communication on with an inaccessible no-reply mailbox blocks scheduling. @@ -168,12 +171,13 @@ codeunit 148317 "EA Mailbox Access Test" // [THEN] The check errors on the no-reply account. asserterror Setup.CheckSchedulingMailboxAccessOrError(); + Assert.ExpectedError('outgoing notifications because the connection failed'); end; [Test] procedure SchedulingAccessCheckSkipsIncomingWhenReceiptsOff() var - Setup: Record "Expense Agent Setup"; + Setup: Record "Expense Agent Setup" temporary; TempEmailAccount: Record "Email Account" temporary; begin // [SCENARIO 636970] An inaccessible incoming mailbox is ignored when receipts are off (the task won't read it). @@ -195,7 +199,7 @@ codeunit 148317 "EA Mailbox Access Test" [Test] procedure SchedulingAccessCheckSkipsNoreplyWhenCommunicationOff() var - Setup: Record "Expense Agent Setup"; + Setup: Record "Expense Agent Setup" temporary; TempEmailAccount: Record "Email Account" temporary; begin // [SCENARIO 636970] An inaccessible no-reply mailbox is ignored when communication is off (the task won't send). @@ -217,7 +221,7 @@ codeunit 148317 "EA Mailbox Access Test" [Test] procedure SchedulingAccessCheckPassesWhenMailboxesAccessible() var - Setup: Record "Expense Agent Setup"; + Setup: Record "Expense Agent Setup" temporary; TempEmailAccount: Record "Email Account" temporary; begin // [SCENARIO 636970] The scheduling access check succeeds (no error) when the enabled features @@ -242,18 +246,14 @@ codeunit 148317 "EA Mailbox Access Test" [HandlerFunctions('EmailAccountsCancelHandler,ConfirmYesHandler')] procedure AssistEditNoreplyClearsAccountWhenLookupCancelledAndConfirmed() var - Setup: Record "Expense Agent Setup"; - TempEmailAccount: Record "Email Account" temporary; + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO 636970] Cancelling the no-reply account lookup and confirming the prompt clears // the no-reply mailbox so the agent stops sending until a new account is chosen. // [GIVEN] A configured no-reply account (an account exists, so the wizard is skipped). - InitEmptySetup(Setup); - RegisterTestEmailAccount(TempEmailAccount); - Setup."Noreply Email Account ID" := TempEmailAccount."Account Id"; - Setup."Noreply Email Connector" := TempEmailAccount.Connector; - Setup."Noreply Email Address" := 'noreply@contoso.com'; - Setup.Modify(); + InitConfiguredSetup(Setup); + PreviousSetup := Setup; Commit(); // [WHEN] The user cancels the account lookup and confirms clearing the no-reply account. @@ -261,26 +261,23 @@ codeunit 148317 "EA Mailbox Access Test" // [THEN] The no-reply account fields are cleared. Setup.Get(); - Assert.IsTrue(IsNullGuid(Setup."Noreply Email Account ID"), 'Noreply Email Account ID should be cleared.'); - Assert.AreEqual('', Setup."Noreply Email Address", 'Noreply Email Address should be cleared.'); + AssertNoreplyCleared(Setup); + AssertIncomingUnchanged(PreviousSetup, Setup); + AssertPreferencesUnchanged(PreviousSetup, Setup); end; [Test] [HandlerFunctions('EmailAccountsCancelHandler,ConfirmYesHandler')] procedure AssistEditMailboxClearsAccountWhenLookupCancelledAndConfirmed() var - Setup: Record "Expense Agent Setup"; - TempEmailAccount: Record "Email Account" temporary; + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO 636970] Cancelling the incoming (receipts) account lookup and confirming the // prompt clears the mailbox so the agent stops processing receipts until a new account is chosen. // [GIVEN] A configured incoming mailbox (an account exists, so the wizard is skipped). - InitEmptySetup(Setup); - RegisterTestEmailAccount(TempEmailAccount); - Setup."Email Account ID" := TempEmailAccount."Account Id"; - Setup."Email Connector" := TempEmailAccount.Connector; - Setup."Email Address" := 'mailbox@contoso.com'; - Setup.Modify(); + InitConfiguredSetup(Setup); + PreviousSetup := Setup; Commit(); // [WHEN] The user cancels the account lookup and confirms clearing the mailbox account. @@ -288,11 +285,558 @@ codeunit 148317 "EA Mailbox Access Test" // [THEN] The incoming mailbox fields are cleared. Setup.Get(); - Assert.IsTrue(IsNullGuid(Setup."Email Account ID"), 'Email Account ID should be cleared.'); - Assert.AreEqual('', Setup."Email Address", 'Email Address should be cleared.'); + AssertIncomingCleared(Setup); + AssertNoreplyUnchanged(PreviousSetup, Setup); + AssertPreferencesUnchanged(PreviousSetup, Setup); + end; + + [Test] + [HandlerFunctions('EmailAccountsCancelHandler,ConfirmNoHandler')] + procedure DecliningIncomingClearPreservesConfiguration() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + begin + InitConfiguredSetup(Setup); + PreviousSetup := Setup; + Commit(); + + Setup.AssistEditMailbox(); + + AssertConfigurationUnchanged(PreviousSetup, Setup); + Setup.Get(); + AssertConfigurationUnchanged(PreviousSetup, Setup); + end; + + [Test] + [HandlerFunctions('EmailAccountsCancelHandler,ConfirmNoHandler')] + procedure DecliningNoreplyClearPreservesConfiguration() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + begin + InitConfiguredSetup(Setup); + PreviousSetup := Setup; + Commit(); + + Setup.AssistEditNoreplyMailbox(); + + AssertConfigurationUnchanged(PreviousSetup, Setup); + Setup.Get(); + AssertConfigurationUnchanged(PreviousSetup, Setup); + end; + + [Test] + procedure IncomingClearOnlyChangesIdentityInRecordBuffer() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + begin + InitConfiguredSetup(Setup); + PreviousSetup := Setup; + + Setup.ClearIncomingMailbox(); + + AssertIncomingCleared(Setup); + AssertNoreplyUnchanged(PreviousSetup, Setup); + AssertPreferencesUnchanged(PreviousSetup, Setup); + Setup.Get(); + AssertConfigurationUnchanged(PreviousSetup, Setup); + end; + + [Test] + procedure NoreplyClearOnlyChangesIdentityInRecordBuffer() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + begin + InitConfiguredSetup(Setup); + PreviousSetup := Setup; + + Setup.ClearNoreplyMailbox(); + + AssertNoreplyCleared(Setup); + AssertIncomingUnchanged(PreviousSetup, Setup); + AssertPreferencesUnchanged(PreviousSetup, Setup); + Setup.Get(); + AssertConfigurationUnchanged(PreviousSetup, Setup); + end; + + [Test] + procedure ExplicitCommunicationDisableStillClearsNotificationPreferences() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + begin + InitConfiguredSetup(Setup); + PreviousSetup := Setup; + + Setup.Validate("Enable Communication", false); + + Assert.IsFalse(Setup."Enable Communication", 'The explicit communication preference must be off.'); + Assert.IsFalse(Setup."Enable Open Report Notif.", 'Explicitly disabling communication must still disable reminders.'); + Assert.IsFalse(Setup."Enable Approval Notif.", 'Explicitly disabling communication must still disable approval notifications.'); + Assert.IsTrue(Setup."Enable Email with Receipts", 'Disabling outgoing communication must not disable receipts.'); + Assert.AreEqual(PreviousSetup."Enable Agent", Setup."Enable Agent", 'The native agent state must not change.'); + AssertIncomingUnchanged(PreviousSetup, Setup); + AssertNoreplyUnchanged(PreviousSetup, Setup); + end; + + [Test] + [HandlerFunctions('EmailAccountSelectionHandler')] + procedure SameAddressIncomingReplacementClearsOldFolders() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + TestEmailAccount: Record "Test Email Account"; + begin + InitConfiguredSetup(Setup); + PreviousSetup := Setup; + ConnectorMock.AddAccount(SelectedEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); + TestEmailAccount.Get(SelectedEmailAccount."Account Id"); + TestEmailAccount.Email := Setup."Email Address"; + TestEmailAccount.Modify(); + SelectedEmailAccount."Email Address" := Setup."Email Address"; + Commit(); + + Setup.AssistEditMailbox(); + + Setup.Get(); + Assert.AreEqual(SelectedEmailAccount."Account Id", Setup."Email Account ID", 'The incoming identity must change even if the address is unchanged.'); + Assert.AreEqual(PreviousSetup."Email Address", Setup."Email Address", 'The replacement intentionally uses the same address.'); + Assert.AreEqual('', Setup."Email Folder", 'The previous account folder must be cleared.'); + Assert.AreEqual('', Setup."Email Folder Id", 'The previous account folder ID must be cleared.'); + AssertNoreplyUnchanged(PreviousSetup, Setup); + AssertPreferencesUnchanged(PreviousSetup, Setup); + end; + + [Test] + [HandlerFunctions('EmailAccountSelectionHandler')] + procedure IncomingConnectorReplacementClearsOldFolders() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + begin + InitConfiguredSetup(Setup); + SelectIncomingAccount(Setup); + Setup."Email Connector" := Enum::"Email Connector"::"Test Email Connector"; + PreviousSetup := Setup; + Commit(); + + Setup.AssistEditMailbox(); + + Setup.Get(); + Assert.AreEqual(PreviousSetup."Email Account ID", Setup."Email Account ID", 'Only the connector identity changes.'); + Assert.AreEqual(SelectedEmailAccount.Connector, Setup."Email Connector", 'The selected connector must replace the stale connector.'); + Assert.AreEqual('', Setup."Email Folder", 'Changing the connector must clear the folder.'); + Assert.AreEqual('', Setup."Email Folder Id", 'Changing the connector must clear the folder ID.'); + AssertNoreplyUnchanged(PreviousSetup, Setup); + AssertPreferencesUnchanged(PreviousSetup, Setup); + end; + + [Test] + [HandlerFunctions('EmailAccountSelectionHandler')] + procedure ReselectingIncomingPreservesFoldersAndDefaultsEmptyNoreply() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + begin + InitConfiguredSetup(Setup); + Setup.ClearNoreplyMailbox(); + SelectIncomingAccount(Setup); + PreviousSetup := Setup; + Commit(); + + Setup.AssistEditMailbox(); + + Setup.Get(); + AssertIncomingUnchanged(PreviousSetup, Setup); + Assert.AreEqual(Setup."Email Account ID", Setup."Noreply Email Account ID", 'Reselecting the same incoming account must still default an empty no-reply account.'); + Assert.AreEqual(Setup."Email Connector", Setup."Noreply Email Connector", 'The defaulted no-reply connector must match.'); + Assert.AreEqual(Setup."Email Address", Setup."Noreply Email Address", 'The defaulted no-reply address must match.'); + AssertPreferencesUnchanged(PreviousSetup, Setup); + end; + + [Test] + [HandlerFunctions('EmailAccountSelectionHandler')] + procedure InaccessibleIncomingReplacementPreservesPreviousConfiguration() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + begin + InitConfiguredSetup(Setup); + PreviousSetup := Setup; + ConnectorMock.AddAccount(SelectedEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); + ConnectorMock.FailOnRetrieveEmails(true); + Commit(); + + asserterror Setup.AssistEditMailbox(); + + Assert.ExpectedError('incoming receipts because the connection failed'); + AssertConfigurationUnchanged(PreviousSetup, Setup); + Setup.Get(); + AssertConfigurationUnchanged(PreviousSetup, Setup); + end; + + [Test] + [HandlerFunctions('EmailAccountSelectionHandler')] + procedure InaccessibleNoreplyReplacementPreservesPreviousConfiguration() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + begin + InitConfiguredSetup(Setup); + PreviousSetup := Setup; + ConnectorMock.AddAccount(SelectedEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); + ConnectorMock.FailOnRetrieveEmails(true); + Commit(); + + asserterror Setup.AssistEditNoreplyMailbox(); + + Assert.ExpectedError('outgoing notifications because the connection failed'); + AssertConfigurationUnchanged(PreviousSetup, Setup); + Setup.Get(); + AssertConfigurationUnchanged(PreviousSetup, Setup); + end; + + [Test] + procedure MissingAccountRepairPreservesPreferencesAndOtherChannel() + var + Setup: Record "Expense Agent Setup" temporary; + RegisteredSetup: Record "Expense Agent Setup" temporary; + MissingChannels: Integer; + begin + InitConfiguredSetup(RegisteredSetup); + for MissingChannels := 1 to 3 do begin + Setup := RegisteredSetup; + if MissingChannels in [1, 3] then + Setup."Email Account ID" := CreateGuid(); + if MissingChannels in [2, 3] then + Setup."Noreply Email Account ID" := CreateGuid(); + + Assert.IsTrue(Setup.RepairMissingEmailAccounts(), 'Missing references must be repaired.'); + + if MissingChannels in [1, 3] then + AssertIncomingCleared(Setup) + else + AssertIncomingUnchanged(RegisteredSetup, Setup); + if MissingChannels in [2, 3] then + AssertNoreplyCleared(Setup) + else + AssertNoreplyUnchanged(RegisteredSetup, Setup); + AssertPreferencesUnchanged(RegisteredSetup, Setup); + Assert.IsFalse(Setup.RepairMissingEmailAccounts(), 'Repeated repair must be a no-op.'); + end; + end; + + [Test] + procedure WrongConnectorAndEmptyIdentityRepairClearsOrphanedFields() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + begin + InitConfiguredSetup(Setup); + PreviousSetup := Setup; + Setup."Email Connector" := Enum::"Email Connector"::"Test Email Connector"; + Setup."Noreply Email Connector" := Enum::"Email Connector"::"Test Email Connector"; + + Assert.IsTrue(Setup.RepairMissingEmailAccounts(), 'The ID must be registered under the selected connector.'); + AssertIncomingCleared(Setup); + AssertNoreplyCleared(Setup); + AssertPreferencesUnchanged(PreviousSetup, Setup); + + Setup := PreviousSetup; + Clear(Setup."Email Account ID"); + Clear(Setup."Noreply Email Account ID"); + Assert.IsTrue(Setup.RepairMissingEmailAccounts(), 'Empty IDs must not retain orphaned addresses, connectors or folders.'); + AssertIncomingCleared(Setup); + AssertNoreplyCleared(Setup); + AssertPreferencesUnchanged(PreviousSetup, Setup); + end; + + [Test] + procedure StagedRepairCanBeDiscardedWithoutChangingOriginalSetup() + var + Setup: Record "Expense Agent Setup" temporary; + StagedSetup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + begin + InitConfiguredSetup(Setup); + Setup."Email Account ID" := CreateGuid(); + Setup."Noreply Email Account ID" := CreateGuid(); + Setup.Modify(); + PreviousSetup := Setup; + StagedSetup := Setup; + StagedSetup.Insert(); + + Assert.IsTrue(StagedSetup.RepairMissingEmailAccounts(), 'Opening a temporary wizard buffer must stage missing-account repair.'); + AssertIncomingCleared(StagedSetup); + AssertNoreplyCleared(StagedSetup); + AssertPreferencesUnchanged(PreviousSetup, StagedSetup); + StagedSetup.Get(); + AssertConfigurationUnchanged(PreviousSetup, StagedSetup); + StagedSetup.RepairMissingEmailAccounts(); + StagedSetup.Modify(); + StagedSetup.DeleteAll(); + + Setup.Get(); + AssertConfigurationUnchanged(PreviousSetup, Setup); + end; + + [Test] + [HandlerFunctions('EmailAccountsCancelHandler,ConfirmYesHandler')] + procedure DiscardingStagedIncomingClearPreservesOriginalSetup() + var + Setup: Record "Expense Agent Setup" temporary; + StagedSetup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + begin + InitConfiguredSetup(Setup); + PreviousSetup := Setup; + StagedSetup := Setup; + StagedSetup.Insert(); + Commit(); + + StagedSetup.AssistEditMailbox(); + StagedSetup.Get(); + AssertIncomingCleared(StagedSetup); + AssertPreferencesUnchanged(PreviousSetup, StagedSetup); + StagedSetup.DeleteAll(); + + Setup.Get(); + AssertConfigurationUnchanged(PreviousSetup, Setup); end; - local procedure InitEmptySetup(var Setup: Record "Expense Agent Setup") + [Test] + procedure TemporaryDisableOnlyChangesAgentStateInBuffer() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + begin + InitConfiguredSetup(Setup); + PreviousSetup := Setup; + + Setup.Validate("Enable Agent", false); + + Assert.IsFalse(Setup."Enable Agent", 'The pending disable must be staged.'); + Setup.Get(); + AssertConfigurationUnchanged(PreviousSetup, Setup); + end; + + [Test] + procedure SchedulingAccessSkipsStaleIncomingWithAvailableOutgoing() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + begin + InitConfiguredSetup(Setup); + Setup."Email Account ID" := CreateGuid(); + PreviousSetup := Setup; + Commit(); + + Setup.CheckSchedulingMailboxAccessOrError(); + + AssertConfigurationUnchanged(PreviousSetup, Setup); + Assert.IsTrue(Setup.ShouldScheduleAgentTask(true), 'The registered outgoing channel must remain usable.'); + end; + + [Test] + procedure SchedulingAccessSkipsStaleOutgoingWithAvailableIncoming() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + begin + InitConfiguredSetup(Setup); + Setup."Noreply Email Account ID" := CreateGuid(); + PreviousSetup := Setup; + Commit(); + + Setup.CheckSchedulingMailboxAccessOrError(); + + AssertConfigurationUnchanged(PreviousSetup, Setup); + Assert.IsTrue(Setup.ShouldScheduleAgentTask(true), 'The registered incoming channel must remain usable.'); + end; + + [Test] + procedure SchedulingAccessDoesNotProbeAccountsWithWrongConnector() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + begin + InitConfiguredSetup(Setup); + Setup."Email Connector" := Enum::"Email Connector"::"Test Email Connector"; + Setup."Noreply Email Connector" := Enum::"Email Connector"::"Test Email Connector"; + PreviousSetup := Setup; + ConnectorMock.FailOnRetrieveEmails(true); + Commit(); + + Setup.CheckSchedulingMailboxAccessOrError(); + + AssertConfigurationUnchanged(PreviousSetup, Setup); + Assert.IsFalse(Setup.ShouldScheduleAgentTask(true), 'Neither account is registered under its selected connector.'); + end; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + procedure DeletingIncomingAccountPreservesOutgoingSenderAndPreferences() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + begin + InitAccountDeletionSetup(Setup); + PreviousSetup := Setup; + + DeleteTestEmailAccount(Setup."Email Account ID", Setup."Email Connector"); + ReloadAccountDeletionSetup(Setup); + + AssertIncomingCleared(Setup); + AssertNoreplyUnchanged(PreviousSetup, Setup); + AssertPreferencesUnchanged(PreviousSetup, Setup); + Assert.IsTrue(Setup.IsOutgoingCommunicationConfigured(), 'The surviving registered sender must remain available.'); + end; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + procedure DeletingNoreplyAccountPreservesIncomingAndPreferences() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + begin + InitAccountDeletionSetup(Setup); + PreviousSetup := Setup; + + DeleteTestEmailAccount(Setup."Noreply Email Account ID", Setup."Noreply Email Connector"); + ReloadAccountDeletionSetup(Setup); + + AssertNoreplyCleared(Setup); + AssertIncomingUnchanged(PreviousSetup, Setup); + AssertPreferencesUnchanged(PreviousSetup, Setup); + Assert.IsTrue(Setup.IsIncomingCommunicationConfigured(), 'The surviving registered incoming account must remain available.'); + end; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + procedure DeletingSharedAccountClearsBothChannelsWithoutChangingPreferences() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + begin + InitAccountDeletionSetup(Setup); + Setup."Noreply Email Account ID" := Setup."Email Account ID"; + Setup."Noreply Email Connector" := Setup."Email Connector"; + Setup."Noreply Email Address" := Setup."Email Address"; + SaveAccountDeletionSetup(Setup); + PreviousSetup := Setup; + + DeleteTestEmailAccount(Setup."Email Account ID", Setup."Email Connector"); + ReloadAccountDeletionSetup(Setup); + + AssertIncomingCleared(Setup); + AssertNoreplyCleared(Setup); + AssertPreferencesUnchanged(PreviousSetup, Setup); + Assert.IsFalse(Setup.ShouldScheduleAgentTask(true), 'Deleting the shared account leaves no available channel.'); + end; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + procedure DeletingAccountWithMismatchedConnectorPreservesSelections() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + RegisteredConnector: Enum "Email Connector"; + begin + InitAccountDeletionSetup(Setup); + RegisteredConnector := Setup."Email Connector"; + Setup."Email Connector" := Enum::"Email Connector"::"Test Email Connector"; + Setup."Noreply Email Account ID" := Setup."Email Account ID"; + Setup."Noreply Email Connector" := Setup."Email Connector"; + Setup."Noreply Email Address" := Setup."Email Address"; + SaveAccountDeletionSetup(Setup); + PreviousSetup := Setup; + + DeleteTestEmailAccount(Setup."Email Account ID", RegisteredConnector); + ReloadAccountDeletionSetup(Setup); + + AssertConfigurationUnchanged(PreviousSetup, Setup); + end; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + procedure DeletingUnrelatedAccountPreservesBothChannels() + var + Setup: Record "Expense Agent Setup" temporary; + PreviousSetup: Record "Expense Agent Setup" temporary; + TempEmailAccount: Record "Email Account" temporary; + begin + InitAccountDeletionSetup(Setup); + PreviousSetup := Setup; + ConnectorMock.AddAccount(TempEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); + + DeleteTestEmailAccount(TempEmailAccount."Account Id", TempEmailAccount.Connector); + ReloadAccountDeletionSetup(Setup); + + AssertConfigurationUnchanged(PreviousSetup, Setup); + Assert.IsTrue(Setup.IsIncomingCommunicationConfigured(), 'An unrelated deletion must not affect the incoming channel.'); + Assert.IsTrue(Setup.IsOutgoingCommunicationConfigured(), 'An unrelated deletion must not affect the outgoing channel.'); + end; + + local procedure InitAccountDeletionSetup(var Setup: Record "Expense Agent Setup" temporary) + var + PersistedSetup: Record "Expense Agent Setup"; + ExpenseAgentStatus: Record "Expense Agent Status"; + begin + Assert.AreEqual(IsolatedTestCompanyLbl, CompanyName(), 'Account-deletion tests must run only in their isolated test company.'); + PersistedSetup.ReadIsolation(IsolationLevel::UpdLock); + if PersistedSetup.Get() then; + ExpenseAgentStatus.ReadIsolation(IsolationLevel::UpdLock); + if ExpenseAgentStatus.Get() then begin + Assert.IsTrue(IsNullGuid(ExpenseAgentStatus."Agent Task ID"), 'Account-deletion fixtures must not run with a dispatcher task ID.'); + Assert.IsTrue(IsNullGuid(ExpenseAgentStatus."Agent Recovery Task ID"), 'Account-deletion fixtures must not run with a recovery task ID.'); + end else begin + ExpenseAgentStatus.Init(); + ExpenseAgentStatus.Insert(); + end; + + InitConfiguredSetup(Setup); + SaveAccountDeletionSetup(Setup); + end; + + local procedure SaveAccountDeletionSetup(Setup: Record "Expense Agent Setup" temporary) + var + PersistedSetup: Record "Expense Agent Setup"; + begin + PersistedSetup.ReadIsolation(IsolationLevel::UpdLock); + if not PersistedSetup.Get() then + PersistedSetup.Insert(); + PersistedSetup.TransferFields(Setup, false); + PersistedSetup.Modify(); + end; + + local procedure DeleteTestEmailAccount(AccountId: Guid; Connector: Enum "Email Connector") + var + TempAccountsToDelete: Record "Email Account" temporary; + EmailAccount: Codeunit "Email Account"; + begin + TempAccountsToDelete."Account Id" := AccountId; + TempAccountsToDelete.Connector := Connector; + TempAccountsToDelete.Insert(); + EmailAccount.DeleteAccounts(TempAccountsToDelete, true); + Assert.IsFalse(EmailAccount.IsAccountRegistered(AccountId, Connector), 'The registered mock account must actually be deleted.'); + end; + + local procedure ReloadAccountDeletionSetup(var Setup: Record "Expense Agent Setup" temporary) + var + PersistedSetup: Record "Expense Agent Setup"; + ExpenseAgentStatus: Record "Expense Agent Status"; + begin + PersistedSetup.Get(); + Setup := PersistedSetup; + ExpenseAgentStatus.Get(); + Assert.IsTrue(IsNullGuid(ExpenseAgentStatus."Agent Task ID"), 'Deletion must leave the dispatcher task ID empty.'); + Assert.IsTrue(IsNullGuid(ExpenseAgentStatus."Agent Recovery Task ID"), 'Deletion must leave the recovery task ID empty.'); + end; + + local procedure InitEmptySetup(var Setup: Record "Expense Agent Setup" temporary) begin Setup.DeleteAll(); Setup.Init(); @@ -302,10 +846,112 @@ codeunit 148317 "EA Mailbox Access Test" local procedure RegisterTestEmailAccount(var TempEmailAccount: Record "Email Account" temporary) begin + Assert.AreEqual(IsolatedTestCompanyLbl, CompanyName(), 'Email lifecycle tests must run only in their isolated test company.'); ConnectorMock.Initialize(); ConnectorMock.AddAccount(TempEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); end; + local procedure InitConfiguredSetup(var Setup: Record "Expense Agent Setup" temporary) + var + TempEmailAccount: Record "Email Account" temporary; + begin + InitEmptySetup(Setup); + RegisterTestEmailAccount(TempEmailAccount); + Setup."Email Account ID" := TempEmailAccount."Account Id"; + Setup."Email Connector" := TempEmailAccount.Connector; + Setup."Email Address" := TempEmailAccount."Email Address"; + Setup."Email Folder" := 'Receipts'; + Setup."Email Folder Id" := 'old-folder-id'; + ConnectorMock.AddAccount(TempEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); + Setup."Noreply Email Account ID" := TempEmailAccount."Account Id"; + Setup."Noreply Email Connector" := TempEmailAccount.Connector; + Setup."Noreply Email Address" := TempEmailAccount."Email Address"; + Setup."Enable Agent" := true; + Setup."User Security ID" := CreateGuid(); + Setup."Enable Email with Receipts" := true; + Setup."Enable Communication" := true; + Setup."Enable Open Report Notif." := true; + Setup."Enable Approval Notif." := true; + Setup."Open Report Notif. Freq." := Setup."Open Report Notif. Freq."::Weekly; + Setup."Notif. Day of Week" := Setup."Notif. Day of Week"::Friday; + Setup."Notif. Day In A Month" := 15; + Evaluate(Setup."Custom Notif. Formula", '<2D>'); + Evaluate(Setup."Approval Reminder After", '<3D>'); + Setup.Modify(); + end; + + local procedure SelectIncomingAccount(Setup: Record "Expense Agent Setup" temporary) + begin + SelectedEmailAccount."Account Id" := Setup."Email Account ID"; + SelectedEmailAccount.Connector := Setup."Email Connector"; + SelectedEmailAccount."Email Address" := Setup."Email Address"; + end; + + local procedure AssertConfigurationUnchanged(ExpectedSetup: Record "Expense Agent Setup" temporary; ActualSetup: Record "Expense Agent Setup" temporary) + begin + AssertIncomingUnchanged(ExpectedSetup, ActualSetup); + AssertNoreplyUnchanged(ExpectedSetup, ActualSetup); + AssertPreferencesUnchanged(ExpectedSetup, ActualSetup); + end; + + local procedure AssertIncomingUnchanged(ExpectedSetup: Record "Expense Agent Setup" temporary; ActualSetup: Record "Expense Agent Setup" temporary) + begin + Assert.AreEqual(ExpectedSetup."Email Account ID", ActualSetup."Email Account ID", 'The incoming account ID must be preserved.'); + Assert.AreEqual(ExpectedSetup."Email Connector", ActualSetup."Email Connector", 'The incoming connector must be preserved.'); + Assert.AreEqual(ExpectedSetup."Email Address", ActualSetup."Email Address", 'The incoming address must be preserved.'); + Assert.AreEqual(ExpectedSetup."Email Folder", ActualSetup."Email Folder", 'The incoming folder must be preserved.'); + Assert.AreEqual(ExpectedSetup."Email Folder Id", ActualSetup."Email Folder Id", 'The incoming folder ID must be preserved.'); + end; + + local procedure AssertNoreplyUnchanged(ExpectedSetup: Record "Expense Agent Setup" temporary; ActualSetup: Record "Expense Agent Setup" temporary) + begin + Assert.AreEqual(ExpectedSetup."Noreply Email Account ID", ActualSetup."Noreply Email Account ID", 'The no-reply account ID must be preserved.'); + Assert.AreEqual(ExpectedSetup."Noreply Email Connector", ActualSetup."Noreply Email Connector", 'The no-reply connector must be preserved.'); + Assert.AreEqual(ExpectedSetup."Noreply Email Address", ActualSetup."Noreply Email Address", 'The no-reply address must be preserved.'); + end; + + local procedure AssertPreferencesUnchanged(ExpectedSetup: Record "Expense Agent Setup" temporary; ActualSetup: Record "Expense Agent Setup" temporary) + begin + Assert.AreEqual(ExpectedSetup."Enable Email with Receipts", ActualSetup."Enable Email with Receipts", 'The receipts preference must be preserved.'); + Assert.AreEqual(ExpectedSetup."Enable Communication", ActualSetup."Enable Communication", 'The communication preference must be preserved.'); + Assert.AreEqual(ExpectedSetup."Enable Open Report Notif.", ActualSetup."Enable Open Report Notif.", 'The reminder preference must be preserved.'); + Assert.AreEqual(ExpectedSetup."Enable Approval Notif.", ActualSetup."Enable Approval Notif.", 'The approval notification preference must be preserved.'); + Assert.AreEqual(ExpectedSetup."Open Report Notif. Freq.", ActualSetup."Open Report Notif. Freq.", 'The reminder frequency must be preserved.'); + Assert.AreEqual(ExpectedSetup."Notif. Day of Week", ActualSetup."Notif. Day of Week", 'The reminder weekday must be preserved.'); + Assert.AreEqual(ExpectedSetup."Notif. Day In A Month", ActualSetup."Notif. Day In A Month", 'The reminder day must be preserved.'); + Assert.AreEqual(Format(ExpectedSetup."Custom Notif. Formula"), Format(ActualSetup."Custom Notif. Formula"), 'The custom reminder formula must be preserved.'); + Assert.AreEqual(Format(ExpectedSetup."Approval Reminder After"), Format(ActualSetup."Approval Reminder After"), 'The approval reminder formula must be preserved.'); + Assert.AreEqual(ExpectedSetup."Enable Agent", ActualSetup."Enable Agent", 'The native agent state must be preserved.'); + Assert.AreEqual(ExpectedSetup."User Security ID", ActualSetup."User Security ID", 'The native agent identity must be preserved.'); + end; + + local procedure AssertIncomingCleared(Setup: Record "Expense Agent Setup" temporary) + var + EmptyEmailConnector: Enum "Email Connector"; + begin + Assert.IsTrue(IsNullGuid(Setup."Email Account ID"), 'The incoming account ID must be cleared.'); + Assert.AreEqual(EmptyEmailConnector, Setup."Email Connector", 'The incoming connector must be cleared.'); + Assert.AreEqual('', Setup."Email Address", 'The incoming address must be cleared.'); + Assert.AreEqual('', Setup."Email Folder", 'The incoming folder must be cleared.'); + Assert.AreEqual('', Setup."Email Folder Id", 'The incoming folder ID must be cleared.'); + end; + + local procedure AssertNoreplyCleared(Setup: Record "Expense Agent Setup" temporary) + var + EmptyEmailConnector: Enum "Email Connector"; + begin + Assert.IsTrue(IsNullGuid(Setup."Noreply Email Account ID"), 'The no-reply account ID must be cleared.'); + Assert.AreEqual(EmptyEmailConnector, Setup."Noreply Email Connector", 'The no-reply connector must be cleared.'); + Assert.AreEqual('', Setup."Noreply Email Address", 'The no-reply address must be cleared.'); + end; + + [ModalPageHandler] + procedure EmailAccountSelectionHandler(var EmailAccounts: TestPage "Email Accounts") + begin + Assert.IsTrue(EmailAccounts.GoToRecord(SelectedEmailAccount), 'The selected mock account must be listed.'); + EmailAccounts.OK().Invoke(); + end; + [ConfirmHandler] procedure ConfirmYesHandler(Question: Text[1024]; var Reply: Boolean) begin diff --git a/src/Apps/W1/ExpenseAgent/test/src/WelcomeEmailQueueTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/WelcomeEmailQueueTest.Codeunit.al index 2786658bff0..01718144f33 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/WelcomeEmailQueueTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/WelcomeEmailQueueTest.Codeunit.al @@ -5,15 +5,19 @@ namespace Microsoft.Test.ExpenseAgent; using Microsoft.ExpenseAgent; +using System.Email; +using System.Environment; +using System.TestLibraries.Email; codeunit 148334 "Welcome Email Queue Test" { Subtype = Test; TestType = UnitTest; TestPermissions = Disabled; + RequiredTestIsolation = Function; + TestHttpRequestPolicy = BlockOutboundRequests; var - LibraryExpense: Codeunit "Library - Expense"; LibraryUtility: Codeunit "Library - Utility"; Assert: Codeunit Assert; @@ -286,6 +290,7 @@ codeunit 148334 "Welcome Email Queue Test" begin // [SCENARIO 636970] The EA Outbox Email correlation id and notification type persist as written. // [GIVEN] A correlation id. + AssertIsolatedCompany(); CorrelationId := CreateGuid(); // [WHEN] An outbox email is created with the correlation fields. @@ -302,9 +307,10 @@ codeunit 148334 "Welcome Email Queue Test" local procedure CreateExpenseUserWithEmail(var ExpenseUser: Record "Expense User") begin + AssertIsolatedCompany(); ExpenseUser.Init(); ExpenseUser."No." := LibraryUtility.GenerateRandomCode(ExpenseUser.FieldNo("No."), Database::"Expense User"); - ExpenseUser."E-mail" := 'user@contoso.com'; + ExpenseUser."E-mail" := 'user@example.invalid'; ExpenseUser."Welcome Email Status" := ExpenseUser."Welcome Email Status"::None; ExpenseUser.Insert(); end; @@ -312,14 +318,35 @@ codeunit 148334 "Welcome Email Queue Test" local procedure EnableAgentWithCommunication() var ExpenseAgentSetup: Record "Expense Agent Setup"; + TestEmailAccount: Record "Test Email Account"; begin - LibraryExpense.UpdateEnableAgentInAgentSetup(true); - ExpenseAgentSetup.Get(); + AssertIsolatedCompany(); + TestEmailAccount.Id := CreateGuid(); + TestEmailAccount.Email := 'noreply@example.invalid'; + TestEmailAccount.Name := 'Welcome queue mock'; + TestEmailAccount.Connector := Enum::"Email Connector"::"Test Email Connector v4"; + TestEmailAccount.Insert(); + if not ExpenseAgentSetup.Get() then begin + ExpenseAgentSetup.Init(); + ExpenseAgentSetup.Insert(); + end; + ExpenseAgentSetup."Enable Agent" := true; ExpenseAgentSetup."Enable Communication" := true; - ExpenseAgentSetup."Noreply Email Account ID" := CreateGuid(); + ExpenseAgentSetup."Noreply Email Account ID" := TestEmailAccount.Id; + ExpenseAgentSetup."Noreply Email Connector" := TestEmailAccount.Connector; + ExpenseAgentSetup."Noreply Email Address" := TestEmailAccount.Email; ExpenseAgentSetup.Modify(); end; + local procedure AssertIsolatedCompany() + var + EnvironmentInformation: Codeunit "Environment Information"; + begin + Assert.AreEqual('EA Email Lifecycle Test', CompanyName(), 'Run only in the dedicated disposable test company, never CRONUS.'); + Assert.IsFalse(EnvironmentInformation.IsSaaS(), 'These isolated tests must run on-prem.'); + Assert.IsFalse(EnvironmentInformation.IsSaaSInfrastructure(), 'These tests must not use SaaS infrastructure.'); + end; + local procedure CreateInOutboxUser(var ExpenseUser: Record "Expense User"; CorrelationId: Guid) begin CreateExpenseUserWithEmail(ExpenseUser); From 85f784d8938c242eb602fc95c4a0805163048aec Mon Sep 17 00:00:00 2001 From: Prangshuman Das Date: Fri, 18 Sep 2026 15:38:04 +0200 Subject: [PATCH 02/13] Finalize Expense Agent email lifecycle tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a1112d7a-1712-4a8f-ac8c-dc2c78b0ec9a --- .../src/Integration/EAHttpClient.Codeunit.al | 9 ++-- .../Integration/EARetrieveEmails.Codeunit.al | 10 ++++ .../src/EAAgentDispatcherTest.Codeunit.al | 48 ++++++++++++------- 3 files changed, 48 insertions(+), 19 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAHttpClient.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAHttpClient.Codeunit.al index 333f4759afc..bcff293ef7a 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAHttpClient.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAHttpClient.Codeunit.al @@ -442,6 +442,7 @@ codeunit 6941 "EA Http Client" local procedure GetExpenseAgentBaseUrl(var BaseUrl: SecretText): Boolean var ExpenseAgentSetup: Record "Expense Agent Setup"; + BaseUrlOverride: Text; begin Clear(BaseUrl); if not ExpenseAgentSetup.Get() then begin @@ -449,15 +450,17 @@ codeunit 6941 "EA Http Client" exit(false); end; - OnGetCommunicationBaseUrl(ExpenseAgentSetup."Use Canary Endpoint", BaseUrl); - if not BaseUrl.IsEmpty() then + OnGetCommunicationBaseUrl(ExpenseAgentSetup."Use Canary Endpoint", BaseUrlOverride); + if BaseUrlOverride <> '' then begin + BaseUrl := SecretText.SecretStrSubstNo(BaseUrlOverride); exit(true); + end; exit(GetExpenseAgentBaseUrl(ExpenseAgentSetup."Use Canary Endpoint", BaseUrl)); end; [InternalEvent(false, false)] - local procedure OnGetCommunicationBaseUrl(UseCanaryEndpoint: Boolean; var BaseUrl: SecretText) + local procedure OnGetCommunicationBaseUrl(UseCanaryEndpoint: Boolean; var BaseUrl: Text) begin end; diff --git a/src/Apps/W1/ExpenseAgent/app/src/Integration/EARetrieveEmails.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Integration/EARetrieveEmails.Codeunit.al index a2cd7cd20f6..961f9c785a4 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Integration/EARetrieveEmails.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Integration/EARetrieveEmails.Codeunit.al @@ -206,8 +206,13 @@ codeunit 6940 "EA Retrieve Emails" OutStream: OutStream; FileMIMEType: Text[100]; FileName: Text[250]; + IsHandled: Boolean; IsFileMimeTypeSupported: Boolean; begin + OnGetEmailAttachments(TempAttachment, IsHandled); + if IsHandled then + exit; + if not EmailMessage.Attachments_First() then exit; @@ -231,6 +236,11 @@ codeunit 6940 "EA Retrieve Emails" until EmailMessage.Attachments_Next() = 0; end; + [InternalEvent(false, false)] + local procedure OnGetEmailAttachments(var TempAttachment: Record "EA Email Attachment" temporary; var IsHandled: Boolean) + begin + end; + [InternalEvent(false, true)] local procedure OnAfterProcessEmail(EmailInboxId: BigInteger) begin diff --git a/src/Apps/W1/ExpenseAgent/test/src/EAAgentDispatcherTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EAAgentDispatcherTest.Codeunit.al index c1be8f3c55a..bd9715a3f99 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EAAgentDispatcherTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EAAgentDispatcherTest.Codeunit.al @@ -9,7 +9,6 @@ using System.AI; using System.Email; using System.Environment; using System.TestLibraries.Email; -using System.Utilities; codeunit 148314 "EA Agent Dispatcher Test" { @@ -37,6 +36,7 @@ codeunit 148314 "EA Agent Dispatcher Test" OutgoingMockAccountId: Guid; FixtureMessageIds: List of [Guid]; DisableOutgoingAfterSend: Boolean; + UseReceiptAttachmentFixture: Boolean; TestCompanyTok: Label 'EA Email Lifecycle Test', Locked = true; ServiceBaseUrlTok: Label 'https://expense-agent.example.invalid', Locked = true; RecipientEmailTok: Label 'recipient@example.invalid', Locked = true; @@ -354,6 +354,7 @@ codeunit 148314 "EA Agent Dispatcher Test" Clear(OutgoingMockAccountId); Clear(FixtureMessageIds); Clear(DisableOutgoingAfterSend); + Clear(UseReceiptAttachmentFixture); ExpectNoService(); TestEmailConnector.SetEmailInbox(TempEmailInbox); ConnectorMock.Initialize(); @@ -457,20 +458,9 @@ codeunit 148314 "EA Agent Dispatcher Test" TempEmailInbox: Record "Email Inbox" temporary; EmailMessage: Codeunit "Email Message"; TestEmailConnector: Codeunit "Test Email Connector v4"; - TempBlob: Codeunit "Temp Blob"; - AttachmentInStream: InStream; - AttachmentOutStream: OutStream; begin EmailMessage.Create('receipts@example.invalid', 'Receipt € ø', '

Two receipts for processing.

', true); - TempBlob.CreateOutStream(AttachmentOutStream, TextEncoding::UTF8); - AttachmentOutStream.WriteText('mock-receipt-one'); - TempBlob.CreateInStream(AttachmentInStream); - EmailMessage.AddAttachment('receipt-one.pdf', 'application/pdf', AttachmentInStream); - Clear(TempBlob); - TempBlob.CreateOutStream(AttachmentOutStream, TextEncoding::UTF8); - AttachmentOutStream.WriteText('mock-receipt-two'); - TempBlob.CreateInStream(AttachmentInStream); - EmailMessage.AddAttachment('receipt-two.png', 'image/png', AttachmentInStream); + UseReceiptAttachmentFixture := true; ReceiptMessageId := EmailMessage.GetId(); TempEmailInbox.Id := 1; TempEmailInbox."Account Id" := Setup."Email Account ID"; @@ -645,12 +635,12 @@ codeunit 148314 "EA Agent Dispatcher Test" end; [EventSubscriber(ObjectType::Codeunit, Codeunit::"EA Http Client", 'OnGetCommunicationBaseUrl', '', false, false)] - local procedure SetCommunicationBaseUrl(UseCanaryEndpoint: Boolean; var BaseUrl: SecretText) + local procedure SetCommunicationBaseUrl(UseCanaryEndpoint: Boolean; var BaseUrl: Text) begin Assert.AreEqual(ExpectedUseCanaryEndpoint, UseCanaryEndpoint, 'Endpoint selection must use the saved company setup flag.'); - Assert.IsTrue(BaseUrl.IsEmpty(), 'The communication override must precede normal endpoint lookup.'); + Assert.AreEqual('', BaseUrl, 'The communication override must precede normal endpoint lookup.'); BaseUrl := ServiceBaseUrlTok; - Assert.IsFalse(BaseUrl.IsEmpty(), 'The isolated mock endpoint must be nonempty.'); + Assert.AreNotEqual('', BaseUrl, 'The isolated mock endpoint must be nonempty.'); EndpointResolutionCount += 1; end; @@ -684,6 +674,32 @@ codeunit 148314 "EA Agent Dispatcher Test" end; end; + [EventSubscriber(ObjectType::Codeunit, Codeunit::"EA Retrieve Emails", 'OnGetEmailAttachments', '', false, false)] + local procedure SupplyReceiptAttachments(var TempAttachment: Record "EA Email Attachment" temporary; var IsHandled: Boolean) + begin + if not UseReceiptAttachmentFixture then + exit; + + Assert.IsTrue(TempAttachment.IsEmpty(), 'The fixture must be the only attachment source.'); + AddReceiptAttachment(TempAttachment, 1, 'receipt-one.pdf', 'application/pdf', 'mock-receipt-one'); + AddReceiptAttachment(TempAttachment, 2, 'receipt-two.png', 'image/png', 'mock-receipt-two'); + IsHandled := true; + end; + + local procedure AddReceiptAttachment(var TempAttachment: Record "EA Email Attachment" temporary; EntryNo: Integer; FileName: Text[250]; ContentType: Text[100]; ContentText: Text) + var + ContentOutStream: OutStream; + begin + TempAttachment.Init(); + TempAttachment."Entry No." := EntryNo; + TempAttachment.FileName := FileName; + TempAttachment.ContentType := ContentType; + TempAttachment.Insert(); + TempAttachment.Content.CreateOutStream(ContentOutStream, TextEncoding::UTF8); + ContentOutStream.WriteText(ContentText); + TempAttachment.Modify(); + end; + [EventSubscriber(ObjectType::Table, Database::"EA Outbox Email", 'OnAfterModifyEvent', '', false, false)] local procedure DisableCommunicationAfterDelivery(var Rec: Record "EA Outbox Email"; var xRec: Record "EA Outbox Email"; RunTrigger: Boolean) var From 79e98147ecf51ca03d9375149f5ea02e2ac82235 Mon Sep 17 00:00:00 2001 From: Prangshuman Das Date: Fri, 18 Sep 2026 16:55:45 +0200 Subject: [PATCH 03/13] Group Expense Agent email lifecycle tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/{ => EmailLifecycle}/EAAgentDispatcherTest.Codeunit.al | 0 .../src/{ => EmailLifecycle}/EAAgentSchedulingTest.Codeunit.al | 0 .../test/src/{ => EmailLifecycle}/EAMailboxAccessTest.Codeunit.al | 0 .../HttpResponseFiles/notification-outbox-accepted.json | 0 .../HttpResponseFiles/outbox-email-correlated.json | 0 .../EmailLifecycle}/HttpResponseFiles/receipt-accepted.json | 0 .../EmailLifecycle}/HttpResponseFiles/reminder-send-failed.json | 0 .../EmailLifecycle}/HttpResponseFiles/reminder-skipped.json | 0 .../src/{ => EmailLifecycle}/WelcomeEmailQueueTest.Codeunit.al | 0 9 files changed, 0 insertions(+), 0 deletions(-) rename src/Apps/W1/ExpenseAgent/test/src/{ => EmailLifecycle}/EAAgentDispatcherTest.Codeunit.al (100%) rename src/Apps/W1/ExpenseAgent/test/src/{ => EmailLifecycle}/EAAgentSchedulingTest.Codeunit.al (100%) rename src/Apps/W1/ExpenseAgent/test/src/{ => EmailLifecycle}/EAMailboxAccessTest.Codeunit.al (100%) rename src/Apps/W1/ExpenseAgent/test/{ => src/EmailLifecycle}/HttpResponseFiles/notification-outbox-accepted.json (100%) rename src/Apps/W1/ExpenseAgent/test/{ => src/EmailLifecycle}/HttpResponseFiles/outbox-email-correlated.json (100%) rename src/Apps/W1/ExpenseAgent/test/{ => src/EmailLifecycle}/HttpResponseFiles/receipt-accepted.json (100%) rename src/Apps/W1/ExpenseAgent/test/{ => src/EmailLifecycle}/HttpResponseFiles/reminder-send-failed.json (100%) rename src/Apps/W1/ExpenseAgent/test/{ => src/EmailLifecycle}/HttpResponseFiles/reminder-skipped.json (100%) rename src/Apps/W1/ExpenseAgent/test/src/{ => EmailLifecycle}/WelcomeEmailQueueTest.Codeunit.al (100%) diff --git a/src/Apps/W1/ExpenseAgent/test/src/EAAgentDispatcherTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al similarity index 100% rename from src/Apps/W1/ExpenseAgent/test/src/EAAgentDispatcherTest.Codeunit.al rename to src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al diff --git a/src/Apps/W1/ExpenseAgent/test/src/EAAgentSchedulingTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al similarity index 100% rename from src/Apps/W1/ExpenseAgent/test/src/EAAgentSchedulingTest.Codeunit.al rename to src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al diff --git a/src/Apps/W1/ExpenseAgent/test/src/EAMailboxAccessTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al similarity index 100% rename from src/Apps/W1/ExpenseAgent/test/src/EAMailboxAccessTest.Codeunit.al rename to src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al diff --git a/src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/notification-outbox-accepted.json b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/HttpResponseFiles/notification-outbox-accepted.json similarity index 100% rename from src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/notification-outbox-accepted.json rename to src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/HttpResponseFiles/notification-outbox-accepted.json diff --git a/src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/outbox-email-correlated.json b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/HttpResponseFiles/outbox-email-correlated.json similarity index 100% rename from src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/outbox-email-correlated.json rename to src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/HttpResponseFiles/outbox-email-correlated.json diff --git a/src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/receipt-accepted.json b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/HttpResponseFiles/receipt-accepted.json similarity index 100% rename from src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/receipt-accepted.json rename to src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/HttpResponseFiles/receipt-accepted.json diff --git a/src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/reminder-send-failed.json b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/HttpResponseFiles/reminder-send-failed.json similarity index 100% rename from src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/reminder-send-failed.json rename to src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/HttpResponseFiles/reminder-send-failed.json diff --git a/src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/reminder-skipped.json b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/HttpResponseFiles/reminder-skipped.json similarity index 100% rename from src/Apps/W1/ExpenseAgent/test/HttpResponseFiles/reminder-skipped.json rename to src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/HttpResponseFiles/reminder-skipped.json diff --git a/src/Apps/W1/ExpenseAgent/test/src/WelcomeEmailQueueTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/WelcomeEmailQueueTest.Codeunit.al similarity index 100% rename from src/Apps/W1/ExpenseAgent/test/src/WelcomeEmailQueueTest.Codeunit.al rename to src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/WelcomeEmailQueueTest.Codeunit.al From 9daeb53ef49ad6ad805fc76b339dfe07a3a75f9a Mon Sep 17 00:00:00 2001 From: Prangshuman Das Date: Fri, 18 Sep 2026 16:55:57 +0200 Subject: [PATCH 04/13] Update lifecycle test resource path Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Apps/W1/ExpenseAgent/test/app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Apps/W1/ExpenseAgent/test/app.json b/src/Apps/W1/ExpenseAgent/test/app.json index 599648a04e2..fe08a6f3a04 100644 --- a/src/Apps/W1/ExpenseAgent/test/app.json +++ b/src/Apps/W1/ExpenseAgent/test/app.json @@ -69,6 +69,6 @@ }, "target": "OnPrem", "resourceFolders": [ - "HttpResponseFiles" + "src/EmailLifecycle/HttpResponseFiles" ] } \ No newline at end of file From d6721dfab0b077989206663c779b14aeb460b947 Mon Sep 17 00:00:00 2001 From: Prangshuman Das Date: Fri, 18 Sep 2026 17:45:53 +0200 Subject: [PATCH 05/13] Trim redundant Expense Agent lifecycle tests Copilot-Session: 9131e2a8-a4b5-40c8-a748-caade17b55c8 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../EAAgentDispatcherTest.Codeunit.al | 10 +- .../EAAgentSchedulingTest.Codeunit.al | 136 ++---------------- .../EAMailboxAccessTest.Codeunit.al | 105 -------------- .../reminder-send-failed.json | 8 -- 4 files changed, 12 insertions(+), 247 deletions(-) delete mode 100644 src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/HttpResponseFiles/reminder-send-failed.json diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al index bd9715a3f99..01a10cddb11 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al @@ -270,14 +270,6 @@ codeunit 148314 "EA Agent Dispatcher Test" VerifyReminderResponse('reminder-skipped.json'); end; - [Test] - [HandlerFunctions('ExpenseServiceHandler')] - procedure ReminderBodyFailurePreservesExistingHttpOnlyBoundary() - begin - // Current AL wrappers inspect HTTP status only; do not reinterpret the service response body. - VerifyReminderResponse('reminder-send-failed.json'); - end; - [Test] [HandlerFunctions('ExpenseServiceHandler')] procedure BothChannelsProcessReceiptAndPendingOutbox() @@ -587,7 +579,7 @@ codeunit 148314 "EA Agent Dispatcher Test" Assert.AreEqual(1, HttpRequestCount, 'An eligible local open report must cause a real reminder request.'); Assert.IsFalse(IsNullGuid(RequestCorrelationId), 'The reminder request carries a production correlation id.'); - Assert.IsTrue(OutboxEmail.IsEmpty(), 'Skipped/body-failed reminders have no callback and no outbox delivery.'); + Assert.IsTrue(OutboxEmail.IsEmpty(), 'Skipped reminders have no callback and no outbox delivery.'); ExpenseAgentStatus.Get(); Assert.IsTrue(ExpenseAgentStatus."Last Notif. Run At" > PreviousRun, 'HTTP 200 advances the current HTTP-only polling boundary.'); AssertNoIncomingProcessing(); diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al index 3a76c0e2dee..1c456be0876 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al @@ -34,100 +34,6 @@ codeunit 148335 "EA Agent Scheduling Test" Assert.IsFalse(Setup.ShouldScheduleAgentTask(false), 'Disabled agent must not be scheduled.'); end; - [Test] - procedure ReceiptsOnWithMailboxSchedules() - var - Setup: Record "Expense Agent Setup" temporary; - begin - // [SCENARIO 636970] Inbound receipt processing schedules the task when a mailbox is configured. - // [GIVEN] Enabled agent, receipts on with a mailbox, communication off. - InitializeSetup(Setup); - Setup."Enable Email with Receipts" := true; - Setup."Enable Communication" := false; - - // [THEN] Scheduled. - Assert.IsTrue(Setup.ShouldScheduleAgentTask(true), 'Receipts on with a mailbox should schedule.'); - end; - - [Test] - procedure ReceiptsOnWithoutMailboxDoesNotSchedule() - var - Setup: Record "Expense Agent Setup" temporary; - begin - // [SCENARIO 636970] Receipts on but no mailbox does not schedule (nothing usable to do). - // [GIVEN] Enabled agent, receipts on, no email account, communication off. - InitializeSetup(Setup); - Setup."Enable Email with Receipts" := true; - Clear(Setup."Email Account ID"); - Setup."Enable Communication" := false; - - // [THEN] Not scheduled. - Assert.IsFalse(Setup.ShouldScheduleAgentTask(true), 'Receipts on without a mailbox must not schedule.'); - end; - - [Test] - procedure CommunicationOnWithNoreplySchedulesWhenReceiptsOff() - var - Setup: Record "Expense Agent Setup" temporary; - begin - // [SCENARIO 636970] Outbound communication keeps the task alive even when receipts are off. - // [GIVEN] Enabled agent, receipts off, communication on with a noreply account. - InitializeSetup(Setup); - Setup."Enable Email with Receipts" := false; - Setup."Enable Communication" := true; - - // [THEN] Scheduled (the welcome/outbox path needs the task). - Assert.IsTrue(Setup.ShouldScheduleAgentTask(true), 'Communication on with a noreply account should schedule.'); - end; - - [Test] - procedure CommunicationOnWithoutNoreplyDoesNotSchedule() - var - Setup: Record "Expense Agent Setup" temporary; - begin - // [SCENARIO 636970] Outbound communication requires a dedicated noreply account; the main mailbox is not used as a fallback. - // [GIVEN] Enabled agent, receipts off, communication on, only the main email account set (no noreply). - InitializeSetup(Setup); - Setup."Enable Email with Receipts" := false; - Setup."Enable Communication" := true; - Clear(Setup."Noreply Email Account ID"); - - // [THEN] Not scheduled — a noreply account is required for outbound communication. - Assert.IsFalse(Setup.ShouldScheduleAgentTask(true), 'Communication requires a noreply account; the main account is not a fallback.'); - end; - - [Test] - procedure CommunicationOnWithoutAnyAccountDoesNotSchedule() - var - Setup: Record "Expense Agent Setup" temporary; - begin - // [SCENARIO 636970] Communication on but no sender account does not schedule. - // [GIVEN] Enabled agent, receipts off, communication on, no accounts. - InitializeSetup(Setup); - Setup."Enable Email with Receipts" := false; - Setup."Enable Communication" := true; - Clear(Setup."Noreply Email Account ID"); - Clear(Setup."Email Account ID"); - - // [THEN] Not scheduled. - Assert.IsFalse(Setup.ShouldScheduleAgentTask(true), 'Communication on without any account must not schedule.'); - end; - - [Test] - procedure ReceiptsAndCommunicationOffDoesNotSchedule() - var - Setup: Record "Expense Agent Setup" temporary; - begin - // [SCENARIO 636970] With both receipts and communication off, the task is stopped even if accounts exist. - // [GIVEN] Enabled agent, both toggles off, but accounts configured. - InitializeSetup(Setup); - Setup."Enable Email with Receipts" := false; - Setup."Enable Communication" := false; - - // [THEN] Not scheduled (no idle background task). - Assert.IsFalse(Setup.ShouldScheduleAgentTask(true), 'Both toggles off must not schedule.'); - end; - [Test] procedure ReceiptsWithoutMailboxButCommunicationOnStillSchedules() var @@ -144,30 +50,6 @@ codeunit 148335 "EA Agent Scheduling Test" Assert.IsTrue(Setup.ShouldScheduleAgentTask(true), 'Communication must keep the scheduler alive without the inbound mailbox.'); end; - [Test] - procedure OutgoingCommunicationConfiguredRequiresToggleAndNoreplyAccount() - var - Setup: Record "Expense Agent Setup" temporary; - begin - // [SCENARIO 636970] Outgoing communication is only configured when the master toggle is on - // and a no-reply account is registered; the no-reply account alone is not enough and there is no - // fallback to the inbound mailbox. - InitializeSetup(Setup); - - // [GIVEN] Communication on with a no-reply account. [THEN] Configured. - Setup."Enable Communication" := true; - Assert.IsTrue(Setup.IsOutgoingCommunicationConfigured(), 'Communication on with a noreply account is configured.'); - - // [GIVEN] Communication off (account still set). [THEN] Not configured. - Setup."Enable Communication" := false; - Assert.IsFalse(Setup.IsOutgoingCommunicationConfigured(), 'Communication off must not be configured, even with an account.'); - - // [GIVEN] Communication on but no no-reply account. [THEN] Not configured. - Setup."Enable Communication" := true; - Clear(Setup."Noreply Email Account ID"); - Assert.IsFalse(Setup.IsOutgoingCommunicationConfigured(), 'Communication on without a noreply account must not be configured.'); - end; - [Test] procedure RegisteredChannelAvailabilityMatrix() var @@ -179,6 +61,7 @@ codeunit 148335 "EA Agent Scheduling Test" CommunicationPreference: Integer; IncomingAvailable: Boolean; OutgoingAvailable: Boolean; + Combination: Text; begin InitializeSetup(RegisteredSetup); @@ -194,11 +77,14 @@ codeunit 148335 "EA Agent Scheduling Test" SetOutgoingAccountState(Setup, OutgoingState); IncomingAvailable := (ReceiptsPreference = 1) and (IncomingState = 3); OutgoingAvailable := (CommunicationPreference = 1) and (OutgoingState = 3); - - Assert.AreEqual(IncomingAvailable, Setup.IsIncomingCommunicationConfigured(), 'Incoming availability must use preference, ID and connector registration.'); - Assert.AreEqual(OutgoingAvailable, Setup.IsOutgoingCommunicationConfigured(), 'Outgoing availability must use preference, ID and connector registration.'); - Assert.AreEqual(IncomingAvailable or OutgoingAvailable, Setup.ShouldScheduleAgentTask(true), 'An enabled agent requires at least one available channel.'); - Assert.IsFalse(Setup.ShouldScheduleAgentTask(false), 'No channel may schedule a disabled agent.'); + Combination := StrSubstNo( + 'Incoming state %1, outgoing state %2, receipts preference %3, communication preference %4.', + IncomingState, OutgoingState, ReceiptsPreference, CommunicationPreference); + + Assert.AreEqual(IncomingAvailable, Setup.IsIncomingCommunicationConfigured(), 'Incoming availability must use preference, ID and connector registration. ' + Combination); + Assert.AreEqual(OutgoingAvailable, Setup.IsOutgoingCommunicationConfigured(), 'Outgoing availability must use preference, ID and connector registration. ' + Combination); + Assert.AreEqual(IncomingAvailable or OutgoingAvailable, Setup.ShouldScheduleAgentTask(true), 'An enabled agent requires at least one available channel. ' + Combination); + Assert.IsFalse(Setup.ShouldScheduleAgentTask(false), 'No channel may schedule a disabled agent. ' + Combination); end; end; @@ -249,8 +135,8 @@ codeunit 148335 "EA Agent Scheduling Test" Setup."Noreply Email Connector" := Enum::"Email Connector"::"Test Email Connector v4"; end; - Assert.IsTrue(Setup.HasSchedulingChanges(PreviousSetup), 'Changing any eligibility input must require reconciliation even when the address stays the same.'); - Assert.IsTrue(PreviousSetup.HasSchedulingChanges(Setup), 'Reversing a change must also require reconciliation.'); + Assert.IsTrue(Setup.HasSchedulingChanges(PreviousSetup), StrSubstNo('Changing eligibility input %1 must require reconciliation even when the address stays the same.', ChangedInput)); + Assert.IsTrue(PreviousSetup.HasSchedulingChanges(Setup), StrSubstNo('Reversing eligibility input %1 must also require reconciliation.', ChangedInput)); end; end; diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al index b7637f588fd..fb51d36dcab 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al @@ -326,42 +326,6 @@ codeunit 148317 "EA Mailbox Access Test" AssertConfigurationUnchanged(PreviousSetup, Setup); end; - [Test] - procedure IncomingClearOnlyChangesIdentityInRecordBuffer() - var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; - begin - InitConfiguredSetup(Setup); - PreviousSetup := Setup; - - Setup.ClearIncomingMailbox(); - - AssertIncomingCleared(Setup); - AssertNoreplyUnchanged(PreviousSetup, Setup); - AssertPreferencesUnchanged(PreviousSetup, Setup); - Setup.Get(); - AssertConfigurationUnchanged(PreviousSetup, Setup); - end; - - [Test] - procedure NoreplyClearOnlyChangesIdentityInRecordBuffer() - var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; - begin - InitConfiguredSetup(Setup); - PreviousSetup := Setup; - - Setup.ClearNoreplyMailbox(); - - AssertNoreplyCleared(Setup); - AssertIncomingUnchanged(PreviousSetup, Setup); - AssertPreferencesUnchanged(PreviousSetup, Setup); - Setup.Get(); - AssertConfigurationUnchanged(PreviousSetup, Setup); - end; - [Test] procedure ExplicitCommunicationDisableStillClearsNotificationPreferences() var @@ -554,75 +518,6 @@ codeunit 148317 "EA Mailbox Access Test" AssertPreferencesUnchanged(PreviousSetup, Setup); end; - [Test] - procedure StagedRepairCanBeDiscardedWithoutChangingOriginalSetup() - var - Setup: Record "Expense Agent Setup" temporary; - StagedSetup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; - begin - InitConfiguredSetup(Setup); - Setup."Email Account ID" := CreateGuid(); - Setup."Noreply Email Account ID" := CreateGuid(); - Setup.Modify(); - PreviousSetup := Setup; - StagedSetup := Setup; - StagedSetup.Insert(); - - Assert.IsTrue(StagedSetup.RepairMissingEmailAccounts(), 'Opening a temporary wizard buffer must stage missing-account repair.'); - AssertIncomingCleared(StagedSetup); - AssertNoreplyCleared(StagedSetup); - AssertPreferencesUnchanged(PreviousSetup, StagedSetup); - StagedSetup.Get(); - AssertConfigurationUnchanged(PreviousSetup, StagedSetup); - StagedSetup.RepairMissingEmailAccounts(); - StagedSetup.Modify(); - StagedSetup.DeleteAll(); - - Setup.Get(); - AssertConfigurationUnchanged(PreviousSetup, Setup); - end; - - [Test] - [HandlerFunctions('EmailAccountsCancelHandler,ConfirmYesHandler')] - procedure DiscardingStagedIncomingClearPreservesOriginalSetup() - var - Setup: Record "Expense Agent Setup" temporary; - StagedSetup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; - begin - InitConfiguredSetup(Setup); - PreviousSetup := Setup; - StagedSetup := Setup; - StagedSetup.Insert(); - Commit(); - - StagedSetup.AssistEditMailbox(); - StagedSetup.Get(); - AssertIncomingCleared(StagedSetup); - AssertPreferencesUnchanged(PreviousSetup, StagedSetup); - StagedSetup.DeleteAll(); - - Setup.Get(); - AssertConfigurationUnchanged(PreviousSetup, Setup); - end; - - [Test] - procedure TemporaryDisableOnlyChangesAgentStateInBuffer() - var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; - begin - InitConfiguredSetup(Setup); - PreviousSetup := Setup; - - Setup.Validate("Enable Agent", false); - - Assert.IsFalse(Setup."Enable Agent", 'The pending disable must be staged.'); - Setup.Get(); - AssertConfigurationUnchanged(PreviousSetup, Setup); - end; - [Test] procedure SchedulingAccessSkipsStaleIncomingWithAvailableOutgoing() var diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/HttpResponseFiles/reminder-send-failed.json b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/HttpResponseFiles/reminder-send-failed.json deleted file mode 100644 index 01d2db99ae5..00000000000 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/HttpResponseFiles/reminder-send-failed.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "success": true, - "report_count": 1, - "skipped": false, - "email_generated": true, - "notification_sent": false, - "send_error": "RuntimeError" -} From 64c32ce628bcd02b4b8fcf21ff21d08e15f6c09a Mon Sep 17 00:00:00 2001 From: Prangshuman Das Date: Fri, 18 Sep 2026 18:11:40 +0200 Subject: [PATCH 06/13] Document new email lifecycle tests Remove obsolete hidden mailbox assist-edit handlers from the legacy setup card while retaining the active wizard and table paths. Copilot-Session: 9131e2a8-a4b5-40c8-a748-caade17b55c8 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/Setup/Pages/ExpenseAgentSetup.Page.al | 27 ---- .../EAAgentDispatcherTest.Codeunit.al | 79 ++++++++++- .../EAAgentSchedulingTest.Codeunit.al | 29 +++- .../EAMailboxAccessTest.Codeunit.al | 126 ++++++++++++++++++ 4 files changed, 231 insertions(+), 30 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetup.Page.al b/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetup.Page.al index 20be8817a38..78cdc238e92 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetup.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetup.Page.al @@ -47,15 +47,6 @@ page 6996 "Expense Agent Setup" ObsoleteTag = '30.0'; ObsoleteReason = 'Use Configure Expense Agent to set up receipt submission.'; - trigger OnAssistEdit() - var - PreviousSetup: Record "Expense Agent Setup"; - begin - PreviousSetup := Rec; - Rec.AssistEditMailbox(); - if Rec.HasSchedulingChanges(PreviousSetup) then - ScheduleAllTasks(); - end; } field("Enable Email with Receipts"; Rec."Enable Email with Receipts") { @@ -146,15 +137,6 @@ page 6996 "Expense Agent Setup" ObsoleteTag = '30.0'; ObsoleteReason = 'Use Configure Expense Agent to set up outgoing communication.'; - trigger OnAssistEdit() - var - PreviousSetup: Record "Expense Agent Setup"; - begin - PreviousSetup := Rec; - Rec.AssistEditNoreplyMailbox(); - if Rec.HasSchedulingChanges(PreviousSetup) then - ScheduleAllTasks(); - end; } } group(OpenReportReminders) @@ -592,13 +574,4 @@ page 6996 "Expense Agent Setup" ActivatePolicyEvalQst: Label 'You are about to activate automated policy evaluation. By doing this, you acknowledge that this feature will consume additional AI credits. Continue?'; #endif -#if not CLEAN30 - local procedure ScheduleAllTasks() - var - EAAgentScheduler: Codeunit "EA Agent Scheduler"; - begin - Rec.Modify(); - EAAgentScheduler.ScheduleAgent(Rec); - end; -#endif } \ No newline at end of file diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al index 01a10cddb11..573a2517adb 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al @@ -50,13 +50,20 @@ codeunit 148314 "EA Agent Dispatcher Test" FirstOutboxEmail: Record "EA Outbox Email"; SecondOutboxEmail: Record "EA Outbox Email"; begin + // [SCENARIO] Outgoing delivery processes multiple pending rows without an incoming mailbox. + + // [GIVEN] The isolated fixture enables only a registered outgoing channel and creates two pending outbox rows. InitializeCommunication(Setup, false, true); CreateRecipient(ExpenseUser, false); CreatePendingEmail(FirstOutboxEmail); CreatePendingEmail(SecondOutboxEmail); + + // [WHEN] The production synchronous communication pass runs through the native mocked email connector. RunCommunication(Setup); + + // [THEN] Both rows are sent, the connector receives a message, and no incoming processing occurs. FirstOutboxEmail.Get(FirstOutboxEmail.Id); SecondOutboxEmail.Get(SecondOutboxEmail.Id); Assert.AreEqual(FirstOutboxEmail.Status::Sent, FirstOutboxEmail.Status, 'Outgoing must work with receipts enabled but no incoming account.'); @@ -75,6 +82,9 @@ codeunit 148314 "EA Agent Dispatcher Test" EAKPI: Record "EA KPI"; FilesReceivedBefore: Integer; begin + // [SCENARIO] Incoming-only processing submits receipt attachments without consuming outgoing work. + + // [GIVEN] The isolated fixture enables only the incoming channel, supplies a mocked inbox message with two BLOB attachments, and expects an HTTP 202 response. InitializeCommunication(Setup, true, false); CreateRecipient(ExpenseUser, true); CreatePendingEmail(OutboxEmail); @@ -83,8 +93,12 @@ codeunit 148314 "EA Agent Dispatcher Test" FilesReceivedBefore := EAKPI."File Received"; ExpectService('/api/v1.0/expenses/process', 'receipt-accepted.json', 202); + + // [WHEN] The production synchronous communication pass processes the mocked inbox. RunCommunication(Setup); + + // [THEN] One multipart request is observed, the receipt is persisted and marked processed, the KPI counts both attachments, and outgoing work is unchanged. Assert.AreEqual(1, HttpRequestCount, 'Incoming-only must submit one receipt.'); AssertMultipartReceipt(); AssertReceiptProcessed(); @@ -102,6 +116,9 @@ codeunit 148314 "EA Agent Dispatcher Test" ExpenseAgentStatus: Record "Expense Agent Status"; PreviousNotificationRun: DateTime; begin + // [SCENARIO] No communication phase runs when neither channel has a registered account. + + // [GIVEN] Queued welcome, outbox, and reminder work exists, while both channel accounts are unavailable and connector operations are configured to fail if called. InitializeCommunication(Setup, false, false); CreateRecipient(ExpenseUser, true); CreatePendingEmail(OutboxEmail); @@ -109,8 +126,12 @@ codeunit 148314 "EA Agent Dispatcher Test" ConnectorMock.FailOnRetrieveEmails(true); ConnectorMock.FailOnSend(true); + + // [WHEN] The production synchronous communication pass runs. RunCommunication(Setup); + + // [THEN] Outgoing work, incoming status, and reminder polling remain unchanged because both channels are skipped. AssertOutgoingUnchanged(OutboxEmail, ExpenseUser); AssertNoIncomingProcessing(); ExpenseAgentStatus.Get(); @@ -126,6 +147,9 @@ codeunit 148314 "EA Agent Dispatcher Test" OutboxEmail: Record "EA Outbox Email"; Attempt: Integer; begin + // [SCENARIO] Repeated mocked connector failures become terminal on the fifth delivery attempt. + + // [GIVEN] A welcome handoff is accepted, a simulated correlated callback creates pending outbox work, and connector sending is configured to fail. InitializeCommunication(Setup, false, true); CreateRecipient(ExpenseUser, true); ExpectService('/api/v1.0/notifications/welcome', 'notification-outbox-accepted.json', 200); @@ -136,10 +160,14 @@ codeunit 148314 "EA Agent Dispatcher Test" ExpectNoService(); ConnectorMock.FailOnSend(true); + + // [WHEN] The production communication pass is repeated through five failed attempts and once after terminal failure. for Attempt := 1 to 5 do begin RunCommunication(Setup); OutboxEmail.Get(OutboxEmail.Id); ExpenseUser.Get(ExpenseUser."No."); + + // [THEN] Retry count advances once per pass, attempts one through four remain pending, attempt five fails the row and welcome, and terminal work is not retried. Assert.AreEqual(Attempt, OutboxEmail."Retry Count", 'One failed connector delivery per pass is one retry.'); if Attempt < 5 then begin Assert.AreEqual(OutboxEmail.Status::Pending, OutboxEmail.Status, 'Attempts one through four remain pending.'); @@ -164,12 +192,19 @@ codeunit 148314 "EA Agent Dispatcher Test" OutboxEmail: Record "EA Outbox Email"; EmailMessage: Codeunit "Email Message"; begin + // [SCENARIO] HTTP handoff acceptance and mocked connector delivery remain distinct welcome-email stages. + + // [GIVEN] An outgoing-only fixture queues a welcome request and the mocked service accepts it with a correlation ID. InitializeCommunication(Setup, false, true); CreateRecipient(ExpenseUser, true); ExpectService('/api/v1.0/notifications/welcome', 'notification-outbox-accepted.json', 200); + + // [WHEN] The first pass records handoff acceptance; a simulated Business Central callback is inserted; the second pass sends through the mocked connector. RunCommunication(Setup); + + // [THEN] Acceptance alone leaves the user In Outbox, while the correlated callback and connector delivery set Sent and persist the expected message content. ExpenseUser.Get(ExpenseUser."No."); Assert.AreEqual(1, HttpRequestCount, 'One real welcome request is expected.'); Assert.IsFalse(IsNullGuid(RequestCorrelationId), 'The production request must carry a correlation header.'); @@ -178,7 +213,6 @@ codeunit 148314 "EA Agent Dispatcher Test" Assert.AreEqual(0DT, ExpenseUser."Welcome Email Sent At", 'HTTP acceptance alone is not Sent.'); Assert.IsTrue(OutboxEmail.IsEmpty(), 'Returning HTTP 200 alone must not fabricate a callback.'); - // Simulated BC writeback, outside the HTTP TryFunction. This is not OData/auth validation. InsertCorrelatedCallback(OutboxEmail, RequestCorrelationId); Assert.AreEqual(OutboxEmail.Status::Pending, OutboxEmail.Status, 'The callback inserts pending work.'); ExpectNoService(); @@ -202,13 +236,19 @@ codeunit 148314 "EA Agent Dispatcher Test" ExpenseUser: Record "Expense User"; OutboxEmail: Record "EA Outbox Email"; begin + // [SCENARIO] A failed welcome HTTP handoff does not fabricate callback or delivery state. + + // [GIVEN] An outgoing-only fixture queues a welcome request and the mocked endpoint returns HTTP 502 without asserting an external error-body contract. InitializeCommunication(Setup, false, true); CreateRecipient(ExpenseUser, true); - // Only the HTTP 502 boundary is asserted; no unverified service error-body contract is invented. ExpectService('/api/v1.0/notifications/welcome', '', 502); + + // [WHEN] The production synchronous communication pass attempts the handoff. RunCommunication(Setup); + + // [THEN] The welcome is Failed with no correlation or sent timestamp, and no outbox callback row exists. ExpenseUser.Get(ExpenseUser."No."); Assert.AreEqual(1, HttpRequestCount, 'The failure must come from the real HTTP status boundary.'); Assert.AreEqual(ExpenseUser."Welcome Email Status"::Failed, ExpenseUser."Welcome Email Status", 'HTTP 502 fails the handoff.'); @@ -224,14 +264,21 @@ codeunit 148314 "EA Agent Dispatcher Test" EAHttpClient: Codeunit "EA Http Client"; Success: Boolean; begin + // [SCENARIO] The HTTP wrapper rejects a welcome request before endpoint resolution when persisted setup is missing. + + // [GIVEN] The isolated company has no Expense Agent setup and request counters are reset. AssertIsolatedCompany(); ExpectNoService(); Setup.DeleteAll(); Commit(); BindSubscription(this); + + // [WHEN] The production welcome notification wrapper is invoked with the read-only test subscriptions bound. Success := EAHttpClient.SendWelcomeEmailNotification(RecipientEmailTok, CreateGuid()); UnbindSubscription(this); + + // [THEN] The call returns false without resolving an endpoint, constructing an observed request, or reaching mocked HTTP. Assert.IsFalse(Success, 'The real HTTP wrapper must reject missing persisted setup.'); Assert.AreEqual(0, EndpointResolutionCount, 'Missing setup must be checked before the endpoint override event.'); Assert.AreEqual(0, ObservedRequestCount, 'Missing setup must not construct a service request.'); @@ -245,10 +292,17 @@ codeunit 148314 "EA Agent Dispatcher Test" Setup: Record "Expense Agent Setup"; ExpenseUser: Record "Expense User"; begin + // [SCENARIO] Persisted default and canary selections both reach endpoint resolution. + + // [GIVEN] An outgoing-only fixture uses the safe mocked communication endpoint and queues a welcome recipient. InitializeCommunication(Setup, false, true); CreateRecipient(ExpenseUser, true); ExpectService('/api/v1.0/notifications/welcome', 'notification-outbox-accepted.json', 200); + + // [WHEN] A communication pass runs with the saved default selection, then another runs after persisting the canary selection. RunCommunication(Setup); + + // [THEN] Each saved selection resolves exactly one endpoint and reaches exactly one mocked HTTP request. Assert.AreEqual(1, EndpointResolutionCount, 'The saved default selection must reach endpoint resolution.'); Assert.AreEqual(1, HttpRequestCount, 'The default selection must execute the real HTTP wrapper.'); @@ -267,6 +321,13 @@ codeunit 148314 "EA Agent Dispatcher Test" [HandlerFunctions('ExpenseServiceHandler')] procedure EligibleReminderWithoutIncomingAcceptsSkippedResponse() begin + // [SCENARIO] An outgoing-only eligible reminder accepts a mocked skipped response. + + // [GIVEN] The helper creates a due open-report reminder with a registered outgoing channel, no incoming account, and the packaged skipped-response fixture. + + // [WHEN] The helper runs the production synchronous communication pass against the mocked endpoint. + + // [THEN] The helper verifies one correlated reminder request, no callback delivery, an advanced polling timestamp, and no incoming processing. VerifyReminderResponse('reminder-skipped.json'); end; @@ -278,14 +339,21 @@ codeunit 148314 "EA Agent Dispatcher Test" ExpenseUser: Record "Expense User"; OutboxEmail: Record "EA Outbox Email"; begin + // [SCENARIO] One communication pass handles available incoming and outgoing work independently. + + // [GIVEN] Both registered channels are enabled with a mocked receipt inbox, an HTTP 202 receipt response, and one pending outbox email. InitializeCommunication(Setup, true, true); CreateRecipient(ExpenseUser, false); CreatePendingEmail(OutboxEmail); CreateReceiptInbox(Setup); ExpectService('/api/v1.0/expenses/process', 'receipt-accepted.json', 202); + + // [WHEN] The production synchronous communication pass runs. RunCommunication(Setup); + + // [THEN] The receipt is submitted and persisted, and the pending outgoing email is sent in the same pass. Assert.AreEqual(1, HttpRequestCount, 'The incoming phase must submit the receipt.'); AssertMultipartReceipt(); AssertReceiptProcessed(); @@ -302,14 +370,21 @@ codeunit 148314 "EA Agent Dispatcher Test" ExpenseAgentStatus: Record "Expense Agent Status"; PreviousNotificationRun: DateTime; begin + // [SCENARIO] Later communication phases re-read persisted setup after the outbox phase commits. + + // [GIVEN] Outgoing delivery, queued welcome work, and a due reminder exist; the delivery subscriber disables communication during the pass. InitializeCommunication(Setup, false, true); CreateRecipient(ExpenseUser, true); CreatePendingEmail(OutboxEmail); CreateEligibleReminder(Setup, ExpenseUser, PreviousNotificationRun); DisableOutgoingAfterSend := true; + + // [WHEN] The production synchronous communication pass sends the pending outbox row. RunCommunication(Setup); + + // [THEN] The persisted disable is retained, already-running delivery completes, and later welcome and reminder phases do not use stale enabled settings. Setup.Get(); Assert.IsFalse(Setup."Enable Communication", 'The callback must persist the changed setup during the send phase.'); OutboxEmail.Get(OutboxEmail.Id); diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al index 1c456be0876..36dca5e29e6 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al @@ -63,9 +63,13 @@ codeunit 148335 "EA Agent Scheduling Test" OutgoingAvailable: Boolean; Combination: Text; begin + // [SCENARIO] Channel readiness and scheduling use preferences plus local account registration. + + // [GIVEN] The connector mock provides registered accounts, and each channel is varied across empty, stale, wrong-connector, and registered states with both preference values. InitializeSetup(RegisteredSetup); - // Each channel is empty, stale, registered under another connector, or registered. + + // [WHEN] Incoming readiness, outgoing readiness, and enabled or disabled scheduling are evaluated for every combination. for IncomingState := 0 to 3 do for OutgoingState := 0 to 3 do for ReceiptsPreference := 0 to 1 do @@ -81,6 +85,8 @@ codeunit 148335 "EA Agent Scheduling Test" 'Incoming state %1, outgoing state %2, receipts preference %3, communication preference %4.', IncomingState, OutgoingState, ReceiptsPreference, CommunicationPreference); + + // [THEN] Each decision matches the expected local registration matrix without probing external mailbox connectivity. Assert.AreEqual(IncomingAvailable, Setup.IsIncomingCommunicationConfigured(), 'Incoming availability must use preference, ID and connector registration. ' + Combination); Assert.AreEqual(OutgoingAvailable, Setup.IsOutgoingCommunicationConfigured(), 'Outgoing availability must use preference, ID and connector registration. ' + Combination); Assert.AreEqual(IncomingAvailable or OutgoingAvailable, Setup.ShouldScheduleAgentTask(true), 'An enabled agent requires at least one available channel. ' + Combination); @@ -93,11 +99,18 @@ codeunit 148335 "EA Agent Scheduling Test" var Setup: Record "Expense Agent Setup" temporary; begin + // [SCENARIO] Transient mailbox access failure does not erase registered channel configuration. + + // [GIVEN] Both preferences use locally registered connector accounts, and the connector mock is configured to fail retrieval. InitializeSetup(Setup); Setup."Enable Email with Receipts" := true; Setup."Enable Communication" := true; ConnectorMock.FailOnRetrieveEmails(true); + + // [WHEN] Readiness, scheduling eligibility, and missing-account repair are evaluated. + + // [THEN] Both channels remain configured, scheduling stays eligible, and repair reports no missing account. Assert.IsTrue(Setup.IsIncomingCommunicationConfigured(), 'Mailbox access failure must not be treated as deleted incoming configuration.'); Assert.IsTrue(Setup.IsOutgoingCommunicationConfigured(), 'Mailbox access failure must not be treated as deleted outgoing configuration.'); Assert.IsTrue(Setup.ShouldScheduleAgentTask(true), 'Availability must use local registration, not a live mailbox probe.'); @@ -111,11 +124,16 @@ codeunit 148335 "EA Agent Scheduling Test" PreviousSetup: Record "Expense Agent Setup" temporary; ChangedInput: Integer; begin + // [SCENARIO] Every scheduling eligibility input triggers reconciliation when changed in either direction. + + // [GIVEN] A baseline temporary setup has unchanged display addresses and no eligibility differences. PreviousSetup.Init(); PreviousSetup."Email Address" := 'receipts@example.invalid'; PreviousSetup."Noreply Email Address" := 'noreply@example.invalid'; Assert.IsFalse(PreviousSetup.HasSchedulingChanges(PreviousSetup), 'Unchanged setup must not trigger reconciliation.'); + + // [WHEN] Each agent, preference, account ID, and connector input is changed and compared in both directions. for ChangedInput := 1 to 7 do begin Setup := PreviousSetup; case ChangedInput of @@ -135,6 +153,8 @@ codeunit 148335 "EA Agent Scheduling Test" Setup."Noreply Email Connector" := Enum::"Email Connector"::"Test Email Connector v4"; end; + + // [THEN] Every eligibility change requires reconciliation, while the unchanged baseline does not. Assert.IsTrue(Setup.HasSchedulingChanges(PreviousSetup), StrSubstNo('Changing eligibility input %1 must require reconciliation even when the address stays the same.', ChangedInput)); Assert.IsTrue(PreviousSetup.HasSchedulingChanges(Setup), StrSubstNo('Reversing eligibility input %1 must also require reconciliation.', ChangedInput)); end; @@ -146,6 +166,9 @@ codeunit 148335 "EA Agent Scheduling Test" Setup: Record "Expense Agent Setup" temporary; PreviousSetup: Record "Expense Agent Setup" temporary; begin + // [SCENARIO] Non-eligibility setup changes do not trigger scheduling reconciliation. + + // [GIVEN] Only addresses, folder values, notification preferences, rules, and number-series settings differ from the baseline. PreviousSetup.Init(); Setup := PreviousSetup; Setup."Email Address" := 'receipts@example.invalid'; @@ -157,6 +180,10 @@ codeunit 148335 "EA Agent Scheduling Test" Setup."Use Rules" := not Setup."Use Rules"; Setup."No. Series Applied" := not Setup."No. Series Applied"; + + // [WHEN] The changed setup is compared with the baseline for scheduling changes. + + // [THEN] No scheduling reconciliation is required. Assert.IsFalse(Setup.HasSchedulingChanges(PreviousSetup), 'Display values, notification preferences and accounting defaults do not change channel eligibility.'); end; diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al index fb51d36dcab..2c1da97d6d1 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al @@ -297,12 +297,19 @@ codeunit 148317 "EA Mailbox Access Test" Setup: Record "Expense Agent Setup" temporary; PreviousSetup: Record "Expense Agent Setup" temporary; begin + // [SCENARIO] Declining the incoming-account clear confirmation preserves configuration. + + // [GIVEN] A configured temporary setup is loaded; the account selector is cancelled and the confirm handler replies No. InitConfiguredSetup(Setup); PreviousSetup := Setup; Commit(); + + // [WHEN] The incoming AssistEdit flow runs. Setup.AssistEditMailbox(); + + // [THEN] Incoming, no-reply, and preference values remain unchanged in the temporary record. AssertConfigurationUnchanged(PreviousSetup, Setup); Setup.Get(); AssertConfigurationUnchanged(PreviousSetup, Setup); @@ -315,12 +322,19 @@ codeunit 148317 "EA Mailbox Access Test" Setup: Record "Expense Agent Setup" temporary; PreviousSetup: Record "Expense Agent Setup" temporary; begin + // [SCENARIO] Declining the no-reply-account clear confirmation preserves configuration. + + // [GIVEN] A configured temporary setup is loaded; the account selector is cancelled and the confirm handler replies No. InitConfiguredSetup(Setup); PreviousSetup := Setup; Commit(); + + // [WHEN] The no-reply AssistEdit flow runs. Setup.AssistEditNoreplyMailbox(); + + // [THEN] Incoming, no-reply, and preference values remain unchanged in the temporary record. AssertConfigurationUnchanged(PreviousSetup, Setup); Setup.Get(); AssertConfigurationUnchanged(PreviousSetup, Setup); @@ -332,11 +346,18 @@ codeunit 148317 "EA Mailbox Access Test" Setup: Record "Expense Agent Setup" temporary; PreviousSetup: Record "Expense Agent Setup" temporary; begin + // [SCENARIO] Explicitly disabling communication clears outgoing notification preferences without changing account identities. + + // [GIVEN] A configured temporary setup has receipts, communication, and notification preferences enabled. InitConfiguredSetup(Setup); PreviousSetup := Setup; + + // [WHEN] The communication preference is validated to false. Setup.Validate("Enable Communication", false); + + // [THEN] Communication and notification preferences are off, while receipts, agent state, and both account identities are preserved. Assert.IsFalse(Setup."Enable Communication", 'The explicit communication preference must be off.'); Assert.IsFalse(Setup."Enable Open Report Notif.", 'Explicitly disabling communication must still disable reminders.'); Assert.IsFalse(Setup."Enable Approval Notif.", 'Explicitly disabling communication must still disable approval notifications.'); @@ -354,6 +375,9 @@ codeunit 148317 "EA Mailbox Access Test" PreviousSetup: Record "Expense Agent Setup" temporary; TestEmailAccount: Record "Test Email Account"; begin + // [SCENARIO] Replacing an incoming account clears folders even when the email address is unchanged. + + // [GIVEN] A configured temporary setup has old folder values, and the selector handler chooses a different registered account with the same address. InitConfiguredSetup(Setup); PreviousSetup := Setup; ConnectorMock.AddAccount(SelectedEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); @@ -363,8 +387,12 @@ codeunit 148317 "EA Mailbox Access Test" SelectedEmailAccount."Email Address" := Setup."Email Address"; Commit(); + + // [WHEN] The incoming AssistEdit flow applies the selected account. Setup.AssistEditMailbox(); + + // [THEN] The account identity changes, stale folder values clear, and no-reply settings and preferences remain unchanged. Setup.Get(); Assert.AreEqual(SelectedEmailAccount."Account Id", Setup."Email Account ID", 'The incoming identity must change even if the address is unchanged.'); Assert.AreEqual(PreviousSetup."Email Address", Setup."Email Address", 'The replacement intentionally uses the same address.'); @@ -381,14 +409,21 @@ codeunit 148317 "EA Mailbox Access Test" Setup: Record "Expense Agent Setup" temporary; PreviousSetup: Record "Expense Agent Setup" temporary; begin + // [SCENARIO] Replacing the connector for the same incoming account clears stale folders. + + // [GIVEN] A configured temporary setup holds the selected account ID under a different connector and has old folder values. InitConfiguredSetup(Setup); SelectIncomingAccount(Setup); Setup."Email Connector" := Enum::"Email Connector"::"Test Email Connector"; PreviousSetup := Setup; Commit(); + + // [WHEN] The incoming AssistEdit flow applies the registered connector identity. Setup.AssistEditMailbox(); + + // [THEN] The connector changes, folder values clear, and no-reply settings and preferences remain unchanged. Setup.Get(); Assert.AreEqual(PreviousSetup."Email Account ID", Setup."Email Account ID", 'Only the connector identity changes.'); Assert.AreEqual(SelectedEmailAccount.Connector, Setup."Email Connector", 'The selected connector must replace the stale connector.'); @@ -405,14 +440,21 @@ codeunit 148317 "EA Mailbox Access Test" Setup: Record "Expense Agent Setup" temporary; PreviousSetup: Record "Expense Agent Setup" temporary; begin + // [SCENARIO] Reselecting the unchanged incoming account preserves folders and defaults an empty no-reply channel. + + // [GIVEN] A configured temporary setup has no no-reply identity, and the selector handler chooses the current incoming account. InitConfiguredSetup(Setup); Setup.ClearNoreplyMailbox(); SelectIncomingAccount(Setup); PreviousSetup := Setup; Commit(); + + // [WHEN] The incoming AssistEdit flow runs. Setup.AssistEditMailbox(); + + // [THEN] Incoming fields including folders remain unchanged, and the no-reply identity is copied from the incoming account. Setup.Get(); AssertIncomingUnchanged(PreviousSetup, Setup); Assert.AreEqual(Setup."Email Account ID", Setup."Noreply Email Account ID", 'Reselecting the same incoming account must still default an empty no-reply account.'); @@ -428,14 +470,21 @@ codeunit 148317 "EA Mailbox Access Test" Setup: Record "Expense Agent Setup" temporary; PreviousSetup: Record "Expense Agent Setup" temporary; begin + // [SCENARIO] A replacement incoming account that fails its retrieval probe is rejected without changing configuration. + + // [GIVEN] A configured temporary setup is captured, and the selector handler chooses a registered replacement whose connector retrieval is configured to fail. InitConfiguredSetup(Setup); PreviousSetup := Setup; ConnectorMock.AddAccount(SelectedEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); ConnectorMock.FailOnRetrieveEmails(true); Commit(); + + // [WHEN] The incoming AssistEdit flow is invoked with asserterror. asserterror Setup.AssistEditMailbox(); + + // [THEN] The specific incoming connection error is asserted and all temporary configuration values remain unchanged. Assert.ExpectedError('incoming receipts because the connection failed'); AssertConfigurationUnchanged(PreviousSetup, Setup); Setup.Get(); @@ -449,14 +498,21 @@ codeunit 148317 "EA Mailbox Access Test" Setup: Record "Expense Agent Setup" temporary; PreviousSetup: Record "Expense Agent Setup" temporary; begin + // [SCENARIO] A replacement no-reply account that fails its retrieval probe is rejected without changing configuration. + + // [GIVEN] A configured temporary setup is captured, and the selector handler chooses a registered replacement whose connector retrieval is configured to fail. InitConfiguredSetup(Setup); PreviousSetup := Setup; ConnectorMock.AddAccount(SelectedEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); ConnectorMock.FailOnRetrieveEmails(true); Commit(); + + // [WHEN] The no-reply AssistEdit flow is invoked with asserterror. asserterror Setup.AssistEditNoreplyMailbox(); + + // [THEN] The specific outgoing connection error is asserted and all temporary configuration values remain unchanged. Assert.ExpectedError('outgoing notifications because the connection failed'); AssertConfigurationUnchanged(PreviousSetup, Setup); Setup.Get(); @@ -470,7 +526,12 @@ codeunit 148317 "EA Mailbox Access Test" RegisteredSetup: Record "Expense Agent Setup" temporary; MissingChannels: Integer; begin + // [SCENARIO] Missing-account repair clears only missing channel identities and preserves preferences. + + // [GIVEN] A configured registered setup is copied across incoming-only, outgoing-only, and both-missing account-ID cases. InitConfiguredSetup(RegisteredSetup); + + // [WHEN] RepairMissingEmailAccounts runs for each case and is repeated after repair. for MissingChannels := 1 to 3 do begin Setup := RegisteredSetup; if MissingChannels in [1, 3] then @@ -478,6 +539,8 @@ codeunit 148317 "EA Mailbox Access Test" if MissingChannels in [2, 3] then Setup."Noreply Email Account ID" := CreateGuid(); + + // [THEN] Only missing identities clear, surviving channels and preferences remain unchanged, and repeated repair is a no-op. Assert.IsTrue(Setup.RepairMissingEmailAccounts(), 'Missing references must be repaired.'); if MissingChannels in [1, 3] then @@ -499,12 +562,19 @@ codeunit 148317 "EA Mailbox Access Test" Setup: Record "Expense Agent Setup" temporary; PreviousSetup: Record "Expense Agent Setup" temporary; begin + // [SCENARIO] Missing-account repair clears orphaned fields for wrong connectors and empty IDs. + + // [GIVEN] A configured setup is varied first to mismatched connectors and then to empty account IDs. InitConfiguredSetup(Setup); PreviousSetup := Setup; Setup."Email Connector" := Enum::"Email Connector"::"Test Email Connector"; Setup."Noreply Email Connector" := Enum::"Email Connector"::"Test Email Connector"; + + // [WHEN] RepairMissingEmailAccounts runs for each invalid identity state. Assert.IsTrue(Setup.RepairMissingEmailAccounts(), 'The ID must be registered under the selected connector.'); + + // [THEN] Both channel identities clear while all preferences remain unchanged. AssertIncomingCleared(Setup); AssertNoreplyCleared(Setup); AssertPreferencesUnchanged(PreviousSetup, Setup); @@ -524,13 +594,20 @@ codeunit 148317 "EA Mailbox Access Test" Setup: Record "Expense Agent Setup" temporary; PreviousSetup: Record "Expense Agent Setup" temporary; begin + // [SCENARIO] Scheduling access checking skips a stale incoming reference when outgoing remains available. + + // [GIVEN] A configured temporary setup has a missing incoming account ID and a registered outgoing account. InitConfiguredSetup(Setup); Setup."Email Account ID" := CreateGuid(); PreviousSetup := Setup; Commit(); + + // [WHEN] The scheduling mailbox access check runs. Setup.CheckSchedulingMailboxAccessOrError(); + + // [THEN] Configuration remains unchanged and the outgoing channel keeps the agent eligible. AssertConfigurationUnchanged(PreviousSetup, Setup); Assert.IsTrue(Setup.ShouldScheduleAgentTask(true), 'The registered outgoing channel must remain usable.'); end; @@ -541,13 +618,20 @@ codeunit 148317 "EA Mailbox Access Test" Setup: Record "Expense Agent Setup" temporary; PreviousSetup: Record "Expense Agent Setup" temporary; begin + // [SCENARIO] Scheduling access checking skips a stale outgoing reference when incoming remains available. + + // [GIVEN] A configured temporary setup has a missing no-reply account ID and a registered incoming account. InitConfiguredSetup(Setup); Setup."Noreply Email Account ID" := CreateGuid(); PreviousSetup := Setup; Commit(); + + // [WHEN] The scheduling mailbox access check runs. Setup.CheckSchedulingMailboxAccessOrError(); + + // [THEN] Configuration remains unchanged and the incoming channel keeps the agent eligible. AssertConfigurationUnchanged(PreviousSetup, Setup); Assert.IsTrue(Setup.ShouldScheduleAgentTask(true), 'The registered incoming channel must remain usable.'); end; @@ -558,6 +642,9 @@ codeunit 148317 "EA Mailbox Access Test" Setup: Record "Expense Agent Setup" temporary; PreviousSetup: Record "Expense Agent Setup" temporary; begin + // [SCENARIO] Scheduling access checking does not probe account IDs registered under another connector. + + // [GIVEN] Both saved channel identities use connector values that do not match their native mock registrations; retrieval is configured to fail if probed. InitConfiguredSetup(Setup); Setup."Email Connector" := Enum::"Email Connector"::"Test Email Connector"; Setup."Noreply Email Connector" := Enum::"Email Connector"::"Test Email Connector"; @@ -565,8 +652,12 @@ codeunit 148317 "EA Mailbox Access Test" ConnectorMock.FailOnRetrieveEmails(true); Commit(); + + // [WHEN] The scheduling mailbox access check runs. Setup.CheckSchedulingMailboxAccessOrError(); + + // [THEN] Configuration remains unchanged and neither mismatched channel qualifies the agent for scheduling. AssertConfigurationUnchanged(PreviousSetup, Setup); Assert.IsFalse(Setup.ShouldScheduleAgentTask(true), 'Neither account is registered under its selected connector.'); end; @@ -578,12 +669,19 @@ codeunit 148317 "EA Mailbox Access Test" Setup: Record "Expense Agent Setup" temporary; PreviousSetup: Record "Expense Agent Setup" temporary; begin + // [SCENARIO] Deleting the registered incoming account preserves the outgoing channel and preferences. + + // [GIVEN] Persisted setup contains distinct registered incoming and no-reply accounts with enabled preferences. InitAccountDeletionSetup(Setup); PreviousSetup := Setup; + + // [WHEN] The native email-account API deletes the incoming account and setup is reloaded. DeleteTestEmailAccount(Setup."Email Account ID", Setup."Email Connector"); ReloadAccountDeletionSetup(Setup); + + // [THEN] Only incoming identity and folder fields clear; outgoing identity, preferences, and outgoing readiness remain. AssertIncomingCleared(Setup); AssertNoreplyUnchanged(PreviousSetup, Setup); AssertPreferencesUnchanged(PreviousSetup, Setup); @@ -597,12 +695,19 @@ codeunit 148317 "EA Mailbox Access Test" Setup: Record "Expense Agent Setup" temporary; PreviousSetup: Record "Expense Agent Setup" temporary; begin + // [SCENARIO] Deleting the registered no-reply account preserves the incoming channel and preferences. + + // [GIVEN] Persisted setup contains distinct registered incoming and no-reply accounts with enabled preferences. InitAccountDeletionSetup(Setup); PreviousSetup := Setup; + + // [WHEN] The native email-account API deletes the no-reply account and setup is reloaded. DeleteTestEmailAccount(Setup."Noreply Email Account ID", Setup."Noreply Email Connector"); ReloadAccountDeletionSetup(Setup); + + // [THEN] Only no-reply identity clears; incoming identity, preferences, and incoming readiness remain. AssertNoreplyCleared(Setup); AssertIncomingUnchanged(PreviousSetup, Setup); AssertPreferencesUnchanged(PreviousSetup, Setup); @@ -616,6 +721,9 @@ codeunit 148317 "EA Mailbox Access Test" Setup: Record "Expense Agent Setup" temporary; PreviousSetup: Record "Expense Agent Setup" temporary; begin + // [SCENARIO] Deleting one registered account shared by both channels clears both identities without changing preferences. + + // [GIVEN] Persisted setup points incoming and no-reply identities to the same registered mock account. InitAccountDeletionSetup(Setup); Setup."Noreply Email Account ID" := Setup."Email Account ID"; Setup."Noreply Email Connector" := Setup."Email Connector"; @@ -623,9 +731,13 @@ codeunit 148317 "EA Mailbox Access Test" SaveAccountDeletionSetup(Setup); PreviousSetup := Setup; + + // [WHEN] The native email-account API deletes the shared account and setup is reloaded. DeleteTestEmailAccount(Setup."Email Account ID", Setup."Email Connector"); ReloadAccountDeletionSetup(Setup); + + // [THEN] Both channel identities clear, preferences remain unchanged, and no channel remains schedulable. AssertIncomingCleared(Setup); AssertNoreplyCleared(Setup); AssertPreferencesUnchanged(PreviousSetup, Setup); @@ -640,6 +752,9 @@ codeunit 148317 "EA Mailbox Access Test" PreviousSetup: Record "Expense Agent Setup" temporary; RegisteredConnector: Enum "Email Connector"; begin + // [SCENARIO] Deleting an account registration under another connector does not clear saved mismatched selections. + + // [GIVEN] Persisted incoming and no-reply selections use a connector different from the account registration being deleted. InitAccountDeletionSetup(Setup); RegisteredConnector := Setup."Email Connector"; Setup."Email Connector" := Enum::"Email Connector"::"Test Email Connector"; @@ -649,9 +764,13 @@ codeunit 148317 "EA Mailbox Access Test" SaveAccountDeletionSetup(Setup); PreviousSetup := Setup; + + // [WHEN] The native email-account API deletes the registered connector identity and setup is reloaded. DeleteTestEmailAccount(Setup."Email Account ID", RegisteredConnector); ReloadAccountDeletionSetup(Setup); + + // [THEN] Both saved channel selections and preferences remain unchanged. AssertConfigurationUnchanged(PreviousSetup, Setup); end; @@ -663,13 +782,20 @@ codeunit 148317 "EA Mailbox Access Test" PreviousSetup: Record "Expense Agent Setup" temporary; TempEmailAccount: Record "Email Account" temporary; begin + // [SCENARIO] Deleting an unrelated registered account leaves both configured channels unchanged. + + // [GIVEN] Persisted setup contains two registered channels and the connector mock registers an additional unrelated account. InitAccountDeletionSetup(Setup); PreviousSetup := Setup; ConnectorMock.AddAccount(TempEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); + + // [WHEN] The native email-account API deletes the unrelated account and setup is reloaded. DeleteTestEmailAccount(TempEmailAccount."Account Id", TempEmailAccount.Connector); ReloadAccountDeletionSetup(Setup); + + // [THEN] Both configured channels, preferences, and channel readiness remain unchanged. AssertConfigurationUnchanged(PreviousSetup, Setup); Assert.IsTrue(Setup.IsIncomingCommunicationConfigured(), 'An unrelated deletion must not affect the incoming channel.'); Assert.IsTrue(Setup.IsOutgoingCommunicationConfigured(), 'An unrelated deletion must not affect the outgoing channel.'); From 950e4677dd470de2ebb268115a66e377491580f6 Mon Sep 17 00:00:00 2001 From: Prangshuman Das Date: Mon, 21 Sep 2026 11:18:59 +0200 Subject: [PATCH 07/13] Fix Expense Agent CI warnings Keep scheduler repair persistence under the existing update lock while making the mutation visible to CodeCop, and align lifecycle test fixtures with repository analyzer conventions. Copilot-Session: 9131e2a8-a4b5-40c8-a748-caade17b55c8 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Integration/EAAgentScheduler.Codeunit.al | 5 +- .../Setup/Tables/ExpenseAgentSetup.Table.al | 24 +- .../EAAgentDispatcherTest.Codeunit.al | 16 +- .../EAAgentSchedulingTest.Codeunit.al | 157 ++-- .../EAMailboxAccessTest.Codeunit.al | 692 +++++++++--------- 5 files changed, 459 insertions(+), 435 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentScheduler.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentScheduler.Codeunit.al index a79439267a3..0324267d23b 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentScheduler.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentScheduler.Codeunit.al @@ -46,8 +46,8 @@ codeunit 6935 "EA Agent Scheduler" EASetup: Record "Expense Agent Setup"; ExpenseAgentStatus: Record "Expense Agent Status"; ExpenseAgentAccessControl: Record "Expense Agent Access Control"; - ExpenseAgentSetupPage: Page "Expense Agent Setup"; AzureOpenAI: Codeunit "Azure OpenAI"; + ExpenseAgentSetupPage: Page "Expense Agent Setup"; TelemetryDimensions: Dictionary of [Text, Text]; begin // Setup is always locked before access control and task status, including saves and deletion. @@ -58,8 +58,7 @@ codeunit 6935 "EA Agent Scheduler" exit; end; - if EASetup.RepairMissingEmailAccounts() then - EASetup.Modify(); + EASetup.RepairMissingEmailAccounts(true); if not EASetup.ShouldScheduleAgentTask(EASetup."Enable Agent") or not AzureOpenAI.IsEnabled(Enum::"Copilot Capability"::"Expense Agent", true) diff --git a/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al b/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al index 0e0f95024ed..0c50e65ce28 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al @@ -812,7 +812,16 @@ table 6930 "Expense Agent Setup" /// Clears only unavailable account references on this record buffer. The caller owns /// persistence and scheduling; user preferences and native agent state remain unchanged. /// - internal procedure RepairMissingEmailAccounts() Changed: Boolean + internal procedure RepairMissingEmailAccounts(): Boolean + begin + exit(RepairMissingEmailAccounts(false)); + end; + + /// + /// Clears unavailable account references and optionally persists this record buffer. + /// User preferences and native agent state remain unchanged. + /// + internal procedure RepairMissingEmailAccounts(PersistChanges: Boolean) Changed: Boolean var EmailAccount: Codeunit "Email Account"; EmptyEmailConnector: Enum "Email Connector"; @@ -821,7 +830,11 @@ table 6930 "Expense Agent Setup" if not IsNullGuid(Rec."Email Account ID") or (Rec."Email Address" <> '') or (Rec."Email Connector" <> EmptyEmailConnector) or (Rec."Email Folder" <> '') or (Rec."Email Folder Id" <> '') then begin - ClearIncomingMailbox(); + Rec."Email Address" := ''; + Clear(Rec."Email Account ID"); + Clear(Rec."Email Connector"); + Rec."Email Folder" := ''; + Rec."Email Folder Id" := ''; Changed := true; end; @@ -829,9 +842,14 @@ table 6930 "Expense Agent Setup" if not IsNullGuid(Rec."Noreply Email Account ID") or (Rec."Noreply Email Address" <> '') or (Rec."Noreply Email Connector" <> EmptyEmailConnector) then begin - ClearNoreplyMailbox(); + Rec."Noreply Email Address" := ''; + Clear(Rec."Noreply Email Account ID"); + Clear(Rec."Noreply Email Connector"); Changed := true; end; + + if Changed and PersistChanges then + Rec.Modify(); end; var diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al index 573a2517adb..08303ea45b5 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al @@ -39,7 +39,6 @@ codeunit 148314 "EA Agent Dispatcher Test" UseReceiptAttachmentFixture: Boolean; TestCompanyTok: Label 'EA Email Lifecycle Test', Locked = true; ServiceBaseUrlTok: Label 'https://expense-agent.example.invalid', Locked = true; - RecipientEmailTok: Label 'recipient@example.invalid', Locked = true; OneOwnerMustBeDefinedErr: Label 'At least one user must be able to configure the Expense Agent.'; [Test] @@ -274,7 +273,7 @@ codeunit 148314 "EA Agent Dispatcher Test" BindSubscription(this); // [WHEN] The production welcome notification wrapper is invoked with the read-only test subscriptions bound. - Success := EAHttpClient.SendWelcomeEmailNotification(RecipientEmailTok, CreateGuid()); + Success := EAHttpClient.SendWelcomeEmailNotification(GetRecipientEmail(), CreateGuid()); UnbindSubscription(this); @@ -504,7 +503,7 @@ codeunit 148314 "EA Agent Dispatcher Test" begin ExpenseUser.Init(); ExpenseUser."No." := CopyStr(DelChr(Format(CreateGuid()), '=', '{}-'), 1, MaxStrLen(ExpenseUser."No.")); - ExpenseUser."E-mail" := RecipientEmailTok; + ExpenseUser."E-mail" := GetRecipientEmail(); if QueueWelcome then ExpenseUser."Welcome Email Status" := ExpenseUser."Welcome Email Status"::Queued; ExpenseUser.Insert(); @@ -514,7 +513,7 @@ codeunit 148314 "EA Agent Dispatcher Test" begin OutboxEmail.Init(); OutboxEmail.Id := 0; - OutboxEmail.ToLine := RecipientEmailTok; + OutboxEmail.ToLine := GetRecipientEmail(); OutboxEmail.Subject := 'Isolated communication test'; OutboxEmail.WriteBody('

Mock notification.

'); OutboxEmail.Insert(); @@ -533,7 +532,7 @@ codeunit 148314 "EA Agent Dispatcher Test" TempEmailInbox."Account Id" := Setup."Email Account ID"; TempEmailInbox.Connector := Setup."Email Connector"; TempEmailInbox."Message Id" := ReceiptMessageId; - TempEmailInbox."Sender Address" := RecipientEmailTok; + TempEmailInbox."Sender Address" := GetRecipientEmail(); TempEmailInbox."Sender Name" := 'Mock expense user'; TempEmailInbox."Received DateTime" := CurrentDateTime(); TempEmailInbox."Sent DateTime" := CurrentDateTime(); @@ -726,7 +725,7 @@ codeunit 148314 "EA Agent Dispatcher Test" Assert.IsFalse(Headers.Contains('Authorization'), 'The observation boundary must not expose authorization headers.'); Assert.IsTrue(Headers.GetValues('On-Behalf-Of', HeaderValues), 'The request must carry the intended expense user.'); Assert.AreEqual(1, HeaderValues.Count(), 'Exactly one expense user is expected.'); - Assert.AreEqual(RecipientEmailTok, HeaderValues.Get(1), 'Only sanitized mock recipients are allowed.'); + Assert.AreEqual(GetRecipientEmail(), HeaderValues.Get(1), 'Only sanitized mock recipients are allowed.'); Clear(HeaderValues); if ExpectedPath.EndsWith('/expenses/process') then begin Content := RequestMessage.Content(); @@ -767,6 +766,11 @@ codeunit 148314 "EA Agent Dispatcher Test" TempAttachment.Modify(); end; + local procedure GetRecipientEmail(): Text[80] + begin + exit('recipient@example.invalid'); + end; + [EventSubscriber(ObjectType::Table, Database::"EA Outbox Email", 'OnAfterModifyEvent', '', false, false)] local procedure DisableCommunicationAfterDelivery(var Rec: Record "EA Outbox Email"; var xRec: Record "EA Outbox Email"; RunTrigger: Boolean) var diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al index 36dca5e29e6..ac8dcc43fac 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al @@ -18,43 +18,46 @@ codeunit 148335 "EA Agent Scheduling Test" Assert: Codeunit Assert; ConnectorMock: Codeunit "Connector Mock"; IsolatedTestCompanyLbl: Label 'EA Email Lifecycle Test', Locked = true; + CombinationMsg: Label 'Incoming state %1, outgoing state %2, receipts preference %3, communication preference %4.', Comment = '%1 = incoming account state, %2 = outgoing account state, %3 = receipts preference, %4 = communication preference'; + ChangeInputMsg: Label 'Changing eligibility input %1 must require reconciliation even when the address stays the same.', Comment = '%1 = changed input index'; + ReverseInputMsg: Label 'Reversing eligibility input %1 must also require reconciliation.', Comment = '%1 = changed input index'; [Test] procedure DisabledAgentIsNeverScheduled() var - Setup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO 636970] The task is never scheduled when the agent is disabled, even if everything else is configured. // [GIVEN] Receipts on with a mailbox and communication on with a noreply account. - InitializeSetup(Setup); - Setup."Enable Email with Receipts" := true; - Setup."Enable Communication" := true; + InitializeSetup(TempSetup); + TempSetup."Enable Email with Receipts" := true; + TempSetup."Enable Communication" := true; // [THEN] Passing AgentEnabled = false never schedules. - Assert.IsFalse(Setup.ShouldScheduleAgentTask(false), 'Disabled agent must not be scheduled.'); + Assert.IsFalse(TempSetup.ShouldScheduleAgentTask(false), 'Disabled agent must not be scheduled.'); end; [Test] procedure ReceiptsWithoutMailboxButCommunicationOnStillSchedules() var - Setup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO 636970] Regression: turning off the inbound mailbox no longer stops the scheduler when communication is on. // [GIVEN] Enabled agent, receipts on but no mailbox, communication on with a noreply account. - InitializeSetup(Setup); - Setup."Enable Email with Receipts" := true; - Clear(Setup."Email Account ID"); - Setup."Enable Communication" := true; + InitializeSetup(TempSetup); + TempSetup."Enable Email with Receipts" := true; + Clear(TempSetup."Email Account ID"); + TempSetup."Enable Communication" := true; // [THEN] Still scheduled via the outbound path. - Assert.IsTrue(Setup.ShouldScheduleAgentTask(true), 'Communication must keep the scheduler alive without the inbound mailbox.'); + Assert.IsTrue(TempSetup.ShouldScheduleAgentTask(true), 'Communication must keep the scheduler alive without the inbound mailbox.'); end; [Test] procedure RegisteredChannelAvailabilityMatrix() var - Setup: Record "Expense Agent Setup" temporary; - RegisteredSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempRegisteredSetup: Record "Expense Agent Setup" temporary; IncomingState: Integer; OutgoingState: Integer; ReceiptsPreference: Integer; @@ -66,7 +69,7 @@ codeunit 148335 "EA Agent Scheduling Test" // [SCENARIO] Channel readiness and scheduling use preferences plus local account registration. // [GIVEN] The connector mock provides registered accounts, and each channel is varied across empty, stale, wrong-connector, and registered states with both preference values. - InitializeSetup(RegisteredSetup); + InitializeSetup(TempRegisteredSetup); // [WHEN] Incoming readiness, outgoing readiness, and enabled or disabled scheduling are evaluated for every combination. @@ -74,157 +77,157 @@ codeunit 148335 "EA Agent Scheduling Test" for OutgoingState := 0 to 3 do for ReceiptsPreference := 0 to 1 do for CommunicationPreference := 0 to 1 do begin - Setup := RegisteredSetup; - Setup."Enable Email with Receipts" := ReceiptsPreference = 1; - Setup."Enable Communication" := CommunicationPreference = 1; - SetIncomingAccountState(Setup, IncomingState); - SetOutgoingAccountState(Setup, OutgoingState); + TempSetup := TempRegisteredSetup; + TempSetup."Enable Email with Receipts" := ReceiptsPreference = 1; + TempSetup."Enable Communication" := CommunicationPreference = 1; + SetIncomingAccountState(TempSetup, IncomingState); + SetOutgoingAccountState(TempSetup, OutgoingState); IncomingAvailable := (ReceiptsPreference = 1) and (IncomingState = 3); OutgoingAvailable := (CommunicationPreference = 1) and (OutgoingState = 3); Combination := StrSubstNo( - 'Incoming state %1, outgoing state %2, receipts preference %3, communication preference %4.', + CombinationMsg, IncomingState, OutgoingState, ReceiptsPreference, CommunicationPreference); // [THEN] Each decision matches the expected local registration matrix without probing external mailbox connectivity. - Assert.AreEqual(IncomingAvailable, Setup.IsIncomingCommunicationConfigured(), 'Incoming availability must use preference, ID and connector registration. ' + Combination); - Assert.AreEqual(OutgoingAvailable, Setup.IsOutgoingCommunicationConfigured(), 'Outgoing availability must use preference, ID and connector registration. ' + Combination); - Assert.AreEqual(IncomingAvailable or OutgoingAvailable, Setup.ShouldScheduleAgentTask(true), 'An enabled agent requires at least one available channel. ' + Combination); - Assert.IsFalse(Setup.ShouldScheduleAgentTask(false), 'No channel may schedule a disabled agent. ' + Combination); + Assert.AreEqual(IncomingAvailable, TempSetup.IsIncomingCommunicationConfigured(), 'Incoming availability must use preference, ID and connector registration. ' + Combination); + Assert.AreEqual(OutgoingAvailable, TempSetup.IsOutgoingCommunicationConfigured(), 'Outgoing availability must use preference, ID and connector registration. ' + Combination); + Assert.AreEqual(IncomingAvailable or OutgoingAvailable, TempSetup.ShouldScheduleAgentTask(true), 'An enabled agent requires at least one available channel. ' + Combination); + Assert.IsFalse(TempSetup.ShouldScheduleAgentTask(false), 'No channel may schedule a disabled agent. ' + Combination); end; end; [Test] procedure RegisteredButInaccessibleAccountsRemainConfigured() var - Setup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO] Transient mailbox access failure does not erase registered channel configuration. // [GIVEN] Both preferences use locally registered connector accounts, and the connector mock is configured to fail retrieval. - InitializeSetup(Setup); - Setup."Enable Email with Receipts" := true; - Setup."Enable Communication" := true; + InitializeSetup(TempSetup); + TempSetup."Enable Email with Receipts" := true; + TempSetup."Enable Communication" := true; ConnectorMock.FailOnRetrieveEmails(true); // [WHEN] Readiness, scheduling eligibility, and missing-account repair are evaluated. // [THEN] Both channels remain configured, scheduling stays eligible, and repair reports no missing account. - Assert.IsTrue(Setup.IsIncomingCommunicationConfigured(), 'Mailbox access failure must not be treated as deleted incoming configuration.'); - Assert.IsTrue(Setup.IsOutgoingCommunicationConfigured(), 'Mailbox access failure must not be treated as deleted outgoing configuration.'); - Assert.IsTrue(Setup.ShouldScheduleAgentTask(true), 'Availability must use local registration, not a live mailbox probe.'); - Assert.IsFalse(Setup.RepairMissingEmailAccounts(), 'Registered but inaccessible accounts must not be cleared.'); + Assert.IsTrue(TempSetup.IsIncomingCommunicationConfigured(), 'Mailbox access failure must not be treated as deleted incoming configuration.'); + Assert.IsTrue(TempSetup.IsOutgoingCommunicationConfigured(), 'Mailbox access failure must not be treated as deleted outgoing configuration.'); + Assert.IsTrue(TempSetup.ShouldScheduleAgentTask(true), 'Availability must use local registration, not a live mailbox probe.'); + Assert.IsFalse(TempSetup.RepairMissingEmailAccounts(), 'Registered but inaccessible accounts must not be cleared.'); end; [Test] procedure SchedulingChangesDetectEveryEligibilityInputInBothDirections() var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempPreviousSetup: Record "Expense Agent Setup" temporary; ChangedInput: Integer; begin // [SCENARIO] Every scheduling eligibility input triggers reconciliation when changed in either direction. // [GIVEN] A baseline temporary setup has unchanged display addresses and no eligibility differences. - PreviousSetup.Init(); - PreviousSetup."Email Address" := 'receipts@example.invalid'; - PreviousSetup."Noreply Email Address" := 'noreply@example.invalid'; - Assert.IsFalse(PreviousSetup.HasSchedulingChanges(PreviousSetup), 'Unchanged setup must not trigger reconciliation.'); + TempPreviousSetup.Init(); + TempPreviousSetup."Email Address" := 'receipts@example.invalid'; + TempPreviousSetup."Noreply Email Address" := 'noreply@example.invalid'; + Assert.IsFalse(TempPreviousSetup.HasSchedulingChanges(TempPreviousSetup), 'Unchanged setup must not trigger reconciliation.'); // [WHEN] Each agent, preference, account ID, and connector input is changed and compared in both directions. for ChangedInput := 1 to 7 do begin - Setup := PreviousSetup; + TempSetup := TempPreviousSetup; case ChangedInput of 1: - Setup."Enable Agent" := not Setup."Enable Agent"; + TempSetup."Enable Agent" := not TempSetup."Enable Agent"; 2: - Setup."Enable Email with Receipts" := not Setup."Enable Email with Receipts"; + TempSetup."Enable Email with Receipts" := not TempSetup."Enable Email with Receipts"; 3: - Setup."Enable Communication" := not Setup."Enable Communication"; + TempSetup."Enable Communication" := not TempSetup."Enable Communication"; 4: - Setup."Email Account ID" := CreateGuid(); + TempSetup."Email Account ID" := CreateGuid(); 5: - Setup."Email Connector" := Enum::"Email Connector"::"Test Email Connector v4"; + TempSetup."Email Connector" := Enum::"Email Connector"::"Test Email Connector v4"; 6: - Setup."Noreply Email Account ID" := CreateGuid(); + TempSetup."Noreply Email Account ID" := CreateGuid(); 7: - Setup."Noreply Email Connector" := Enum::"Email Connector"::"Test Email Connector v4"; + TempSetup."Noreply Email Connector" := Enum::"Email Connector"::"Test Email Connector v4"; end; // [THEN] Every eligibility change requires reconciliation, while the unchanged baseline does not. - Assert.IsTrue(Setup.HasSchedulingChanges(PreviousSetup), StrSubstNo('Changing eligibility input %1 must require reconciliation even when the address stays the same.', ChangedInput)); - Assert.IsTrue(PreviousSetup.HasSchedulingChanges(Setup), StrSubstNo('Reversing eligibility input %1 must also require reconciliation.', ChangedInput)); + Assert.IsTrue(TempSetup.HasSchedulingChanges(TempPreviousSetup), StrSubstNo(ChangeInputMsg, ChangedInput)); + Assert.IsTrue(TempPreviousSetup.HasSchedulingChanges(TempSetup), StrSubstNo(ReverseInputMsg, ChangedInput)); end; end; [Test] procedure OtherSetupChangesDoNotRequireSchedulingReconciliation() var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempPreviousSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO] Non-eligibility setup changes do not trigger scheduling reconciliation. // [GIVEN] Only addresses, folder values, notification preferences, rules, and number-series settings differ from the baseline. - PreviousSetup.Init(); - Setup := PreviousSetup; - Setup."Email Address" := 'receipts@example.invalid'; - Setup."Noreply Email Address" := 'noreply@example.invalid'; - Setup."Email Folder" := 'Receipts'; - Setup."Email Folder Id" := 'folder-id'; - Setup."Enable Open Report Notif." := not Setup."Enable Open Report Notif."; - Setup."Enable Approval Notif." := not Setup."Enable Approval Notif."; - Setup."Use Rules" := not Setup."Use Rules"; - Setup."No. Series Applied" := not Setup."No. Series Applied"; + TempPreviousSetup.Init(); + TempSetup := TempPreviousSetup; + TempSetup."Email Address" := 'receipts@example.invalid'; + TempSetup."Noreply Email Address" := 'noreply@example.invalid'; + TempSetup."Email Folder" := 'Receipts'; + TempSetup."Email Folder Id" := 'folder-id'; + TempSetup."Enable Open Report Notif." := not TempSetup."Enable Open Report Notif."; + TempSetup."Enable Approval Notif." := not TempSetup."Enable Approval Notif."; + TempSetup."Use Rules" := not TempSetup."Use Rules"; + TempSetup."No. Series Applied" := not TempSetup."No. Series Applied"; // [WHEN] The changed setup is compared with the baseline for scheduling changes. // [THEN] No scheduling reconciliation is required. - Assert.IsFalse(Setup.HasSchedulingChanges(PreviousSetup), 'Display values, notification preferences and accounting defaults do not change channel eligibility.'); + Assert.IsFalse(TempSetup.HasSchedulingChanges(TempPreviousSetup), 'Display values, notification preferences and accounting defaults do not change channel eligibility.'); end; - local procedure InitializeSetup(var Setup: Record "Expense Agent Setup" temporary) + local procedure InitializeSetup(var TempSetup: Record "Expense Agent Setup" temporary) var TempEmailAccount: Record "Email Account" temporary; begin Assert.AreEqual(IsolatedTestCompanyLbl, CompanyName(), 'Email lifecycle tests must run only in their isolated test company.'); ConnectorMock.Initialize(); - Setup.Init(); + TempSetup.Init(); ConnectorMock.AddAccount(TempEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); - Setup."Email Account ID" := TempEmailAccount."Account Id"; - Setup."Email Connector" := TempEmailAccount.Connector; - Setup."Email Address" := TempEmailAccount."Email Address"; + TempSetup."Email Account ID" := TempEmailAccount."Account Id"; + TempSetup."Email Connector" := TempEmailAccount.Connector; + TempSetup."Email Address" := TempEmailAccount."Email Address"; ConnectorMock.AddAccount(TempEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); - Setup."Noreply Email Account ID" := TempEmailAccount."Account Id"; - Setup."Noreply Email Connector" := TempEmailAccount.Connector; - Setup."Noreply Email Address" := TempEmailAccount."Email Address"; + TempSetup."Noreply Email Account ID" := TempEmailAccount."Account Id"; + TempSetup."Noreply Email Connector" := TempEmailAccount.Connector; + TempSetup."Noreply Email Address" := TempEmailAccount."Email Address"; end; - local procedure SetIncomingAccountState(var Setup: Record "Expense Agent Setup" temporary; AccountState: Integer) + local procedure SetIncomingAccountState(var TempSetup: Record "Expense Agent Setup" temporary; AccountState: Integer) begin case AccountState of 0: - Setup.ClearIncomingMailbox(); + TempSetup.ClearIncomingMailbox(); 1: - Setup."Email Account ID" := CreateGuid(); + TempSetup."Email Account ID" := CreateGuid(); 2: - Setup."Email Connector" := Enum::"Email Connector"::"Test Email Connector"; + TempSetup."Email Connector" := Enum::"Email Connector"::"Test Email Connector"; end; end; - local procedure SetOutgoingAccountState(var Setup: Record "Expense Agent Setup" temporary; AccountState: Integer) + local procedure SetOutgoingAccountState(var TempSetup: Record "Expense Agent Setup" temporary; AccountState: Integer) begin case AccountState of 0: - Setup.ClearNoreplyMailbox(); + TempSetup.ClearNoreplyMailbox(); 1: - Setup."Noreply Email Account ID" := CreateGuid(); + TempSetup."Noreply Email Account ID" := CreateGuid(); 2: - Setup."Noreply Email Connector" := Enum::"Email Connector"::"Test Email Connector"; + TempSetup."Noreply Email Connector" := Enum::"Email Connector"::"Test Email Connector"; end; end; } diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al index 2c1da97d6d1..b706bae7955 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al @@ -16,7 +16,7 @@ codeunit 148317 "EA Mailbox Access Test" TestPermissions = Disabled; var - SelectedEmailAccount: Record "Email Account" temporary; + TempSelectedEmailAccount: Record "Email Account" temporary; Assert: Codeunit Assert; ConnectorMock: Codeunit "Connector Mock"; IsolatedTestCompanyLbl: Label 'EA Email Lifecycle Test', Locked = true; @@ -24,784 +24,784 @@ codeunit 148317 "EA Mailbox Access Test" [Test] procedure ValidateMailboxAccessTrueWhenNoEmailAccountsAreConfigured() var - Setup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; begin - InitEmptySetup(Setup); + InitEmptySetup(TempSetup); - Assert.IsTrue(Setup.ValidateIncomingMailboxAccess(), 'Expected true when no incoming account is configured.'); - Assert.IsTrue(Setup.ValidateNoreplyMailboxAccess(), 'Expected true when no noreply account is configured.'); + Assert.IsTrue(TempSetup.ValidateIncomingMailboxAccess(), 'Expected true when no incoming account is configured.'); + Assert.IsTrue(TempSetup.ValidateNoreplyMailboxAccess(), 'Expected true when no noreply account is configured.'); end; [Test] procedure CheckMailboxAccessOrErrorIsNoOpWhenNoEmailAccountsAreConfigured() var - Setup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; begin - InitEmptySetup(Setup); + InitEmptySetup(TempSetup); - Setup.CheckMailboxAccessOrError(); - Assert.IsTrue(IsNullGuid(Setup."Email Account ID"), 'Email Account ID should still be empty.'); - Assert.IsTrue(IsNullGuid(Setup."Noreply Email Account ID"), 'Noreply Email Account ID should still be empty.'); + TempSetup.CheckMailboxAccessOrError(); + Assert.IsTrue(IsNullGuid(TempSetup."Email Account ID"), 'Email Account ID should still be empty.'); + Assert.IsTrue(IsNullGuid(TempSetup."Noreply Email Account ID"), 'Noreply Email Account ID should still be empty.'); end; [Test] procedure ValidateAccessFalseWhenRetrieveEmailsFails() var - Setup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; TempEmailAccount: Record "Email Account" temporary; begin // The probe runs against a real test account; the connector is configured to fail // on RetrieveEmails to simulate the current user not having access to the mailbox. - InitEmptySetup(Setup); + InitEmptySetup(TempSetup); RegisterTestEmailAccount(TempEmailAccount); ConnectorMock.FailOnRetrieveEmails(true); - Setup."Email Account ID" := TempEmailAccount."Account Id"; - Setup."Email Connector" := TempEmailAccount.Connector; - Setup."Noreply Email Account ID" := TempEmailAccount."Account Id"; - Setup."Noreply Email Connector" := TempEmailAccount.Connector; + TempSetup."Email Account ID" := TempEmailAccount."Account Id"; + TempSetup."Email Connector" := TempEmailAccount.Connector; + TempSetup."Noreply Email Account ID" := TempEmailAccount."Account Id"; + TempSetup."Noreply Email Connector" := TempEmailAccount.Connector; Commit(); // Close the write transaction before running Codeunit.Run() - Assert.IsFalse(Setup.ValidateIncomingMailboxAccess(), 'Expected false when RetrieveEmails fails on the incoming account.'); - Assert.IsFalse(Setup.ValidateNoreplyMailboxAccess(), 'Expected false when RetrieveEmails fails on the noreply account.'); + Assert.IsFalse(TempSetup.ValidateIncomingMailboxAccess(), 'Expected false when RetrieveEmails fails on the incoming account.'); + Assert.IsFalse(TempSetup.ValidateNoreplyMailboxAccess(), 'Expected false when RetrieveEmails fails on the noreply account.'); end; [Test] procedure DeactivationWarningProceedsWhenNoMailbox() var - Setup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; begin // No mailbox -> warning skipped, deactivation proceeds. - InitEmptySetup(Setup); - Assert.IsTrue(Setup.ShowDeactivationAccessWarning(), 'Expected proceed when no mailbox is configured.'); + InitEmptySetup(TempSetup); + Assert.IsTrue(TempSetup.ShowDeactivationAccessWarning(), 'Expected proceed when no mailbox is configured.'); end; [Test] [HandlerFunctions('ConfirmYesHandler')] procedure DeactivationWarningProceedsWhenUserConfirms() var - Setup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; TempEmailAccount: Record "Email Account" temporary; begin // Inaccessible mailbox -> warning shown; user clicks Yes -> proceed. - InitEmptySetup(Setup); + InitEmptySetup(TempSetup); RegisterTestEmailAccount(TempEmailAccount); ConnectorMock.FailOnRetrieveEmails(true); - Setup."Email Account ID" := TempEmailAccount."Account Id"; - Setup."Email Connector" := TempEmailAccount.Connector; + TempSetup."Email Account ID" := TempEmailAccount."Account Id"; + TempSetup."Email Connector" := TempEmailAccount.Connector; Commit(); // Close the write transaction before running Codeunit.Run() - Assert.IsTrue(Setup.ShowDeactivationAccessWarning(), 'Expected proceed when user confirms.'); + Assert.IsTrue(TempSetup.ShowDeactivationAccessWarning(), 'Expected proceed when user confirms.'); end; [Test] [HandlerFunctions('ConfirmNoHandler')] procedure DeactivationWarningCancelsWhenUserDeclines() var - Setup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; TempEmailAccount: Record "Email Account" temporary; begin // Inaccessible mailbox -> warning shown; user clicks No -> cancel. - InitEmptySetup(Setup); + InitEmptySetup(TempSetup); RegisterTestEmailAccount(TempEmailAccount); ConnectorMock.FailOnRetrieveEmails(true); - Setup."Email Account ID" := TempEmailAccount."Account Id"; - Setup."Email Connector" := TempEmailAccount.Connector; + TempSetup."Email Account ID" := TempEmailAccount."Account Id"; + TempSetup."Email Connector" := TempEmailAccount.Connector; Commit(); // Close the write transaction before running Codeunit.Run() - Assert.IsFalse(Setup.ShowDeactivationAccessWarning(), 'Expected cancel when user declines.'); + Assert.IsFalse(TempSetup.ShowDeactivationAccessWarning(), 'Expected cancel when user declines.'); end; [Test] procedure SchedulingAccessCheckIsNoOpWhenNoAccountsConfigured() var - Setup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO 636970] The scheduling access check does nothing when the enabled features have no mailbox. // [GIVEN] Receipts and communication on, but no accounts configured. - InitEmptySetup(Setup); - Setup."Enable Email with Receipts" := true; - Setup."Enable Communication" := true; + InitEmptySetup(TempSetup); + TempSetup."Enable Email with Receipts" := true; + TempSetup."Enable Communication" := true; // [THEN] The check is a no-op (no error) because there is no account to probe. - Setup.CheckSchedulingMailboxAccessOrError(); - Assert.IsTrue(IsNullGuid(Setup."Email Account ID"), 'Email Account ID should still be empty.'); - Assert.IsTrue(IsNullGuid(Setup."Noreply Email Account ID"), 'Noreply Email Account ID should still be empty.'); + TempSetup.CheckSchedulingMailboxAccessOrError(); + Assert.IsTrue(IsNullGuid(TempSetup."Email Account ID"), 'Email Account ID should still be empty.'); + Assert.IsTrue(IsNullGuid(TempSetup."Noreply Email Account ID"), 'Noreply Email Account ID should still be empty.'); end; [Test] procedure SchedulingAccessCheckErrorsWhenReceiptsOnAndIncomingInaccessible() var - Setup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; TempEmailAccount: Record "Email Account" temporary; begin // [SCENARIO 636970] Receipts on with an inaccessible incoming mailbox blocks scheduling. // [GIVEN] Receipts on with a mailbox the current user cannot access. - InitEmptySetup(Setup); + InitEmptySetup(TempSetup); RegisterTestEmailAccount(TempEmailAccount); ConnectorMock.FailOnRetrieveEmails(true); - Setup."Enable Email with Receipts" := true; - Setup."Email Account ID" := TempEmailAccount."Account Id"; - Setup."Email Connector" := TempEmailAccount.Connector; + TempSetup."Enable Email with Receipts" := true; + TempSetup."Email Account ID" := TempEmailAccount."Account Id"; + TempSetup."Email Connector" := TempEmailAccount.Connector; Commit(); // Close the write transaction before running Codeunit.Run() // [THEN] The check errors so the task is not scheduled to fail silently. - asserterror Setup.CheckSchedulingMailboxAccessOrError(); + asserterror TempSetup.CheckSchedulingMailboxAccessOrError(); Assert.ExpectedError('incoming receipts because the connection failed'); end; [Test] procedure SchedulingAccessCheckErrorsWhenCommunicationOnAndNoreplyInaccessible() var - Setup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; TempEmailAccount: Record "Email Account" temporary; begin // [SCENARIO 636970] Communication on with an inaccessible no-reply mailbox blocks scheduling. // [GIVEN] Communication on with a no-reply account the current user cannot access, receipts off. - InitEmptySetup(Setup); + InitEmptySetup(TempSetup); RegisterTestEmailAccount(TempEmailAccount); ConnectorMock.FailOnRetrieveEmails(true); - Setup."Enable Email with Receipts" := false; - Setup."Enable Communication" := true; - Setup."Noreply Email Account ID" := TempEmailAccount."Account Id"; - Setup."Noreply Email Connector" := TempEmailAccount.Connector; + TempSetup."Enable Email with Receipts" := false; + TempSetup."Enable Communication" := true; + TempSetup."Noreply Email Account ID" := TempEmailAccount."Account Id"; + TempSetup."Noreply Email Connector" := TempEmailAccount.Connector; Commit(); // Close the write transaction before running Codeunit.Run() // [THEN] The check errors on the no-reply account. - asserterror Setup.CheckSchedulingMailboxAccessOrError(); + asserterror TempSetup.CheckSchedulingMailboxAccessOrError(); Assert.ExpectedError('outgoing notifications because the connection failed'); end; [Test] procedure SchedulingAccessCheckSkipsIncomingWhenReceiptsOff() var - Setup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; TempEmailAccount: Record "Email Account" temporary; begin // [SCENARIO 636970] An inaccessible incoming mailbox is ignored when receipts are off (the task won't read it). // [GIVEN] Receipts off with an inaccessible incoming account set, communication off. - InitEmptySetup(Setup); + InitEmptySetup(TempSetup); RegisterTestEmailAccount(TempEmailAccount); ConnectorMock.FailOnRetrieveEmails(true); - Setup."Enable Email with Receipts" := false; - Setup."Email Account ID" := TempEmailAccount."Account Id"; - Setup."Email Connector" := TempEmailAccount.Connector; - Setup."Enable Communication" := false; + TempSetup."Enable Email with Receipts" := false; + TempSetup."Email Account ID" := TempEmailAccount."Account Id"; + TempSetup."Email Connector" := TempEmailAccount.Connector; + TempSetup."Enable Communication" := false; Commit(); // Close the write transaction before running Codeunit.Run() // [THEN] The check does not error because the incoming mailbox is not needed. - Setup.CheckSchedulingMailboxAccessOrError(); - Assert.IsFalse(Setup."Enable Email with Receipts", 'Receipts should remain off.'); + TempSetup.CheckSchedulingMailboxAccessOrError(); + Assert.IsFalse(TempSetup."Enable Email with Receipts", 'Receipts should remain off.'); end; [Test] procedure SchedulingAccessCheckSkipsNoreplyWhenCommunicationOff() var - Setup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; TempEmailAccount: Record "Email Account" temporary; begin // [SCENARIO 636970] An inaccessible no-reply mailbox is ignored when communication is off (the task won't send). // [GIVEN] Communication off with an inaccessible no-reply account set, receipts off. - InitEmptySetup(Setup); + InitEmptySetup(TempSetup); RegisterTestEmailAccount(TempEmailAccount); ConnectorMock.FailOnRetrieveEmails(true); - Setup."Enable Communication" := false; - Setup."Noreply Email Account ID" := TempEmailAccount."Account Id"; - Setup."Noreply Email Connector" := TempEmailAccount.Connector; - Setup."Enable Email with Receipts" := false; + TempSetup."Enable Communication" := false; + TempSetup."Noreply Email Account ID" := TempEmailAccount."Account Id"; + TempSetup."Noreply Email Connector" := TempEmailAccount.Connector; + TempSetup."Enable Email with Receipts" := false; Commit(); // Close the write transaction before running Codeunit.Run() // [THEN] The check does not error because the no-reply mailbox is not needed. - Setup.CheckSchedulingMailboxAccessOrError(); - Assert.IsFalse(Setup."Enable Communication", 'Communication should remain off.'); + TempSetup.CheckSchedulingMailboxAccessOrError(); + Assert.IsFalse(TempSetup."Enable Communication", 'Communication should remain off.'); end; [Test] procedure SchedulingAccessCheckPassesWhenMailboxesAccessible() var - Setup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; TempEmailAccount: Record "Email Account" temporary; begin // [SCENARIO 636970] The scheduling access check succeeds (no error) when the enabled features // point at mailboxes the current user can access. // [GIVEN] Receipts and communication on with an accessible account (RetrieveEmails succeeds). - InitEmptySetup(Setup); + InitEmptySetup(TempSetup); RegisterTestEmailAccount(TempEmailAccount); - Setup."Enable Email with Receipts" := true; - Setup."Email Account ID" := TempEmailAccount."Account Id"; - Setup."Email Connector" := TempEmailAccount.Connector; - Setup."Enable Communication" := true; - Setup."Noreply Email Account ID" := TempEmailAccount."Account Id"; - Setup."Noreply Email Connector" := TempEmailAccount.Connector; + TempSetup."Enable Email with Receipts" := true; + TempSetup."Email Account ID" := TempEmailAccount."Account Id"; + TempSetup."Email Connector" := TempEmailAccount.Connector; + TempSetup."Enable Communication" := true; + TempSetup."Noreply Email Account ID" := TempEmailAccount."Account Id"; + TempSetup."Noreply Email Connector" := TempEmailAccount.Connector; Commit(); // Close the write transaction before running Codeunit.Run() // [THEN] The check does not error. - Setup.CheckSchedulingMailboxAccessOrError(); - Assert.IsTrue(Setup."Enable Communication", 'Communication should remain on after a successful check.'); + TempSetup.CheckSchedulingMailboxAccessOrError(); + Assert.IsTrue(TempSetup."Enable Communication", 'Communication should remain on after a successful check.'); end; [Test] [HandlerFunctions('EmailAccountsCancelHandler,ConfirmYesHandler')] procedure AssistEditNoreplyClearsAccountWhenLookupCancelledAndConfirmed() var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempPreviousSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO 636970] Cancelling the no-reply account lookup and confirming the prompt clears // the no-reply mailbox so the agent stops sending until a new account is chosen. // [GIVEN] A configured no-reply account (an account exists, so the wizard is skipped). - InitConfiguredSetup(Setup); - PreviousSetup := Setup; + InitConfiguredSetup(TempSetup); + TempPreviousSetup := TempSetup; Commit(); // [WHEN] The user cancels the account lookup and confirms clearing the no-reply account. - Setup.AssistEditNoreplyMailbox(); + TempSetup.AssistEditNoreplyMailbox(); // [THEN] The no-reply account fields are cleared. - Setup.Get(); - AssertNoreplyCleared(Setup); - AssertIncomingUnchanged(PreviousSetup, Setup); - AssertPreferencesUnchanged(PreviousSetup, Setup); + TempSetup.Get(); + AssertNoreplyCleared(TempSetup); + AssertIncomingUnchanged(TempPreviousSetup, TempSetup); + AssertPreferencesUnchanged(TempPreviousSetup, TempSetup); end; [Test] [HandlerFunctions('EmailAccountsCancelHandler,ConfirmYesHandler')] procedure AssistEditMailboxClearsAccountWhenLookupCancelledAndConfirmed() var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempPreviousSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO 636970] Cancelling the incoming (receipts) account lookup and confirming the // prompt clears the mailbox so the agent stops processing receipts until a new account is chosen. // [GIVEN] A configured incoming mailbox (an account exists, so the wizard is skipped). - InitConfiguredSetup(Setup); - PreviousSetup := Setup; + InitConfiguredSetup(TempSetup); + TempPreviousSetup := TempSetup; Commit(); // [WHEN] The user cancels the account lookup and confirms clearing the mailbox account. - Setup.AssistEditMailbox(); + TempSetup.AssistEditMailbox(); // [THEN] The incoming mailbox fields are cleared. - Setup.Get(); - AssertIncomingCleared(Setup); - AssertNoreplyUnchanged(PreviousSetup, Setup); - AssertPreferencesUnchanged(PreviousSetup, Setup); + TempSetup.Get(); + AssertIncomingCleared(TempSetup); + AssertNoreplyUnchanged(TempPreviousSetup, TempSetup); + AssertPreferencesUnchanged(TempPreviousSetup, TempSetup); end; [Test] [HandlerFunctions('EmailAccountsCancelHandler,ConfirmNoHandler')] procedure DecliningIncomingClearPreservesConfiguration() var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempPreviousSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO] Declining the incoming-account clear confirmation preserves configuration. // [GIVEN] A configured temporary setup is loaded; the account selector is cancelled and the confirm handler replies No. - InitConfiguredSetup(Setup); - PreviousSetup := Setup; + InitConfiguredSetup(TempSetup); + TempPreviousSetup := TempSetup; Commit(); // [WHEN] The incoming AssistEdit flow runs. - Setup.AssistEditMailbox(); + TempSetup.AssistEditMailbox(); // [THEN] Incoming, no-reply, and preference values remain unchanged in the temporary record. - AssertConfigurationUnchanged(PreviousSetup, Setup); - Setup.Get(); - AssertConfigurationUnchanged(PreviousSetup, Setup); + AssertConfigurationUnchanged(TempPreviousSetup, TempSetup); + TempSetup.Get(); + AssertConfigurationUnchanged(TempPreviousSetup, TempSetup); end; [Test] [HandlerFunctions('EmailAccountsCancelHandler,ConfirmNoHandler')] procedure DecliningNoreplyClearPreservesConfiguration() var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempPreviousSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO] Declining the no-reply-account clear confirmation preserves configuration. // [GIVEN] A configured temporary setup is loaded; the account selector is cancelled and the confirm handler replies No. - InitConfiguredSetup(Setup); - PreviousSetup := Setup; + InitConfiguredSetup(TempSetup); + TempPreviousSetup := TempSetup; Commit(); // [WHEN] The no-reply AssistEdit flow runs. - Setup.AssistEditNoreplyMailbox(); + TempSetup.AssistEditNoreplyMailbox(); // [THEN] Incoming, no-reply, and preference values remain unchanged in the temporary record. - AssertConfigurationUnchanged(PreviousSetup, Setup); - Setup.Get(); - AssertConfigurationUnchanged(PreviousSetup, Setup); + AssertConfigurationUnchanged(TempPreviousSetup, TempSetup); + TempSetup.Get(); + AssertConfigurationUnchanged(TempPreviousSetup, TempSetup); end; [Test] procedure ExplicitCommunicationDisableStillClearsNotificationPreferences() var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempPreviousSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO] Explicitly disabling communication clears outgoing notification preferences without changing account identities. // [GIVEN] A configured temporary setup has receipts, communication, and notification preferences enabled. - InitConfiguredSetup(Setup); - PreviousSetup := Setup; + InitConfiguredSetup(TempSetup); + TempPreviousSetup := TempSetup; // [WHEN] The communication preference is validated to false. - Setup.Validate("Enable Communication", false); + TempSetup.Validate("Enable Communication", false); // [THEN] Communication and notification preferences are off, while receipts, agent state, and both account identities are preserved. - Assert.IsFalse(Setup."Enable Communication", 'The explicit communication preference must be off.'); - Assert.IsFalse(Setup."Enable Open Report Notif.", 'Explicitly disabling communication must still disable reminders.'); - Assert.IsFalse(Setup."Enable Approval Notif.", 'Explicitly disabling communication must still disable approval notifications.'); - Assert.IsTrue(Setup."Enable Email with Receipts", 'Disabling outgoing communication must not disable receipts.'); - Assert.AreEqual(PreviousSetup."Enable Agent", Setup."Enable Agent", 'The native agent state must not change.'); - AssertIncomingUnchanged(PreviousSetup, Setup); - AssertNoreplyUnchanged(PreviousSetup, Setup); + Assert.IsFalse(TempSetup."Enable Communication", 'The explicit communication preference must be off.'); + Assert.IsFalse(TempSetup."Enable Open Report Notif.", 'Explicitly disabling communication must still disable reminders.'); + Assert.IsFalse(TempSetup."Enable Approval Notif.", 'Explicitly disabling communication must still disable approval notifications.'); + Assert.IsTrue(TempSetup."Enable Email with Receipts", 'Disabling outgoing communication must not disable receipts.'); + Assert.AreEqual(TempPreviousSetup."Enable Agent", TempSetup."Enable Agent", 'The native agent state must not change.'); + AssertIncomingUnchanged(TempPreviousSetup, TempSetup); + AssertNoreplyUnchanged(TempPreviousSetup, TempSetup); end; [Test] [HandlerFunctions('EmailAccountSelectionHandler')] procedure SameAddressIncomingReplacementClearsOldFolders() var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempPreviousSetup: Record "Expense Agent Setup" temporary; TestEmailAccount: Record "Test Email Account"; begin // [SCENARIO] Replacing an incoming account clears folders even when the email address is unchanged. // [GIVEN] A configured temporary setup has old folder values, and the selector handler chooses a different registered account with the same address. - InitConfiguredSetup(Setup); - PreviousSetup := Setup; - ConnectorMock.AddAccount(SelectedEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); - TestEmailAccount.Get(SelectedEmailAccount."Account Id"); - TestEmailAccount.Email := Setup."Email Address"; + InitConfiguredSetup(TempSetup); + TempPreviousSetup := TempSetup; + ConnectorMock.AddAccount(TempSelectedEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); + TestEmailAccount.Get(TempSelectedEmailAccount."Account Id"); + TestEmailAccount.Email := TempSetup."Email Address"; TestEmailAccount.Modify(); - SelectedEmailAccount."Email Address" := Setup."Email Address"; + TempSelectedEmailAccount."Email Address" := TempSetup."Email Address"; Commit(); // [WHEN] The incoming AssistEdit flow applies the selected account. - Setup.AssistEditMailbox(); + TempSetup.AssistEditMailbox(); // [THEN] The account identity changes, stale folder values clear, and no-reply settings and preferences remain unchanged. - Setup.Get(); - Assert.AreEqual(SelectedEmailAccount."Account Id", Setup."Email Account ID", 'The incoming identity must change even if the address is unchanged.'); - Assert.AreEqual(PreviousSetup."Email Address", Setup."Email Address", 'The replacement intentionally uses the same address.'); - Assert.AreEqual('', Setup."Email Folder", 'The previous account folder must be cleared.'); - Assert.AreEqual('', Setup."Email Folder Id", 'The previous account folder ID must be cleared.'); - AssertNoreplyUnchanged(PreviousSetup, Setup); - AssertPreferencesUnchanged(PreviousSetup, Setup); + TempSetup.Get(); + Assert.AreEqual(TempSelectedEmailAccount."Account Id", TempSetup."Email Account ID", 'The incoming identity must change even if the address is unchanged.'); + Assert.AreEqual(TempPreviousSetup."Email Address", TempSetup."Email Address", 'The replacement intentionally uses the same address.'); + Assert.AreEqual('', TempSetup."Email Folder", 'The previous account folder must be cleared.'); + Assert.AreEqual('', TempSetup."Email Folder Id", 'The previous account folder ID must be cleared.'); + AssertNoreplyUnchanged(TempPreviousSetup, TempSetup); + AssertPreferencesUnchanged(TempPreviousSetup, TempSetup); end; [Test] [HandlerFunctions('EmailAccountSelectionHandler')] procedure IncomingConnectorReplacementClearsOldFolders() var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempPreviousSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO] Replacing the connector for the same incoming account clears stale folders. // [GIVEN] A configured temporary setup holds the selected account ID under a different connector and has old folder values. - InitConfiguredSetup(Setup); - SelectIncomingAccount(Setup); - Setup."Email Connector" := Enum::"Email Connector"::"Test Email Connector"; - PreviousSetup := Setup; + InitConfiguredSetup(TempSetup); + SelectIncomingAccount(TempSetup); + TempSetup."Email Connector" := Enum::"Email Connector"::"Test Email Connector"; + TempPreviousSetup := TempSetup; Commit(); // [WHEN] The incoming AssistEdit flow applies the registered connector identity. - Setup.AssistEditMailbox(); + TempSetup.AssistEditMailbox(); // [THEN] The connector changes, folder values clear, and no-reply settings and preferences remain unchanged. - Setup.Get(); - Assert.AreEqual(PreviousSetup."Email Account ID", Setup."Email Account ID", 'Only the connector identity changes.'); - Assert.AreEqual(SelectedEmailAccount.Connector, Setup."Email Connector", 'The selected connector must replace the stale connector.'); - Assert.AreEqual('', Setup."Email Folder", 'Changing the connector must clear the folder.'); - Assert.AreEqual('', Setup."Email Folder Id", 'Changing the connector must clear the folder ID.'); - AssertNoreplyUnchanged(PreviousSetup, Setup); - AssertPreferencesUnchanged(PreviousSetup, Setup); + TempSetup.Get(); + Assert.AreEqual(TempPreviousSetup."Email Account ID", TempSetup."Email Account ID", 'Only the connector identity changes.'); + Assert.AreEqual(TempSelectedEmailAccount.Connector, TempSetup."Email Connector", 'The selected connector must replace the stale connector.'); + Assert.AreEqual('', TempSetup."Email Folder", 'Changing the connector must clear the folder.'); + Assert.AreEqual('', TempSetup."Email Folder Id", 'Changing the connector must clear the folder ID.'); + AssertNoreplyUnchanged(TempPreviousSetup, TempSetup); + AssertPreferencesUnchanged(TempPreviousSetup, TempSetup); end; [Test] [HandlerFunctions('EmailAccountSelectionHandler')] procedure ReselectingIncomingPreservesFoldersAndDefaultsEmptyNoreply() var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempPreviousSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO] Reselecting the unchanged incoming account preserves folders and defaults an empty no-reply channel. // [GIVEN] A configured temporary setup has no no-reply identity, and the selector handler chooses the current incoming account. - InitConfiguredSetup(Setup); - Setup.ClearNoreplyMailbox(); - SelectIncomingAccount(Setup); - PreviousSetup := Setup; + InitConfiguredSetup(TempSetup); + TempSetup.ClearNoreplyMailbox(); + SelectIncomingAccount(TempSetup); + TempPreviousSetup := TempSetup; Commit(); // [WHEN] The incoming AssistEdit flow runs. - Setup.AssistEditMailbox(); + TempSetup.AssistEditMailbox(); // [THEN] Incoming fields including folders remain unchanged, and the no-reply identity is copied from the incoming account. - Setup.Get(); - AssertIncomingUnchanged(PreviousSetup, Setup); - Assert.AreEqual(Setup."Email Account ID", Setup."Noreply Email Account ID", 'Reselecting the same incoming account must still default an empty no-reply account.'); - Assert.AreEqual(Setup."Email Connector", Setup."Noreply Email Connector", 'The defaulted no-reply connector must match.'); - Assert.AreEqual(Setup."Email Address", Setup."Noreply Email Address", 'The defaulted no-reply address must match.'); - AssertPreferencesUnchanged(PreviousSetup, Setup); + TempSetup.Get(); + AssertIncomingUnchanged(TempPreviousSetup, TempSetup); + Assert.AreEqual(TempSetup."Email Account ID", TempSetup."Noreply Email Account ID", 'Reselecting the same incoming account must still default an empty no-reply account.'); + Assert.AreEqual(TempSetup."Email Connector", TempSetup."Noreply Email Connector", 'The defaulted no-reply connector must match.'); + Assert.AreEqual(TempSetup."Email Address", TempSetup."Noreply Email Address", 'The defaulted no-reply address must match.'); + AssertPreferencesUnchanged(TempPreviousSetup, TempSetup); end; [Test] [HandlerFunctions('EmailAccountSelectionHandler')] procedure InaccessibleIncomingReplacementPreservesPreviousConfiguration() var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempPreviousSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO] A replacement incoming account that fails its retrieval probe is rejected without changing configuration. // [GIVEN] A configured temporary setup is captured, and the selector handler chooses a registered replacement whose connector retrieval is configured to fail. - InitConfiguredSetup(Setup); - PreviousSetup := Setup; - ConnectorMock.AddAccount(SelectedEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); + InitConfiguredSetup(TempSetup); + TempPreviousSetup := TempSetup; + ConnectorMock.AddAccount(TempSelectedEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); ConnectorMock.FailOnRetrieveEmails(true); Commit(); // [WHEN] The incoming AssistEdit flow is invoked with asserterror. - asserterror Setup.AssistEditMailbox(); + asserterror TempSetup.AssistEditMailbox(); // [THEN] The specific incoming connection error is asserted and all temporary configuration values remain unchanged. Assert.ExpectedError('incoming receipts because the connection failed'); - AssertConfigurationUnchanged(PreviousSetup, Setup); - Setup.Get(); - AssertConfigurationUnchanged(PreviousSetup, Setup); + AssertConfigurationUnchanged(TempPreviousSetup, TempSetup); + TempSetup.Get(); + AssertConfigurationUnchanged(TempPreviousSetup, TempSetup); end; [Test] [HandlerFunctions('EmailAccountSelectionHandler')] procedure InaccessibleNoreplyReplacementPreservesPreviousConfiguration() var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempPreviousSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO] A replacement no-reply account that fails its retrieval probe is rejected without changing configuration. // [GIVEN] A configured temporary setup is captured, and the selector handler chooses a registered replacement whose connector retrieval is configured to fail. - InitConfiguredSetup(Setup); - PreviousSetup := Setup; - ConnectorMock.AddAccount(SelectedEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); + InitConfiguredSetup(TempSetup); + TempPreviousSetup := TempSetup; + ConnectorMock.AddAccount(TempSelectedEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); ConnectorMock.FailOnRetrieveEmails(true); Commit(); // [WHEN] The no-reply AssistEdit flow is invoked with asserterror. - asserterror Setup.AssistEditNoreplyMailbox(); + asserterror TempSetup.AssistEditNoreplyMailbox(); // [THEN] The specific outgoing connection error is asserted and all temporary configuration values remain unchanged. Assert.ExpectedError('outgoing notifications because the connection failed'); - AssertConfigurationUnchanged(PreviousSetup, Setup); - Setup.Get(); - AssertConfigurationUnchanged(PreviousSetup, Setup); + AssertConfigurationUnchanged(TempPreviousSetup, TempSetup); + TempSetup.Get(); + AssertConfigurationUnchanged(TempPreviousSetup, TempSetup); end; [Test] procedure MissingAccountRepairPreservesPreferencesAndOtherChannel() var - Setup: Record "Expense Agent Setup" temporary; - RegisteredSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempRegisteredSetup: Record "Expense Agent Setup" temporary; MissingChannels: Integer; begin // [SCENARIO] Missing-account repair clears only missing channel identities and preserves preferences. // [GIVEN] A configured registered setup is copied across incoming-only, outgoing-only, and both-missing account-ID cases. - InitConfiguredSetup(RegisteredSetup); + InitConfiguredSetup(TempRegisteredSetup); // [WHEN] RepairMissingEmailAccounts runs for each case and is repeated after repair. for MissingChannels := 1 to 3 do begin - Setup := RegisteredSetup; + TempSetup := TempRegisteredSetup; if MissingChannels in [1, 3] then - Setup."Email Account ID" := CreateGuid(); + TempSetup."Email Account ID" := CreateGuid(); if MissingChannels in [2, 3] then - Setup."Noreply Email Account ID" := CreateGuid(); + TempSetup."Noreply Email Account ID" := CreateGuid(); // [THEN] Only missing identities clear, surviving channels and preferences remain unchanged, and repeated repair is a no-op. - Assert.IsTrue(Setup.RepairMissingEmailAccounts(), 'Missing references must be repaired.'); + Assert.IsTrue(TempSetup.RepairMissingEmailAccounts(), 'Missing references must be repaired.'); if MissingChannels in [1, 3] then - AssertIncomingCleared(Setup) + AssertIncomingCleared(TempSetup) else - AssertIncomingUnchanged(RegisteredSetup, Setup); + AssertIncomingUnchanged(TempRegisteredSetup, TempSetup); if MissingChannels in [2, 3] then - AssertNoreplyCleared(Setup) + AssertNoreplyCleared(TempSetup) else - AssertNoreplyUnchanged(RegisteredSetup, Setup); - AssertPreferencesUnchanged(RegisteredSetup, Setup); - Assert.IsFalse(Setup.RepairMissingEmailAccounts(), 'Repeated repair must be a no-op.'); + AssertNoreplyUnchanged(TempRegisteredSetup, TempSetup); + AssertPreferencesUnchanged(TempRegisteredSetup, TempSetup); + Assert.IsFalse(TempSetup.RepairMissingEmailAccounts(), 'Repeated repair must be a no-op.'); end; end; [Test] procedure WrongConnectorAndEmptyIdentityRepairClearsOrphanedFields() var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempPreviousSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO] Missing-account repair clears orphaned fields for wrong connectors and empty IDs. // [GIVEN] A configured setup is varied first to mismatched connectors and then to empty account IDs. - InitConfiguredSetup(Setup); - PreviousSetup := Setup; - Setup."Email Connector" := Enum::"Email Connector"::"Test Email Connector"; - Setup."Noreply Email Connector" := Enum::"Email Connector"::"Test Email Connector"; + InitConfiguredSetup(TempSetup); + TempPreviousSetup := TempSetup; + TempSetup."Email Connector" := Enum::"Email Connector"::"Test Email Connector"; + TempSetup."Noreply Email Connector" := Enum::"Email Connector"::"Test Email Connector"; // [WHEN] RepairMissingEmailAccounts runs for each invalid identity state. - Assert.IsTrue(Setup.RepairMissingEmailAccounts(), 'The ID must be registered under the selected connector.'); + Assert.IsTrue(TempSetup.RepairMissingEmailAccounts(), 'The ID must be registered under the selected connector.'); // [THEN] Both channel identities clear while all preferences remain unchanged. - AssertIncomingCleared(Setup); - AssertNoreplyCleared(Setup); - AssertPreferencesUnchanged(PreviousSetup, Setup); + AssertIncomingCleared(TempSetup); + AssertNoreplyCleared(TempSetup); + AssertPreferencesUnchanged(TempPreviousSetup, TempSetup); - Setup := PreviousSetup; - Clear(Setup."Email Account ID"); - Clear(Setup."Noreply Email Account ID"); - Assert.IsTrue(Setup.RepairMissingEmailAccounts(), 'Empty IDs must not retain orphaned addresses, connectors or folders.'); - AssertIncomingCleared(Setup); - AssertNoreplyCleared(Setup); - AssertPreferencesUnchanged(PreviousSetup, Setup); + TempSetup := TempPreviousSetup; + Clear(TempSetup."Email Account ID"); + Clear(TempSetup."Noreply Email Account ID"); + Assert.IsTrue(TempSetup.RepairMissingEmailAccounts(), 'Empty IDs must not retain orphaned addresses, connectors or folders.'); + AssertIncomingCleared(TempSetup); + AssertNoreplyCleared(TempSetup); + AssertPreferencesUnchanged(TempPreviousSetup, TempSetup); end; [Test] procedure SchedulingAccessSkipsStaleIncomingWithAvailableOutgoing() var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempPreviousSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO] Scheduling access checking skips a stale incoming reference when outgoing remains available. // [GIVEN] A configured temporary setup has a missing incoming account ID and a registered outgoing account. - InitConfiguredSetup(Setup); - Setup."Email Account ID" := CreateGuid(); - PreviousSetup := Setup; + InitConfiguredSetup(TempSetup); + TempSetup."Email Account ID" := CreateGuid(); + TempPreviousSetup := TempSetup; Commit(); // [WHEN] The scheduling mailbox access check runs. - Setup.CheckSchedulingMailboxAccessOrError(); + TempSetup.CheckSchedulingMailboxAccessOrError(); // [THEN] Configuration remains unchanged and the outgoing channel keeps the agent eligible. - AssertConfigurationUnchanged(PreviousSetup, Setup); - Assert.IsTrue(Setup.ShouldScheduleAgentTask(true), 'The registered outgoing channel must remain usable.'); + AssertConfigurationUnchanged(TempPreviousSetup, TempSetup); + Assert.IsTrue(TempSetup.ShouldScheduleAgentTask(true), 'The registered outgoing channel must remain usable.'); end; [Test] procedure SchedulingAccessSkipsStaleOutgoingWithAvailableIncoming() var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempPreviousSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO] Scheduling access checking skips a stale outgoing reference when incoming remains available. // [GIVEN] A configured temporary setup has a missing no-reply account ID and a registered incoming account. - InitConfiguredSetup(Setup); - Setup."Noreply Email Account ID" := CreateGuid(); - PreviousSetup := Setup; + InitConfiguredSetup(TempSetup); + TempSetup."Noreply Email Account ID" := CreateGuid(); + TempPreviousSetup := TempSetup; Commit(); // [WHEN] The scheduling mailbox access check runs. - Setup.CheckSchedulingMailboxAccessOrError(); + TempSetup.CheckSchedulingMailboxAccessOrError(); // [THEN] Configuration remains unchanged and the incoming channel keeps the agent eligible. - AssertConfigurationUnchanged(PreviousSetup, Setup); - Assert.IsTrue(Setup.ShouldScheduleAgentTask(true), 'The registered incoming channel must remain usable.'); + AssertConfigurationUnchanged(TempPreviousSetup, TempSetup); + Assert.IsTrue(TempSetup.ShouldScheduleAgentTask(true), 'The registered incoming channel must remain usable.'); end; [Test] procedure SchedulingAccessDoesNotProbeAccountsWithWrongConnector() var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempPreviousSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO] Scheduling access checking does not probe account IDs registered under another connector. // [GIVEN] Both saved channel identities use connector values that do not match their native mock registrations; retrieval is configured to fail if probed. - InitConfiguredSetup(Setup); - Setup."Email Connector" := Enum::"Email Connector"::"Test Email Connector"; - Setup."Noreply Email Connector" := Enum::"Email Connector"::"Test Email Connector"; - PreviousSetup := Setup; + InitConfiguredSetup(TempSetup); + TempSetup."Email Connector" := Enum::"Email Connector"::"Test Email Connector"; + TempSetup."Noreply Email Connector" := Enum::"Email Connector"::"Test Email Connector"; + TempPreviousSetup := TempSetup; ConnectorMock.FailOnRetrieveEmails(true); Commit(); // [WHEN] The scheduling mailbox access check runs. - Setup.CheckSchedulingMailboxAccessOrError(); + TempSetup.CheckSchedulingMailboxAccessOrError(); // [THEN] Configuration remains unchanged and neither mismatched channel qualifies the agent for scheduling. - AssertConfigurationUnchanged(PreviousSetup, Setup); - Assert.IsFalse(Setup.ShouldScheduleAgentTask(true), 'Neither account is registered under its selected connector.'); + AssertConfigurationUnchanged(TempPreviousSetup, TempSetup); + Assert.IsFalse(TempSetup.ShouldScheduleAgentTask(true), 'Neither account is registered under its selected connector.'); end; [Test] [TransactionModel(TransactionModel::AutoRollback)] procedure DeletingIncomingAccountPreservesOutgoingSenderAndPreferences() var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempPreviousSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO] Deleting the registered incoming account preserves the outgoing channel and preferences. // [GIVEN] Persisted setup contains distinct registered incoming and no-reply accounts with enabled preferences. - InitAccountDeletionSetup(Setup); - PreviousSetup := Setup; + InitAccountDeletionSetup(TempSetup); + TempPreviousSetup := TempSetup; // [WHEN] The native email-account API deletes the incoming account and setup is reloaded. - DeleteTestEmailAccount(Setup."Email Account ID", Setup."Email Connector"); - ReloadAccountDeletionSetup(Setup); + DeleteTestEmailAccount(TempSetup."Email Account ID", TempSetup."Email Connector"); + ReloadAccountDeletionSetup(TempSetup); // [THEN] Only incoming identity and folder fields clear; outgoing identity, preferences, and outgoing readiness remain. - AssertIncomingCleared(Setup); - AssertNoreplyUnchanged(PreviousSetup, Setup); - AssertPreferencesUnchanged(PreviousSetup, Setup); - Assert.IsTrue(Setup.IsOutgoingCommunicationConfigured(), 'The surviving registered sender must remain available.'); + AssertIncomingCleared(TempSetup); + AssertNoreplyUnchanged(TempPreviousSetup, TempSetup); + AssertPreferencesUnchanged(TempPreviousSetup, TempSetup); + Assert.IsTrue(TempSetup.IsOutgoingCommunicationConfigured(), 'The surviving registered sender must remain available.'); end; [Test] [TransactionModel(TransactionModel::AutoRollback)] procedure DeletingNoreplyAccountPreservesIncomingAndPreferences() var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempPreviousSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO] Deleting the registered no-reply account preserves the incoming channel and preferences. // [GIVEN] Persisted setup contains distinct registered incoming and no-reply accounts with enabled preferences. - InitAccountDeletionSetup(Setup); - PreviousSetup := Setup; + InitAccountDeletionSetup(TempSetup); + TempPreviousSetup := TempSetup; // [WHEN] The native email-account API deletes the no-reply account and setup is reloaded. - DeleteTestEmailAccount(Setup."Noreply Email Account ID", Setup."Noreply Email Connector"); - ReloadAccountDeletionSetup(Setup); + DeleteTestEmailAccount(TempSetup."Noreply Email Account ID", TempSetup."Noreply Email Connector"); + ReloadAccountDeletionSetup(TempSetup); // [THEN] Only no-reply identity clears; incoming identity, preferences, and incoming readiness remain. - AssertNoreplyCleared(Setup); - AssertIncomingUnchanged(PreviousSetup, Setup); - AssertPreferencesUnchanged(PreviousSetup, Setup); - Assert.IsTrue(Setup.IsIncomingCommunicationConfigured(), 'The surviving registered incoming account must remain available.'); + AssertNoreplyCleared(TempSetup); + AssertIncomingUnchanged(TempPreviousSetup, TempSetup); + AssertPreferencesUnchanged(TempPreviousSetup, TempSetup); + Assert.IsTrue(TempSetup.IsIncomingCommunicationConfigured(), 'The surviving registered incoming account must remain available.'); end; [Test] [TransactionModel(TransactionModel::AutoRollback)] procedure DeletingSharedAccountClearsBothChannelsWithoutChangingPreferences() var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempPreviousSetup: Record "Expense Agent Setup" temporary; begin // [SCENARIO] Deleting one registered account shared by both channels clears both identities without changing preferences. // [GIVEN] Persisted setup points incoming and no-reply identities to the same registered mock account. - InitAccountDeletionSetup(Setup); - Setup."Noreply Email Account ID" := Setup."Email Account ID"; - Setup."Noreply Email Connector" := Setup."Email Connector"; - Setup."Noreply Email Address" := Setup."Email Address"; - SaveAccountDeletionSetup(Setup); - PreviousSetup := Setup; + InitAccountDeletionSetup(TempSetup); + TempSetup."Noreply Email Account ID" := TempSetup."Email Account ID"; + TempSetup."Noreply Email Connector" := TempSetup."Email Connector"; + TempSetup."Noreply Email Address" := TempSetup."Email Address"; + SaveAccountDeletionSetup(TempSetup); + TempPreviousSetup := TempSetup; // [WHEN] The native email-account API deletes the shared account and setup is reloaded. - DeleteTestEmailAccount(Setup."Email Account ID", Setup."Email Connector"); - ReloadAccountDeletionSetup(Setup); + DeleteTestEmailAccount(TempSetup."Email Account ID", TempSetup."Email Connector"); + ReloadAccountDeletionSetup(TempSetup); // [THEN] Both channel identities clear, preferences remain unchanged, and no channel remains schedulable. - AssertIncomingCleared(Setup); - AssertNoreplyCleared(Setup); - AssertPreferencesUnchanged(PreviousSetup, Setup); - Assert.IsFalse(Setup.ShouldScheduleAgentTask(true), 'Deleting the shared account leaves no available channel.'); + AssertIncomingCleared(TempSetup); + AssertNoreplyCleared(TempSetup); + AssertPreferencesUnchanged(TempPreviousSetup, TempSetup); + Assert.IsFalse(TempSetup.ShouldScheduleAgentTask(true), 'Deleting the shared account leaves no available channel.'); end; [Test] [TransactionModel(TransactionModel::AutoRollback)] procedure DeletingAccountWithMismatchedConnectorPreservesSelections() var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempPreviousSetup: Record "Expense Agent Setup" temporary; RegisteredConnector: Enum "Email Connector"; begin // [SCENARIO] Deleting an account registration under another connector does not clear saved mismatched selections. // [GIVEN] Persisted incoming and no-reply selections use a connector different from the account registration being deleted. - InitAccountDeletionSetup(Setup); - RegisteredConnector := Setup."Email Connector"; - Setup."Email Connector" := Enum::"Email Connector"::"Test Email Connector"; - Setup."Noreply Email Account ID" := Setup."Email Account ID"; - Setup."Noreply Email Connector" := Setup."Email Connector"; - Setup."Noreply Email Address" := Setup."Email Address"; - SaveAccountDeletionSetup(Setup); - PreviousSetup := Setup; + InitAccountDeletionSetup(TempSetup); + RegisteredConnector := TempSetup."Email Connector"; + TempSetup."Email Connector" := Enum::"Email Connector"::"Test Email Connector"; + TempSetup."Noreply Email Account ID" := TempSetup."Email Account ID"; + TempSetup."Noreply Email Connector" := TempSetup."Email Connector"; + TempSetup."Noreply Email Address" := TempSetup."Email Address"; + SaveAccountDeletionSetup(TempSetup); + TempPreviousSetup := TempSetup; // [WHEN] The native email-account API deletes the registered connector identity and setup is reloaded. - DeleteTestEmailAccount(Setup."Email Account ID", RegisteredConnector); - ReloadAccountDeletionSetup(Setup); + DeleteTestEmailAccount(TempSetup."Email Account ID", RegisteredConnector); + ReloadAccountDeletionSetup(TempSetup); // [THEN] Both saved channel selections and preferences remain unchanged. - AssertConfigurationUnchanged(PreviousSetup, Setup); + AssertConfigurationUnchanged(TempPreviousSetup, TempSetup); end; [Test] [TransactionModel(TransactionModel::AutoRollback)] procedure DeletingUnrelatedAccountPreservesBothChannels() var - Setup: Record "Expense Agent Setup" temporary; - PreviousSetup: Record "Expense Agent Setup" temporary; + TempSetup: Record "Expense Agent Setup" temporary; + TempPreviousSetup: Record "Expense Agent Setup" temporary; TempEmailAccount: Record "Email Account" temporary; begin // [SCENARIO] Deleting an unrelated registered account leaves both configured channels unchanged. // [GIVEN] Persisted setup contains two registered channels and the connector mock registers an additional unrelated account. - InitAccountDeletionSetup(Setup); - PreviousSetup := Setup; + InitAccountDeletionSetup(TempSetup); + TempPreviousSetup := TempSetup; ConnectorMock.AddAccount(TempEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); // [WHEN] The native email-account API deletes the unrelated account and setup is reloaded. DeleteTestEmailAccount(TempEmailAccount."Account Id", TempEmailAccount.Connector); - ReloadAccountDeletionSetup(Setup); + ReloadAccountDeletionSetup(TempSetup); // [THEN] Both configured channels, preferences, and channel readiness remain unchanged. - AssertConfigurationUnchanged(PreviousSetup, Setup); - Assert.IsTrue(Setup.IsIncomingCommunicationConfigured(), 'An unrelated deletion must not affect the incoming channel.'); - Assert.IsTrue(Setup.IsOutgoingCommunicationConfigured(), 'An unrelated deletion must not affect the outgoing channel.'); + AssertConfigurationUnchanged(TempPreviousSetup, TempSetup); + Assert.IsTrue(TempSetup.IsIncomingCommunicationConfigured(), 'An unrelated deletion must not affect the incoming channel.'); + Assert.IsTrue(TempSetup.IsOutgoingCommunicationConfigured(), 'An unrelated deletion must not affect the outgoing channel.'); end; - local procedure InitAccountDeletionSetup(var Setup: Record "Expense Agent Setup" temporary) + local procedure InitAccountDeletionSetup(var TempSetup: Record "Expense Agent Setup" temporary) var PersistedSetup: Record "Expense Agent Setup"; ExpenseAgentStatus: Record "Expense Agent Status"; @@ -818,18 +818,18 @@ codeunit 148317 "EA Mailbox Access Test" ExpenseAgentStatus.Insert(); end; - InitConfiguredSetup(Setup); - SaveAccountDeletionSetup(Setup); + InitConfiguredSetup(TempSetup); + SaveAccountDeletionSetup(TempSetup); end; - local procedure SaveAccountDeletionSetup(Setup: Record "Expense Agent Setup" temporary) + local procedure SaveAccountDeletionSetup(TempSetup: Record "Expense Agent Setup" temporary) var PersistedSetup: Record "Expense Agent Setup"; begin PersistedSetup.ReadIsolation(IsolationLevel::UpdLock); if not PersistedSetup.Get() then PersistedSetup.Insert(); - PersistedSetup.TransferFields(Setup, false); + PersistedSetup.TransferFields(TempSetup, false); PersistedSetup.Modify(); end; @@ -845,24 +845,24 @@ codeunit 148317 "EA Mailbox Access Test" Assert.IsFalse(EmailAccount.IsAccountRegistered(AccountId, Connector), 'The registered mock account must actually be deleted.'); end; - local procedure ReloadAccountDeletionSetup(var Setup: Record "Expense Agent Setup" temporary) + local procedure ReloadAccountDeletionSetup(var TempSetup: Record "Expense Agent Setup" temporary) var PersistedSetup: Record "Expense Agent Setup"; ExpenseAgentStatus: Record "Expense Agent Status"; begin PersistedSetup.Get(); - Setup := PersistedSetup; + TempSetup := PersistedSetup; ExpenseAgentStatus.Get(); Assert.IsTrue(IsNullGuid(ExpenseAgentStatus."Agent Task ID"), 'Deletion must leave the dispatcher task ID empty.'); Assert.IsTrue(IsNullGuid(ExpenseAgentStatus."Agent Recovery Task ID"), 'Deletion must leave the recovery task ID empty.'); end; - local procedure InitEmptySetup(var Setup: Record "Expense Agent Setup" temporary) + local procedure InitEmptySetup(var TempSetup: Record "Expense Agent Setup" temporary) begin - Setup.DeleteAll(); - Setup.Init(); - Setup."Primary Key" := ''; - Setup.Insert(); + TempSetup.DeleteAll(); + TempSetup.Init(); + TempSetup."Primary Key" := ''; + TempSetup.Insert(); end; local procedure RegisterTestEmailAccount(var TempEmailAccount: Record "Email Account" temporary) @@ -872,40 +872,40 @@ codeunit 148317 "EA Mailbox Access Test" ConnectorMock.AddAccount(TempEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); end; - local procedure InitConfiguredSetup(var Setup: Record "Expense Agent Setup" temporary) + local procedure InitConfiguredSetup(var TempSetup: Record "Expense Agent Setup" temporary) var TempEmailAccount: Record "Email Account" temporary; begin - InitEmptySetup(Setup); + InitEmptySetup(TempSetup); RegisterTestEmailAccount(TempEmailAccount); - Setup."Email Account ID" := TempEmailAccount."Account Id"; - Setup."Email Connector" := TempEmailAccount.Connector; - Setup."Email Address" := TempEmailAccount."Email Address"; - Setup."Email Folder" := 'Receipts'; - Setup."Email Folder Id" := 'old-folder-id'; + TempSetup."Email Account ID" := TempEmailAccount."Account Id"; + TempSetup."Email Connector" := TempEmailAccount.Connector; + TempSetup."Email Address" := TempEmailAccount."Email Address"; + TempSetup."Email Folder" := 'Receipts'; + TempSetup."Email Folder Id" := 'old-folder-id'; ConnectorMock.AddAccount(TempEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); - Setup."Noreply Email Account ID" := TempEmailAccount."Account Id"; - Setup."Noreply Email Connector" := TempEmailAccount.Connector; - Setup."Noreply Email Address" := TempEmailAccount."Email Address"; - Setup."Enable Agent" := true; - Setup."User Security ID" := CreateGuid(); - Setup."Enable Email with Receipts" := true; - Setup."Enable Communication" := true; - Setup."Enable Open Report Notif." := true; - Setup."Enable Approval Notif." := true; - Setup."Open Report Notif. Freq." := Setup."Open Report Notif. Freq."::Weekly; - Setup."Notif. Day of Week" := Setup."Notif. Day of Week"::Friday; - Setup."Notif. Day In A Month" := 15; - Evaluate(Setup."Custom Notif. Formula", '<2D>'); - Evaluate(Setup."Approval Reminder After", '<3D>'); - Setup.Modify(); - end; - - local procedure SelectIncomingAccount(Setup: Record "Expense Agent Setup" temporary) - begin - SelectedEmailAccount."Account Id" := Setup."Email Account ID"; - SelectedEmailAccount.Connector := Setup."Email Connector"; - SelectedEmailAccount."Email Address" := Setup."Email Address"; + TempSetup."Noreply Email Account ID" := TempEmailAccount."Account Id"; + TempSetup."Noreply Email Connector" := TempEmailAccount.Connector; + TempSetup."Noreply Email Address" := TempEmailAccount."Email Address"; + TempSetup."Enable Agent" := true; + TempSetup."User Security ID" := CreateGuid(); + TempSetup."Enable Email with Receipts" := true; + TempSetup."Enable Communication" := true; + TempSetup."Enable Open Report Notif." := true; + TempSetup."Enable Approval Notif." := true; + TempSetup."Open Report Notif. Freq." := TempSetup."Open Report Notif. Freq."::Weekly; + TempSetup."Notif. Day of Week" := TempSetup."Notif. Day of Week"::Friday; + TempSetup."Notif. Day In A Month" := 15; + Evaluate(TempSetup."Custom Notif. Formula", '<2D>'); + Evaluate(TempSetup."Approval Reminder After", '<3D>'); + TempSetup.Modify(); + end; + + local procedure SelectIncomingAccount(TempSetup: Record "Expense Agent Setup" temporary) + begin + TempSelectedEmailAccount."Account Id" := TempSetup."Email Account ID"; + TempSelectedEmailAccount.Connector := TempSetup."Email Connector"; + TempSelectedEmailAccount."Email Address" := TempSetup."Email Address"; end; local procedure AssertConfigurationUnchanged(ExpectedSetup: Record "Expense Agent Setup" temporary; ActualSetup: Record "Expense Agent Setup" temporary) @@ -946,30 +946,30 @@ codeunit 148317 "EA Mailbox Access Test" Assert.AreEqual(ExpectedSetup."User Security ID", ActualSetup."User Security ID", 'The native agent identity must be preserved.'); end; - local procedure AssertIncomingCleared(Setup: Record "Expense Agent Setup" temporary) + local procedure AssertIncomingCleared(TempSetup: Record "Expense Agent Setup" temporary) var EmptyEmailConnector: Enum "Email Connector"; begin - Assert.IsTrue(IsNullGuid(Setup."Email Account ID"), 'The incoming account ID must be cleared.'); - Assert.AreEqual(EmptyEmailConnector, Setup."Email Connector", 'The incoming connector must be cleared.'); - Assert.AreEqual('', Setup."Email Address", 'The incoming address must be cleared.'); - Assert.AreEqual('', Setup."Email Folder", 'The incoming folder must be cleared.'); - Assert.AreEqual('', Setup."Email Folder Id", 'The incoming folder ID must be cleared.'); + Assert.IsTrue(IsNullGuid(TempSetup."Email Account ID"), 'The incoming account ID must be cleared.'); + Assert.AreEqual(EmptyEmailConnector, TempSetup."Email Connector", 'The incoming connector must be cleared.'); + Assert.AreEqual('', TempSetup."Email Address", 'The incoming address must be cleared.'); + Assert.AreEqual('', TempSetup."Email Folder", 'The incoming folder must be cleared.'); + Assert.AreEqual('', TempSetup."Email Folder Id", 'The incoming folder ID must be cleared.'); end; - local procedure AssertNoreplyCleared(Setup: Record "Expense Agent Setup" temporary) + local procedure AssertNoreplyCleared(TempSetup: Record "Expense Agent Setup" temporary) var EmptyEmailConnector: Enum "Email Connector"; begin - Assert.IsTrue(IsNullGuid(Setup."Noreply Email Account ID"), 'The no-reply account ID must be cleared.'); - Assert.AreEqual(EmptyEmailConnector, Setup."Noreply Email Connector", 'The no-reply connector must be cleared.'); - Assert.AreEqual('', Setup."Noreply Email Address", 'The no-reply address must be cleared.'); + Assert.IsTrue(IsNullGuid(TempSetup."Noreply Email Account ID"), 'The no-reply account ID must be cleared.'); + Assert.AreEqual(EmptyEmailConnector, TempSetup."Noreply Email Connector", 'The no-reply connector must be cleared.'); + Assert.AreEqual('', TempSetup."Noreply Email Address", 'The no-reply address must be cleared.'); end; [ModalPageHandler] procedure EmailAccountSelectionHandler(var EmailAccounts: TestPage "Email Accounts") begin - Assert.IsTrue(EmailAccounts.GoToRecord(SelectedEmailAccount), 'The selected mock account must be listed.'); + Assert.IsTrue(EmailAccounts.GoToRecord(TempSelectedEmailAccount), 'The selected mock account must be listed.'); EmailAccounts.OK().Invoke(); end; From 8971b4308156a526e8fb1d9003be4fd00359f161 Mon Sep 17 00:00:00 2001 From: Prangshuman Das Date: Mon, 21 Sep 2026 14:45:26 +0200 Subject: [PATCH 08/13] Fix Expense Agent CI test isolation Allow the lifecycle suites to run in AL-Go's disposable Empty Company while retaining their local safety guard, and scope inherent permissions to the optional email-account deletion integration. Copilot-Session: 9131e2a8-a4b5-40c8-a748-caade17b55c8 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../app/src/Integration/EAAgentScheduler.Codeunit.al | 2 ++ .../EmailLifecycle/EAAgentDispatcherTest.Codeunit.al | 8 +++++++- .../EmailLifecycle/EAAgentSchedulingTest.Codeunit.al | 8 +++++++- .../src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al | 10 ++++++++-- .../EmailLifecycle/WelcomeEmailQueueTest.Codeunit.al | 9 ++++++++- 5 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentScheduler.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentScheduler.Codeunit.al index 0324267d23b..f925789c8cb 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentScheduler.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentScheduler.Codeunit.al @@ -176,6 +176,8 @@ codeunit 6935 "EA Agent Scheduler" ReconcileAgent(Rec, CompletedTaskId); end; + [InherentPermissions(PermissionObjectType::TableData, Database::"Expense Agent Setup", 'RM', InherentPermissionsScope::Permissions)] + [InherentPermissions(PermissionObjectType::TableData, Database::"Expense Agent Status", 'RM', InherentPermissionsScope::Permissions)] [EventSubscriber(ObjectType::Codeunit, Codeunit::"Email Account", 'OnAfterDeleteEmailAccount', '', false, false)] local procedure OnAfterDeleteEmailAccount(EmailAccountId: Guid; EmailAccountConnector: Enum "Email Connector") var diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al index 08303ea45b5..1bd2383275f 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al @@ -38,6 +38,7 @@ codeunit 148314 "EA Agent Dispatcher Test" DisableOutgoingAfterSend: Boolean; UseReceiptAttachmentFixture: Boolean; TestCompanyTok: Label 'EA Email Lifecycle Test', Locked = true; + CITestCompanyTok: Label 'Empty Company', Locked = true; ServiceBaseUrlTok: Label 'https://expense-agent.example.invalid', Locked = true; OneOwnerMustBeDefinedErr: Label 'At least one user must be able to configure the Expense Agent.'; @@ -457,11 +458,16 @@ codeunit 148314 "EA Agent Dispatcher Test" var EnvironmentInformation: Codeunit "Environment Information"; begin - Assert.AreEqual(TestCompanyTok, CompanyName(), 'Run only in the dedicated disposable EA Email Lifecycle Test company, never CRONUS.'); + Assert.IsTrue(IsSafeTestCompany(), 'Run only in a dedicated disposable test company, never CRONUS.'); Assert.IsFalse(EnvironmentInformation.IsSaaS(), 'Mock integration tests require on-prem; SaaS authentication is not under test.'); Assert.IsFalse(EnvironmentInformation.IsSaaSInfrastructure(), 'These tests must not use SaaS infrastructure.'); end; + local procedure IsSafeTestCompany(): Boolean + begin + exit(CompanyName() in [TestCompanyTok, CITestCompanyTok]); + end; + local procedure RegisterMockAccount(Address: Text[250]): Guid var TestEmailAccount: Record "Test Email Account"; diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al index ac8dcc43fac..9f0d47c097e 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al @@ -18,6 +18,7 @@ codeunit 148335 "EA Agent Scheduling Test" Assert: Codeunit Assert; ConnectorMock: Codeunit "Connector Mock"; IsolatedTestCompanyLbl: Label 'EA Email Lifecycle Test', Locked = true; + CIIsolatedTestCompanyLbl: Label 'Empty Company', Locked = true; CombinationMsg: Label 'Incoming state %1, outgoing state %2, receipts preference %3, communication preference %4.', Comment = '%1 = incoming account state, %2 = outgoing account state, %3 = receipts preference, %4 = communication preference'; ChangeInputMsg: Label 'Changing eligibility input %1 must require reconciliation even when the address stays the same.', Comment = '%1 = changed input index'; ReverseInputMsg: Label 'Reversing eligibility input %1 must also require reconciliation.', Comment = '%1 = changed input index'; @@ -194,7 +195,7 @@ codeunit 148335 "EA Agent Scheduling Test" var TempEmailAccount: Record "Email Account" temporary; begin - Assert.AreEqual(IsolatedTestCompanyLbl, CompanyName(), 'Email lifecycle tests must run only in their isolated test company.'); + Assert.IsTrue(IsSafeTestCompany(), 'Email lifecycle tests must run only in a dedicated disposable company.'); ConnectorMock.Initialize(); TempSetup.Init(); ConnectorMock.AddAccount(TempEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); @@ -207,6 +208,11 @@ codeunit 148335 "EA Agent Scheduling Test" TempSetup."Noreply Email Address" := TempEmailAccount."Email Address"; end; + local procedure IsSafeTestCompany(): Boolean + begin + exit(CompanyName() in [IsolatedTestCompanyLbl, CIIsolatedTestCompanyLbl]); + end; + local procedure SetIncomingAccountState(var TempSetup: Record "Expense Agent Setup" temporary; AccountState: Integer) begin case AccountState of diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al index b706bae7955..0277ca9a64d 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al @@ -20,6 +20,7 @@ codeunit 148317 "EA Mailbox Access Test" Assert: Codeunit Assert; ConnectorMock: Codeunit "Connector Mock"; IsolatedTestCompanyLbl: Label 'EA Email Lifecycle Test', Locked = true; + CIIsolatedTestCompanyLbl: Label 'Empty Company', Locked = true; [Test] procedure ValidateMailboxAccessTrueWhenNoEmailAccountsAreConfigured() @@ -806,7 +807,7 @@ codeunit 148317 "EA Mailbox Access Test" PersistedSetup: Record "Expense Agent Setup"; ExpenseAgentStatus: Record "Expense Agent Status"; begin - Assert.AreEqual(IsolatedTestCompanyLbl, CompanyName(), 'Account-deletion tests must run only in their isolated test company.'); + Assert.IsTrue(IsSafeTestCompany(), 'Account-deletion tests must run only in a dedicated disposable company.'); PersistedSetup.ReadIsolation(IsolationLevel::UpdLock); if PersistedSetup.Get() then; ExpenseAgentStatus.ReadIsolation(IsolationLevel::UpdLock); @@ -867,11 +868,16 @@ codeunit 148317 "EA Mailbox Access Test" local procedure RegisterTestEmailAccount(var TempEmailAccount: Record "Email Account" temporary) begin - Assert.AreEqual(IsolatedTestCompanyLbl, CompanyName(), 'Email lifecycle tests must run only in their isolated test company.'); + Assert.IsTrue(IsSafeTestCompany(), 'Email lifecycle tests must run only in a dedicated disposable company.'); ConnectorMock.Initialize(); ConnectorMock.AddAccount(TempEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); end; + local procedure IsSafeTestCompany(): Boolean + begin + exit(CompanyName() in [IsolatedTestCompanyLbl, CIIsolatedTestCompanyLbl]); + end; + local procedure InitConfiguredSetup(var TempSetup: Record "Expense Agent Setup" temporary) var TempEmailAccount: Record "Email Account" temporary; diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/WelcomeEmailQueueTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/WelcomeEmailQueueTest.Codeunit.al index 01718144f33..b031e10fea7 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/WelcomeEmailQueueTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/WelcomeEmailQueueTest.Codeunit.al @@ -20,6 +20,8 @@ codeunit 148334 "Welcome Email Queue Test" var LibraryUtility: Codeunit "Library - Utility"; Assert: Codeunit Assert; + IsolatedTestCompanyLbl: Label 'EA Email Lifecycle Test', Locked = true; + CIIsolatedTestCompanyLbl: Label 'Empty Company', Locked = true; [Test] [HandlerFunctions('WelcomeQueuedMsgHandler')] @@ -342,11 +344,16 @@ codeunit 148334 "Welcome Email Queue Test" var EnvironmentInformation: Codeunit "Environment Information"; begin - Assert.AreEqual('EA Email Lifecycle Test', CompanyName(), 'Run only in the dedicated disposable test company, never CRONUS.'); + Assert.IsTrue(IsSafeTestCompany(), 'Run only in a dedicated disposable test company, never CRONUS.'); Assert.IsFalse(EnvironmentInformation.IsSaaS(), 'These isolated tests must run on-prem.'); Assert.IsFalse(EnvironmentInformation.IsSaaSInfrastructure(), 'These tests must not use SaaS infrastructure.'); end; + local procedure IsSafeTestCompany(): Boolean + begin + exit(CompanyName() in [IsolatedTestCompanyLbl, CIIsolatedTestCompanyLbl]); + end; + local procedure CreateInOutboxUser(var ExpenseUser: Record "Expense User"; CorrelationId: Guid) begin CreateExpenseUserWithEmail(ExpenseUser); From 1756f0eaed99b3aa0ab7fd0bd889d1082f1052dc Mon Sep 17 00:00:00 2001 From: Prangshuman Das Date: Mon, 21 Sep 2026 23:58:56 +0200 Subject: [PATCH 09/13] Simplify Expense Agent account cleanup Remove the synchronous email-account deletion subscriber and its dedicated tests. Missing registrations continue to be repaired by the existing wizard and scheduler reconciliation paths. Copilot-Session: 9131e2a8-a4b5-40c8-a748-caade17b55c8 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Integration/EAAgentScheduler.Codeunit.al | 40 ---- .../EAMailboxAccessTest.Codeunit.al | 195 ------------------ 2 files changed, 235 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentScheduler.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentScheduler.Codeunit.al index f925789c8cb..448a3e9793e 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentScheduler.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentScheduler.Codeunit.al @@ -176,46 +176,6 @@ codeunit 6935 "EA Agent Scheduler" ReconcileAgent(Rec, CompletedTaskId); end; - [InherentPermissions(PermissionObjectType::TableData, Database::"Expense Agent Setup", 'RM', InherentPermissionsScope::Permissions)] - [InherentPermissions(PermissionObjectType::TableData, Database::"Expense Agent Status", 'RM', InherentPermissionsScope::Permissions)] - [EventSubscriber(ObjectType::Codeunit, Codeunit::"Email Account", 'OnAfterDeleteEmailAccount', '', false, false)] - local procedure OnAfterDeleteEmailAccount(EmailAccountId: Guid; EmailAccountConnector: Enum "Email Connector") - var - ExpenseAgentSetup: Record "Expense Agent Setup"; - ExpenseAgentStatus: Record "Expense Agent Status"; - EmailAccount: Codeunit "Email Account"; - Changed: Boolean; - begin - if IsNullGuid(EmailAccountId) then - exit; - if EmailAccount.IsAccountRegistered(EmailAccountId, EmailAccountConnector) then - exit; - ExpenseAgentSetup.ReadIsolation(IsolationLevel::UpdLock); - if not ExpenseAgentSetup.Get() then - exit; - if (ExpenseAgentSetup."Email Account ID" = EmailAccountId) and - (ExpenseAgentSetup."Email Connector" = EmailAccountConnector) - then begin - ExpenseAgentSetup.ClearIncomingMailbox(); - Changed := true; - end; - if (ExpenseAgentSetup."Noreply Email Account ID" = EmailAccountId) and - (ExpenseAgentSetup."Noreply Email Connector" = EmailAccountConnector) - then begin - ExpenseAgentSetup.ClearNoreplyMailbox(); - Changed := true; - end; - if not Changed then - exit; - - // Stay in the connector deletion transaction and current company. Never grant - // delegation, change the remaining channel's worker, or commit as the deleting actor. - ExpenseAgentSetup.Modify(); - if not ExpenseAgentSetup.ShouldScheduleAgentTask(ExpenseAgentSetup."Enable Agent") then - if GetTaskStatus(ExpenseAgentStatus) then - CancelPendingTasks(ExpenseAgentStatus); - end; - local procedure ScheduleDelay(): Integer begin exit(60 * 1000) // 1 minute diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al index 0277ca9a64d..01114a316d8 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al @@ -663,201 +663,6 @@ codeunit 148317 "EA Mailbox Access Test" Assert.IsFalse(TempSetup.ShouldScheduleAgentTask(true), 'Neither account is registered under its selected connector.'); end; - [Test] - [TransactionModel(TransactionModel::AutoRollback)] - procedure DeletingIncomingAccountPreservesOutgoingSenderAndPreferences() - var - TempSetup: Record "Expense Agent Setup" temporary; - TempPreviousSetup: Record "Expense Agent Setup" temporary; - begin - // [SCENARIO] Deleting the registered incoming account preserves the outgoing channel and preferences. - - // [GIVEN] Persisted setup contains distinct registered incoming and no-reply accounts with enabled preferences. - InitAccountDeletionSetup(TempSetup); - TempPreviousSetup := TempSetup; - - - // [WHEN] The native email-account API deletes the incoming account and setup is reloaded. - DeleteTestEmailAccount(TempSetup."Email Account ID", TempSetup."Email Connector"); - ReloadAccountDeletionSetup(TempSetup); - - - // [THEN] Only incoming identity and folder fields clear; outgoing identity, preferences, and outgoing readiness remain. - AssertIncomingCleared(TempSetup); - AssertNoreplyUnchanged(TempPreviousSetup, TempSetup); - AssertPreferencesUnchanged(TempPreviousSetup, TempSetup); - Assert.IsTrue(TempSetup.IsOutgoingCommunicationConfigured(), 'The surviving registered sender must remain available.'); - end; - - [Test] - [TransactionModel(TransactionModel::AutoRollback)] - procedure DeletingNoreplyAccountPreservesIncomingAndPreferences() - var - TempSetup: Record "Expense Agent Setup" temporary; - TempPreviousSetup: Record "Expense Agent Setup" temporary; - begin - // [SCENARIO] Deleting the registered no-reply account preserves the incoming channel and preferences. - - // [GIVEN] Persisted setup contains distinct registered incoming and no-reply accounts with enabled preferences. - InitAccountDeletionSetup(TempSetup); - TempPreviousSetup := TempSetup; - - - // [WHEN] The native email-account API deletes the no-reply account and setup is reloaded. - DeleteTestEmailAccount(TempSetup."Noreply Email Account ID", TempSetup."Noreply Email Connector"); - ReloadAccountDeletionSetup(TempSetup); - - - // [THEN] Only no-reply identity clears; incoming identity, preferences, and incoming readiness remain. - AssertNoreplyCleared(TempSetup); - AssertIncomingUnchanged(TempPreviousSetup, TempSetup); - AssertPreferencesUnchanged(TempPreviousSetup, TempSetup); - Assert.IsTrue(TempSetup.IsIncomingCommunicationConfigured(), 'The surviving registered incoming account must remain available.'); - end; - - [Test] - [TransactionModel(TransactionModel::AutoRollback)] - procedure DeletingSharedAccountClearsBothChannelsWithoutChangingPreferences() - var - TempSetup: Record "Expense Agent Setup" temporary; - TempPreviousSetup: Record "Expense Agent Setup" temporary; - begin - // [SCENARIO] Deleting one registered account shared by both channels clears both identities without changing preferences. - - // [GIVEN] Persisted setup points incoming and no-reply identities to the same registered mock account. - InitAccountDeletionSetup(TempSetup); - TempSetup."Noreply Email Account ID" := TempSetup."Email Account ID"; - TempSetup."Noreply Email Connector" := TempSetup."Email Connector"; - TempSetup."Noreply Email Address" := TempSetup."Email Address"; - SaveAccountDeletionSetup(TempSetup); - TempPreviousSetup := TempSetup; - - - // [WHEN] The native email-account API deletes the shared account and setup is reloaded. - DeleteTestEmailAccount(TempSetup."Email Account ID", TempSetup."Email Connector"); - ReloadAccountDeletionSetup(TempSetup); - - - // [THEN] Both channel identities clear, preferences remain unchanged, and no channel remains schedulable. - AssertIncomingCleared(TempSetup); - AssertNoreplyCleared(TempSetup); - AssertPreferencesUnchanged(TempPreviousSetup, TempSetup); - Assert.IsFalse(TempSetup.ShouldScheduleAgentTask(true), 'Deleting the shared account leaves no available channel.'); - end; - - [Test] - [TransactionModel(TransactionModel::AutoRollback)] - procedure DeletingAccountWithMismatchedConnectorPreservesSelections() - var - TempSetup: Record "Expense Agent Setup" temporary; - TempPreviousSetup: Record "Expense Agent Setup" temporary; - RegisteredConnector: Enum "Email Connector"; - begin - // [SCENARIO] Deleting an account registration under another connector does not clear saved mismatched selections. - - // [GIVEN] Persisted incoming and no-reply selections use a connector different from the account registration being deleted. - InitAccountDeletionSetup(TempSetup); - RegisteredConnector := TempSetup."Email Connector"; - TempSetup."Email Connector" := Enum::"Email Connector"::"Test Email Connector"; - TempSetup."Noreply Email Account ID" := TempSetup."Email Account ID"; - TempSetup."Noreply Email Connector" := TempSetup."Email Connector"; - TempSetup."Noreply Email Address" := TempSetup."Email Address"; - SaveAccountDeletionSetup(TempSetup); - TempPreviousSetup := TempSetup; - - - // [WHEN] The native email-account API deletes the registered connector identity and setup is reloaded. - DeleteTestEmailAccount(TempSetup."Email Account ID", RegisteredConnector); - ReloadAccountDeletionSetup(TempSetup); - - - // [THEN] Both saved channel selections and preferences remain unchanged. - AssertConfigurationUnchanged(TempPreviousSetup, TempSetup); - end; - - [Test] - [TransactionModel(TransactionModel::AutoRollback)] - procedure DeletingUnrelatedAccountPreservesBothChannels() - var - TempSetup: Record "Expense Agent Setup" temporary; - TempPreviousSetup: Record "Expense Agent Setup" temporary; - TempEmailAccount: Record "Email Account" temporary; - begin - // [SCENARIO] Deleting an unrelated registered account leaves both configured channels unchanged. - - // [GIVEN] Persisted setup contains two registered channels and the connector mock registers an additional unrelated account. - InitAccountDeletionSetup(TempSetup); - TempPreviousSetup := TempSetup; - ConnectorMock.AddAccount(TempEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); - - - // [WHEN] The native email-account API deletes the unrelated account and setup is reloaded. - DeleteTestEmailAccount(TempEmailAccount."Account Id", TempEmailAccount.Connector); - ReloadAccountDeletionSetup(TempSetup); - - - // [THEN] Both configured channels, preferences, and channel readiness remain unchanged. - AssertConfigurationUnchanged(TempPreviousSetup, TempSetup); - Assert.IsTrue(TempSetup.IsIncomingCommunicationConfigured(), 'An unrelated deletion must not affect the incoming channel.'); - Assert.IsTrue(TempSetup.IsOutgoingCommunicationConfigured(), 'An unrelated deletion must not affect the outgoing channel.'); - end; - - local procedure InitAccountDeletionSetup(var TempSetup: Record "Expense Agent Setup" temporary) - var - PersistedSetup: Record "Expense Agent Setup"; - ExpenseAgentStatus: Record "Expense Agent Status"; - begin - Assert.IsTrue(IsSafeTestCompany(), 'Account-deletion tests must run only in a dedicated disposable company.'); - PersistedSetup.ReadIsolation(IsolationLevel::UpdLock); - if PersistedSetup.Get() then; - ExpenseAgentStatus.ReadIsolation(IsolationLevel::UpdLock); - if ExpenseAgentStatus.Get() then begin - Assert.IsTrue(IsNullGuid(ExpenseAgentStatus."Agent Task ID"), 'Account-deletion fixtures must not run with a dispatcher task ID.'); - Assert.IsTrue(IsNullGuid(ExpenseAgentStatus."Agent Recovery Task ID"), 'Account-deletion fixtures must not run with a recovery task ID.'); - end else begin - ExpenseAgentStatus.Init(); - ExpenseAgentStatus.Insert(); - end; - - InitConfiguredSetup(TempSetup); - SaveAccountDeletionSetup(TempSetup); - end; - - local procedure SaveAccountDeletionSetup(TempSetup: Record "Expense Agent Setup" temporary) - var - PersistedSetup: Record "Expense Agent Setup"; - begin - PersistedSetup.ReadIsolation(IsolationLevel::UpdLock); - if not PersistedSetup.Get() then - PersistedSetup.Insert(); - PersistedSetup.TransferFields(TempSetup, false); - PersistedSetup.Modify(); - end; - - local procedure DeleteTestEmailAccount(AccountId: Guid; Connector: Enum "Email Connector") - var - TempAccountsToDelete: Record "Email Account" temporary; - EmailAccount: Codeunit "Email Account"; - begin - TempAccountsToDelete."Account Id" := AccountId; - TempAccountsToDelete.Connector := Connector; - TempAccountsToDelete.Insert(); - EmailAccount.DeleteAccounts(TempAccountsToDelete, true); - Assert.IsFalse(EmailAccount.IsAccountRegistered(AccountId, Connector), 'The registered mock account must actually be deleted.'); - end; - - local procedure ReloadAccountDeletionSetup(var TempSetup: Record "Expense Agent Setup" temporary) - var - PersistedSetup: Record "Expense Agent Setup"; - ExpenseAgentStatus: Record "Expense Agent Status"; - begin - PersistedSetup.Get(); - TempSetup := PersistedSetup; - ExpenseAgentStatus.Get(); - Assert.IsTrue(IsNullGuid(ExpenseAgentStatus."Agent Task ID"), 'Deletion must leave the dispatcher task ID empty.'); - Assert.IsTrue(IsNullGuid(ExpenseAgentStatus."Agent Recovery Task ID"), 'Deletion must leave the recovery task ID empty.'); - end; - local procedure InitEmptySetup(var TempSetup: Record "Expense Agent Setup" temporary) begin TempSetup.DeleteAll(); From 26d64b9ddb233f2fd5771fc0a323d350719e18f0 Mon Sep 17 00:00:00 2001 From: Prangshuman Das Date: Tue, 22 Sep 2026 01:50:17 +0200 Subject: [PATCH 10/13] Clarify Expense Agent communication lifecycle Rename buffer repair and communication scheduling APIs, remove company-name coupling from lifecycle tests, and use codeunit isolation so the dispatcher and welcome suites are selected by standard CI passes. Copilot-Session: 9131e2a8-a4b5-40c8-a748-caade17b55c8 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Integration/EAAgentDispatcher.Codeunit.al | 4 +- .../Integration/EAAgentScheduler.Codeunit.al | 8 ++-- .../Pages/ExpenseAgentSetupWizard.Page.al | 4 +- .../Setup/Tables/ExpenseAgentSetup.Table.al | 2 +- .../EAAgentDispatcherTest.Codeunit.al | 40 ++++++++----------- .../EAAgentSchedulingTest.Codeunit.al | 10 +---- .../EAMailboxAccessTest.Codeunit.al | 20 +++------- .../WelcomeEmailQueueTest.Codeunit.al | 18 +++------ 8 files changed, 37 insertions(+), 69 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentDispatcher.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentDispatcher.Codeunit.al index 81caffe9638..16a28a5d269 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentDispatcher.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentDispatcher.Codeunit.al @@ -70,7 +70,7 @@ codeunit 6938 "EA Agent Dispatcher" ExpenseAgentStatus.Modify(); Commit(); - if not ProcessCommunication(Setup, ErrorMessage) then begin + if not ProcessIncomingAndOutgoingEmails(Setup, ErrorMessage) then begin EASchedulerTask.Status := EASchedulerTask.Status::Failed; EASchedulerTask."Error Message" := CopyStr(ErrorMessage, 1, MaxStrLen(EASchedulerTask."Error Message")); EASchedulerTask.Modify(); @@ -86,7 +86,7 @@ codeunit 6938 "EA Agent Dispatcher" EAAgentScheduler.CompleteAgentTask(Setup, CompletedTaskId); end; - internal procedure ProcessCommunication(var Setup: Record "Expense Agent Setup"; var ErrorMessage: Text): Boolean + internal procedure ProcessIncomingAndOutgoingEmails(var Setup: Record "Expense Agent Setup"; var ErrorMessage: Text): Boolean var EARetrieveEmails: Codeunit "EA Retrieve Emails"; RetrievalSuccess: Boolean; diff --git a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentScheduler.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentScheduler.Codeunit.al index 448a3e9793e..fdb0fe4d073 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentScheduler.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Integration/EAAgentScheduler.Codeunit.al @@ -31,17 +31,17 @@ codeunit 6935 "EA Agent Scheduler" var CompletedTaskId: Guid; begin - ReconcileAgent(EASetup, CompletedTaskId); + ReconcileCommunicationScheduling(EASetup, CompletedTaskId); Commit(); end; internal procedure CompleteAgentTask(EASetup: Record "Expense Agent Setup"; CompletedTaskId: Guid) begin - ReconcileAgent(EASetup, CompletedTaskId); + ReconcileCommunicationScheduling(EASetup, CompletedTaskId); Commit(); end; - local procedure ReconcileAgent(RequestedSetup: Record "Expense Agent Setup"; CompletedTaskId: Guid) + local procedure ReconcileCommunicationScheduling(RequestedSetup: Record "Expense Agent Setup"; CompletedTaskId: Guid) var EASetup: Record "Expense Agent Setup"; ExpenseAgentStatus: Record "Expense Agent Status"; @@ -173,7 +173,7 @@ codeunit 6935 "EA Agent Scheduler" if Rec.IsTemporary() or not RunTrigger then exit; if Rec.HasSchedulingChanges(xRec) then - ReconcileAgent(Rec, CompletedTaskId); + ReconcileCommunicationScheduling(Rec, CompletedTaskId); end; local procedure ScheduleDelay(): Integer diff --git a/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetupWizard.Page.al b/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetupWizard.Page.al index fd26adb55f5..6a0761c3378 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetupWizard.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetupWizard.Page.al @@ -1182,7 +1182,7 @@ page 6991 "Expense Agent Setup Wizard" ExpenseAgentSetup.TransferFields(Rec, false); if not IsNullGuid(AgentSetupBuffer."User Security ID") then ExpenseAgentSetup."User Security ID" := AgentSetupBuffer."User Security ID"; - // The wizard reconciles once in ApplyScheduleChange after saving state and defaults. + // Save without automatic scheduling; ApplyScheduleChange reconciles once after defaults. ExpenseAgentSetup.Modify(false); end; @@ -1360,7 +1360,7 @@ page 6991 "Expense Agent Setup Wizard" local procedure UpdateControls() begin - if not Rec.RepairMissingEmailAccounts() then + if not Rec.RepairMissingEmailAccountsInBuffer() then exit; Rec.Modify(); diff --git a/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al b/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al index 0c50e65ce28..7c8423f9dfb 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al @@ -812,7 +812,7 @@ table 6930 "Expense Agent Setup" /// Clears only unavailable account references on this record buffer. The caller owns /// persistence and scheduling; user preferences and native agent state remain unchanged. /// - internal procedure RepairMissingEmailAccounts(): Boolean + internal procedure RepairMissingEmailAccountsInBuffer(): Boolean begin exit(RepairMissingEmailAccounts(false)); end; diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al index 1bd2383275f..92bd3f02124 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al @@ -14,7 +14,7 @@ codeunit 148314 "EA Agent Dispatcher Test" { Subtype = Test; TestPermissions = Disabled; - RequiredTestIsolation = Function; + RequiredTestIsolation = Codeunit; TestHttpRequestPolicy = BlockOutboundRequests; EventSubscriberInstance = Manual; @@ -37,8 +37,6 @@ codeunit 148314 "EA Agent Dispatcher Test" FixtureMessageIds: List of [Guid]; DisableOutgoingAfterSend: Boolean; UseReceiptAttachmentFixture: Boolean; - TestCompanyTok: Label 'EA Email Lifecycle Test', Locked = true; - CITestCompanyTok: Label 'Empty Company', Locked = true; ServiceBaseUrlTok: Label 'https://expense-agent.example.invalid', Locked = true; OneOwnerMustBeDefinedErr: Label 'At least one user must be able to configure the Expense Agent.'; @@ -267,7 +265,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // [SCENARIO] The HTTP wrapper rejects a welcome request before endpoint resolution when persisted setup is missing. // [GIVEN] The isolated company has no Expense Agent setup and request counters are reset. - AssertIsolatedCompany(); + AssertMockTestEnvironment(); ExpectNoService(); Setup.DeleteAll(); Commit(); @@ -408,7 +406,7 @@ codeunit 148314 "EA Agent Dispatcher Test" CopilotCapability: Codeunit "Copilot Capability"; ExpenseAgentAppId: Guid; begin - AssertIsolatedCompany(); + AssertMockTestEnvironment(); Evaluate(ExpenseAgentAppId, '66efe10c-8033-403b-a86d-77c0887178ba'); Assert.IsTrue(CopilotCapability.IsCapabilityActive(Enum::"Copilot Capability"::"Expense Agent", ExpenseAgentAppId), 'Expense Agent capability and required privacy approvals must already be enabled. These tests never change tenant-wide Copilot settings or approvals.'); @@ -454,20 +452,14 @@ codeunit 148314 "EA Agent Dispatcher Test" Commit(); end; - local procedure AssertIsolatedCompany() + local procedure AssertMockTestEnvironment() var EnvironmentInformation: Codeunit "Environment Information"; begin - Assert.IsTrue(IsSafeTestCompany(), 'Run only in a dedicated disposable test company, never CRONUS.'); Assert.IsFalse(EnvironmentInformation.IsSaaS(), 'Mock integration tests require on-prem; SaaS authentication is not under test.'); Assert.IsFalse(EnvironmentInformation.IsSaaSInfrastructure(), 'These tests must not use SaaS infrastructure.'); end; - local procedure IsSafeTestCompany(): Boolean - begin - exit(CompanyName() in [TestCompanyTok, CITestCompanyTok]); - end; - local procedure RegisterMockAccount(Address: Text[250]): Guid var TestEmailAccount: Record "Test Email Account"; @@ -495,7 +487,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // The endpoint override and read-only request observers are bound only for this production pass. BindSubscription(this); Commit(); - Success := Dispatcher.ProcessCommunication(Setup, ErrorMessage); + Success := Dispatcher.ProcessIncomingAndOutgoingEmails(Setup, ErrorMessage); UnbindSubscription(this); TestEmailConnector.SetEmailInbox(TempEmailInbox); Assert.IsTrue(Success, 'The scheduler-free production pass failed: ' + ErrorMessage); @@ -867,7 +859,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // [SCENARIO] New installations have empty noreply fields by default (backward-compatible). // [GIVEN] A fresh setup record - AssertIsolatedCompany(); + AssertMockTestEnvironment(); Setup.DeleteAll(); Setup.Init(); Setup.Insert(); @@ -907,7 +899,7 @@ codeunit 148314 "EA Agent Dispatcher Test" local procedure InitSetupWithMainAccount(var Setup: Record "Expense Agent Setup"; AccountID: Guid) begin - AssertIsolatedCompany(); + AssertMockTestEnvironment(); Setup.DeleteAll(); Setup.Init(); Setup."Email Account ID" := AccountID; @@ -925,7 +917,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // [SCENARIO] EA Scheduler Task supports the new Failed status and stores an error message. // [GIVEN] A scheduler task in progress - AssertIsolatedCompany(); + AssertMockTestEnvironment(); EASchedulerTask.DeleteAll(); Clear(EASchedulerTask); EASchedulerTask.Status := EASchedulerTask.Status::"In Progress"; @@ -953,7 +945,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // [SCENARIO] The Expense Agent Status FlowFields read Status and Error Message from the linked scheduler task. // [GIVEN] A failed scheduler task - AssertIsolatedCompany(); + AssertMockTestEnvironment(); EASchedulerTask.DeleteAll(); Clear(EASchedulerTask); EASchedulerTask.Status := EASchedulerTask.Status::Failed; @@ -983,7 +975,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // [SCENARIO] GetByUserSecurityID finds an existing access control row by user. // [GIVEN] An access control record for a user - AssertIsolatedCompany(); + AssertMockTestEnvironment(); AccessControl.DeleteAll(); UserID := CreateGuid(); InsertAccessControl(AccessControl, UserID, true, true); @@ -1004,7 +996,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // [SCENARIO] GetByUserSecurityID returns false when no row exists for the user. // [GIVEN] No access control rows for the queried user - AssertIsolatedCompany(); + AssertMockTestEnvironment(); AccessControl.DeleteAll(); // [THEN] Lookup returns false @@ -1022,7 +1014,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // [SCENARIO] Clearing Can Configure Agent on one owner is allowed when another owner remains. // [GIVEN] Two users with Can Configure Agent set to true - AssertIsolatedCompany(); + AssertMockTestEnvironment(); AccessControl.DeleteAll(); SetupSystemID := EmptyGuid(); UserA := CreateGuid(); @@ -1049,7 +1041,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // [SCENARIO] Clearing 'Can Configure' Agent on the only owner raises an error. // [GIVEN] A single user with Can Configure Agent set to true - AssertIsolatedCompany(); + AssertMockTestEnvironment(); AccessControl.DeleteAll(); UserID := CreateGuid(); InsertAccessControl(AccessControl, UserID, true, true); @@ -1072,7 +1064,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // [SCENARIO] Deleting an owner is allowed when at least one other owner remains. // [GIVEN] Two users with Can Configure Agent - AssertIsolatedCompany(); + AssertMockTestEnvironment(); AccessControl.DeleteAll(); UserA := CreateGuid(); UserB := CreateGuid(); @@ -1096,7 +1088,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // [SCENARIO] Deleting the only owner raises an error. // [GIVEN] A single user with Can Configure Agent - AssertIsolatedCompany(); + AssertMockTestEnvironment(); AccessControl.DeleteAll(); UserID := CreateGuid(); InsertAccessControl(AccessControl, UserID, true, true); @@ -1119,7 +1111,7 @@ codeunit 148314 "EA Agent Dispatcher Test" // [SCENARIO] Deleting a non-owner row does not raise the owner rule even when only one owner exists. // [GIVEN] One owner and one non-owner - AssertIsolatedCompany(); + AssertMockTestEnvironment(); AccessControl.DeleteAll(); OwnerID := CreateGuid(); NonOwnerID := CreateGuid(); diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al index 9f0d47c097e..84a89964f47 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentSchedulingTest.Codeunit.al @@ -17,8 +17,6 @@ codeunit 148335 "EA Agent Scheduling Test" var Assert: Codeunit Assert; ConnectorMock: Codeunit "Connector Mock"; - IsolatedTestCompanyLbl: Label 'EA Email Lifecycle Test', Locked = true; - CIIsolatedTestCompanyLbl: Label 'Empty Company', Locked = true; CombinationMsg: Label 'Incoming state %1, outgoing state %2, receipts preference %3, communication preference %4.', Comment = '%1 = incoming account state, %2 = outgoing account state, %3 = receipts preference, %4 = communication preference'; ChangeInputMsg: Label 'Changing eligibility input %1 must require reconciliation even when the address stays the same.', Comment = '%1 = changed input index'; ReverseInputMsg: Label 'Reversing eligibility input %1 must also require reconciliation.', Comment = '%1 = changed input index'; @@ -118,7 +116,7 @@ codeunit 148335 "EA Agent Scheduling Test" Assert.IsTrue(TempSetup.IsIncomingCommunicationConfigured(), 'Mailbox access failure must not be treated as deleted incoming configuration.'); Assert.IsTrue(TempSetup.IsOutgoingCommunicationConfigured(), 'Mailbox access failure must not be treated as deleted outgoing configuration.'); Assert.IsTrue(TempSetup.ShouldScheduleAgentTask(true), 'Availability must use local registration, not a live mailbox probe.'); - Assert.IsFalse(TempSetup.RepairMissingEmailAccounts(), 'Registered but inaccessible accounts must not be cleared.'); + Assert.IsFalse(TempSetup.RepairMissingEmailAccountsInBuffer(), 'Registered but inaccessible accounts must not be cleared.'); end; [Test] @@ -195,7 +193,6 @@ codeunit 148335 "EA Agent Scheduling Test" var TempEmailAccount: Record "Email Account" temporary; begin - Assert.IsTrue(IsSafeTestCompany(), 'Email lifecycle tests must run only in a dedicated disposable company.'); ConnectorMock.Initialize(); TempSetup.Init(); ConnectorMock.AddAccount(TempEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); @@ -208,11 +205,6 @@ codeunit 148335 "EA Agent Scheduling Test" TempSetup."Noreply Email Address" := TempEmailAccount."Email Address"; end; - local procedure IsSafeTestCompany(): Boolean - begin - exit(CompanyName() in [IsolatedTestCompanyLbl, CIIsolatedTestCompanyLbl]); - end; - local procedure SetIncomingAccountState(var TempSetup: Record "Expense Agent Setup" temporary; AccountState: Integer) begin case AccountState of diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al index 01114a316d8..45e384faae3 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAMailboxAccessTest.Codeunit.al @@ -19,8 +19,6 @@ codeunit 148317 "EA Mailbox Access Test" TempSelectedEmailAccount: Record "Email Account" temporary; Assert: Codeunit Assert; ConnectorMock: Codeunit "Connector Mock"; - IsolatedTestCompanyLbl: Label 'EA Email Lifecycle Test', Locked = true; - CIIsolatedTestCompanyLbl: Label 'Empty Company', Locked = true; [Test] procedure ValidateMailboxAccessTrueWhenNoEmailAccountsAreConfigured() @@ -532,7 +530,7 @@ codeunit 148317 "EA Mailbox Access Test" // [GIVEN] A configured registered setup is copied across incoming-only, outgoing-only, and both-missing account-ID cases. InitConfiguredSetup(TempRegisteredSetup); - // [WHEN] RepairMissingEmailAccounts runs for each case and is repeated after repair. + // [WHEN] RepairMissingEmailAccountsInBuffer runs for each case and is repeated after repair. for MissingChannels := 1 to 3 do begin TempSetup := TempRegisteredSetup; if MissingChannels in [1, 3] then @@ -542,7 +540,7 @@ codeunit 148317 "EA Mailbox Access Test" // [THEN] Only missing identities clear, surviving channels and preferences remain unchanged, and repeated repair is a no-op. - Assert.IsTrue(TempSetup.RepairMissingEmailAccounts(), 'Missing references must be repaired.'); + Assert.IsTrue(TempSetup.RepairMissingEmailAccountsInBuffer(), 'Missing references must be repaired.'); if MissingChannels in [1, 3] then AssertIncomingCleared(TempSetup) @@ -553,7 +551,7 @@ codeunit 148317 "EA Mailbox Access Test" else AssertNoreplyUnchanged(TempRegisteredSetup, TempSetup); AssertPreferencesUnchanged(TempRegisteredSetup, TempSetup); - Assert.IsFalse(TempSetup.RepairMissingEmailAccounts(), 'Repeated repair must be a no-op.'); + Assert.IsFalse(TempSetup.RepairMissingEmailAccountsInBuffer(), 'Repeated repair must be a no-op.'); end; end; @@ -572,8 +570,8 @@ codeunit 148317 "EA Mailbox Access Test" TempSetup."Noreply Email Connector" := Enum::"Email Connector"::"Test Email Connector"; - // [WHEN] RepairMissingEmailAccounts runs for each invalid identity state. - Assert.IsTrue(TempSetup.RepairMissingEmailAccounts(), 'The ID must be registered under the selected connector.'); + // [WHEN] RepairMissingEmailAccountsInBuffer runs for each invalid identity state. + Assert.IsTrue(TempSetup.RepairMissingEmailAccountsInBuffer(), 'The ID must be registered under the selected connector.'); // [THEN] Both channel identities clear while all preferences remain unchanged. AssertIncomingCleared(TempSetup); @@ -583,7 +581,7 @@ codeunit 148317 "EA Mailbox Access Test" TempSetup := TempPreviousSetup; Clear(TempSetup."Email Account ID"); Clear(TempSetup."Noreply Email Account ID"); - Assert.IsTrue(TempSetup.RepairMissingEmailAccounts(), 'Empty IDs must not retain orphaned addresses, connectors or folders.'); + Assert.IsTrue(TempSetup.RepairMissingEmailAccountsInBuffer(), 'Empty IDs must not retain orphaned addresses, connectors or folders.'); AssertIncomingCleared(TempSetup); AssertNoreplyCleared(TempSetup); AssertPreferencesUnchanged(TempPreviousSetup, TempSetup); @@ -673,16 +671,10 @@ codeunit 148317 "EA Mailbox Access Test" local procedure RegisterTestEmailAccount(var TempEmailAccount: Record "Email Account" temporary) begin - Assert.IsTrue(IsSafeTestCompany(), 'Email lifecycle tests must run only in a dedicated disposable company.'); ConnectorMock.Initialize(); ConnectorMock.AddAccount(TempEmailAccount, Enum::"Email Connector"::"Test Email Connector v4"); end; - local procedure IsSafeTestCompany(): Boolean - begin - exit(CompanyName() in [IsolatedTestCompanyLbl, CIIsolatedTestCompanyLbl]); - end; - local procedure InitConfiguredSetup(var TempSetup: Record "Expense Agent Setup" temporary) var TempEmailAccount: Record "Email Account" temporary; diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/WelcomeEmailQueueTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/WelcomeEmailQueueTest.Codeunit.al index b031e10fea7..d757e125ac0 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/WelcomeEmailQueueTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/WelcomeEmailQueueTest.Codeunit.al @@ -14,14 +14,12 @@ codeunit 148334 "Welcome Email Queue Test" Subtype = Test; TestType = UnitTest; TestPermissions = Disabled; - RequiredTestIsolation = Function; + RequiredTestIsolation = Codeunit; TestHttpRequestPolicy = BlockOutboundRequests; var LibraryUtility: Codeunit "Library - Utility"; Assert: Codeunit Assert; - IsolatedTestCompanyLbl: Label 'EA Email Lifecycle Test', Locked = true; - CIIsolatedTestCompanyLbl: Label 'Empty Company', Locked = true; [Test] [HandlerFunctions('WelcomeQueuedMsgHandler')] @@ -292,7 +290,7 @@ codeunit 148334 "Welcome Email Queue Test" begin // [SCENARIO 636970] The EA Outbox Email correlation id and notification type persist as written. // [GIVEN] A correlation id. - AssertIsolatedCompany(); + AssertMockTestEnvironment(); CorrelationId := CreateGuid(); // [WHEN] An outbox email is created with the correlation fields. @@ -309,7 +307,7 @@ codeunit 148334 "Welcome Email Queue Test" local procedure CreateExpenseUserWithEmail(var ExpenseUser: Record "Expense User") begin - AssertIsolatedCompany(); + AssertMockTestEnvironment(); ExpenseUser.Init(); ExpenseUser."No." := LibraryUtility.GenerateRandomCode(ExpenseUser.FieldNo("No."), Database::"Expense User"); ExpenseUser."E-mail" := 'user@example.invalid'; @@ -322,7 +320,7 @@ codeunit 148334 "Welcome Email Queue Test" ExpenseAgentSetup: Record "Expense Agent Setup"; TestEmailAccount: Record "Test Email Account"; begin - AssertIsolatedCompany(); + AssertMockTestEnvironment(); TestEmailAccount.Id := CreateGuid(); TestEmailAccount.Email := 'noreply@example.invalid'; TestEmailAccount.Name := 'Welcome queue mock'; @@ -340,20 +338,14 @@ codeunit 148334 "Welcome Email Queue Test" ExpenseAgentSetup.Modify(); end; - local procedure AssertIsolatedCompany() + local procedure AssertMockTestEnvironment() var EnvironmentInformation: Codeunit "Environment Information"; begin - Assert.IsTrue(IsSafeTestCompany(), 'Run only in a dedicated disposable test company, never CRONUS.'); Assert.IsFalse(EnvironmentInformation.IsSaaS(), 'These isolated tests must run on-prem.'); Assert.IsFalse(EnvironmentInformation.IsSaaSInfrastructure(), 'These tests must not use SaaS infrastructure.'); end; - local procedure IsSafeTestCompany(): Boolean - begin - exit(CompanyName() in [IsolatedTestCompanyLbl, CIIsolatedTestCompanyLbl]); - end; - local procedure CreateInOutboxUser(var ExpenseUser: Record "Expense User"; CorrelationId: Guid) begin CreateExpenseUserWithEmail(ExpenseUser); From 90c49725e2e42d0f236ce5a3fce22b540f3bef7e Mon Sep 17 00:00:00 2001 From: Prangshuman Das Date: Tue, 22 Sep 2026 06:53:26 +0200 Subject: [PATCH 11/13] Fix Expense Agent CI capability fixture Configure the Expense Agent capability and required privacy approvals inside the codeunit-isolated dispatcher test fixture so standard CI can execute the newly discovered tests without tenant preconfiguration. Copilot-Session: 9131e2a8-a4b5-40c8-a748-caade17b55c8 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../EAAgentDispatcherTest.Codeunit.al | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al index 92bd3f02124..9236e72a0f4 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al @@ -8,6 +8,8 @@ using Microsoft.ExpenseAgent; using System.AI; using System.Email; using System.Environment; +using System.Privacy; +using System.TestLibraries.AI; using System.TestLibraries.Email; codeunit 148314 "EA Agent Dispatcher Test" @@ -403,13 +405,9 @@ codeunit 148314 "EA Agent Dispatcher Test" ExpenseAgentStatus: Record "Expense Agent Status"; TempEmailInbox: Record "Email Inbox" temporary; TestEmailConnector: Codeunit "Test Email Connector v4"; - CopilotCapability: Codeunit "Copilot Capability"; - ExpenseAgentAppId: Guid; begin AssertMockTestEnvironment(); - Evaluate(ExpenseAgentAppId, '66efe10c-8033-403b-a86d-77c0887178ba'); - Assert.IsTrue(CopilotCapability.IsCapabilityActive(Enum::"Copilot Capability"::"Expense Agent", ExpenseAgentAppId), - 'Expense Agent capability and required privacy approvals must already be enabled. These tests never change tenant-wide Copilot settings or approvals.'); + EnableExpenseAgentCapability(); Assert.IsTrue(EmailOutbox.IsEmpty(), 'The disposable company must have no existing email outbox rows, including failed background work.'); if ExpenseAgentStatus.Get() then begin Assert.IsTrue(IsNullGuid(ExpenseAgentStatus."Agent Task ID"), 'The isolated fixture must not have a configured dispatcher.'); @@ -452,6 +450,25 @@ codeunit 148314 "EA Agent Dispatcher Test" Commit(); end; + local procedure EnableExpenseAgentCapability() + var + CopilotCapability: Codeunit "Copilot Capability"; + CopilotTestLibrary: Codeunit "Copilot Test Library"; + ExpPrivacyNoticeReg: Codeunit "Exp. Privacy Notice Reg."; + PrivacyNotice: Codeunit "Privacy Notice"; + ExpenseAgentAppId: Guid; + AzureOpenAITok: Label 'Azure OpenAI', Locked = true; + begin + Evaluate(ExpenseAgentAppId, '66efe10c-8033-403b-a86d-77c0887178ba'); + // Codeunit isolation rolls these shared settings back after the suite, including committed test flows. + CopilotTestLibrary.RegisterCopilotCapabilityWithAppId(Enum::"Copilot Capability"::"Expense Agent", ExpenseAgentAppId); + PrivacyNotice.SetApprovalState(AzureOpenAITok, "Privacy Notice Approval State"::Agreed); + PrivacyNotice.SetApprovalState(ExpPrivacyNoticeReg.GetExpenseAgentPrivacyNoticeId(), "Privacy Notice Approval State"::Agreed); + + Assert.IsTrue(CopilotCapability.IsCapabilityActive(Enum::"Copilot Capability"::"Expense Agent", ExpenseAgentAppId), + 'The isolated fixture must activate the Expense Agent capability and required privacy approvals.'); + end; + local procedure AssertMockTestEnvironment() var EnvironmentInformation: Codeunit "Environment Information"; From e63c7b2d73cd45307b44c47c2e7168bcb7ad732c Mon Sep 17 00:00:00 2001 From: Prangshuman Das Date: Tue, 22 Sep 2026 11:22:16 +0200 Subject: [PATCH 12/13] Fix Expense Agent email fixture cleanup Clean only native email records owned by prior dispatcher-test accounts and scope queue assertions to the current fixture account, so standard Codeunit isolation can coexist with unrelated outbox data. Copilot-Session: 9131e2a8-a4b5-40c8-a748-caade17b55c8 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../EAAgentDispatcherTest.Codeunit.al | 64 +++++++++++++++---- 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al index 9236e72a0f4..6f9368e5714 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al @@ -19,6 +19,8 @@ codeunit 148314 "EA Agent Dispatcher Test" RequiredTestIsolation = Codeunit; TestHttpRequestPolicy = BlockOutboundRequests; EventSubscriberInstance = Manual; + Permissions = tabledata "Email Outbox" = rimd, + tabledata "Sent Email" = rid; var Assert: Codeunit Assert; @@ -398,7 +400,6 @@ codeunit 148314 "EA Agent Dispatcher Test" local procedure InitializeCommunication(var Setup: Record "Expense Agent Setup"; IncomingAvailable: Boolean; OutgoingAvailable: Boolean) var OutboxEmail: Record "EA Outbox Email"; - EmailOutbox: Record "Email Outbox"; ExpenseUser: Record "Expense User"; ExpenseReportHeader: Record "Expense Report Header"; EAEmail: Record "EA Email"; @@ -408,7 +409,7 @@ codeunit 148314 "EA Agent Dispatcher Test" begin AssertMockTestEnvironment(); EnableExpenseAgentCapability(); - Assert.IsTrue(EmailOutbox.IsEmpty(), 'The disposable company must have no existing email outbox rows, including failed background work.'); + CleanPreviousCommunicationFixture(); if ExpenseAgentStatus.Get() then begin Assert.IsTrue(IsNullGuid(ExpenseAgentStatus."Agent Task ID"), 'The isolated fixture must not have a configured dispatcher.'); Assert.IsTrue(IsNullGuid(ExpenseAgentStatus."Agent Recovery Task ID"), 'The isolated fixture must not have configured recovery.'); @@ -450,6 +451,42 @@ codeunit 148314 "EA Agent Dispatcher Test" Commit(); end; + local procedure CleanPreviousCommunicationFixture() + var + TestEmailAccount: Record "Test Email Account"; + begin + TestEmailAccount.SetRange(Name, 'Expense communication mock'); + TestEmailAccount.SetRange(Connector, Enum::"Email Connector"::"Test Email Connector v4"); + if TestEmailAccount.FindSet() then + repeat + DeleteFixtureNativeEmails(TestEmailAccount.Id); + until TestEmailAccount.Next() = 0; + end; + + local procedure DeleteFixtureNativeEmails(AccountId: Guid) + var + EmailOutbox: Record "Email Outbox"; + SentEmail: Record "Sent Email"; + begin + if EmailOutbox.FindSet() then + repeat + if (EmailOutbox.GetAccountId() = AccountId) and + (EmailOutbox.GetConnector() = Enum::"Email Connector"::"Test Email Connector v4") + then + EmailOutbox.Mark(true); + until EmailOutbox.Next() = 0; + EmailOutbox.MarkedOnly(true); + EmailOutbox.DeleteAll(true); + + if SentEmail.FindSet() then + repeat + if SentEmail.GetAccountId() = AccountId then + SentEmail.Mark(true); + until SentEmail.Next() = 0; + SentEmail.MarkedOnly(true); + SentEmail.DeleteAll(true); + end; + local procedure EnableExpenseAgentCapability() var CopilotCapability: Codeunit "Copilot Capability"; @@ -807,21 +844,22 @@ codeunit 148314 "EA Agent Dispatcher Test" FoundCurrentMessage: Boolean; begin // This event precedes Email Dispatcher. Rate is explicitly zero; concurrency counts Processing rows only. - // Only this new Queued row and known Failed foreground attempts may exist, so the processing count is zero. + // Only this fixture account is relevant; unrelated application outbox rows must remain untouched. Assert.IsFalse(IsNullGuid(OutgoingMockAccountId), 'No email may be queued without the fixture outgoing account.'); Assert.IsFalse(FixtureMessageIds.Contains(MessageId), 'Every synchronous attempt must use a fresh message.'); if EmailOutbox.FindSet() then repeat - Assert.AreEqual(OutgoingMockAccountId, EmailOutbox.GetAccountId(), 'Unknown account work must not reach the native dispatcher.'); - Assert.AreEqual(Enum::"Email Connector"::"Test Email Connector v4", EmailOutbox.GetConnector(), 'Only the native mock connector is allowed.'); - if EmailOutbox.GetMessageId() = MessageId then begin - FoundCurrentMessage := true; - Assert.IsTrue(LibraryEmailMock.CheckEmailOutBoxStatusWithMessageId(MessageId, Enum::"Email Status"::Queued), - 'The current foreground message must still be queued before dispatch.'); - end else begin - Assert.IsTrue(FixtureMessageIds.Contains(EmailOutbox.GetMessageId()), 'Pre-existing background or unrelated emails are forbidden.'); - Assert.IsTrue(LibraryEmailMock.CheckEmailOutBoxStatusWithMessageId(EmailOutbox.GetMessageId(), Enum::"Email Status"::Failed), - 'Earlier fixture attempts must be Failed, never Queued or Processing.'); + if EmailOutbox.GetAccountId() = OutgoingMockAccountId then begin + Assert.AreEqual(Enum::"Email Connector"::"Test Email Connector v4", EmailOutbox.GetConnector(), 'Only the native mock connector is allowed.'); + if EmailOutbox.GetMessageId() = MessageId then begin + FoundCurrentMessage := true; + Assert.IsTrue(LibraryEmailMock.CheckEmailOutBoxStatusWithMessageId(MessageId, Enum::"Email Status"::Queued), + 'The current foreground message must still be queued before dispatch.'); + end else begin + Assert.IsTrue(FixtureMessageIds.Contains(EmailOutbox.GetMessageId()), 'Unexpected work for the fixture account must not reach the native dispatcher.'); + Assert.IsTrue(LibraryEmailMock.CheckEmailOutBoxStatusWithMessageId(EmailOutbox.GetMessageId(), Enum::"Email Status"::Failed), + 'Earlier fixture attempts must be Failed, never Queued or Processing.'); + end; end; until EmailOutbox.Next() = 0; Assert.IsTrue(FoundCurrentMessage, 'The foreground email must have a native outbox row.'); From 897a87c6e644a9559e6774e91c0d221fcd1c1b34 Mon Sep 17 00:00:00 2001 From: Prangshuman Das Date: Tue, 22 Sep 2026 17:54:59 +0200 Subject: [PATCH 13/13] Trim Expense Agent endpoint seam tests Remove the missing-setup and saved-Canary endpoint tests, delete their fixture-only counters, and rely on the normal TestPermissions Disabled runner context instead of redundant table grants. Copilot-Session: 9131e2a8-a4b5-40c8-a748-caade17b55c8 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../EAAgentDispatcherTest.Codeunit.al | 69 ------------------- 1 file changed, 69 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al index 6f9368e5714..5873aa8fa11 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/EmailLifecycle/EAAgentDispatcherTest.Codeunit.al @@ -19,8 +19,6 @@ codeunit 148314 "EA Agent Dispatcher Test" RequiredTestIsolation = Codeunit; TestHttpRequestPolicy = BlockOutboundRequests; EventSubscriberInstance = Manual; - Permissions = tabledata "Email Outbox" = rimd, - tabledata "Sent Email" = rid; var Assert: Codeunit Assert; @@ -30,8 +28,6 @@ codeunit 148314 "EA Agent Dispatcher Test" ResponseStatusCode: Integer; HttpRequestCount: Integer; ObservedRequestCount: Integer; - EndpointResolutionCount: Integer; - ExpectedUseCanaryEndpoint: Boolean; RequestCorrelationId: Guid; MultipartBody: Text; MultipartContentType: Text; @@ -259,66 +255,6 @@ codeunit 148314 "EA Agent Dispatcher Test" Assert.IsTrue(OutboxEmail.IsEmpty(), 'A failed service handoff must not insert an outbox callback.'); end; - [Test] - procedure MissingSetupSkipsEndpointOverrideAndHttp() - var - Setup: Record "Expense Agent Setup"; - EAHttpClient: Codeunit "EA Http Client"; - Success: Boolean; - begin - // [SCENARIO] The HTTP wrapper rejects a welcome request before endpoint resolution when persisted setup is missing. - - // [GIVEN] The isolated company has no Expense Agent setup and request counters are reset. - AssertMockTestEnvironment(); - ExpectNoService(); - Setup.DeleteAll(); - Commit(); - BindSubscription(this); - - // [WHEN] The production welcome notification wrapper is invoked with the read-only test subscriptions bound. - Success := EAHttpClient.SendWelcomeEmailNotification(GetRecipientEmail(), CreateGuid()); - UnbindSubscription(this); - - - // [THEN] The call returns false without resolving an endpoint, constructing an observed request, or reaching mocked HTTP. - Assert.IsFalse(Success, 'The real HTTP wrapper must reject missing persisted setup.'); - Assert.AreEqual(0, EndpointResolutionCount, 'Missing setup must be checked before the endpoint override event.'); - Assert.AreEqual(0, ObservedRequestCount, 'Missing setup must not construct a service request.'); - Assert.AreEqual(0, HttpRequestCount, 'Missing setup must not reach HTTP.'); - end; - - [Test] - [HandlerFunctions('ExpenseServiceHandler')] - procedure SavedCanarySelectionReachesCommunicationEndpoint() - var - Setup: Record "Expense Agent Setup"; - ExpenseUser: Record "Expense User"; - begin - // [SCENARIO] Persisted default and canary selections both reach endpoint resolution. - - // [GIVEN] An outgoing-only fixture uses the safe mocked communication endpoint and queues a welcome recipient. - InitializeCommunication(Setup, false, true); - CreateRecipient(ExpenseUser, true); - ExpectService('/api/v1.0/notifications/welcome', 'notification-outbox-accepted.json', 200); - - // [WHEN] A communication pass runs with the saved default selection, then another runs after persisting the canary selection. - RunCommunication(Setup); - - // [THEN] Each saved selection resolves exactly one endpoint and reaches exactly one mocked HTTP request. - Assert.AreEqual(1, EndpointResolutionCount, 'The saved default selection must reach endpoint resolution.'); - Assert.AreEqual(1, HttpRequestCount, 'The default selection must execute the real HTTP wrapper.'); - - Setup.Get(); - Setup."Use Canary Endpoint" := true; - Setup.Modify(); - CreateRecipient(ExpenseUser, true); - ExpectService('/api/v1.0/notifications/welcome', 'notification-outbox-accepted.json', 200); - ExpectedUseCanaryEndpoint := true; - RunCommunication(Setup); - Assert.AreEqual(1, EndpointResolutionCount, 'The saved canary selection must reach endpoint resolution.'); - Assert.AreEqual(1, HttpRequestCount, 'The canary selection must execute the real HTTP wrapper with a safe mock endpoint.'); - end; - [Test] [HandlerFunctions('ExpenseServiceHandler')] procedure EligibleReminderWithoutIncomingAcceptsSkippedResponse() @@ -548,7 +484,6 @@ codeunit 148314 "EA Agent Dispatcher Test" Assert.AreEqual('', ErrorMessage, 'Runnable channels must not report a missing-incoming error.'); Assert.AreEqual('', UnexpectedRequest, 'Unexpected HTTP must fail even if production catches the handler error.'); Assert.AreEqual(HttpRequestCount, ObservedRequestCount, 'Every observed production request must reach the native HTTP mock.'); - Assert.AreEqual(HttpRequestCount, EndpointResolutionCount, 'Each mocked request must resolve its endpoint through the real persisted-setup boundary.'); end; local procedure CreateRecipient(var ExpenseUser: Record "Expense User"; QueueWelcome: Boolean) @@ -718,8 +653,6 @@ codeunit 148314 "EA Agent Dispatcher Test" Clear(ResponseStatusCode); Clear(HttpRequestCount); Clear(ObservedRequestCount); - Clear(EndpointResolutionCount); - Clear(ExpectedUseCanaryEndpoint); Clear(RequestCorrelationId); Clear(MultipartBody); Clear(MultipartContentType); @@ -755,11 +688,9 @@ codeunit 148314 "EA Agent Dispatcher Test" [EventSubscriber(ObjectType::Codeunit, Codeunit::"EA Http Client", 'OnGetCommunicationBaseUrl', '', false, false)] local procedure SetCommunicationBaseUrl(UseCanaryEndpoint: Boolean; var BaseUrl: Text) begin - Assert.AreEqual(ExpectedUseCanaryEndpoint, UseCanaryEndpoint, 'Endpoint selection must use the saved company setup flag.'); Assert.AreEqual('', BaseUrl, 'The communication override must precede normal endpoint lookup.'); BaseUrl := ServiceBaseUrlTok; Assert.AreNotEqual('', BaseUrl, 'The isolated mock endpoint must be nonempty.'); - EndpointResolutionCount += 1; end; [EventSubscriber(ObjectType::Codeunit, Codeunit::"EA Http Client", 'OnBeforeAddAuthHeaders', '', false, false)]