diff --git a/CovidSafe/CovidSafe.API.Tests/v20200611/Controllers/MessageControllers/AnnounceControllerTests.cs b/CovidSafe/CovidSafe.API.Tests/v20200611/Controllers/MessageControllers/AnnounceControllerTests.cs index d7921af..23009e3 100644 --- a/CovidSafe/CovidSafe.API.Tests/v20200611/Controllers/MessageControllers/AnnounceControllerTests.cs +++ b/CovidSafe/CovidSafe.API.Tests/v20200611/Controllers/MessageControllers/AnnounceControllerTests.cs @@ -56,6 +56,71 @@ public AnnounceControllerTests() this._controller.ControllerContext.HttpContext = new DefaultHttpContext(); } + /// + /// + /// returns when no objects are provided + /// with request + /// + [TestMethod] + public async Task DeleteAsync_BadRequestObjectWithInvalidMessageId() + { + // Arrange + string invalidId = "this is an invalid ID."; + + // Act + ActionResult controllerResponse = await this._controller + .DeleteAsync(invalidId, CancellationToken.None); + + // Assert + Assert.IsNotNull(controllerResponse); + Assert.IsInstanceOfType(controllerResponse, typeof(BadRequestObjectResult)); + } + + /// + /// + /// returns when no + /// objects are matched by the provided 'messageId' parameter + /// + [TestMethod] + public async Task DeleteAsync_NotFoundWithInvalidMessageId() + { + // Arrange + string unmatchedId = "00000000-0000-0000-0000-000000000001"; + this._repo + .Setup(r => r.DeleteAsync(It.IsAny(), CancellationToken.None)) + .Returns(Task.FromResult(true)); + + // Act + ActionResult controllerResponse = await this._controller + .DeleteAsync(unmatchedId, CancellationToken.None); + + // Assert + Assert.IsNotNull(controllerResponse); + Assert.IsInstanceOfType(controllerResponse, typeof(NotFoundResult)); + } + + /// + /// + /// returns with valid input data + /// + [TestMethod] + public async Task DeleteAsync_OkWithValidInputs() + { + // Arrange + string validId = "00000000-0000-0000-0000-000000000001"; + this._repo + .Setup(r => r.DeleteAsync(validId, CancellationToken.None)) + .Returns(Task.FromResult(true)); + + // Act + ActionResult controllerResponse = await this._controller + .DeleteAsync(validId, CancellationToken.None); + + // Assert + Assert.IsNotNull(controllerResponse); + Assert.IsInstanceOfType(controllerResponse, typeof(OkResult)); + } + /// /// /// returns when no objects are provided diff --git a/CovidSafe/CovidSafe.API/v20200611/Controllers/MessageControllers/AnnounceController.cs b/CovidSafe/CovidSafe.API/v20200611/Controllers/MessageControllers/AnnounceController.cs index 1acc818..d9f00f0 100644 --- a/CovidSafe/CovidSafe.API/v20200611/Controllers/MessageControllers/AnnounceController.cs +++ b/CovidSafe/CovidSafe.API/v20200611/Controllers/MessageControllers/AnnounceController.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Threading; using System.Threading.Tasks; @@ -42,13 +43,53 @@ public AnnounceController(IMapper map, IMessageService reportService) this._reportService = reportService; } + /// + /// Deletes a + /// + /// + /// Sample request: + /// + /// DELETE /api/Messages/Announce?messageId=00000000-0000-0000-0000-000000000000&api-version=2020-06-11 + /// + /// + /// Unique to delete + /// Cancellation token (not required in API call) + /// Delete successful + /// Malformed or invalid request + [HttpDelete] + [Consumes("application/x-protobuf", "application/json")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public async Task DeleteAsync([Required] string messageId, CancellationToken cancellationToken = default) + { + try + { + // Attempt delete + await this._reportService.DeleteMessageByIdAsync(messageId, cancellationToken); + return Ok(); + } + catch (KeyNotFoundException) + { + return NotFound(); + } + catch (RequestValidationFailedException ex) + { + // Only return validation issues + return BadRequest(ex.ValidationResult); + } + catch (ArgumentNullException) + { + return BadRequest(); + } + } + /// /// Publish a for distribution among devices /// /// /// Sample request: /// - /// PUT /api/Messages/Announce&api-version=2020-06-11 + /// PUT /api/Messages/Announce?api-version=2020-06-11 /// { /// "userMessage": "Monitor symptoms for one week.", /// "area": { diff --git a/CovidSafe/CovidSafe.DAL.Tests/Services/InfectionReportServiceTests.cs b/CovidSafe/CovidSafe.DAL.Tests/Services/MessageServiceTests.cs similarity index 84% rename from CovidSafe/CovidSafe.DAL.Tests/Services/InfectionReportServiceTests.cs rename to CovidSafe/CovidSafe.DAL.Tests/Services/MessageServiceTests.cs index cbf5ed3..c9379b3 100644 --- a/CovidSafe/CovidSafe.DAL.Tests/Services/InfectionReportServiceTests.cs +++ b/CovidSafe/CovidSafe.DAL.Tests/Services/MessageServiceTests.cs @@ -8,6 +8,7 @@ using CovidSafe.DAL.Services; using CovidSafe.Entities.Geospatial; using CovidSafe.Entities.Messages; +using CovidSafe.Entities.Validation; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -40,6 +41,87 @@ public MessageServiceTests() this._service = new MessageService(this._repo.Object); } + /// + /// + /// throws with empty 'id' parameter + /// + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public async Task DeleteMessageById_ArgumentNullOnEmptyId() + { + // Arrange + // N/A + + // Act + await this._service + .DeleteMessageByIdAsync(null, CancellationToken.None); + + // Assert + // Exception caught by decorator + } + + /// + /// + /// throws with unmatched 'id' parameter + /// + [TestMethod] + [ExpectedException(typeof(KeyNotFoundException))] + public async Task DeleteMessageById_KeyNotFoundOnUnmatchedId() + { + // Arrange + string unmatchedId = "00000000-0000-0000-0000-000000000001"; + this._repo + .Setup(r => r.DeleteAsync(It.IsAny(), CancellationToken.None)) + .Returns(Task.FromResult(false)); + + // Act + await this._service + .DeleteMessageByIdAsync(unmatchedId, CancellationToken.None); + + // Assert + // Exception caught by decorator + } + + /// + /// + /// throws with non-GUID 'id' parameter + /// + [TestMethod] + [ExpectedException(typeof(RequestValidationFailedException))] + public async Task DeleteMessageById_RequestValidationFailedOnInvalidId() + { + // Arrange + string invalidId = "this is not a valid ID"; + + // Act + await this._service + .DeleteMessageByIdAsync(invalidId, CancellationToken.None); + + // Assert + // Exception caught by decorator + } + + /// + /// + /// succeeds with valid 'id' parameter + /// + [TestMethod] + public async Task DeleteMessageById_SucceedsOnValidId() + { + // Arrange + string validId = "00000000-0000-0000-0000-000000000001"; + this._repo + .Setup(r => r.DeleteAsync(validId, CancellationToken.None)) + .Returns(Task.FromResult(true)); + + // Act + await this._service + .DeleteMessageByIdAsync(validId, CancellationToken.None); + + // Assert + // No exceptions should be thrown + } + /// /// /// throws with empty 'ids' parameter diff --git a/CovidSafe/CovidSafe.DAL/Repositories/Cosmos/CosmosMessageContainerRepository.cs b/CovidSafe/CovidSafe.DAL/Repositories/Cosmos/CosmosMessageContainerRepository.cs index 6931d4a..fec20f2 100644 --- a/CovidSafe/CovidSafe.DAL/Repositories/Cosmos/CosmosMessageContainerRepository.cs +++ b/CovidSafe/CovidSafe.DAL/Repositories/Cosmos/CosmosMessageContainerRepository.cs @@ -11,7 +11,6 @@ using CovidSafe.Entities.Messages; using Microsoft.Azure.Cosmos; using Microsoft.Azure.Cosmos.Linq; -using Microsoft.Azure.Cosmos.Spatial; namespace CovidSafe.DAL.Repositories.Cosmos { @@ -32,6 +31,44 @@ public CosmosMessageContainerRepository(CosmosContext dbContext) : base(dbContex ); } + /// + /// Retrieves a object by its unique identifier + /// + /// Unique identifier + /// Cancellation token + /// matching provided identifier, or null + /// + /// Some functions require the Cosmos record attributes of a matching document, whereas + /// by definition returns the object represented + /// by this repository. Hence, this function was created to retrieve the actual + /// implementation to allow examination of the additional attributes. + /// + private async Task _getRecordById(string id, CancellationToken cancellationToken) + { + // Create LINQ query + var queryable = this.Container + .GetItemLinqQueryable(); + + // Execute query + var iterator = queryable + .Where(r => + r.Id == id + && r.Version == MessageContainerRecord.CURRENT_RECORD_VERSION + ) + .ToFeedIterator(); + + List results = new List(); + + while (iterator.HasMoreResults) + { + results.AddRange(await iterator.ReadNextAsync()); + } + + // There should only ever be one result + // This is a semantic of how SELECT works in the LINQ/CosmosDB SDK + return results.FirstOrDefault(); + } + /// /// Returns the most restrictive timestamp filter, based on the application /// configuration and the one provided by the user @@ -59,29 +96,52 @@ private long _getTimestampFilter(long timestampFilter) } /// - public async Task GetAsync(string messageId, CancellationToken cancellationToken = default) + public async Task DeleteAsync(string id, CancellationToken cancellationToken = default) { - // Create LINQ query - var queryable = this.Container - .GetItemLinqQueryable(); + if(String.IsNullOrEmpty(id)) + { + throw new ArgumentNullException(nameof(id)); + } - // Execute query - var iterator = queryable - .Where(r => - r.Id == messageId - && r.Version == MessageContainerRecord.CURRENT_RECORD_VERSION - ) - .Select(r => r.Value) - .ToFeedIterator(); + // Retrieve the record to be deleted + // Necessary to get PartitionKey + MessageContainerRecord toDelete = await this._getRecordById(id, cancellationToken); - List results = new List(); + if(toDelete != null) + { + await this.Container.DeleteItemAsync( + id, + new PartitionKey(toDelete.PartitionKey), + cancellationToken: cancellationToken + ); - while (iterator.HasMoreResults) + return true; + } + else { - results.AddRange(await iterator.ReadNextAsync()); + return false; } + } - return results.FirstOrDefault(); + /// + public async Task GetAsync(string messageId, CancellationToken cancellationToken = default) + { + if(String.IsNullOrEmpty(messageId)) + { + throw new ArgumentNullException(nameof(messageId)); + } + + // Get result from private function + MessageContainerRecord record = await this._getRecordById(messageId, cancellationToken); + + if (record != null) + { + return record.Value; + } + else + { + return null; + } } /// diff --git a/CovidSafe/CovidSafe.DAL/Repositories/IRepository.cs b/CovidSafe/CovidSafe.DAL/Repositories/IRepository.cs index 586d256..c548fe8 100644 --- a/CovidSafe/CovidSafe.DAL/Repositories/IRepository.cs +++ b/CovidSafe/CovidSafe.DAL/Repositories/IRepository.cs @@ -10,6 +10,13 @@ namespace CovidSafe.DAL.Repositories /// Type used by the primary key public interface IRepository { + /// + /// Deletes an object matching the provided identifier + /// + /// Unique object identifier + /// Cancellation token + /// True if record was found and deleted, false if no matching record was found + Task DeleteAsync(TT id, CancellationToken cancellationToken = default); /// /// Retrieves an object which matches the provided identifier /// diff --git a/CovidSafe/CovidSafe.DAL/Services/IMessageService.cs b/CovidSafe/CovidSafe.DAL/Services/IMessageService.cs index 92f8f57..d80387b 100644 --- a/CovidSafe/CovidSafe.DAL/Services/IMessageService.cs +++ b/CovidSafe/CovidSafe.DAL/Services/IMessageService.cs @@ -12,6 +12,12 @@ namespace CovidSafe.DAL.Services /// public interface IMessageService : IService { + /// + /// Deletes a based on its unique identifier + /// + /// Identifier of to delete + /// Cancellation token + Task DeleteMessageByIdAsync(string id, CancellationToken cancellationToken = default); /// /// Retrieves a collection of objects by their unique identifiers /// diff --git a/CovidSafe/CovidSafe.DAL/Services/MessageService.cs b/CovidSafe/CovidSafe.DAL/Services/MessageService.cs index f6618e8..e253a8d 100644 --- a/CovidSafe/CovidSafe.DAL/Services/MessageService.cs +++ b/CovidSafe/CovidSafe.DAL/Services/MessageService.cs @@ -46,6 +46,33 @@ public MessageService(IMessageContainerRepository messageRepo) this._reportRepo = messageRepo; } + /// + public async Task DeleteMessageByIdAsync(string id, CancellationToken cancellationToken = default) + { + if(String.IsNullOrEmpty(id)) + { + throw new ArgumentNullException(nameof(id)); + } + + // Confirm ID is valid + RequestValidationResult validationStatus = Validator.ValidateGuid(id, nameof(id)); + + if(validationStatus.Passed) + { + bool result = await this._reportRepo.DeleteAsync(id, cancellationToken); + + if(!result) + { + // No matching records, throw exception to signal this + throw new KeyNotFoundException(); + } + } + else + { + throw new RequestValidationFailedException(validationStatus); + } + } + /// public async Task> GetByIdsAsync(IEnumerable ids, CancellationToken cancellationToken = default) {