From 02de93e8e35633564e241d10b39c9624bb8363f3 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:01:17 +0100 Subject: [PATCH 01/15] Add MarkdownImport and WebClip capture source values Extend the CaptureSource enum with two new intake origins for note-style import (markdown files) and web clip (URL + snippet) flows. --- backend/src/Taskdeck.Domain/Enums/CaptureSource.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/Taskdeck.Domain/Enums/CaptureSource.cs b/backend/src/Taskdeck.Domain/Enums/CaptureSource.cs index 1231a3f09..d683f17c2 100644 --- a/backend/src/Taskdeck.Domain/Enums/CaptureSource.cs +++ b/backend/src/Taskdeck.Domain/Enums/CaptureSource.cs @@ -11,5 +11,7 @@ public enum CaptureSource Import = 3, Voice = 4, MeetingIntegration = 5, - TranscriptFile = 6 + TranscriptFile = 6, + MarkdownImport = 7, + WebClip = 8 } From 51b2becaf50d2eddcc6e3e50f8c48868b2e7c2a3 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:01:32 +0100 Subject: [PATCH 02/15] Add MarkdownImport and WebClip to frontend CaptureSource type --- frontend/taskdeck-web/src/types/capture.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/taskdeck-web/src/types/capture.ts b/frontend/taskdeck-web/src/types/capture.ts index 4276d22a0..ad03d1523 100644 --- a/frontend/taskdeck-web/src/types/capture.ts +++ b/frontend/taskdeck-web/src/types/capture.ts @@ -34,6 +34,8 @@ export type CaptureSource = | 'Voice' | 'MeetingIntegration' | 'TranscriptFile' + | 'MarkdownImport' + | 'WebClip' export type CaptureSourceValue = CaptureSource | number From 9ef8b5648c830b6cd0e85fdbb38ecd9ed0b9e060 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:01:46 +0100 Subject: [PATCH 03/15] Add NoteImportDtos for markdown import and web clip intake Defines request/result DTOs for markdown file import and web clip intake operations that route content through the capture pipeline. --- .../DTOs/NoteImportDtos.cs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 backend/src/Taskdeck.Application/DTOs/NoteImportDtos.cs diff --git a/backend/src/Taskdeck.Application/DTOs/NoteImportDtos.cs b/backend/src/Taskdeck.Application/DTOs/NoteImportDtos.cs new file mode 100644 index 000000000..bc3fd1be0 --- /dev/null +++ b/backend/src/Taskdeck.Application/DTOs/NoteImportDtos.cs @@ -0,0 +1,38 @@ +namespace Taskdeck.Application.DTOs; + +/// +/// Request to import a markdown file as one or more capture items. +/// The content is parsed, split into logical sections, and routed +/// through the standard capture pipeline. +/// +public sealed record MarkdownImportRequestDto( + string FileName, + string Content, + Guid? BoardId = null); + +/// +/// Request to import a web clip (URL + content snippet) as a capture item. +/// The content is routed through the standard capture pipeline with +/// source provenance preserved. +/// +public sealed record WebClipImportRequestDto( + string Url, + string Content, + string? Title = null, + Guid? BoardId = null); + +/// +/// Result of a note-style import operation. +/// +public sealed record NoteImportResultDto( + int ItemsCreated, + IReadOnlyList Items); + +/// +/// Result for a single capture item created from a note import. +/// +public sealed record NoteImportItemResultDto( + Guid CaptureItemId, + string TextExcerpt, + string SourceType, + string? SourceRef); From d1ccb7bbcf9dec147bfd3b85e20b3accd81e115b Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:02:02 +0100 Subject: [PATCH 04/15] Add INoteImportService interface for note-style import Defines the service contract for markdown import and web clip intake, both routing through the capture pipeline. --- .../Services/INoteImportService.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 backend/src/Taskdeck.Application/Services/INoteImportService.cs diff --git a/backend/src/Taskdeck.Application/Services/INoteImportService.cs b/backend/src/Taskdeck.Application/Services/INoteImportService.cs new file mode 100644 index 000000000..1776d7286 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/INoteImportService.cs @@ -0,0 +1,28 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Common; + +namespace Taskdeck.Application.Services; + +/// +/// Handles note-style import (markdown files, web clips) by routing +/// imported content through the standard capture pipeline. +/// No direct board mutations — all content enters the capture → triage → proposal flow. +/// +public interface INoteImportService +{ + /// + /// Parses a markdown file and creates capture items for each logical section. + /// + Task> ImportMarkdownAsync( + Guid userId, + MarkdownImportRequestDto request, + CancellationToken cancellationToken = default); + + /// + /// Creates a capture item from a web clip (URL + content snippet). + /// + Task> ImportWebClipAsync( + Guid userId, + WebClipImportRequestDto request, + CancellationToken cancellationToken = default); +} From 4f58d376b1ed9fd6be93dd1167218dee2f0724ca Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:03:28 +0100 Subject: [PATCH 05/15] Add NoteImportService for markdown and web clip intake Routes all imported content through the capture pipeline via ICaptureService.CreateAsync. Markdown files are split into sections at heading boundaries. Web clips preserve the source URL as provenance. No direct board mutations. --- .../Services/NoteImportService.cs | 299 ++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 backend/src/Taskdeck.Application/Services/NoteImportService.cs diff --git a/backend/src/Taskdeck.Application/Services/NoteImportService.cs b/backend/src/Taskdeck.Application/Services/NoteImportService.cs new file mode 100644 index 000000000..76a0c4af6 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/NoteImportService.cs @@ -0,0 +1,299 @@ +using System.Text.RegularExpressions; +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Enums; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Application.Services; + +/// +/// Handles note-style import (markdown files, web clips) by creating +/// capture items through the standard capture pipeline. Imported content +/// never bypasses review — all items enter capture → triage → proposal flow. +/// +public sealed class NoteImportService : INoteImportService +{ + /// Maximum markdown content length (100 KB). + internal const int MaxMarkdownContentLength = 102_400; + + /// Maximum web clip content length (20 KB). + internal const int MaxWebClipContentLength = 20_000; + + /// Maximum filename length. + internal const int MaxFileNameLength = 255; + + /// Maximum URL length. + internal const int MaxUrlLength = 2_048; + + /// Maximum title hint length (from CaptureRequestContract). + internal const int MaxTitleLength = 240; + + /// Maximum number of sections extracted from a single markdown file. + internal const int MaxSectionsPerFile = 50; + + private static readonly Regex HeadingPattern = new( + @"^(#{1,6})\s+(.+)$", + RegexOptions.Multiline | RegexOptions.Compiled); + + private readonly ICaptureService _captureService; + + public NoteImportService(ICaptureService captureService) + { + _captureService = captureService; + } + + public async Task> ImportMarkdownAsync( + Guid userId, + MarkdownImportRequestDto request, + CancellationToken cancellationToken = default) + { + if (userId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "UserId cannot be empty"); + + if (request == null) + return Result.Failure(ErrorCodes.ValidationError, "Request body is required"); + + if (string.IsNullOrWhiteSpace(request.FileName)) + return Result.Failure(ErrorCodes.ValidationError, "File name is required"); + + if (request.FileName.Length > MaxFileNameLength) + return Result.Failure( + ErrorCodes.ValidationError, + $"File name cannot exceed {MaxFileNameLength} characters"); + + if (!IsValidFileName(request.FileName)) + return Result.Failure( + ErrorCodes.ValidationError, + "File name contains invalid characters"); + + if (string.IsNullOrWhiteSpace(request.Content)) + return Result.Failure(ErrorCodes.ValidationError, "Markdown content is required"); + + if (request.Content.Length > MaxMarkdownContentLength) + return Result.Failure( + ErrorCodes.ValidationError, + $"Markdown content cannot exceed {MaxMarkdownContentLength} characters"); + + var sections = SplitMarkdownIntoSections(request.Content); + if (sections.Count == 0) + return Result.Failure(ErrorCodes.ValidationError, "No content sections found in markdown"); + + if (sections.Count > MaxSectionsPerFile) + { + sections = sections.Take(MaxSectionsPerFile).ToList(); + } + + var items = new List(); + + foreach (var section in sections) + { + var externalRef = $"md://{SanitizeForExternalRef(request.FileName)}"; + if (!string.IsNullOrWhiteSpace(section.Heading)) + { + externalRef += $"#{SanitizeForExternalRef(section.Heading)}"; + } + + var captureText = BuildCaptureText(section); + if (string.IsNullOrWhiteSpace(captureText)) + continue; + + // Truncate to CaptureRequestContract max if needed + if (captureText.Length > CaptureRequestContract.MaxRawTextLength) + { + captureText = captureText[..CaptureRequestContract.MaxRawTextLength]; + } + + var titleHint = section.Heading; + if (titleHint != null && titleHint.Length > MaxTitleLength) + { + titleHint = titleHint[..MaxTitleLength]; + } + + var dto = new CreateCaptureItemDto( + request.BoardId, + captureText, + Source: CaptureSource.MarkdownImport.ToString(), + TitleHint: titleHint, + ExternalRef: TruncateExternalRef(externalRef)); + + var result = await _captureService.CreateAsync(userId, dto, cancellationToken); + if (!result.IsSuccess) + continue; + + items.Add(new NoteImportItemResultDto( + result.Value.Id, + BuildExcerpt(captureText, 200), + "markdown", + externalRef)); + } + + return Result.Success(new NoteImportResultDto(items.Count, items)); + } + + public async Task> ImportWebClipAsync( + Guid userId, + WebClipImportRequestDto request, + CancellationToken cancellationToken = default) + { + if (userId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "UserId cannot be empty"); + + if (request == null) + return Result.Failure(ErrorCodes.ValidationError, "Request body is required"); + + if (string.IsNullOrWhiteSpace(request.Url)) + return Result.Failure(ErrorCodes.ValidationError, "URL is required"); + + if (request.Url.Length > MaxUrlLength) + return Result.Failure( + ErrorCodes.ValidationError, + $"URL cannot exceed {MaxUrlLength} characters"); + + if (!IsValidUrl(request.Url)) + return Result.Failure( + ErrorCodes.ValidationError, + "URL must be a valid HTTP or HTTPS URL"); + + if (string.IsNullOrWhiteSpace(request.Content)) + return Result.Failure(ErrorCodes.ValidationError, "Clip content is required"); + + if (request.Content.Length > MaxWebClipContentLength) + return Result.Failure( + ErrorCodes.ValidationError, + $"Clip content cannot exceed {MaxWebClipContentLength} characters"); + + if (request.Title != null && request.Title.Length > MaxTitleLength) + return Result.Failure( + ErrorCodes.ValidationError, + $"Title cannot exceed {MaxTitleLength} characters"); + + var captureText = $"[Web Clip] {request.Url}\n\n{request.Content}"; + if (captureText.Length > CaptureRequestContract.MaxRawTextLength) + { + captureText = captureText[..CaptureRequestContract.MaxRawTextLength]; + } + + var externalRef = TruncateExternalRef(request.Url); + + var dto = new CreateCaptureItemDto( + request.BoardId, + captureText, + Source: CaptureSource.WebClip.ToString(), + TitleHint: request.Title, + ExternalRef: externalRef); + + var result = await _captureService.CreateAsync(userId, dto, cancellationToken); + if (!result.IsSuccess) + return Result.Failure(result.ErrorCode, result.ErrorMessage); + + var item = new NoteImportItemResultDto( + result.Value.Id, + BuildExcerpt(captureText, 200), + "webclip", + request.Url); + + return Result.Success(new NoteImportResultDto(1, new List { item })); + } + + internal static List SplitMarkdownIntoSections(string content) + { + var sections = new List(); + var lines = content.Split('\n'); + string? currentHeading = null; + var currentBody = new List(); + + foreach (var rawLine in lines) + { + var line = rawLine.TrimEnd('\r'); + var match = HeadingPattern.Match(line); + if (match.Success) + { + // Flush previous section + if (currentHeading != null || currentBody.Count > 0) + { + var bodyText = string.Join("\n", currentBody).Trim(); + if (!string.IsNullOrWhiteSpace(bodyText) || currentHeading != null) + { + sections.Add(new MarkdownSection(currentHeading, bodyText)); + } + } + + currentHeading = match.Groups[2].Value.Trim(); + currentBody.Clear(); + } + else + { + currentBody.Add(line); + } + } + + // Flush final section + if (currentHeading != null || currentBody.Count > 0) + { + var bodyText = string.Join("\n", currentBody).Trim(); + if (!string.IsNullOrWhiteSpace(bodyText) || currentHeading != null) + { + sections.Add(new MarkdownSection(currentHeading, bodyText)); + } + } + + return sections; + } + + private static string BuildCaptureText(MarkdownSection section) + { + if (string.IsNullOrWhiteSpace(section.Heading)) + return section.Body; + + if (string.IsNullOrWhiteSpace(section.Body)) + return section.Heading; + + return $"{section.Heading}\n\n{section.Body}"; + } + + private static bool IsValidFileName(string fileName) + { + // Reject path traversal and dangerous characters + if (fileName.Contains("..") || fileName.Contains('/') || fileName.Contains('\\')) + return false; + + var invalidChars = Path.GetInvalidFileNameChars(); + return !fileName.Any(c => invalidChars.Contains(c)); + } + + private static bool IsValidUrl(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + return false; + + return uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps; + } + + private static string SanitizeForExternalRef(string value) + { + // Replace whitespace with dashes, strip control chars + var sanitized = Regex.Replace(value, @"[\s]+", "-"); + sanitized = Regex.Replace(sanitized, @"[^\w\-\.\(\)]", ""); + return sanitized; + } + + private static string TruncateExternalRef(string value) + { + return value.Length <= CaptureRequestContract.MaxExternalRefLength + ? value + : value[..CaptureRequestContract.MaxExternalRefLength]; + } + + private static string BuildExcerpt(string text, int maxLength) + { + var normalized = string.Join( + " ", + text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + return normalized.Length <= maxLength + ? normalized + : normalized[..maxLength]; + } + + internal sealed record MarkdownSection(string? Heading, string Body); +} From 80ff111dd9b7f61cceefbc47e6e9f99413580913 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:03:51 +0100 Subject: [PATCH 06/15] Add NoteImportController with markdown and web clip endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/import/notes/markdown — parses markdown into sections, creates capture items. POST /api/import/notes/webclip — creates a capture item from a URL + content snippet with source provenance. --- .../Controllers/NoteImportController.cs | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 backend/src/Taskdeck.Api/Controllers/NoteImportController.cs diff --git a/backend/src/Taskdeck.Api/Controllers/NoteImportController.cs b/backend/src/Taskdeck.Api/Controllers/NoteImportController.cs new file mode 100644 index 000000000..2119c4916 --- /dev/null +++ b/backend/src/Taskdeck.Api/Controllers/NoteImportController.cs @@ -0,0 +1,103 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Taskdeck.Api.Contracts; +using Taskdeck.Api.Extensions; +using Taskdeck.Api.RateLimiting; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; + +namespace Taskdeck.Api.Controllers; + +/// +/// Note-style import endpoints for markdown files and web clips. +/// All imported content routes through the standard capture pipeline — +/// no direct board mutations occur here. +/// +[ApiController] +[Authorize] +[Route("api/import/notes")] +[Produces("application/json")] +public class NoteImportController : AuthenticatedControllerBase +{ + private readonly INoteImportService _noteImportService; + + public NoteImportController( + INoteImportService noteImportService, + IUserContext userContext) + : base(userContext) + { + _noteImportService = noteImportService; + } + + /// + /// Import a markdown file. The content is parsed into sections and + /// each section becomes a capture item in the standard pipeline. + /// + /// Markdown import request with filename and content. + /// Cancellation token. + /// Import result with created capture item IDs. + /// Markdown imported successfully — capture items created. + /// Validation error (empty content, oversized file, etc.). + /// Authentication required. + /// Rate limit exceeded. + [HttpPost("markdown")] + [EnableRateLimiting(RateLimitingPolicyNames.CaptureWritePerUser)] + [ProducesResponseType(typeof(NoteImportResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] + public async Task ImportMarkdown( + [FromBody] MarkdownImportRequestDto? dto, + CancellationToken cancellationToken) + { + if (!TryGetCurrentUserId(out var userId, out var errorResult)) + return errorResult!; + + if (dto == null) + { + return BadRequest(new ApiErrorResponse( + "VALIDATION_ERROR", + "Request body is required")); + } + + var result = await _noteImportService.ImportMarkdownAsync(userId, dto, cancellationToken); + return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); + } + + /// + /// Import a web clip (URL + content snippet). Creates a single capture + /// item with the URL preserved as source provenance. + /// + /// Web clip import request with URL, content, and optional title. + /// Cancellation token. + /// Import result with created capture item ID. + /// Web clip imported successfully — capture item created. + /// Validation error (invalid URL, empty content, etc.). + /// Authentication required. + /// Rate limit exceeded. + [HttpPost("webclip")] + [EnableRateLimiting(RateLimitingPolicyNames.CaptureWritePerUser)] + [ProducesResponseType(typeof(NoteImportResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] + public async Task ImportWebClip( + [FromBody] WebClipImportRequestDto? dto, + CancellationToken cancellationToken) + { + if (!TryGetCurrentUserId(out var userId, out var errorResult)) + return errorResult!; + + if (dto == null) + { + return BadRequest(new ApiErrorResponse( + "VALIDATION_ERROR", + "Request body is required")); + } + + var result = await _noteImportService.ImportWebClipAsync(userId, dto, cancellationToken); + return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); + } +} From 7b68632f8a3eb876e558da8d1579a8cc5ca03fb5 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:04:08 +0100 Subject: [PATCH 07/15] Register INoteImportService in DI container --- .../Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs index 720f1bff7..877b26c9b 100644 --- a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs @@ -31,6 +31,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); From 6f89eb44f4be33952d2afab00630263de190bfdd Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:04:32 +0100 Subject: [PATCH 08/15] Add TypeScript types for note import API --- .../taskdeck-web/src/types/note-import.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 frontend/taskdeck-web/src/types/note-import.ts diff --git a/frontend/taskdeck-web/src/types/note-import.ts b/frontend/taskdeck-web/src/types/note-import.ts new file mode 100644 index 000000000..99332f335 --- /dev/null +++ b/frontend/taskdeck-web/src/types/note-import.ts @@ -0,0 +1,24 @@ +export interface MarkdownImportRequest { + fileName: string + content: string + boardId?: string | null +} + +export interface WebClipImportRequest { + url: string + content: string + title?: string | null + boardId?: string | null +} + +export interface NoteImportItemResult { + captureItemId: string + textExcerpt: string + sourceType: string + sourceRef: string | null +} + +export interface NoteImportResult { + itemsCreated: number + items: NoteImportItemResult[] +} From 57c671eb3a5f56d3d2dcf87a73a64895e55ce721 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:04:41 +0100 Subject: [PATCH 09/15] Add noteImportApi client for markdown and web clip endpoints --- frontend/taskdeck-web/src/api/noteImportApi.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 frontend/taskdeck-web/src/api/noteImportApi.ts diff --git a/frontend/taskdeck-web/src/api/noteImportApi.ts b/frontend/taskdeck-web/src/api/noteImportApi.ts new file mode 100644 index 000000000..0f9a8d20a --- /dev/null +++ b/frontend/taskdeck-web/src/api/noteImportApi.ts @@ -0,0 +1,18 @@ +import http from './http' +import type { + MarkdownImportRequest, + WebClipImportRequest, + NoteImportResult, +} from '../types/note-import' + +export const noteImportApi = { + async importMarkdown(request: MarkdownImportRequest): Promise { + const { data } = await http.post('/import/notes/markdown', request) + return data + }, + + async importWebClip(request: WebClipImportRequest): Promise { + const { data } = await http.post('/import/notes/webclip', request) + return data + }, +} From 570e9cd1292323eabfd980163480e9ef4e3a53a0 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:06:12 +0100 Subject: [PATCH 10/15] Extend ExportImportView with Markdown and Web Clip tabs Add two new tabs to the existing Export/Import view: - Markdown: file upload or paste, split at headings, routed to capture inbox for review - Web Clip: URL + content snippet intake, preserves source URL as provenance, routed to capture inbox --- .../src/views/ExportImportView.vue | 287 +++++++++++++++++- 1 file changed, 285 insertions(+), 2 deletions(-) diff --git a/frontend/taskdeck-web/src/views/ExportImportView.vue b/frontend/taskdeck-web/src/views/ExportImportView.vue index 105077796..06d512db1 100644 --- a/frontend/taskdeck-web/src/views/ExportImportView.vue +++ b/frontend/taskdeck-web/src/views/ExportImportView.vue @@ -1,24 +1,46 @@ @@ -188,7 +466,7 @@ function resetImport() { .td-section-title { font-size: var(--td-font-lg); font-weight: 600; margin-bottom: var(--td-space-2); color: var(--td-text-primary); } .td-section-desc { font-size: var(--td-font-sm); color: var(--td-text-secondary); margin-bottom: var(--td-space-4); } .td-export-form { display: flex; gap: var(--td-space-3); align-items: flex-end; margin-bottom: var(--td-space-4); } -.td-form-group { display: flex; flex-direction: column; gap: var(--td-space-1); flex: 1; } +.td-form-group { display: flex; flex-direction: column; gap: var(--td-space-1); flex: 1; margin-bottom: var(--td-space-3); } .td-label { font-size: var(--td-font-sm); font-weight: 500; color: var(--td-text-secondary); } .td-input { padding: var(--td-space-2) var(--td-space-3); border: 1px solid var(--td-border-default); border-radius: var(--td-radius-md); font-size: var(--td-font-sm); } .td-input:focus { outline: none; border-color: var(--td-border-focus); box-shadow: var(--td-focus-ring); } @@ -211,4 +489,9 @@ function resetImport() { .td-import-result--success { background: var(--td-color-success-light); color: var(--td-color-success); } .td-import-result--error { background: var(--td-color-error-light); color: var(--td-color-error); } .td-import-summary { margin-bottom: var(--td-space-4); color: var(--td-text-secondary); font-size: var(--td-font-sm); } +.td-note-import-items { display: flex; flex-direction: column; gap: var(--td-space-2); margin-bottom: var(--td-space-4); } +.td-note-import-item { display: flex; align-items: center; gap: var(--td-space-2); padding: var(--td-space-2) var(--td-space-3); background: var(--td-surface-container-lowest); border-radius: var(--td-radius-md); font-size: var(--td-font-sm); } +.td-note-import-badge { display: inline-block; padding: var(--td-space-0) var(--td-space-2); background: var(--td-color-primary); color: var(--td-text-inverse); border-radius: var(--td-radius-sm); font-size: var(--td-font-xs); font-weight: 600; text-transform: uppercase; flex-shrink: 0; } +.td-note-import-excerpt { color: var(--td-text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.td-note-import-ref { color: var(--td-text-tertiary); font-size: var(--td-font-xs); flex-shrink: 0; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } From 5f48ada5f1c974fa9f552a2329134ecf036bdd88 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:07:34 +0100 Subject: [PATCH 11/15] Add NoteImportService unit tests Covers markdown import (section splitting, provenance, validation, path traversal rejection), web clip import (URL validation, source preservation, content limits), and security edge cases (javascript URLs, data URLs, backslash paths). --- .../Services/NoteImportServiceTests.cs | 581 ++++++++++++++++++ 1 file changed, 581 insertions(+) create mode 100644 backend/tests/Taskdeck.Application.Tests/Services/NoteImportServiceTests.cs diff --git a/backend/tests/Taskdeck.Application.Tests/Services/NoteImportServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/NoteImportServiceTests.cs new file mode 100644 index 000000000..b7da6aaa5 --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/NoteImportServiceTests.cs @@ -0,0 +1,581 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Enums; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class NoteImportServiceTests +{ + private readonly Mock _captureServiceMock; + private readonly NoteImportService _sut; + + public NoteImportServiceTests() + { + _captureServiceMock = new Mock(); + _sut = new NoteImportService(_captureServiceMock.Object); + } + + private void SetupCaptureServiceReturnsSuccess() + { + var counter = 0; + _captureServiceMock + .Setup(s => s.CreateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(() => + { + counter++; + var itemId = Guid.NewGuid(); + return Result.Success(new CaptureItemDto( + itemId, + Guid.NewGuid(), + null, + CaptureStatus.New, + CaptureSource.MarkdownImport, + "raw text", + "excerpt", + DateTimeOffset.UtcNow, + null, + 0)); + }); + } + + // --- Markdown import tests --- + + [Fact] + public async Task ImportMarkdownAsync_ShouldFail_WhenUserIdIsEmpty() + { + var request = new MarkdownImportRequestDto("test.md", "# Hello"); + + var result = await _sut.ImportMarkdownAsync(Guid.Empty, request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldFail_WhenRequestIsNull() + { + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), null!); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldFail_WhenFileNameIsEmpty() + { + var request = new MarkdownImportRequestDto("", "# Hello"); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("File name"); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldFail_WhenFileNameContainsPathTraversal() + { + var request = new MarkdownImportRequestDto("../../../etc/passwd", "# Hello"); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("invalid characters"); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldFail_WhenContentIsEmpty() + { + var request = new MarkdownImportRequestDto("notes.md", ""); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("content is required"); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldFail_WhenContentExceedsMaxLength() + { + var request = new MarkdownImportRequestDto( + "notes.md", + new string('x', NoteImportService.MaxMarkdownContentLength + 1)); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("cannot exceed"); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldCreateCaptureItems_ForEachSection() + { + SetupCaptureServiceReturnsSuccess(); + + var content = "# Section One\nBody of section one\n\n# Section Two\nBody of section two"; + var request = new MarkdownImportRequestDto("notes.md", content); + var userId = Guid.NewGuid(); + + var result = await _sut.ImportMarkdownAsync(userId, request); + + result.IsSuccess.Should().BeTrue(); + result.Value.ItemsCreated.Should().Be(2); + result.Value.Items.Should().HaveCount(2); + + _captureServiceMock.Verify( + s => s.CreateAsync(userId, It.IsAny(), It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldUseCaptureSourceMarkdownImport() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new MarkdownImportRequestDto("notes.md", "# Hello\nWorld"); + var userId = Guid.NewGuid(); + + await _sut.ImportMarkdownAsync(userId, request); + + _captureServiceMock.Verify( + s => s.CreateAsync(userId, + It.Is(dto => dto.Source == "MarkdownImport"), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldPreserveSourceFileName_InExternalRef() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new MarkdownImportRequestDto("my-notes.md", "# Heading\nBody text"); + + await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + _captureServiceMock.Verify( + s => s.CreateAsync(It.IsAny(), + It.Is(dto => + dto.ExternalRef != null && dto.ExternalRef.Contains("my-notes.md")), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldSetTitleHint_FromHeading() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new MarkdownImportRequestDto("notes.md", "# My Important Note\nContent here"); + + await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + _captureServiceMock.Verify( + s => s.CreateAsync(It.IsAny(), + It.Is(dto => dto.TitleHint == "My Important Note"), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldHandlePlainTextWithoutHeadings() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new MarkdownImportRequestDto("notes.md", "Just some plain text content\nWith multiple lines"); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeTrue(); + result.Value.ItemsCreated.Should().Be(1); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldPassBoardId_WhenProvided() + { + SetupCaptureServiceReturnsSuccess(); + + var boardId = Guid.NewGuid(); + var request = new MarkdownImportRequestDto("notes.md", "# Hello\nWorld", boardId); + + await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + _captureServiceMock.Verify( + s => s.CreateAsync(It.IsAny(), + It.Is(dto => dto.BoardId == boardId), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldReturnItemSourceType_AsMarkdown() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new MarkdownImportRequestDto("notes.md", "# Hello\nWorld"); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeTrue(); + result.Value.Items[0].SourceType.Should().Be("markdown"); + } + + // --- Web clip import tests --- + + [Fact] + public async Task ImportWebClipAsync_ShouldFail_WhenUserIdIsEmpty() + { + var request = new WebClipImportRequestDto("https://example.com", "content"); + + var result = await _sut.ImportWebClipAsync(Guid.Empty, request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldFail_WhenRequestIsNull() + { + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), null!); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldFail_WhenUrlIsEmpty() + { + var request = new WebClipImportRequestDto("", "content"); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("URL is required"); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldFail_WhenUrlIsNotHttpOrHttps() + { + var request = new WebClipImportRequestDto("ftp://evil.com/file", "content"); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("valid HTTP or HTTPS URL"); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldFail_WhenUrlIsInvalid() + { + var request = new WebClipImportRequestDto("not a url", "content"); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldFail_WhenContentIsEmpty() + { + var request = new WebClipImportRequestDto("https://example.com", ""); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("content is required"); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldFail_WhenContentExceedsMaxLength() + { + var request = new WebClipImportRequestDto( + "https://example.com", + new string('x', NoteImportService.MaxWebClipContentLength + 1)); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("cannot exceed"); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldCreateSingleCaptureItem() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new WebClipImportRequestDto( + "https://example.com/article", + "Important content from article"); + var userId = Guid.NewGuid(); + + var result = await _sut.ImportWebClipAsync(userId, request); + + result.IsSuccess.Should().BeTrue(); + result.Value.ItemsCreated.Should().Be(1); + result.Value.Items.Should().HaveCount(1); + + _captureServiceMock.Verify( + s => s.CreateAsync(userId, It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldUseCaptureSourceWebClip() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new WebClipImportRequestDto( + "https://example.com", + "content"); + + await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + _captureServiceMock.Verify( + s => s.CreateAsync(It.IsAny(), + It.Is(dto => dto.Source == "WebClip"), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldPreserveUrl_InExternalRef() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new WebClipImportRequestDto( + "https://example.com/important", + "content"); + + await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + _captureServiceMock.Verify( + s => s.CreateAsync(It.IsAny(), + It.Is(dto => + dto.ExternalRef == "https://example.com/important"), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldIncludeUrlInCaptureText() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new WebClipImportRequestDto( + "https://example.com/article", + "Content from the article"); + + await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + _captureServiceMock.Verify( + s => s.CreateAsync(It.IsAny(), + It.Is(dto => + dto.Text.Contains("https://example.com/article") && + dto.Text.Contains("Content from the article")), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldSetTitleHint_WhenProvided() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new WebClipImportRequestDto( + "https://example.com", + "content", + "Article Title"); + + await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + _captureServiceMock.Verify( + s => s.CreateAsync(It.IsAny(), + It.Is(dto => dto.TitleHint == "Article Title"), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldPassBoardId_WhenProvided() + { + SetupCaptureServiceReturnsSuccess(); + + var boardId = Guid.NewGuid(); + var request = new WebClipImportRequestDto( + "https://example.com", + "content", + null, + boardId); + + await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + _captureServiceMock.Verify( + s => s.CreateAsync(It.IsAny(), + It.Is(dto => dto.BoardId == boardId), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldReturnItemSourceType_AsWebClip() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new WebClipImportRequestDto( + "https://example.com", + "content"); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeTrue(); + result.Value.Items[0].SourceType.Should().Be("webclip"); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldFail_WhenUrlExceedsMaxLength() + { + var request = new WebClipImportRequestDto( + "https://example.com/" + new string('a', NoteImportService.MaxUrlLength), + "content"); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldFail_WhenTitleExceedsMaxLength() + { + var request = new WebClipImportRequestDto( + "https://example.com", + "content", + new string('t', NoteImportService.MaxTitleLength + 1)); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + // --- Markdown section splitting tests --- + + [Fact] + public void SplitMarkdownIntoSections_ShouldHandleSingleSection() + { + var sections = NoteImportService.SplitMarkdownIntoSections("# Title\nBody text"); + + sections.Should().HaveCount(1); + sections[0].Heading.Should().Be("Title"); + sections[0].Body.Should().Be("Body text"); + } + + [Fact] + public void SplitMarkdownIntoSections_ShouldHandleMultipleSections() + { + var sections = NoteImportService.SplitMarkdownIntoSections( + "# First\nBody one\n\n# Second\nBody two\n\n## Subsection\nBody three"); + + sections.Should().HaveCount(3); + sections[0].Heading.Should().Be("First"); + sections[1].Heading.Should().Be("Second"); + sections[2].Heading.Should().Be("Subsection"); + } + + [Fact] + public void SplitMarkdownIntoSections_ShouldHandleContentBeforeFirstHeading() + { + var sections = NoteImportService.SplitMarkdownIntoSections( + "Preamble text\n\n# First Heading\nBody"); + + sections.Should().HaveCount(2); + sections[0].Heading.Should().BeNull(); + sections[0].Body.Should().Be("Preamble text"); + sections[1].Heading.Should().Be("First Heading"); + } + + [Fact] + public void SplitMarkdownIntoSections_ShouldHandlePlainTextWithNoHeadings() + { + var sections = NoteImportService.SplitMarkdownIntoSections( + "Just some plain text\nWith multiple lines"); + + sections.Should().HaveCount(1); + sections[0].Heading.Should().BeNull(); + sections[0].Body.Should().Contain("Just some plain text"); + } + + [Fact] + public void SplitMarkdownIntoSections_ShouldHandleEmptyBodyAfterHeading() + { + var sections = NoteImportService.SplitMarkdownIntoSections("# Empty Section"); + + sections.Should().HaveCount(1); + sections[0].Heading.Should().Be("Empty Section"); + sections[0].Body.Should().BeEmpty(); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldFail_WhenFileNameContainsBackslash() + { + var request = new MarkdownImportRequestDto("..\\..\\secret.md", "# Hello"); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldFail_WhenFileNameContainsForwardSlash() + { + var request = new MarkdownImportRequestDto("path/to/file.md", "# Hello"); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldRejectJavascriptUrl() + { + var request = new WebClipImportRequestDto( + "javascript:alert(1)", + "content"); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldRejectDataUrl() + { + var request = new WebClipImportRequestDto( + "data:text/html,", + "content"); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } +} From 75fbb8876ac1862354737f5211dd94176e810115 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:08:07 +0100 Subject: [PATCH 12/15] Add noteImportApi frontend unit tests Covers markdown import and web clip import API calls with and without optional parameters. --- .../src/tests/api/noteImportApi.spec.ts | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/api/noteImportApi.spec.ts diff --git a/frontend/taskdeck-web/src/tests/api/noteImportApi.spec.ts b/frontend/taskdeck-web/src/tests/api/noteImportApi.spec.ts new file mode 100644 index 000000000..3183e4a6e --- /dev/null +++ b/frontend/taskdeck-web/src/tests/api/noteImportApi.spec.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import http from '../../api/http' +import { noteImportApi } from '../../api/noteImportApi' + +vi.mock('../../api/http', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + }, +})) + +describe('noteImportApi', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('importMarkdown', () => { + it('posts markdown import request', async () => { + vi.mocked(http.post).mockResolvedValue({ + data: { + itemsCreated: 2, + items: [ + { + captureItemId: 'item-1', + textExcerpt: 'Section one content', + sourceType: 'markdown', + sourceRef: 'md://notes.md#Section-One', + }, + { + captureItemId: 'item-2', + textExcerpt: 'Section two content', + sourceType: 'markdown', + sourceRef: 'md://notes.md#Section-Two', + }, + ], + }, + }) + + const result = await noteImportApi.importMarkdown({ + fileName: 'notes.md', + content: '# Section One\nContent\n\n# Section Two\nMore content', + boardId: 'board-123', + }) + + expect(http.post).toHaveBeenCalledWith('/import/notes/markdown', { + fileName: 'notes.md', + content: '# Section One\nContent\n\n# Section Two\nMore content', + boardId: 'board-123', + }) + expect(result.itemsCreated).toBe(2) + expect(result.items).toHaveLength(2) + expect(result.items[0].sourceType).toBe('markdown') + }) + + it('posts markdown import without boardId', async () => { + vi.mocked(http.post).mockResolvedValue({ + data: { itemsCreated: 1, items: [] }, + }) + + await noteImportApi.importMarkdown({ + fileName: 'notes.md', + content: '# Hello', + }) + + expect(http.post).toHaveBeenCalledWith('/import/notes/markdown', { + fileName: 'notes.md', + content: '# Hello', + }) + }) + }) + + describe('importWebClip', () => { + it('posts web clip import request', async () => { + vi.mocked(http.post).mockResolvedValue({ + data: { + itemsCreated: 1, + items: [ + { + captureItemId: 'clip-1', + textExcerpt: '[Web Clip] https://example.com', + sourceType: 'webclip', + sourceRef: 'https://example.com', + }, + ], + }, + }) + + const result = await noteImportApi.importWebClip({ + url: 'https://example.com', + content: 'Important article content', + title: 'Article Title', + boardId: null, + }) + + expect(http.post).toHaveBeenCalledWith('/import/notes/webclip', { + url: 'https://example.com', + content: 'Important article content', + title: 'Article Title', + boardId: null, + }) + expect(result.itemsCreated).toBe(1) + expect(result.items[0].sourceType).toBe('webclip') + }) + + it('posts web clip without optional fields', async () => { + vi.mocked(http.post).mockResolvedValue({ + data: { itemsCreated: 1, items: [] }, + }) + + await noteImportApi.importWebClip({ + url: 'https://example.com', + content: 'Clip content', + }) + + expect(http.post).toHaveBeenCalledWith('/import/notes/webclip', { + url: 'https://example.com', + content: 'Clip content', + }) + }) + }) +}) From ade9794d9fac15f378bfa92c9119e3ca12923b4f Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 22:50:02 +0100 Subject: [PATCH 13/15] Fix silent success on total import failure and sourceRef inconsistency - Return failure result when all markdown sections fail to import, surfacing the last error code and message instead of a misleading success with 0 items - Use truncated ExternalRef value consistently in both the CreateCaptureItemDto and the NoteImportItemResultDto response - Add tests for all-sections-fail scenario and response ref length --- .../Services/NoteImportService.cs | 22 ++++++++++- .../Services/NoteImportServiceTests.cs | 38 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/NoteImportService.cs b/backend/src/Taskdeck.Application/Services/NoteImportService.cs index 76a0c4af6..fb6a84ad8 100644 --- a/backend/src/Taskdeck.Application/Services/NoteImportService.cs +++ b/backend/src/Taskdeck.Application/Services/NoteImportService.cs @@ -84,6 +84,9 @@ public async Task> ImportMarkdownAsync( } var items = new List(); + var sectionsAttempted = 0; + string? lastErrorCode = null; + string? lastErrorMessage = null; foreach (var section in sections) { @@ -97,6 +100,8 @@ public async Task> ImportMarkdownAsync( if (string.IsNullOrWhiteSpace(captureText)) continue; + sectionsAttempted++; + // Truncate to CaptureRequestContract max if needed if (captureText.Length > CaptureRequestContract.MaxRawTextLength) { @@ -109,22 +114,35 @@ public async Task> ImportMarkdownAsync( titleHint = titleHint[..MaxTitleLength]; } + var truncatedRef = TruncateExternalRef(externalRef); + var dto = new CreateCaptureItemDto( request.BoardId, captureText, Source: CaptureSource.MarkdownImport.ToString(), TitleHint: titleHint, - ExternalRef: TruncateExternalRef(externalRef)); + ExternalRef: truncatedRef); var result = await _captureService.CreateAsync(userId, dto, cancellationToken); if (!result.IsSuccess) + { + lastErrorCode = result.ErrorCode; + lastErrorMessage = result.ErrorMessage; continue; + } items.Add(new NoteImportItemResultDto( result.Value.Id, BuildExcerpt(captureText, 200), "markdown", - externalRef)); + truncatedRef)); + } + + if (items.Count == 0 && sectionsAttempted > 0) + { + return Result.Failure( + lastErrorCode ?? ErrorCodes.UnexpectedError, + $"All {sectionsAttempted} section(s) failed to import. Last error: {lastErrorMessage ?? "unknown"}"); } return Result.Success(new NoteImportResultDto(items.Count, items)); diff --git a/backend/tests/Taskdeck.Application.Tests/Services/NoteImportServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/NoteImportServiceTests.cs index b7da6aaa5..1ee988949 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/NoteImportServiceTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/NoteImportServiceTests.cs @@ -231,6 +231,44 @@ public async Task ImportMarkdownAsync_ShouldReturnItemSourceType_AsMarkdown() result.Value.Items[0].SourceType.Should().Be("markdown"); } + [Fact] + public async Task ImportMarkdownAsync_ShouldFail_WhenAllSectionsFail() + { + _captureServiceMock + .Setup(s => s.CreateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(Result.Failure(ErrorCodes.Forbidden, "You do not have access to this board")); + + var content = "# Section One\nBody of section one\n\n# Section Two\nBody of section two"; + var request = new MarkdownImportRequestDto("notes.md", content); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorMessage.Should().Contain("failed to import"); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldReturnTruncatedExternalRef_InResponseItems() + { + SetupCaptureServiceReturnsSuccess(); + + // Use a heading long enough that after md:// prefix the ref would be very long + var request = new MarkdownImportRequestDto("notes.md", "# Short Heading\nBody text"); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeTrue(); + // The sourceRef in the response should not exceed MaxExternalRefLength + foreach (var item in result.Value.Items) + { + item.SourceRef.Should().NotBeNull(); + item.SourceRef!.Length.Should().BeLessOrEqualTo(CaptureRequestContract.MaxExternalRefLength); + } + } + // --- Web clip import tests --- [Fact] From f81749bb210e53fe99fd8a608fd93e2001d542fd Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 23:21:57 +0100 Subject: [PATCH 14/15] Guard proposal decisions with EF concurrency --- .../Services/AutomationProposalService.cs | 8 ++--- .../AutomationProposalConfiguration.cs | 3 +- .../Repositories/UnitOfWork.cs | 9 ++++++ .../ConcurrencyRaceConditionStressTests.cs | 32 +++++++++++-------- .../AutomationProposalServiceEdgeCaseTests.cs | 21 ++++++++++++ 5 files changed, 54 insertions(+), 19 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs b/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs index 6422581a1..cad17948f 100644 --- a/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs +++ b/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs @@ -155,10 +155,10 @@ public async Task> ApproveProposalAsync(Guid id, Guid decide return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found"); proposal.Approve(decidedByUserId); + await _unitOfWork.SaveChangesAsync(cancellationToken); var notifyResult = await PublishProposalOutcomeNotificationAsync(proposal, "approved", cancellationToken); if (!notifyResult.IsSuccess) return Result.Failure(notifyResult.ErrorCode, notifyResult.ErrorMessage); - await _unitOfWork.SaveChangesAsync(cancellationToken); return Result.Success(MapToDto(proposal)); } @@ -177,10 +177,10 @@ public async Task> RejectProposalAsync(Guid id, Guid decided return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found"); proposal.Reject(decidedByUserId, dto.Reason); + await _unitOfWork.SaveChangesAsync(cancellationToken); var notifyResult = await PublishProposalOutcomeNotificationAsync(proposal, "rejected", cancellationToken); if (!notifyResult.IsSuccess) return Result.Failure(notifyResult.ErrorCode, notifyResult.ErrorMessage); - await _unitOfWork.SaveChangesAsync(cancellationToken); return Result.Success(MapToDto(proposal)); } @@ -199,10 +199,10 @@ public async Task> MarkAsAppliedAsync(Guid id, CancellationT return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found"); proposal.MarkAsApplied(); + await _unitOfWork.SaveChangesAsync(cancellationToken); var notifyResult = await PublishProposalOutcomeNotificationAsync(proposal, "applied", cancellationToken); if (!notifyResult.IsSuccess) return Result.Failure(notifyResult.ErrorCode, notifyResult.ErrorMessage); - await _unitOfWork.SaveChangesAsync(cancellationToken); return Result.Success(MapToDto(proposal)); } @@ -221,10 +221,10 @@ public async Task> MarkAsFailedAsync(Guid id, string failure return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found"); proposal.MarkAsFailed(failureReason); + await _unitOfWork.SaveChangesAsync(cancellationToken); var notifyResult = await PublishProposalOutcomeNotificationAsync(proposal, "failed", cancellationToken); if (!notifyResult.IsSuccess) return Result.Failure(notifyResult.ErrorCode, notifyResult.ErrorMessage); - await _unitOfWork.SaveChangesAsync(cancellationToken); return Result.Success(MapToDto(proposal)); } diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalConfiguration.cs b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalConfiguration.cs index 59a6f452b..5e621579b 100644 --- a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalConfiguration.cs +++ b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalConfiguration.cs @@ -66,7 +66,8 @@ public void Configure(EntityTypeBuilder builder) .IsRequired(); builder.Property(ap => ap.UpdatedAt) - .IsRequired(); + .IsRequired() + .IsConcurrencyToken(); builder.HasMany(ap => ap.Operations) .WithOne(o => o.Proposal) diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs b/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs index 9cd2cf84a..4ad27f4a4 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs @@ -1,7 +1,9 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Common; using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; using Taskdeck.Infrastructure.Persistence; namespace Taskdeck.Infrastructure.Repositories; @@ -102,6 +104,13 @@ public async Task SaveChangesAsync(CancellationToken cancellationToken = de { return await _context.SaveChangesAsync(cancellationToken); } + catch (DbUpdateConcurrencyException ex) + { + throw new DomainException( + ErrorCodes.Conflict, + "The requested change conflicted with a concurrent update.", + ex); + } catch (DbUpdateException ex) when (TryResolveRecoverableUniqueConflicts(ex)) { return await _context.SaveChangesAsync(cancellationToken); diff --git a/backend/tests/Taskdeck.Api.Tests/ConcurrencyRaceConditionStressTests.cs b/backend/tests/Taskdeck.Api.Tests/ConcurrencyRaceConditionStressTests.cs index a19b80681..01edc17dd 100644 --- a/backend/tests/Taskdeck.Api.Tests/ConcurrencyRaceConditionStressTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ConcurrencyRaceConditionStressTests.cs @@ -497,15 +497,15 @@ public async Task ProposalApprove_ConcurrentDoubleApprove_ExactlyOneSucceeds() barrier.Release(2); await Task.WhenAll(approveTasks); - var codes = statusCodes.ToList(); - var successCount = codes.Count(s => s == HttpStatusCode.OK); - var failCount = codes.Count(s => s != HttpStatusCode.OK); - - // Exactly one should succeed, one should fail - successCount.Should().Be(1, - "exactly one concurrent approve should succeed"); - failCount.Should().Be(1, - "the second concurrent approve should fail"); + var codes = statusCodes.ToList(); + var successCount = codes.Count(s => s == HttpStatusCode.OK); + var conflictCount = codes.Count(s => s == HttpStatusCode.Conflict); + + // Exactly one should succeed, one should fail + successCount.Should().Be(1, + "exactly one concurrent approve should succeed"); + conflictCount.Should().Be(1, + "the losing concurrent approve should return 409 conflict"); } /// @@ -573,11 +573,15 @@ public async Task ProposalDecision_ConcurrentApproveAndReject_ExactlyOneWins() barrier.Release(2); await Task.WhenAll(approveTask, rejectTask); - // Exactly one should succeed - var successCount = (results["approve"] == HttpStatusCode.OK ? 1 : 0) - + (results["reject"] == HttpStatusCode.OK ? 1 : 0); - successCount.Should().Be(1, - "exactly one of approve/reject should succeed in a race"); + // Exactly one should succeed + var successCount = (results["approve"] == HttpStatusCode.OK ? 1 : 0) + + (results["reject"] == HttpStatusCode.OK ? 1 : 0); + var conflictCount = (results["approve"] == HttpStatusCode.Conflict ? 1 : 0) + + (results["reject"] == HttpStatusCode.Conflict ? 1 : 0); + successCount.Should().Be(1, + "exactly one of approve/reject should succeed in a race"); + conflictCount.Should().Be(1, + "the losing proposal decision should return 409 conflict"); // Verify final state is consistent var proposalResp = await client.GetAsync($"/api/automation/proposals/{proposalId}"); diff --git a/backend/tests/Taskdeck.Application.Tests/Services/AutomationProposalServiceEdgeCaseTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/AutomationProposalServiceEdgeCaseTests.cs index 08d64b4ac..66933b6b1 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/AutomationProposalServiceEdgeCaseTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/AutomationProposalServiceEdgeCaseTests.cs @@ -74,6 +74,27 @@ public async Task ApproveProposalAsync_ShouldReturnFailure_WhenProposalAlreadyAp result.ErrorMessage.Should().Contain("Approved"); } + [Fact] + public async Task ApproveProposalAsync_ShouldReturnConflict_WhenConcurrentDecisionWins() + { + var proposal = CreatePendingProposal(); + + _proposalRepoMock + .Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _unitOfWorkMock + .Setup(u => u.SaveChangesAsync(It.IsAny())) + .ThrowsAsync(new DomainException(ErrorCodes.Conflict, "The requested change conflicted with a concurrent update.")); + + var result = await _service.ApproveProposalAsync(proposal.Id, Guid.NewGuid()); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Conflict); + _notificationServiceMock.Verify( + s => s.PublishAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + [Fact] public async Task ApproveProposalAsync_ShouldReturnNotFound_WhenProposalDoesNotExist() { From f311fc57d7dd9b90fd618c540b497ca61f423014 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 12 Apr 2026 01:18:13 +0100 Subject: [PATCH 15/15] Address PR #809 review comments for note import - Return partial-success with errors when some sections fail (P1 fix) - Add Warnings and Errors fields to NoteImportResultDto for transparency - Fix webclip returning un-truncated URL in response (now returns truncatedRef) - Add warning when section count exceeds MaxSectionsPerFile limit - Add dedicated NoteImportPerUser rate limit policy (5/min vs 10/min for capture) to account for bulk import creating up to 50 items per request - Add tests for partial success and section truncation warning scenarios --- .../Controllers/NoteImportController.cs | 4 +- .../Extensions/RateLimitingRegistration.cs | 6 ++ .../RateLimiting/RateLimitingPolicyNames.cs | 1 + .../DTOs/NoteImportDtos.cs | 13 ++- .../Services/NoteImportService.cs | 41 +++++++--- .../Services/RateLimitingSettings.cs | 5 ++ .../Services/NoteImportServiceTests.cs | 82 +++++++++++++++++++ 7 files changed, 136 insertions(+), 16 deletions(-) diff --git a/backend/src/Taskdeck.Api/Controllers/NoteImportController.cs b/backend/src/Taskdeck.Api/Controllers/NoteImportController.cs index 2119c4916..b439b568f 100644 --- a/backend/src/Taskdeck.Api/Controllers/NoteImportController.cs +++ b/backend/src/Taskdeck.Api/Controllers/NoteImportController.cs @@ -43,7 +43,7 @@ public NoteImportController( /// Authentication required. /// Rate limit exceeded. [HttpPost("markdown")] - [EnableRateLimiting(RateLimitingPolicyNames.CaptureWritePerUser)] + [EnableRateLimiting(RateLimitingPolicyNames.NoteImportPerUser)] [ProducesResponseType(typeof(NoteImportResultDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] @@ -78,7 +78,7 @@ public async Task ImportMarkdown( /// Authentication required. /// Rate limit exceeded. [HttpPost("webclip")] - [EnableRateLimiting(RateLimitingPolicyNames.CaptureWritePerUser)] + [EnableRateLimiting(RateLimitingPolicyNames.NoteImportPerUser)] [ProducesResponseType(typeof(NoteImportResultDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] diff --git a/backend/src/Taskdeck.Api/Extensions/RateLimitingRegistration.cs b/backend/src/Taskdeck.Api/Extensions/RateLimitingRegistration.cs index 485c6d5b2..40fd6eace 100644 --- a/backend/src/Taskdeck.Api/Extensions/RateLimitingRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/RateLimitingRegistration.cs @@ -73,6 +73,12 @@ await context.HttpContext.Response.WriteAsJsonAsync( return BuildFixedWindowPartition(partitionKey, settings.CaptureWritePerUser); }); + options.AddPolicy(RateLimitingPolicyNames.NoteImportPerUser, httpContext => + { + var partitionKey = $"note-import-user:{ResolveUserOrClientIdentifier(httpContext)}"; + return BuildFixedWindowPartition(partitionKey, settings.NoteImportPerUser); + }); + options.AddPolicy(RateLimitingPolicyNames.McpPerApiKey, httpContext => { // Partition by API key user or fall back to IP for unauthenticated attempts. diff --git a/backend/src/Taskdeck.Api/RateLimiting/RateLimitingPolicyNames.cs b/backend/src/Taskdeck.Api/RateLimiting/RateLimitingPolicyNames.cs index fa6faa433..78b9c02c6 100644 --- a/backend/src/Taskdeck.Api/RateLimiting/RateLimitingPolicyNames.cs +++ b/backend/src/Taskdeck.Api/RateLimiting/RateLimitingPolicyNames.cs @@ -5,5 +5,6 @@ public static class RateLimitingPolicyNames public const string AuthPerIp = "AuthPerIp"; public const string HotPathPerUser = "HotPathPerUser"; public const string CaptureWritePerUser = "CaptureWritePerUser"; + public const string NoteImportPerUser = "NoteImportPerUser"; public const string McpPerApiKey = "McpPerApiKey"; } diff --git a/backend/src/Taskdeck.Application/DTOs/NoteImportDtos.cs b/backend/src/Taskdeck.Application/DTOs/NoteImportDtos.cs index bc3fd1be0..80112a749 100644 --- a/backend/src/Taskdeck.Application/DTOs/NoteImportDtos.cs +++ b/backend/src/Taskdeck.Application/DTOs/NoteImportDtos.cs @@ -26,7 +26,18 @@ public sealed record WebClipImportRequestDto( /// public sealed record NoteImportResultDto( int ItemsCreated, - IReadOnlyList Items); + IReadOnlyList Items, + IReadOnlyList? Warnings = null, + IReadOnlyList? Errors = null); + +/// +/// Error detail for a section that failed to import. +/// +public sealed record NoteImportItemErrorDto( + int SectionIndex, + string? Heading, + string ErrorCode, + string ErrorMessage); /// /// Result for a single capture item created from a note import. diff --git a/backend/src/Taskdeck.Application/Services/NoteImportService.cs b/backend/src/Taskdeck.Application/Services/NoteImportService.cs index fb6a84ad8..8c440c12d 100644 --- a/backend/src/Taskdeck.Application/Services/NoteImportService.cs +++ b/backend/src/Taskdeck.Application/Services/NoteImportService.cs @@ -78,15 +78,18 @@ public async Task> ImportMarkdownAsync( if (sections.Count == 0) return Result.Failure(ErrorCodes.ValidationError, "No content sections found in markdown"); + var warnings = new List(); + var truncatedSectionCount = 0; if (sections.Count > MaxSectionsPerFile) { + truncatedSectionCount = sections.Count - MaxSectionsPerFile; + warnings.Add($"Content contained {sections.Count} sections but only the first {MaxSectionsPerFile} were imported. {truncatedSectionCount} section(s) were skipped."); sections = sections.Take(MaxSectionsPerFile).ToList(); } var items = new List(); - var sectionsAttempted = 0; - string? lastErrorCode = null; - string? lastErrorMessage = null; + var errors = new List(); + var sectionIndex = 0; foreach (var section in sections) { @@ -98,9 +101,10 @@ public async Task> ImportMarkdownAsync( var captureText = BuildCaptureText(section); if (string.IsNullOrWhiteSpace(captureText)) + { + sectionIndex++; continue; - - sectionsAttempted++; + } // Truncate to CaptureRequestContract max if needed if (captureText.Length > CaptureRequestContract.MaxRawTextLength) @@ -126,8 +130,12 @@ public async Task> ImportMarkdownAsync( var result = await _captureService.CreateAsync(userId, dto, cancellationToken); if (!result.IsSuccess) { - lastErrorCode = result.ErrorCode; - lastErrorMessage = result.ErrorMessage; + errors.Add(new NoteImportItemErrorDto( + sectionIndex, + section.Heading, + result.ErrorCode ?? ErrorCodes.UnexpectedError, + result.ErrorMessage ?? "Unknown error")); + sectionIndex++; continue; } @@ -136,16 +144,23 @@ public async Task> ImportMarkdownAsync( BuildExcerpt(captureText, 200), "markdown", truncatedRef)); + sectionIndex++; } - if (items.Count == 0 && sectionsAttempted > 0) + // If all sections failed, return a failure result + if (items.Count == 0 && errors.Count > 0) { + var lastError = errors[^1]; return Result.Failure( - lastErrorCode ?? ErrorCodes.UnexpectedError, - $"All {sectionsAttempted} section(s) failed to import. Last error: {lastErrorMessage ?? "unknown"}"); + lastError.ErrorCode, + $"All {errors.Count} section(s) failed to import. Last error: {lastError.ErrorMessage}"); } - return Result.Success(new NoteImportResultDto(items.Count, items)); + return Result.Success(new NoteImportResultDto( + items.Count, + items, + warnings.Count > 0 ? warnings : null, + errors.Count > 0 ? errors : null)); } public async Task> ImportWebClipAsync( @@ -208,9 +223,9 @@ public async Task> ImportWebClipAsync( result.Value.Id, BuildExcerpt(captureText, 200), "webclip", - request.Url); + externalRef); - return Result.Success(new NoteImportResultDto(1, new List { item })); + return Result.Success(new NoteImportResultDto(1, new List { item }, null, null)); } internal static List SplitMarkdownIntoSections(string content) diff --git a/backend/src/Taskdeck.Application/Services/RateLimitingSettings.cs b/backend/src/Taskdeck.Application/Services/RateLimitingSettings.cs index 58feb7c11..298014014 100644 --- a/backend/src/Taskdeck.Application/Services/RateLimitingSettings.cs +++ b/backend/src/Taskdeck.Application/Services/RateLimitingSettings.cs @@ -6,6 +6,11 @@ public sealed class RateLimitingSettings public RateLimitPolicySettings AuthPerIp { get; set; } = new(20, 60); public RateLimitPolicySettings HotPathPerUser { get; set; } = new(30, 60); public RateLimitPolicySettings CaptureWritePerUser { get; set; } = new(10, 60); + /// + /// Rate limit for note import endpoints. Lower than CaptureWritePerUser because + /// each import request can create up to 50 capture items. + /// + public RateLimitPolicySettings NoteImportPerUser { get; set; } = new(5, 60); public RateLimitPolicySettings McpPerApiKey { get; set; } = new(60, 60); } diff --git a/backend/tests/Taskdeck.Application.Tests/Services/NoteImportServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/NoteImportServiceTests.cs index 1ee988949..76a43a632 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/NoteImportServiceTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/NoteImportServiceTests.cs @@ -250,6 +250,71 @@ public async Task ImportMarkdownAsync_ShouldFail_WhenAllSectionsFail() result.ErrorMessage.Should().Contain("failed to import"); } + [Fact] + public async Task ImportMarkdownAsync_ShouldReturnPartialSuccess_WhenSomeSectionsFail() + { + var callCount = 0; + _captureServiceMock + .Setup(s => s.CreateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(() => + { + callCount++; + if (callCount == 2) + { + return Result.Failure(ErrorCodes.Forbidden, "Access denied"); + } + return Result.Success(new CaptureItemDto( + Guid.NewGuid(), + Guid.NewGuid(), + null, + CaptureStatus.New, + CaptureSource.MarkdownImport, + "raw text", + "excerpt", + DateTimeOffset.UtcNow, + null, + 0)); + }); + + var content = "# Section One\nBody of section one\n\n# Section Two\nBody of section two\n\n# Section Three\nBody three"; + var request = new MarkdownImportRequestDto("notes.md", content); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeTrue(); + result.Value.ItemsCreated.Should().Be(2); + result.Value.Errors.Should().NotBeNull(); + result.Value.Errors.Should().HaveCount(1); + result.Value.Errors![0].SectionIndex.Should().Be(1); + result.Value.Errors![0].Heading.Should().Be("Section Two"); + result.Value.Errors![0].ErrorCode.Should().Be(ErrorCodes.Forbidden); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldReturnWarning_WhenSectionsTruncated() + { + SetupCaptureServiceReturnsSuccess(); + + // Create content with more than MaxSectionsPerFile sections + var sections = Enumerable.Range(1, NoteImportService.MaxSectionsPerFile + 5) + .Select(i => $"# Section {i}\nBody {i}") + .ToList(); + var content = string.Join("\n\n", sections); + + var request = new MarkdownImportRequestDto("notes.md", content); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeTrue(); + result.Value.ItemsCreated.Should().Be(NoteImportService.MaxSectionsPerFile); + result.Value.Warnings.Should().NotBeNull(); + result.Value.Warnings.Should().HaveCount(1); + result.Value.Warnings![0].Should().Contain("5 section(s) were skipped"); + } + [Fact] public async Task ImportMarkdownAsync_ShouldReturnTruncatedExternalRef_InResponseItems() { @@ -616,4 +681,21 @@ public async Task ImportWebClipAsync_ShouldRejectDataUrl() result.IsSuccess.Should().BeFalse(); result.ErrorCode.Should().Be(ErrorCodes.ValidationError); } + + [Fact] + public async Task ImportWebClipAsync_ShouldReturnTruncatedRef_WhenUrlExceedsLimit() + { + SetupCaptureServiceReturnsSuccess(); + + // Create a URL that is longer than MaxExternalRefLength + var longPath = new string('a', CaptureRequestContract.MaxExternalRefLength); + var longUrl = $"https://example.com/{longPath}"; + var request = new WebClipImportRequestDto(longUrl[..NoteImportService.MaxUrlLength], "content"); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeTrue(); + result.Value.Items[0].SourceRef.Should().NotBeNull(); + result.Value.Items[0].SourceRef!.Length.Should().BeLessOrEqualTo(CaptureRequestContract.MaxExternalRefLength); + } }