diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8718f366..820e294d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,7 +142,7 @@ jobs: echo "Test 6: Verifying basic CRUD operations" task_response=$(curl -s -X POST http://localhost:8080/api/v1/TaskItems \ -H "Content-Type: application/json" \ - -d '{"title": "Smoke Test Task", "description": "Integration test", "isComplete": false}') + -d '{"title": "Smoke Test Task", "description": "Integration test"}') task_id=$(echo "$task_response" | jq -r '.id') echo "Created task with ID: $task_id" if [ -z "$task_id" ] || [ "$task_id" = "null" ]; then diff --git a/TaskFlow.Api.Tests/Controllers/V1/JournalEntriesControllerV1Tests.cs b/TaskFlow.Api.Tests/Controllers/V1/JournalEntriesControllerV1Tests.cs index 06ff51e9..09335cb3 100644 --- a/TaskFlow.Api.Tests/Controllers/V1/JournalEntriesControllerV1Tests.cs +++ b/TaskFlow.Api.Tests/Controllers/V1/JournalEntriesControllerV1Tests.cs @@ -412,7 +412,7 @@ public RealRepoHarness() { var entry = new JournalEntry { Title = "Old", Date = date, CreatedAt = DateTime.UtcNow }; var open = new TaskItem { Title = "Open", Status = Status.Todo }; - var done = new TaskItem { Title = "Done", Status = Status.Completed, IsComplete = true }; + var done = new TaskItem { Title = "Done", Status = Status.Completed }; Context.JournalEntries.Add(entry); Context.TaskItems.AddRange(open, done); await Context.SaveChangesAsync(); diff --git a/TaskFlow.Api.Tests/Controllers/V1/ProjectsControllerV1Tests.cs b/TaskFlow.Api.Tests/Controllers/V1/ProjectsControllerV1Tests.cs index 619477fe..1aca91d2 100644 --- a/TaskFlow.Api.Tests/Controllers/V1/ProjectsControllerV1Tests.cs +++ b/TaskFlow.Api.Tests/Controllers/V1/ProjectsControllerV1Tests.cs @@ -36,7 +36,7 @@ public async Task GetAll_ShouldReturnProjectsWithTaskCounts() Tasks = [ new TaskItem { Id = 1, Title = "Open", Status = Status.Todo }, - new TaskItem { Id = 2, Title = "Done flag", IsComplete = true }, + new TaskItem { Id = 2, Title = "Draft", Status = Status.Draft }, new TaskItem { Id = 3, Title = "Done status", Status = Status.Completed }, ] }; @@ -46,8 +46,8 @@ public async Task GetAll_ShouldReturnProjectsWithTaskCounts() var ok = Assert.IsType(result.Result); var dto = Assert.IsAssignableFrom>(ok.Value).Single(); - Assert.Equal(1, dto.OpenTaskCount); - Assert.Equal(2, dto.CompletedTaskCount); + Assert.Equal(2, dto.OpenTaskCount); + Assert.Equal(1, dto.CompletedTaskCount); } [Fact] @@ -99,6 +99,22 @@ public async Task GetTasks_ShouldReturnMappedTasks() Assert.Equal("Todo", dto.Status); } + [Fact] + public async Task GetTasks_ShouldExposeCompletionOnlyThroughStatus() + { + var project = new Project { Id = 1, Name = "Alpha" }; + _mockRepo.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(project); + _mockRepo.Setup(r => r.GetTasksAsync(1)).ReturnsAsync( + [new TaskItem { Id = 4, Title = "T", ProjectId = 1, Project = project, Status = Status.Completed }]); + + var result = await _controller.GetTasks(1); + + var ok = Assert.IsType(result.Result); + var dto = Assert.IsAssignableFrom>(ok.Value).Single(); + Assert.Equal("Completed", dto.Status); + Assert.Null(typeof(TaskItemResponseDto).GetProperty("IsComplete")); + } + [Fact] public async Task Create_ShouldReturnCreated_WhenValid() { diff --git a/TaskFlow.Api.Tests/Controllers/V1/TaskItemsControllerLegacyIsCompleteTests.cs b/TaskFlow.Api.Tests/Controllers/V1/TaskItemsControllerLegacyIsCompleteTests.cs new file mode 100644 index 00000000..15f28d84 --- /dev/null +++ b/TaskFlow.Api.Tests/Controllers/V1/TaskItemsControllerLegacyIsCompleteTests.cs @@ -0,0 +1,133 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; +using TaskFlow.Api.Controllers.V1; +using TaskFlow.Api.Data; +using TaskFlow.Api.DTOs; +using TaskFlow.Api.Extensions; +using TaskFlow.Api.Models; +using TaskFlow.Api.Repositories; +using TaskFlow.Api.Validators; +using MvcJsonOptions = Microsoft.AspNetCore.Mvc.JsonOptions; + +namespace TaskFlow.Api.Tests.Controllers.V1; + +/// +/// Proves a legacy client's stale isComplete is ignored (never a 400) on POST, PUT and PATCH. +/// The raw JSON is deserialized with the MVC resolved from a service collection +/// built with the app's own AddControllers() + ConfigureJsonSerialization(), then the DTOs go through +/// the real controller, repository, validator and SQLite. Model binding itself (the input formatter that calls +/// System.Text.Json with these options) is not exercised; no WebApplicationFactory harness is used. +/// +public class TaskItemsControllerLegacyIsCompleteTests +{ + private static JsonSerializerOptions AppMvcJsonOptions() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddControllers(); + services.ConfigureJsonSerialization(); + using var provider = services.BuildServiceProvider(); + return provider.GetRequiredService>().Value.JsonSerializerOptions; + } + + private sealed class Stack : IDisposable + { + private readonly SqliteConnection _connection = new("DataSource=:memory:"); + + public Stack() + { + _connection.Open(); + Context = new TaskDbContext(new DbContextOptionsBuilder().UseSqlite(_connection).Options); + Context.Database.EnsureCreated(); + Controller = new TaskItemsController( + new TaskRepository(Context), + new TaskItemValidator(), + new JournalEntryRepository(Context), + new Mock().Object); + } + + public TaskDbContext Context { get; } + public TaskItemsController Controller { get; } + + public async Task SeedAsync(Status status) + { + var item = new TaskItem { Title = "Seed", Status = status }; + Context.TaskItems.Add(item); + await Context.SaveChangesAsync(); + Context.ChangeTracker.Clear(); + return item.Id; + } + + public async Task StatusOfAsync(int id) + { + Context.ChangeTracker.Clear(); + return (await Context.TaskItems.AsNoTracking().SingleAsync(t => t.Id == id)).Status; + } + + public void Dispose() + { + Context.Dispose(); + _connection.Dispose(); + } + } + + [Fact] + public void AppMvcJsonOptions_ShouldSkipUnmappedMembers() + { + var options = AppMvcJsonOptions(); + + Assert.Equal(JsonUnmappedMemberHandling.Skip, options.UnmappedMemberHandling); + } + + [Theory] + [InlineData("""{"title":"x","status":"todo","isComplete":true}""", Status.Todo)] + [InlineData("""{"title":"x","status":"completed","isComplete":false}""", Status.Completed)] + public async Task Create_ShouldFollowStatusOnly_WhenLegacyIsCompleteSent(string json, Status expected) + { + using var stack = new Stack(); + var dto = JsonSerializer.Deserialize(json, AppMvcJsonOptions())!; + + var result = await stack.Controller.Create(dto); + + var created = Assert.IsType(result.Result); + var body = Assert.IsType(created.Value); + Assert.Equal(expected.ToString(), body.Status); + Assert.Equal(expected, await stack.StatusOfAsync(body.Id)); + } + + [Theory] + [InlineData("""{"title":"x","status":"todo","isComplete":true}""", Status.Todo, Status.Completed)] + [InlineData("""{"title":"x","status":"completed","isComplete":false}""", Status.Completed, Status.Todo)] + public async Task Update_ShouldFollowStatusOnly_WhenLegacyIsCompleteSent(string json, Status expected, Status seeded) + { + using var stack = new Stack(); + var id = await stack.SeedAsync(seeded); + var dto = JsonSerializer.Deserialize(json, AppMvcJsonOptions())!; + + var result = await stack.Controller.Update(id, dto); + + Assert.IsType(result.Result); + Assert.Equal(expected, await stack.StatusOfAsync(id)); + } + + [Theory] + [InlineData("""{"title":"x","status":"todo","isComplete":true}""", Status.Todo, Status.Completed)] + [InlineData("""{"title":"x","status":"completed","isComplete":false}""", Status.Completed, Status.Todo)] + public async Task Patch_ShouldFollowStatusOnly_WhenLegacyIsCompleteSent(string json, Status expected, Status seeded) + { + using var stack = new Stack(); + var id = await stack.SeedAsync(seeded); + var dto = JsonSerializer.Deserialize(json, AppMvcJsonOptions())!; + + var result = await stack.Controller.Patch(id, dto); + + Assert.IsType(result.Result); + Assert.Equal(expected, await stack.StatusOfAsync(id)); + } +} diff --git a/TaskFlow.Api.Tests/Controllers/V1/TaskItemsControllerPatchTests.cs b/TaskFlow.Api.Tests/Controllers/V1/TaskItemsControllerPatchTests.cs new file mode 100644 index 00000000..7eacaecd --- /dev/null +++ b/TaskFlow.Api.Tests/Controllers/V1/TaskItemsControllerPatchTests.cs @@ -0,0 +1,588 @@ +using FluentValidation.Results; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Moq; +using TaskFlow.Api.Controllers.V1; +using TaskFlow.Api.Data; +using TaskFlow.Api.DTOs; +using TaskFlow.Api.Helpers; +using TaskFlow.Api.Models; +using TaskFlow.Api.Repositories; +using TaskFlow.Api.Validators; + +namespace TaskFlow.Api.Tests.Controllers.V1; + +/// +/// Tests for PATCH /api/v1/TaskItems/{id} and the Status-only completion contract shared with PUT. +/// "Real" tests use a real SQLite context, the real and the real +/// so history events and validation are actually exercised. +/// +public class TaskItemsControllerPatchTests +{ + private readonly Mock _mockRepo = new(); + private readonly Mock> _mockValidator = new(); + private readonly Mock _mockJournalRepo = new(); + private readonly Mock _mockProjectRepo = new(); + private readonly TaskItemsController _mockedController; + + public TaskItemsControllerPatchTests() + { + _mockRepo.Setup(r => r.GetAssignedJournalDateAsync(It.IsAny())).ReturnsAsync((DateOnly?)null); + _mockRepo.Setup(r => r.GetAllAsync()).ReturnsAsync([]); + _mockRepo.Setup(r => r.GetHistoryAsync(It.IsAny())).ReturnsAsync([]); + _mockRepo.Setup(r => r.UpdateAsync(It.IsAny())).Returns(Task.CompletedTask); + _mockValidator.Setup(v => v.ValidateAsync(It.IsAny(), default)).ReturnsAsync(new ValidationResult()); + _mockedController = new TaskItemsController(_mockRepo.Object, _mockValidator.Object, _mockJournalRepo.Object, _mockProjectRepo.Object); + } + + private sealed class RealStack : IDisposable + { + private readonly SqliteConnection _connection; + + public RealStack() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + Context = new TaskDbContext(new DbContextOptionsBuilder().UseSqlite(_connection).Options); + Context.Database.EnsureCreated(); + Controller = new TaskItemsController( + new TaskRepository(Context), + new TaskItemValidator(), + new JournalEntryRepository(Context), + new Mock().Object); + } + + public TaskDbContext Context { get; } + public TaskItemsController Controller { get; } + + public async Task SeedAsync(TaskItem item) + { + Context.TaskItems.Add(item); + await Context.SaveChangesAsync(); + Context.ChangeTracker.Clear(); + return item; + } + + public async Task> EventTypesAsync(int taskId) => + await Context.TaskItemEvents.Where(e => e.TaskItemId == taskId).Select(e => e.EventType).ToListAsync(); + + public async Task ReloadAsync(int id) => + await Context.TaskItems.AsNoTracking().SingleAsync(t => t.Id == id); + + public void Dispose() + { + Context.Dispose(); + _connection.Dispose(); + } + } + + private static TaskItemResponseDto OkDto(ActionResult result) + { + var ok = Assert.IsType(result.Result); + return Assert.IsType(ok.Value); + } + + private static string ErrorCode(ActionResult result) + { + var unprocessable = Assert.IsType(result.Result); + var value = unprocessable.Value!; + return (string)value.GetType().GetProperty("code")!.GetValue(value)!; + } + + private static TaskItem NewRichTask() => new() + { + Title = "Original", + Description = "Keep me", + DueDate = new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc), + Status = Status.Todo, + Priority = Priority.High + }; + + // ---- Real-stack success paths ---- + + [Fact] + public async Task Patch_ShouldLeaveEveryOtherFieldUnchanged_WhenOnlyTitleSent() + { + using var stack = new RealStack(); + var parent = await stack.SeedAsync(new TaskItem { Title = "Parent", Status = Status.Todo }); + var seeded = NewRichTask(); + seeded.ParentTaskItemId = parent.Id; + await stack.SeedAsync(seeded); + + var result = await stack.Controller.Patch(seeded.Id, new PatchTaskItemDto { Title = "Renamed" }); + + var dto = OkDto(result); + Assert.Equal("Renamed", dto.Title); + var reloaded = await stack.ReloadAsync(seeded.Id); + Assert.Equal("Keep me", reloaded.Description); + Assert.Equal(seeded.DueDate, reloaded.DueDate); + Assert.Equal(Status.Todo, reloaded.Status); + Assert.Equal(Priority.High, reloaded.Priority); + Assert.Equal(parent.Id, reloaded.ParentTaskItemId); + Assert.Null(reloaded.ProjectId); + Assert.Equal(["TitleChanged"], await stack.EventTypesAsync(seeded.Id)); + } + + [Fact] + public async Task Patch_ShouldWriteNoDescriptionOrDueDateEvents_WhenStatusToggledWithoutThoseFields() + { + using var stack = new RealStack(); + var seeded = await stack.SeedAsync(NewRichTask()); + + var result = await stack.Controller.Patch(seeded.Id, new PatchTaskItemDto + { + Status = Status.Completed, + AutoCompleteParentWhenChildrenDone = true, + TimezoneOffsetMinutes = -300 + }); + + Assert.Equal("Completed", OkDto(result).Status); + var reloaded = await stack.ReloadAsync(seeded.Id); + Assert.Equal("Keep me", reloaded.Description); + Assert.Equal(seeded.DueDate, reloaded.DueDate); + Assert.Equal(["Completed"], await stack.EventTypesAsync(seeded.Id)); + } + + [Fact] + public async Task Patch_ShouldCompleteThenReopen_WithCompletedAndReopenedEvents() + { + using var stack = new RealStack(); + var seeded = await stack.SeedAsync(NewRichTask()); + + OkDto(await stack.Controller.Patch(seeded.Id, new PatchTaskItemDto { Status = Status.Completed })); + Assert.Equal(Status.Completed, (await stack.ReloadAsync(seeded.Id)).Status); + stack.Context.ChangeTracker.Clear(); + OkDto(await stack.Controller.Patch(seeded.Id, new PatchTaskItemDto { Status = Status.Todo })); + + Assert.Equal(Status.Todo, (await stack.ReloadAsync(seeded.Id)).Status); + Assert.Equal(["Completed", "Reopened"], (await stack.EventTypesAsync(seeded.Id)).Order().ToList()); + } + + [Fact] + public async Task Patch_ShouldClearDescriptionAndWriteEvent_WhenDescriptionExplicitlyNull() + { + using var stack = new RealStack(); + var seeded = await stack.SeedAsync(NewRichTask()); + + var dto = OkDto(await stack.Controller.Patch(seeded.Id, new PatchTaskItemDto { Description = null })); + + Assert.Null(dto.Description); + Assert.Null((await stack.ReloadAsync(seeded.Id)).Description); + Assert.Equal(["DescriptionChanged"], await stack.EventTypesAsync(seeded.Id)); + } + + [Fact] + public async Task Patch_ShouldClearDueDateAndWriteEvent_WhenDueDateExplicitlyNull() + { + using var stack = new RealStack(); + var seeded = await stack.SeedAsync(NewRichTask()); + + var dto = OkDto(await stack.Controller.Patch(seeded.Id, new PatchTaskItemDto { DueDate = null })); + + Assert.Null(dto.DueDate); + var reloaded = await stack.ReloadAsync(seeded.Id); + Assert.Null(reloaded.DueDate); + Assert.Equal("Keep me", reloaded.Description); + Assert.Equal(["DueDateChanged"], await stack.EventTypesAsync(seeded.Id)); + } + + [Fact] + public async Task Patch_ShouldSetDescriptionDueDateAndPriority_WhenSupplied() + { + using var stack = new RealStack(); + var seeded = await stack.SeedAsync(NewRichTask()); + var newDue = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc); + + var dto = OkDto(await stack.Controller.Patch(seeded.Id, new PatchTaskItemDto + { + Description = "New", + DueDate = newDue, + Priority = Priority.Low + })); + + Assert.Equal("New", dto.Description); + Assert.Equal(newDue, dto.DueDate); + Assert.Equal("Low", dto.Priority); + } + + [Fact] + public async Task Patch_ShouldLeaveStatusAndPriorityUnchanged_WhenSentExplicitlyNull() + { + using var stack = new RealStack(); + var seeded = await stack.SeedAsync(NewRichTask()); + + var dto = OkDto(await stack.Controller.Patch(seeded.Id, new PatchTaskItemDto { Status = null, Priority = null, Title = "T" })); + + Assert.Equal("Todo", dto.Status); + Assert.Equal("High", dto.Priority); + } + + [Fact] + public async Task Patch_ShouldSetAndClearParent_WhenParentPresent() + { + using var stack = new RealStack(); + var parent = await stack.SeedAsync(new TaskItem { Title = "Parent", Status = Status.Todo }); + var child = await stack.SeedAsync(new TaskItem { Title = "Child", Status = Status.Todo }); + + var assigned = OkDto(await stack.Controller.Patch(child.Id, new PatchTaskItemDto { ParentTaskItemId = parent.Id })); + Assert.Equal(parent.Id, assigned.ParentTaskItemId); + stack.Context.ChangeTracker.Clear(); + + var untouched = OkDto(await stack.Controller.Patch(child.Id, new PatchTaskItemDto { Title = "Renamed child" })); + Assert.Equal(parent.Id, untouched.ParentTaskItemId); + stack.Context.ChangeTracker.Clear(); + + var cleared = OkDto(await stack.Controller.Patch(child.Id, new PatchTaskItemDto { ParentTaskItemId = null })); + Assert.Null(cleared.ParentTaskItemId); + } + + [Fact] + public async Task Patch_ShouldKeepCompletedStatus_WhenRenamingCompletedTask() + { + using var stack = new RealStack(); + var seeded = await stack.SeedAsync(new TaskItem { Title = "Done", Status = Status.Completed }); + + var dto = OkDto(await stack.Controller.Patch(seeded.Id, new PatchTaskItemDto { Title = "Done renamed" })); + + Assert.Equal("Completed", dto.Status); + Assert.Equal(["TitleChanged"], await stack.EventTypesAsync(seeded.Id)); + } + + [Fact] + public async Task Patch_ShouldAutoCompleteParent_WhenLastChildCompletesAndFlagSet() + { + using var stack = new RealStack(); + var parent = await stack.SeedAsync(new TaskItem { Title = "Parent", Status = Status.Todo }); + var child = await stack.SeedAsync(new TaskItem { Title = "Child", Status = Status.Todo, ParentTaskItemId = parent.Id }); + + OkDto(await stack.Controller.Patch(child.Id, new PatchTaskItemDto + { + Status = Status.Completed, + AutoCompleteParentWhenChildrenDone = true + })); + + Assert.Equal(Status.Completed, (await stack.ReloadAsync(child.Id)).Status); + Assert.Equal(Status.Completed, (await stack.ReloadAsync(parent.Id)).Status); + Assert.Contains("Completed", await stack.EventTypesAsync(parent.Id)); + } + + [Fact] + public async Task Patch_ShouldNotAutoCompleteParent_WhenFlagNotSet() + { + using var stack = new RealStack(); + var parent = await stack.SeedAsync(new TaskItem { Title = "Parent", Status = Status.Todo }); + var child = await stack.SeedAsync(new TaskItem { Title = "Child", Status = Status.Todo, ParentTaskItemId = parent.Id }); + + OkDto(await stack.Controller.Patch(child.Id, new PatchTaskItemDto { Status = Status.Completed })); + + Assert.Equal(Status.Todo, (await stack.ReloadAsync(parent.Id)).Status); + } + + [Fact] + public async Task Patch_ShouldNotAutoCompleteParent_WhenAnotherChildIsStillOpen() + { + using var stack = new RealStack(); + var parent = await stack.SeedAsync(new TaskItem { Title = "Parent", Status = Status.Todo }); + var child = await stack.SeedAsync(new TaskItem { Title = "Child", Status = Status.Todo, ParentTaskItemId = parent.Id }); + await stack.SeedAsync(new TaskItem { Title = "Other child", Status = Status.Draft, ParentTaskItemId = parent.Id }); + + OkDto(await stack.Controller.Patch(child.Id, new PatchTaskItemDto + { + Status = Status.Completed, + AutoCompleteParentWhenChildrenDone = true + })); + + Assert.Equal(Status.Todo, (await stack.ReloadAsync(parent.Id)).Status); + } + + [Fact] + public async Task Put_ThenPatchReopen_ShouldReadAsOpenEverywhere() + { + using var stack = new RealStack(); + var seeded = await stack.SeedAsync(NewRichTask()); + + OkDto(await stack.Controller.Update(seeded.Id, new UpdateTaskItemDto + { + Title = "Original", + Description = "Keep me", + DueDate = seeded.DueDate, + Status = Status.Completed + })); + stack.Context.ChangeTracker.Clear(); + var reopened = OkDto(await stack.Controller.Patch(seeded.Id, new PatchTaskItemDto { Status = Status.Todo })); + stack.Context.ChangeTracker.Clear(); + + Assert.Equal("Todo", reopened.Status); + var get = Assert.IsType((await stack.Controller.Get(seeded.Id)).Result); + Assert.Equal("Todo", Assert.IsType(get.Value).Status); + var all = Assert.IsType((await stack.Controller.GetAll()).Result); + Assert.Equal("Todo", Assert.IsAssignableFrom>(all.Value).Single().Status); + } + + [Fact] + public async Task Put_ThenPatchReopen_ShouldBeIncludedInCarryForward() + { + using var stack = new RealStack(); + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var tomorrow = today.AddDays(1); + var journalRepo = new JournalEntryRepository(stack.Context); + var source = await journalRepo.AddAsync(new JournalEntry { Title = "Today", Date = today }); + var seeded = await stack.SeedAsync(NewRichTask()); + stack.Context.JournalEntries.Single(e => e.Id == source.Id).Todos.Add(stack.Context.TaskItems.Single(t => t.Id == seeded.Id)); + stack.Context.TaskItems.Single(t => t.Id == seeded.Id).CurrentJournalEntryId = source.Id; + await stack.Context.SaveChangesAsync(); + stack.Context.ChangeTracker.Clear(); + + OkDto(await stack.Controller.Update(seeded.Id, new UpdateTaskItemDto { Title = "Original", Status = Status.Completed })); + stack.Context.ChangeTracker.Clear(); + OkDto(await stack.Controller.Patch(seeded.Id, new PatchTaskItemDto { Status = Status.Todo, TimezoneOffsetMinutes = 0 })); + stack.Context.ChangeTracker.Clear(); + + var target = await journalRepo.AddAsync(new JournalEntry { Title = "Tomorrow", Date = tomorrow }); + var moved = await journalRepo.CarryForwardIncompleteTasksAsync(target.Id, tomorrow); + + Assert.Equal([seeded.Id], moved); + } + + [Fact] + public async Task Put_ShouldKeepStoredStatus_WhenStatusOmitted() + { + using var stack = new RealStack(); + var seeded = await stack.SeedAsync(new TaskItem { Title = "T", Status = Status.Todo }); + + var dto = OkDto(await stack.Controller.Update(seeded.Id, new UpdateTaskItemDto { Title = "T2" })); + + Assert.Equal("Todo", dto.Status); + } + + [Fact] + public async Task Put_ShouldStillClearDescriptionAndDueDate_WhenSentNull() + { + using var stack = new RealStack(); + var seeded = await stack.SeedAsync(NewRichTask()); + + var dto = OkDto(await stack.Controller.Update(seeded.Id, new UpdateTaskItemDto { Title = "Original", Description = null, DueDate = null })); + + Assert.Null(dto.Description); + Assert.Null(dto.DueDate); + Assert.Equal("High", dto.Priority); + } + + // ---- Real-stack failure paths ---- + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Patch_ShouldReturnBadRequest_WhenTitleIsNullEmptyOrWhitespace(string? title) + { + using var stack = new RealStack(); + var seeded = await stack.SeedAsync(NewRichTask()); + + var result = await stack.Controller.Patch(seeded.Id, new PatchTaskItemDto { Title = title }); + + Assert.IsType(result.Result); + stack.Context.ChangeTracker.Clear(); + Assert.Equal("Original", (await stack.ReloadAsync(seeded.Id)).Title); + } + + [Fact] + public async Task Patch_ShouldReturnNotFound_WhenTaskDoesNotExist() + { + using var stack = new RealStack(); + + var result = await stack.Controller.Patch(999, new PatchTaskItemDto { Title = "x" }); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task Patch_ShouldReturnUnprocessable_WhenCompletingParentWithOpenChildren() + { + using var stack = new RealStack(); + var parent = await stack.SeedAsync(new TaskItem { Title = "Parent", Status = Status.Todo }); + await stack.SeedAsync(new TaskItem { Title = "Child", Status = Status.Todo, ParentTaskItemId = parent.Id }); + + var result = await stack.Controller.Patch(parent.Id, new PatchTaskItemDto { Status = Status.Completed }); + + Assert.Equal("TASK_PARENT_COMPLETE_BLOCKED_BY_CHILDREN", ErrorCode(result)); + } + + // ---- Mocked guard paths ---- + + [Fact] + public async Task Patch_ShouldReturnUnprocessable_WhenReopeningTaskAssignedToPastDay() + { + _mockRepo.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(new TaskItem { Id = 1, Title = "T", Status = Status.Completed }); + _mockRepo.Setup(r => r.GetAssignedJournalDateAsync(1)).ReturnsAsync(DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-1)); + + var result = await _mockedController.Patch(1, new PatchTaskItemDto { Status = Status.Todo, TimezoneOffsetMinutes = 0 }); + + Assert.Equal("TASK_REOPEN_PAST_DAY_NOT_ALLOWED", ErrorCode(result)); + _mockRepo.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Patch_ShouldUseRequestTimezoneOffset_ForReopenGuard() + { + const int offset = 1440; + var utcNow = DateTime.UtcNow; + _mockRepo.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(new TaskItem { Id = 1, Title = "T", Status = Status.Completed }); + _mockRepo.Setup(r => r.GetAssignedJournalDateAsync(1)).ReturnsAsync(UserDateHelper.GetUserToday(utcNow, offset).AddDays(-1)); + + var result = await _mockedController.Patch(1, new PatchTaskItemDto { Status = Status.Todo, TimezoneOffsetMinutes = offset }); + + Assert.Equal("TASK_REOPEN_PAST_DAY_NOT_ALLOWED", ErrorCode(result)); + } + + [Fact] + public async Task Patch_ShouldAllowReopening_WhenAssignedToToday() + { + var existing = new TaskItem { Id = 1, Title = "T", Status = Status.Completed }; + _mockRepo.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(existing); + _mockRepo.Setup(r => r.GetAssignedJournalDateAsync(1)).ReturnsAsync(DateOnly.FromDateTime(DateTime.UtcNow)); + + var result = await _mockedController.Patch(1, new PatchTaskItemDto { Status = Status.Todo, TimezoneOffsetMinutes = 0 }); + + Assert.Equal("Todo", OkDto(result).Status); + _mockRepo.Verify(r => r.UpdateAsync(existing), Times.Once); + } + + [Fact] + public async Task Patch_ShouldReturnUnprocessable_WhenParentIsSelf() + { + _mockRepo.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(new TaskItem { Id = 1, Title = "T" }); + + var result = await _mockedController.Patch(1, new PatchTaskItemDto { ParentTaskItemId = 1 }); + + Assert.Equal("TASK_PARENT_SELF_NOT_ALLOWED", ErrorCode(result)); + } + + [Fact] + public async Task Patch_ShouldReturnUnprocessable_WhenParentNotFound() + { + _mockRepo.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(new TaskItem { Id = 1, Title = "T" }); + _mockRepo.Setup(r => r.GetByIdAsync(9)).ReturnsAsync((TaskItem?)null); + + var result = await _mockedController.Patch(1, new PatchTaskItemDto { ParentTaskItemId = 9 }); + + Assert.Equal("TASK_PARENT_NOT_FOUND", ErrorCode(result)); + } + + [Fact] + public async Task Patch_ShouldReturnUnprocessable_WhenParentWouldExceedDepth() + { + _mockRepo.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(new TaskItem { Id = 1, Title = "T" }); + _mockRepo.Setup(r => r.GetByIdAsync(9)).ReturnsAsync(new TaskItem { Id = 9, Title = "Child", ParentTaskItemId = 4 }); + + var result = await _mockedController.Patch(1, new PatchTaskItemDto { ParentTaskItemId = 9 }); + + Assert.Equal("TASK_PARENT_DEPTH_NOT_ALLOWED", ErrorCode(result)); + } + + [Fact] + public async Task Patch_ShouldReturnUnprocessable_WhenTaskWithChildrenBecomesChild() + { + var root = new TaskItem { Id = 1, Title = "Root" }; + var candidate = new TaskItem { Id = 9, Title = "Candidate" }; + var child = new TaskItem { Id = 2, Title = "Child", ParentTaskItemId = 1 }; + _mockRepo.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(root); + _mockRepo.Setup(r => r.GetByIdAsync(9)).ReturnsAsync(candidate); + _mockRepo.Setup(r => r.GetAllAsync()).ReturnsAsync([root, candidate, child]); + + var result = await _mockedController.Patch(1, new PatchTaskItemDto { ParentTaskItemId = 9 }); + + Assert.Equal("TASK_PARENT_DEPTH_NOT_ALLOWED", ErrorCode(result)); + } + + [Fact] + public async Task Patch_ShouldReturnDepthError_WhenProposedParentIsOwnChild() + { + // The cycle guard (TASK_PARENT_CYCLE_NOT_ALLOWED) is unreachable in a one-level hierarchy: the walk starts at + // the proposed parent, which has already passed the depth guard (no parent of its own) and the self guard + // (it is not the task), so the walk ends immediately. It is kept identical to PUT, so only the depth guard + // is asserted here. + var root = new TaskItem { Id = 1, Title = "Root" }; + var child = new TaskItem { Id = 2, Title = "Child", ParentTaskItemId = 1 }; + _mockRepo.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(root); + _mockRepo.Setup(r => r.GetByIdAsync(2)).ReturnsAsync(child); + _mockRepo.Setup(r => r.GetAllAsync()).ReturnsAsync([root, child]); + + var result = await _mockedController.Patch(1, new PatchTaskItemDto { ParentTaskItemId = 2 }); + + Assert.Equal("TASK_PARENT_DEPTH_NOT_ALLOWED", ErrorCode(result)); + _mockRepo.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Patch_ShouldNotRunParentGuards_WhenParentOmittedOrNull() + { + var existing = new TaskItem { Id = 1, Title = "T", ParentTaskItemId = 5 }; + _mockRepo.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(existing); + + OkDto(await _mockedController.Patch(1, new PatchTaskItemDto { Title = "x" })); + Assert.Equal(5, existing.ParentTaskItemId); + + OkDto(await _mockedController.Patch(1, new PatchTaskItemDto { ParentTaskItemId = null })); + Assert.Null(existing.ParentTaskItemId); + _mockRepo.Verify(r => r.GetByIdAsync(5), Times.Never); + } + + [Fact] + public async Task Patch_ShouldReturnBadRequestWithValidatorErrors_WhenValidationFails() + { + var failures = new List { new("Title", "Title is required.") }; + _mockRepo.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(new TaskItem { Id = 1, Title = "T" }); + _mockValidator.Setup(v => v.ValidateAsync(It.IsAny(), default)).ReturnsAsync(new ValidationResult(failures)); + + var result = await _mockedController.Patch(1, new PatchTaskItemDto { Title = "" }); + + var bad = Assert.IsType(result.Result); + Assert.Equal(failures, bad.Value); + _mockRepo.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + } + + // ---- Status-only contract ---- + + [Theory] + [InlineData(Status.Todo)] + [InlineData(Status.Draft)] + public async Task Update_ShouldReturnRequestedStatus(Status status) + { + // Status is the only completion input on UpdateTaskItemDto. The stale-isComplete + // behaviour is covered end to end in TaskItemsControllerLegacyIsCompleteTests. + var existing = new TaskItem { Id = 1, Title = "T", Status = Status.Draft }; + _mockRepo.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(existing); + + var result = await _mockedController.Update(1, new UpdateTaskItemDto { Title = "T", Status = status }); + + Assert.Equal(status.ToString(), OkDto(result).Status); + } + + [Fact] + public async Task Create_ShouldPersistRequestedStatus() + { + TaskItem? captured = null; + _mockRepo.Setup(r => r.AddAsync(It.IsAny())) + .Callback(t => captured = t) + .ReturnsAsync((TaskItem t) => { t.Id = 1; return t; }); + _mockRepo.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(() => captured); + + var result = await _mockedController.Create(new CreateTaskItemDto { Title = "T", Status = Status.Completed }); + + var created = Assert.IsType(result.Result); + Assert.Equal("Completed", Assert.IsType(created.Value).Status); + Assert.Equal(Status.Completed, captured!.Status); + } + + [Fact] + public void Contracts_ShouldNotExposeIsComplete() + { + Assert.Null(typeof(TaskItem).GetProperty("IsComplete")); + Assert.Null(typeof(TaskItemResponseDto).GetProperty("IsComplete")); + Assert.Null(typeof(CreateTaskItemDto).GetProperty("IsComplete")); + Assert.Null(typeof(UpdateTaskItemDto).GetProperty("IsComplete")); + Assert.Null(typeof(PatchTaskItemDto).GetProperty("IsComplete")); + } +} diff --git a/TaskFlow.Api.Tests/Controllers/V1/TaskItemsControllerV1Tests.cs b/TaskFlow.Api.Tests/Controllers/V1/TaskItemsControllerV1Tests.cs index dde4ae35..93fb2a8d 100644 --- a/TaskFlow.Api.Tests/Controllers/V1/TaskItemsControllerV1Tests.cs +++ b/TaskFlow.Api.Tests/Controllers/V1/TaskItemsControllerV1Tests.cs @@ -40,8 +40,8 @@ public async Task GetAll_ShouldReturnOkWithAllTasks() var dueDate = new DateTime(2025, 12, 31, 0, 0, 0, DateTimeKind.Utc); var tasks = new List { - new() { Id = 1, Title = "Task 1", Description = "Description 1", IsComplete = false, Status = Status.Todo, Priority = Priority.Low, DueDate = dueDate }, - new() { Id = 2, Title = "Task 2", Description = "Description 2", IsComplete = true, Status = Status.Completed, Priority = Priority.High } + new() { Id = 1, Title = "Task 1", Description = "Description 1", Status = Status.Todo, Priority = Priority.Low, DueDate = dueDate }, + new() { Id = 2, Title = "Task 2", Description = "Description 2", Status = Status.Completed, Priority = Priority.High } }; _mockRepo.Setup(r => r.GetAllAsync()).ReturnsAsync(tasks); @@ -67,7 +67,6 @@ public async Task Get_ShouldReturnOkWithTask_WhenTaskExists() Id = 1, Title = "Task 1", Description = "Description", - IsComplete = false, Status = Status.Todo, Priority = Priority.Medium, DueDate = dueDate @@ -83,7 +82,6 @@ public async Task Get_ShouldReturnOkWithTask_WhenTaskExists() Assert.Equal(1, dto.Id); Assert.Equal("Task 1", dto.Title); Assert.Equal("Description", dto.Description); - Assert.False(dto.IsComplete); Assert.Equal("Todo", dto.Status); Assert.Equal("Medium", dto.Priority); Assert.Equal(dueDate, dto.DueDate); @@ -113,7 +111,6 @@ public async Task Create_ShouldReturnCreatedAtRoute_WhenValid() { Title = "New Task", Description = "New Description", - IsComplete = false, Status = Status.Todo, Priority = Priority.High, DueDate = dueDate @@ -123,7 +120,6 @@ public async Task Create_ShouldReturnCreatedAtRoute_WhenValid() Id = 1, Title = createDto.Title, Description = createDto.Description, - IsComplete = createDto.IsComplete, Status = createDto.Status, Priority = createDto.Priority, DueDate = createDto.DueDate @@ -149,7 +145,6 @@ public async Task Create_ShouldReturnCreatedAtRoute_WhenValid() Assert.Equal(1, responseDto.Id); Assert.Equal("New Task", responseDto.Title); Assert.Equal("New Description", responseDto.Description); - Assert.False(responseDto.IsComplete); Assert.Equal("Todo", responseDto.Status); Assert.Equal("High", responseDto.Priority); Assert.Equal(dueDate, responseDto.DueDate); @@ -513,7 +508,6 @@ public async Task Update_ShouldReturnOkWithUpdatedTask_WhenValid() { Title = "Updated Task", Description = "Updated Description", - IsComplete = true, Status = Status.Completed, Priority = Priority.Medium, DueDate = dueDate @@ -523,7 +517,6 @@ public async Task Update_ShouldReturnOkWithUpdatedTask_WhenValid() Id = 1, Title = "Old Task", Description = "Old", - IsComplete = false, Status = Status.Completed, Priority = Priority.Low }; @@ -542,7 +535,6 @@ public async Task Update_ShouldReturnOkWithUpdatedTask_WhenValid() Assert.Equal(1, responseDto.Id); Assert.Equal("Updated Task", responseDto.Title); Assert.Equal("Updated Description", responseDto.Description); - Assert.True(responseDto.IsComplete); Assert.Equal("Completed", responseDto.Status); Assert.Equal("Medium", responseDto.Priority); Assert.Equal(dueDate, responseDto.DueDate); @@ -553,7 +545,7 @@ public async Task Update_ShouldReturnOkWithUpdatedTask_WhenValid() public async Task Delete_ShouldReturnNoContent_WhenTaskExists() { // Arrange - var task = new TaskItem { Id = 1, Title = "Task 1", Description = "Description", IsComplete = false, Status = Status.Todo }; + var task = new TaskItem { Id = 1, Title = "Task 1", Description = "Description", Status = Status.Todo }; _mockRepo.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(task); _mockRepo.Setup(r => r.DeleteAsync(1)).Returns(Task.CompletedTask); @@ -599,7 +591,7 @@ public async Task GetAll_ShouldReturnOkWithEmptyList_WhenNoTasks() public async Task Create_ShouldReturnBadRequest_WhenValidationFails() { // Arrange - var createDto = new CreateTaskItemDto { Title = "", Description = "Description", IsComplete = false }; + var createDto = new CreateTaskItemDto { Title = "", Description = "Description" }; var validationFailures = new List { new("Title", "Title is required.") @@ -620,8 +612,8 @@ public async Task Create_ShouldReturnBadRequest_WhenValidationFails() public async Task Create_ShouldHandleTaskWithNullDescription() { // Arrange - var createDto = new CreateTaskItemDto { Title = "Task", Description = null, IsComplete = false }; - var createdTask = new TaskItem { Id = 1, Title = "Task", Description = null, IsComplete = false }; + var createDto = new CreateTaskItemDto { Title = "Task", Description = null }; + var createdTask = new TaskItem { Id = 1, Title = "Task", Description = null }; _mockValidator.Setup(v => v.ValidateAsync(It.IsAny(), default)) .ReturnsAsync(new ValidationResult()); _mockRepo.Setup(r => r.AddAsync(It.IsAny())).ReturnsAsync(createdTask); @@ -634,7 +626,6 @@ public async Task Create_ShouldHandleTaskWithNullDescription() var dto = Assert.IsType(createdResult.Value); Assert.Equal("Task", dto.Title); Assert.Null(dto.Description); - Assert.False(dto.IsComplete); Assert.Equal("Draft", dto.Status); } @@ -642,7 +633,7 @@ public async Task Create_ShouldHandleTaskWithNullDescription() public async Task Update_ShouldReturnNotFound_WhenTaskDoesNotExist() { // Arrange - var updateDto = new UpdateTaskItemDto { Title = "Updated Task", Description = "Updated Description", IsComplete = true }; + var updateDto = new UpdateTaskItemDto { Title = "Updated Task", Description = "Updated Description", Status = Status.Completed }; _mockRepo.Setup(r => r.GetByIdAsync(999)).ReturnsAsync((TaskItem?)null); // Act @@ -658,8 +649,8 @@ public async Task Update_ShouldReturnNotFound_WhenTaskDoesNotExist() public async Task Update_ShouldReturnBadRequest_WhenValidationFails() { // Arrange - var updateDto = new UpdateTaskItemDto { Title = "", Description = "Description", IsComplete = true }; - var existingTask = new TaskItem { Id = 1, Title = "Old Task", Description = "Old Description", IsComplete = false }; + var updateDto = new UpdateTaskItemDto { Title = "", Description = "Description", Status = Status.Completed }; + var existingTask = new TaskItem { Id = 1, Title = "Old Task", Description = "Old Description", Status = Status.Todo }; var validationFailures = new List { new("Title", "Title is required.") @@ -685,7 +676,6 @@ public async Task Update_ShouldPreserveExistingPriority_WhenPriorityOmitted() { Title = "Updated Task", Description = "Updated Description", - IsComplete = true, Status = Status.Completed, Priority = null // omitted — should preserve existing priority }; @@ -694,7 +684,6 @@ public async Task Update_ShouldPreserveExistingPriority_WhenPriorityOmitted() Id = 1, Title = "Old Task", Description = "Old", - IsComplete = false, Status = Status.Completed, Priority = Priority.High }; @@ -721,7 +710,6 @@ public async Task Update_ShouldReturnUnprocessableEntity_WhenReopeningTaskAssign { Title = "Task", Description = "Desc", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low }; @@ -730,7 +718,6 @@ public async Task Update_ShouldReturnUnprocessableEntity_WhenReopeningTaskAssign Id = 1, Title = "Task", Description = "Desc", - IsComplete = true, Status = Status.Completed, Priority = Priority.Low }; @@ -756,7 +743,6 @@ public async Task Update_ShouldAllowReopeningTaskAssignedToToday() { Title = "Task", Description = "Desc", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low }; @@ -765,7 +751,6 @@ public async Task Update_ShouldAllowReopeningTaskAssignedToToday() Id = 1, Title = "Task", Description = "Desc", - IsComplete = true, Status = Status.Completed, Priority = Priority.Low }; @@ -794,7 +779,6 @@ public async Task Update_ShouldAllowReopeningTaskAssignedToUserLocalToday_WhenOf { Title = "Task", Description = "Desc", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low, TimezoneOffsetMinutes = offset @@ -804,7 +788,6 @@ public async Task Update_ShouldAllowReopeningTaskAssignedToUserLocalToday_WhenOf Id = 1, Title = "Task", Description = "Desc", - IsComplete = true, Status = Status.Completed, Priority = Priority.Low }; @@ -832,7 +815,6 @@ public async Task Update_ShouldReturnUnprocessableEntity_WhenReopeningTaskAssign { Title = "Task", Description = "Desc", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low, TimezoneOffsetMinutes = offset @@ -842,7 +824,6 @@ public async Task Update_ShouldReturnUnprocessableEntity_WhenReopeningTaskAssign Id = 1, Title = "Task", Description = "Desc", - IsComplete = true, Status = Status.Completed, Priority = Priority.Low }; @@ -873,7 +854,6 @@ public async Task Update_ShouldReturnUnprocessableEntity_WhenAssignedDateIsUtcTo { Title = "Task", Description = "Desc", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low, TimezoneOffsetMinutes = offset @@ -883,7 +863,6 @@ public async Task Update_ShouldReturnUnprocessableEntity_WhenAssignedDateIsUtcTo Id = 1, Title = "Task", Description = "Desc", - IsComplete = true, Status = Status.Completed, Priority = Priority.Low }; @@ -907,7 +886,6 @@ public async Task Update_ShouldUseUtcToday_WhenOffsetMissing_AndAllowReopeningTa { Title = "Task", Description = "Desc", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low, TimezoneOffsetMinutes = null @@ -917,7 +895,6 @@ public async Task Update_ShouldUseUtcToday_WhenOffsetMissing_AndAllowReopeningTa Id = 1, Title = "Task", Description = "Desc", - IsComplete = true, Status = Status.Completed, Priority = Priority.Low }; @@ -942,7 +919,6 @@ public async Task Update_ShouldUseUtcToday_WhenOffsetMissing_AndRejectReopeningT { Title = "Task", Description = "Desc", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low, TimezoneOffsetMinutes = null @@ -952,7 +928,6 @@ public async Task Update_ShouldUseUtcToday_WhenOffsetMissing_AndRejectReopeningT Id = 1, Title = "Task", Description = "Desc", - IsComplete = true, Status = Status.Completed, Priority = Priority.Low }; @@ -976,7 +951,6 @@ public async Task Update_ShouldReturnUnprocessableEntity_WhenParentTaskMatchesSe { Title = "Task", Description = "Desc", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low, ParentTaskItemId = 1 @@ -987,7 +961,6 @@ public async Task Update_ShouldReturnUnprocessableEntity_WhenParentTaskMatchesSe Id = 1, Title = "Task", Description = "Desc", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low }; @@ -1007,7 +980,6 @@ public async Task Update_ShouldReturnUnprocessableEntity_WhenParentTaskDoesNotEx { Title = "Task", Description = "Desc", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low, ParentTaskItemId = 12 @@ -1018,7 +990,6 @@ public async Task Update_ShouldReturnUnprocessableEntity_WhenParentTaskDoesNotEx Id = 1, Title = "Task", Description = "Desc", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low }; @@ -1039,7 +1010,6 @@ public async Task Update_ShouldReturnUnprocessableEntity_WhenParentWouldExceedOn { Title = "Task", Description = "Desc", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low, ParentTaskItemId = 12 @@ -1050,7 +1020,6 @@ public async Task Update_ShouldReturnUnprocessableEntity_WhenParentWouldExceedOn Id = 1, Title = "Task", Description = "Desc", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low }; @@ -1076,7 +1045,6 @@ public async Task Update_ShouldReturnUnprocessableEntity_WhenTaskWithChildrenIsA { Title = "Existing Parent", Description = "Desc", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low, ParentTaskItemId = 12 @@ -1087,7 +1055,6 @@ public async Task Update_ShouldReturnUnprocessableEntity_WhenTaskWithChildrenIsA Id = 1, Title = "Existing Parent", Description = "Desc", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low }; @@ -1096,7 +1063,6 @@ public async Task Update_ShouldReturnUnprocessableEntity_WhenTaskWithChildrenIsA { Id = 12, Title = "New Parent", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low }; @@ -1105,7 +1071,6 @@ public async Task Update_ShouldReturnUnprocessableEntity_WhenTaskWithChildrenIsA { Id = 2, Title = "Child", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low, ParentTaskItemId = 1 @@ -1128,7 +1093,6 @@ public async Task Update_ShouldReturnUnprocessableEntity_WhenCompletingParentWit { Title = "Parent", Description = "Desc", - IsComplete = true, Status = Status.Completed, Priority = Priority.Low, ParentTaskItemId = null @@ -1139,7 +1103,6 @@ public async Task Update_ShouldReturnUnprocessableEntity_WhenCompletingParentWit Id = 1, Title = "Parent", Description = "Desc", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low }; @@ -1149,7 +1112,6 @@ public async Task Update_ShouldReturnUnprocessableEntity_WhenCompletingParentWit Id = 2, Title = "Child", Description = "Desc", - IsComplete = false, Status = Status.Todo, ParentTaskItemId = 1 }; @@ -1170,7 +1132,6 @@ public async Task Update_ShouldReturnUnprocessableEntity_WhenParentAssignmentWou { Title = "Root", Description = "Desc", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low, ParentTaskItemId = 3 @@ -1246,7 +1207,6 @@ public async Task Update_ShouldAutoCompleteParent_WhenEnabledAndLastChildComplet { Title = "Child", Description = "Desc", - IsComplete = true, Status = Status.Completed, Priority = Priority.Low, ParentTaskItemId = 10, @@ -1258,7 +1218,6 @@ public async Task Update_ShouldAutoCompleteParent_WhenEnabledAndLastChildComplet Id = 2, Title = "Child", Description = "Desc", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low, ParentTaskItemId = 10 @@ -1268,7 +1227,6 @@ public async Task Update_ShouldAutoCompleteParent_WhenEnabledAndLastChildComplet Id = 10, Title = "Parent", Description = "Desc", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low }; @@ -1277,7 +1235,7 @@ public async Task Update_ShouldAutoCompleteParent_WhenEnabledAndLastChildComplet _mockRepo.Setup(r => r.GetByIdAsync(10)).ReturnsAsync(parent); _mockRepo.Setup(r => r.GetAllAsync()).ReturnsAsync([ parent, - new TaskItem { Id = 2, Title = "Child", IsComplete = true, Status = Status.Completed, ParentTaskItemId = 10 } + new TaskItem { Id = 2, Title = "Child", Status = Status.Completed, ParentTaskItemId = 10 } ]); _mockValidator.Setup(v => v.ValidateAsync(It.IsAny(), default)) .ReturnsAsync(new ValidationResult()); @@ -1287,7 +1245,7 @@ public async Task Update_ShouldAutoCompleteParent_WhenEnabledAndLastChildComplet Assert.IsType(result.Result); _mockRepo.Verify(r => r.UpdateAsync(It.Is(t => t.Id == 2)), Times.Once); - _mockRepo.Verify(r => r.UpdateAsync(It.Is(t => t.Id == 10 && t.IsComplete && t.Status == Status.Completed)), Times.Once); + _mockRepo.Verify(r => r.UpdateAsync(It.Is(t => t.Id == 10 && t.Status == Status.Completed)), Times.Once); } [Fact] @@ -1361,7 +1319,6 @@ public async Task Update_ReturnsCorrectChildTaskCount() var updateDto = new UpdateTaskItemDto { Title = "Updated Parent", - IsComplete = false, Status = Status.Todo, Priority = Priority.Low }; diff --git a/TaskFlow.Api.Tests/Migrations/RemoveTaskItemIsCompleteMigrationTests.cs b/TaskFlow.Api.Tests/Migrations/RemoveTaskItemIsCompleteMigrationTests.cs new file mode 100644 index 00000000..5e1610ac --- /dev/null +++ b/TaskFlow.Api.Tests/Migrations/RemoveTaskItemIsCompleteMigrationTests.cs @@ -0,0 +1,218 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using TaskFlow.Api.Data; +using TaskFlow.Api.Models; + +namespace TaskFlow.Api.Tests.Migrations; + +/// +/// Runs the real migration chain against real in-memory SQLite (not EnsureCreated) to prove the data +/// normalization, the synthetic history events and the SQLite column drop (table rebuild). +/// +public class RemoveTaskItemIsCompleteMigrationTests : IDisposable +{ + private const string PreviousMigration = "20261009173936_AddProjects"; + private const string TargetMigration = "20261022141248_RemoveTaskItemIsComplete"; + + private const int Draft = 0; + private const int Todo = 1; + private const int Completed = 2; + + private readonly SqliteConnection _connection; + private readonly TaskDbContext _context; + + public RemoveTaskItemIsCompleteMigrationTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _context = new TaskDbContext(new DbContextOptionsBuilder().UseSqlite(_connection).Options); + } + + public void Dispose() + { + _context.Dispose(); + _connection.Dispose(); + } + + private void MigrateTo(string migration) => _context.GetService().Migrate(migration); + + private void Exec(string sql) + { + using var command = _connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + private T Scalar(string sql) + { + using var command = _connection.CreateCommand(); + command.CommandText = sql; + return (T)Convert.ChangeType(command.ExecuteScalar()!, typeof(T)); + } + + private List Strings(string sql) + { + using var command = _connection.CreateCommand(); + command.CommandText = sql; + using var reader = command.ExecuteReader(); + var values = new List(); + while (reader.Read()) + { + values.Add(reader.GetString(0)); + } + + return values; + } + + private bool HasIsCompleteColumn() => + Scalar("SELECT COUNT(*) FROM pragma_table_info('TaskItems') WHERE name = 'IsComplete'") > 0; + + private int StatusOf(int id) => Scalar($"SELECT \"Status\" FROM \"TaskItems\" WHERE \"Id\" = {id}"); + + private void SeedAtPreviousMigration() + { + Exec("INSERT INTO \"JournalEntries\" (\"Id\", \"Title\", \"Date\", \"CreatedAt\") VALUES (1, 'J', '2026-10-01', '2026-10-01 00:00:00')"); + + // Id, IsComplete, Status: A flag+Todo, B flag+Draft, C flag+Completed, D Completed only, E open, F draft. + var rows = new (int Id, int Flag, int Status)[] + { + (1, 1, Todo), (2, 1, Draft), (3, 1, Completed), (4, 0, Completed), (5, 0, Todo), (6, 0, Draft) + }; + foreach (var (id, flag, status) in rows) + { + Exec($"INSERT INTO \"TaskItems\" (\"Id\", \"Title\", \"Description\", \"IsComplete\", \"Status\", \"Priority\", \"MoveCount\") " + + $"VALUES ({id}, 'Task {id}', 'Desc {id}', {flag}, {status}, 1, 0)"); + } + + // Self-FK, journal FK and join table rows that must survive the table rebuild. + Exec("UPDATE \"TaskItems\" SET \"ParentTaskItemId\" = 5 WHERE \"Id\" = 1"); + Exec("UPDATE \"TaskItems\" SET \"CurrentJournalEntryId\" = 1 WHERE \"Id\" = 1"); + Exec("INSERT INTO \"JournalEntryTaskItem\" (\"JournalEntryId\", \"TodosId\") VALUES (1, 1)"); + Exec("INSERT INTO \"TaskItemEvents\" (\"TaskItemId\", \"EventType\", \"OccurredAtUtc\", \"ChangeSummary\") " + + "VALUES (3, 'TaskCreated', '2026-01-01 00:00:00.000', 'seeded')"); + + // Other tables referencing TaskItems: Notes (cascade) and JournalLogEntries (set null). + Exec("INSERT INTO \"Notes\" (\"Id\", \"Content\", \"CreatedAt\", \"TaskItemId\") VALUES (1, 'Note', '2026-01-01 00:00:00', 1)"); + Exec("INSERT INTO \"JournalLogEntries\" (\"Id\", \"Content\", \"CreatedAt\", \"JournalEntryId\", \"TaskItemId\") " + + "VALUES (1, 'Log', '2026-01-01 00:00:00', 1, 1)"); + } + + [Fact] + public void Migration_ShouldNormalizeStatusDropColumnAndWriteSyntheticEvents() + { + MigrateTo(PreviousMigration); + Assert.True(HasIsCompleteColumn()); + SeedAtPreviousMigration(); + + var before = DateTime.UtcNow.AddSeconds(-2); + MigrateTo(TargetMigration); + var after = DateTime.UtcNow.AddSeconds(2); + + // Status results. + Assert.Equal(Completed, StatusOf(1)); + Assert.Equal(Completed, StatusOf(2)); + Assert.Equal(Completed, StatusOf(3)); + Assert.Equal(Completed, StatusOf(4)); + Assert.Equal(Todo, StatusOf(5)); + Assert.Equal(Draft, StatusOf(6)); + Assert.False(HasIsCompleteColumn()); + + // Synthetic events: exactly one for A and B, none for C, D, E, F beyond C's seeded event. + using var verify = new TaskDbContext(new DbContextOptionsBuilder().UseSqlite(_connection).Options); + var events = verify.TaskItemEvents.AsNoTracking().OrderBy(e => e.Id).ToList(); + Assert.Equal(3, events.Count); + + foreach (var taskId in new[] { 1, 2 }) + { + var synthetic = Assert.Single(events, e => e.TaskItemId == taskId); + Assert.Equal("Completed", synthetic.EventType); + Assert.Contains("data migration", synthetic.ChangeSummary); + Assert.Equal(DateTimeKind.Utc, synthetic.OccurredAtUtc.Kind); + Assert.InRange(synthetic.OccurredAtUtc, before, after); + Assert.Null(synthetic.FromJournalEntryId); + Assert.Null(synthetic.ToJournalEntryId); + Assert.Null(synthetic.FromJournalDate); + Assert.Null(synthetic.ToJournalDate); + } + + var seeded = Assert.Single(events, e => e.TaskItemId == 3); + Assert.Equal("TaskCreated", seeded.EventType); + Assert.DoesNotContain(events, e => e.TaskItemId is 4 or 5 or 6); + } + + [Fact] + public void Migration_ShouldPreserveForeignKeysIndexesAndRowData_AcrossTableRebuild() + { + MigrateTo(PreviousMigration); + SeedAtPreviousMigration(); + + MigrateTo(TargetMigration); + + var foreignKeyColumns = Strings("SELECT \"from\" FROM pragma_foreign_key_list('TaskItems')"); + Assert.Contains("ParentTaskItemId", foreignKeyColumns); + Assert.Contains("CurrentJournalEntryId", foreignKeyColumns); + Assert.Contains("ProjectId", foreignKeyColumns); + + var selfFkTarget = Strings("SELECT \"table\" FROM pragma_foreign_key_list('TaskItems') WHERE \"from\" = 'ParentTaskItemId'"); + Assert.Equal(["TaskItems"], selfFkTarget); + + var indexes = Strings("SELECT name FROM pragma_index_list('TaskItems')"); + Assert.Contains("IX_TaskItems_ParentTaskItemId", indexes); + Assert.Contains("IX_TaskItems_CurrentJournalEntryId", indexes); + Assert.Contains("IX_TaskItems_ProjectId", indexes); + + Assert.Equal(5, Scalar("SELECT \"ParentTaskItemId\" FROM \"TaskItems\" WHERE \"Id\" = 1")); + Assert.Equal(1, Scalar("SELECT \"CurrentJournalEntryId\" FROM \"TaskItems\" WHERE \"Id\" = 1")); + Assert.Equal("Desc 1", Scalar("SELECT \"Description\" FROM \"TaskItems\" WHERE \"Id\" = 1")); + Assert.Equal(1L, Scalar("SELECT COUNT(*) FROM \"JournalEntryTaskItem\"")); + Assert.Equal(6L, Scalar("SELECT COUNT(*) FROM \"TaskItems\"")); + Assert.Equal(1L, Scalar("SELECT COUNT(*) FROM \"Notes\" WHERE \"TaskItemId\" = 1")); + Assert.Equal(1L, Scalar("SELECT COUNT(*) FROM \"JournalLogEntries\" WHERE \"TaskItemId\" = 1")); + Assert.Equal(0L, Scalar("SELECT COUNT(*) FROM pragma_foreign_key_check()")); + } + + [Fact] + public void Down_ShouldRestoreColumnFromStatus_KeepSyntheticEvents_AndNotDuplicateOnReapply() + { + MigrateTo(PreviousMigration); + SeedAtPreviousMigration(); + MigrateTo(TargetMigration); + var eventCountAfterUp = Scalar("SELECT COUNT(*) FROM \"TaskItemEvents\""); + Assert.Equal(3L, eventCountAfterUp); + + MigrateTo(PreviousMigration); + + Assert.True(HasIsCompleteColumn()); + foreach (var id in new[] { 1, 2, 3, 4 }) + { + Assert.Equal(1L, Scalar($"SELECT \"IsComplete\" FROM \"TaskItems\" WHERE \"Id\" = {id}")); + } + + foreach (var id in new[] { 5, 6 }) + { + Assert.Equal(0L, Scalar($"SELECT \"IsComplete\" FROM \"TaskItems\" WHERE \"Id\" = {id}")); + } + + Assert.Equal(1L, Scalar("SELECT COUNT(*) FROM pragma_table_info('TaskItems') WHERE name = 'IsComplete' AND \"notnull\" = 1")); + Assert.Equal(eventCountAfterUp, Scalar("SELECT COUNT(*) FROM \"TaskItemEvents\"")); + + MigrateTo(TargetMigration); + + Assert.False(HasIsCompleteColumn()); + Assert.Equal(eventCountAfterUp, Scalar("SELECT COUNT(*) FROM \"TaskItemEvents\"")); + } + + [Fact] + public void Migration_ShouldBeNoOpForData_WhenNoRowsNeedNormalizing() + { + MigrateTo(PreviousMigration); + Exec("INSERT INTO \"TaskItems\" (\"Id\", \"Title\", \"IsComplete\", \"Status\", \"Priority\", \"MoveCount\") VALUES (1, 'Open', 0, 1, 1, 0)"); + + MigrateTo(TargetMigration); + + Assert.Equal(Todo, StatusOf(1)); + Assert.Equal(0L, Scalar("SELECT COUNT(*) FROM \"TaskItemEvents\"")); + } +} diff --git a/TaskFlow.Api.Tests/Providers/JsonSerializerOptionsProviderTests.cs b/TaskFlow.Api.Tests/Providers/JsonSerializerOptionsProviderTests.cs index b533957e..64067f45 100644 --- a/TaskFlow.Api.Tests/Providers/JsonSerializerOptionsProviderTests.cs +++ b/TaskFlow.Api.Tests/Providers/JsonSerializerOptionsProviderTests.cs @@ -1,4 +1,6 @@ using System.Text.Json; +using TaskFlow.Api.DTOs; +using TaskFlow.Api.Models; using TaskFlow.Api.Providers; namespace TaskFlow.Api.Tests.Providers; @@ -51,4 +53,78 @@ public void ConfigureOptions_ShouldSetCamelCaseNamingPolicy() Assert.Equal(JsonNamingPolicy.CamelCase, options.PropertyNamingPolicy); } + + private static JsonSerializerOptions CreateRequestOptions() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + JsonSerializerOptionsProvider.ConfigureOptions(options); + return options; + } + + [Theory] + [InlineData("""{"title":"x","status":"completed","isComplete":true}""", Status.Completed)] + [InlineData("""{"isComplete":true,"status":"todo"}""", Status.Todo)] + [InlineData("""{"isComplete":false,"status":"completed"}""", Status.Completed)] + public void Deserialize_ShouldIgnoreUnknownIsCompleteAndParseLowercaseStatus_ForAllWriteDtos(string json, Status expected) + { + var options = CreateRequestOptions(); + + var update = JsonSerializer.Deserialize(json, options); + var create = JsonSerializer.Deserialize(json, options); + var patch = JsonSerializer.Deserialize(json, options); + + Assert.Equal(expected, update!.Status); + Assert.Equal(expected, create!.Status); + Assert.Equal(expected, patch!.Status); + } + + [Fact] + public void PatchDto_ShouldReportNothingPresent_ForEmptyObject() + { + var patch = JsonSerializer.Deserialize("{}", CreateRequestOptions())!; + + Assert.False(patch.IsPresent("Title")); + Assert.False(patch.IsPresent("Description")); + Assert.False(patch.IsPresent("Status")); + Assert.False(patch.IsPresent("Priority")); + Assert.False(patch.IsPresent("DueDate")); + Assert.False(patch.IsPresent("ParentTaskItemId")); + } + + [Theory] + [InlineData("""{"description":null}""", "Description")] + [InlineData("""{"dueDate":null}""", "DueDate")] + [InlineData("""{"parentTaskItemId":null}""", "ParentTaskItemId")] + [InlineData("""{"title":null}""", "Title")] + [InlineData("""{"description":"d"}""", "Description")] + public void PatchDto_ShouldReportExplicitFieldsAsPresent_IncludingNull(string json, string property) + { + var patch = JsonSerializer.Deserialize(json, CreateRequestOptions())!; + + Assert.True(patch.IsPresent(property)); + Assert.True(patch.IsPresent(property.ToLowerInvariant())); + Assert.True(patch.IsPresent(property.ToUpperInvariant())); + } + + [Fact] + public void PatchDto_ShouldOnlyReportSuppliedFieldsAsPresent() + { + var patch = JsonSerializer.Deserialize("""{"title":"t"}""", CreateRequestOptions())!; + + Assert.True(patch.IsPresent("Title")); + Assert.False(patch.IsPresent("Description")); + Assert.False(patch.IsPresent("DueDate")); + } + + [Fact] + public void PatchDto_ShouldNotSerializePresenceTracking() + { + var json = JsonSerializer.Serialize(new PatchTaskItemDto { Title = "t" }, CreateRequestOptions()); + + using var document = JsonDocument.Parse(json); + var names = document.RootElement.EnumerateObject().Select(p => p.Name).Order().ToList(); + Assert.Equal( + ["autoCompleteParentWhenChildrenDone", "description", "dueDate", "parentTaskItemId", "priority", "status", "timezoneOffsetMinutes", "title"], + names); + } } diff --git a/TaskFlow.Api.Tests/Repositories/JournalEntryRepositoryTests.cs b/TaskFlow.Api.Tests/Repositories/JournalEntryRepositoryTests.cs index ff3e1779..76ed546c 100644 --- a/TaskFlow.Api.Tests/Repositories/JournalEntryRepositoryTests.cs +++ b/TaskFlow.Api.Tests/Repositories/JournalEntryRepositoryTests.cs @@ -223,7 +223,7 @@ public async Task CarryForwardIncompleteTasksAsync_ShouldLeaveCompletedTasksInPl var repo = new JournalEntryRepository(context); var today = new DateOnly(2026, 10, 13); var (source, open) = await SeedLinkedTaskAsync(context, today.AddDays(-1)); - var done = await LinkNewTaskAsync(context, source, new TaskItem { Title = "Done", Status = Status.Completed, IsComplete = true }); + var done = await LinkNewTaskAsync(context, source, new TaskItem { Title = "Done", Status = Status.Completed }); var target = await repo.AddAsync(new JournalEntry { Title = "Today", Date = today }); var moved = await repo.CarryForwardIncompleteTasksAsync(target.Id, today); @@ -521,21 +521,21 @@ public async Task CarryForwardIncompleteTasksAsync_ShouldSetCountersAndEvents_Fo } [Fact] - public async Task CarryForwardIncompleteTasksAsync_ShouldRespectStatusAndIsCompleteIndependently() + public async Task CarryForwardIncompleteTasksAsync_TreatTaskAsIncompleteExactlyWhenStatusIsNotCompleted() { using var context = CreateInMemoryContext(); var repo = new JournalEntryRepository(context); var today = new DateOnly(2026, 10, 13); var (source, openTask) = await SeedLinkedTaskAsync(context, today.AddDays(-1)); - var statusOnly = await LinkNewTaskAsync(context, source, new TaskItem { Title = "S", Status = Status.Completed, IsComplete = false }); - var flagOnly = await LinkNewTaskAsync(context, source, new TaskItem { Title = "F", Status = Status.Todo, IsComplete = true }); + var completed = await LinkNewTaskAsync(context, source, new TaskItem { Title = "C", Status = Status.Completed }); + var draft = await LinkNewTaskAsync(context, source, new TaskItem { Title = "D", Status = Status.Draft }); var target = await repo.AddAsync(new JournalEntry { Title = "Today", Date = today }); var moved = await repo.CarryForwardIncompleteTasksAsync(target.Id, today); - Assert.Equal(new[] { openTask.Id }, moved); - Assert.Equal(source.Id, statusOnly.CurrentJournalEntryId); - Assert.Equal(source.Id, flagOnly.CurrentJournalEntryId); + Assert.Equal(new[] { openTask.Id, draft.Id }, moved); + Assert.Equal(source.Id, completed.CurrentJournalEntryId); + Assert.Equal(target.Id, draft.CurrentJournalEntryId); } [Fact] @@ -606,7 +606,6 @@ public async Task CarryForwardIncompleteTasksAsync_ShouldNotPullBackTaskComplete await repo.CarryForwardIncompleteTasksAsync(target.Id, today); completedLater.Status = Status.Completed; - completedLater.IsComplete = true; await context.SaveChangesAsync(); await repo.RemoveTodoAsync(target.Id, removedLater.Id); @@ -624,9 +623,9 @@ public async Task CarryForwardIncompleteTasksAsync_ShouldMoveSubTasksIndependent var repo = new JournalEntryRepository(context); var today = new DateOnly(2026, 10, 13); var (source, _) = await SeedLinkedTaskAsync(context, today.AddDays(-1)); - var doneParent = await LinkNewTaskAsync(context, source, new TaskItem { Title = "Parent", Status = Status.Completed, IsComplete = true }); + var doneParent = await LinkNewTaskAsync(context, source, new TaskItem { Title = "Parent", Status = Status.Completed }); var openChild = await LinkNewTaskAsync(context, source, new TaskItem { Title = "Open child", Status = Status.Todo, ParentTaskItemId = doneParent.Id }); - var doneChild = await LinkNewTaskAsync(context, source, new TaskItem { Title = "Done child", Status = Status.Completed, IsComplete = true, ParentTaskItemId = doneParent.Id }); + var doneChild = await LinkNewTaskAsync(context, source, new TaskItem { Title = "Done child", Status = Status.Completed, ParentTaskItemId = doneParent.Id }); var target = await repo.AddAsync(new JournalEntry { Title = "Today", Date = today }); await repo.CarryForwardIncompleteTasksAsync(target.Id, today); @@ -668,7 +667,6 @@ public async Task CarryForwardIncompleteTasksAsync_ShouldReturnEmpty_WhenNoIncom var today = new DateOnly(2026, 10, 13); var (_, seeded) = await SeedLinkedTaskAsync(context, today.AddDays(-1)); seeded.Status = Status.Completed; - seeded.IsComplete = true; await context.SaveChangesAsync(); var target = await repo.AddAsync(new JournalEntry { Title = "Today", Date = today }); diff --git a/TaskFlow.Api.Tests/Repositories/TaskRepositoryTests.cs b/TaskFlow.Api.Tests/Repositories/TaskRepositoryTests.cs index f72e5bfa..06c248a5 100644 --- a/TaskFlow.Api.Tests/Repositories/TaskRepositoryTests.cs +++ b/TaskFlow.Api.Tests/Repositories/TaskRepositoryTests.cs @@ -24,8 +24,8 @@ public async Task GetAllAsync_ShouldReturnAllTasks() var tasks = new List { - new() { Id = 1, Title = "Task 1", Description = "Description 1", IsComplete = false, Status = Status.Todo }, - new() { Id = 2, Title = "Task 2", Description = "Description 2", IsComplete = true, Status = Status.Completed } + new() { Id = 1, Title = "Task 1", Description = "Description 1", Status = Status.Todo }, + new() { Id = 2, Title = "Task 2", Description = "Description 2", Status = Status.Completed } }; await context.TaskItems.AddRangeAsync(tasks); await context.SaveChangesAsync(); @@ -64,7 +64,6 @@ public async Task GetByIdAsync_ShouldReturnTask_WhenTaskExists() Id = 1, Title = "Task 1", Description = "Description 1", - IsComplete = false, Status = Status.Todo }; await context.TaskItems.AddAsync(task); @@ -99,7 +98,7 @@ public async Task AddAsync_ShouldAddTaskAndReturnIt() // Arrange using var context = CreateInMemoryContext(); var repository = new TaskRepository(context); - var newTask = new TaskItem { Title = "New Task", Description = "Description", IsComplete = false }; + var newTask = new TaskItem { Title = "New Task", Description = "Description" }; // Act var result = await repository.AddAsync(newTask); @@ -124,7 +123,7 @@ public async Task AddAsync_ShouldHandleTaskWithNullDescription() // Arrange using var context = CreateInMemoryContext(); var repository = new TaskRepository(context); - var newTask = new TaskItem { Title = "Task", Description = null, IsComplete = false }; + var newTask = new TaskItem { Title = "Task", Description = null }; // Act var result = await repository.AddAsync(newTask); @@ -144,18 +143,18 @@ public async Task AddAsync_ShouldHandleCompletedTask() // Arrange using var context = CreateInMemoryContext(); var repository = new TaskRepository(context); - var newTask = new TaskItem { Title = "Completed Task", Description = "Done", IsComplete = true }; + var newTask = new TaskItem { Title = "Completed Task", Description = "Done", Status = Status.Completed }; // Act var result = await repository.AddAsync(newTask); // Assert Assert.NotNull(result); - Assert.True(result.IsComplete); + Assert.Equal(Status.Completed, result.Status); var savedTask = await context.TaskItems.FindAsync(result.Id); Assert.NotNull(savedTask); - Assert.True(savedTask.IsComplete); + Assert.Equal(Status.Completed, savedTask.Status); } [Fact] @@ -164,14 +163,14 @@ public async Task UpdateAsync_ShouldUpdateExistingTask() // Arrange using var context = CreateInMemoryContext(); var repository = new TaskRepository(context); - var task = new TaskItem { Id = 1, Title = "Original", Description = "Original Description", IsComplete = false }; + var task = new TaskItem { Id = 1, Title = "Original", Description = "Original Description", Status = Status.Todo }; await context.TaskItems.AddAsync(task); await context.SaveChangesAsync(); // Modify the task task.Title = "Updated"; task.Description = "Updated Description"; - task.IsComplete = true; + task.Status = Status.Completed; // Act await repository.UpdateAsync(task); @@ -181,7 +180,7 @@ public async Task UpdateAsync_ShouldUpdateExistingTask() Assert.NotNull(updatedTask); Assert.Equal("Updated", updatedTask.Title); Assert.Equal("Updated Description", updatedTask.Description); - Assert.True(updatedTask.IsComplete); + Assert.Equal(Status.Completed, updatedTask.Status); } [Fact] @@ -190,12 +189,12 @@ public async Task UpdateAsync_ShouldHandlePartialUpdate() // Arrange using var context = CreateInMemoryContext(); var repository = new TaskRepository(context); - var task = new TaskItem { Id = 1, Title = "Original", Description = "Description", IsComplete = false }; + var task = new TaskItem { Id = 1, Title = "Original", Description = "Description", Status = Status.Todo }; await context.TaskItems.AddAsync(task); await context.SaveChangesAsync(); - // Modify only IsComplete - task.IsComplete = true; + // Modify only Status + task.Status = Status.Completed; // Act await repository.UpdateAsync(task); @@ -205,7 +204,7 @@ public async Task UpdateAsync_ShouldHandlePartialUpdate() Assert.NotNull(updatedTask); Assert.Equal("Original", updatedTask.Title); Assert.Equal("Description", updatedTask.Description); - Assert.True(updatedTask.IsComplete); + Assert.Equal(Status.Completed, updatedTask.Status); } [Fact] @@ -213,7 +212,7 @@ public async Task UpdateAsync_ShouldWriteTitleAndPriorityHistoryEvents_WhenValue { using var context = CreateInMemoryContext(); var repository = new TaskRepository(context); - var task = new TaskItem { Id = 1, Title = "Original", Description = "Description", IsComplete = false, Priority = Priority.Low, Status = Status.Todo }; + var task = new TaskItem { Id = 1, Title = "Original", Description = "Description", Priority = Priority.Low, Status = Status.Todo }; await context.TaskItems.AddAsync(task); await context.SaveChangesAsync(); context.ChangeTracker.Clear(); @@ -239,21 +238,19 @@ public async Task UpdateAsync_ShouldWriteCompletedAndReopenedEvents_WhenCompleti { using var context = CreateInMemoryContext(); var repository = new TaskRepository(context); - var task = new TaskItem { Id = 1, Title = "Task", Description = "Description", IsComplete = false, Status = Status.Todo }; + var task = new TaskItem { Id = 1, Title = "Task", Description = "Description", Status = Status.Todo }; await context.TaskItems.AddAsync(task); await context.SaveChangesAsync(); context.ChangeTracker.Clear(); var tracked = await context.TaskItems.FindAsync(1); Assert.NotNull(tracked); - tracked.IsComplete = true; tracked.Status = Status.Completed; await repository.UpdateAsync(tracked); context.ChangeTracker.Clear(); tracked = await context.TaskItems.FindAsync(1); Assert.NotNull(tracked); - tracked.IsComplete = false; tracked.Status = Status.Todo; await repository.UpdateAsync(tracked); @@ -266,6 +263,48 @@ public async Task UpdateAsync_ShouldWriteCompletedAndReopenedEvents_WhenCompleti Assert.Contains(events, e => e.EventType == "Reopened"); } + [Theory] + [InlineData(Status.Todo, Status.Completed, "Completed")] + [InlineData(Status.Draft, Status.Completed, "Completed")] + [InlineData(Status.Completed, Status.Todo, "Reopened")] + [InlineData(Status.Completed, Status.Draft, "Reopened")] + [InlineData(Status.Draft, Status.Todo, "StatusChanged")] + [InlineData(Status.Todo, Status.Draft, "StatusChanged")] + public async Task UpdateAsync_ShouldWriteStatusOnlyHistoryEvent_WhenStatusChanges(Status from, Status to, string expectedEvent) + { + using var context = CreateInMemoryContext(); + var repository = new TaskRepository(context); + await context.TaskItems.AddAsync(new TaskItem { Id = 1, Title = "Task", Status = from }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var tracked = await context.TaskItems.FindAsync(1); + Assert.NotNull(tracked); + tracked.Status = to; + await repository.UpdateAsync(tracked); + + var statusEvent = Assert.Single(await context.TaskItemEvents.Where(e => e.TaskItemId == 1).ToListAsync()); + Assert.Equal(expectedEvent, statusEvent.EventType); + } + + [Fact] + public async Task UpdateAsync_ShouldWriteNoStatusEvent_WhenStatusUnchanged() + { + using var context = CreateInMemoryContext(); + var repository = new TaskRepository(context); + await context.TaskItems.AddAsync(new TaskItem { Id = 1, Title = "Task", Status = Status.Completed }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var tracked = await context.TaskItems.FindAsync(1); + Assert.NotNull(tracked); + tracked.Title = "Renamed"; + await repository.UpdateAsync(tracked); + + var events = await context.TaskItemEvents.Where(e => e.TaskItemId == 1).ToListAsync(); + Assert.Equal("TitleChanged", Assert.Single(events).EventType); + } + [Fact] public async Task UpdateAsync_ShouldWriteDescriptionAndDueDateHistoryEvents_WhenValuesChange() { @@ -277,7 +316,6 @@ public async Task UpdateAsync_ShouldWriteDescriptionAndDueDateHistoryEvents_When Id = 1, Title = "Task", Description = "Initial description", - IsComplete = false, Status = Status.Todo, DueDate = dueDate }; @@ -307,7 +345,7 @@ public async Task DeleteAsync_ShouldRemoveTask_WhenTaskExists() // Arrange using var context = CreateInMemoryContext(); var repository = new TaskRepository(context); - var task = new TaskItem { Id = 1, Title = "Task to Delete", Description = "Description", IsComplete = false }; + var task = new TaskItem { Id = 1, Title = "Task to Delete", Description = "Description" }; await context.TaskItems.AddAsync(task); await context.SaveChangesAsync(); @@ -341,8 +379,8 @@ public async Task DeleteAsync_ShouldOnlyDeleteSpecifiedTask() var repository = new TaskRepository(context); var tasks = new List { - new() { Id = 1, Title = "Task 1", Description = "Description 1", IsComplete = false }, - new() { Id = 2, Title = "Task 2", Description = "Description 2", IsComplete = true } + new() { Id = 1, Title = "Task 1", Description = "Description 1", Status = Status.Todo }, + new() { Id = 2, Title = "Task 2", Description = "Description 2", Status = Status.Completed } }; await context.TaskItems.AddRangeAsync(tasks); await context.SaveChangesAsync(); diff --git a/TaskFlow.Api.Tests/Validators/TaskItemValidatorTests.cs b/TaskFlow.Api.Tests/Validators/TaskItemValidatorTests.cs index ef850e16..0f15278b 100644 --- a/TaskFlow.Api.Tests/Validators/TaskItemValidatorTests.cs +++ b/TaskFlow.Api.Tests/Validators/TaskItemValidatorTests.cs @@ -22,7 +22,6 @@ public async Task Validate_ShouldPass_WhenTaskIsValid() { Title = "Valid Task", Description = "Valid Description", - IsComplete = false, Status = Status.Todo }; @@ -42,7 +41,6 @@ public async Task Validate_ShouldPass_WhenDescriptionIsNull() { Title = "Valid Task", Description = null, - IsComplete = false, Status = Status.Todo }; @@ -62,7 +60,6 @@ public async Task Validate_ShouldPass_WhenTaskIsComplete() { Title = "Completed Task", Description = "Completed", - IsComplete = true, Status = Status.Completed }; @@ -82,7 +79,6 @@ public async Task Validate_ShouldFail_WhenTitleIsEmpty() { Title = string.Empty, Description = "Description", - IsComplete = false, Status = Status.Todo }; @@ -104,7 +100,6 @@ public async Task Validate_ShouldFail_WhenTitleExceedsMaxLength() { Title = new string('a', 101), // 101 characters Description = "Description", - IsComplete = false, Status = Status.Todo }; @@ -126,7 +121,6 @@ public async Task Validate_ShouldPass_WhenTitleIsExactly100Characters() { Title = new string('a', 100), // Exactly 100 characters Description = "Description", - IsComplete = false, Status = Status.Todo }; @@ -146,7 +140,6 @@ public async Task Validate_ShouldPass_WhenTitleIs1Character() { Title = "A", Description = "Description", - IsComplete = false, Status = Status.Todo }; @@ -166,7 +159,6 @@ public async Task Validate_ShouldPass_WhenDescriptionIsEmpty() { Title = "Valid Task", Description = string.Empty, - IsComplete = false, Status = Status.Todo }; @@ -186,7 +178,6 @@ public async Task Validate_ShouldPass_WhenDescriptionIsVeryLong() { Title = "Valid Task", Description = new string('a', 10000), - IsComplete = false, Status = Status.Todo }; @@ -206,7 +197,6 @@ public async Task Validate_ShouldPass_WhenStatusIsDraft() { Title = "Draft Task", Description = "Description", - IsComplete = false, Status = Status.Draft }; @@ -226,7 +216,6 @@ public async Task Validate_ShouldPass_WhenStatusIsCompleted() { Title = "Completed Task", Description = "Description", - IsComplete = true, Status = Status.Completed }; @@ -246,7 +235,6 @@ public async Task Validate_ShouldFail_WhenStatusIsOutOfRange() { Title = "Task", Description = "Description", - IsComplete = false, Status = (Status)999 }; diff --git a/TaskFlow.Api/Controllers/V1/JournalTodosController.cs b/TaskFlow.Api/Controllers/V1/JournalTodosController.cs index c2cd3ca3..d0fd3510 100644 --- a/TaskFlow.Api/Controllers/V1/JournalTodosController.cs +++ b/TaskFlow.Api/Controllers/V1/JournalTodosController.cs @@ -96,7 +96,6 @@ private static TaskItemResponseDto MapTodo(TaskItem task, DateOnly? entryDate) Id = task.Id, Title = task.Title, Description = task.Description, - IsComplete = task.IsComplete, DueDate = task.DueDate, Status = task.Status.ToString(), Priority = task.Priority.ToString(), diff --git a/TaskFlow.Api/Controllers/V1/ProjectsController.cs b/TaskFlow.Api/Controllers/V1/ProjectsController.cs index 29ac3300..7058947d 100644 --- a/TaskFlow.Api/Controllers/V1/ProjectsController.cs +++ b/TaskFlow.Api/Controllers/V1/ProjectsController.cs @@ -58,7 +58,6 @@ public async Task>> GetTasks(int i Id = t.Id, Title = t.Title, Description = t.Description, - IsComplete = t.IsComplete, DueDate = t.DueDate, Status = t.Status.ToString(), Priority = t.Priority.ToString(), @@ -174,7 +173,7 @@ public async Task UnassignTask(int id, int taskItemId) IsArchived = project.IsArchived, CreatedAt = project.CreatedAt, UpdatedAt = project.UpdatedAt, - OpenTaskCount = project.Tasks.Count(t => !(t.IsComplete || t.Status == Status.Completed)), - CompletedTaskCount = project.Tasks.Count(t => t.IsComplete || t.Status == Status.Completed), + OpenTaskCount = project.Tasks.Count(t => t.Status != Status.Completed), + CompletedTaskCount = project.Tasks.Count(t => t.Status == Status.Completed), }; } diff --git a/TaskFlow.Api/Controllers/V1/TaskItemsController.cs b/TaskFlow.Api/Controllers/V1/TaskItemsController.cs index ec0dd300..5fbe3b32 100644 --- a/TaskFlow.Api/Controllers/V1/TaskItemsController.cs +++ b/TaskFlow.Api/Controllers/V1/TaskItemsController.cs @@ -143,7 +143,6 @@ public async Task> Create([FromBody] CreateTas Title = createDto.Title, Description = createDto.Description, Status = createDto.Status, - IsComplete = createDto.IsComplete, Priority = createDto.Priority, DueDate = createDto.DueDate, ParentTaskItemId = createDto.ParentTaskItemId, @@ -171,7 +170,67 @@ public async Task> Create([FromBody] CreateTas // PUT: api/v1/TaskItems/5 [HttpPut("{id}")] - public async Task> Update(int id, [FromBody] UpdateTaskItemDto updateDto) + public Task> Update(int id, [FromBody] UpdateTaskItemDto updateDto) => + ApplyUpdateAsync(id, new TaskChanges + { + HasTitle = true, + Title = updateDto.Title, + HasDescription = true, + Description = updateDto.Description, + HasStatus = updateDto.Status.HasValue, + Status = updateDto.Status, + HasPriority = updateDto.Priority.HasValue, + Priority = updateDto.Priority, + HasDueDate = true, + DueDate = updateDto.DueDate, + HasParent = true, + ParentTaskItemId = updateDto.ParentTaskItemId, + AutoCompleteParentWhenChildrenDone = updateDto.AutoCompleteParentWhenChildrenDone, + TimezoneOffsetMinutes = updateDto.TimezoneOffsetMinutes + }); + + // PATCH: api/v1/TaskItems/5 + // Partial update (JSON Merge Patch): absent fields are left unchanged. + [HttpPatch("{id}")] + public Task> Patch(int id, [FromBody] PatchTaskItemDto patchDto) => + ApplyUpdateAsync(id, new TaskChanges + { + // A present null/empty title becomes "" so the validator returns 400. + HasTitle = patchDto.IsPresent(nameof(PatchTaskItemDto.Title)), + Title = patchDto.Title ?? string.Empty, + HasDescription = patchDto.IsPresent(nameof(PatchTaskItemDto.Description)), + Description = patchDto.Description, + HasStatus = patchDto.Status.HasValue, + Status = patchDto.Status, + HasPriority = patchDto.Priority.HasValue, + Priority = patchDto.Priority, + HasDueDate = patchDto.IsPresent(nameof(PatchTaskItemDto.DueDate)), + DueDate = patchDto.DueDate, + HasParent = patchDto.IsPresent(nameof(PatchTaskItemDto.ParentTaskItemId)), + ParentTaskItemId = patchDto.ParentTaskItemId, + AutoCompleteParentWhenChildrenDone = patchDto.AutoCompleteParentWhenChildrenDone, + TimezoneOffsetMinutes = patchDto.TimezoneOffsetMinutes + }); + + private sealed record TaskChanges + { + public bool HasTitle { get; init; } + public string Title { get; init; } = string.Empty; + public bool HasDescription { get; init; } + public string? Description { get; init; } + public bool HasStatus { get; init; } + public Status? Status { get; init; } + public bool HasPriority { get; init; } + public Priority? Priority { get; init; } + public bool HasDueDate { get; init; } + public DateTime? DueDate { get; init; } + public bool HasParent { get; init; } + public int? ParentTaskItemId { get; init; } + public bool AutoCompleteParentWhenChildrenDone { get; init; } + public int? TimezoneOffsetMinutes { get; init; } + } + + private async Task> ApplyUpdateAsync(int id, TaskChanges changes) { var existing = await _repo.GetByIdAsync(id); if (existing is null) @@ -179,26 +238,27 @@ public async Task> Update(int id, [FromBody] U return NotFound(); } - if (updateDto.ParentTaskItemId.HasValue) + if (changes.HasParent && changes.ParentTaskItemId.HasValue) { - if (updateDto.ParentTaskItemId.Value == id) + var proposedParentId = changes.ParentTaskItemId.Value; + if (proposedParentId == id) { return UnprocessableEntity(new { code = ParentSelfNotAllowedErrorCode, message = "A task cannot be its own parent.", - details = new { parentTaskItemId = updateDto.ParentTaskItemId } + details = new { parentTaskItemId = changes.ParentTaskItemId } }); } - var parent = await _repo.GetByIdAsync(updateDto.ParentTaskItemId.Value); + var parent = await _repo.GetByIdAsync(proposedParentId); if (parent is null) { return UnprocessableEntity(new { code = ParentTaskNotFoundErrorCode, message = "The selected parent task was not found.", - details = new { parentTaskItemId = updateDto.ParentTaskItemId } + details = new { parentTaskItemId = changes.ParentTaskItemId } }); } @@ -208,17 +268,17 @@ public async Task> Update(int id, [FromBody] U { code = ParentDepthNotAllowedErrorCode, message = "Only one subtask level is supported.", - details = new { parentTaskItemId = updateDto.ParentTaskItemId } + details = new { parentTaskItemId = changes.ParentTaskItemId } }); } - if (await WouldCreateCycleAsync(id, updateDto.ParentTaskItemId.Value)) + if (await WouldCreateCycleAsync(id, proposedParentId)) { return UnprocessableEntity(new { code = ParentCycleNotAllowedErrorCode, message = "This parent assignment would create a cycle.", - details = new { parentTaskItemId = updateDto.ParentTaskItemId } + details = new { parentTaskItemId = changes.ParentTaskItemId } }); } @@ -228,14 +288,14 @@ public async Task> Update(int id, [FromBody] U { code = ParentDepthNotAllowedErrorCode, message = "Only one subtask level is supported.", - details = new { parentTaskItemId = updateDto.ParentTaskItemId } + details = new { parentTaskItemId = changes.ParentTaskItemId } }); } } - var wasCompleted = IsCompleted(existing.IsComplete, existing.Status); - var nextStatus = updateDto.Status ?? existing.Status; - var nowCompleted = IsCompleted(updateDto.IsComplete, nextStatus); + var wasCompleted = IsCompleted(existing.Status); + var nextStatus = changes.HasStatus && changes.Status.HasValue ? changes.Status.Value : existing.Status; + var nowCompleted = IsCompleted(nextStatus); if (!wasCompleted && nowCompleted && await HasIncompleteChildrenAsync(id)) { @@ -249,7 +309,7 @@ public async Task> Update(int id, [FromBody] U if (wasCompleted && !nowCompleted) { var assignedDate = await _repo.GetAssignedJournalDateAsync(id); - var today = UserDateHelper.GetUserToday(DateTime.UtcNow, updateDto.TimezoneOffsetMinutes); + var today = UserDateHelper.GetUserToday(DateTime.UtcNow, changes.TimezoneOffsetMinutes); if (assignedDate.HasValue && assignedDate.Value < today) { return UnprocessableEntity(new @@ -262,13 +322,32 @@ public async Task> Update(int id, [FromBody] U } // Apply incoming changes - existing.Title = updateDto.Title; - existing.Description = updateDto.Description; - existing.IsComplete = updateDto.IsComplete; - existing.Status = updateDto.Status ?? existing.Status; - existing.Priority = updateDto.Priority ?? existing.Priority; - existing.DueDate = updateDto.DueDate; - existing.ParentTaskItemId = updateDto.ParentTaskItemId; + if (changes.HasTitle) + { + existing.Title = changes.Title; + } + + if (changes.HasDescription) + { + existing.Description = changes.Description; + } + + existing.Status = nextStatus; + + if (changes.HasPriority && changes.Priority.HasValue) + { + existing.Priority = changes.Priority.Value; + } + + if (changes.HasDueDate) + { + existing.DueDate = changes.DueDate; + } + + if (changes.HasParent) + { + existing.ParentTaskItemId = changes.ParentTaskItemId; + } var validationResult = await _validator.ValidateAsync(existing); if (!validationResult.IsValid) @@ -278,7 +357,7 @@ public async Task> Update(int id, [FromBody] U await _repo.UpdateAsync(existing); - if (updateDto.AutoCompleteParentWhenChildrenDone && + if (changes.AutoCompleteParentWhenChildrenDone && !wasCompleted && nowCompleted && existing.ParentTaskItemId.HasValue) @@ -291,13 +370,12 @@ public async Task> Update(int id, [FromBody] U return Ok(responseDto); // Return 200 OK with the updated resource } - private static bool IsCompleted(bool isComplete, Status status) => - isComplete || status == Status.Completed; + private static bool IsCompleted(Status status) => status == Status.Completed; private async Task HasIncompleteChildrenAsync(int taskId) { var allTasks = await _repo.GetAllAsync(); - return allTasks.Any(t => t.ParentTaskItemId == taskId && !IsCompleted(t.IsComplete, t.Status)); + return allTasks.Any(t => t.ParentTaskItemId == taskId && !IsCompleted(t.Status)); } private async Task HasChildrenAsync(int taskId) @@ -315,7 +393,7 @@ private async Task GetChildTaskCountAsync(int taskId) private async Task TryAutoCompleteParentAsync(int parentTaskId) { var parent = await _repo.GetByIdAsync(parentTaskId); - if (parent is null || IsCompleted(parent.IsComplete, parent.Status)) + if (parent is null || IsCompleted(parent.Status)) { return; } @@ -328,14 +406,13 @@ private async Task TryAutoCompleteParentAsync(int parentTaskId) } var hasIncompleteChildren = allTasks.Any(t => - t.ParentTaskItemId == parentTaskId && !IsCompleted(t.IsComplete, t.Status)); + t.ParentTaskItemId == parentTaskId && !IsCompleted(t.Status)); if (hasIncompleteChildren) { return; } - parent.IsComplete = true; parent.Status = Status.Completed; var validationResult = await _validator.ValidateAsync(parent); @@ -437,7 +514,6 @@ private async Task MapTaskItemResponseAsync(TaskItem item, Id = item.Id, Title = item.Title, Description = item.Description, - IsComplete = item.IsComplete, DueDate = item.DueDate, Status = item.Status.ToString(), Priority = item.Priority.ToString(), diff --git a/TaskFlow.Api/DTOs/CreateTaskItemDto.cs b/TaskFlow.Api/DTOs/CreateTaskItemDto.cs index 513780eb..547f28ee 100644 --- a/TaskFlow.Api/DTOs/CreateTaskItemDto.cs +++ b/TaskFlow.Api/DTOs/CreateTaskItemDto.cs @@ -7,7 +7,6 @@ public class CreateTaskItemDto public string Title { get; set; } = string.Empty; public string? Description { get; set; } public Status Status { get; set; } = Status.Draft; - public bool IsComplete { get; set; } public Priority Priority { get; set; } = Priority.Low; public DateTime? DueDate { get; set; } public DateOnly? JournalDate { get; set; } diff --git a/TaskFlow.Api/DTOs/PatchTaskItemDto.cs b/TaskFlow.Api/DTOs/PatchTaskItemDto.cs new file mode 100644 index 00000000..7fcc20f1 --- /dev/null +++ b/TaskFlow.Api/DTOs/PatchTaskItemDto.cs @@ -0,0 +1,70 @@ +using TaskFlow.Api.Models; + +namespace TaskFlow.Api.DTOs; + +/// +/// Partial-update payload for PATCH /api/v1/TaskItems/{id} (JSON Merge Patch semantics). +/// A property that is absent from the JSON body is left unchanged. A property that is present, +/// including an explicit null, is applied. Presence is recorded by the property setters, +/// which System.Text.Json invokes only for properties that appear in the body. +/// +/// : present with null or empty fails validation. +/// , , : +/// present with null clears the value. +/// , : null is treated as absent. +/// +/// Completion is expressed only through . The project cannot be changed here. +/// +public class PatchTaskItemDto +{ + private readonly HashSet _present = new(StringComparer.OrdinalIgnoreCase); + private string? _title; + private string? _description; + private Status? _status; + private Priority? _priority; + private DateTime? _dueDate; + private int? _parentTaskItemId; + + public string? Title + { + get => _title; + set { _title = value; _present.Add(nameof(Title)); } + } + + public string? Description + { + get => _description; + set { _description = value; _present.Add(nameof(Description)); } + } + + public Status? Status + { + get => _status; + set { _status = value; _present.Add(nameof(Status)); } + } + + public Priority? Priority + { + get => _priority; + set { _priority = value; _present.Add(nameof(Priority)); } + } + + public DateTime? DueDate + { + get => _dueDate; + set { _dueDate = value; _present.Add(nameof(DueDate)); } + } + + public int? ParentTaskItemId + { + get => _parentTaskItemId; + set { _parentTaskItemId = value; _present.Add(nameof(ParentTaskItemId)); } + } + + public bool AutoCompleteParentWhenChildrenDone { get; set; } + + public int? TimezoneOffsetMinutes { get; set; } + + /// Returns true when the named property was supplied in the request body (case-insensitive). + public bool IsPresent(string propertyName) => _present.Contains(propertyName); +} diff --git a/TaskFlow.Api/DTOs/TaskItemResponseDto.cs b/TaskFlow.Api/DTOs/TaskItemResponseDto.cs index 13f4a698..38c40b10 100644 --- a/TaskFlow.Api/DTOs/TaskItemResponseDto.cs +++ b/TaskFlow.Api/DTOs/TaskItemResponseDto.cs @@ -7,7 +7,6 @@ public class TaskItemResponseDto public int Id { get; set; } public required string Title { get; set; } public string? Description { get; set; } - public bool IsComplete { get; set; } public DateTime? DueDate { get; set; } public string Status { get; set; } = "Draft"; public string Priority { get; set; } = "Low"; // String representation of the priority diff --git a/TaskFlow.Api/DTOs/UpdateTaskItemDto.cs b/TaskFlow.Api/DTOs/UpdateTaskItemDto.cs index cecd17d7..12c62a50 100644 --- a/TaskFlow.Api/DTOs/UpdateTaskItemDto.cs +++ b/TaskFlow.Api/DTOs/UpdateTaskItemDto.cs @@ -6,21 +6,21 @@ namespace TaskFlow.Api.DTOs; /// Update payload for PUT /api/v1/TaskItems/{id}. Field semantics are mixed: /// /// Full-replace — , , -/// , , and always +/// , and always /// overwrite the existing value with whatever is sent, including null. Omitting /// (or sending it as null) clears the task's parent link. /// Merge-on-null — and are /// only applied when non-null; a null value leaves the existing stored value unchanged. /// /// Callers must explicitly resend full-replace fields (e.g. parentTaskItemId) on every -/// update or they will be cleared. +/// update or they will be cleared. Completion is expressed only through . +/// Use PATCH /api/v1/TaskItems/{id} for partial updates. /// public class UpdateTaskItemDto { public string Title { get; set; } = string.Empty; public string? Description { get; set; } public Status? Status { get; set; } - public bool IsComplete { get; set; } public Priority? Priority { get; set; } public DateTime? DueDate { get; set; } public int? ParentTaskItemId { get; set; } diff --git a/TaskFlow.Api/Migrations/20261022141248_RemoveTaskItemIsComplete.Designer.cs b/TaskFlow.Api/Migrations/20261022141248_RemoveTaskItemIsComplete.Designer.cs new file mode 100644 index 00000000..cf91ee5e --- /dev/null +++ b/TaskFlow.Api/Migrations/20261022141248_RemoveTaskItemIsComplete.Designer.cs @@ -0,0 +1,386 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TaskFlow.Api.Data; + +#nullable disable + +namespace TaskFlow.Api.Migrations +{ + [DbContext(typeof(TaskDbContext))] + [Migration("20261022141248_RemoveTaskItemIsComplete")] + partial class RemoveTaskItemIsComplete + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("JournalEntryTaskItem", b => + { + b.Property("JournalEntryId") + .HasColumnType("INTEGER"); + + b.Property("TodosId") + .HasColumnType("INTEGER"); + + b.HasKey("JournalEntryId", "TodosId"); + + b.HasIndex("TodosId"); + + b.ToTable("JournalEntryTaskItem"); + }); + + modelBuilder.Entity("TaskFlow.Api.Models.JournalEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Date") + .IsUnique(); + + b.ToTable("JournalEntries"); + }); + + modelBuilder.Entity("TaskFlow.Api.Models.JournalLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("JournalEntryId") + .HasColumnType("INTEGER"); + + b.Property("LinkedTaskTitleSnapshot") + .HasColumnType("TEXT"); + + b.Property("TaskItemId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JournalEntryId"); + + b.HasIndex("TaskItemId"); + + b.ToTable("JournalLogEntries"); + }); + + modelBuilder.Entity("TaskFlow.Api.Models.JournalNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("JournalEntryId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JournalEntryId"); + + b.ToTable("JournalNotes"); + }); + + modelBuilder.Entity("TaskFlow.Api.Models.Note", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("TaskItemId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TaskItemId"); + + b.ToTable("Notes"); + }); + + modelBuilder.Entity("TaskFlow.Api.Models.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsArchived") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("TaskFlow.Api.Models.TaskItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentJournalEntryId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("DueDate") + .HasColumnType("TEXT"); + + b.Property("FirstTaggedDate") + .HasColumnType("TEXT"); + + b.Property("MoveCount") + .HasColumnType("INTEGER"); + + b.Property("ParentTaskItemId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CurrentJournalEntryId"); + + b.HasIndex("ParentTaskItemId"); + + b.HasIndex("ProjectId"); + + b.ToTable("TaskItems"); + }); + + modelBuilder.Entity("TaskFlow.Api.Models.TaskItemEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChangeSummary") + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("FromJournalDate") + .HasColumnType("TEXT"); + + b.Property("FromJournalEntryId") + .HasColumnType("INTEGER"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("TaskItemId") + .HasColumnType("INTEGER"); + + b.Property("ToJournalDate") + .HasColumnType("TEXT"); + + b.Property("ToJournalEntryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TaskItemId", "OccurredAtUtc"); + + b.ToTable("TaskItemEvents"); + }); + + modelBuilder.Entity("JournalEntryTaskItem", b => + { + b.HasOne("TaskFlow.Api.Models.JournalEntry", null) + .WithMany() + .HasForeignKey("JournalEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TaskFlow.Api.Models.TaskItem", null) + .WithMany() + .HasForeignKey("TodosId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TaskFlow.Api.Models.JournalLogEntry", b => + { + b.HasOne("TaskFlow.Api.Models.JournalEntry", "JournalEntry") + .WithMany("LogEntries") + .HasForeignKey("JournalEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TaskFlow.Api.Models.TaskItem", "TaskItem") + .WithMany() + .HasForeignKey("TaskItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("JournalEntry"); + + b.Navigation("TaskItem"); + }); + + modelBuilder.Entity("TaskFlow.Api.Models.JournalNote", b => + { + b.HasOne("TaskFlow.Api.Models.JournalEntry", "JournalEntry") + .WithMany("Notes") + .HasForeignKey("JournalEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JournalEntry"); + }); + + modelBuilder.Entity("TaskFlow.Api.Models.Note", b => + { + b.HasOne("TaskFlow.Api.Models.TaskItem", "TaskItem") + .WithMany("Notes") + .HasForeignKey("TaskItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TaskItem"); + }); + + modelBuilder.Entity("TaskFlow.Api.Models.TaskItem", b => + { + b.HasOne("TaskFlow.Api.Models.JournalEntry", "CurrentJournalEntry") + .WithMany() + .HasForeignKey("CurrentJournalEntryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("TaskFlow.Api.Models.TaskItem", "ParentTaskItem") + .WithMany("ChildTaskItems") + .HasForeignKey("ParentTaskItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TaskFlow.Api.Models.Project", "Project") + .WithMany("Tasks") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CurrentJournalEntry"); + + b.Navigation("ParentTaskItem"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("TaskFlow.Api.Models.TaskItemEvent", b => + { + b.HasOne("TaskFlow.Api.Models.TaskItem", "TaskItem") + .WithMany("Events") + .HasForeignKey("TaskItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TaskItem"); + }); + + modelBuilder.Entity("TaskFlow.Api.Models.JournalEntry", b => + { + b.Navigation("LogEntries"); + + b.Navigation("Notes"); + }); + + modelBuilder.Entity("TaskFlow.Api.Models.Project", b => + { + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("TaskFlow.Api.Models.TaskItem", b => + { + b.Navigation("ChildTaskItems"); + + b.Navigation("Events"); + + b.Navigation("Notes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TaskFlow.Api/Migrations/20261022141248_RemoveTaskItemIsComplete.cs b/TaskFlow.Api/Migrations/20261022141248_RemoveTaskItemIsComplete.cs new file mode 100644 index 00000000..db8bac82 --- /dev/null +++ b/TaskFlow.Api/Migrations/20261022141248_RemoveTaskItemIsComplete.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TaskFlow.Api.Migrations +{ + /// + public partial class RemoveTaskItemIsComplete : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Order matters. The synthetic history rows must be written first, while IsComplete still + // exists and before Status changes, so the affected set is exactly the rows the UPDATE changes. + migrationBuilder.Sql( + """ + INSERT INTO "TaskItemEvents" ("TaskItemId", "EventType", "OccurredAtUtc", "ChangeSummary") + SELECT "Id", 'Completed', strftime('%Y-%m-%d %H:%M:%f', 'now'), + 'Marked complete by a data migration that merged the separate completion flag into Status. Recorded when the migration ran, not when the task was completed.' + FROM "TaskItems" + WHERE "IsComplete" = 1 AND "Status" <> 2; + """); + + migrationBuilder.Sql( + """ + UPDATE "TaskItems" SET "Status" = 2 WHERE "IsComplete" = 1 AND "Status" <> 2; + """); + + migrationBuilder.DropColumn( + name: "IsComplete", + table: "TaskItems"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsComplete", + table: "TaskItems", + type: "INTEGER", + nullable: false, + defaultValue: false); + + // History is append-only: the synthetic Completed events written by Up are intentionally kept. + migrationBuilder.Sql( + """ + UPDATE "TaskItems" SET "IsComplete" = 1 WHERE "Status" = 2; + """); + } + } +} diff --git a/TaskFlow.Api/Migrations/TaskDbContextModelSnapshot.cs b/TaskFlow.Api/Migrations/TaskDbContextModelSnapshot.cs index 8cb38482..fe3dcf0a 100644 --- a/TaskFlow.Api/Migrations/TaskDbContextModelSnapshot.cs +++ b/TaskFlow.Api/Migrations/TaskDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ partial class TaskDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); modelBuilder.Entity("JournalEntryTaskItem", b => { @@ -198,9 +198,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("FirstTaggedDate") .HasColumnType("TEXT"); - b.Property("IsComplete") - .HasColumnType("INTEGER"); - b.Property("MoveCount") .HasColumnType("INTEGER"); diff --git a/TaskFlow.Api/Models/TaskItem.cs b/TaskFlow.Api/Models/TaskItem.cs index f4cad6fc..06c373d4 100644 --- a/TaskFlow.Api/Models/TaskItem.cs +++ b/TaskFlow.Api/Models/TaskItem.cs @@ -5,7 +5,6 @@ public class TaskItem public int Id { get; set; } public required string Title { get; set; } public string? Description { get; set; } - public bool IsComplete { get; set; } public Priority Priority { get; set; } = Priority.Low; public Status Status { get; set; } = Status.Draft; public DateTime? DueDate { get; set; } diff --git a/TaskFlow.Api/Repositories/JournalEntryRepository.cs b/TaskFlow.Api/Repositories/JournalEntryRepository.cs index c66ee577..34f97edf 100644 --- a/TaskFlow.Api/Repositories/JournalEntryRepository.cs +++ b/TaskFlow.Api/Repositories/JournalEntryRepository.cs @@ -75,7 +75,7 @@ public async Task> CarryForwardIncompleteTasksAsync(int entry var sources = await _context.JournalEntries .Include(e => e.Todos) .Where(e => e.Date < userToday && e.Id != entryId - && e.Todos.Any(t => t.CurrentJournalEntryId == e.Id && t.Status != Status.Completed && !t.IsComplete)) + && e.Todos.Any(t => t.CurrentJournalEntryId == e.Id && t.Status != Status.Completed)) .OrderBy(e => e.Date) .ToListAsync(); @@ -83,7 +83,7 @@ public async Task> CarryForwardIncompleteTasksAsync(int entry foreach (var source in sources) { var tasksToMove = source.Todos - .Where(t => t.CurrentJournalEntryId == source.Id && t.Status != Status.Completed && !t.IsComplete) + .Where(t => t.CurrentJournalEntryId == source.Id && t.Status != Status.Completed) .OrderBy(t => t.Id) .ToList(); diff --git a/TaskFlow.Api/Repositories/TaskRepository.cs b/TaskFlow.Api/Repositories/TaskRepository.cs index 03bd1987..cb5987b8 100644 --- a/TaskFlow.Api/Repositories/TaskRepository.cs +++ b/TaskFlow.Api/Repositories/TaskRepository.cs @@ -58,7 +58,6 @@ public async Task UpdateAsync(TaskItem task) var originalDescription = persisted?.Description ?? entry.OriginalValues.GetValue(nameof(TaskItem.Description)); var originalPriority = persisted?.Priority ?? entry.OriginalValues.GetValue(nameof(TaskItem.Priority)); var originalStatus = persisted?.Status ?? entry.OriginalValues.GetValue(nameof(TaskItem.Status)); - var originalIsComplete = persisted?.IsComplete ?? entry.OriginalValues.GetValue(nameof(TaskItem.IsComplete)); var originalDueDate = persisted?.DueDate ?? entry.OriginalValues.GetValue(nameof(TaskItem.DueDate)); if (!string.Equals(originalTitle, task.Title, StringComparison.Ordinal)) @@ -105,8 +104,8 @@ public async Task UpdateAsync(TaskItem task) }); } - var wasCompleted = IsCompleted(originalIsComplete, originalStatus); - var isCompletedNow = IsCompleted(task.IsComplete, task.Status); + var wasCompleted = IsCompleted(originalStatus); + var isCompletedNow = IsCompleted(task.Status); if (!wasCompleted && isCompletedNow) { _context.TaskItemEvents.Add(new TaskItemEvent @@ -154,8 +153,7 @@ public async Task DeleteAsync(int id) await _context.SaveChangesAsync(); } - private static bool IsCompleted(bool isComplete, Status status) => - isComplete || status == Status.Completed; + private static bool IsCompleted(Status status) => status == Status.Completed; private static string FormatDueDate(DateTime? value) => value?.ToString("yyyy-MM-dd") ?? "none"; diff --git a/TaskFlow.Api/TaskFlow.Api.http b/TaskFlow.Api/TaskFlow.Api.http index 100c52f9..7f7c74cc 100644 --- a/TaskFlow.Api/TaskFlow.Api.http +++ b/TaskFlow.Api/TaskFlow.Api.http @@ -31,12 +31,22 @@ Content-Type: application/json { "title": "Updated Task", "description": "Updated description", - "isCompleted": true, + "status": "completed", "dueDate": "2025-12-31T00:00:00Z" } ### +### Partially update task (v1): only the fields present are changed; null clears description/dueDate +PATCH {{TaskFlow.Api_HostAddress}}/api/v1/taskitems/1 +Content-Type: application/json + +{ + "status": "completed" +} + +### + ### Delete task (v1) DELETE {{TaskFlow.Api_HostAddress}}/api/v1/taskitems/1 diff --git a/TaskFlow.Web/src/api/client/index.ts b/TaskFlow.Web/src/api/client/index.ts index 3d350c28..04554109 100644 --- a/TaskFlow.Web/src/api/client/index.ts +++ b/TaskFlow.Web/src/api/client/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { deleteApiV1JournalEntriesByEntryIdLogsById, deleteApiV1JournalEntriesByEntryIdNotesById, deleteApiV1JournalEntriesByEntryIdTodosByTaskItemId, deleteApiV1JournalEntriesById, deleteApiV1ProjectsById, deleteApiV1ProjectsByIdTasksByTaskItemId, deleteApiV1TaskItemsById, deleteApiV1TaskItemsByTaskIdNotesById, getApiV1JournalEntries, getApiV1JournalEntriesByEntryIdLogs, getApiV1JournalEntriesByEntryIdNotes, getApiV1JournalEntriesByEntryIdTodos, getApiV1Projects, getApiV1ProjectsByIdTasks, getApiV1TaskItems, getApiV1TaskItemsByIdHistory, getApiV1TaskItemsByTaskIdNotes, getJournalEntryV1, getJournalLogEntryV1, getJournalNoteV1, getNoteV1, getProjectV1, getTaskV1, type Options, postApiV1JournalEntries, postApiV1JournalEntriesByEntryIdLogs, postApiV1JournalEntriesByEntryIdNotes, postApiV1JournalEntriesByEntryIdTodos, postApiV1JournalEntriesByIdCarryForward, postApiV1Projects, postApiV1ProjectsByIdTasks, postApiV1TaskItems, postApiV1TaskItemsByTaskIdNotes, putApiV1JournalEntriesByEntryIdLogsById, putApiV1JournalEntriesByEntryIdNotesById, putApiV1JournalEntriesById, putApiV1ProjectsById, putApiV1TaskItemsById, putApiV1TaskItemsByTaskIdNotesById } from './sdk.gen'; -export type { AddJournalTodoDto, AssignProjectTaskDto, CarryForwardRequestDto, CarryForwardResponseDto, ClientOptions, CreateJournalEntryDto, CreateJournalLogEntryDto, CreateJournalNoteDto, CreateNoteDto, CreateProjectDto, CreateTaskItemDto, DeleteApiV1JournalEntriesByEntryIdLogsByIdData, DeleteApiV1JournalEntriesByEntryIdLogsByIdResponses, DeleteApiV1JournalEntriesByEntryIdNotesByIdData, DeleteApiV1JournalEntriesByEntryIdNotesByIdResponses, DeleteApiV1JournalEntriesByEntryIdTodosByTaskItemIdData, DeleteApiV1JournalEntriesByEntryIdTodosByTaskItemIdResponses, DeleteApiV1JournalEntriesByIdData, DeleteApiV1JournalEntriesByIdResponses, DeleteApiV1ProjectsByIdData, DeleteApiV1ProjectsByIdResponses, DeleteApiV1ProjectsByIdTasksByTaskItemIdData, DeleteApiV1ProjectsByIdTasksByTaskItemIdResponses, DeleteApiV1TaskItemsByIdData, DeleteApiV1TaskItemsByIdResponses, DeleteApiV1TaskItemsByTaskIdNotesByIdData, DeleteApiV1TaskItemsByTaskIdNotesByIdResponses, GetApiV1JournalEntriesByEntryIdLogsData, GetApiV1JournalEntriesByEntryIdLogsResponse, GetApiV1JournalEntriesByEntryIdLogsResponses, GetApiV1JournalEntriesByEntryIdNotesData, GetApiV1JournalEntriesByEntryIdNotesResponse, GetApiV1JournalEntriesByEntryIdNotesResponses, GetApiV1JournalEntriesByEntryIdTodosData, GetApiV1JournalEntriesByEntryIdTodosResponse, GetApiV1JournalEntriesByEntryIdTodosResponses, GetApiV1JournalEntriesData, GetApiV1JournalEntriesResponse, GetApiV1JournalEntriesResponses, GetApiV1ProjectsByIdTasksData, GetApiV1ProjectsByIdTasksResponse, GetApiV1ProjectsByIdTasksResponses, GetApiV1ProjectsData, GetApiV1ProjectsResponse, GetApiV1ProjectsResponses, GetApiV1TaskItemsByIdHistoryData, GetApiV1TaskItemsByIdHistoryResponse, GetApiV1TaskItemsByIdHistoryResponses, GetApiV1TaskItemsByTaskIdNotesData, GetApiV1TaskItemsByTaskIdNotesResponse, GetApiV1TaskItemsByTaskIdNotesResponses, GetApiV1TaskItemsData, GetApiV1TaskItemsResponse, GetApiV1TaskItemsResponses, GetJournalEntryV1Data, GetJournalEntryV1Response, GetJournalEntryV1Responses, GetJournalLogEntryV1Data, GetJournalLogEntryV1Response, GetJournalLogEntryV1Responses, GetJournalNoteV1Data, GetJournalNoteV1Response, GetJournalNoteV1Responses, GetNoteV1Data, GetNoteV1Response, GetNoteV1Responses, GetProjectV1Data, GetProjectV1Response, GetProjectV1Responses, GetTaskV1Data, GetTaskV1Response, GetTaskV1Responses, JournalEntryResponseDto, JournalLogEntryResponseDto, JournalNoteResponseDto, NoteResponseDto, PostApiV1JournalEntriesByEntryIdLogsData, PostApiV1JournalEntriesByEntryIdLogsResponse, PostApiV1JournalEntriesByEntryIdLogsResponses, PostApiV1JournalEntriesByEntryIdNotesData, PostApiV1JournalEntriesByEntryIdNotesResponse, PostApiV1JournalEntriesByEntryIdNotesResponses, PostApiV1JournalEntriesByEntryIdTodosData, PostApiV1JournalEntriesByEntryIdTodosResponses, PostApiV1JournalEntriesByIdCarryForwardData, PostApiV1JournalEntriesByIdCarryForwardResponse, PostApiV1JournalEntriesByIdCarryForwardResponses, PostApiV1JournalEntriesData, PostApiV1JournalEntriesResponse, PostApiV1JournalEntriesResponses, PostApiV1ProjectsByIdTasksData, PostApiV1ProjectsByIdTasksResponses, PostApiV1ProjectsData, PostApiV1ProjectsResponse, PostApiV1ProjectsResponses, PostApiV1TaskItemsByTaskIdNotesData, PostApiV1TaskItemsByTaskIdNotesResponse, PostApiV1TaskItemsByTaskIdNotesResponses, PostApiV1TaskItemsData, PostApiV1TaskItemsResponse, PostApiV1TaskItemsResponses, Priority, ProjectResponseDto, PutApiV1JournalEntriesByEntryIdLogsByIdData, PutApiV1JournalEntriesByEntryIdLogsByIdResponse, PutApiV1JournalEntriesByEntryIdLogsByIdResponses, PutApiV1JournalEntriesByEntryIdNotesByIdData, PutApiV1JournalEntriesByEntryIdNotesByIdResponse, PutApiV1JournalEntriesByEntryIdNotesByIdResponses, PutApiV1JournalEntriesByIdData, PutApiV1JournalEntriesByIdResponse, PutApiV1JournalEntriesByIdResponses, PutApiV1ProjectsByIdData, PutApiV1ProjectsByIdResponse, PutApiV1ProjectsByIdResponses, PutApiV1TaskItemsByIdData, PutApiV1TaskItemsByIdResponse, PutApiV1TaskItemsByIdResponses, PutApiV1TaskItemsByTaskIdNotesByIdData, PutApiV1TaskItemsByTaskIdNotesByIdResponse, PutApiV1TaskItemsByTaskIdNotesByIdResponses, Status, TaskItemEventResponseDto, TaskItemResponseDto, UpdateJournalEntryDto, UpdateJournalLogEntryDto, UpdateJournalNoteDto, UpdateNoteDto, UpdateProjectDto, UpdateTaskItemDto } from './types.gen'; +export { deleteApiV1JournalEntriesByEntryIdLogsById, deleteApiV1JournalEntriesByEntryIdNotesById, deleteApiV1JournalEntriesByEntryIdTodosByTaskItemId, deleteApiV1JournalEntriesById, deleteApiV1ProjectsById, deleteApiV1ProjectsByIdTasksByTaskItemId, deleteApiV1TaskItemsById, deleteApiV1TaskItemsByTaskIdNotesById, getApiV1JournalEntries, getApiV1JournalEntriesByEntryIdLogs, getApiV1JournalEntriesByEntryIdNotes, getApiV1JournalEntriesByEntryIdTodos, getApiV1Projects, getApiV1ProjectsByIdTasks, getApiV1TaskItems, getApiV1TaskItemsByIdHistory, getApiV1TaskItemsByTaskIdNotes, getJournalEntryV1, getJournalLogEntryV1, getJournalNoteV1, getNoteV1, getProjectV1, getTaskV1, type Options, patchApiV1TaskItemsById, postApiV1JournalEntries, postApiV1JournalEntriesByEntryIdLogs, postApiV1JournalEntriesByEntryIdNotes, postApiV1JournalEntriesByEntryIdTodos, postApiV1JournalEntriesByIdCarryForward, postApiV1Projects, postApiV1ProjectsByIdTasks, postApiV1TaskItems, postApiV1TaskItemsByTaskIdNotes, putApiV1JournalEntriesByEntryIdLogsById, putApiV1JournalEntriesByEntryIdNotesById, putApiV1JournalEntriesById, putApiV1ProjectsById, putApiV1TaskItemsById, putApiV1TaskItemsByTaskIdNotesById } from './sdk.gen'; +export type { AddJournalTodoDto, AssignProjectTaskDto, CarryForwardRequestDto, CarryForwardResponseDto, ClientOptions, CreateJournalEntryDto, CreateJournalLogEntryDto, CreateJournalNoteDto, CreateNoteDto, CreateProjectDto, CreateTaskItemDto, DeleteApiV1JournalEntriesByEntryIdLogsByIdData, DeleteApiV1JournalEntriesByEntryIdLogsByIdResponses, DeleteApiV1JournalEntriesByEntryIdNotesByIdData, DeleteApiV1JournalEntriesByEntryIdNotesByIdResponses, DeleteApiV1JournalEntriesByEntryIdTodosByTaskItemIdData, DeleteApiV1JournalEntriesByEntryIdTodosByTaskItemIdResponses, DeleteApiV1JournalEntriesByIdData, DeleteApiV1JournalEntriesByIdResponses, DeleteApiV1ProjectsByIdData, DeleteApiV1ProjectsByIdResponses, DeleteApiV1ProjectsByIdTasksByTaskItemIdData, DeleteApiV1ProjectsByIdTasksByTaskItemIdResponses, DeleteApiV1TaskItemsByIdData, DeleteApiV1TaskItemsByIdResponses, DeleteApiV1TaskItemsByTaskIdNotesByIdData, DeleteApiV1TaskItemsByTaskIdNotesByIdResponses, GetApiV1JournalEntriesByEntryIdLogsData, GetApiV1JournalEntriesByEntryIdLogsResponse, GetApiV1JournalEntriesByEntryIdLogsResponses, GetApiV1JournalEntriesByEntryIdNotesData, GetApiV1JournalEntriesByEntryIdNotesResponse, GetApiV1JournalEntriesByEntryIdNotesResponses, GetApiV1JournalEntriesByEntryIdTodosData, GetApiV1JournalEntriesByEntryIdTodosResponse, GetApiV1JournalEntriesByEntryIdTodosResponses, GetApiV1JournalEntriesData, GetApiV1JournalEntriesResponse, GetApiV1JournalEntriesResponses, GetApiV1ProjectsByIdTasksData, GetApiV1ProjectsByIdTasksResponse, GetApiV1ProjectsByIdTasksResponses, GetApiV1ProjectsData, GetApiV1ProjectsResponse, GetApiV1ProjectsResponses, GetApiV1TaskItemsByIdHistoryData, GetApiV1TaskItemsByIdHistoryResponse, GetApiV1TaskItemsByIdHistoryResponses, GetApiV1TaskItemsByTaskIdNotesData, GetApiV1TaskItemsByTaskIdNotesResponse, GetApiV1TaskItemsByTaskIdNotesResponses, GetApiV1TaskItemsData, GetApiV1TaskItemsResponse, GetApiV1TaskItemsResponses, GetJournalEntryV1Data, GetJournalEntryV1Response, GetJournalEntryV1Responses, GetJournalLogEntryV1Data, GetJournalLogEntryV1Response, GetJournalLogEntryV1Responses, GetJournalNoteV1Data, GetJournalNoteV1Response, GetJournalNoteV1Responses, GetNoteV1Data, GetNoteV1Response, GetNoteV1Responses, GetProjectV1Data, GetProjectV1Response, GetProjectV1Responses, GetTaskV1Data, GetTaskV1Response, GetTaskV1Responses, JournalEntryResponseDto, JournalLogEntryResponseDto, JournalNoteResponseDto, NoteResponseDto, PatchApiV1TaskItemsByIdData, PatchApiV1TaskItemsByIdResponse, PatchApiV1TaskItemsByIdResponses, PatchTaskItemDto, PostApiV1JournalEntriesByEntryIdLogsData, PostApiV1JournalEntriesByEntryIdLogsResponse, PostApiV1JournalEntriesByEntryIdLogsResponses, PostApiV1JournalEntriesByEntryIdNotesData, PostApiV1JournalEntriesByEntryIdNotesResponse, PostApiV1JournalEntriesByEntryIdNotesResponses, PostApiV1JournalEntriesByEntryIdTodosData, PostApiV1JournalEntriesByEntryIdTodosResponses, PostApiV1JournalEntriesByIdCarryForwardData, PostApiV1JournalEntriesByIdCarryForwardResponse, PostApiV1JournalEntriesByIdCarryForwardResponses, PostApiV1JournalEntriesData, PostApiV1JournalEntriesResponse, PostApiV1JournalEntriesResponses, PostApiV1ProjectsByIdTasksData, PostApiV1ProjectsByIdTasksResponses, PostApiV1ProjectsData, PostApiV1ProjectsResponse, PostApiV1ProjectsResponses, PostApiV1TaskItemsByTaskIdNotesData, PostApiV1TaskItemsByTaskIdNotesResponse, PostApiV1TaskItemsByTaskIdNotesResponses, PostApiV1TaskItemsData, PostApiV1TaskItemsResponse, PostApiV1TaskItemsResponses, Priority, ProjectResponseDto, PutApiV1JournalEntriesByEntryIdLogsByIdData, PutApiV1JournalEntriesByEntryIdLogsByIdResponse, PutApiV1JournalEntriesByEntryIdLogsByIdResponses, PutApiV1JournalEntriesByEntryIdNotesByIdData, PutApiV1JournalEntriesByEntryIdNotesByIdResponse, PutApiV1JournalEntriesByEntryIdNotesByIdResponses, PutApiV1JournalEntriesByIdData, PutApiV1JournalEntriesByIdResponse, PutApiV1JournalEntriesByIdResponses, PutApiV1ProjectsByIdData, PutApiV1ProjectsByIdResponse, PutApiV1ProjectsByIdResponses, PutApiV1TaskItemsByIdData, PutApiV1TaskItemsByIdResponse, PutApiV1TaskItemsByIdResponses, PutApiV1TaskItemsByTaskIdNotesByIdData, PutApiV1TaskItemsByTaskIdNotesByIdResponse, PutApiV1TaskItemsByTaskIdNotesByIdResponses, Status, TaskItemEventResponseDto, TaskItemResponseDto, UpdateJournalEntryDto, UpdateJournalLogEntryDto, UpdateJournalNoteDto, UpdateNoteDto, UpdateProjectDto, UpdateTaskItemDto } from './types.gen'; diff --git a/TaskFlow.Web/src/api/client/sdk.gen.ts b/TaskFlow.Web/src/api/client/sdk.gen.ts index ef0c613d..9c9bf144 100644 --- a/TaskFlow.Web/src/api/client/sdk.gen.ts +++ b/TaskFlow.Web/src/api/client/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; import { client } from './client.gen'; -import type { DeleteApiV1JournalEntriesByEntryIdLogsByIdData, DeleteApiV1JournalEntriesByEntryIdLogsByIdResponses, DeleteApiV1JournalEntriesByEntryIdNotesByIdData, DeleteApiV1JournalEntriesByEntryIdNotesByIdResponses, DeleteApiV1JournalEntriesByEntryIdTodosByTaskItemIdData, DeleteApiV1JournalEntriesByEntryIdTodosByTaskItemIdResponses, DeleteApiV1JournalEntriesByIdData, DeleteApiV1JournalEntriesByIdResponses, DeleteApiV1ProjectsByIdData, DeleteApiV1ProjectsByIdResponses, DeleteApiV1ProjectsByIdTasksByTaskItemIdData, DeleteApiV1ProjectsByIdTasksByTaskItemIdResponses, DeleteApiV1TaskItemsByIdData, DeleteApiV1TaskItemsByIdResponses, DeleteApiV1TaskItemsByTaskIdNotesByIdData, DeleteApiV1TaskItemsByTaskIdNotesByIdResponses, GetApiV1JournalEntriesByEntryIdLogsData, GetApiV1JournalEntriesByEntryIdLogsResponses, GetApiV1JournalEntriesByEntryIdNotesData, GetApiV1JournalEntriesByEntryIdNotesResponses, GetApiV1JournalEntriesByEntryIdTodosData, GetApiV1JournalEntriesByEntryIdTodosResponses, GetApiV1JournalEntriesData, GetApiV1JournalEntriesResponses, GetApiV1ProjectsByIdTasksData, GetApiV1ProjectsByIdTasksResponses, GetApiV1ProjectsData, GetApiV1ProjectsResponses, GetApiV1TaskItemsByIdHistoryData, GetApiV1TaskItemsByIdHistoryResponses, GetApiV1TaskItemsByTaskIdNotesData, GetApiV1TaskItemsByTaskIdNotesResponses, GetApiV1TaskItemsData, GetApiV1TaskItemsResponses, GetJournalEntryV1Data, GetJournalEntryV1Responses, GetJournalLogEntryV1Data, GetJournalLogEntryV1Responses, GetJournalNoteV1Data, GetJournalNoteV1Responses, GetNoteV1Data, GetNoteV1Responses, GetProjectV1Data, GetProjectV1Responses, GetTaskV1Data, GetTaskV1Responses, PostApiV1JournalEntriesByEntryIdLogsData, PostApiV1JournalEntriesByEntryIdLogsResponses, PostApiV1JournalEntriesByEntryIdNotesData, PostApiV1JournalEntriesByEntryIdNotesResponses, PostApiV1JournalEntriesByEntryIdTodosData, PostApiV1JournalEntriesByEntryIdTodosResponses, PostApiV1JournalEntriesByIdCarryForwardData, PostApiV1JournalEntriesByIdCarryForwardResponses, PostApiV1JournalEntriesData, PostApiV1JournalEntriesResponses, PostApiV1ProjectsByIdTasksData, PostApiV1ProjectsByIdTasksResponses, PostApiV1ProjectsData, PostApiV1ProjectsResponses, PostApiV1TaskItemsByTaskIdNotesData, PostApiV1TaskItemsByTaskIdNotesResponses, PostApiV1TaskItemsData, PostApiV1TaskItemsResponses, PutApiV1JournalEntriesByEntryIdLogsByIdData, PutApiV1JournalEntriesByEntryIdLogsByIdResponses, PutApiV1JournalEntriesByEntryIdNotesByIdData, PutApiV1JournalEntriesByEntryIdNotesByIdResponses, PutApiV1JournalEntriesByIdData, PutApiV1JournalEntriesByIdResponses, PutApiV1ProjectsByIdData, PutApiV1ProjectsByIdResponses, PutApiV1TaskItemsByIdData, PutApiV1TaskItemsByIdResponses, PutApiV1TaskItemsByTaskIdNotesByIdData, PutApiV1TaskItemsByTaskIdNotesByIdResponses } from './types.gen'; +import type { DeleteApiV1JournalEntriesByEntryIdLogsByIdData, DeleteApiV1JournalEntriesByEntryIdLogsByIdResponses, DeleteApiV1JournalEntriesByEntryIdNotesByIdData, DeleteApiV1JournalEntriesByEntryIdNotesByIdResponses, DeleteApiV1JournalEntriesByEntryIdTodosByTaskItemIdData, DeleteApiV1JournalEntriesByEntryIdTodosByTaskItemIdResponses, DeleteApiV1JournalEntriesByIdData, DeleteApiV1JournalEntriesByIdResponses, DeleteApiV1ProjectsByIdData, DeleteApiV1ProjectsByIdResponses, DeleteApiV1ProjectsByIdTasksByTaskItemIdData, DeleteApiV1ProjectsByIdTasksByTaskItemIdResponses, DeleteApiV1TaskItemsByIdData, DeleteApiV1TaskItemsByIdResponses, DeleteApiV1TaskItemsByTaskIdNotesByIdData, DeleteApiV1TaskItemsByTaskIdNotesByIdResponses, GetApiV1JournalEntriesByEntryIdLogsData, GetApiV1JournalEntriesByEntryIdLogsResponses, GetApiV1JournalEntriesByEntryIdNotesData, GetApiV1JournalEntriesByEntryIdNotesResponses, GetApiV1JournalEntriesByEntryIdTodosData, GetApiV1JournalEntriesByEntryIdTodosResponses, GetApiV1JournalEntriesData, GetApiV1JournalEntriesResponses, GetApiV1ProjectsByIdTasksData, GetApiV1ProjectsByIdTasksResponses, GetApiV1ProjectsData, GetApiV1ProjectsResponses, GetApiV1TaskItemsByIdHistoryData, GetApiV1TaskItemsByIdHistoryResponses, GetApiV1TaskItemsByTaskIdNotesData, GetApiV1TaskItemsByTaskIdNotesResponses, GetApiV1TaskItemsData, GetApiV1TaskItemsResponses, GetJournalEntryV1Data, GetJournalEntryV1Responses, GetJournalLogEntryV1Data, GetJournalLogEntryV1Responses, GetJournalNoteV1Data, GetJournalNoteV1Responses, GetNoteV1Data, GetNoteV1Responses, GetProjectV1Data, GetProjectV1Responses, GetTaskV1Data, GetTaskV1Responses, PatchApiV1TaskItemsByIdData, PatchApiV1TaskItemsByIdResponses, PostApiV1JournalEntriesByEntryIdLogsData, PostApiV1JournalEntriesByEntryIdLogsResponses, PostApiV1JournalEntriesByEntryIdNotesData, PostApiV1JournalEntriesByEntryIdNotesResponses, PostApiV1JournalEntriesByEntryIdTodosData, PostApiV1JournalEntriesByEntryIdTodosResponses, PostApiV1JournalEntriesByIdCarryForwardData, PostApiV1JournalEntriesByIdCarryForwardResponses, PostApiV1JournalEntriesData, PostApiV1JournalEntriesResponses, PostApiV1ProjectsByIdTasksData, PostApiV1ProjectsByIdTasksResponses, PostApiV1ProjectsData, PostApiV1ProjectsResponses, PostApiV1TaskItemsByTaskIdNotesData, PostApiV1TaskItemsByTaskIdNotesResponses, PostApiV1TaskItemsData, PostApiV1TaskItemsResponses, PutApiV1JournalEntriesByEntryIdLogsByIdData, PutApiV1JournalEntriesByEntryIdLogsByIdResponses, PutApiV1JournalEntriesByEntryIdNotesByIdData, PutApiV1JournalEntriesByEntryIdNotesByIdResponses, PutApiV1JournalEntriesByIdData, PutApiV1JournalEntriesByIdResponses, PutApiV1ProjectsByIdData, PutApiV1ProjectsByIdResponses, PutApiV1TaskItemsByIdData, PutApiV1TaskItemsByIdResponses, PutApiV1TaskItemsByTaskIdNotesByIdData, PutApiV1TaskItemsByTaskIdNotesByIdResponses } from './types.gen'; export type Options = Options2 & { /** @@ -188,6 +188,15 @@ export const deleteApiV1TaskItemsById = (o export const getTaskV1 = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/TaskItems/{id}', ...options }); +export const patchApiV1TaskItemsById = (options: Options): RequestResult => (options.client ?? client).patch({ + url: '/api/v1/TaskItems/{id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + export const putApiV1TaskItemsById = (options: Options): RequestResult => (options.client ?? client).put({ url: '/api/v1/TaskItems/{id}', ...options, diff --git a/TaskFlow.Web/src/api/client/types.gen.ts b/TaskFlow.Web/src/api/client/types.gen.ts index 6a1d6c9a..2be61614 100644 --- a/TaskFlow.Web/src/api/client/types.gen.ts +++ b/TaskFlow.Web/src/api/client/types.gen.ts @@ -51,7 +51,6 @@ export type CreateTaskItemDto = { title?: string; description?: null | string; status?: Status; - isComplete?: boolean; priority?: Priority; dueDate?: null | string; journalDate?: null | string; @@ -100,6 +99,17 @@ export type NoteResponseDto = { updatedAt?: null | string; }; +export type PatchTaskItemDto = { + title?: null | string; + description?: null | string; + status?: null | Status; + priority?: null | Priority; + dueDate?: null | string; + parentTaskItemId?: null | number | string; + autoCompleteParentWhenChildrenDone?: boolean; + timezoneOffsetMinutes?: null | number | string; +}; + export type Priority = 'low' | 'medium' | 'high'; export type ProjectResponseDto = { @@ -131,7 +141,6 @@ export type TaskItemResponseDto = { id?: number | string; title: string; description?: null | string; - isComplete?: boolean; dueDate?: null | string; status?: string; priority?: string; @@ -176,7 +185,6 @@ export type UpdateTaskItemDto = { title?: string; description?: null | string; status?: null | Status; - isComplete?: boolean; priority?: null | Priority; dueDate?: null | string; parentTaskItemId?: null | number | string; @@ -811,6 +819,24 @@ export type GetTaskV1Responses = { export type GetTaskV1Response = GetTaskV1Responses[keyof GetTaskV1Responses]; +export type PatchApiV1TaskItemsByIdData = { + body: PatchTaskItemDto; + path: { + id: number | string; + }; + query?: never; + url: '/api/v1/TaskItems/{id}'; +}; + +export type PatchApiV1TaskItemsByIdResponses = { + /** + * OK + */ + 200: TaskItemResponseDto; +}; + +export type PatchApiV1TaskItemsByIdResponse = PatchApiV1TaskItemsByIdResponses[keyof PatchApiV1TaskItemsByIdResponses]; + export type PutApiV1TaskItemsByIdData = { body: UpdateTaskItemDto; path: { diff --git a/TaskFlow.Web/src/api/projects.ts b/TaskFlow.Web/src/api/projects.ts index e69fa49d..a0c9ef23 100644 --- a/TaskFlow.Web/src/api/projects.ts +++ b/TaskFlow.Web/src/api/projects.ts @@ -18,7 +18,6 @@ export type ProjectTaskDto = { id: number title: string description?: string | null - isComplete: boolean dueDate?: string | null status: 'Draft' | 'Todo' | 'Completed' priority: 'Low' | 'Medium' | 'High' diff --git a/TaskFlow.Web/src/components/TaskCard.test.tsx b/TaskFlow.Web/src/components/TaskCard.test.tsx index 106fc955..090832f0 100644 --- a/TaskFlow.Web/src/components/TaskCard.test.tsx +++ b/TaskFlow.Web/src/components/TaskCard.test.tsx @@ -12,7 +12,6 @@ const task: TaskItemResponseDto = { status: 'todo', priority: 'high', dueDate: '2026-05-01T00:00:00Z', - isComplete: false, } function renderCard(onEdit = vi.fn(), onDelete = vi.fn(), taskOverride: TaskItemResponseDto = task) { diff --git a/TaskFlow.Web/src/components/TaskForm.test.tsx b/TaskFlow.Web/src/components/TaskForm.test.tsx index cd194706..a46e9adc 100644 --- a/TaskFlow.Web/src/components/TaskForm.test.tsx +++ b/TaskFlow.Web/src/components/TaskForm.test.tsx @@ -20,7 +20,7 @@ const projects = [ ] const existing = { - id: 7, title: 'Existing task', description: 'Some desc', status: 'Todo' as const, priority: 'Medium' as const, isComplete: false, + id: 7, title: 'Existing task', description: 'Some desc', status: 'Todo' as const, priority: 'Medium' as const, } function projectSelect() { @@ -39,7 +39,7 @@ describe('TaskForm', () => { it('pre-fills fields when editing an existing task', () => { const task = { - id: 1, title: 'Existing task', description: 'Some desc', status: 'todo', priority: 'medium', isComplete: false, + id: 1, title: 'Existing task', description: 'Some desc', status: 'todo', priority: 'medium', } renderForm(task) expect(screen.getByDisplayValue('Existing task')).toBeInTheDocument() diff --git a/TaskFlow.Web/src/components/TaskHistoryPanel.test.tsx b/TaskFlow.Web/src/components/TaskHistoryPanel.test.tsx index 55f86921..6cdcd753 100644 --- a/TaskFlow.Web/src/components/TaskHistoryPanel.test.tsx +++ b/TaskFlow.Web/src/components/TaskHistoryPanel.test.tsx @@ -70,6 +70,17 @@ describe('TaskHistoryPanel', () => { expect(screen.getByText('Marked complete')).toBeInTheDocument() }) + it('renders the long migration changeSummary for a Completed event, not the "Marked complete" fallback', () => { + const summary = 'Marked complete by a data migration that merged the separate completion flag into Status. Recorded when the migration ran, not when the task was completed.' + vi.mocked(useTaskHistoryQuery).mockReturnValue({ + data: { data: [makeEvent({ eventType: 'Completed', changeSummary: summary })] }, + isLoading: false, + } as never) + render() + expect(screen.getByText(summary)).toBeInTheDocument() + expect(screen.queryByText('Marked complete')).not.toBeInTheDocument() + }) + it('renders "Assigned to" for AssignedToJournalDay with toJournalDate', () => { vi.mocked(useTaskHistoryQuery).mockReturnValue({ data: { data: [makeEvent({ eventType: 'AssignedToJournalDay', toJournalDate: '2026-05-28', changeSummary: null })] }, diff --git a/TaskFlow.Web/src/components/TaskListRow.test.tsx b/TaskFlow.Web/src/components/TaskListRow.test.tsx index 74fa2f3a..8628fc76 100644 --- a/TaskFlow.Web/src/components/TaskListRow.test.tsx +++ b/TaskFlow.Web/src/components/TaskListRow.test.tsx @@ -14,7 +14,6 @@ const baseTask: TaskProp = { status: 'todo', priority: 'high', dueDate: null, - isComplete: false, } function renderRow( diff --git a/TaskFlow.Web/src/components/journal/TaskTypeahead.test.tsx b/TaskFlow.Web/src/components/journal/TaskTypeahead.test.tsx index bff604b1..9f9a5ff5 100644 --- a/TaskFlow.Web/src/components/journal/TaskTypeahead.test.tsx +++ b/TaskFlow.Web/src/components/journal/TaskTypeahead.test.tsx @@ -3,9 +3,9 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' const mockTasks = [ - { id: 1, title: 'Fix login bug', status: 'Todo', isComplete: false }, - { id: 2, title: 'Write tests', status: 'Todo', isComplete: false }, - { id: 3, title: 'Deploy to production', status: 'Completed', isComplete: true }, + { id: 1, title: 'Fix login bug', status: 'Todo' }, + { id: 2, title: 'Write tests', status: 'Todo' }, + { id: 3, title: 'Deploy to production', status: 'Completed' }, ] vi.mock('@/hooks/useTasks', () => ({ diff --git a/TaskFlow.Web/src/components/journal/TodosSection.test.tsx b/TaskFlow.Web/src/components/journal/TodosSection.test.tsx index 750dbbac..452131e8 100644 --- a/TaskFlow.Web/src/components/journal/TodosSection.test.tsx +++ b/TaskFlow.Web/src/components/journal/TodosSection.test.tsx @@ -39,7 +39,6 @@ const openTodo: TaskItemResponseDto = { id: 1, title: 'Write tests', status: 'Todo', - isComplete: false, priority: 'low', description: null, dueDate: null, @@ -49,7 +48,6 @@ const doneTodo: TaskItemResponseDto = { id: 2, title: 'Fix bug', status: 'Completed', - isComplete: false, priority: 'low', description: null, dueDate: null, @@ -94,7 +92,7 @@ describe('TodosSection', () => { }) it('todo with lowercase status "completed" does NOT get is-done class', () => { - const lowercaseDone: TaskItemResponseDto = { ...openTodo, id: 3, title: 'lowercase', status: 'completed' as never, isComplete: false } + const lowercaseDone: TaskItemResponseDto = { ...openTodo, id: 3, title: 'lowercase', status: 'completed' as never } vi.mocked(useJournalTodos).mockReturnValue({ data: { data: [lowercaseDone] }, isLoading: false } as never) renderSection() const li = screen.getByText('lowercase').closest('li') @@ -105,10 +103,51 @@ describe('TodosSection', () => { vi.mocked(useJournalTodos).mockReturnValue({ data: { data: [openTodo] }, isLoading: false } as never) renderSection() await userEvent.click(screen.getByRole('button', { name: /mark done/i })) - expect(toggleTodoMutate).toHaveBeenCalledWith( - expect.objectContaining({ id: 1, title: 'Write tests', done: true }), - expect.any(Object) - ) + expect(toggleTodoMutate).toHaveBeenCalledWith({ id: 1, done: true }, expect.any(Object)) + }) + + it('toggling a completed todo sends done=false with only the id', async () => { + vi.mocked(useJournalTodos).mockReturnValue({ data: { data: [doneTodo] }, isLoading: false } as never) + renderSection() + await userEvent.click(screen.getByRole('button', { name: /mark not done/i })) + expect(toggleTodoMutate).toHaveBeenCalledWith({ id: 2, done: false }, expect.any(Object)) + }) + + it('renaming a completed todo sends only id and title (no status)', async () => { + vi.mocked(useJournalTodos).mockReturnValue({ data: { data: [doneTodo] }, isLoading: false } as never) + renderSection() + await userEvent.dblClick(screen.getByText('Fix bug')) + const editInput = screen.getByDisplayValue('Fix bug') + await userEvent.clear(editInput) + await userEvent.type(editInput, 'Fix bug properly') + await userEvent.keyboard('{Enter}') + expect(editTodoMutate).toHaveBeenCalledTimes(1) + expect(editTodoMutate).toHaveBeenCalledWith({ id: 2, title: 'Fix bug properly' }) + expect(editTodoMutate.mock.calls[0][0]).not.toHaveProperty('status') + }) + + it('renaming a subtask sends only id and title (no parentTaskItemId)', async () => { + vi.mocked(useJournalTodos).mockReturnValue({ data: { data: [{ ...openTodo, children: [childTodo] }] }, isLoading: false } as never) + renderSection() + await userEvent.dblClick(screen.getByText('Sub A')) + const editInput = screen.getByDisplayValue('Sub A') + await userEvent.clear(editInput) + await userEvent.type(editInput, 'Sub B') + await userEvent.keyboard('{Enter}') + expect(editTodoMutate).toHaveBeenCalledWith({ id: 99, title: 'Sub B' }) + expect(editTodoMutate.mock.calls[0][0]).not.toHaveProperty('parentTaskItemId') + }) + + it('renaming a subtask whose parent is not in the loaded list (shown top-level) sends no parentTaskItemId', async () => { + // The child is not nested under any loaded todo, so the old code could not look it up and sent parentTaskItemId: null + vi.mocked(useJournalTodos).mockReturnValue({ data: { data: [childTodo] }, isLoading: false } as never) + renderSection() + await userEvent.dblClick(screen.getByText('Sub A')) + const editInput = screen.getByDisplayValue('Sub A') + await userEvent.clear(editInput) + await userEvent.type(editInput, 'Sub C') + await userEvent.keyboard('{Enter}') + expect(editTodoMutate).toHaveBeenCalledWith({ id: 99, title: 'Sub C' }) }) it('toggle rejected with TASK_REOPEN_PAST_DAY_NOT_ALLOWED shows the past-days message and task stays completed', async () => { @@ -266,7 +305,6 @@ describe('TodosSection', () => { id: 99, title: 'Sub A', status: 'Todo', - isComplete: false, priority: 'low', description: null, dueDate: null, @@ -338,7 +376,7 @@ describe('TodosSection', () => { // Click the child check button (second 'Mark done' button) await userEvent.click(checkButtons[1]) expect(toggleTodoMutate).toHaveBeenCalledWith( - expect.objectContaining({ autoCompleteParentWhenChildrenDone: false }), + { id: 99, done: true, autoCompleteParentWhenChildrenDone: false }, expect.any(Object) ) }) diff --git a/TaskFlow.Web/src/components/journal/TodosSection.tsx b/TaskFlow.Web/src/components/journal/TodosSection.tsx index 12e84a98..51aa992a 100644 --- a/TaskFlow.Web/src/components/journal/TodosSection.tsx +++ b/TaskFlow.Web/src/components/journal/TodosSection.tsx @@ -95,8 +95,8 @@ export const TodosSection = forwardRef(function Todos const arr = [...todos] if (sort === 'open first' || sort === 'done last') { arr.sort((a, b) => { - const aDone = a.status === 'Completed' || !!a.isComplete ? 1 : 0 - const bDone = b.status === 'Completed' || !!b.isComplete ? 1 : 0 + const aDone = a.status === 'Completed' ? 1 : 0 + const bDone = b.status === 'Completed' ? 1 : 0 return aDone - bDone }) } @@ -124,14 +124,12 @@ export const TodosSection = forwardRef(function Todos function toggle(todo: TaskItemResponseDto) { setActionError(null) - const isDone = todo.status === 'Completed' || !!todo.isComplete + const isDone = todo.status === 'Completed' const isChild = todo.parentTaskItemId != null toggleTodo.mutate( { id: Number(todo.id), - title: todo.title, done: !isDone, - parentTaskItemId: todo.parentTaskItemId ?? null, // Subtask completion must never auto-complete the parent in the journal UI ...(isChild && { autoCompleteParentWhenChildrenDone: false }), }, @@ -143,19 +141,7 @@ export const TodosSection = forwardRef(function Todos if (editingId == null) return const text = editingText.trim() if (text) { - // Search both top-level todos and their children for the task being edited - let current: TaskItemResponseDto | undefined = todos.find(t => Number(t.id) === editingId) - if (!current) { - for (const td of todos) { - current = td.children?.find(c => Number(c.id) === editingId) - if (current) break - } - } - editTodo.mutate({ - id: editingId, - title: text, - parentTaskItemId: current?.parentTaskItemId ?? null, - }) + editTodo.mutate({ id: editingId, title: text }) } setEditingId(null) setEditingText('') @@ -178,7 +164,7 @@ export const TodosSection = forwardRef(function Todos
  • No TODOs yet — add one below.
  • )} {sorted.map(td => { - const isDone = td.status === 'Completed' || !!td.isComplete + const isDone = td.status === 'Completed' return (