From 530a4b1bc3c03e7b3d65a701bb5a525b40140d58 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 22:49:24 +0100 Subject: [PATCH 1/6] Record audit log entries for all board mutation operations CardService, ColumnService, BoardService, and LabelService now call IHistoryService.LogActionAsync after each successful create, update, move, delete, and archive mutation. The dependency is optional to preserve backward compatibility. Fixes #521 --- .../Taskdeck.Application/Services/BoardService.cs | 14 ++++++++++++-- .../Taskdeck.Application/Services/CardService.cs | 14 ++++++++++++-- .../Taskdeck.Application/Services/ColumnService.cs | 13 +++++++++++-- .../Taskdeck.Application/Services/LabelService.cs | 13 +++++++++++-- 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/BoardService.cs b/backend/src/Taskdeck.Application/Services/BoardService.cs index 5b8e69394..e51ece221 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,23 @@ 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; } public async Task> CreateBoardAsync(CreateBoardDto dto, Guid actingUserId, CancellationToken cancellationToken = default) @@ -139,6 +143,8 @@ private async Task> CreateBoardInternalAsync(CreateBoardDto dto await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(board.Id, "board", "created", board.Id, DateTimeOffset.UtcNow), cancellationToken); + if (_historyService != null) + await _historyService.LogActionAsync("board", board.Id, AuditAction.Created, ownerId, $"name={board.Name}"); return Result.Success(MapToDto(board)); } @@ -171,6 +177,8 @@ private async Task> UpdateBoardInternalAsync(Guid id, UpdateBoa await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(board.Id, "board", "updated", board.Id, DateTimeOffset.UtcNow), cancellationToken); + if (_historyService != null) + await _historyService.LogActionAsync("board", board.Id, AuditAction.Updated); return Result.Success(MapToDto(board)); } catch (DomainException ex) @@ -199,6 +207,8 @@ private async Task DeleteBoardInternalAsync(Guid id, CancellationToken c await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(board.Id, "board", "archived", board.Id, DateTimeOffset.UtcNow), cancellationToken); + if (_historyService != null) + await _historyService.LogActionAsync("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..a11ad7fe2 100644 --- a/backend/src/Taskdeck.Application/Services/CardService.cs +++ b/backend/src/Taskdeck.Application/Services/CardService.cs @@ -11,16 +11,18 @@ 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; } public async Task> CreateCardAsync(CreateCardDto dto, CancellationToken cancellationToken = default) @@ -74,6 +76,8 @@ public async Task> CreateCardAsync( await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(card.BoardId, "card", "created", card.Id, DateTimeOffset.UtcNow), cancellationToken); + if (_historyService != null) + await _historyService.LogActionAsync("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 +138,8 @@ public async Task> UpdateCardAsync( await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(card.BoardId, "card", "updated", card.Id, DateTimeOffset.UtcNow), cancellationToken); + if (_historyService != null) + await _historyService.LogActionAsync("card", card.Id, AuditAction.Updated, actorUserId); var updatedCard = await _unitOfWork.Cards.GetByIdWithLabelsAsync(id, cancellationToken); return Result.Success(MapToDto(updatedCard!)); @@ -213,6 +219,8 @@ public async Task> MoveCardAsync(Guid id, MoveCardDto dto, Cance await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(card.BoardId, "card", "moved", card.Id, DateTimeOffset.UtcNow), cancellationToken); + if (_historyService != null) + await _historyService.LogActionAsync("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 +307,8 @@ public async Task DeleteCardAsync(Guid id, CancellationToken cancellatio await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(card.BoardId, "card", "deleted", card.Id, DateTimeOffset.UtcNow), cancellationToken); + if (_historyService != null) + await _historyService.LogActionAsync("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..8bbb0558c 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,18 @@ 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; } public async Task> CreateColumnAsync(CreateColumnDto dto, CancellationToken cancellationToken = default) @@ -45,6 +48,8 @@ public async Task> CreateColumnAsync(CreateColumnDto dto, Canc await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(column.BoardId, "column", "created", column.Id, DateTimeOffset.UtcNow), cancellationToken); + if (_historyService != null) + await _historyService.LogActionAsync("column", column.Id, AuditAction.Created, changes: $"name={column.Name}"); return Result.Success(MapToDto(column)); } @@ -67,6 +72,8 @@ public async Task> UpdateColumnAsync(Guid id, UpdateColumnDto await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(column.BoardId, "column", "updated", column.Id, DateTimeOffset.UtcNow), cancellationToken); + if (_historyService != null) + await _historyService.LogActionAsync("column", column.Id, AuditAction.Updated); return Result.Success(MapToDto(column)); } @@ -105,6 +112,8 @@ public async Task DeleteColumnAsync(Guid id, CancellationToken cancellat await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(column.BoardId, "column", "deleted", column.Id, DateTimeOffset.UtcNow), cancellationToken); + if (_historyService != null) + await _historyService.LogActionAsync("column", column.Id, AuditAction.Deleted, changes: $"name={column.Name}"); return Result.Success(); } diff --git a/backend/src/Taskdeck.Application/Services/LabelService.cs b/backend/src/Taskdeck.Application/Services/LabelService.cs index 63f27f03b..11c7bed66 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,18 @@ 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; } public async Task> CreateLabelAsync(CreateLabelDto dto, CancellationToken cancellationToken = default) @@ -36,6 +39,8 @@ public async Task> CreateLabelAsync(CreateLabelDto dto, Cancell await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(label.BoardId, "label", "created", label.Id, DateTimeOffset.UtcNow), cancellationToken); + if (_historyService != null) + await _historyService.LogActionAsync("label", label.Id, AuditAction.Created, changes: $"name={label.Name}"); return Result.Success(MapToDto(label)); } @@ -58,6 +63,8 @@ public async Task> UpdateLabelAsync(Guid id, UpdateLabelDto dto await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(label.BoardId, "label", "updated", label.Id, DateTimeOffset.UtcNow), cancellationToken); + if (_historyService != null) + await _historyService.LogActionAsync("label", label.Id, AuditAction.Updated); return Result.Success(MapToDto(label)); } @@ -93,6 +100,8 @@ public async Task DeleteLabelAsync(Guid id, CancellationToken cancellati await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(label.BoardId, "label", "deleted", label.Id, DateTimeOffset.UtcNow), cancellationToken); + if (_historyService != null) + await _historyService.LogActionAsync("label", label.Id, AuditAction.Deleted, changes: $"name={label.Name}"); return Result.Success(); } From 11eec625f69355802d3a7a36af20d966915a5f9f Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 22:49:28 +0100 Subject: [PATCH 2/6] Register IHistoryService in DI container for audit injection Adds IHistoryService registration so mutation services receive the HistoryService instance via constructor injection. --- .../Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs | 1 + 1 file changed, 1 insertion(+) 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(); From fe30cda52331409e0a459649738cf5578657120b Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 22:49:32 +0100 Subject: [PATCH 3/6] Add tests for audit log recording on board mutations 12 tests covering card CRUD+move, column CRUD, board CRUD+archive, label CRUD, and backward compatibility when IHistoryService is absent. --- .../Services/BoardMutationAuditTests.cs | 341 ++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 backend/tests/Taskdeck.Application.Tests/Services/BoardMutationAuditTests.cs 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..e2be47fbb --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/BoardMutationAuditTests.cs @@ -0,0 +1,341 @@ +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(); + } + + #endregion +} From 5eeb45eb9bbade64fd9ae1a89ac84466ef9c4978 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 22:49:36 +0100 Subject: [PATCH 4/6] Default activity board selector to most-recently-active non-archived board Sort board options so non-archived boards appear first, ordered by updatedAt descending, instead of alphabetical order that could default to an archived board. --- .../taskdeck-web/src/composables/useActivityQuery.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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, From 1500e1a05fba617866c34d8757ade11e20f55938 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 22:49:40 +0100 Subject: [PATCH 5/6] Update activity query tests for new board selector ordering Adjust existing tests for updatedAt-based ordering and add test verifying archived boards sort after non-archived boards with most-recently-active default selection. --- .../composables/useActivityQuery.spec.ts | 44 +++++++++++++++---- 1 file changed, 35 insertions(+), 9 deletions(-) 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 () => { From 90e820b00758d186e236a1c4eb2cc59e12d30081 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 23:09:08 +0100 Subject: [PATCH 6/6] Harden audit logging: catch exceptions at both service and HistoryService level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SafeLogAsync wrapper in CardService, BoardService, ColumnService, LabelService to catch exceptions from IHistoryService — audit failure must never crash mutations - Add catch(Exception) in HistoryService.LogActionAsync for defense-in-depth - Fix UpdateBoardInternalAsync to record Archived action (not Updated) when IsArchived=true - Add audit logging to ColumnService.ReorderColumnsAsync (was missing) - Add test: CreateCard_AuditFailure_DoesNotCrashMutation - Add test: UpdateBoard_WithArchiveFlag_RecordsArchivedAction - Remove stale SCAFFOLDING comment from IHistoryService --- .../Services/BoardService.cs | 21 +++++-- .../Services/CardService.cs | 19 ++++--- .../Services/ColumnService.cs | 17 ++++-- .../Services/HistoryService.cs | 6 ++ .../Services/IHistoryService.cs | 1 - .../Services/LabelService.cs | 16 ++++-- .../Services/BoardMutationAuditTests.cs | 56 +++++++++++++++++++ 7 files changed, 109 insertions(+), 27 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/BoardService.cs b/backend/src/Taskdeck.Application/Services/BoardService.cs index e51ece221..40ccf807c 100644 --- a/backend/src/Taskdeck.Application/Services/BoardService.cs +++ b/backend/src/Taskdeck.Application/Services/BoardService.cs @@ -31,6 +31,13 @@ public BoardService( _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) { if (actingUserId == Guid.Empty) @@ -143,8 +150,7 @@ private async Task> CreateBoardInternalAsync(CreateBoardDto dto await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(board.Id, "board", "created", board.Id, DateTimeOffset.UtcNow), cancellationToken); - if (_historyService != null) - await _historyService.LogActionAsync("board", board.Id, AuditAction.Created, ownerId, $"name={board.Name}"); + await SafeLogAsync("board", board.Id, AuditAction.Created, ownerId, $"name={board.Name}"); return Result.Success(MapToDto(board)); } @@ -177,8 +183,12 @@ private async Task> UpdateBoardInternalAsync(Guid id, UpdateBoa await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(board.Id, "board", "updated", board.Id, DateTimeOffset.UtcNow), cancellationToken); - if (_historyService != null) - await _historyService.LogActionAsync("board", board.Id, AuditAction.Updated); + 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) @@ -207,8 +217,7 @@ private async Task DeleteBoardInternalAsync(Guid id, CancellationToken c await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(board.Id, "board", "archived", board.Id, DateTimeOffset.UtcNow), cancellationToken); - if (_historyService != null) - await _historyService.LogActionAsync("board", board.Id, AuditAction.Archived, changes: $"name={board.Name}"); + 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 a11ad7fe2..6ff968344 100644 --- a/backend/src/Taskdeck.Application/Services/CardService.cs +++ b/backend/src/Taskdeck.Application/Services/CardService.cs @@ -25,6 +25,13 @@ public CardService(IUnitOfWork unitOfWork, IBoardRealtimeNotifier? realtimeNotif _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) { return await CreateCardAsync(dto, cardId: null, cancellationToken); @@ -76,8 +83,7 @@ public async Task> CreateCardAsync( await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(card.BoardId, "card", "created", card.Id, DateTimeOffset.UtcNow), cancellationToken); - if (_historyService != null) - await _historyService.LogActionAsync("card", card.Id, AuditAction.Created, changes: $"title={card.Title}"); + 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!)); @@ -138,8 +144,7 @@ public async Task> UpdateCardAsync( await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(card.BoardId, "card", "updated", card.Id, DateTimeOffset.UtcNow), cancellationToken); - if (_historyService != null) - await _historyService.LogActionAsync("card", card.Id, AuditAction.Updated, actorUserId); + await SafeLogAsync("card", card.Id, AuditAction.Updated, actorUserId); var updatedCard = await _unitOfWork.Cards.GetByIdWithLabelsAsync(id, cancellationToken); return Result.Success(MapToDto(updatedCard!)); @@ -219,8 +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); - if (_historyService != null) - await _historyService.LogActionAsync("card", card.Id, AuditAction.Moved, changes: $"target_column={dto.TargetColumnId}; position={dto.TargetPosition}"); + 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!)); @@ -307,8 +311,7 @@ public async Task DeleteCardAsync(Guid id, CancellationToken cancellatio await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(card.BoardId, "card", "deleted", card.Id, DateTimeOffset.UtcNow), cancellationToken); - if (_historyService != null) - await _historyService.LogActionAsync("card", card.Id, AuditAction.Deleted, changes: $"title={card.Title}"); + 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 8bbb0558c..d96be3eaa 100644 --- a/backend/src/Taskdeck.Application/Services/ColumnService.cs +++ b/backend/src/Taskdeck.Application/Services/ColumnService.cs @@ -25,6 +25,13 @@ public ColumnService(IUnitOfWork unitOfWork, IBoardRealtimeNotifier? realtimeNot _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) { try @@ -48,8 +55,7 @@ public async Task> CreateColumnAsync(CreateColumnDto dto, Canc await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(column.BoardId, "column", "created", column.Id, DateTimeOffset.UtcNow), cancellationToken); - if (_historyService != null) - await _historyService.LogActionAsync("column", column.Id, AuditAction.Created, changes: $"name={column.Name}"); + await SafeLogAsync("column", column.Id, AuditAction.Created, changes: $"name={column.Name}"); return Result.Success(MapToDto(column)); } @@ -72,8 +78,7 @@ public async Task> UpdateColumnAsync(Guid id, UpdateColumnDto await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(column.BoardId, "column", "updated", column.Id, DateTimeOffset.UtcNow), cancellationToken); - if (_historyService != null) - await _historyService.LogActionAsync("column", column.Id, AuditAction.Updated); + await SafeLogAsync("column", column.Id, AuditAction.Updated); return Result.Success(MapToDto(column)); } @@ -112,8 +117,7 @@ public async Task DeleteColumnAsync(Guid id, CancellationToken cancellat await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(column.BoardId, "column", "deleted", column.Id, DateTimeOffset.UtcNow), cancellationToken); - if (_historyService != null) - await _historyService.LogActionAsync("column", column.Id, AuditAction.Deleted, changes: $"name={column.Name}"); + await SafeLogAsync("column", column.Id, AuditAction.Deleted, changes: $"name={column.Name}"); return Result.Success(); } @@ -175,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 11c7bed66..e2ac02784 100644 --- a/backend/src/Taskdeck.Application/Services/LabelService.cs +++ b/backend/src/Taskdeck.Application/Services/LabelService.cs @@ -25,6 +25,13 @@ public LabelService(IUnitOfWork unitOfWork, IBoardRealtimeNotifier? realtimeNoti _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) { try @@ -39,8 +46,7 @@ public async Task> CreateLabelAsync(CreateLabelDto dto, Cancell await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(label.BoardId, "label", "created", label.Id, DateTimeOffset.UtcNow), cancellationToken); - if (_historyService != null) - await _historyService.LogActionAsync("label", label.Id, AuditAction.Created, changes: $"name={label.Name}"); + await SafeLogAsync("label", label.Id, AuditAction.Created, changes: $"name={label.Name}"); return Result.Success(MapToDto(label)); } @@ -63,8 +69,7 @@ public async Task> UpdateLabelAsync(Guid id, UpdateLabelDto dto await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(label.BoardId, "label", "updated", label.Id, DateTimeOffset.UtcNow), cancellationToken); - if (_historyService != null) - await _historyService.LogActionAsync("label", label.Id, AuditAction.Updated); + await SafeLogAsync("label", label.Id, AuditAction.Updated); return Result.Success(MapToDto(label)); } @@ -100,8 +105,7 @@ public async Task DeleteLabelAsync(Guid id, CancellationToken cancellati await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(label.BoardId, "label", "deleted", label.Id, DateTimeOffset.UtcNow), cancellationToken); - if (_historyService != null) - await _historyService.LogActionAsync("label", label.Id, AuditAction.Deleted, changes: $"name={label.Name}"); + 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 index e2be47fbb..d3e91114f 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/BoardMutationAuditTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/BoardMutationAuditTests.cs @@ -337,5 +337,61 @@ public async Task CreateCard_WithoutHistoryService_StillSucceeds() 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 }