diff --git a/src/Api/Data/IssueRepository.cs b/src/Api/Data/IssueRepository.cs index 45c16bd..cd46821 100644 --- a/src/Api/Data/IssueRepository.cs +++ b/src/Api/Data/IssueRepository.cs @@ -152,7 +152,9 @@ public static IssueEntity FromDomain(Issue issue) Status = issue.Status.ToString(), CreatedAt = issue.CreatedAt, UpdatedAt = issue.UpdatedAt, - IsArchived = false, + IsArchived = issue.IsArchived, + ArchivedBy = issue.ArchivedBy, + ArchivedAt = issue.ArchivedAt, Labels = issue.Labels?.Select(l => new LabelEntity { Name = l.Name, Color = l.Color }).ToList() }; } diff --git a/src/Api/Handlers/DeleteIssueHandler.cs b/src/Api/Handlers/DeleteIssueHandler.cs index 0c48462..4e2f954 100644 --- a/src/Api/Handlers/DeleteIssueHandler.cs +++ b/src/Api/Handlers/DeleteIssueHandler.cs @@ -1,6 +1,7 @@ using FluentValidation; using IssueManager.Api.Data; using IssueManager.Shared.Validators; +using global::Shared.Exceptions; namespace IssueManager.Api.Handlers; @@ -33,7 +34,21 @@ public async Task Handle(DeleteIssueCommand command, CancellationToken can throw new ValidationException(validationResult.Errors); } - // Archive the issue (soft-delete) - return await _repository.ArchiveAsync(command.Id, cancellationToken); + // Get the existing issue + var existingIssue = await _repository.GetByIdAsync(command.Id, cancellationToken); + if (existingIssue is null) + { + throw new NotFoundException($"Issue with ID '{command.Id}' was not found."); + } + + // If already archived, this is idempotent - return success without updating + if (existingIssue.IsArchived) + { + return true; + } + + // Archive the issue via the dedicated archive operation + await _repository.ArchiveAsync(command.Id, cancellationToken); + return true; } } diff --git a/src/Api/Handlers/UpdateIssueHandler.cs b/src/Api/Handlers/UpdateIssueHandler.cs index 5490693..3fdf07e 100644 --- a/src/Api/Handlers/UpdateIssueHandler.cs +++ b/src/Api/Handlers/UpdateIssueHandler.cs @@ -1,7 +1,8 @@ using FluentValidation; using IssueManager.Api.Data; -using IssueManager.Shared.Domain; +using global::Shared.Domain; using IssueManager.Shared.Validators; +using global::Shared.Exceptions; namespace IssueManager.Api.Handlers; @@ -25,7 +26,7 @@ public UpdateIssueHandler(IIssueRepository repository, UpdateIssueValidator vali /// /// Handles the update of an existing issue. /// - public async Task Handle(UpdateIssueCommand command, CancellationToken cancellationToken = default) + public async Task Handle(UpdateIssueCommand command, CancellationToken cancellationToken = default) { // Validate the command var validationResult = await _validator.ValidateAsync(command, cancellationToken); @@ -38,13 +39,24 @@ public UpdateIssueHandler(IIssueRepository repository, UpdateIssueValidator vali var existingIssue = await _repository.GetByIdAsync(command.Id, cancellationToken); if (existingIssue is null) { - return null; + throw new NotFoundException($"Issue with ID '{command.Id}' was not found."); + } + + // Cannot update an archived issue + if (existingIssue.IsArchived) + { + throw new ConflictException($"Issue with ID '{command.Id}' is archived and cannot be updated."); } // Update the issue using the domain method var updatedIssue = existingIssue.Update(command.Title, command.Description); // Persist the updated issue - return await _repository.UpdateAsync(updatedIssue, cancellationToken); + var result = await _repository.UpdateAsync(updatedIssue, cancellationToken); + if (result is null) + { + throw new NotFoundException($"Issue with ID '{command.Id}' could not be updated."); + } + return result; } } diff --git a/src/Api/Program.cs b/src/Api/Program.cs index 266e4d0..3cb9dcd 100644 --- a/src/Api/Program.cs +++ b/src/Api/Program.cs @@ -2,6 +2,7 @@ using IssueManager.Api.Data; using IssueManager.Api.Handlers; using IssueManager.Shared.Validators; +using global::Shared.Validators; using IssueManager.Shared.Domain.DTOs; using static IssueManager.Api.Handlers.GetIssueHandler; @@ -46,6 +47,18 @@ .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); await context.Response.WriteAsJsonAsync(new { title = "Validation failed", errors }); } + else if (ex is global::Shared.Exceptions.NotFoundException notFoundEx) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + context.Response.ContentType = "application/problem+json"; + await context.Response.WriteAsJsonAsync(new { title = "Not Found", detail = notFoundEx.Message }); + } + else if (ex is global::Shared.Exceptions.ConflictException conflictEx) + { + context.Response.StatusCode = StatusCodes.Status409Conflict; + context.Response.ContentType = "application/problem+json"; + await context.Response.WriteAsJsonAsync(new { title = "Conflict", detail = conflictEx.Message }); + } else { context.Response.StatusCode = StatusCodes.Status500InternalServerError; @@ -100,20 +113,21 @@ { var commandWithId = command with { Id = id }; var result = await handler.Handle(commandWithId); - return result is not null ? Results.Ok(result) : Results.NotFound(); + return Results.Ok(result); }) .WithName("UpdateIssue") .WithSummary("Update an existing issue") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) -.Produces(StatusCodes.Status404NotFound); +.Produces(StatusCodes.Status404NotFound) +.Produces(StatusCodes.Status409Conflict); // Delete Issue (soft-delete) issuesApi.MapDelete("{id}", async (string id, DeleteIssueHandler handler) => { var command = new DeleteIssueCommand { Id = id }; - var result = await handler.Handle(command); - return result ? Results.NoContent() : Results.NotFound(); + await handler.Handle(command); + return Results.NoContent(); }) .WithName("DeleteIssue") .WithSummary("Delete (archive) an issue") diff --git a/src/Shared/Domain/Issue.cs b/src/Shared/Domain/Issue.cs index 2c1e70f..465bbcd 100644 --- a/src/Shared/Domain/Issue.cs +++ b/src/Shared/Domain/Issue.cs @@ -36,22 +36,22 @@ public record Issue( /// /// Gets the detailed description of the issue. /// - public string? Description { get; init; } + public string? Description { get; init; } = Description; /// /// Gets the current status of the issue. /// - public IssueStatus Status { get; init; } + public IssueStatus Status { get; init; } = Status; /// /// Gets the timestamp when the issue was created. /// - public DateTime CreatedAt { get; init; } + public DateTime CreatedAt { get; init; } = CreatedAt; /// /// Gets the timestamp when the issue was last updated. /// - public DateTime UpdatedAt { get; init; } + public DateTime UpdatedAt { get; init; } = UpdatedAt; /// /// Gets the collection of labels attached to the issue. diff --git a/src/Web/Components/IssueForm.razor b/src/Web/Components/IssueForm.razor index c52d85d..74cb9bd 100644 --- a/src/Web/Components/IssueForm.razor +++ b/src/Web/Components/IssueForm.razor @@ -1,4 +1,4 @@ -@using Shared.Domain +@using global::Shared.Domain @namespace IssueManager.Web.Components
diff --git a/src/Web/_Imports.razor b/src/Web/_Imports.razor index a980293..74b8772 100644 --- a/src/Web/_Imports.razor +++ b/src/Web/_Imports.razor @@ -7,3 +7,4 @@ @using IssueManager.Web.Layout @using IssueManager.Web.Pages @using IssueManager.Web.Components +@using global::Shared.Domain diff --git a/tests/Integration/Data/IssueRepositoryTests.cs b/tests/Integration/Data/IssueRepositoryTests.cs index a3ba581..af45ff5 100644 --- a/tests/Integration/Data/IssueRepositoryTests.cs +++ b/tests/Integration/Data/IssueRepositoryTests.cs @@ -1,6 +1,6 @@ using FluentAssertions; using IssueManager.Api.Data; -using IssueManager.Shared.Domain.Models; +using global::Shared.Domain; using Testcontainers.MongoDb; namespace IssueManager.Tests.Integration.Data; @@ -10,336 +10,333 @@ namespace IssueManager.Tests.Integration.Data; ///
public class IssueRepositoryTests : IAsyncLifetime { - private const string MONGODB_IMAGE = "mongo:8.0"; - private const string TEST_DATABASE = "IssueManagerTestDb"; - private readonly MongoDbContainer _mongoContainer; - - private IIssueRepository _repository = null!; - - public IssueRepositoryTests() - { - _mongoContainer = new MongoDbBuilder() - .WithImage(MONGODB_IMAGE) - .Build(); - } - - /// - /// Initializes the test container and repository. - /// - public async Task InitializeAsync() - { - await _mongoContainer.StartAsync(); - var connectionString = _mongoContainer.GetConnectionString(); - _repository = new IssueRepository(connectionString, TEST_DATABASE); - } - - /// - /// Disposes the test container. - /// - public async Task DisposeAsync() - { - await _mongoContainer.StopAsync(); - await _mongoContainer.DisposeAsync(); - } - - [Fact] - public async Task GetAllAsync_FirstPage_ReturnsCorrectItems() - { - // Arrange - Create 50 issues - for (int i = 0; i < 50; i++) - { - var issue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: $"Issue {i + 1}", - Description: $"Description {i + 1}", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddMinutes(-i)); - - await _repository.CreateAsync(issue); - } - - // Act - var result = await _repository.GetAllAsync(page: 1, pageSize: 20, includeArchived: false); - - // Assert - result.Should().HaveCount(20); - } - - [Fact] - public async Task GetAllAsync_SecondPage_ReturnsNextSetOfItems() - { - // Arrange - Create 50 issues - var issueIds = new List(); - for (int i = 0; i < 50; i++) - { - var issue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: $"Issue {i + 1}", - Description: $"Description {i + 1}", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddMinutes(-i)); - - await _repository.CreateAsync(issue); - issueIds.Add(issue.Id); - } - - // Act - var page1 = await _repository.GetAllAsync(page: 1, pageSize: 20, includeArchived: false); - var page2 = await _repository.GetAllAsync(page: 2, pageSize: 20, includeArchived: false); - - // Assert - page2.Should().HaveCount(20); - page1.Select(i => i.Id).Should().NotIntersectWith(page2.Select(i => i.Id)); // No overlap - } - - [Fact] - public async Task GetAllAsync_ExcludesArchived_ByDefault() - { - // Arrange - Create 10 issues, archive 3 - for (int i = 0; i < 10; i++) - { - var issue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: $"Issue {i + 1}", - Description: $"Description {i + 1}", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddMinutes(-i)) - { - IsArchived = i < 3 // Archive first 3 - }; - - await _repository.CreateAsync(issue); - } - - // Act - var result = await _repository.GetAllAsync(page: 1, pageSize: 20, includeArchived: false); - - // Assert - result.Should().HaveCount(7); // 10 - 3 archived = 7 - result.Should().OnlyContain(i => !i.IsArchived); - } - - [Fact] - public async Task GetAllAsync_IncludesArchived_WhenRequested() - { - // Arrange - Create 10 issues, archive 3 - for (int i = 0; i < 10; i++) - { - var issue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: $"Issue {i + 1}", - Description: $"Description {i + 1}", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddMinutes(-i)) - { - IsArchived = i < 3 - }; - - await _repository.CreateAsync(issue); - } - - // Act - var result = await _repository.GetAllAsync(page: 1, pageSize: 20, includeArchived: true); - - // Assert - result.Should().HaveCount(10); // All issues including archived - } - - [Fact] - public async Task CountAsync_ExcludesArchived_ByDefault() - { - // Arrange - Create 10 issues, archive 3 - for (int i = 0; i < 10; i++) - { - var issue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: $"Issue {i + 1}", - Description: $"Description {i + 1}", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddMinutes(-i)) - { - IsArchived = i < 3 - }; - - await _repository.CreateAsync(issue); - } - - // Act - var count = await _repository.CountAsync(includeArchived: false); - - // Assert - count.Should().Be(7); // 10 - 3 archived = 7 - } - - [Fact] - public async Task CountAsync_IncludesArchived_WhenRequested() - { - // Arrange - Create 10 issues, archive 3 - for (int i = 0; i < 10; i++) - { - var issue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: $"Issue {i + 1}", - Description: $"Description {i + 1}", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddMinutes(-i)) - { - IsArchived = i < 3 - }; - - await _repository.CreateAsync(issue); - } - - // Act - var count = await _repository.CountAsync(includeArchived: true); - - // Assert - count.Should().Be(10); // All issues - } - - [Fact] - public async Task ArchiveAsync_SetsIsArchivedToTrue() - { - // Arrange - Create an issue - var issue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Issue to Archive", - Description: "Test", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow) - { - IsArchived = false - }; - - await _repository.CreateAsync(issue); - - // Act - var archivedIssue = issue with { IsArchived = true, UpdatedAt = DateTime.UtcNow }; - var result = await _repository.UpdateAsync(archivedIssue); - - // Assert - result.Should().NotBeNull(); - result!.IsArchived.Should().BeTrue(); - - // Verify in database - var dbIssue = await _repository.GetByIdAsync(issue.Id); - dbIssue!.IsArchived.Should().BeTrue(); - } - - [Fact] - public async Task ArchiveAsync_UpdatesTimestamp() - { - // Arrange - Create an issue - var issue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Issue to Archive", - Description: "Test", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddDays(-1)) - { - IsArchived = false, - UpdatedAt = DateTime.UtcNow.AddHours(-2) - }; - - await _repository.CreateAsync(issue); - - // Act - var archivedIssue = issue with { IsArchived = true, UpdatedAt = DateTime.UtcNow }; - var result = await _repository.UpdateAsync(archivedIssue); - - // Assert - result!.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2)); - result.UpdatedAt.Should().BeAfter(issue.UpdatedAt!.Value); - } - - [Fact] - public async Task ArchiveAsync_DoesNotDeleteRecord() - { - // Arrange - Create an issue - var issue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Issue to Archive", - Description: "Should still exist", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow); - - await _repository.CreateAsync(issue); - - // Act - Soft delete (archive) - var archivedIssue = issue with { IsArchived = true, UpdatedAt = DateTime.UtcNow }; - await _repository.UpdateAsync(archivedIssue); - - // Assert - Record still exists - var dbIssue = await _repository.GetByIdAsync(issue.Id); - dbIssue.Should().NotBeNull(); - dbIssue!.Id.Should().Be(issue.Id); - dbIssue.IsArchived.Should().BeTrue(); - } - - [Fact] - public async Task UpdateAsync_NonExistentIssue_ReturnsNull() - { - // Arrange - var nonExistentIssue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Non-existent", - Description: "Does not exist", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow); - - // Act - var result = await _repository.UpdateAsync(nonExistentIssue); - - // Assert - result.Should().BeNull(); - } - - [Fact] - public async Task GetAllAsync_OrdersByCreatedAtDescending() - { - // Arrange - Create issues with specific timestamps - var issue1 = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Oldest", - Description: "Created first", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddDays(-3)); - - var issue2 = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Middle", - Description: "Created second", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddDays(-2)); - - var issue3 = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Newest", - Description: "Created last", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddDays(-1)); - - await _repository.CreateAsync(issue1); - await _repository.CreateAsync(issue2); - await _repository.CreateAsync(issue3); - - // Act - var result = await _repository.GetAllAsync(page: 1, pageSize: 10, includeArchived: false); - - // Assert - result.Should().HaveCount(3); - result[0].Title.Should().Be("Newest"); // Newest first - result[1].Title.Should().Be("Middle"); - result[2].Title.Should().Be("Oldest"); // Oldest last - } - - [Fact] - public async Task GetAllAsync_EmptyDatabase_ReturnsEmptyList() - { - // Act - var result = await _repository.GetAllAsync(page: 1, pageSize: 20, includeArchived: false); - - // Assert - result.Should().BeEmpty(); - } +private const string MONGODB_IMAGE = "mongo:8.0"; +private const string TEST_DATABASE = "IssueManagerTestDb"; +private readonly MongoDbContainer _mongoContainer; + +private IIssueRepository _repository = null!; + +public IssueRepositoryTests() +{ +_mongoContainer = new MongoDbBuilder() +.WithImage(MONGODB_IMAGE) +.Build(); +} + +/// +/// Initializes the test container and repository. +/// +public async Task InitializeAsync() +{ +await _mongoContainer.StartAsync(); +var connectionString = _mongoContainer.GetConnectionString(); +_repository = new IssueRepository(connectionString, TEST_DATABASE); +} + +/// +/// Disposes the test container. +/// +public async Task DisposeAsync() +{ +await _mongoContainer.StopAsync(); +await _mongoContainer.DisposeAsync(); +} + +[Fact] +public async Task GetAllAsync_FirstPage_ReturnsCorrectItems() +{ +// Arrange - Create 50 issues +for (int i = 0; i < 50; i++) +{ +var issue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: $"Issue {i + 1}", +Description: $"Description {i + 1}", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow.AddMinutes(-i), +UpdatedAt: DateTime.UtcNow.AddMinutes(-i)); + +await _repository.CreateAsync(issue); +} + +// Act +var (items, total) = await _repository.GetAllAsync(page: 1, pageSize: 20); + +// Assert +items.Should().HaveCount(20); +total.Should().Be(50); +} + +[Fact] +public async Task GetAllAsync_SecondPage_ReturnsNextSetOfItems() +{ +// Arrange - Create 50 issues +for (int i = 0; i < 50; i++) +{ +var issue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: $"Issue {i + 1}", +Description: $"Description {i + 1}", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow.AddMinutes(-i), +UpdatedAt: DateTime.UtcNow.AddMinutes(-i)); + +await _repository.CreateAsync(issue); +} + +// Act +var (page1Items, _) = await _repository.GetAllAsync(page: 1, pageSize: 20); +var (page2Items, _) = await _repository.GetAllAsync(page: 2, pageSize: 20); + +// Assert +page2Items.Should().HaveCount(20); +page1Items.Select(i => i.Id).Should().NotIntersectWith(page2Items.Select(i => i.Id)); // No overlap +} + +[Fact] +public async Task GetAllAsync_ExcludesArchived_ByDefault() +{ +// Arrange - Create 10 issues, archive 3 +var issuesToArchive = new List(); +for (int i = 0; i < 10; i++) +{ +var issue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: $"Issue {i + 1}", +Description: $"Description {i + 1}", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow.AddMinutes(-i), +UpdatedAt: DateTime.UtcNow.AddMinutes(-i)); + +await _repository.CreateAsync(issue); +if (i < 3) +issuesToArchive.Add(issue.Id); +} + +foreach (var id in issuesToArchive) +{ +await _repository.ArchiveAsync(id); +} + +// Act +var (items, total) = await _repository.GetAllAsync(page: 1, pageSize: 20); + +// Assert +items.Should().HaveCount(7); // 10 - 3 archived = 7 +total.Should().Be(7); +items.Should().OnlyContain(i => !i.IsArchived); +} + +[Fact] +public async Task GetAllAsync_All_IncludesArchivedIssues() +{ +// Arrange - Create 10 issues, archive 3 +var issuesToArchive = new List(); +for (int i = 0; i < 10; i++) +{ +var issue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: $"Issue {i + 1}", +Description: $"Description {i + 1}", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow.AddMinutes(-i), +UpdatedAt: DateTime.UtcNow.AddMinutes(-i)); + +await _repository.CreateAsync(issue); +if (i < 3) +issuesToArchive.Add(issue.Id); +} + +foreach (var id in issuesToArchive) +{ +await _repository.ArchiveAsync(id); +} + +// Act — non-paginated GetAllAsync returns all records +var allIssues = await _repository.GetAllAsync(); + +// Assert +allIssues.Should().HaveCount(10); // All issues including archived +} + +[Fact] +public async Task CountAsync_ReturnsTotalIssueCount() +{ +// Arrange - Create 10 issues, archive 3 +var issuesToArchive = new List(); +for (int i = 0; i < 10; i++) +{ +var issue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: $"Issue {i + 1}", +Description: $"Description {i + 1}", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow.AddMinutes(-i), +UpdatedAt: DateTime.UtcNow.AddMinutes(-i)); + +await _repository.CreateAsync(issue); +if (i < 3) +issuesToArchive.Add(issue.Id); +} + +foreach (var id in issuesToArchive) +{ +await _repository.ArchiveAsync(id); +} + +// Act +var count = await _repository.CountAsync(); + +// Assert — CountAsync counts all issues regardless of archive status +count.Should().Be(10); +} + +[Fact] +public async Task ArchiveAsync_SetsIsArchivedToTrue() +{ +// Arrange - Create an issue +var issue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Issue to Archive", +Description: "Test", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow, +UpdatedAt: DateTime.UtcNow) +{ +IsArchived = false +}; + +await _repository.CreateAsync(issue); + +// Act +var result = await _repository.ArchiveAsync(issue.Id); + +// Assert +result.Should().BeTrue(); + +// Verify in database +var dbIssue = await _repository.GetByIdAsync(issue.Id); +dbIssue.Should().NotBeNull(); +dbIssue!.IsArchived.Should().BeTrue(); +} + +[Fact] +public async Task ArchiveAsync_UpdatesTimestamp() +{ +// Arrange - Create an issue +var issue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Issue to Archive", +Description: "Test", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow.AddDays(-1), +UpdatedAt: DateTime.UtcNow.AddHours(-2)); + +await _repository.CreateAsync(issue); + +// Act +await _repository.ArchiveAsync(issue.Id); + +// Assert +var dbIssue = await _repository.GetByIdAsync(issue.Id); +dbIssue.Should().NotBeNull(); +dbIssue!.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2)); +dbIssue.UpdatedAt.Should().BeAfter(issue.UpdatedAt); +} + +[Fact] +public async Task ArchiveAsync_DoesNotDeleteRecord() +{ +// Arrange - Create an issue +var issue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Issue to Archive", +Description: "Should still exist", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow, +UpdatedAt: DateTime.UtcNow); + +await _repository.CreateAsync(issue); + +// Act - Soft delete (archive) +await _repository.ArchiveAsync(issue.Id); + +// Assert - Record still exists +var dbIssue = await _repository.GetByIdAsync(issue.Id); +dbIssue.Should().NotBeNull(); +dbIssue!.Id.Should().Be(issue.Id); +dbIssue.IsArchived.Should().BeTrue(); +} + +[Fact] +public async Task UpdateAsync_NonExistentIssue_ReturnsNull() +{ +// Arrange +var nonExistentIssue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Non-existent", +Description: "Does not exist", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow, +UpdatedAt: DateTime.UtcNow); + +// Act +var result = await _repository.UpdateAsync(nonExistentIssue); + +// Assert +result.Should().BeNull(); +} + +[Fact] +public async Task GetAllAsync_OrdersByCreatedAtDescending() +{ +// Arrange - Create issues with specific timestamps +var issue1 = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Oldest", +Description: "Created first", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow.AddDays(-3), +UpdatedAt: DateTime.UtcNow.AddDays(-3)); + +var issue2 = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Middle", +Description: "Created second", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow.AddDays(-2), +UpdatedAt: DateTime.UtcNow.AddDays(-2)); + +var issue3 = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Newest", +Description: "Created last", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow.AddDays(-1), +UpdatedAt: DateTime.UtcNow.AddDays(-1)); + +await _repository.CreateAsync(issue1); +await _repository.CreateAsync(issue2); +await _repository.CreateAsync(issue3); + +// Act +var (items, _) = await _repository.GetAllAsync(page: 1, pageSize: 10); + +// Assert +items.Should().HaveCount(3); +items[0].Title.Should().Be("Newest"); // Newest first +items[1].Title.Should().Be("Middle"); +items[2].Title.Should().Be("Oldest"); // Oldest last +} + +[Fact] +public async Task GetAllAsync_EmptyDatabase_ReturnsEmptyList() +{ +// Act +var (items, total) = await _repository.GetAllAsync(page: 1, pageSize: 20); + +// Assert +items.Should().BeEmpty(); +total.Should().Be(0); +} } diff --git a/tests/Integration/Handlers/DeleteIssueHandlerIntegrationTests.cs b/tests/Integration/Handlers/DeleteIssueHandlerIntegrationTests.cs index 40b5779..234a80c 100644 --- a/tests/Integration/Handlers/DeleteIssueHandlerIntegrationTests.cs +++ b/tests/Integration/Handlers/DeleteIssueHandlerIntegrationTests.cs @@ -1,7 +1,9 @@ using FluentAssertions; using IssueManager.Api.Data; using IssueManager.Api.Handlers; -using IssueManager.Shared.Domain.Models; +using global::Shared.Domain; +using global::Shared.Exceptions; +using IssueManager.Shared.Validators; using Testcontainers.MongoDb; namespace IssueManager.Tests.Integration.Handlers; @@ -11,190 +13,194 @@ namespace IssueManager.Tests.Integration.Handlers; /// public class DeleteIssueHandlerIntegrationTests : IAsyncLifetime { - private const string MONGODB_IMAGE = "mongo:8.0"; - private const string TEST_DATABASE = "IssueManagerTestDb"; - private readonly MongoDbContainer _mongoContainer; - - private IIssueRepository _repository = null!; - private DeleteIssueHandler _handler = null!; - - public DeleteIssueHandlerIntegrationTests() - { - _mongoContainer = new MongoDbBuilder() - .WithImage(MONGODB_IMAGE) - .Build(); - } - - /// - /// Initializes the test container and repository. - /// - public async Task InitializeAsync() - { - await _mongoContainer.StartAsync(); - var connectionString = _mongoContainer.GetConnectionString(); - _repository = new IssueRepository(connectionString, TEST_DATABASE); - _handler = new DeleteIssueHandler(_repository); - } - - /// - /// Disposes the test container. - /// - public async Task DisposeAsync() - { - await _mongoContainer.StopAsync(); - await _mongoContainer.DisposeAsync(); - } - - [Fact] - public async Task Handle_ValidIssue_SetsIsArchivedInDatabase() - { - // Arrange - Create an issue - var issue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Issue to Delete", - Description: "This will be archived", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow) - { - IsArchived = false - }; - - await _repository.CreateAsync(issue); - - var command = new DeleteIssueCommand { Id = issue.Id }; - - // Act - await _handler.Handle(command, CancellationToken.None); - - // Assert - Verify IsArchived is set in database - var dbIssue = await _repository.GetByIdAsync(issue.Id); - dbIssue.Should().NotBeNull(); - dbIssue!.IsArchived.Should().BeTrue(); - dbIssue.UpdatedAt.Should().NotBeNull(); - dbIssue.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2)); - } - - [Fact] - public async Task Handle_ArchivedIssue_ExcludedFromListByDefault() - { - // Arrange - Create and archive an issue - var issue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Issue to Archive", - Description: "Test", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow); - - await _repository.CreateAsync(issue); - - var command = new DeleteIssueCommand { Id = issue.Id }; - - // Act - Archive the issue - await _handler.Handle(command, CancellationToken.None); - - // Assert - GetAll should exclude archived issues - var allIssues = await _repository.GetAllAsync(1, 100, includeArchived: false); - allIssues.Should().NotContain(i => i.Id == issue.Id); - } - - [Fact] - public async Task Handle_NonExistentIssue_ThrowsNotFoundException() - { - // Arrange - var nonExistentId = Guid.NewGuid().ToString(); - var command = new DeleteIssueCommand { Id = nonExistentId }; - - // Act - Func act = async () => await _handler.Handle(command, CancellationToken.None); - - // Assert - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Handle_IssueNotDeleted_RecordStillExists() - { - // Arrange - Create an issue - var issue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Issue to Archive", - Description: "Should still exist in DB", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow); - - await _repository.CreateAsync(issue); - - var command = new DeleteIssueCommand { Id = issue.Id }; - - // Act - Soft delete - await _handler.Handle(command, CancellationToken.None); - - // Assert - Record should still exist (soft delete) - var dbIssue = await _repository.GetByIdAsync(issue.Id); - dbIssue.Should().NotBeNull(); - dbIssue!.Id.Should().Be(issue.Id); - dbIssue.IsArchived.Should().BeTrue(); - } - - [Fact] - public async Task Handle_AlreadyArchivedIssue_IsIdempotent() - { - // Arrange - Create an already archived issue - var archivedIssue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Already Archived", - Description: "Already archived", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow) - { - IsArchived = true, - UpdatedAt = DateTime.UtcNow.AddHours(-1) - }; - - await _repository.CreateAsync(archivedIssue); - - var command = new DeleteIssueCommand { Id = archivedIssue.Id }; - - // Act - Delete already archived issue (should be idempotent) - await _handler.Handle(command, CancellationToken.None); - - // Assert - Should still be archived - var dbIssue = await _repository.GetByIdAsync(archivedIssue.Id); - dbIssue.Should().NotBeNull(); - dbIssue!.IsArchived.Should().BeTrue(); - } - - [Fact] - public async Task Handle_MultipleIssues_ArchivesOnlySpecifiedIssue() - { - // Arrange - Create multiple issues - var issue1 = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Issue 1", - Description: "To be archived", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow); - - var issue2 = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Issue 2", - Description: "Should remain active", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow); - - await _repository.CreateAsync(issue1); - await _repository.CreateAsync(issue2); - - var command = new DeleteIssueCommand { Id = issue1.Id }; - - // Act - await _handler.Handle(command, CancellationToken.None); - - // Assert - var dbIssue1 = await _repository.GetByIdAsync(issue1.Id); - var dbIssue2 = await _repository.GetByIdAsync(issue2.Id); - - dbIssue1!.IsArchived.Should().BeTrue(); - dbIssue2!.IsArchived.Should().BeFalse(); - } +private const string MONGODB_IMAGE = "mongo:8.0"; +private const string TEST_DATABASE = "IssueManagerTestDb"; +private readonly MongoDbContainer _mongoContainer; + +private IIssueRepository _repository = null!; +private DeleteIssueHandler _handler = null!; + +public DeleteIssueHandlerIntegrationTests() +{ +_mongoContainer = new MongoDbBuilder() +.WithImage(MONGODB_IMAGE) +.Build(); +} + +/// +/// Initializes the test container and repository. +/// +public async Task InitializeAsync() +{ +await _mongoContainer.StartAsync(); +var connectionString = _mongoContainer.GetConnectionString(); +_repository = new IssueRepository(connectionString, TEST_DATABASE); +_handler = new DeleteIssueHandler(_repository, new DeleteIssueValidator()); +} + +/// +/// Disposes the test container. +/// +public async Task DisposeAsync() +{ +await _mongoContainer.StopAsync(); +await _mongoContainer.DisposeAsync(); +} + +[Fact] +public async Task Handle_ValidIssue_SetsIsArchivedInDatabase() +{ +// Arrange - Create an issue +var issue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Issue to Delete", +Description: "This will be archived", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow, +UpdatedAt: DateTime.UtcNow) +{ +IsArchived = false +}; + +await _repository.CreateAsync(issue); + +var command = new DeleteIssueCommand { Id = issue.Id }; + +// Act +await _handler.Handle(command, CancellationToken.None); + +// Assert - Verify IsArchived is set in database +var dbIssue = await _repository.GetByIdAsync(issue.Id); +dbIssue.Should().NotBeNull(); +dbIssue!.IsArchived.Should().BeTrue(); +dbIssue.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2)); +} + +[Fact] +public async Task Handle_ArchivedIssue_ExcludedFromListByDefault() +{ +// Arrange - Create and archive an issue +var issue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Issue to Archive", +Description: "Test", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow, +UpdatedAt: DateTime.UtcNow); + +await _repository.CreateAsync(issue); + +var command = new DeleteIssueCommand { Id = issue.Id }; + +// Act - Archive the issue +await _handler.Handle(command, CancellationToken.None); + +// Assert - GetAll (paginated) should exclude archived issues +var (allIssues, _) = await _repository.GetAllAsync(1, 100); +allIssues.Should().NotContain(i => i.Id == issue.Id); +} + +[Fact] +public async Task Handle_NonExistentIssue_ThrowsNotFoundException() +{ +// Arrange +var nonExistentId = Guid.NewGuid().ToString(); +var command = new DeleteIssueCommand { Id = nonExistentId }; + +// Act +Func act = async () => await _handler.Handle(command, CancellationToken.None); + +// Assert +await act.Should().ThrowAsync(); +} + +[Fact] +public async Task Handle_IssueNotDeleted_RecordStillExists() +{ +// Arrange - Create an issue +var issue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Issue to Archive", +Description: "Should still exist in DB", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow, +UpdatedAt: DateTime.UtcNow); + +await _repository.CreateAsync(issue); + +var command = new DeleteIssueCommand { Id = issue.Id }; + +// Act - Soft delete +await _handler.Handle(command, CancellationToken.None); + +// Assert - Record should still exist (soft delete) +var dbIssue = await _repository.GetByIdAsync(issue.Id); +dbIssue.Should().NotBeNull(); +dbIssue!.Id.Should().Be(issue.Id); +dbIssue.IsArchived.Should().BeTrue(); +} + +[Fact] +public async Task Handle_AlreadyArchivedIssue_IsIdempotent() +{ +// Arrange - Create an already archived issue +var archivedIssue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Already Archived", +Description: "Already archived", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow, +UpdatedAt: DateTime.UtcNow.AddHours(-1)) +{ +IsArchived = true +}; + +await _repository.CreateAsync(archivedIssue); + +var command = new DeleteIssueCommand { Id = archivedIssue.Id }; + +// Act - Delete already archived issue (should be idempotent) +await _handler.Handle(command, CancellationToken.None); + +// Assert - Should still be archived +var dbIssue = await _repository.GetByIdAsync(archivedIssue.Id); +dbIssue.Should().NotBeNull(); +dbIssue!.IsArchived.Should().BeTrue(); +} + +[Fact] +public async Task Handle_MultipleIssues_ArchivesOnlySpecifiedIssue() +{ +// Arrange - Create multiple issues +var issue1 = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Issue 1", +Description: "To be archived", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow, +UpdatedAt: DateTime.UtcNow); + +var issue2 = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Issue 2", +Description: "Should remain active", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow, +UpdatedAt: DateTime.UtcNow); + +await _repository.CreateAsync(issue1); +await _repository.CreateAsync(issue2); + +var command = new DeleteIssueCommand { Id = issue1.Id }; + +// Act +await _handler.Handle(command, CancellationToken.None); + +// Assert +var dbIssue1 = await _repository.GetByIdAsync(issue1.Id); +var dbIssue2 = await _repository.GetByIdAsync(issue2.Id); + +dbIssue1!.IsArchived.Should().BeTrue(); +dbIssue2!.IsArchived.Should().BeFalse(); +} } diff --git a/tests/Integration/Handlers/DeleteIssueHandlerTests.cs b/tests/Integration/Handlers/DeleteIssueHandlerTests.cs index e462553..71d9027 100644 --- a/tests/Integration/Handlers/DeleteIssueHandlerTests.cs +++ b/tests/Integration/Handlers/DeleteIssueHandlerTests.cs @@ -48,15 +48,13 @@ public async Task ArchiveAsync_ExistingUnarchivedIssue_ReturnsTrue() await _repository.CreateAsync(issue); // Act - var result = await _repository.ArchiveAsync(issue.Id, "testuser"); + var result = await _repository.ArchiveAsync(issue.Id); // Assert result.Should().BeTrue(); var retrieved = await _repository.GetByIdAsync(issue.Id); retrieved!.IsArchived.Should().BeTrue(); - retrieved.ArchivedBy.Should().Be("testuser"); - retrieved.ArchivedAt.Should().NotBeNull(); } [Fact] @@ -65,10 +63,10 @@ public async Task ArchiveAsync_AlreadyArchivedIssue_ReturnsTrueIdempotent() // Arrange var issue = Issue.Create("Already Archived Issue", "Description"); await _repository.CreateAsync(issue); - await _repository.ArchiveAsync(issue.Id, "firstuser"); + await _repository.ArchiveAsync(issue.Id); - // Act - archive again (already archived, ModifiedCount will be 0) - var result = await _repository.ArchiveAsync(issue.Id, "seconduser"); + // Act - archive again (already archived, MatchedCount still > 0) + var result = await _repository.ArchiveAsync(issue.Id); // Assert - should return true (issue was found), not false (issue not found) result.Should().BeTrue(); @@ -78,7 +76,7 @@ public async Task ArchiveAsync_AlreadyArchivedIssue_ReturnsTrueIdempotent() public async Task ArchiveAsync_NonExistentIssue_ReturnsFalse() { // Act - var result = await _repository.ArchiveAsync("non-existent-id", "testuser"); + var result = await _repository.ArchiveAsync("non-existent-id"); // Assert result.Should().BeFalse(); diff --git a/tests/Integration/Handlers/ListIssuesHandlerIntegrationTests.cs b/tests/Integration/Handlers/ListIssuesHandlerIntegrationTests.cs index 262975d..0d762b5 100644 --- a/tests/Integration/Handlers/ListIssuesHandlerIntegrationTests.cs +++ b/tests/Integration/Handlers/ListIssuesHandlerIntegrationTests.cs @@ -1,7 +1,8 @@ using FluentAssertions; using IssueManager.Api.Data; using IssueManager.Api.Handlers; -using IssueManager.Shared.Domain.Models; +using global::Shared.Domain; +using IssueManager.Shared.Validators; using Testcontainers.MongoDb; namespace IssueManager.Tests.Integration.Handlers; @@ -11,275 +12,289 @@ namespace IssueManager.Tests.Integration.Handlers; /// public class ListIssuesHandlerIntegrationTests : IAsyncLifetime { - private const string MONGODB_IMAGE = "mongo:8.0"; - private const string TEST_DATABASE = "IssueManagerTestDb"; - private readonly MongoDbContainer _mongoContainer; - - private IIssueRepository _repository = null!; - private ListIssuesHandler _handler = null!; - - public ListIssuesHandlerIntegrationTests() - { - _mongoContainer = new MongoDbBuilder() - .WithImage(MONGODB_IMAGE) - .Build(); - } - - /// - /// Initializes the test container and repository. - /// - public async Task InitializeAsync() - { - await _mongoContainer.StartAsync(); - var connectionString = _mongoContainer.GetConnectionString(); - _repository = new IssueRepository(connectionString, TEST_DATABASE); - _handler = new ListIssuesHandler(_repository); - } - - /// - /// Disposes the test container. - /// - public async Task DisposeAsync() - { - await _mongoContainer.StopAsync(); - await _mongoContainer.DisposeAsync(); - } - - [Fact] - public async Task Handle_WithPagination_ReturnsCorrectPage() - { - // Arrange - Create 50 issues - for (int i = 0; i < 50; i++) - { - var issue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: $"Issue {i + 1}", - Description: $"Description {i + 1}", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddMinutes(-i)); - - await _repository.CreateAsync(issue); - } - - var query = new ListIssuesQuery { Page = 1, PageSize = 20 }; - - // Act - var result = await _handler.Handle(query, CancellationToken.None); - - // Assert - result.Items.Should().HaveCount(20); - result.Page.Should().Be(1); - result.PageSize.Should().Be(20); - result.TotalCount.Should().Be(50); - result.TotalPages.Should().Be(3); // 50 / 20 = 2.5 → 3 pages - } - - [Fact] - public async Task Handle_SecondPage_ReturnsNextSetOfItems() - { - // Arrange - Create 50 issues - for (int i = 0; i < 50; i++) - { - var issue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: $"Issue {i + 1}", - Description: $"Description {i + 1}", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddMinutes(-i)); - - await _repository.CreateAsync(issue); - } - - var query = new ListIssuesQuery { Page = 2, PageSize = 20 }; - - // Act - var result = await _handler.Handle(query, CancellationToken.None); - - // Assert - result.Items.Should().HaveCount(20); - result.Page.Should().Be(2); - result.TotalCount.Should().Be(50); - } - - [Fact] - public async Task Handle_ExcludesArchivedIssues() - { - // Arrange - Create 10 issues, archive 3 - for (int i = 0; i < 10; i++) - { - var issue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: $"Issue {i + 1}", - Description: $"Description {i + 1}", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddMinutes(-i)) - { - IsArchived = i < 3 // Archive first 3 issues - }; - - await _repository.CreateAsync(issue); - } - - var query = new ListIssuesQuery { Page = 1, PageSize = 20 }; - - // Act - var result = await _handler.Handle(query, CancellationToken.None); - - // Assert - result.TotalCount.Should().Be(7); // 10 - 3 archived = 7 - result.Items.Should().HaveCount(7); - result.Items.Should().OnlyContain(i => !i.IsArchived); - } - - [Fact] - public async Task Handle_OrdersByCreatedAtDescending() - { - // Arrange - Create issues with specific timestamps - var issue1 = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Oldest Issue", - Description: "Created first", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddDays(-3)); - - var issue2 = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Middle Issue", - Description: "Created second", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddDays(-2)); - - var issue3 = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Newest Issue", - Description: "Created last", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddDays(-1)); - - await _repository.CreateAsync(issue1); - await _repository.CreateAsync(issue2); - await _repository.CreateAsync(issue3); - - var query = new ListIssuesQuery { Page = 1, PageSize = 10 }; - - // Act - var result = await _handler.Handle(query, CancellationToken.None); - - // Assert - result.Items.Should().HaveCount(3); - result.Items[0].Title.Should().Be("Newest Issue"); // Newest first - result.Items[1].Title.Should().Be("Middle Issue"); - result.Items[2].Title.Should().Be("Oldest Issue"); // Oldest last - } - - [Fact] - public async Task Handle_EmptyDatabase_ReturnsEmptyList() - { - // Arrange - No issues in database - var query = new ListIssuesQuery { Page = 1, PageSize = 20 }; - - // Act - var result = await _handler.Handle(query, CancellationToken.None); - - // Assert - result.Items.Should().BeEmpty(); - result.TotalCount.Should().Be(0); - result.TotalPages.Should().Be(0); - } - - [Fact] - public async Task Handle_LastPagePartial_ReturnsRemainingItems() - { - // Arrange - Create 42 issues - for (int i = 0; i < 42; i++) - { - var issue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: $"Issue {i + 1}", - Description: $"Description {i + 1}", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddMinutes(-i)); - - await _repository.CreateAsync(issue); - } - - var query = new ListIssuesQuery { Page = 3, PageSize = 20 }; - - // Act - var result = await _handler.Handle(query, CancellationToken.None); - - // Assert - result.Items.Should().HaveCount(2); // 42 - 40 = 2 on last page - result.Page.Should().Be(3); - result.TotalCount.Should().Be(42); - result.TotalPages.Should().Be(3); - } - - [Fact] - public async Task Handle_LargeDataset_PerformanceUnder1Second() - { - // Arrange - Create 1000 issues - for (int i = 0; i < 1000; i++) - { - var issue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: $"Issue {i + 1}", - Description: $"Description {i + 1}", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddMinutes(-i)); - - await _repository.CreateAsync(issue); - } - - var query = new ListIssuesQuery { Page = 1, PageSize = 20 }; - - // Act - var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - var result = await _handler.Handle(query, CancellationToken.None); - stopwatch.Stop(); - - // Assert - result.Items.Should().HaveCount(20); - result.TotalCount.Should().Be(1000); - stopwatch.ElapsedMilliseconds.Should().BeLessThan(1000); // < 1 second - } - - [Fact] - public async Task Handle_ConcurrentCreates_ReturnsConsistentResults() - { - // Arrange - Create 20 issues - for (int i = 0; i < 20; i++) - { - var issue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: $"Issue {i + 1}", - Description: $"Description {i + 1}", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddMinutes(-i)); - - await _repository.CreateAsync(issue); - } - - var query = new ListIssuesQuery { Page = 1, PageSize = 20 }; - - // Act - List while creating new issue - var listTask = _handler.Handle(query, CancellationToken.None); - - var newIssue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Concurrent Issue", - Description: "Created during list", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow); - - var createTask = _repository.CreateAsync(newIssue); - - await Task.WhenAll(listTask, createTask); - - var result = await listTask; - - // Assert - List should be consistent (snapshot isolation) - result.Items.Should().HaveCount(20); - result.TotalCount.Should().BeOneOf(20, 21); // Either before or after concurrent create - } +private const string MONGODB_IMAGE = "mongo:8.0"; +private const string TEST_DATABASE = "IssueManagerTestDb"; +private readonly MongoDbContainer _mongoContainer; + +private IIssueRepository _repository = null!; +private ListIssuesHandler _handler = null!; + +public ListIssuesHandlerIntegrationTests() +{ +_mongoContainer = new MongoDbBuilder() +.WithImage(MONGODB_IMAGE) +.Build(); +} + +/// +/// Initializes the test container and repository. +/// +public async Task InitializeAsync() +{ +await _mongoContainer.StartAsync(); +var connectionString = _mongoContainer.GetConnectionString(); +_repository = new IssueRepository(connectionString, TEST_DATABASE); +_handler = new ListIssuesHandler(_repository, new ListIssuesQueryValidator()); +} + +/// +/// Disposes the test container. +/// +public async Task DisposeAsync() +{ +await _mongoContainer.StopAsync(); +await _mongoContainer.DisposeAsync(); +} + +[Fact] +public async Task Handle_WithPagination_ReturnsCorrectPage() +{ +// Arrange - Create 50 issues +for (int i = 0; i < 50; i++) +{ +var issue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: $"Issue {i + 1}", +Description: $"Description {i + 1}", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow.AddMinutes(-i), +UpdatedAt: DateTime.UtcNow.AddMinutes(-i)); + +await _repository.CreateAsync(issue); +} + +var query = new ListIssuesQuery { Page = 1, PageSize = 20 }; + +// Act +var result = await _handler.Handle(query, CancellationToken.None); + +// Assert +result.Items.Should().HaveCount(20); +result.Page.Should().Be(1); +result.PageSize.Should().Be(20); +result.Total.Should().Be(50); +result.TotalPages.Should().Be(3); // 50 / 20 = 2.5 → 3 pages +} + +[Fact] +public async Task Handle_SecondPage_ReturnsNextSetOfItems() +{ +// Arrange - Create 50 issues +for (int i = 0; i < 50; i++) +{ +var issue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: $"Issue {i + 1}", +Description: $"Description {i + 1}", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow.AddMinutes(-i), +UpdatedAt: DateTime.UtcNow.AddMinutes(-i)); + +await _repository.CreateAsync(issue); +} + +var query = new ListIssuesQuery { Page = 2, PageSize = 20 }; + +// Act +var result = await _handler.Handle(query, CancellationToken.None); + +// Assert +result.Items.Should().HaveCount(20); +result.Page.Should().Be(2); +result.Total.Should().Be(50); +} + +[Fact] +public async Task Handle_ExcludesArchivedIssues() +{ +// Arrange - Create 10 issues, archive 3 +var issuesToArchive = new List(); +for (int i = 0; i < 10; i++) +{ +var issue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: $"Issue {i + 1}", +Description: $"Description {i + 1}", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow.AddMinutes(-i), +UpdatedAt: DateTime.UtcNow.AddMinutes(-i)); + +await _repository.CreateAsync(issue); +if (i < 3) +issuesToArchive.Add(issue.Id); +} + +foreach (var id in issuesToArchive) +{ +await _repository.ArchiveAsync(id); +} + +var query = new ListIssuesQuery { Page = 1, PageSize = 20 }; + +// Act +var result = await _handler.Handle(query, CancellationToken.None); + +// Assert +result.Total.Should().Be(7); // 10 - 3 archived = 7 +result.Items.Should().HaveCount(7); +} + +[Fact] +public async Task Handle_OrdersByCreatedAtDescending() +{ +// Arrange - Create issues with specific timestamps +var issue1 = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Oldest Issue", +Description: "Created first", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow.AddDays(-3), +UpdatedAt: DateTime.UtcNow.AddDays(-3)); + +var issue2 = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Middle Issue", +Description: "Created second", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow.AddDays(-2), +UpdatedAt: DateTime.UtcNow.AddDays(-2)); + +var issue3 = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Newest Issue", +Description: "Created last", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow.AddDays(-1), +UpdatedAt: DateTime.UtcNow.AddDays(-1)); + +await _repository.CreateAsync(issue1); +await _repository.CreateAsync(issue2); +await _repository.CreateAsync(issue3); + +var query = new ListIssuesQuery { Page = 1, PageSize = 10 }; + +// Act +var result = await _handler.Handle(query, CancellationToken.None); + +// Assert +result.Items.Should().HaveCount(3); +result.Items[0].Title.Should().Be("Newest Issue"); // Newest first +result.Items[1].Title.Should().Be("Middle Issue"); +result.Items[2].Title.Should().Be("Oldest Issue"); // Oldest last +} + +[Fact] +public async Task Handle_EmptyDatabase_ReturnsEmptyList() +{ +// Arrange - No issues in database +var query = new ListIssuesQuery { Page = 1, PageSize = 20 }; + +// Act +var result = await _handler.Handle(query, CancellationToken.None); + +// Assert +result.Items.Should().BeEmpty(); +result.Total.Should().Be(0); +result.TotalPages.Should().Be(0); +} + +[Fact] +public async Task Handle_LastPagePartial_ReturnsRemainingItems() +{ +// Arrange - Create 42 issues +for (int i = 0; i < 42; i++) +{ +var issue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: $"Issue {i + 1}", +Description: $"Description {i + 1}", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow.AddMinutes(-i), +UpdatedAt: DateTime.UtcNow.AddMinutes(-i)); + +await _repository.CreateAsync(issue); +} + +var query = new ListIssuesQuery { Page = 3, PageSize = 20 }; + +// Act +var result = await _handler.Handle(query, CancellationToken.None); + +// Assert +result.Items.Should().HaveCount(2); // 42 - 40 = 2 on last page +result.Page.Should().Be(3); +result.Total.Should().Be(42); +result.TotalPages.Should().Be(3); +} + +[Fact] +public async Task Handle_LargeDataset_PerformanceUnder1Second() +{ +// Arrange - Create 1000 issues +for (int i = 0; i < 1000; i++) +{ +var issue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: $"Issue {i + 1}", +Description: $"Description {i + 1}", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow.AddMinutes(-i), +UpdatedAt: DateTime.UtcNow.AddMinutes(-i)); + +await _repository.CreateAsync(issue); +} + +var query = new ListIssuesQuery { Page = 1, PageSize = 20 }; + +// Act +var stopwatch = System.Diagnostics.Stopwatch.StartNew(); +var result = await _handler.Handle(query, CancellationToken.None); +stopwatch.Stop(); + +// Assert +result.Items.Should().HaveCount(20); +result.Total.Should().Be(1000); +stopwatch.ElapsedMilliseconds.Should().BeLessThan(1000); // < 1 second +} + +[Fact] +public async Task Handle_ConcurrentCreates_ReturnsConsistentResults() +{ +// Arrange - Create 20 issues +for (int i = 0; i < 20; i++) +{ +var issue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: $"Issue {i + 1}", +Description: $"Description {i + 1}", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow.AddMinutes(-i), +UpdatedAt: DateTime.UtcNow.AddMinutes(-i)); + +await _repository.CreateAsync(issue); +} + +var query = new ListIssuesQuery { Page = 1, PageSize = 20 }; + +// Act - List while creating new issue +var listTask = _handler.Handle(query, CancellationToken.None); + +var newIssue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Concurrent Issue", +Description: "Created during list", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow, +UpdatedAt: DateTime.UtcNow); + +var createTask = _repository.CreateAsync(newIssue); + +await Task.WhenAll(listTask, createTask); + +var result = await listTask; + +// Assert - List should be consistent (snapshot isolation) +result.Items.Should().HaveCount(20); +result.Total.Should().BeOneOf(20, 21); // Either before or after concurrent create +} } diff --git a/tests/Integration/Handlers/UpdateIssueHandlerIntegrationTests.cs b/tests/Integration/Handlers/UpdateIssueHandlerIntegrationTests.cs index ea1f106..11cd50d 100644 --- a/tests/Integration/Handlers/UpdateIssueHandlerIntegrationTests.cs +++ b/tests/Integration/Handlers/UpdateIssueHandlerIntegrationTests.cs @@ -1,7 +1,8 @@ using FluentAssertions; using IssueManager.Api.Data; using IssueManager.Api.Handlers; -using IssueManager.Shared.Domain.Models; +using global::Shared.Domain; +using global::Shared.Exceptions; using IssueManager.Shared.Validators; using Testcontainers.MongoDb; @@ -12,235 +13,234 @@ namespace IssueManager.Tests.Integration.Handlers; /// public class UpdateIssueHandlerIntegrationTests : IAsyncLifetime { - private const string MONGODB_IMAGE = "mongo:8.0"; - private const string TEST_DATABASE = "IssueManagerTestDb"; - private readonly MongoDbContainer _mongoContainer; - - private IIssueRepository _repository = null!; - private UpdateIssueHandler _handler = null!; - - public UpdateIssueHandlerIntegrationTests() - { - _mongoContainer = new MongoDbBuilder() - .WithImage(MONGODB_IMAGE) - .Build(); - } - - /// - /// Initializes the test container and repository. - /// - public async Task InitializeAsync() - { - await _mongoContainer.StartAsync(); - var connectionString = _mongoContainer.GetConnectionString(); - _repository = new IssueRepository(connectionString, TEST_DATABASE); - _handler = new UpdateIssueHandler(_repository, new UpdateIssueValidator()); - } - - /// - /// Disposes the test container. - /// - public async Task DisposeAsync() - { - await _mongoContainer.StopAsync(); - await _mongoContainer.DisposeAsync(); - } - - [Fact] - public async Task Handle_ValidUpdate_UpdatesIssueInDatabase() - { - // Arrange - Create an issue first - var originalIssue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Original Title", - Description: "Original Description", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow); - - await _repository.CreateAsync(originalIssue); - - var command = new UpdateIssueCommand - { - Id = originalIssue.Id, - Title = "Updated Title", - Description = "Updated Description" - }; - - // Act - var result = await _handler.Handle(command, CancellationToken.None); - - // Assert - result.Should().NotBeNull(); - result.Id.Should().Be(originalIssue.Id); - result.Title.Should().Be("Updated Title"); - result.Description.Should().Be("Updated Description"); - result.UpdatedAt.Should().NotBeNull(); - result.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2)); - - // Verify in database - var dbIssue = await _repository.GetByIdAsync(originalIssue.Id); - dbIssue.Should().NotBeNull(); - dbIssue!.Title.Should().Be("Updated Title"); - dbIssue.Description.Should().Be("Updated Description"); - dbIssue.UpdatedAt.Should().NotBeNull(); - } - - [Fact] - public async Task Handle_UpdateTimestamp_SetsToCurrentTime() - { - // Arrange - var originalIssue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Original Title", - Description: "Original Description", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddDays(-1)) - { - UpdatedAt = DateTime.UtcNow.AddHours(-5) - }; - - await _repository.CreateAsync(originalIssue); - - var command = new UpdateIssueCommand - { - Id = originalIssue.Id, - Title = "New Title", - Description = "New Description" - }; - - // Act - var result = await _handler.Handle(command, CancellationToken.None); - - // Assert - result.UpdatedAt.Should().NotBeNull(); - result.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2)); - result.UpdatedAt.Should().BeAfter(originalIssue.UpdatedAt!.Value); - - // Verify in database - var dbIssue = await _repository.GetByIdAsync(originalIssue.Id); - dbIssue!.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2)); - } - - [Fact] - public async Task Handle_AtomicUpdate_TitleAndDescriptionBothUpdate() - { - // Arrange - var originalIssue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Original Title", - Description: "Original Description", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow); - - await _repository.CreateAsync(originalIssue); - - var command = new UpdateIssueCommand - { - Id = originalIssue.Id, - Title = "New Title", - Description = "New Description" - }; - - // Act - var result = await _handler.Handle(command, CancellationToken.None); - - // Assert - Both fields should be updated atomically - var dbIssue = await _repository.GetByIdAsync(originalIssue.Id); - dbIssue.Should().NotBeNull(); - dbIssue!.Title.Should().Be("New Title"); - dbIssue.Description.Should().Be("New Description"); - } - - [Fact] - public async Task Handle_NonExistentIssue_ThrowsNotFoundException() - { - // Arrange - var nonExistentId = Guid.NewGuid().ToString(); - var command = new UpdateIssueCommand - { - Id = nonExistentId, - Title = "Title", - Description = "Description" - }; - - // Act - Func act = async () => await _handler.Handle(command, CancellationToken.None); - - // Assert - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Handle_ConcurrentUpdates_LastWriteWins() - { - // Arrange - Create an issue - var issue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Original Title", - Description: "Original Description", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow); - - await _repository.CreateAsync(issue); - - var command1 = new UpdateIssueCommand - { - Id = issue.Id, - Title = "First Update", - Description = "First Description" - }; - - var command2 = new UpdateIssueCommand - { - Id = issue.Id, - Title = "Second Update", - Description = "Second Description" - }; - - // Act - Simulate concurrent updates - var result1 = await _handler.Handle(command1, CancellationToken.None); - await Task.Delay(100); // Small delay to ensure different timestamp - var result2 = await _handler.Handle(command2, CancellationToken.None); - - // Assert - Last write wins - var dbIssue = await _repository.GetByIdAsync(issue.Id); - dbIssue.Should().NotBeNull(); - dbIssue!.Title.Should().Be("Second Update"); - dbIssue.Description.Should().Be("Second Description"); - dbIssue.UpdatedAt.Should().BeAfter(result1.UpdatedAt!.Value); - } - - [Fact] - public async Task Handle_ArchivedIssue_ThrowsConflictException() - { - // Arrange - Create and archive an issue - var archivedIssue = new Issue( - Id: Guid.NewGuid().ToString(), - Title: "Archived Issue", - Description: "This is archived", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow) - { - IsArchived = true - }; - - await _repository.CreateAsync(archivedIssue); - - var command = new UpdateIssueCommand - { - Id = archivedIssue.Id, - Title = "Attempt Update", - Description = "Should fail" - }; - - // Act - Func act = async () => await _handler.Handle(command, CancellationToken.None); - - // Assert - await act.Should().ThrowAsync(); - - // Verify issue wasn't updated - var dbIssue = await _repository.GetByIdAsync(archivedIssue.Id); - dbIssue!.Title.Should().Be("Archived Issue"); - } +private const string MONGODB_IMAGE = "mongo:8.0"; +private const string TEST_DATABASE = "IssueManagerTestDb"; +private readonly MongoDbContainer _mongoContainer; + +private IIssueRepository _repository = null!; +private UpdateIssueHandler _handler = null!; + +public UpdateIssueHandlerIntegrationTests() +{ +_mongoContainer = new MongoDbBuilder() +.WithImage(MONGODB_IMAGE) +.Build(); +} + +/// +/// Initializes the test container and repository. +/// +public async Task InitializeAsync() +{ +await _mongoContainer.StartAsync(); +var connectionString = _mongoContainer.GetConnectionString(); +_repository = new IssueRepository(connectionString, TEST_DATABASE); +_handler = new UpdateIssueHandler(_repository, new UpdateIssueValidator()); +} + +/// +/// Disposes the test container. +/// +public async Task DisposeAsync() +{ +await _mongoContainer.StopAsync(); +await _mongoContainer.DisposeAsync(); +} + +[Fact] +public async Task Handle_ValidUpdate_UpdatesIssueInDatabase() +{ +// Arrange - Create an issue first +var originalIssue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Original Title", +Description: "Original Description", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow, +UpdatedAt: DateTime.UtcNow); + +await _repository.CreateAsync(originalIssue); + +var command = new UpdateIssueCommand +{ +Id = originalIssue.Id, +Title = "Updated Title", +Description = "Updated Description" +}; + +// Act +var result = await _handler.Handle(command, CancellationToken.None); + +// Assert +result.Should().NotBeNull(); +result.Id.Should().Be(originalIssue.Id); +result.Title.Should().Be("Updated Title"); +result.Description.Should().Be("Updated Description"); +result.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2)); + +// Verify in database +var dbIssue = await _repository.GetByIdAsync(originalIssue.Id); +dbIssue.Should().NotBeNull(); +dbIssue!.Title.Should().Be("Updated Title"); +dbIssue.Description.Should().Be("Updated Description"); +} + +[Fact] +public async Task Handle_UpdateTimestamp_SetsToCurrentTime() +{ +// Arrange +var originalIssue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Original Title", +Description: "Original Description", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow.AddDays(-1), +UpdatedAt: DateTime.UtcNow.AddHours(-5)); + +await _repository.CreateAsync(originalIssue); + +var command = new UpdateIssueCommand +{ +Id = originalIssue.Id, +Title = "New Title", +Description = "New Description" +}; + +// Act +var result = await _handler.Handle(command, CancellationToken.None); + +// Assert +result.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2)); +result.UpdatedAt.Should().BeAfter(originalIssue.UpdatedAt); + +// Verify in database +var dbIssue = await _repository.GetByIdAsync(originalIssue.Id); +dbIssue!.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2)); +} + +[Fact] +public async Task Handle_AtomicUpdate_TitleAndDescriptionBothUpdate() +{ +// Arrange +var originalIssue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Original Title", +Description: "Original Description", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow, +UpdatedAt: DateTime.UtcNow); + +await _repository.CreateAsync(originalIssue); + +var command = new UpdateIssueCommand +{ +Id = originalIssue.Id, +Title = "New Title", +Description = "New Description" +}; + +// Act +var result = await _handler.Handle(command, CancellationToken.None); + +// Assert - Both fields should be updated atomically +var dbIssue = await _repository.GetByIdAsync(originalIssue.Id); +dbIssue.Should().NotBeNull(); +dbIssue!.Title.Should().Be("New Title"); +dbIssue.Description.Should().Be("New Description"); +} + +[Fact] +public async Task Handle_NonExistentIssue_ThrowsNotFoundException() +{ +// Arrange +var nonExistentId = Guid.NewGuid().ToString(); +var command = new UpdateIssueCommand +{ +Id = nonExistentId, +Title = "Title", +Description = "Description" +}; + +// Act +Func act = async () => await _handler.Handle(command, CancellationToken.None); + +// Assert +await act.Should().ThrowAsync(); +} + +[Fact] +public async Task Handle_ConcurrentUpdates_LastWriteWins() +{ +// Arrange - Create an issue +var issue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Original Title", +Description: "Original Description", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow, +UpdatedAt: DateTime.UtcNow); + +await _repository.CreateAsync(issue); + +var command1 = new UpdateIssueCommand +{ +Id = issue.Id, +Title = "First Update", +Description = "First Description" +}; + +var command2 = new UpdateIssueCommand +{ +Id = issue.Id, +Title = "Second Update", +Description = "Second Description" +}; + +// Act - Simulate concurrent updates +var result1 = await _handler.Handle(command1, CancellationToken.None); +await Task.Delay(100); // Small delay to ensure different timestamp +var result2 = await _handler.Handle(command2, CancellationToken.None); + +// Assert - Last write wins +var dbIssue = await _repository.GetByIdAsync(issue.Id); +dbIssue.Should().NotBeNull(); +dbIssue!.Title.Should().Be("Second Update"); +dbIssue.Description.Should().Be("Second Description"); +dbIssue.UpdatedAt.Should().BeAfter(result1.UpdatedAt); +} + +[Fact] +public async Task Handle_ArchivedIssue_ThrowsConflictException() +{ +// Arrange - Create and archive an issue +var archivedIssue = new Issue( +Id: Guid.NewGuid().ToString(), +Title: "Archived Issue", +Description: "This is archived", +Status: IssueStatus.Open, +CreatedAt: DateTime.UtcNow, +UpdatedAt: DateTime.UtcNow) +{ +IsArchived = true +}; + +await _repository.CreateAsync(archivedIssue); + +var command = new UpdateIssueCommand +{ +Id = archivedIssue.Id, +Title = "Attempt Update", +Description = "Should fail" +}; + +// Act +Func act = async () => await _handler.Handle(command, CancellationToken.None); + +// Assert +await act.Should().ThrowAsync(); + +// Verify issue wasn't updated +var dbIssue = await _repository.GetByIdAsync(archivedIssue.Id); +dbIssue!.Title.Should().Be("Archived Issue"); +} } diff --git a/tests/Unit/Builders/IssueBuilder.cs b/tests/Unit/Builders/IssueBuilder.cs index 87ce512..ca79034 100644 --- a/tests/Unit/Builders/IssueBuilder.cs +++ b/tests/Unit/Builders/IssueBuilder.cs @@ -1,4 +1,4 @@ -using IssueManager.Shared.Domain.Models; +using global::Shared.Domain; namespace IssueManager.Tests.Unit.Builders; @@ -10,14 +10,10 @@ public class IssueBuilder private string _id = Guid.NewGuid().ToString(); private string _title = "Default Test Issue"; private string _description = "Default test description"; - private string _authorId = "test-user-123"; + private IssueStatus _status = IssueStatus.Open; private DateTime _createdAt = DateTime.UtcNow; - private DateTime? _updatedAt; + private DateTime _updatedAt = DateTime.UtcNow; private bool _isArchived; - private string? _categoryId; - private string? _statusId; - private bool _approvedForRelease; - private bool _rejected; /// /// Sets the issue ID. @@ -47,11 +43,11 @@ public IssueBuilder WithDescription(string description) } /// - /// Sets the author ID. + /// Sets the issue status. /// - public IssueBuilder WithAuthorId(string authorId) + public IssueBuilder WithStatus(IssueStatus status) { - _authorId = authorId; + _status = status; return this; } @@ -67,7 +63,7 @@ public IssueBuilder WithCreatedAt(DateTime createdAt) /// /// Sets the updated at timestamp. /// - public IssueBuilder WithUpdatedAt(DateTime? updatedAt) + public IssueBuilder WithUpdatedAt(DateTime updatedAt) { _updatedAt = updatedAt; return this; @@ -91,42 +87,6 @@ public IssueBuilder AsActive() return this; } - /// - /// Sets the category ID. - /// - public IssueBuilder WithCategoryId(string? categoryId) - { - _categoryId = categoryId; - return this; - } - - /// - /// Sets the status ID. - /// - public IssueBuilder WithStatusId(string? statusId) - { - _statusId = statusId; - return this; - } - - /// - /// Marks the issue as approved for release. - /// - public IssueBuilder AsApprovedForRelease() - { - _approvedForRelease = true; - return this; - } - - /// - /// Marks the issue as rejected. - /// - public IssueBuilder AsRejected() - { - _rejected = true; - return this; - } - /// /// Builds the Issue instance. /// @@ -136,15 +96,11 @@ public Issue Build() Id: _id, Title: _title, Description: _description, - AuthorId: _authorId, - CreatedAt: _createdAt) + Status: _status, + CreatedAt: _createdAt, + UpdatedAt: _updatedAt) { - UpdatedAt = _updatedAt, - IsArchived = _isArchived, - CategoryId = _categoryId, - StatusId = _statusId, - ApprovedForRelease = _approvedForRelease, - Rejected = _rejected + IsArchived = _isArchived }; } @@ -159,7 +115,7 @@ public Issue Build() public static IssueBuilder Archived() => new IssueBuilder().AsArchived(); /// - /// Creates an issue builder with a specific title. + /// Creates a closed issue builder. /// - public static IssueBuilder WithTitle(string title) => new IssueBuilder().WithTitle(title); + public static IssueBuilder Closed() => new IssueBuilder().WithStatus(IssueStatus.Closed); } diff --git a/tests/Unit/Handlers/DeleteIssueHandlerTests.cs b/tests/Unit/Handlers/DeleteIssueHandlerTests.cs index 1e59eb7..739d96e 100644 --- a/tests/Unit/Handlers/DeleteIssueHandlerTests.cs +++ b/tests/Unit/Handlers/DeleteIssueHandlerTests.cs @@ -1,7 +1,10 @@ using FluentAssertions; +using FluentValidation; using IssueManager.Api.Data; using IssueManager.Api.Handlers; -using IssueManager.Shared.Domain.Models; +using global::Shared.Domain; +using global::Shared.Exceptions; +using IssueManager.Shared.Validators; using NSubstitute; namespace IssueManager.Tests.Unit.Handlers; @@ -17,7 +20,7 @@ public class DeleteIssueHandlerTests public DeleteIssueHandlerTests() { _repository = Substitute.For(); - _handler = new DeleteIssueHandler(_repository); + _handler = new DeleteIssueHandler(_repository, new DeleteIssueValidator()); } [Fact] @@ -29,8 +32,9 @@ public async Task Handle_ValidIssue_SetsIsArchivedToTrue() Id: issueId, Title: "Issue to Delete", Description: "This will be archived", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddDays(-1)) + Status: IssueStatus.Open, + CreatedAt: DateTime.UtcNow.AddDays(-1), + UpdatedAt: DateTime.UtcNow.AddDays(-1)) { IsArchived = false }; @@ -40,23 +44,15 @@ public async Task Handle_ValidIssue_SetsIsArchivedToTrue() _repository.GetByIdAsync(issueId, Arg.Any()) .Returns(existingIssue); - var archivedIssue = existingIssue with - { - IsArchived = true, - UpdatedAt = DateTime.UtcNow - }; - - _repository.UpdateAsync(Arg.Any(), Arg.Any()) - .Returns(archivedIssue); + _repository.ArchiveAsync(issueId, Arg.Any()) + .Returns(true); // Act await _handler.Handle(command, CancellationToken.None); // Assert await _repository.Received(1).GetByIdAsync(issueId, Arg.Any()); - await _repository.Received(1).UpdateAsync( - Arg.Is(i => i.IsArchived == true && i.Id == issueId), - Arg.Any()); + await _repository.Received(1).ArchiveAsync(issueId, Arg.Any()); } [Fact] @@ -86,11 +82,11 @@ public async Task Handle_AlreadyArchivedIssue_IsIdempotent() Id: issueId, Title: "Already Archived", Description: "Already archived", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddDays(-1)) + Status: IssueStatus.Open, + CreatedAt: DateTime.UtcNow.AddDays(-1), + UpdatedAt: DateTime.UtcNow.AddHours(-1)) { - IsArchived = true, - UpdatedAt = DateTime.UtcNow.AddHours(-1) + IsArchived = true }; var command = new DeleteIssueCommand { Id = issueId }; @@ -98,17 +94,16 @@ public async Task Handle_AlreadyArchivedIssue_IsIdempotent() _repository.GetByIdAsync(issueId, Arg.Any()) .Returns(archivedIssue); - // Act & Assert - Should be idempotent (either succeed silently or throw) - // Decision: Return success (204) without updating (idempotent) + // Act — should succeed idempotently without calling ArchiveAsync await _handler.Handle(command, CancellationToken.None); await _repository.Received(1).GetByIdAsync(issueId, Arg.Any()); - // Should NOT call UpdateAsync since already archived - await _repository.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + // Should NOT call ArchiveAsync since already archived + await _repository.DidNotReceive().ArchiveAsync(Arg.Any(), Arg.Any()); } [Fact] - public async Task Handle_ValidIssue_UpdatesTimestamp() + public async Task Handle_ValidIssue_CallsArchive() { // Arrange var issueId = Guid.NewGuid().ToString(); @@ -116,11 +111,11 @@ public async Task Handle_ValidIssue_UpdatesTimestamp() Id: issueId, Title: "Issue to Delete", Description: "This will be archived", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddDays(-1)) + Status: IssueStatus.Open, + CreatedAt: DateTime.UtcNow.AddDays(-1), + UpdatedAt: DateTime.UtcNow.AddHours(-2)) { - IsArchived = false, - UpdatedAt = DateTime.UtcNow.AddHours(-2) + IsArchived = false }; var command = new DeleteIssueCommand { Id = issueId }; @@ -128,22 +123,15 @@ public async Task Handle_ValidIssue_UpdatesTimestamp() _repository.GetByIdAsync(issueId, Arg.Any()) .Returns(existingIssue); - var archivedIssue = existingIssue with - { - IsArchived = true, - UpdatedAt = DateTime.UtcNow - }; - - _repository.UpdateAsync(Arg.Any(), Arg.Any()) - .Returns(archivedIssue); + _repository.ArchiveAsync(issueId, Arg.Any()) + .Returns(true); // Act - await _handler.Handle(command, CancellationToken.None); + var result = await _handler.Handle(command, CancellationToken.None); // Assert - await _repository.Received(1).UpdateAsync( - Arg.Is(i => i.UpdatedAt != null && i.UpdatedAt > existingIssue.UpdatedAt), - Arg.Any()); + result.Should().BeTrue(); + await _repository.Received(1).ArchiveAsync(issueId, Arg.Any()); } [Fact] diff --git a/tests/Unit/Handlers/ListIssuesHandlerTests.cs b/tests/Unit/Handlers/ListIssuesHandlerTests.cs index d2c1625..09baea6 100644 --- a/tests/Unit/Handlers/ListIssuesHandlerTests.cs +++ b/tests/Unit/Handlers/ListIssuesHandlerTests.cs @@ -1,7 +1,13 @@ using FluentAssertions; + +using FluentValidation; + +using global::Shared.Domain; + using IssueManager.Api.Data; using IssueManager.Api.Handlers; -using IssueManager.Shared.Domain.Models; +using IssueManager.Shared.Validators; + using NSubstitute; namespace IssueManager.Tests.Unit.Handlers; @@ -17,7 +23,7 @@ public class ListIssuesHandlerTests public ListIssuesHandlerTests() { _repository = Substitute.For(); - _handler = new ListIssuesHandler(_repository); + _handler = new ListIssuesHandler(_repository, new ListIssuesQueryValidator()); } [Fact] @@ -27,11 +33,8 @@ public async Task Handle_DefaultPagination_ReturnsFirstPageWithCorrectMetadata() var query = new ListIssuesQuery { Page = 1, PageSize = 20 }; var issues = GenerateIssues(20); - _repository.GetAllAsync(1, 20, false, Arg.Any()) - .Returns(issues); - - _repository.CountAsync(false, Arg.Any()) - .Returns(42); + _repository.GetAllAsync(1, 20, Arg.Any()) + .Returns(((IReadOnlyList)issues, 42L)); // Act var result = await _handler.Handle(query, CancellationToken.None); @@ -40,7 +43,7 @@ public async Task Handle_DefaultPagination_ReturnsFirstPageWithCorrectMetadata() result.Items.Should().HaveCount(20); result.Page.Should().Be(1); result.PageSize.Should().Be(20); - result.TotalCount.Should().Be(42); + result.Total.Should().Be(42); result.TotalPages.Should().Be(3); // 42 / 20 = 2.1 → 3 pages } @@ -51,11 +54,8 @@ public async Task Handle_SecondPage_ReturnsCorrectItems() var query = new ListIssuesQuery { Page = 2, PageSize = 10 }; var issues = GenerateIssues(10); - _repository.GetAllAsync(2, 10, false, Arg.Any()) - .Returns(issues); - - _repository.CountAsync(false, Arg.Any()) - .Returns(42); + _repository.GetAllAsync(2, 10, Arg.Any()) + .Returns(((IReadOnlyList)issues, 42L)); // Act var result = await _handler.Handle(query, CancellationToken.None); @@ -64,7 +64,7 @@ public async Task Handle_SecondPage_ReturnsCorrectItems() result.Items.Should().HaveCount(10); result.Page.Should().Be(2); result.PageSize.Should().Be(10); - result.TotalCount.Should().Be(42); + result.Total.Should().Be(42); result.TotalPages.Should().Be(5); // 42 / 10 = 4.2 → 5 pages } @@ -75,11 +75,8 @@ public async Task Handle_LastPagePartialItems_ReturnsCorrectCount() var query = new ListIssuesQuery { Page = 3, PageSize = 20 }; var issues = GenerateIssues(2); // Last page has only 2 items - _repository.GetAllAsync(3, 20, false, Arg.Any()) - .Returns(issues); - - _repository.CountAsync(false, Arg.Any()) - .Returns(42); + _repository.GetAllAsync(3, 20, Arg.Any()) + .Returns(((IReadOnlyList)issues, 42L)); // Act var result = await _handler.Handle(query, CancellationToken.None); @@ -88,7 +85,7 @@ public async Task Handle_LastPagePartialItems_ReturnsCorrectCount() result.Items.Should().HaveCount(2); result.Page.Should().Be(3); result.PageSize.Should().Be(20); - result.TotalCount.Should().Be(42); + result.Total.Should().Be(42); result.TotalPages.Should().Be(3); } @@ -98,11 +95,8 @@ public async Task Handle_EmptyResult_ReturnsEmptyList() // Arrange var query = new ListIssuesQuery { Page = 1, PageSize = 20 }; - _repository.GetAllAsync(1, 20, false, Arg.Any()) - .Returns(new List()); - - _repository.CountAsync(false, Arg.Any()) - .Returns(0); + _repository.GetAllAsync(1, 20, Arg.Any()) + .Returns(((IReadOnlyList)new List(), 0L)); // Act var result = await _handler.Handle(query, CancellationToken.None); @@ -111,7 +105,7 @@ public async Task Handle_EmptyResult_ReturnsEmptyList() result.Items.Should().BeEmpty(); result.Page.Should().Be(1); result.PageSize.Should().Be(20); - result.TotalCount.Should().Be(0); + result.Total.Should().Be(0); result.TotalPages.Should().Be(0); } @@ -121,11 +115,8 @@ public async Task Handle_PageExceedsTotalPages_ReturnsEmptyList() // Arrange var query = new ListIssuesQuery { Page = 10, PageSize = 20 }; - _repository.GetAllAsync(10, 20, false, Arg.Any()) - .Returns(new List()); - - _repository.CountAsync(false, Arg.Any()) - .Returns(42); // Only 3 pages exist + _repository.GetAllAsync(10, 20, Arg.Any()) + .Returns(((IReadOnlyList)new List(), 42L)); // Act var result = await _handler.Handle(query, CancellationToken.None); @@ -147,7 +138,7 @@ public async Task Handle_InvalidPage_ThrowsValidationException() // Assert await act.Should().ThrowAsync() - .WithMessage("*Page*greater than 0*"); + .WithMessage("*Page*greater than or equal to 1*"); } [Fact] @@ -161,7 +152,7 @@ public async Task Handle_InvalidPageSize_ThrowsValidationException() // Assert await act.Should().ThrowAsync() - .WithMessage("*PageSize*greater than 0*"); + .WithMessage("*Page size*between 1 and 100*"); } [Fact] @@ -175,7 +166,7 @@ public async Task Handle_PageSizeExceedsMax_ThrowsValidationException() // Assert await act.Should().ThrowAsync() - .WithMessage("*PageSize*100*"); + .WithMessage("*Page size*between 1 and 100*"); } [Fact] @@ -185,17 +176,14 @@ public async Task Handle_ExcludesArchivedIssues_ByDefault() var query = new ListIssuesQuery { Page = 1, PageSize = 20 }; var issues = GenerateIssues(10); - _repository.GetAllAsync(1, 20, false, Arg.Any()) - .Returns(issues); - - _repository.CountAsync(false, Arg.Any()) - .Returns(10); + _repository.GetAllAsync(1, 20, Arg.Any()) + .Returns(((IReadOnlyList)issues, 10L)); // Act var result = await _handler.Handle(query, CancellationToken.None); // Assert - await _repository.Received(1).GetAllAsync(1, 20, false, Arg.Any()); + await _repository.Received(1).GetAllAsync(1, 20, Arg.Any()); result.Items.Should().HaveCount(10); } @@ -205,18 +193,16 @@ public async Task Handle_OrdersByCreatedAtDescending_NewestFirst() // Arrange var query = new ListIssuesQuery { Page = 1, PageSize = 3 }; - var issues = new List - { - new("1", "Issue 1", "Desc", "user1", DateTime.UtcNow.AddDays(-3)), - new("2", "Issue 2", "Desc", "user2", DateTime.UtcNow.AddDays(-2)), - new("3", "Issue 3", "Desc", "user3", DateTime.UtcNow.AddDays(-1)) - }; - - _repository.GetAllAsync(1, 3, false, Arg.Any()) - .Returns(issues.OrderByDescending(i => i.CreatedAt).ToList()); + // Create issues already in expected descending order (newest first) + var orderedIssues = new List +{ +new("3", "Issue 3", "Desc", IssueStatus.Open, DateTime.UtcNow.AddDays(-1), DateTime.UtcNow.AddDays(-1)), +new("2", "Issue 2", "Desc", IssueStatus.Open, DateTime.UtcNow.AddDays(-2), DateTime.UtcNow.AddDays(-2)), +new("1", "Issue 1", "Desc", IssueStatus.Open, DateTime.UtcNow.AddDays(-3), DateTime.UtcNow.AddDays(-3)) +}; - _repository.CountAsync(false, Arg.Any()) - .Returns(3); + _repository.GetAllAsync(1, 3, Arg.Any()) + .Returns(((IReadOnlyList)orderedIssues, 3L)); // Act var result = await _handler.Handle(query, CancellationToken.None); @@ -234,11 +220,12 @@ private static List GenerateIssues(int count) for (int i = 0; i < count; i++) { issues.Add(new Issue( - Id: Guid.NewGuid().ToString(), - Title: $"Issue {i + 1}", - Description: $"Description {i + 1}", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddDays(-i))); + Id: Guid.NewGuid().ToString(), + Title: $"Issue {i + 1}", + Description: $"Description {i + 1}", + Status: IssueStatus.Open, + CreatedAt: DateTime.UtcNow.AddDays(-i), + UpdatedAt: DateTime.UtcNow.AddDays(-i))); } return issues; } diff --git a/tests/Unit/Handlers/UpdateIssueHandlerTests.cs b/tests/Unit/Handlers/UpdateIssueHandlerTests.cs index c4524ef..108a67a 100644 --- a/tests/Unit/Handlers/UpdateIssueHandlerTests.cs +++ b/tests/Unit/Handlers/UpdateIssueHandlerTests.cs @@ -1,7 +1,8 @@ using FluentAssertions; using IssueManager.Api.Data; using IssueManager.Api.Handlers; -using IssueManager.Shared.Domain.Models; +using global::Shared.Domain; +using global::Shared.Exceptions; using IssueManager.Shared.Validators; using NSubstitute; @@ -32,11 +33,9 @@ public async Task Handle_ValidCommand_ReturnsUpdatedIssue() Id: issueId, Title: "Original Title", Description: "Original Description", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddDays(-1)) - { - UpdatedAt = DateTime.UtcNow.AddDays(-1) - }; + Status: IssueStatus.Open, + CreatedAt: DateTime.UtcNow.AddDays(-1), + UpdatedAt: DateTime.UtcNow.AddDays(-1)); var command = new UpdateIssueCommand { @@ -65,7 +64,7 @@ public async Task Handle_ValidCommand_ReturnsUpdatedIssue() result.Should().NotBeNull(); result.Title.Should().Be("Updated Title"); result.Description.Should().Be("Updated Description"); - result.UpdatedAt.Should().NotBeNull(); + result.UpdatedAt.Should().BeAfter(DateTime.MinValue); result.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2)); await _repository.Received(1).GetByIdAsync(issueId, Arg.Any()); @@ -163,8 +162,9 @@ public async Task Handle_ArchivedIssue_ThrowsConflictException() Id: issueId, Title: "Archived Issue", Description: "This is archived", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddDays(-1)) + Status: IssueStatus.Open, + CreatedAt: DateTime.UtcNow.AddDays(-1), + UpdatedAt: DateTime.UtcNow.AddDays(-1)) { IsArchived = true }; @@ -196,11 +196,9 @@ public async Task Handle_IdempotentUpdate_UpdatesTimestamp() Id: issueId, Title: "Same Title", Description: "Same Description", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddDays(-1)) - { - UpdatedAt = DateTime.UtcNow.AddHours(-1) - }; + Status: IssueStatus.Open, + CreatedAt: DateTime.UtcNow.AddDays(-1), + UpdatedAt: DateTime.UtcNow.AddHours(-1)); var command = new UpdateIssueCommand { @@ -221,9 +219,9 @@ public async Task Handle_IdempotentUpdate_UpdatesTimestamp() var result = await _handler.Handle(command, CancellationToken.None); // Assert - result.UpdatedAt.Should().NotBeNull(); + result.UpdatedAt.Should().BeAfter(DateTime.MinValue); result.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2)); - result.UpdatedAt.Should().BeAfter(existingIssue.UpdatedAt!.Value); + result.UpdatedAt.Should().BeAfter(existingIssue.UpdatedAt); } [Fact] @@ -235,8 +233,9 @@ public async Task Handle_NullDescription_AllowsNullValue() Id: issueId, Title: "Original Title", Description: "Original Description", - AuthorId: "user-123", - CreatedAt: DateTime.UtcNow.AddDays(-1)); + Status: IssueStatus.Open, + CreatedAt: DateTime.UtcNow.AddDays(-1), + UpdatedAt: DateTime.UtcNow.AddDays(-1)); var command = new UpdateIssueCommand {