Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
22 changes: 19 additions & 3 deletions TaskFlow.Api.Tests/Controllers/V1/ProjectsControllerV1Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
]
};
Expand All @@ -46,8 +46,8 @@ public async Task GetAll_ShouldReturnProjectsWithTaskCounts()

var ok = Assert.IsType<OkObjectResult>(result.Result);
var dto = Assert.IsAssignableFrom<IEnumerable<ProjectResponseDto>>(ok.Value).Single();
Assert.Equal(1, dto.OpenTaskCount);
Assert.Equal(2, dto.CompletedTaskCount);
Assert.Equal(2, dto.OpenTaskCount);
Assert.Equal(1, dto.CompletedTaskCount);
}

[Fact]
Expand Down Expand Up @@ -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<OkObjectResult>(result.Result);
var dto = Assert.IsAssignableFrom<IEnumerable<TaskItemResponseDto>>(ok.Value).Single();
Assert.Equal("Completed", dto.Status);
Assert.Null(typeof(TaskItemResponseDto).GetProperty("IsComplete"));
}

[Fact]
public async Task Create_ShouldReturnCreated_WhenValid()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Proves a legacy client's stale <c>isComplete</c> is ignored (never a 400) on POST, PUT and PATCH.
/// The raw JSON is deserialized with the MVC <see cref="MvcJsonOptions"/> resolved from a service collection
/// built with the app's own <c>AddControllers()</c> + <c>ConfigureJsonSerialization()</c>, 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.
/// </summary>
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<IOptions<MvcJsonOptions>>().Value.JsonSerializerOptions;
}

private sealed class Stack : IDisposable
{
private readonly SqliteConnection _connection = new("DataSource=:memory:");

public Stack()
{
_connection.Open();
Context = new TaskDbContext(new DbContextOptionsBuilder<TaskDbContext>().UseSqlite(_connection).Options);
Context.Database.EnsureCreated();
Controller = new TaskItemsController(
new TaskRepository(Context),
new TaskItemValidator(),
new JournalEntryRepository(Context),
new Mock<IProjectRepository>().Object);
}

public TaskDbContext Context { get; }
public TaskItemsController Controller { get; }

public async Task<int> 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<Status> 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<CreateTaskItemDto>(json, AppMvcJsonOptions())!;

var result = await stack.Controller.Create(dto);

var created = Assert.IsType<CreatedAtRouteResult>(result.Result);
var body = Assert.IsType<TaskItemResponseDto>(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<UpdateTaskItemDto>(json, AppMvcJsonOptions())!;

var result = await stack.Controller.Update(id, dto);

Assert.IsType<OkObjectResult>(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<PatchTaskItemDto>(json, AppMvcJsonOptions())!;

var result = await stack.Controller.Patch(id, dto);

Assert.IsType<OkObjectResult>(result.Result);
Assert.Equal(expected, await stack.StatusOfAsync(id));
}
}
Loading
Loading