Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,20 @@

using System;
using System.Net;
using System.Reflection.Metadata;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Health.Core.Features.Security.Authorization;
using Microsoft.Health.Extensions.DependencyInjection;
using Microsoft.Health.Fhir.Core.Exceptions;
using Microsoft.Health.Fhir.Core.Extensions;
using Microsoft.Health.Fhir.Core.Features.Operations;
using Microsoft.Health.Fhir.Core.Features.Operations.Export;
using Microsoft.Health.Fhir.Core.Features.Operations.Export.Models;
using Microsoft.Health.Fhir.Core.Features.Persistence;
using Microsoft.Health.Fhir.Core.Features.Security;
using Microsoft.Health.Fhir.Core.Features.Security.Authorization;
using Microsoft.Health.Fhir.Core.Messages.Export;
using Microsoft.Health.Fhir.Tests.Common;
Expand Down Expand Up @@ -58,22 +60,68 @@ public CancelExportRequestHandlerTests()
_mediator = new Mediator(provider);
}

[Theory]
[InlineData(OperationStatus.Canceled)]
[InlineData(OperationStatus.Completed)]
[InlineData(OperationStatus.Failed)]
public async Task GivenAFhirMediator_WhenCancelingExistingExportJobThatHasAlreadyCompleted_ThenConflictStatusCodeShouldBeReturned(OperationStatus operationStatus)
/// <summary>
/// When the user is not authorized, an UnauthorizedFhirActionException should be thrown.
/// </summary>
[Fact]
public async Task GivenAFhirMediator_WhenUserIsNotAuthorized_ThenUnauthorizedFhirActionExceptionShouldBeThrown()
{
var authorizationService = Substitute.For<IAuthorizationService<DataActions>>();
authorizationService.CheckAccess(DataActions.Export, Arg.Any<CancellationToken>()).Returns(DataActions.None);

var handler = new CancelExportRequestHandler(
_fhirOperationDataStore,
authorizationService,
_retryCount,
_sleepDurationProvider,
NullLogger<CancelExportRequestHandler>.Instance);

await Assert.ThrowsAsync<UnauthorizedFhirActionException>(() =>
handler.Handle(new CancelExportRequest(JobId), _cancellationToken));
}

/// <summary>
/// By Orchestrator job Id:
/// If Orchestrator job is in Cancelled status or cancel is requested, GetExportJobByIdAsync throws 404.
/// If Orchestrator job is in CancelledByUser status, GetExportJobByIdAsync throws 404.
/// By Processing job Id:
/// If Orchestrator job is in Cancelled status or cancel is requested, GetExportJobByIdAsync throws 404.
/// If Orchestrator job is in CancelledByUser status, GetExportJobByIdAsync throws 404.
/// </summary>
[Fact]
public async Task GivenAFhirMediator_WhenGetExportJobByIdThrowsJobNotFoundException_ThenJobNotFoundExceptionShouldBeThrown()
{
ExportJobOutcome outcome = await SetupAndExecuteCancelExportAsync(operationStatus, HttpStatusCode.Conflict);
_fhirOperationDataStore.GetExportJobByIdAsync(JobId, _cancellationToken)
.Returns(Task.FromException<ExportJobOutcome>(new JobNotFoundException(string.Format(Core.Resources.JobNotFound, JobId))));

Assert.Equal(operationStatus, outcome.JobRecord.Status);
Assert.Null(outcome.JobRecord.CanceledTime);
await Assert.ThrowsAsync<JobNotFoundException>(() => _mediator.CancelExportAsync(JobId, _cancellationToken));
}

/// <summary>
/// By Orchestrator job Id:
/// If Orchestrator job is in Completed status:
/// Processing jobs are running > return Running > Set Orchestrator job status to Cancel here (SP will set it to CancelByUser)
/// Processing jobs are cancelled and no failed jobs exists > return Cancelled > Set Orchestrator job status to Cancel here (SP will set it to CancelByUser)
/// Processing jobs are cancelled and failed jobs exists > return Failed > Set Orchestrator job status to Cancel here (SP will set it to CancelByUser)
/// By Processing job Id:
/// If Processing job is in Completed status:
/// Processing jobs are running > return Running > Set Orchestrator job status to Cancel here (SP will set it to CancelByUser)
/// Processing jobs are cancelled and no failed jobs exists > return Cancelled > Set Orchestrator job status to Cancel here (SP will set it to CancelByUser)
/// Processing jobs are cancelled and failed jobs exists > return Failed > Set Orchestrator job status to Cancel here (SP will set it to CancelByUser)
/// If Processing job is in Failed status > return Failed > Set to Cancel here (SP will set the Orchestrator status to CancelByUser, processing job status stays Failed)
/// If Processing job is in Cancelled status > return Cancelled > Set to Cancel here (SP will set the Orchestrator status to CancelByUser, processing job status stays Cancelled)
///
/// GetExportJobByIdAsync returns Running/Cancelled/Failed based on group job analysis.
/// It return 404 if job's status is cancelled or cancelledByUser or CancelRequested = 1
/// The handler always sets status to Canceled, CanceledTime, and FailureDetails, then calls UpdateExportJobAsync.
/// </summary>
[Theory]
[InlineData(OperationStatus.Queued)]
[InlineData(OperationStatus.Running)]
public async Task GivenAFhirMediator_WhenCancelingExistingExportJobThatHasNotCompleted_ThenAcceptedStatusCodeShouldBeReturned(OperationStatus operationStatus)
[InlineData(OperationStatus.Completed)]
[InlineData(OperationStatus.Failed)]
[InlineData(OperationStatus.Canceled)]
public async Task GivenAFhirMediator_WhenCancelingJobInAnyNon404Status_ThenRecordIsSetToCanceledWithFailureDetailsAndAcceptedReturned(OperationStatus operationStatus)
{
ExportJobOutcome outcome = null;

Expand All @@ -82,16 +130,26 @@ public async Task GivenAFhirMediator_WhenCancelingExistingExportJobThatHasNotCom
Microsoft.Extensions.Time.Testing.FakeTimeProvider timeProvider = new(instant);
using (Mock.Property(() => ClockResolver.TimeProvider, timeProvider))
{
outcome = await SetupAndExecuteCancelExportAsync(operationStatus, HttpStatusCode.Accepted);
outcome = SetupExportJob(operationStatus);

CancelExportResponse response = await _mediator.CancelExportAsync(JobId, _cancellationToken);

Assert.NotNull(response);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
}

// Check to make sure the record is updated
Assert.Equal(OperationStatus.Canceled, outcome.JobRecord.Status);
Assert.Equal(instant, outcome.JobRecord.CanceledTime);
Assert.NotNull(outcome.JobRecord.CanceledTime);
Assert.NotNull(outcome.JobRecord.FailureDetails);
Assert.Equal(HttpStatusCode.NoContent, outcome.JobRecord.FailureDetails.FailureStatusCode);
Assert.Equal(Core.Resources.UserRequestedCancellation, outcome.JobRecord.FailureDetails.FailureReason);

await _fhirOperationDataStore.Received(1).UpdateExportJobAsync(outcome.JobRecord, outcome.ETag, _cancellationToken);
}

