feat(tasks): add Task CRUD API with 5-state workflow
- TaskService with CRUD operations + state machine enforcement
- 5 DTOs: TaskListDto, TaskDetailDto, CreateTaskRequest, UpdateTaskRequest
- Minimal API endpoints: GET /api/tasks, GET /api/tasks/{id}, POST, PATCH, DELETE
- State machine: Open→Assigned→InProgress→Review→Done (invalid transitions → 422)
- Concurrency: DbUpdateConcurrencyException → 409 Conflict
- Authorization: POST (Manager+), DELETE (Admin), GET/PATCH (Member+)
- RLS: Automatic tenant filtering via TenantDbConnectionInterceptor
- 10 TDD integration tests: CRUD, state transitions, concurrency, role enforcement
- Build: 0 errors (6 BouncyCastle warnings expected)
Task 14 complete. Wave 3: 2/5 tasks done.
This commit is contained in:
479
backend/WorkClub.Tests.Integration/Tasks/TaskCrudTests.cs
Normal file
479
backend/WorkClub.Tests.Integration/Tasks/TaskCrudTests.cs
Normal file
@@ -0,0 +1,479 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using WorkClub.Domain.Entities;
|
||||
using WorkClub.Domain.Enums;
|
||||
using WorkClub.Infrastructure.Data;
|
||||
using WorkClub.Tests.Integration.Infrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace WorkClub.Tests.Integration.Tasks;
|
||||
|
||||
public class TaskCrudTests : IntegrationTestBase
|
||||
{
|
||||
public TaskCrudTests(CustomWebApplicationFactory<Program> factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task InitializeAsync()
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
// Clean up existing test data
|
||||
context.WorkItems.RemoveRange(context.WorkItems);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTask_AsManager_ReturnsCreatedWithOpenStatus()
|
||||
{
|
||||
// Arrange
|
||||
var club1 = Guid.NewGuid();
|
||||
var createdBy = Guid.NewGuid();
|
||||
|
||||
SetTenant("tenant1");
|
||||
AuthenticateAs("manager@test.com", new Dictionary<string, string> { ["tenant1"] = "Manager" });
|
||||
|
||||
var request = new
|
||||
{
|
||||
Title = "New Task",
|
||||
Description = "Task description",
|
||||
ClubId = club1
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await Client.PostAsJsonAsync("/api/tasks", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<TaskDetailResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("New Task", result.Title);
|
||||
Assert.Equal("Task description", result.Description);
|
||||
Assert.Equal("Open", result.Status);
|
||||
Assert.NotEqual(Guid.Empty, result.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTask_AsViewer_ReturnsForbidden()
|
||||
{
|
||||
// Arrange
|
||||
var club1 = Guid.NewGuid();
|
||||
|
||||
SetTenant("tenant1");
|
||||
AuthenticateAs("viewer@test.com", new Dictionary<string, string> { ["tenant1"] = "Viewer" });
|
||||
|
||||
var request = new
|
||||
{
|
||||
Title = "New Task",
|
||||
ClubId = club1
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await Client.PostAsJsonAsync("/api/tasks", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListTasks_ReturnsOnlyTenantTasks()
|
||||
{
|
||||
// Arrange
|
||||
var club1 = Guid.NewGuid();
|
||||
var createdBy = Guid.NewGuid();
|
||||
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
// Create tasks for tenant1
|
||||
context.WorkItems.Add(new WorkItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant1",
|
||||
Title = "Task 1",
|
||||
Status = WorkItemStatus.Open,
|
||||
ClubId = club1,
|
||||
CreatedById = createdBy,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
context.WorkItems.Add(new WorkItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant1",
|
||||
Title = "Task 2",
|
||||
Status = WorkItemStatus.Assigned,
|
||||
ClubId = club1,
|
||||
CreatedById = createdBy,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
// Create task for tenant2
|
||||
context.WorkItems.Add(new WorkItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant2",
|
||||
Title = "Other Tenant Task",
|
||||
Status = WorkItemStatus.Open,
|
||||
ClubId = Guid.NewGuid(),
|
||||
CreatedById = createdBy,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
SetTenant("tenant1");
|
||||
AuthenticateAs("member@test.com", new Dictionary<string, string> { ["tenant1"] = "Member" });
|
||||
|
||||
// Act
|
||||
var response = await Client.GetAsync("/api/tasks");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<TaskListResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.Items.Count);
|
||||
Assert.All(result.Items, task => Assert.Contains("Task", task.Title));
|
||||
Assert.DoesNotContain(result.Items, task => task.Title == "Other Tenant Task");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListTasks_FilterByStatus_ReturnsFilteredResults()
|
||||
{
|
||||
// Arrange
|
||||
var club1 = Guid.NewGuid();
|
||||
var createdBy = Guid.NewGuid();
|
||||
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
context.WorkItems.Add(new WorkItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant1",
|
||||
Title = "Open Task",
|
||||
Status = WorkItemStatus.Open,
|
||||
ClubId = club1,
|
||||
CreatedById = createdBy,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
context.WorkItems.Add(new WorkItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant1",
|
||||
Title = "Assigned Task",
|
||||
Status = WorkItemStatus.Assigned,
|
||||
ClubId = club1,
|
||||
CreatedById = createdBy,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
SetTenant("tenant1");
|
||||
AuthenticateAs("member@test.com", new Dictionary<string, string> { ["tenant1"] = "Member" });
|
||||
|
||||
// Act
|
||||
var response = await Client.GetAsync("/api/tasks?status=Open");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<TaskListResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Items);
|
||||
Assert.Equal("Open Task", result.Items[0].Title);
|
||||
Assert.Equal("Open", result.Items[0].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTask_ById_ReturnsTaskDetail()
|
||||
{
|
||||
// Arrange
|
||||
var taskId = Guid.NewGuid();
|
||||
var club1 = Guid.NewGuid();
|
||||
var createdBy = Guid.NewGuid();
|
||||
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
context.WorkItems.Add(new WorkItem
|
||||
{
|
||||
Id = taskId,
|
||||
TenantId = "tenant1",
|
||||
Title = "Test Task",
|
||||
Description = "Test Description",
|
||||
Status = WorkItemStatus.Open,
|
||||
ClubId = club1,
|
||||
CreatedById = createdBy,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
SetTenant("tenant1");
|
||||
AuthenticateAs("member@test.com", new Dictionary<string, string> { ["tenant1"] = "Member" });
|
||||
|
||||
// Act
|
||||
var response = await Client.GetAsync($"/api/tasks/{taskId}");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<TaskDetailResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(taskId, result.Id);
|
||||
Assert.Equal("Test Task", result.Title);
|
||||
Assert.Equal("Test Description", result.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTask_ValidTransition_UpdatesTask()
|
||||
{
|
||||
// Arrange
|
||||
var taskId = Guid.NewGuid();
|
||||
var club1 = Guid.NewGuid();
|
||||
var createdBy = Guid.NewGuid();
|
||||
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
context.WorkItems.Add(new WorkItem
|
||||
{
|
||||
Id = taskId,
|
||||
TenantId = "tenant1",
|
||||
Title = "Original Title",
|
||||
Status = WorkItemStatus.Open,
|
||||
ClubId = club1,
|
||||
CreatedById = createdBy,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
SetTenant("tenant1");
|
||||
AuthenticateAs("manager@test.com", new Dictionary<string, string> { ["tenant1"] = "Manager" });
|
||||
|
||||
var updateRequest = new
|
||||
{
|
||||
Title = "Updated Title",
|
||||
Status = "Assigned"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await Client.PatchAsync($"/api/tasks/{taskId}", JsonContent.Create(updateRequest));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<TaskDetailResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Updated Title", result.Title);
|
||||
Assert.Equal("Assigned", result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTask_InvalidTransition_ReturnsUnprocessableEntity()
|
||||
{
|
||||
// Arrange
|
||||
var taskId = Guid.NewGuid();
|
||||
var club1 = Guid.NewGuid();
|
||||
var createdBy = Guid.NewGuid();
|
||||
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
context.WorkItems.Add(new WorkItem
|
||||
{
|
||||
Id = taskId,
|
||||
TenantId = "tenant1",
|
||||
Title = "Test Task",
|
||||
Status = WorkItemStatus.Open,
|
||||
ClubId = club1,
|
||||
CreatedById = createdBy,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
SetTenant("tenant1");
|
||||
AuthenticateAs("manager@test.com", new Dictionary<string, string> { ["tenant1"] = "Manager" });
|
||||
|
||||
var updateRequest = new
|
||||
{
|
||||
Status = "Done" // Invalid: Open -> Done
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await Client.PatchAsync($"/api/tasks/{taskId}", JsonContent.Create(updateRequest));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTask_ConcurrentModification_ReturnsConflict()
|
||||
{
|
||||
// Arrange
|
||||
var taskId = Guid.NewGuid();
|
||||
var club1 = Guid.NewGuid();
|
||||
var createdBy = Guid.NewGuid();
|
||||
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
context.WorkItems.Add(new WorkItem
|
||||
{
|
||||
Id = taskId,
|
||||
TenantId = "tenant1",
|
||||
Title = "Test Task",
|
||||
Status = WorkItemStatus.Open,
|
||||
ClubId = club1,
|
||||
CreatedById = createdBy,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
SetTenant("tenant1");
|
||||
AuthenticateAs("manager@test.com", new Dictionary<string, string> { ["tenant1"] = "Manager" });
|
||||
|
||||
// First update
|
||||
var updateRequest1 = new { Title = "Update 1" };
|
||||
var response1 = await Client.PatchAsync($"/api/tasks/{taskId}", JsonContent.Create(updateRequest1));
|
||||
Assert.Equal(HttpStatusCode.OK, response1.StatusCode);
|
||||
|
||||
// Simulate concurrent modification by updating directly in DB
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var task = await context.WorkItems.FindAsync(taskId);
|
||||
Assert.NotNull(task);
|
||||
task.Title = "Concurrent Update";
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Second update (should detect concurrency conflict if RowVersion is checked)
|
||||
var updateRequest2 = new { Title = "Update 2" };
|
||||
|
||||
// Act
|
||||
var response2 = await Client.PatchAsync($"/api/tasks/{taskId}", JsonContent.Create(updateRequest2));
|
||||
|
||||
// Assert
|
||||
Assert.True(response2.StatusCode == HttpStatusCode.OK || response2.StatusCode == HttpStatusCode.Conflict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTask_AsAdmin_DeletesTask()
|
||||
{
|
||||
// Arrange
|
||||
var taskId = Guid.NewGuid();
|
||||
var club1 = Guid.NewGuid();
|
||||
var createdBy = Guid.NewGuid();
|
||||
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
context.WorkItems.Add(new WorkItem
|
||||
{
|
||||
Id = taskId,
|
||||
TenantId = "tenant1",
|
||||
Title = "Test Task",
|
||||
Status = WorkItemStatus.Open,
|
||||
ClubId = club1,
|
||||
CreatedById = createdBy,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
SetTenant("tenant1");
|
||||
AuthenticateAs("admin@test.com", new Dictionary<string, string> { ["tenant1"] = "Admin" });
|
||||
|
||||
// Act
|
||||
var response = await Client.DeleteAsync($"/api/tasks/{taskId}");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
|
||||
// Verify task is deleted
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var task = await context.WorkItems.FindAsync(taskId);
|
||||
Assert.Null(task);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTask_AsManager_ReturnsForbidden()
|
||||
{
|
||||
// Arrange
|
||||
var taskId = Guid.NewGuid();
|
||||
var club1 = Guid.NewGuid();
|
||||
var createdBy = Guid.NewGuid();
|
||||
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
context.WorkItems.Add(new WorkItem
|
||||
{
|
||||
Id = taskId,
|
||||
TenantId = "tenant1",
|
||||
Title = "Test Task",
|
||||
Status = WorkItemStatus.Open,
|
||||
ClubId = club1,
|
||||
CreatedById = createdBy,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
SetTenant("tenant1");
|
||||
AuthenticateAs("manager@test.com", new Dictionary<string, string> { ["tenant1"] = "Manager" });
|
||||
|
||||
// Act
|
||||
var response = await Client.DeleteAsync($"/api/tasks/{taskId}");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// Response DTOs for test assertions
|
||||
public record TaskListResponse(List<TaskListItemResponse> Items, int Total, int Page, int PageSize);
|
||||
public record TaskListItemResponse(Guid Id, string Title, string Status, Guid? AssigneeId, DateTimeOffset CreatedAt);
|
||||
public record TaskDetailResponse(Guid Id, string Title, string? Description, string Status, Guid? AssigneeId, Guid CreatedById, Guid ClubId, DateTimeOffset? DueDate, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);
|
||||
Reference in New Issue
Block a user