diff --git a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs index 0bd9364b8..7e6ee09bc 100644 --- a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs @@ -30,6 +30,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/src/Taskdeck.Application/Services/BoardService.cs b/backend/src/Taskdeck.Application/Services/BoardService.cs index 5b8e69394..40ccf807c 100644 --- a/backend/src/Taskdeck.Application/Services/BoardService.cs +++ b/backend/src/Taskdeck.Application/Services/BoardService.cs @@ -2,6 +2,7 @@ using Taskdeck.Application.Interfaces; using Taskdeck.Domain.Common; using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Enums; using Taskdeck.Domain.Exceptions; namespace Taskdeck.Application.Services; @@ -11,20 +12,30 @@ public class BoardService private readonly IUnitOfWork _unitOfWork; private readonly IAuthorizationService? _authorizationService; private readonly IBoardRealtimeNotifier _realtimeNotifier; + private readonly IHistoryService? _historyService; public BoardService(IUnitOfWork unitOfWork) - : this(unitOfWork, authorizationService: null, realtimeNotifier: null) + : this(unitOfWork, authorizationService: null, realtimeNotifier: null, historyService: null) { } public BoardService( IUnitOfWork unitOfWork, IAuthorizationService? authorizationService, - IBoardRealtimeNotifier? realtimeNotifier = null) + IBoardRealtimeNotifier? realtimeNotifier = null, + IHistoryService? historyService = null) { _unitOfWork = unitOfWork; _authorizationService = authorizationService; _realtimeNotifier = realtimeNotifier ?? NoOpBoardRealtimeNotifier.Instance; + _historyService = historyService; + } + + private async Task SafeLogAsync(string entityType, Guid entityId, AuditAction action, Guid? userId = null, string? changes = null) + { + if (_historyService == null) return; + try { await _historyService.LogActionAsync(entityType, entityId, action, userId, changes); } + catch (Exception) { /* Audit is secondary — never crash the mutation */ } } public async Task> CreateBoardAsync(CreateBoardDto dto, Guid actingUserId, CancellationToken cancellationToken = default) @@ -139,6 +150,7 @@ private async Task> CreateBoardInternalAsync(CreateBoardDto dto await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(board.Id, "board", "created", board.Id, DateTimeOffset.UtcNow), cancellationToken); + await SafeLogAsync("board", board.Id, AuditAction.Created, ownerId, $"name={board.Name}"); return Result.Success(MapToDto(board)); } @@ -171,6 +183,12 @@ private async Task> UpdateBoardInternalAsync(Guid id, UpdateBoa await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(board.Id, "board", "updated", board.Id, DateTimeOffset.UtcNow), cancellationToken); + if (dto.IsArchived == true) + await SafeLogAsync("board", board.Id, AuditAction.Archived, changes: $"name={board.Name}"); + else if (dto.IsArchived == false) + await SafeLogAsync("board", board.Id, AuditAction.Updated, changes: $"unarchived; name={board.Name}"); + else + await SafeLogAsync("board", board.Id, AuditAction.Updated, changes: $"name={board.Name}"); return Result.Success(MapToDto(board)); } catch (DomainException ex) @@ -199,6 +217,7 @@ private async Task DeleteBoardInternalAsync(Guid id, CancellationToken c await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(board.Id, "board", "archived", board.Id, DateTimeOffset.UtcNow), cancellationToken); + await SafeLogAsync("board", board.Id, AuditAction.Archived, changes: $"name={board.Name}"); return Result.Success(); } diff --git a/backend/src/Taskdeck.Application/Services/CardService.cs b/backend/src/Taskdeck.Application/Services/CardService.cs index a77f075c1..6ff968344 100644 --- a/backend/src/Taskdeck.Application/Services/CardService.cs +++ b/backend/src/Taskdeck.Application/Services/CardService.cs @@ -11,16 +11,25 @@ public class CardService { private readonly IUnitOfWork _unitOfWork; private readonly IBoardRealtimeNotifier _realtimeNotifier; + private readonly IHistoryService? _historyService; public CardService(IUnitOfWork unitOfWork) - : this(unitOfWork, realtimeNotifier: null) + : this(unitOfWork, realtimeNotifier: null, historyService: null) { } - public CardService(IUnitOfWork unitOfWork, IBoardRealtimeNotifier? realtimeNotifier = null) + public CardService(IUnitOfWork unitOfWork, IBoardRealtimeNotifier? realtimeNotifier = null, IHistoryService? historyService = null) { _unitOfWork = unitOfWork; _realtimeNotifier = realtimeNotifier ?? NoOpBoardRealtimeNotifier.Instance; + _historyService = historyService; + } + + private async Task SafeLogAsync(string entityType, Guid entityId, AuditAction action, Guid? userId = null, string? changes = null) + { + if (_historyService == null) return; + try { await _historyService.LogActionAsync(entityType, entityId, action, userId, changes); } + catch (Exception) { /* Audit is secondary — never crash the mutation */ } } public async Task> CreateCardAsync(CreateCardDto dto, CancellationToken cancellationToken = default) @@ -74,6 +83,7 @@ public async Task> CreateCardAsync( await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(card.BoardId, "card", "created", card.Id, DateTimeOffset.UtcNow), cancellationToken); + await SafeLogAsync("card", card.Id, AuditAction.Created, changes: $"title={card.Title}"); var createdCard = await _unitOfWork.Cards.GetByIdWithLabelsAsync(card.Id, cancellationToken); return Result.Success(MapToDto(createdCard!)); @@ -134,6 +144,7 @@ public async Task> UpdateCardAsync( await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(card.BoardId, "card", "updated", card.Id, DateTimeOffset.UtcNow), cancellationToken); + await SafeLogAsync("card", card.Id, AuditAction.Updated, actorUserId); var updatedCard = await _unitOfWork.Cards.GetByIdWithLabelsAsync(id, cancellationToken); return Result.Success(MapToDto(updatedCard!)); @@ -213,6 +224,7 @@ public async Task> MoveCardAsync(Guid id, MoveCardDto dto, Cance await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(card.BoardId, "card", "moved", card.Id, DateTimeOffset.UtcNow), cancellationToken); + await SafeLogAsync("card", card.Id, AuditAction.Moved, changes: $"target_column={dto.TargetColumnId}; position={dto.TargetPosition}"); var movedCard = await _unitOfWork.Cards.GetByIdWithLabelsAsync(id, cancellationToken); return Result.Success(MapToDto(movedCard!)); @@ -299,6 +311,7 @@ public async Task DeleteCardAsync(Guid id, CancellationToken cancellatio await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(card.BoardId, "card", "deleted", card.Id, DateTimeOffset.UtcNow), cancellationToken); + await SafeLogAsync("card", card.Id, AuditAction.Deleted, changes: $"title={card.Title}"); return Result.Success(); } diff --git a/backend/src/Taskdeck.Application/Services/ColumnService.cs b/backend/src/Taskdeck.Application/Services/ColumnService.cs index 7c727c7d0..d96be3eaa 100644 --- a/backend/src/Taskdeck.Application/Services/ColumnService.cs +++ b/backend/src/Taskdeck.Application/Services/ColumnService.cs @@ -2,6 +2,7 @@ using Taskdeck.Application.Interfaces; using Taskdeck.Domain.Common; using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Enums; using Taskdeck.Domain.Exceptions; namespace Taskdeck.Application.Services; @@ -10,16 +11,25 @@ public class ColumnService { private readonly IUnitOfWork _unitOfWork; private readonly IBoardRealtimeNotifier _realtimeNotifier; + private readonly IHistoryService? _historyService; public ColumnService(IUnitOfWork unitOfWork) - : this(unitOfWork, realtimeNotifier: null) + : this(unitOfWork, realtimeNotifier: null, historyService: null) { } - public ColumnService(IUnitOfWork unitOfWork, IBoardRealtimeNotifier? realtimeNotifier = null) + public ColumnService(IUnitOfWork unitOfWork, IBoardRealtimeNotifier? realtimeNotifier = null, IHistoryService? historyService = null) { _unitOfWork = unitOfWork; _realtimeNotifier = realtimeNotifier ?? NoOpBoardRealtimeNotifier.Instance; + _historyService = historyService; + } + + private async Task SafeLogAsync(string entityType, Guid entityId, AuditAction action, Guid? userId = null, string? changes = null) + { + if (_historyService == null) return; + try { await _historyService.LogActionAsync(entityType, entityId, action, userId, changes); } + catch (Exception) { /* Audit is secondary — never crash the mutation */ } } public async Task> CreateColumnAsync(CreateColumnDto dto, CancellationToken cancellationToken = default) @@ -45,6 +55,7 @@ public async Task> CreateColumnAsync(CreateColumnDto dto, Canc await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(column.BoardId, "column", "created", column.Id, DateTimeOffset.UtcNow), cancellationToken); + await SafeLogAsync("column", column.Id, AuditAction.Created, changes: $"name={column.Name}"); return Result.Success(MapToDto(column)); } @@ -67,6 +78,7 @@ public async Task> UpdateColumnAsync(Guid id, UpdateColumnDto await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(column.BoardId, "column", "updated", column.Id, DateTimeOffset.UtcNow), cancellationToken); + await SafeLogAsync("column", column.Id, AuditAction.Updated); return Result.Success(MapToDto(column)); } @@ -105,6 +117,7 @@ public async Task DeleteColumnAsync(Guid id, CancellationToken cancellat await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(column.BoardId, "column", "deleted", column.Id, DateTimeOffset.UtcNow), cancellationToken); + await SafeLogAsync("column", column.Id, AuditAction.Deleted, changes: $"name={column.Name}"); return Result.Success(); } @@ -166,6 +179,7 @@ public async Task>> ReorderColumnsAsync(Guid board await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(boardId, "column", "reordered", null, DateTimeOffset.UtcNow), cancellationToken); + await SafeLogAsync("column", boardId, AuditAction.Updated, changes: $"reordered; count={dto.ColumnIds.Count}"); // Return reordered columns var reorderedColumns = dto.ColumnIds.Select(id => MapToDto(columnDict[id])); diff --git a/backend/src/Taskdeck.Application/Services/HistoryService.cs b/backend/src/Taskdeck.Application/Services/HistoryService.cs index ef926f9d3..3230e1825 100644 --- a/backend/src/Taskdeck.Application/Services/HistoryService.cs +++ b/backend/src/Taskdeck.Application/Services/HistoryService.cs @@ -70,6 +70,12 @@ public async Task LogActionAsync(string entityType, Guid entityId, Audit { return Result.Failure(ex.ErrorCode, ex.Message); } + catch (Exception) + { + // Audit logging is secondary to the mutation — never let infrastructure + // failures (e.g. DB full, concurrency) crash the calling operation. + return Result.Failure(ErrorCodes.UnexpectedError, $"Failed to persist audit log for {entityType}/{entityId}/{action}"); + } } private static AuditLogDto MapToDto(AuditLog log) diff --git a/backend/src/Taskdeck.Application/Services/IHistoryService.cs b/backend/src/Taskdeck.Application/Services/IHistoryService.cs index 3a6ffbcbf..301c5f87b 100644 --- a/backend/src/Taskdeck.Application/Services/IHistoryService.cs +++ b/backend/src/Taskdeck.Application/Services/IHistoryService.cs @@ -6,7 +6,6 @@ namespace Taskdeck.Application.Services; /// /// Service interface for audit log and history operations. -/// SCAFFOLDING: Implementation pending. /// public interface IHistoryService { diff --git a/backend/src/Taskdeck.Application/Services/LabelService.cs b/backend/src/Taskdeck.Application/Services/LabelService.cs index 63f27f03b..e2ac02784 100644 --- a/backend/src/Taskdeck.Application/Services/LabelService.cs +++ b/backend/src/Taskdeck.Application/Services/LabelService.cs @@ -2,6 +2,7 @@ using Taskdeck.Application.Interfaces; using Taskdeck.Domain.Common; using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Enums; using Taskdeck.Domain.Exceptions; namespace Taskdeck.Application.Services; @@ -10,16 +11,25 @@ public class LabelService { private readonly IUnitOfWork _unitOfWork; private readonly IBoardRealtimeNotifier _realtimeNotifier; + private readonly IHistoryService? _historyService; public LabelService(IUnitOfWork unitOfWork) - : this(unitOfWork, realtimeNotifier: null) + : this(unitOfWork, realtimeNotifier: null, historyService: null) { } - public LabelService(IUnitOfWork unitOfWork, IBoardRealtimeNotifier? realtimeNotifier = null) + public LabelService(IUnitOfWork unitOfWork, IBoardRealtimeNotifier? realtimeNotifier = null, IHistoryService? historyService = null) { _unitOfWork = unitOfWork; _realtimeNotifier = realtimeNotifier ?? NoOpBoardRealtimeNotifier.Instance; + _historyService = historyService; + } + + private async Task SafeLogAsync(string entityType, Guid entityId, AuditAction action, Guid? userId = null, string? changes = null) + { + if (_historyService == null) return; + try { await _historyService.LogActionAsync(entityType, entityId, action, userId, changes); } + catch (Exception) { /* Audit is secondary — never crash the mutation */ } } public async Task> CreateLabelAsync(CreateLabelDto dto, CancellationToken cancellationToken = default) @@ -36,6 +46,7 @@ public async Task> CreateLabelAsync(CreateLabelDto dto, Cancell await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(label.BoardId, "label", "created", label.Id, DateTimeOffset.UtcNow), cancellationToken); + await SafeLogAsync("label", label.Id, AuditAction.Created, changes: $"name={label.Name}"); return Result.Success(MapToDto(label)); } @@ -58,6 +69,7 @@ public async Task> UpdateLabelAsync(Guid id, UpdateLabelDto dto await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(label.BoardId, "label", "updated", label.Id, DateTimeOffset.UtcNow), cancellationToken); + await SafeLogAsync("label", label.Id, AuditAction.Updated); return Result.Success(MapToDto(label)); } @@ -93,6 +105,7 @@ public async Task DeleteLabelAsync(Guid id, CancellationToken cancellati await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(label.BoardId, "label", "deleted", label.Id, DateTimeOffset.UtcNow), cancellationToken); + await SafeLogAsync("label", label.Id, AuditAction.Deleted, changes: $"name={label.Name}"); return Result.Success(); } diff --git a/backend/tests/Taskdeck.Application.Tests/Services/BoardMutationAuditTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/BoardMutationAuditTests.cs new file mode 100644 index 000000000..d3e91114f --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/BoardMutationAuditTests.cs @@ -0,0 +1,397 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Application.Tests.TestUtilities; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Enums; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +/// +/// Verifies that board mutation services (Card, Column, Board, Label) record +/// audit log entries via IHistoryService after successful mutations. +/// +public class BoardMutationAuditTests +{ + private readonly Mock _unitOfWorkMock; + private readonly Mock _boardRepoMock; + private readonly Mock _columnRepoMock; + private readonly Mock _cardRepoMock; + private readonly Mock _labelRepoMock; + private readonly Mock _auditLogRepoMock; + private readonly Mock _historyServiceMock; + + public BoardMutationAuditTests() + { + _unitOfWorkMock = new Mock(); + _boardRepoMock = new Mock(); + _columnRepoMock = new Mock(); + _cardRepoMock = new Mock(); + _labelRepoMock = new Mock(); + _auditLogRepoMock = new Mock(); + _historyServiceMock = new Mock(); + + _unitOfWorkMock.Setup(u => u.Boards).Returns(_boardRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Columns).Returns(_columnRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Cards).Returns(_cardRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Labels).Returns(_labelRepoMock.Object); + _unitOfWorkMock.Setup(u => u.AuditLogs).Returns(_auditLogRepoMock.Object); + + _historyServiceMock + .Setup(h => h.LogActionAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success()); + } + + #region CardService Audit Tests + + [Fact] + public async Task CreateCard_RecordsAuditLog() + { + // Arrange + var board = TestDataBuilder.CreateBoard(); + var column = TestDataBuilder.CreateColumn(board.Id, "To Do"); + var dto = new CreateCardDto(board.Id, column.Id, "New Card", "Description", null, null); + var service = new CardService(_unitOfWorkMock.Object, realtimeNotifier: null, historyService: _historyServiceMock.Object); + + _boardRepoMock.Setup(r => r.GetByIdAsync(board.Id, default)).ReturnsAsync(board); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(column.Id, default)).ReturnsAsync(column); + _cardRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((Card c, CancellationToken ct) => c); + _cardRepoMock.Setup(r => r.GetByIdWithLabelsAsync(It.IsAny(), default)) + .ReturnsAsync((Guid id, CancellationToken ct) => + TestDataBuilder.CreateCard(board.Id, column.Id, dto.Title, dto.Description)); + + // Act + var result = await service.CreateCardAsync(dto); + + // Assert + result.IsSuccess.Should().BeTrue(); + _historyServiceMock.Verify( + h => h.LogActionAsync("card", It.IsAny(), AuditAction.Created, null, It.Is(s => s != null && s.Contains("New Card"))), + Times.Once); + } + + [Fact] + public async Task UpdateCard_RecordsAuditLog() + { + // Arrange + var board = TestDataBuilder.CreateBoard(); + var column = TestDataBuilder.CreateColumn(board.Id, "To Do"); + var card = TestDataBuilder.CreateCard(board.Id, column.Id, "Card", "Desc"); + var dto = new UpdateCardDto("Updated", null, null, null, null, null, null); + var service = new CardService(_unitOfWorkMock.Object, realtimeNotifier: null, historyService: _historyServiceMock.Object); + + _cardRepoMock.Setup(r => r.GetByIdWithLabelsAsync(card.Id, default)).ReturnsAsync(card); + _cardRepoMock.Setup(r => r.GetByIdAsync(card.Id, default)).ReturnsAsync(card); + + // Act + var result = await service.UpdateCardAsync(card.Id, dto); + + // Assert + result.IsSuccess.Should().BeTrue(); + _historyServiceMock.Verify( + h => h.LogActionAsync("card", card.Id, AuditAction.Updated, It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task MoveCard_RecordsAuditLog() + { + // Arrange + var board = TestDataBuilder.CreateBoard(); + var sourceColumn = TestDataBuilder.CreateColumn(board.Id, "To Do"); + var targetColumn = TestDataBuilder.CreateColumn(board.Id, "In Progress", position: 1); + var card = TestDataBuilder.CreateCard(board.Id, sourceColumn.Id, "Card"); + var dto = new MoveCardDto(targetColumn.Id, 0); + var service = new CardService(_unitOfWorkMock.Object, realtimeNotifier: null, historyService: _historyServiceMock.Object); + + _cardRepoMock.Setup(r => r.GetByIdWithLabelsAsync(card.Id, default)).ReturnsAsync(card); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(targetColumn.Id, default)).ReturnsAsync(targetColumn); + _cardRepoMock.Setup(r => r.GetByColumnIdAsync(targetColumn.Id, default)) + .ReturnsAsync(new List()); + + // Act + var result = await service.MoveCardAsync(card.Id, dto); + + // Assert + result.IsSuccess.Should().BeTrue(); + _historyServiceMock.Verify( + h => h.LogActionAsync("card", card.Id, AuditAction.Moved, null, It.Is(s => s != null && s.Contains("target_column"))), + Times.Once); + } + + [Fact] + public async Task DeleteCard_RecordsAuditLog() + { + // Arrange + var board = TestDataBuilder.CreateBoard(); + var column = TestDataBuilder.CreateColumn(board.Id, "To Do"); + var card = TestDataBuilder.CreateCard(board.Id, column.Id, "Card"); + var service = new CardService(_unitOfWorkMock.Object, realtimeNotifier: null, historyService: _historyServiceMock.Object); + + _cardRepoMock.Setup(r => r.GetByIdAsync(card.Id, default)).ReturnsAsync(card); + + // Act + var result = await service.DeleteCardAsync(card.Id); + + // Assert + result.IsSuccess.Should().BeTrue(); + _historyServiceMock.Verify( + h => h.LogActionAsync("card", card.Id, AuditAction.Deleted, null, It.Is(s => s != null && s.Contains("Card"))), + Times.Once); + } + + #endregion + + #region ColumnService Audit Tests + + [Fact] + public async Task CreateColumn_RecordsAuditLog() + { + // Arrange + var board = TestDataBuilder.CreateBoard(); + var dto = new CreateColumnDto(board.Id, "New Column", null, null); + var service = new ColumnService(_unitOfWorkMock.Object, realtimeNotifier: null, historyService: _historyServiceMock.Object); + + _boardRepoMock.Setup(r => r.GetByIdAsync(board.Id, default)).ReturnsAsync(board); + _columnRepoMock.Setup(r => r.GetByBoardIdAsync(board.Id, default)).ReturnsAsync(new List()); + + // Act + var result = await service.CreateColumnAsync(dto); + + // Assert + result.IsSuccess.Should().BeTrue(); + _historyServiceMock.Verify( + h => h.LogActionAsync("column", It.IsAny(), AuditAction.Created, null, It.Is(s => s != null && s.Contains("New Column"))), + Times.Once); + } + + [Fact] + public async Task DeleteColumn_RecordsAuditLog() + { + // Arrange + var board = TestDataBuilder.CreateBoard(); + var column = TestDataBuilder.CreateColumn(board.Id, "Empty Column"); + var service = new ColumnService(_unitOfWorkMock.Object, realtimeNotifier: null, historyService: _historyServiceMock.Object); + + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(column.Id, default)).ReturnsAsync(column); + + // Act + var result = await service.DeleteColumnAsync(column.Id); + + // Assert + result.IsSuccess.Should().BeTrue(); + _historyServiceMock.Verify( + h => h.LogActionAsync("column", column.Id, AuditAction.Deleted, null, It.Is(s => s != null && s.Contains("Empty Column"))), + Times.Once); + } + + #endregion + + #region BoardService Audit Tests + + [Fact] + public async Task CreateBoard_RecordsAuditLog() + { + // Arrange + var userId = Guid.NewGuid(); + var dto = new CreateBoardDto("My Board", "Description"); + var service = new BoardService( + _unitOfWorkMock.Object, + authorizationService: null, + realtimeNotifier: null, + historyService: _historyServiceMock.Object); + + // Act + var result = await service.CreateBoardAsync(dto, userId); + + // Assert + result.IsSuccess.Should().BeTrue(); + _historyServiceMock.Verify( + h => h.LogActionAsync("board", It.IsAny(), AuditAction.Created, userId, It.Is(s => s != null && s.Contains("My Board"))), + Times.Once); + } + + [Fact] + public async Task UpdateBoard_RecordsAuditLog() + { + // Arrange + var board = TestDataBuilder.CreateBoard(); + var dto = new UpdateBoardDto("Renamed", null, null); + var service = new BoardService( + _unitOfWorkMock.Object, + authorizationService: null, + realtimeNotifier: null, + historyService: _historyServiceMock.Object); + + _boardRepoMock.Setup(r => r.GetByIdAsync(board.Id, default)).ReturnsAsync(board); + + // Act + var result = await service.UpdateBoardAsync(board.Id, dto); + + // Assert + result.IsSuccess.Should().BeTrue(); + _historyServiceMock.Verify( + h => h.LogActionAsync("board", board.Id, AuditAction.Updated, null, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task DeleteBoard_RecordsArchiveAuditLog() + { + // Arrange + var board = TestDataBuilder.CreateBoard(); + var service = new BoardService( + _unitOfWorkMock.Object, + authorizationService: null, + realtimeNotifier: null, + historyService: _historyServiceMock.Object); + + _boardRepoMock.Setup(r => r.GetByIdAsync(board.Id, default)).ReturnsAsync(board); + + // Act + var result = await service.DeleteBoardAsync(board.Id); + + // Assert + result.IsSuccess.Should().BeTrue(); + _historyServiceMock.Verify( + h => h.LogActionAsync("board", board.Id, AuditAction.Archived, null, It.Is(s => s != null && s.Contains("Test Board"))), + Times.Once); + } + + #endregion + + #region LabelService Audit Tests + + [Fact] + public async Task CreateLabel_RecordsAuditLog() + { + // Arrange + var board = TestDataBuilder.CreateBoard(); + var dto = new CreateLabelDto(board.Id, "Urgent", "#FF0000"); + var service = new LabelService(_unitOfWorkMock.Object, realtimeNotifier: null, historyService: _historyServiceMock.Object); + + _boardRepoMock.Setup(r => r.GetByIdAsync(board.Id, default)).ReturnsAsync(board); + + // Act + var result = await service.CreateLabelAsync(dto); + + // Assert + result.IsSuccess.Should().BeTrue(); + _historyServiceMock.Verify( + h => h.LogActionAsync("label", It.IsAny(), AuditAction.Created, null, It.Is(s => s != null && s.Contains("Urgent"))), + Times.Once); + } + + [Fact] + public async Task DeleteLabel_RecordsAuditLog() + { + // Arrange + var board = TestDataBuilder.CreateBoard(); + var label = TestDataBuilder.CreateLabel(board.Id, "Bug", "#FF0000"); + var service = new LabelService(_unitOfWorkMock.Object, realtimeNotifier: null, historyService: _historyServiceMock.Object); + + _labelRepoMock.Setup(r => r.GetByIdAsync(label.Id, default)).ReturnsAsync(label); + + // Act + var result = await service.DeleteLabelAsync(label.Id); + + // Assert + result.IsSuccess.Should().BeTrue(); + _historyServiceMock.Verify( + h => h.LogActionAsync("label", label.Id, AuditAction.Deleted, null, It.Is(s => s != null && s.Contains("Bug"))), + Times.Once); + } + + #endregion + + #region No History Service (backward compatibility) + + [Fact] + public async Task CreateCard_WithoutHistoryService_StillSucceeds() + { + // Arrange - use constructor without IHistoryService + var board = TestDataBuilder.CreateBoard(); + var column = TestDataBuilder.CreateColumn(board.Id, "To Do"); + var dto = new CreateCardDto(board.Id, column.Id, "Card", null, null, null); + var service = new CardService(_unitOfWorkMock.Object); + + _boardRepoMock.Setup(r => r.GetByIdAsync(board.Id, default)).ReturnsAsync(board); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(column.Id, default)).ReturnsAsync(column); + _cardRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((Card c, CancellationToken ct) => c); + _cardRepoMock.Setup(r => r.GetByIdWithLabelsAsync(It.IsAny(), default)) + .ReturnsAsync((Guid id, CancellationToken ct) => + TestDataBuilder.CreateCard(board.Id, column.Id, dto.Title)); + + // Act + var result = await service.CreateCardAsync(dto); + + // Assert - succeeds without audit (backward compat) + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task CreateCard_AuditFailure_DoesNotCrashMutation() + { + // Arrange - history service throws an infrastructure exception + var board = TestDataBuilder.CreateBoard(); + var column = TestDataBuilder.CreateColumn(board.Id, "To Do"); + var dto = new CreateCardDto(board.Id, column.Id, "Card", null, null, null); + + var failingHistoryService = new Mock(); + failingHistoryService + .Setup(h => h.LogActionAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("DB disk full")); + + var service = new CardService(_unitOfWorkMock.Object, realtimeNotifier: null, historyService: failingHistoryService.Object); + + _boardRepoMock.Setup(r => r.GetByIdAsync(board.Id, default)).ReturnsAsync(board); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(column.Id, default)).ReturnsAsync(column); + _cardRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((Card c, CancellationToken ct) => c); + _cardRepoMock.Setup(r => r.GetByIdWithLabelsAsync(It.IsAny(), default)) + .ReturnsAsync((Guid id, CancellationToken ct) => + TestDataBuilder.CreateCard(board.Id, column.Id, dto.Title)); + + // Act - should not throw even though audit logging fails + var result = await service.CreateCardAsync(dto); + + // Assert - mutation still succeeds; audit failure is swallowed + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task UpdateBoard_WithArchiveFlag_RecordsArchivedAction() + { + // Arrange + var board = TestDataBuilder.CreateBoard(); + var dto = new UpdateBoardDto(null, null, true); + var service = new BoardService( + _unitOfWorkMock.Object, + authorizationService: null, + realtimeNotifier: null, + historyService: _historyServiceMock.Object); + + _boardRepoMock.Setup(r => r.GetByIdAsync(board.Id, default)).ReturnsAsync(board); + + // Act + var result = await service.UpdateBoardAsync(board.Id, dto); + + // Assert - should record Archived, not Updated + result.IsSuccess.Should().BeTrue(); + _historyServiceMock.Verify( + h => h.LogActionAsync("board", board.Id, AuditAction.Archived, null, It.Is(s => s != null && s.Contains("name="))), + Times.Once); + } + + #endregion +} diff --git a/frontend/taskdeck-web/src/composables/useActivityQuery.ts b/frontend/taskdeck-web/src/composables/useActivityQuery.ts index a49620847..df03a477e 100644 --- a/frontend/taskdeck-web/src/composables/useActivityQuery.ts +++ b/frontend/taskdeck-web/src/composables/useActivityQuery.ts @@ -69,7 +69,14 @@ export function useActivityQuery() { const boardOptions = computed(() => { return [...boards.boards] - .sort((left, right) => left.name.localeCompare(right.name)) + .sort((left, right) => { + // Non-archived boards first + if (left.isArchived !== right.isArchived) { + return left.isArchived ? 1 : -1 + } + // Within same archived status, most-recently-updated first + return right.updatedAt.localeCompare(left.updatedAt) + }) .map((board) => ({ id: board.id, label: board.isArchived ? `${board.name} (Archived)` : board.name, diff --git a/frontend/taskdeck-web/src/tests/composables/useActivityQuery.spec.ts b/frontend/taskdeck-web/src/tests/composables/useActivityQuery.spec.ts index 32f76db11..c70330b40 100644 --- a/frontend/taskdeck-web/src/tests/composables/useActivityQuery.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/useActivityQuery.spec.ts @@ -83,7 +83,7 @@ const mockAuditStore = reactive({ }) const mockBoardStore = reactive({ - boards: [] as Array<{ id: string; name: string; isArchived: boolean }>, + boards: [] as Array<{ id: string; name: string; isArchived: boolean; updatedAt: string }>, currentBoard: null as null | { id: string; columns: Array<{ id: string; name: string; position: number }> }, currentBoardCards: [] as Array<{ id: string; title: string; columnId: string; position: number }>, currentBoardLabels: [] as Array<{ id: string; name: string }>, @@ -169,8 +169,8 @@ describe('useActivityQuery composable', () => { mockBoardStore.fetchBoards.mockImplementation(async () => { mockBoardStore.boards = [ - { id: 'board-1', name: 'Alpha', isArchived: false }, - { id: 'board-2', name: 'Beta', isArchived: false }, + { id: 'board-1', name: 'Alpha', isArchived: false, updatedAt: '2025-01-10T00:00:00Z' }, + { id: 'board-2', name: 'Beta', isArchived: false, updatedAt: '2025-01-15T00:00:00Z' }, ] }) mockBoardStore.fetchBoard.mockResolvedValue(undefined) @@ -181,20 +181,21 @@ describe('useActivityQuery composable', () => { expect(query.viewMode.value).toBe('board') }) - it('computes boardOptions sorted by name', async () => { + it('computes boardOptions sorted by most-recently-updated non-archived first', async () => { const { query } = mountWithQuery() await query.initialize() await tick() expect(query.boardOptions.value).toHaveLength(2) - expect(query.boardOptions.value[0]!.label).toBe('Alpha') - expect(query.boardOptions.value[1]!.label).toBe('Beta') + // Beta has a later updatedAt so appears first + expect(query.boardOptions.value[0]!.label).toBe('Beta') + expect(query.boardOptions.value[1]!.label).toBe('Alpha') }) it('marks archived boards in label', async () => { mockBoardStore.fetchBoards.mockImplementation(async () => { mockBoardStore.boards = [ - { id: 'board-1', name: 'Done', isArchived: true }, + { id: 'board-1', name: 'Done', isArchived: true, updatedAt: '2025-01-10T00:00:00Z' }, ] }) @@ -211,7 +212,31 @@ describe('useActivityQuery composable', () => { await tick() expect(query.canFetch.value).toBe(true) - expect(query.selectedBoardId.value).toBe('board-1') + // Defaults to most-recently-updated board + expect(query.selectedBoardId.value).toBe('board-2') + }) + + it('sorts archived boards after non-archived and defaults to most-recently-active', async () => { + mockBoardStore.fetchBoards.mockImplementation(async () => { + mockBoardStore.boards = [ + { id: 'board-a', name: 'Archived Recent', isArchived: true, updatedAt: '2025-01-20T00:00:00Z' }, + { id: 'board-b', name: 'Active Old', isArchived: false, updatedAt: '2025-01-05T00:00:00Z' }, + { id: 'board-c', name: 'Active Recent', isArchived: false, updatedAt: '2025-01-15T00:00:00Z' }, + ] + }) + + const { query } = mountWithQuery() + await query.initialize() + await tick() + + // Non-archived boards first, ordered by most-recently-updated + expect(query.boardOptions.value[0]!.id).toBe('board-c') + expect(query.boardOptions.value[1]!.id).toBe('board-b') + expect(query.boardOptions.value[2]!.id).toBe('board-a') + expect(query.boardOptions.value[2]!.label).toContain('(Archived)') + + // Default selection should be the most-recently-active non-archived board + expect(query.selectedBoardId.value).toBe('board-c') }) it('canFetch is false in entity mode without entity selected', async () => { @@ -245,7 +270,8 @@ describe('useActivityQuery composable', () => { await query.initialize() await tick() - expect(query.selectedIdForCopy.value).toBe('board-1') + // Defaults to most-recently-updated board + expect(query.selectedIdForCopy.value).toBe('board-2') }) it('selectedIdForCopy returns user ID in user mode', async () => {