/// <summary>
/// When a JobConflictException is encountered, the handler retries up to the configured retry count.
/// </summary>
[Fact]
public async Task GivenAFhirMediator_WhenCancelingExistingExportJobEncountersJobConflictException_ThenItWillBeRetried()
{
Expand All @@ -116,7 +174,6 @@ public async Task GivenAFhirMediator_WhenCancelingExistingExportJobEncountersJob
SetupOperationDataStore(1, _ => throw new JobConflictException());
SetupOperationDataStore(2, _ => CreateExportJobOutcome(jobRecord, WeakETag.FromVersionId("123")));

// No error should be thrown.
CancelExportResponse response = await _mediator.CancelExportAsync(JobId, _cancellationToken);

Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
Expand All @@ -128,6 +185,9 @@ void SetupOperationDataStore(int index, Func<NSubstitute.Core.CallInfo, ExportJo
}
}

/// <summary>
/// When the retry count is exceeded, the JobConflictException is thrown to the caller.
/// </summary>
[Fact]
public async Task GivenAFhirMediator_WhenCancelingExistingExportJobEncountersJobConflictExceptionExceedsMaxRetry_ThenExceptionShouldBeThrown()
{
Expand All @@ -138,27 +198,24 @@ public async Task GivenAFhirMediator_WhenCancelingExistingExportJobEncountersJob
_fhirOperationDataStore.UpdateExportJobAsync(Arg.Any<ExportJobRecord>(), Arg.Any<WeakETag>(), Arg.Any<CancellationToken>())
.Returns<ExportJobOutcome>(_ => throw new JobConflictException());

// Error should be thrown.
await Assert.ThrowsAsync<JobConflictException>(() => _mediator.CancelExportAsync(JobId, _cancellationToken));
}

private async Task<ExportJobOutcome> SetupAndExecuteCancelExportAsync(OperationStatus operationStatus, HttpStatusCode expectedStatusCode)
/// <summary>
/// When UpdateExportJobAsync throws an unexpected exception (not JobConflictException),
/// the handler should not retry and should propagate the exception directly.
/// </summary>
[Fact]
public async Task GivenAFhirMediator_WhenUpdateExportJobThrowsUnexpectedException_ThenExceptionShouldBeThrown()
{
ExportJobOutcome outcome = SetupExportJob(operationStatus);
if (expectedStatusCode == HttpStatusCode.Conflict)
{
OperationFailedException operationFailedException = await Assert.ThrowsAsync<OperationFailedException>(async () => await _mediator.CancelExportAsync(JobId, _cancellationToken));
Assert.Equal(HttpStatusCode.Conflict, operationFailedException.ResponseStatusCode);
}
else
{
CancelExportResponse response = await _mediator.CancelExportAsync(JobId, _cancellationToken);
SetupExportJob(OperationStatus.Queued);

Assert.NotNull(response);
Assert.Equal(expectedStatusCode, response.StatusCode);
}
_fhirOperationDataStore.UpdateExportJobAsync(Arg.Any<ExportJobRecord>(), Arg.Any<WeakETag>(), Arg.Any<CancellationToken>())
.Returns<ExportJobOutcome>(_ => throw new InvalidOperationException("Unexpected error"));

return outcome;
await Assert.ThrowsAsync<InvalidOperationException>(() => _mediator.CancelExportAsync(JobId, _cancellationToken));

await _fhirOperationDataStore.Received(1).UpdateExportJobAsync(Arg.Any<ExportJobRecord>(), Arg.Any<WeakETag>(), Arg.Any<CancellationToken>());
}

private ExportJobOutcome SetupExportJob(OperationStatus operationStatus, WeakETag weakETag = null)
Expand All @@ -182,7 +239,8 @@ private ExportJobRecord CreateExportJobRecord(OperationStatus operationStatus)
filters: null,
hash: "123",
rollingFileSizeInMB: 64,
requestorClaims: null)
requestorClaims: null,
groupId: null)
{
Status = operationStatus,
};
Expand Down
Loading
Loading