diff --git a/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs b/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs index e683d2b0e..c39f40fd0 100644 --- a/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs +++ b/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs @@ -29,6 +29,7 @@ public class AutomationProposalsController : AuthenticatedControllerBase private readonly IAutomationExecutorService _executorService; private readonly ISimilarDecisionService _similarDecisionService; private readonly BoardAuthorizationService _authorizationService; + private readonly IProposalConflictDetector _conflictDetector; private readonly IProvenanceQueryService _provenanceQueryService; private readonly IConfidenceBreakdownService _confidenceBreakdownService; private readonly ICardHistoryService _cardHistoryService; @@ -39,6 +40,7 @@ public AutomationProposalsController( IAutomationExecutorService executorService, ISimilarDecisionService similarDecisionService, BoardAuthorizationService authorizationService, + IProposalConflictDetector conflictDetector, IProvenanceQueryService provenanceQueryService, IConfidenceBreakdownService confidenceBreakdownService, ICardHistoryService cardHistoryService, @@ -49,6 +51,7 @@ public AutomationProposalsController( _executorService = executorService; _similarDecisionService = similarDecisionService; _authorizationService = authorizationService; + _conflictDetector = conflictDetector; _provenanceQueryService = provenanceQueryService; _confidenceBreakdownService = confidenceBreakdownService; _cardHistoryService = cardHistoryService; @@ -277,6 +280,23 @@ public async Task DismissProposals( : result.ToErrorActionResult(); } + /// + /// Gets tone-classified conflict/warning/status rows for a proposal. + /// + [HttpGet("{id}/conflicts")] + public async Task GetProposalConflicts(Guid id, CancellationToken cancellationToken = default) + { + if (!TryGetCurrentUserId(out var callerUserId, out var errorResult)) + return errorResult!; + + var auth = await AuthorizeProposalAsync(id, callerUserId, requireWriteAccess: false, cancellationToken); + if (auth.ErrorResult is not null) + return auth.ErrorResult; + + var result = await _conflictDetector.DetectConflictsAsync(id, callerUserId, cancellationToken); + return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); + } + /// /// Gets the card history ledger for a proposal, showing all touches on affected cards. /// @@ -311,7 +331,6 @@ public async Task GetProposalSideEffects(Guid id, CancellationTok var result = await _sideEffectAnalyzer.AnalyzeAsync(id, cancellationToken); return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } - /// /// Gets a diff preview for a proposal showing what changes will be made. /// diff --git a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs index bf18f9209..8f1c74bcf 100644 --- a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs @@ -48,6 +48,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/src/Taskdeck.Application/DTOs/ConflictRowDto.cs b/backend/src/Taskdeck.Application/DTOs/ConflictRowDto.cs new file mode 100644 index 000000000..ac0bc2479 --- /dev/null +++ b/backend/src/Taskdeck.Application/DTOs/ConflictRowDto.cs @@ -0,0 +1,18 @@ +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.DTOs; + +/// +/// DTO for a single conflict/warning/status row returned by the conflict detector. +/// +public record ConflictRowDto( + ConflictTone Tone, + string Key, + string Value +) +{ + public static ConflictRowDto FromDomain(ConflictRow row) + { + return new ConflictRowDto(row.Tone, row.Key, row.Value); + } +} diff --git a/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs index f067b0348..21265ff34 100644 --- a/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs +++ b/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs @@ -20,6 +20,7 @@ public interface IAutomationProposalRepository : IRepository string actionType, ProposalSourceType sourceType, CancellationToken cancellationToken = default); + Task> GetPendingByOperationTargetAsync(string targetType, string targetId, CancellationToken cancellationToken = default); Task> GetExpiredAsync(CancellationToken cancellationToken = default); /// diff --git a/backend/src/Taskdeck.Application/Interfaces/ICardCommentRepository.cs b/backend/src/Taskdeck.Application/Interfaces/ICardCommentRepository.cs index 8519d7b8d..71e851b78 100644 --- a/backend/src/Taskdeck.Application/Interfaces/ICardCommentRepository.cs +++ b/backend/src/Taskdeck.Application/Interfaces/ICardCommentRepository.cs @@ -5,5 +5,6 @@ namespace Taskdeck.Application.Interfaces; public interface ICardCommentRepository : IRepository { Task> GetByCardIdAsync(Guid cardId, CancellationToken cancellationToken = default); + Task CountByCardIdAsync(Guid cardId, CancellationToken cancellationToken = default); Task GetByIdWithMentionsAsync(Guid id, CancellationToken cancellationToken = default); } diff --git a/backend/src/Taskdeck.Application/Services/IProposalConflictDetector.cs b/backend/src/Taskdeck.Application/Services/IProposalConflictDetector.cs new file mode 100644 index 000000000..6a20a70b9 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/IProposalConflictDetector.cs @@ -0,0 +1,16 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Common; + +namespace Taskdeck.Application.Services; + +/// +/// Detects conflicts, warnings, and status signals for a proposal. +/// Returns a tone-classified list of rows for the review UI. +/// +public interface IProposalConflictDetector +{ + Task>> DetectConflictsAsync( + Guid proposalId, + Guid userId, + CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Services/ProposalConflictDetector.cs b/backend/src/Taskdeck.Application/Services/ProposalConflictDetector.cs new file mode 100644 index 000000000..7f32a6b12 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/ProposalConflictDetector.cs @@ -0,0 +1,540 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Application.Services; + +/// +/// Analyzes a proposal and produces tone-classified conflict/warning/status rows +/// for the review UI (section IV: Conflicts and warnings). +/// +public class ProposalConflictDetector : IProposalConflictDetector +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IAuthorizationService _authorizationService; + + public ProposalConflictDetector( + IUnitOfWork unitOfWork, + IAuthorizationService authorizationService) + { + _unitOfWork = unitOfWork; + _authorizationService = authorizationService; + } + + public async Task>> DetectConflictsAsync( + Guid proposalId, + Guid userId, + CancellationToken cancellationToken = default) + { + var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(proposalId, cancellationToken); + if (proposal is null) + return Result.Failure>(ErrorCodes.NotFound, "Proposal not found"); + + // Authorization: board-scoped proposals require current board read access, + // even for the original proposal owner, matching controller-level read paths. + var authResult = await AuthorizeAccessAsync(proposal, userId, cancellationToken); + if (!authResult.IsSuccess) + return Result.Failure>(authResult.ErrorCode, authResult.ErrorMessage); + + var rows = new List(); + var flaggedCardIds = new HashSet(); + var flaggedColumnIds = new HashSet(); + + // Entity caches to avoid redundant DB lookups across sub-methods + var cardCache = new Dictionary(); + var columnCache = new Dictionary(); + var projectedColumnChanges = await GetProjectedColumnChangesAsync( + proposal, cardCache, cancellationToken); + + // Check each condition and collect rows + await CheckStaleDataAsync(proposal, rows, flaggedCardIds, cardCache, cancellationToken); + await CheckWipLimitAsync(proposal, rows, flaggedColumnIds, columnCache, + projectedColumnChanges, cancellationToken); + await CheckDuplicatePendingProposalsAsync(proposal, rows, cancellationToken); + CheckHighRiskOperations(proposal, rows); + await CheckOutboundWebhooksAsync(proposal, rows, cancellationToken); + await CheckActiveCommentsAsync(proposal, rows, cancellationToken); + CheckMultipleOperationsOnSameCard(proposal, rows); + + // If no warnings or info rows, emit an Ok row + if (rows.Count == 0) + { + rows.Add(new ConflictRow(ConflictTone.Ok, "status", "No conflicts detected")); + } + else + { + // Add positive signals when applicable + await AddPositiveSignalsAsync(proposal, rows, flaggedCardIds, flaggedColumnIds, + cardCache, columnCache, projectedColumnChanges, cancellationToken); + } + + // Sort: Warn first, then Info, then Ok + var sorted = rows + .OrderBy(r => r.Tone) + .ToList(); + + return Result.Success>( + sorted.Select(ConflictRowDto.FromDomain).ToList()); + } + + private async Task AuthorizeAccessAsync( + AutomationProposal proposal, + Guid userId, + CancellationToken cancellationToken) + { + if (proposal.BoardId.HasValue) + { + var canRead = await _authorizationService.CanReadBoardAsync(userId, proposal.BoardId.Value); + if (canRead.IsSuccess && canRead.Value) + return Result.Success(); + + return Result.Failure(ErrorCodes.Forbidden, "You do not have permission to view conflicts for this proposal"); + } + + return proposal.RequestedByUserId == userId + ? Result.Success() + : Result.Failure(ErrorCodes.Forbidden, "You do not have permission to view conflicts for this proposal"); + } + + /// + /// Warn: target card was modified since the proposal was generated. + /// Compares card's UpdatedAt against proposal's CreatedAt. + /// Skips create operations since those cards don't exist yet. + /// + private async Task CheckStaleDataAsync( + AutomationProposal proposal, + List rows, + HashSet flaggedCardIds, + Dictionary cardCache, + CancellationToken cancellationToken) + { + var cardTargetIds = GetDistinctCardTargetIds(proposal, includeCreate: false); + if (cardTargetIds.Count == 0) return; + + foreach (var cardId in cardTargetIds) + { + var card = await GetOrFetchCardAsync(cardId, cardCache, cancellationToken); + if (card is null) + { + flaggedCardIds.Add(cardId); + rows.Add(new ConflictRow( + ConflictTone.Warn, + "missing-target", + $"Target card {cardId} no longer exists")); + continue; + } + + if (card.UpdatedAt > proposal.CreatedAt) + { + flaggedCardIds.Add(cardId); + rows.Add(new ConflictRow( + ConflictTone.Warn, + "stale-data", + $"Card \"{card.Title}\" was modified after this proposal was generated")); + } + } + } + + /// + /// Warn: target column is at or above WIP limit. + /// Checks operations that move or create cards into a column. + /// + private async Task CheckWipLimitAsync( + AutomationProposal proposal, + List rows, + HashSet flaggedColumnIds, + Dictionary columnCache, + IReadOnlyDictionary projectedColumnChanges, + CancellationToken cancellationToken) + { + if (projectedColumnChanges.Count == 0) return; + + foreach (var (columnId, projection) in projectedColumnChanges) + { + var column = await GetOrFetchColumnAsync(columnId, columnCache, cancellationToken); + if (column is null) + { + if (projection.ReceivesCards) + { + flaggedColumnIds.Add(columnId); + rows.Add(new ConflictRow( + ConflictTone.Warn, + "missing-target-column", + $"Target column {columnId:N} no longer exists")); + } + + continue; + } + + if (!projection.ReceivesCards) + continue; + + var projectedCount = column.Cards.Count + projection.Delta; + if (column.WipLimit.HasValue && projectedCount > column.WipLimit.Value) + { + flaggedColumnIds.Add(columnId); + rows.Add(new ConflictRow( + ConflictTone.Warn, + "wip-limit", + $"Column \"{column.Name}\" would exceed WIP limit ({projectedCount}/{column.WipLimit.Value})")); + } + } + } + + /// + /// Warn: another pending proposal targets the same card. + /// Queries for ANY pending proposals on the target card, not just the latest. + /// + private async Task CheckDuplicatePendingProposalsAsync( + AutomationProposal proposal, + List rows, + CancellationToken cancellationToken) + { + var cardTargetIds = GetDistinctCardTargetIds(proposal, includeCreate: true); + if (cardTargetIds.Count == 0) return; + + foreach (var cardId in cardTargetIds) + { + var pendingProposals = await _unitOfWork.AutomationProposals + .GetPendingByOperationTargetAsync("card", cardId.ToString("D"), cancellationToken); + + var hasDuplicate = pendingProposals.Any(p => p.Id != proposal.Id); + if (hasDuplicate) + { + rows.Add(new ConflictRow( + ConflictTone.Warn, + "duplicate-proposal", + $"Another pending proposal also targets card {cardId:N}")); + } + } + } + + /// + /// Warn: proposal risk level is High or Critical. + /// + private static void CheckHighRiskOperations( + AutomationProposal proposal, + List rows) + { + if (proposal.RiskLevel is RiskLevel.High or RiskLevel.Critical) + { + rows.Add(new ConflictRow( + ConflictTone.Warn, + "high-risk", + $"Proposal risk level is {proposal.RiskLevel}")); + } + } + + /// + /// Info: proposal will trigger outbound webhooks. + /// + private async Task CheckOutboundWebhooksAsync( + AutomationProposal proposal, + List rows, + CancellationToken cancellationToken) + { + if (!proposal.BoardId.HasValue) return; + + var eventTypes = GetWebhookEventTypes(proposal); + if (eventTypes.Count == 0) return; + + var webhooks = await _unitOfWork.OutboundWebhookSubscriptions + .GetActiveByBoardAsync(proposal.BoardId.Value, cancellationToken); + + var matchingWebhookCount = webhooks + .Count(webhook => eventTypes.Any(webhook.MatchesEvent)); + + if (matchingWebhookCount > 0) + { + rows.Add(new ConflictRow( + ConflictTone.Info, + "webhooks", + $"This proposal will trigger {matchingWebhookCount} outbound webhook(s)")); + } + } + + /// + /// Info: target card has active comments/discussion. + /// + private async Task CheckActiveCommentsAsync( + AutomationProposal proposal, + List rows, + CancellationToken cancellationToken) + { + var cardTargetIds = GetDistinctCardTargetIds(proposal, includeCreate: false); + if (cardTargetIds.Count == 0) return; + + foreach (var cardId in cardTargetIds) + { + var commentCount = await _unitOfWork.CardComments.CountByCardIdAsync(cardId, cancellationToken); + if (commentCount > 0) + { + rows.Add(new ConflictRow( + ConflictTone.Info, + "active-comments", + $"Card {cardId:N} has {commentCount} comment(s)")); + } + } + } + + /// + /// Info: multiple operations in the proposal affect the same card. + /// + private static void CheckMultipleOperationsOnSameCard( + AutomationProposal proposal, + List rows) + { + var cardOps = proposal.Operations + .Where(op => op.TargetType.Equals("card", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(op.TargetId)) + .GroupBy(op => op.TargetId!, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .ToList(); + + foreach (var group in cardOps) + { + rows.Add(new ConflictRow( + ConflictTone.Info, + "multi-op", + $"Card {group.Key} is affected by {group.Count()} operations in this proposal")); + } + } + + /// + /// Add positive Ok signals for target columns with capacity and fresh card data. + /// Only added when there are already some warn/info rows (otherwise the "no conflicts" row covers it). + /// Reuses cached entities to avoid redundant DB lookups. + /// + private async Task AddPositiveSignalsAsync( + AutomationProposal proposal, + List rows, + HashSet flaggedCardIds, + HashSet flaggedColumnIds, + Dictionary cardCache, + Dictionary columnCache, + IReadOnlyDictionary projectedColumnChanges, + CancellationToken cancellationToken) + { + // Ok: target column has capacity (only if we didn't already warn about WIP for this column) + foreach (var (columnId, projection) in projectedColumnChanges) + { + if (!projection.ReceivesCards) continue; + if (flaggedColumnIds.Contains(columnId)) continue; + + var column = await GetOrFetchColumnAsync(columnId, columnCache, cancellationToken); + if (column is null) continue; + + var projectedCount = column.Cards.Count + projection.Delta; + if (column.WipLimit.HasValue && projectedCount <= column.WipLimit.Value) + { + rows.Add(new ConflictRow( + ConflictTone.Ok, + "capacity", + $"Column \"{column.Name}\" has projected capacity ({projectedCount}/{column.WipLimit.Value})")); + } + } + + // Ok: card data is fresh (only for cards we didn't already flag as stale/missing) + var cardTargetIds = GetDistinctCardTargetIds(proposal, includeCreate: false); + foreach (var cardId in cardTargetIds) + { + if (flaggedCardIds.Contains(cardId)) continue; + + var card = await GetOrFetchCardAsync(cardId, cardCache, cancellationToken); + if (card is not null) + { + rows.Add(new ConflictRow( + ConflictTone.Ok, + "fresh-data", + $"Card \"{card.Title}\" data is current")); + } + } + } + + /// + /// Extracts distinct card GUIDs from proposal operations that target cards. + /// Excludes create operations since those cards don't exist yet and would + /// produce false stale/missing warnings. + /// + private static List GetDistinctCardTargetIds(AutomationProposal proposal, bool includeCreate) + { + return proposal.Operations + .Where(op => op.TargetType.Equals("card", StringComparison.OrdinalIgnoreCase) + && (includeCreate || !op.ActionType.Equals("create", StringComparison.OrdinalIgnoreCase)) + && !string.IsNullOrEmpty(op.TargetId) + && Guid.TryParse(op.TargetId, out _)) + .Select(op => Guid.Parse(op.TargetId!)) + .Distinct() + .ToList(); + } + + /// + /// Projects card count deltas per column for create/move operations. + /// Existing cards moved within their current column do not increase projected WIP. + /// + private async Task> GetProjectedColumnChangesAsync( + AutomationProposal proposal, + Dictionary cardCache, + CancellationToken cancellationToken) + { + var changes = new Dictionary(); + + foreach (var op in proposal.Operations) + { + if (!AddsCardToColumn(op)) + continue; + + var targetColumnId = TryGetTargetColumnId(op); + if (!targetColumnId.HasValue) + continue; + + var sourceColumnId = (Guid?)null; + if (op.ActionType.Equals("move", StringComparison.OrdinalIgnoreCase) + && op.TargetType.Equals("card", StringComparison.OrdinalIgnoreCase) + && Guid.TryParse(op.TargetId, out var movedCardId)) + { + var card = await GetOrFetchCardAsync(movedCardId, cardCache, cancellationToken); + if (card?.ColumnId == targetColumnId.Value) + continue; + + sourceColumnId = card?.ColumnId; + } + + if (sourceColumnId.HasValue) + AddColumnProjectionDelta(changes, sourceColumnId.Value, delta: -1, receivesCards: false); + + AddColumnProjectionDelta(changes, targetColumnId.Value, delta: 1, receivesCards: true); + } + + return changes; + } + + private static void AddColumnProjectionDelta( + Dictionary changes, + Guid columnId, + int delta, + bool receivesCards) + { + var existing = changes.GetValueOrDefault(columnId); + changes[columnId] = new ColumnProjection( + existing.Delta + delta, + existing.ReceivesCards || receivesCards); + } + + /// + /// Extracts a target column ID from an operation that moves or creates into a column. + /// Checks TargetType before Parameters so column-targeted operations with no + /// parameters are still detected. Parses JSON parameters for "columnId" or + /// "targetColumnId" fields when present. + /// + private static Guid? TryGetTargetColumnId(AutomationProposalOperation op) + { + // Target columns for column-targeted card movement/creation operations. + if (op.TargetType.Equals("column", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(op.TargetId) + && Guid.TryParse(op.TargetId, out var colTargetId)) + { + return colTargetId; + } + + if (string.IsNullOrWhiteSpace(op.Parameters)) return null; + + // Parse parameters JSON for columnId / targetColumnId fields + try + { + using var doc = System.Text.Json.JsonDocument.Parse(op.Parameters); + if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object) + return null; + + if (doc.RootElement.TryGetProperty("columnId", out var colProp) + && colProp.ValueKind == System.Text.Json.JsonValueKind.String + && Guid.TryParse(colProp.GetString(), out var columnId)) + { + return columnId; + } + + if (doc.RootElement.TryGetProperty("targetColumnId", out var targetColProp) + && targetColProp.ValueKind == System.Text.Json.JsonValueKind.String + && Guid.TryParse(targetColProp.GetString(), out var targetColumnId)) + { + return targetColumnId; + } + } + catch (System.Text.Json.JsonException) + { + // Malformed JSON in parameters -- skip silently + } + + return null; + } + + private static bool AddsCardToColumn(AutomationProposalOperation operation) + { + return operation.ActionType.Equals("create", StringComparison.OrdinalIgnoreCase) + || operation.ActionType.Equals("move", StringComparison.OrdinalIgnoreCase); + } + + private readonly record struct ColumnProjection(int Delta, bool ReceivesCards); + + private static IReadOnlyList GetWebhookEventTypes(AutomationProposal proposal) + { + return proposal.Operations + .Select(ToWebhookEventType) + .Where(eventType => eventType is not null) + .Distinct(StringComparer.Ordinal) + .Select(eventType => eventType!) + .ToList(); + } + + private static string? ToWebhookEventType(AutomationProposalOperation operation) + { + if (string.IsNullOrWhiteSpace(operation.TargetType) || string.IsNullOrWhiteSpace(operation.ActionType)) + return null; + + var entityType = operation.TargetType.Trim().ToLowerInvariant(); + var eventOperation = operation.ActionType.Trim().ToLowerInvariant() switch + { + "create" or "add" => "created", + "move" => "moved", + "delete" or "remove" => "deleted", + "archive" or "update" or "set" or "rename" or "reorder" or "assign" or "attach" or "block" or "unblock" or "restore" or "unarchive" => "updated", + _ => null + }; + + return eventOperation is null ? null : $"{entityType}.{eventOperation}"; + } + + /// + /// Fetches a card by ID, using the cache to avoid redundant lookups. + /// + private async Task GetOrFetchCardAsync( + Guid cardId, + Dictionary cache, + CancellationToken cancellationToken) + { + if (cache.TryGetValue(cardId, out var cached)) + return cached; + + var card = await _unitOfWork.Cards.GetByIdAsync(cardId, cancellationToken); + cache[cardId] = card; + return card; + } + + /// + /// Fetches a column with cards by ID, using the cache to avoid redundant lookups. + /// + private async Task GetOrFetchColumnAsync( + Guid columnId, + Dictionary cache, + CancellationToken cancellationToken) + { + if (cache.TryGetValue(columnId, out var cached)) + return cached; + + var column = await _unitOfWork.Columns.GetByIdWithCardsAsync(columnId, cancellationToken); + cache[columnId] = column; + return column; + } +} diff --git a/backend/src/Taskdeck.Domain/Entities/ConflictRow.cs b/backend/src/Taskdeck.Domain/Entities/ConflictRow.cs new file mode 100644 index 000000000..005d1f983 --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/ConflictRow.cs @@ -0,0 +1,39 @@ +namespace Taskdeck.Domain.Entities; + +/// +/// Value object representing a single conflict/warning/status row for a proposal. +/// Immutable after creation. +/// +public sealed class ConflictRow : IEquatable +{ + public ConflictTone Tone { get; } + public string Key { get; } + public string Value { get; } + + public ConflictRow(ConflictTone tone, string key, string value) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("Conflict row key cannot be empty.", nameof(key)); + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Conflict row value cannot be empty.", nameof(value)); + if (!Enum.IsDefined(typeof(ConflictTone), tone)) + throw new ArgumentException($"Invalid conflict tone: {tone}", nameof(tone)); + + Tone = tone; + Key = key; + Value = value; + } + + public bool Equals(ConflictRow? other) + { + if (other is null) return false; + if (ReferenceEquals(this, other)) return true; + return Tone == other.Tone && Key == other.Key && Value == other.Value; + } + + public override bool Equals(object? obj) => Equals(obj as ConflictRow); + + public override int GetHashCode() => HashCode.Combine(Tone, Key, Value); + + public override string ToString() => $"[{Tone}] {Key}: {Value}"; +} diff --git a/backend/src/Taskdeck.Domain/Entities/ConflictTone.cs b/backend/src/Taskdeck.Domain/Entities/ConflictTone.cs new file mode 100644 index 000000000..120db720e --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/ConflictTone.cs @@ -0,0 +1,12 @@ +namespace Taskdeck.Domain.Entities; + +/// +/// Tone classification for proposal conflict/warning rows. +/// Maps to frontend color semantics: Warn = rust, Info = mute, Ok = sage. +/// +public enum ConflictTone +{ + Warn, + Info, + Ok +} diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs index e56edf260..37ebdb5f1 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs @@ -201,6 +201,55 @@ public async Task> GetByRiskLevelAsync(RiskLevel .FirstOrDefaultAsync(p => p.Id == proposalId, cancellationToken); } + public async Task> GetPendingByOperationTargetAsync( + string targetType, + string targetId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(targetType) || string.IsNullOrWhiteSpace(targetId)) + return Array.Empty(); + + var normalizedTargetType = targetType.Trim().ToLowerInvariant(); + var targetIsGuid = Guid.TryParse(targetId, out var normalizedTargetGuid); + var now = DateTime.UtcNow; + + var candidateOperations = await _context.AutomationProposalOperations + .Join( + _dbSet.Where(p => p.Status == ProposalStatus.PendingReview && p.ExpiresAt > now), + operation => operation.ProposalId, + proposal => proposal.Id, + (operation, proposal) => new + { + operation.ProposalId, + operation.TargetType, + operation.TargetId + }) + .Where(op => op.TargetType.ToLower() == normalizedTargetType && op.TargetId != null) + .ToListAsync(cancellationToken); + + var proposalIds = candidateOperations + .Where(op => TargetIdMatches(op.TargetId!, targetId, targetIsGuid, normalizedTargetGuid)) + .Select(op => op.ProposalId) + .Distinct() + .ToList(); + + if (proposalIds.Count == 0) + return Array.Empty(); + + return await _dbSet + .Include(p => p.Operations) + .Where(p => proposalIds.Contains(p.Id) && p.Status == ProposalStatus.PendingReview) + .ToListAsync(cancellationToken); + } + + private static bool TargetIdMatches(string storedTargetId, string requestedTargetId, bool requestedTargetIsGuid, Guid requestedTargetGuid) + { + if (requestedTargetIsGuid && Guid.TryParse(storedTargetId, out var storedTargetGuid)) + return storedTargetGuid == requestedTargetGuid; + + return string.Equals(storedTargetId, requestedTargetId, StringComparison.OrdinalIgnoreCase); + } + public async Task> GetExpiredAsync(CancellationToken cancellationToken = default) { var now = DateTime.UtcNow; diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/CardCommentRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/CardCommentRepository.cs index 6ae44aa7c..6f2157201 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/CardCommentRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/CardCommentRepository.cs @@ -27,6 +27,14 @@ public async Task> GetByCardIdAsync( .ToList(); } + public async Task CountByCardIdAsync( + Guid cardId, + CancellationToken cancellationToken = default) + { + return await _dbSet + .CountAsync(comment => comment.CardId == cardId && !comment.IsDeleted, cancellationToken); + } + public async Task GetByIdWithMentionsAsync( Guid id, CancellationToken cancellationToken = default) diff --git a/backend/tests/Taskdeck.Api.Tests/AutomationProposalRepositoryIntegrationTests.cs b/backend/tests/Taskdeck.Api.Tests/AutomationProposalRepositoryIntegrationTests.cs index 3998dcedf..f5a84c890 100644 --- a/backend/tests/Taskdeck.Api.Tests/AutomationProposalRepositoryIntegrationTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/AutomationProposalRepositoryIntegrationTests.cs @@ -390,6 +390,41 @@ await db.Database.ExecuteSqlInterpolatedAsync( results.Should().NotContain(p => p.Id == proposal.Id); } + [Fact] + public async Task GetPendingByOperationTargetAsync_NormalizesGuidAndTargetType_ExcludesExpired() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var repo = scope.ServiceProvider.GetRequiredService(); + + var user = new User($"ap-target-user-{Guid.NewGuid():N}", $"ap-target-{Guid.NewGuid():N}@example.com", "hash"); + db.Users.Add(user); + + var targetId = Guid.NewGuid(); + var pending = new AutomationProposal( + ProposalSourceType.Queue, user.Id, "Pending target", RiskLevel.Low, + $"corr-pending-target-{Guid.NewGuid():N}"); + pending.AddOperation(new AutomationProposalOperation( + pending.Id, 0, "update", "Card", "{\"title\":\"Updated\"}", + $"key-pending-{Guid.NewGuid():N}", targetId: targetId.ToString("B"))); + + var expired = new AutomationProposal( + ProposalSourceType.Queue, user.Id, "Expired target", RiskLevel.Low, + $"corr-expired-target-{Guid.NewGuid():N}"); + expired.AddOperation(new AutomationProposalOperation( + expired.Id, 0, "update", "card", "{\"title\":\"Expired\"}", + $"key-expired-{Guid.NewGuid():N}", targetId: targetId.ToString("D").ToUpperInvariant())); + SetExpiresAt(expired, DateTime.UtcNow.AddMinutes(-1)); + + db.AutomationProposals.AddRange(pending, expired); + await db.SaveChangesAsync(); + + var results = await repo.GetPendingByOperationTargetAsync(" CARD ", targetId.ToString("N").ToUpperInvariant()); + + results.Should().ContainSingle(p => p.Id == pending.Id); + results.Should().NotContain(p => p.Id == expired.Id); + } + private static void SetExpiresAt(AutomationProposal proposal, DateTime expiresAt) { typeof(AutomationProposal) diff --git a/backend/tests/Taskdeck.Api.Tests/CardCommentRepositoryIntegrationTests.cs b/backend/tests/Taskdeck.Api.Tests/CardCommentRepositoryIntegrationTests.cs new file mode 100644 index 000000000..054ce3d32 --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/CardCommentRepositoryIntegrationTests.cs @@ -0,0 +1,45 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Entities; +using Taskdeck.Infrastructure.Persistence; +using Xunit; + +namespace Taskdeck.Api.Tests; + +public class CardCommentRepositoryIntegrationTests : IClassFixture +{ + private readonly TestWebApplicationFactory _factory; + + public CardCommentRepositoryIntegrationTests(TestWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task CountByCardIdAsync_ExcludesSoftDeletedComments() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var repo = scope.ServiceProvider.GetRequiredService(); + + var user = new User($"comment-{Guid.NewGuid():N}", $"comment-count-{Guid.NewGuid():N}@example.com", "hash"); + var board = new Board("Comment count board", ownerId: user.Id); + var column = new Column(board.Id, "Todo", 0); + var card = new Card(board.Id, column.Id, "Commented card"); + var activeComment = new CardComment(card.Id, board.Id, user.Id, "Still active"); + var deletedComment = new CardComment(card.Id, board.Id, user.Id, "Now deleted"); + deletedComment.SoftDelete(); + + db.Users.Add(user); + db.Boards.Add(board); + db.Columns.Add(column); + db.Cards.Add(card); + db.CardComments.AddRange(activeComment, deletedComment); + await db.SaveChangesAsync(); + + var count = await repo.CountByCardIdAsync(card.Id); + + count.Should().Be(1); + } +} diff --git a/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs b/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs index 6a94fb742..8e64bf036 100644 --- a/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs @@ -279,6 +279,7 @@ public Task> GetByStatusAsync( public Task GetByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetLatestByOperationTargetAsync(string targetType, string targetId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetLatestByOperationTargetAsync(string targetType, string targetId, string actionType, ProposalSourceType sourceType, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetPendingByOperationTargetAsync(string targetType, string targetId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetExpiredAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetTerminalByActionTypeAsync(string actionType, Guid? boardId, Guid userId, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } @@ -304,6 +305,7 @@ public Task> GetByStatusAsync( public Task GetByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetLatestByOperationTargetAsync(string targetType, string targetId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetLatestByOperationTargetAsync(string targetType, string targetId, string actionType, ProposalSourceType sourceType, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetPendingByOperationTargetAsync(string targetType, string targetId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetExpiredAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetTerminalByActionTypeAsync(string actionType, Guid? boardId, Guid userId, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } diff --git a/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs b/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs index 982b6a4af..019567508 100644 --- a/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs @@ -181,6 +181,14 @@ public Task> GetByRiskLevelAsync(RiskLevel riskL throw new NotSupportedException(); } + public Task> GetPendingByOperationTargetAsync( + string targetType, + string targetId, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + public Task> GetExpiredAsync(CancellationToken cancellationToken = default) { throw new NotSupportedException(); diff --git a/backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs b/backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs index 919c2e752..df65a90f8 100644 --- a/backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs @@ -588,6 +588,8 @@ public Task> GetByRiskLevelAsync(RiskLevel riskL => throw new NotSupportedException(); public Task GetLatestByOperationTargetAsync(string targetType, string targetId, string actionType, ProposalSourceType sourceType, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetPendingByOperationTargetAsync(string targetType, string targetId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); public Task> GetTerminalByActionTypeAsync(string actionType, Guid? boardId, Guid userId, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } diff --git a/backend/tests/Taskdeck.Application.Tests/Services/ProposalConflictDetectorTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/ProposalConflictDetectorTests.cs new file mode 100644 index 000000000..24eda162c --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/ProposalConflictDetectorTests.cs @@ -0,0 +1,1211 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class ProposalConflictDetectorTests +{ + private readonly Mock _unitOfWorkMock; + private readonly Mock _proposalRepoMock; + private readonly Mock _cardRepoMock; + private readonly Mock _columnRepoMock; + private readonly Mock _commentRepoMock; + private readonly Mock _webhookRepoMock; + private readonly Mock _authServiceMock; + private readonly ProposalConflictDetector _detector; + + private readonly Guid _userId = Guid.NewGuid(); + private readonly Guid _boardId = Guid.NewGuid(); + + public ProposalConflictDetectorTests() + { + _unitOfWorkMock = new Mock(); + _proposalRepoMock = new Mock(); + _cardRepoMock = new Mock(); + _columnRepoMock = new Mock(); + _commentRepoMock = new Mock(); + _webhookRepoMock = new Mock(); + _authServiceMock = new Mock(); + + _unitOfWorkMock.Setup(u => u.AutomationProposals).Returns(_proposalRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Cards).Returns(_cardRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Columns).Returns(_columnRepoMock.Object); + _unitOfWorkMock.Setup(u => u.CardComments).Returns(_commentRepoMock.Object); + _unitOfWorkMock.Setup(u => u.OutboundWebhookSubscriptions).Returns(_webhookRepoMock.Object); + _authServiceMock.Setup(a => a.CanReadBoardAsync(_userId, It.IsAny())) + .ReturnsAsync(Result.Success(true)); + + _detector = new ProposalConflictDetector( + _unitOfWorkMock.Object, + _authServiceMock.Object); + } + + #region Authorization and Not Found + + [Fact] + public async Task DetectConflictsAsync_ProposalNotFound_ReturnsNotFound() + { + _proposalRepoMock.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((AutomationProposal?)null); + + var result = await _detector.DetectConflictsAsync(Guid.NewGuid(), _userId); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + [Fact] + public async Task DetectConflictsAsync_OwnerHasAccess() + { + var proposal = CreateProposal(_userId, _boardId); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + SetupNoConflicts(); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task DetectConflictsAsync_OwnerWithoutCurrentBoardAccess_ReturnsForbidden() + { + var proposal = CreateProposal(_userId, _boardId); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _authServiceMock.Setup(a => a.CanReadBoardAsync(_userId, _boardId)) + .ReturnsAsync(Result.Success(false)); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Forbidden); + } + + [Fact] + public async Task DetectConflictsAsync_NonOwnerWithBoardAccess_Succeeds() + { + var ownerId = Guid.NewGuid(); + var proposal = CreateProposal(ownerId, _boardId); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _authServiceMock.Setup(a => a.CanReadBoardAsync(_userId, _boardId)) + .ReturnsAsync(Result.Success(true)); + SetupNoConflicts(); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task DetectConflictsAsync_NonOwnerWithoutBoardAccess_ReturnsForbidden() + { + var ownerId = Guid.NewGuid(); + var proposal = CreateProposal(ownerId, _boardId); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _authServiceMock.Setup(a => a.CanReadBoardAsync(_userId, _boardId)) + .ReturnsAsync(Result.Success(false)); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Forbidden); + } + + [Fact] + public async Task DetectConflictsAsync_NonOwnerNoBoardScope_ReturnsForbidden() + { + var ownerId = Guid.NewGuid(); + var proposal = CreateProposal(ownerId, boardId: null); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Forbidden); + } + + #endregion + + #region No Conflicts (Ok) + + [Fact] + public async Task DetectConflictsAsync_NoOperations_ReturnsOkRow() + { + var proposal = CreateProposal(_userId, _boardId); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + SetupNoConflicts(); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(1); + result.Value[0].Tone.Should().Be(ConflictTone.Ok); + result.Value[0].Key.Should().Be("status"); + result.Value[0].Value.Should().Be("No conflicts detected"); + } + + #endregion + + #region Warn: Stale Data + + [Fact] + public async Task DetectConflictsAsync_CardModifiedAfterProposal_ReturnsStaleWarning() + { + var cardId = Guid.NewGuid(); + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "move"); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + // Card updated after proposal was created + var card = new Card(_boardId, Guid.NewGuid(), "Test Card"); + // Touch the card to move its UpdatedAt ahead + card.Update(title: "Updated Title"); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + SetupEmptySecondaryChecks(proposal); + SetupNoDuplicateProposal(cardId); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Warn && r.Key == "stale-data"); + } + + [Fact] + public async Task DetectConflictsAsync_CardDeleted_ReturnsMissingTargetWarning() + { + var cardId = Guid.NewGuid(); + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "update"); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync((Card?)null); + SetupEmptySecondaryChecks(proposal); + SetupNoDuplicateProposal(cardId); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Warn && r.Key == "missing-target"); + } + + #endregion + + #region Warn: WIP Limit + + [Fact] + public async Task DetectConflictsAsync_ColumnAtWipLimit_ReturnsWipWarning() + { + var columnId = Guid.NewGuid(); + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + var proposal = CreateProposalWithMoveOp(_userId, _boardId, cardId, columnId); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + // Column with WIP limit of 2 and 2 cards already + var column = new Column(_boardId, "In Progress", 1, wipLimit: 2); + AddCardsToColumn(column, 2); + + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(columnId, It.IsAny())) + .ReturnsAsync(column); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + SetupEmptySecondaryChecks(proposal, cardId); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Warn && r.Key == "wip-limit"); + } + + [Fact] + public async Task DetectConflictsAsync_ColumnBelowWipLimit_NoWipWarning() + { + var columnId = Guid.NewGuid(); + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + var proposal = CreateProposalWithMoveOp(_userId, _boardId, cardId, columnId); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + // Column with WIP limit of 5 and 2 cards + var column = new Column(_boardId, "In Progress", 1, wipLimit: 5); + AddCardsToColumn(column, 2); + + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(columnId, It.IsAny())) + .ReturnsAsync(column); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + SetupEmptySecondaryChecks(proposal, cardId); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "wip-limit"); + } + + [Fact] + public async Task DetectConflictsAsync_ProjectedCreatesExceedWipLimit_ReturnsWipWarning() + { + var columnId = Guid.NewGuid(); + var proposal = CreateProposal(_userId, _boardId); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "create", "column", "{}", Guid.NewGuid().ToString(), columnId.ToString())); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 1, "create", "column", "{}", Guid.NewGuid().ToString(), columnId.ToString())); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + var column = new Column(_boardId, "In Progress", 1, wipLimit: 5); + AddCardsToColumn(column, 4); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(columnId, It.IsAny())) + .ReturnsAsync(column); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => + r.Tone == ConflictTone.Warn + && r.Key == "wip-limit" + && r.Value.Contains("(6/5)", StringComparison.Ordinal)); + } + + [Fact] + public async Task DetectConflictsAsync_MoveWithinSameColumn_DoesNotIncreaseProjectedWip() + { + var columnId = Guid.NewGuid(); + var cardId = Guid.NewGuid(); + var card = new Card(cardId, _boardId, columnId, "Same column move"); + var proposal = CreateProposalWithMoveOp(_userId, _boardId, cardId, columnId); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + var column = new Column(_boardId, "In Progress", 1, wipLimit: 1); + AddCardsToColumn(column, 1); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(columnId, It.IsAny())) + .ReturnsAsync(column); + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + SetupEmptySecondaryChecks(proposal, cardId); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "wip-limit"); + } + + [Fact] + public async Task DetectConflictsAsync_CreateIntoMissingColumn_ReturnsMissingTargetColumnWarning() + { + var columnId = Guid.NewGuid(); + var proposal = CreateProposal(_userId, _boardId); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "create", "column", "{}", Guid.NewGuid().ToString(), columnId.ToString())); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(columnId, It.IsAny())) + .ReturnsAsync((Column?)null); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => + r.Tone == ConflictTone.Warn + && r.Key == "missing-target-column"); + } + + [Fact] + public async Task DetectConflictsAsync_MoveOutAndIntoSameColumn_UsesNetProjectedWip() + { + var targetColumnId = Guid.NewGuid(); + var otherColumnId = Guid.NewGuid(); + var sourceColumnId = Guid.NewGuid(); + var leavingCardId = Guid.NewGuid(); + var enteringCardId = Guid.NewGuid(); + + var proposal = CreateProposal(_userId, _boardId); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, + 0, + "move", + "card", + $"{{\"targetColumnId\":\"{otherColumnId}\"}}", + Guid.NewGuid().ToString(), + leavingCardId.ToString())); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, + 1, + "move", + "card", + $"{{\"targetColumnId\":\"{targetColumnId}\"}}", + Guid.NewGuid().ToString(), + enteringCardId.ToString())); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + var targetColumn = new Column(_boardId, "In Progress", 1, wipLimit: 5); + AddCardsToColumn(targetColumn, 5); + var otherColumn = new Column(_boardId, "Done", 2, wipLimit: 10); + + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(targetColumnId, It.IsAny())) + .ReturnsAsync(targetColumn); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(otherColumnId, It.IsAny())) + .ReturnsAsync(otherColumn); + + _cardRepoMock.Setup(r => r.GetByIdAsync(leavingCardId, It.IsAny())) + .ReturnsAsync(new Card(leavingCardId, _boardId, targetColumnId, "Leaving")); + _cardRepoMock.Setup(r => r.GetByIdAsync(enteringCardId, It.IsAny())) + .ReturnsAsync(new Card(enteringCardId, _boardId, sourceColumnId, "Entering")); + SetupEmptySecondaryChecks(proposal); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => + r.Key == "wip-limit" + && r.Value.Contains("In Progress", StringComparison.Ordinal)); + } + + #endregion + + #region Warn: Duplicate Pending Proposals + + [Fact] + public async Task DetectConflictsAsync_AnotherPendingProposalForSameCard_ReturnsDuplicateWarning() + { + var cardId = Guid.NewGuid(); + // Create card BEFORE proposal so card.UpdatedAt <= proposal.CreatedAt + var card = CreateCard(cardId); + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "update"); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(0); + + // Another pending proposal for the same card -- must be set up AFTER other mocks + // because SetupEmptySecondaryChecks would override with It.IsAny() + var otherProposal = CreateProposal(_userId, _boardId); + _proposalRepoMock.Setup(r => r.GetPendingByOperationTargetAsync( + "card", cardId.ToString(), It.IsAny())) + .ReturnsAsync(new List { otherProposal }); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Warn && r.Key == "duplicate-proposal"); + } + + [Fact] + public async Task DetectConflictsAsync_SameProposalFoundByTarget_NoDuplicateWarning() + { + var cardId = Guid.NewGuid(); + // Create card BEFORE proposal so card.UpdatedAt <= proposal.CreatedAt + var card = CreateCard(cardId); + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "update"); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + // Same proposal returned by target query (not a duplicate) + _proposalRepoMock.Setup(r => r.GetPendingByOperationTargetAsync( + "card", cardId.ToString(), It.IsAny())) + .ReturnsAsync(new List { proposal }); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + SetupEmptySecondaryChecks(proposal); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "duplicate-proposal"); + } + + [Fact] + public async Task DetectConflictsAsync_CreateCardWithPreAssignedId_ChecksDuplicateProposals() + { + var cardId = Guid.NewGuid(); + var proposal = CreateProposal(_userId, _boardId); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "create", "card", "{}", Guid.NewGuid().ToString(), cardId.ToString())); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var otherProposal = CreateProposal(_userId, _boardId); + _proposalRepoMock.Setup(r => r.GetPendingByOperationTargetAsync( + "card", cardId.ToString(), It.IsAny())) + .ReturnsAsync(new List { otherProposal }); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Warn && r.Key == "duplicate-proposal"); + result.Value.Should().NotContain(r => r.Key == "missing-target"); + result.Value.Should().NotContain(r => r.Key == "stale-data"); + } + + #endregion + + #region Warn: High Risk + + [Fact] + public async Task DetectConflictsAsync_HighRiskProposal_ReturnsHighRiskWarning() + { + var proposal = CreateProposal(_userId, _boardId, riskLevel: RiskLevel.High); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + SetupNoConflicts(); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Warn && r.Key == "high-risk"); + } + + [Fact] + public async Task DetectConflictsAsync_CriticalRiskProposal_ReturnsHighRiskWarning() + { + var proposal = CreateProposal(_userId, _boardId, riskLevel: RiskLevel.Critical); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + SetupNoConflicts(); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Warn && r.Key == "high-risk"); + } + + [Fact] + public async Task DetectConflictsAsync_LowRiskProposal_NoHighRiskWarning() + { + var proposal = CreateProposal(_userId, _boardId, riskLevel: RiskLevel.Low); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + SetupNoConflicts(); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "high-risk"); + } + + [Fact] + public async Task DetectConflictsAsync_MediumRiskProposal_NoHighRiskWarning() + { + var proposal = CreateProposal(_userId, _boardId, riskLevel: RiskLevel.Medium); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + SetupNoConflicts(); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "high-risk"); + } + + #endregion + + #region Info: Webhooks + + [Fact] + public async Task DetectConflictsAsync_ActiveWebhooks_ReturnsWebhookInfo() + { + var proposal = CreateProposal(_userId, _boardId); + var columnId = Guid.NewGuid(); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "update", "column", "{}", Guid.NewGuid().ToString(), columnId.ToString())); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + var webhook = new OutboundWebhookSubscription( + _boardId, _userId, "https://example.com/hook", "secret123", ["column.updated"]); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List { webhook }); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Info && r.Key == "webhooks"); + } + + [Fact] + public async Task DetectConflictsAsync_NonMatchingWebhooks_NoWebhookInfo() + { + var proposal = CreateProposal(_userId, _boardId); + var columnId = Guid.NewGuid(); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "update", "column", "{}", Guid.NewGuid().ToString(), columnId.ToString())); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + var webhook = new OutboundWebhookSubscription( + _boardId, _userId, "https://example.com/hook", "secret123", ["card.created"]); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List { webhook }); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "webhooks"); + } + + [Fact] + public async Task DetectConflictsAsync_ArchiveCardMatchesUpdatedWebhookFilter() + { + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "archive"); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(0); + SetupNoDuplicateProposal(cardId); + + var webhook = new OutboundWebhookSubscription( + _boardId, _userId, "https://example.com/hook", "secret123", ["card.updated"]); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List { webhook }); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Info && r.Key == "webhooks"); + } + + [Fact] + public async Task DetectConflictsAsync_NoOperationsWithActiveWebhooks_NoWebhookInfo() + { + var proposal = CreateProposal(_userId, _boardId); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + var webhook = new OutboundWebhookSubscription( + _boardId, _userId, "https://example.com/hook", "secret123"); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List { webhook }); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "webhooks"); + } + + [Fact] + public async Task DetectConflictsAsync_NoWebhooks_NoWebhookInfo() + { + var proposal = CreateProposal(_userId, _boardId); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "webhooks"); + } + + [Fact] + public async Task DetectConflictsAsync_NoBoardId_NoWebhookCheck() + { + var proposal = CreateProposal(_userId, boardId: null); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + SetupNoConflicts(); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + _webhookRepoMock.Verify( + r => r.GetActiveByBoardAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + #endregion + + #region Info: Active Comments + + [Fact] + public async Task DetectConflictsAsync_CardHasComments_ReturnsCommentsInfo() + { + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "update"); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(1); + + SetupNoDuplicateProposal(cardId); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Info && r.Key == "active-comments"); + } + + [Fact] + public async Task DetectConflictsAsync_CardHasNoComments_NoCommentsInfo() + { + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "update"); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(0); + + SetupNoDuplicateProposal(cardId); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "active-comments"); + } + + #endregion + + #region Info: Multiple Operations on Same Card + + [Fact] + public async Task DetectConflictsAsync_MultipleOpsOnSameCard_ReturnsMultiOpInfo() + { + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + var proposal = CreateProposal(_userId, _boardId); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "update", "card", "{}", Guid.NewGuid().ToString(), cardId.ToString())); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 1, "move", "card", "{}", Guid.NewGuid().ToString(), cardId.ToString())); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(0); + SetupNoDuplicateProposal(cardId); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Info && r.Key == "multi-op"); + } + + [Fact] + public async Task DetectConflictsAsync_SingleOpPerCard_NoMultiOpInfo() + { + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "update"); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(0); + SetupNoDuplicateProposal(cardId); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "multi-op"); + } + + #endregion + + #region Ok: Positive Signals + + [Fact] + public async Task DetectConflictsAsync_FreshCardWithOtherWarnings_ReturnsFreshDataOkRow() + { + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + // High risk to trigger a warning, so positive signals are also emitted + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "update", riskLevel: RiskLevel.High); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(0); + SetupNoDuplicateProposal(cardId); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Ok && r.Key == "fresh-data"); + } + + [Fact] + public async Task DetectConflictsAsync_ColumnHasCapacity_ReturnsCapacityOkRow() + { + var columnId = Guid.NewGuid(); + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + // High risk to trigger a warning, so positive signals are also emitted + var proposal = CreateProposalWithMoveOp(_userId, _boardId, cardId, columnId, riskLevel: RiskLevel.High); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + // Column with WIP limit of 5 and 2 cards (has capacity) + var column = new Column(_boardId, "In Progress", 1, wipLimit: 5); + AddCardsToColumn(column, 2); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(columnId, It.IsAny())) + .ReturnsAsync(column); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + SetupEmptySecondaryChecks(proposal, cardId); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Ok && r.Key == "capacity"); + } + + #endregion + + #region Sorting + + [Fact] + public async Task DetectConflictsAsync_MultipleRows_SortedByTone_WarnFirst() + { + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + // High risk proposal targeting a card with comments + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "update", riskLevel: RiskLevel.High); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(1); + SetupNoDuplicateProposal(cardId); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Count.Should().BeGreaterThan(1); + + // Warn should come before Info, Info before Ok + var tones = result.Value.Select(r => r.Tone).ToList(); + tones.Should().BeInAscendingOrder(); + } + + #endregion + + #region Combination + + [Fact] + public async Task DetectConflictsAsync_MultipleConflictsDetected_ReturnsAllRows() + { + var cardId = Guid.NewGuid(); + // Create card BEFORE proposal so card.UpdatedAt <= proposal.CreatedAt + var card = CreateCard(cardId); + // High risk + card with comments + webhooks + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "update", riskLevel: RiskLevel.Critical); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + + // Has comments + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(1); + + // Has webhooks + var webhook = new OutboundWebhookSubscription( + _boardId, _userId, "https://example.com/hook", "secret123"); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List { webhook }); + + SetupNoDuplicateProposal(cardId); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + // Should have: high-risk (warn), active-comments (info), webhooks (info), fresh-data (ok) + result.Value.Should().Contain(r => r.Key == "high-risk"); + result.Value.Should().Contain(r => r.Key == "active-comments"); + result.Value.Should().Contain(r => r.Key == "webhooks"); + result.Value.Should().Contain(r => r.Key == "fresh-data"); + } + + #endregion + + #region Expired Proposal Handling + + [Fact] + public async Task DetectConflictsAsync_ExpiredProposal_StillReturnsConflicts() + { + // Expired proposals can still have their conflicts inspected + var proposal = CreateProposal(_userId, _boardId, expiryMinutes: 1); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + SetupNoConflicts(); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + } + + #endregion + + #region Edge Cases + + [Fact] + public async Task DetectConflictsAsync_OperationWithInvalidTargetId_SkipsGracefully() + { + var proposal = CreateProposal(_userId, _boardId); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "update", "card", "{}", Guid.NewGuid().ToString(), "not-a-guid")); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task DetectConflictsAsync_OperationWithNullTargetId_SkipsGracefully() + { + var proposal = CreateProposal(_userId, _boardId); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "create", "card", "{}", Guid.NewGuid().ToString(), targetId: null)); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task DetectConflictsAsync_MalformedParametersJson_SkipsColumnParsing() + { + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + var proposal = CreateProposal(_userId, _boardId); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "move", "card", "not-json{", Guid.NewGuid().ToString(), cardId.ToString())); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(0); + SetupNoDuplicateProposal(cardId); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task DetectConflictsAsync_CreateCardWithPreAssignedId_NoStaleOrMissingWarning() + { + var cardId = Guid.NewGuid(); + var proposal = CreateProposal(_userId, _boardId); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "create", "card", "{}", Guid.NewGuid().ToString(), cardId.ToString())); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + SetupNoDuplicateProposal(cardId); + + // Card does NOT exist yet (create operation) -- should NOT produce missing-target warning + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync((Card?)null); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "missing-target"); + result.Value.Should().NotContain(r => r.Key == "stale-data"); + } + + [Fact] + public async Task DetectConflictsAsync_ColumnOnlyOperation_DoesNotRunWipCapacityChecks() + { + var columnId = Guid.NewGuid(); + var proposal = CreateProposal(_userId, _boardId, riskLevel: RiskLevel.High); + // Column-targeted operation with minimal parameters (domain requires non-empty) + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "archive", "column", "{}", Guid.NewGuid().ToString(), columnId.ToString())); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + var column = new Column(_boardId, "Done", 3, wipLimit: 10); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(columnId, It.IsAny())) + .ReturnsAsync(column); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Key == "high-risk"); + result.Value.Should().NotContain(r => r.Key == "capacity"); + result.Value.Should().NotContain(r => r.Key == "wip-limit"); + _columnRepoMock.Verify( + r => r.GetByIdWithCardsAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task DetectConflictsAsync_NonStringColumnIdInJson_DoesNotThrow() + { + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + var proposal = CreateProposal(_userId, _boardId); + // columnId is a number, not a string -- should not throw InvalidOperationException + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "move", "card", "{\"columnId\": 12345}", Guid.NewGuid().ToString(), cardId.ToString())); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(0); + SetupNoDuplicateProposal(cardId); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task DetectConflictsAsync_NonObjectParametersJson_DoesNotThrow() + { + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + var proposal = CreateProposal(_userId, _boardId); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "move", "card", "[]", Guid.NewGuid().ToString(), cardId.ToString())); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(0); + SetupNoDuplicateProposal(cardId); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + } + + #endregion + + #region Helpers + + private AutomationProposal CreateProposal( + Guid userId, + Guid? boardId, + RiskLevel riskLevel = RiskLevel.Low, + int expiryMinutes = 1440) + { + return new AutomationProposal( + ProposalSourceType.Chat, + userId, + "Test proposal", + riskLevel, + Guid.NewGuid().ToString(), + boardId, + expiryMinutes: expiryMinutes); + } + + private AutomationProposal CreateProposalWithCardOp( + Guid userId, + Guid boardId, + Guid cardId, + string actionType, + RiskLevel riskLevel = RiskLevel.Low) + { + var proposal = CreateProposal(userId, boardId, riskLevel); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, actionType, "card", "{}", Guid.NewGuid().ToString(), cardId.ToString())); + return proposal; + } + + private AutomationProposal CreateProposalWithMoveOp( + Guid userId, + Guid boardId, + Guid cardId, + Guid targetColumnId, + RiskLevel riskLevel = RiskLevel.Low) + { + var proposal = CreateProposal(userId, boardId, riskLevel); + var parameters = $"{{\"columnId\":\"{targetColumnId}\"}}"; + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "move", "card", parameters, Guid.NewGuid().ToString(), cardId.ToString())); + return proposal; + } + + /// + /// Creates a card with a known ID. Note: When used after a proposal is + /// created, the card's UpdatedAt will be slightly after the proposal's + /// CreatedAt (due to DateTimeOffset.UtcNow in both constructors). + /// For "fresh card" semantics, create the card BEFORE the proposal. + /// + private Card CreateCard(Guid cardId, string title = "Test Card") + { + return new Card(cardId, _boardId, Guid.NewGuid(), title); + } + + private void SetupNoConflicts() + { + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + } + + private void SetupEmptySecondaryChecks(AutomationProposal proposal, Guid? specificCardId = null) + { + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + _commentRepoMock.Setup(r => r.CountByCardIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(0); + + if (specificCardId.HasValue) + { + SetupNoDuplicateProposal(specificCardId.Value); + } + else + { + // Setup for any card ID + _proposalRepoMock.Setup(r => r.GetPendingByOperationTargetAsync( + "card", It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + } + } + + private void SetupNoDuplicateProposal(Guid cardId) + { + _proposalRepoMock.Setup(r => r.GetPendingByOperationTargetAsync( + "card", cardId.ToString(), It.IsAny())) + .ReturnsAsync(new List()); + } + + private void SetupCardForMove(AutomationProposal proposal, Guid? specificCardId = null) + { + // For move operations, we still need the card check for stale data + if (specificCardId.HasValue) + { + var card = CreateCard(specificCardId.Value); + _cardRepoMock.Setup(r => r.GetByIdAsync(specificCardId.Value, It.IsAny())) + .ReturnsAsync(card); + } + else + { + // Setup for any card in the proposal operations + foreach (var op in proposal.Operations) + { + if (op.TargetType.Equals("card", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(op.TargetId) + && Guid.TryParse(op.TargetId, out var cardId)) + { + var card = CreateCard(cardId); + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + } + } + } + } + + private static void AddCardsToColumn(Column column, int count) + { + for (var i = 0; i < count; i++) + { + var card = new Card(column.BoardId, column.Id, $"Card {i}", position: i); + column.AddCard(card); + } + } + + #endregion +} diff --git a/backend/tests/Taskdeck.Domain.Tests/Entities/ConflictRowTests.cs b/backend/tests/Taskdeck.Domain.Tests/Entities/ConflictRowTests.cs new file mode 100644 index 000000000..90d769b8b --- /dev/null +++ b/backend/tests/Taskdeck.Domain.Tests/Entities/ConflictRowTests.cs @@ -0,0 +1,119 @@ +using FluentAssertions; +using Taskdeck.Domain.Entities; +using Xunit; + +namespace Taskdeck.Domain.Tests.Entities; + +public class ConflictRowTests +{ + [Theory] + [InlineData(ConflictTone.Warn)] + [InlineData(ConflictTone.Info)] + [InlineData(ConflictTone.Ok)] + public void Constructor_ShouldCreateRow_WithValidData(ConflictTone tone) + { + var row = new ConflictRow(tone, "test-key", "test value"); + + row.Tone.Should().Be(tone); + row.Key.Should().Be("test-key"); + row.Value.Should().Be("test value"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void Constructor_ShouldThrow_WhenKeyIsBlank(string key) + { + var act = () => new ConflictRow(ConflictTone.Warn, key, "value"); + + act.Should().Throw() + .WithMessage("Conflict row key cannot be empty.*"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void Constructor_ShouldThrow_WhenValueIsBlank(string value) + { + var act = () => new ConflictRow(ConflictTone.Info, "key", value); + + act.Should().Throw() + .WithMessage("Conflict row value cannot be empty.*"); + } + + [Fact] + public void Constructor_ShouldThrow_WhenToneIsInvalid() + { + var act = () => new ConflictRow((ConflictTone)99, "key", "value"); + + act.Should().Throw() + .WithMessage("Invalid conflict tone*"); + } + + [Fact] + public void Equals_SameValues_ShouldBeEqual() + { + var row1 = new ConflictRow(ConflictTone.Warn, "stale-data", "Card was modified"); + var row2 = new ConflictRow(ConflictTone.Warn, "stale-data", "Card was modified"); + + row1.Should().Be(row2); + row1.Equals(row2).Should().BeTrue(); + (row1.GetHashCode() == row2.GetHashCode()).Should().BeTrue(); + } + + [Fact] + public void Equals_DifferentTone_ShouldNotBeEqual() + { + var row1 = new ConflictRow(ConflictTone.Warn, "key", "value"); + var row2 = new ConflictRow(ConflictTone.Info, "key", "value"); + + row1.Should().NotBe(row2); + } + + [Fact] + public void Equals_DifferentKey_ShouldNotBeEqual() + { + var row1 = new ConflictRow(ConflictTone.Ok, "key-a", "value"); + var row2 = new ConflictRow(ConflictTone.Ok, "key-b", "value"); + + row1.Should().NotBe(row2); + } + + [Fact] + public void Equals_DifferentValue_ShouldNotBeEqual() + { + var row1 = new ConflictRow(ConflictTone.Info, "key", "value A"); + var row2 = new ConflictRow(ConflictTone.Info, "key", "value B"); + + row1.Should().NotBe(row2); + } + + [Fact] + public void Equals_Null_ShouldNotBeEqual() + { + var row = new ConflictRow(ConflictTone.Ok, "key", "value"); + + row.Equals(null).Should().BeFalse(); + } + + [Fact] + public void ToString_ShouldFormatCorrectly() + { + var row = new ConflictRow(ConflictTone.Warn, "stale-data", "Card was modified"); + + row.ToString().Should().Be("[Warn] stale-data: Card was modified"); + } + + [Theory] + [InlineData(ConflictTone.Warn)] + [InlineData(ConflictTone.Info)] + [InlineData(ConflictTone.Ok)] + public void Constructor_ShouldAcceptAllTones(ConflictTone tone) + { + var row = new ConflictRow(tone, "test", "test"); + + row.Tone.Should().Be(tone); + } +} diff --git a/docs/IMPLEMENTATION_MASTERPLAN.md b/docs/IMPLEMENTATION_MASTERPLAN.md index 3ed79dfc4..fefc898e2 100644 --- a/docs/IMPLEMENTATION_MASTERPLAN.md +++ b/docs/IMPLEMENTATION_MASTERPLAN.md @@ -41,14 +41,14 @@ Update this file at the end of each meaningful delivery cycle or when new work i Delivered in the latest cycle: -Paper backend gap delivery (2026-04-27, PRs `#1031`--`#1039`, 9 of 10 issues `#1015`--`#1024`; `#1040` pending): -- 9 backend endpoints delivered or merge-ready for the Paper UI surfaces (PAPER-08 Today dossier + PAPER-06 Review deep-dive); the conflict-detection endpoint remains pending in `#1040` +Paper backend gap delivery (2026-05-05, PRs `#1031`--`#1040`, 10 of 10 issues `#1015`--`#1024`): +- 10 backend endpoints delivered or merge-ready for the Paper UI surfaces (PAPER-08 Today dossier + PAPER-06 Review deep-dive), with the conflict-detection endpoint reconciled in `#1040` - Today dossier: cadence aggregation (`#1015`/`#1031`), 90-day streak query (`#1016`/`#1032`), seal-day action with EF migration (`#1017`/`#1037`), line-for-tomorrow autosave (`#1018`/`#1035`) -- Review deep-dive: provenance rows with FK migration (`#1019`/`#1039`), 7-category side-effect analysis (`#1020`/`#1033`), 4-component confidence breakdown (`#1021`/`#1036`), card history ledger (`#1023`/`#1034`), similar past decisions with apply rate (`#1024`/`#1038`); conflict detection with 7 rules (`#1022`/`#1040`) remains pending -- ~402 new backend tests across domain, application, and API layers in the delivered/merge-ready set +- Review deep-dive: provenance rows with FK migration (`#1019`/`#1039`), 7-category side-effect analysis (`#1020`/`#1033`), 4-component confidence breakdown (`#1021`/`#1036`), conflict detection with tone-classified rows and projected WIP checks (`#1022`/`#1040`), card history ledger (`#1023`/`#1034`), similar past decisions with apply rate (`#1024`/`#1038`) +- ~480 new backend tests across domain, application, and API layers in the delivered/merge-ready set - Two rounds of adversarial review per delivered PR; Gemini Code Assist and Codex connector bot findings addressed on delivered PRs -- Key review fixes: 100k entity memory risk replaced with server-side GROUP BY (`#1032`), board-scoped similar-decision query (`#1038`), UnitOfWork unique constraint handlers for DailySnapshot/TomorrowNote (`#1037`/`#1035`), CancellationToken threading, reach formula correction (`#1036`), FK enforcement for provenance (`#1039`) -- New shared infrastructure: `TodayController`, `DailySnapshot` entity + repository, `TomorrowNote` entity + repository, `CountByDateAsync` aggregate audit query, `GetTerminalByActionTypeAsync` proposal repository method +- Key review fixes: 100k entity memory risk replaced with server-side GROUP BY (`#1032`), board-scoped similar-decision query (`#1038`), UnitOfWork unique constraint handlers for DailySnapshot/TomorrowNote (`#1037`/`#1035`), CancellationToken threading, reach formula correction (`#1036`), FK enforcement for provenance (`#1039`), conflict-detector create-card false positives, JSON ValueKind guards, projected WIP accounting, missing-column detection, webhook event mapping, and soft-deleted comment counts (`#1040`) +- New shared infrastructure: `TodayController`, `DailySnapshot` entity + repository, `TomorrowNote` entity + repository, `CountByDateAsync` aggregate audit query, `GetTerminalByActionTypeAsync` and `GetPendingByOperationTargetAsync` proposal repository methods, `CountByCardIdAsync` card-comment aggregate query Latest tooling addition (2026-04-25): - Codex high-autonomy workflow hardening delivered: `docs/tooling/CODEX_AUTONOMY_RUNBOOK.md` now defines issue batch orchestration, worktree workers, PR review loops, CI/comment/conflict recovery, no-silent-deferral rules, and docs rehydration. diff --git a/docs/STATUS.md b/docs/STATUS.md index f788900cf..cd384ac4c 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,7 +2,7 @@ Last Updated: 2026-05-05 -Paper backend gap delivery (9 of 10 issues, `#1015`–`#1024`, PRs `#1031`–`#1039`; `#1040` remains pending), after review-first AI roadmap v4 second-wave delivery. +Paper backend gap delivery (10 of 10 issues, `#1015`–`#1024`, PRs `#1031`–`#1040`), after review-first AI roadmap v4 second-wave delivery.
Status Owner: Repository maintainers Authoritative Scope: Current implementation, verified test execution, and active phase progress @@ -23,8 +23,8 @@ Rebranding thesis (2026-02-23): - automation should remain review-first and provenance-visible - product value is reducing maintenance overhead, not maximizing opaque autonomy -Paper backend gap delivery (2026-04-27, PRs `#1031`--`#1039`, 9 of 10 issues `#1015`--`#1024`; `#1040` pending): -- 9 Paper backend gaps delivered or merge-ready with two rounds of adversarial review per PR; ~402 new backend tests across the delivered set; bot review findings (Gemini Code Assist + Codex connector) addressed on delivered PRs +Paper backend gap delivery (2026-05-05, PRs `#1031`--`#1040`, 10 of 10 issues `#1015`--`#1024`): +- 10 Paper backend gaps delivered or merge-ready with two rounds of adversarial review per PR; ~480 new backend tests across the delivered set; bot review findings (Gemini Code Assist + Codex connector) addressed on delivered PRs - **Today dossier backends** (4 endpoints on `TodayController`, required by PAPER-08 `#1004`): - Cadence aggregation (`#1015`/`#1031`): `GET /api/today/cadence?date=` returns 24-hour activity buckets with first/peak/last action timestamps; `CadenceBucket` and `CadenceSnapshot` value objects with cached `Empty()` singleton; queries `IAuditLogRepository`; 26 tests - Streak query (`#1016`/`#1032`): `GET /api/today/streak?days=90` returns 90-day streak with intensity buckets (quartile-based) and current/longest streak lengths; `StreakDay`/`StreakResult` value objects; server-side `GROUP BY` via `CountByDateAsync` (replaced 100k entity load after review); 61 tests @@ -34,11 +34,11 @@ Paper backend gap delivery (2026-04-27, PRs `#1031`--`#1039`, 9 of 10 issues `#1 - Provenance rows (`#1019`/`#1039`): `GET /api/automation/proposals/{id}/provenance` returns `ProvenanceRowDto[]` with icon/key/value/weight; 26-entry icon map; weight bucketing from `ProvenanceField` confidence; FK migration added after review; `Math.Round` for confidence display; 41 tests - Side-effect analysis (`#1020`/`#1033`): `GET /api/automation/proposals/{id}/side-effects` returns 7-category breakdown (Cards/Subtasks/Comments/Activity/Notifications/Webhooks/Calendar) with active/passive tone and risk-based reversibility window (6h default, 3h for Critical); review fixed target-type checks and webhook-without-operations logic; 66 tests - Confidence breakdown (`#1021`/`#1036`): `GET /api/automation/proposals/{id}/confidence` returns 4-component weighted breakdown (Pattern match 0.30, Reach 0.20, Reversibility 0.35, Recency 0.15) with threshold and explanatory note; review fixed reach formula, promoted weights to static field, removed unused userId; 63 tests - - Conflict detection (`#1022`/`#1040`): pending/open; leave this PR out of merge-readiness claims until it is reconciled and merged + - Conflict detection (`#1022`/`#1040`): `GET /api/automation/proposals/{id}/conflicts` returns tone-classified Warn/Info/Ok rows for stale data, missing targets, projected WIP overflow, duplicate pending proposals, high-risk operations, outbound webhooks, active comments, multi-op card conflicts, and positive capacity/fresh-data signals; review fixed create-card false positives, JSON ValueKind guards, projected WIP accounting, missing-column detection, webhook event mapping, soft-deleted comment counts, and entity caching; 78 tests - Card history ledger (`#1023`/`#1034`): `GET /api/automation/proposals/{id}/history` returns per-card touch history with serial/event/age/status; bounded at 200 entries/card and 500 total; review fixed duplicate dedup, JSON property parsing, GUID single-pass, `InvariantCulture` formatting; 42 tests - Similar past decisions (`#1024`/`#1038`): `GET /api/automation/proposals/{id}/similar-past` returns 3 nearest-neighbour prior decisions with apply rate; board-scoped query (review fixed userId filter that excluded non-caller proposals); 200-proposal lookback limit; UTC week formatting; 50 tests - New EF Core migrations: `AddDailySnapshots`, `AddTomorrowNotes`, `AddProvenanceEntities`, `AddProposalProvenanceForeignKey`, `ExtendProposalOutcomesForMetrics` -- New repository methods: `IAuditLogRepository.CountByDateAsync`, `IAutomationProposalRepository.GetTerminalByActionTypeAsync` +- New repository methods: `IAuditLogRepository.CountByDateAsync`, `IAutomationProposalRepository.GetTerminalByActionTypeAsync`, `IAutomationProposalRepository.GetPendingByOperationTargetAsync`, `ICardCommentRepository.CountByCardIdAsync` Roadmap v4 second-wave delivery (2026-04-25, PRs `#989`--`#994` + `#995`): - RFAI-02 (`#974`/`#989`): `IntentEnvelopeV1` domain spine with `SourceBlock`/`SourceSpan`, `IntentCandidate`, `EvidenceLink`, `TaskdeckProposalBatch`, `IIntentEnvelopeFactory` application interface, `IChatClient` adapter spike, handwritten `proposal-batch.v1.schema.json`; 117 tests; adversarial review fixed partial-write status transition bug, span length consistency, evidence fabrication prevention, nullable schema fields diff --git a/docs/TESTING_GUIDE.md b/docs/TESTING_GUIDE.md index c19fc74bb..78f37eed5 100644 --- a/docs/TESTING_GUIDE.md +++ b/docs/TESTING_GUIDE.md @@ -2,7 +2,7 @@ This is the active testing guide for Taskdeck. -Last Updated: 2026-04-27 +Last Updated: 2026-05-05 Companion Active Docs: - `docs/STATUS.md` - `docs/IMPLEMENTATION_MASTERPLAN.md` @@ -10,24 +10,24 @@ Companion Active Docs: - `docs/MANUAL_TEST_CHECKLIST.md` - `docs/GOLDEN_PRINCIPLES.md` -## Current Verified Totals (2026-04-27) +## Current Verified Totals (2026-05-05) -- Backend: **~5,462 passing** (estimated; 5,060 at last recertification + ~402 new tests from Paper backend gap wave PRs `#1031`–`#1039`; `#1040` remains pending) - - Domain: ~1,120 passed (962 + ~158 new domain tests) - - Application: ~2,670 passed (2,367 + ~302 new application tests) - - API integration: 1,621 passed (0 failed, 2 skipped; 1,623 total) +- Backend: **6,336 passing** (0 failed, 6 skipped; 6,342 total) -- verified 2026-05-05 via `dotnet test backend/Taskdeck.sln -c Release -m:1` after reconciling Paper backend gap PR `#1040` + - Domain: 1,609 passed + - Application: 3,004 passed + - API integration: 1,605 passed (0 failed, 2 skipped; 1,607 total) - CLI contract: 82 passed - - Architecture boundaries: 8 passed + - Architecture boundaries: 16 passed (0 failed, 4 skipped; 20 total) - Integration (Testcontainers): 20 passed - Frontend unit: **2,805 passing** across 214+ test files -- verified 2026-04-25 via `npx vitest --run --reporter=verbose` on `main` - Frontend E2E (smoke + automation/ops + capture loop + starter-pack fixtures + concurrency harness + error recovery/multi-board/edge journeys + cross-browser matrix + onboarding/review/capture/keyboard/dark-mode + validation slices C/D/E + integrated verification): default required lane passing; +20 new scenarios in PRs `#821`–`#826`; +61 new validation/verification scenarios in PRs `#837`–`#840` + `#838` -- Combined automated total: **~8,267+ passing** (backend ~5,462 + frontend unit 2,805 + E2E) +- Combined automated total: **~9,141+ passing** (backend 6,336 + frontend unit 2,805 + E2E) Verification note: -- backend total of ~5,462 is estimated pending recertification after Paper backend gap PRs merge; ~402 new tests verified individually per delivered/merge-ready PR via CI -- Paper backend gap wave (2026-04-27, PRs `#1031`–`#1039`; `#1040` pending): ~402 new tests across 9 delivered/merge-ready issues; each delivered PR CI-verified independently +- backend total of 6,336 is recertified after Paper backend gap PR `#1040` was reconciled with current `main` +- Paper backend gap wave (2026-05-05, PRs `#1031`–`#1040`): ~480 new tests across 10 delivered/merge-ready issues; each delivered PR CI-verified independently, with `#1040` also locally verified after conflict recovery - prior recertification: backend 5,060 (2026-04-25), frontend 2,805 (2026-04-25) at PR `#987` -- growth since last recertification: backend +~402 tests (Paper backend gaps through `#1039`), frontend unchanged +- growth since last recertification: backend +1,276 passing tests, frontend unchanged ## Roadmap v4 Verification Spine (Seeded 2026-04-25) @@ -56,9 +56,9 @@ Pop-Location if ($code -ne 0) { exit $code } ``` -## Paper Backend Gap Testing (2026-04-27, PRs `#1031`–`#1039`; `#1040` pending) +## Paper Backend Gap Testing (2026-05-05, PRs `#1031`–`#1040`) -The Paper backend gap wave through PR `#1039` added ~402 new backend tests across 9 delivered/merge-ready issues. Each delivered PR received two rounds of adversarial review; the second round found and fixed issues including a 100k entity memory risk, a board-scoping error, missing FK enforcement, and CancellationToken threading gaps. Conflict detection (`#1022`/`#1040`) remains pending and is intentionally excluded from delivered totals here. +The Paper backend gap wave through PR `#1040` added ~480 new backend tests across 10 delivered/merge-ready issues. Each delivered PR received adversarial review; later review rounds found and fixed issues including a 100k entity memory risk, a board-scoping error, missing FK enforcement, CancellationToken threading gaps, projected WIP false negatives, JSON parsing 500s, and conflict-detector false positives. ### Cadence Aggregation Tests (`#1015`/`#1031`) @@ -146,18 +146,20 @@ Run: dotnet test backend/Taskdeck.sln -c Release --filter "FullyQualifiedName~ConfidenceBreakdown" ``` -### Pending Conflict Detection Tests (`#1022`/`#1040`) +### Conflict Detection Tests (`#1022`/`#1040`) -Status: pending/open; listed here as the expected verification path once `#1040` is reconciled. +Status: reconciled and locally verified after merge-conflict recovery. -`backend/tests/Taskdeck.Domain.Tests/Entities/ConflictRowTests.cs`, `backend/tests/Taskdeck.Application.Tests/Services/ProposalConflictDetectorTests.cs` — **46 tests** covering: +`backend/tests/Taskdeck.Domain.Tests/Entities/ConflictRowTests.cs`, `backend/tests/Taskdeck.Application.Tests/Services/ProposalConflictDetectorTests.cs`, `backend/tests/Taskdeck.Api.Tests/AutomationProposalRepositoryIntegrationTests.cs`, `backend/tests/Taskdeck.Api.Tests/CardCommentRepositoryIntegrationTests.cs` — **78 tests** covering: - ConflictRow: tone enum, value object creation, equality - ProposalConflictDetector: 7 detection rules — stale data (excludes create-card ops), missing target card, WIP limit, duplicate pending proposals (all pending, not just latest), high/critical risk, outbound webhooks, active comments, multi-op on same card, positive signals (column capacity, fresh data) -- Entity caching (each card/column fetched at most once), safe JSON parsing with ValueKind checks, tone-sorted output (Warn → Info → Ok) +- Entity caching (each card/column fetched at most once), safe JSON parsing with ValueKind checks, projected WIP accounting including departures, missing target columns, soft-deleted comment exclusion, repository aggregate methods, tone-sorted output (Warn → Info → Ok) Run: ```bash -dotnet test backend/Taskdeck.sln -c Release --filter "FullyQualifiedName~ConflictRow or FullyQualifiedName~ConflictDetector" +dotnet test backend/tests/Taskdeck.Domain.Tests/Taskdeck.Domain.Tests.csproj -c Release --filter "FullyQualifiedName~ConflictRow" +dotnet test backend/tests/Taskdeck.Application.Tests/Taskdeck.Application.Tests.csproj -c Release --filter "FullyQualifiedName~ProposalConflictDetector" +dotnet test backend/tests/Taskdeck.Api.Tests/Taskdeck.Api.Tests.csproj -c Release --filter "FullyQualifiedName~AutomationProposalRepositoryIntegrationTests|FullyQualifiedName~CardCommentRepositoryIntegrationTests" ``` ### Card History Tests (`#1023`/`#1034`)