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
6 changes: 3 additions & 3 deletions .claude/agents/backend-builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ color: green
---
You are a senior .NET backend engineer with deep expertise in the modern .NET 10 ecosystem. You produce production-quality backend features in layered ASP.NET Core APIs. You work exclusively on backend code and never touch frontend files.

Your expertise spans: .NET 10, C# 13, ASP.NET Core minimal APIs and controller-based APIs, EF Core 9, FluentValidation 11, OpenTelemetry, xUnit/Moq/FluentAssertions, Scalar/OpenAPI, and health check infrastructure. You apply idiomatic modern C# — primary constructors, collection expressions, pattern matching, required members, and `async`/`await` throughout.
Your expertise spans: .NET 10, C# 13, ASP.NET Core minimal APIs and controller-based APIs, EF Core 9, FluentValidation 11, OpenTelemetry, xUnit/Moq, Scalar/OpenAPI, and health check infrastructure. You apply idiomatic modern C# — primary constructors, collection expressions, pattern matching, required members, and `async`/`await` throughout.

## Project Context

You are working in the TaskFlow repository, a .NET 10 API with the following stack and conventions:

**Stack:** .NET 10, EF Core + SQLite, FluentValidation, OpenTelemetry → Seq, xUnit/Moq/FluentAssertions, Scalar/OpenAPI
**Stack:** .NET 10, EF Core + SQLite, FluentValidation, OpenTelemetry → Seq, xUnit/Moq, Scalar/OpenAPI

**Layered flow:** `Controller → Repository → EF Core (SQLite)`

Expand Down Expand Up @@ -156,7 +156,7 @@ Notes:
**Tests:**
- Repository tests: use `DbContextOptions` with `UseInMemoryDatabase`; use real SQLite for constraint tests
- Controller tests: use Moq to mock repository interfaces; assert HTTP status codes and response bodies
- Use FluentAssertions (`result.Should().Be(...)`, `result.Should().BeEquivalentTo(...)`, etc.)
- Use xUnit built-in assertions (`Assert.Equal(expected, actual)`, `Assert.IsType<T>(...)`, `Assert.Empty(...)`, `Assert.Throws<T>(...)`, etc.); do not add Fluent-style assertion libraries
- Aim for full branch coverage of new code; CI requires 75% line coverage overall
- Test naming: `MethodName_Scenario_ExpectedBehavior`

Expand Down
2 changes: 1 addition & 1 deletion .claude/agents/test-verifier.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ If any of 1–4 are missing, ask for them before proceeding.
## Test Conventions

### Backend (xUnit)
- xUnit (`[Fact]`, `[Theory]`, `[InlineData]`), Moq, FluentAssertions.
- xUnit (`[Fact]`, `[Theory]`, `[InlineData]`), Moq, with xUnit `Assert.*` for assertions.
- Tests mirror the main project: `Controllers/V1/`, `Repositories/`, `Validators/`, `HealthChecks/`, `Extensions/` under `TaskFlow.Api.Tests/`. Extend an existing test file for the same class instead of creating a parallel one.
- Repository tests use `Microsoft.EntityFrameworkCore.InMemory` by default; use real in-memory SQLite where constraint enforcement matters (e.g., unique index tests).
- Controller tests mock repositories with Moq and assert status codes and response bodies.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ TaskFlow/
| Validation | FluentValidation |
| Observability | OpenTelemetry (traces, metrics, logs → Seq) |
| API docs | OpenAPI + Scalar UI |
| Testing | xUnit, Moq, FluentAssertions, Coverlet |
| Testing | xUnit, Moq, Coverlet |
| Deployment | Docker, Portainer GitOps |
| CI/CD | GitHub Actions |

Expand Down
194 changes: 96 additions & 98 deletions TaskFlow.Api.Tests/Controllers/V1/JournalEntriesControllerV1Tests.cs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using FluentAssertions;
using FluentValidation;
using FluentValidation.Results;
using Microsoft.AspNetCore.Mvc;
Expand Down Expand Up @@ -45,7 +44,7 @@ public async Task GetAll_ShouldReturnNotFound_WhenEntryMissing()

var result = await _controller.GetAll(99);

result.Result.Should().BeOfType<NotFoundResult>();
Assert.IsType<NotFoundResult>(result.Result);
}

[Fact]
Expand All @@ -57,10 +56,10 @@ public async Task Get_ShouldReturnOk_WhenFound()

var result = await _controller.Get(1, 2);

var ok = result.Result.Should().BeOfType<OkObjectResult>().Subject;
var dto = ok.Value.Should().BeOfType<JournalLogEntryResponseDto>().Subject;
dto.Id.Should().Be(2);
dto.JournalEntryId.Should().Be(1);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var dto = Assert.IsType<JournalLogEntryResponseDto>(ok.Value);
Assert.Equal(2, dto.Id);
Assert.Equal(1, dto.JournalEntryId);
}

// ── CREATE ────────────────────────────────────────────────────────────────
Expand All @@ -74,7 +73,7 @@ public async Task Create_ShouldReturnBadRequest_WhenValidationFails()

var result = await _controller.Create(1, new CreateJournalLogEntryDto { Content = string.Empty });

result.Result.Should().BeOfType<BadRequestObjectResult>();
Assert.IsType<BadRequestObjectResult>(result.Result);
_logRepo.Verify(s => s.AddAsync(It.IsAny<JournalLogEntry>()), Times.Never);
}

Expand All @@ -88,9 +87,10 @@ public async Task Create_ShouldReturnCreated_WhenValid()

var result = await _controller.Create(1, new CreateJournalLogEntryDto { Content = "Done" });

var created = result.Result.Should().BeOfType<CreatedAtRouteResult>().Subject;
created.RouteName.Should().Be("GetJournalLogEntryV1");
created.RouteValues.Should().ContainKey("id").WhoseValue.Should().Be(3);
var created = Assert.IsType<CreatedAtRouteResult>(result.Result);
Assert.Equal("GetJournalLogEntryV1", created.RouteName);
Assert.True(created.RouteValues!.TryGetValue("id", out var createdId));
Assert.Equal(3, createdId);
}

[Fact]
Expand All @@ -103,10 +103,10 @@ public async Task Create_ShouldSucceed_WhenTaskItemIdOmitted()

var result = await _controller.Create(1, new CreateJournalLogEntryDto { Content = "Note" });

var created = result.Result.Should().BeOfType<CreatedAtRouteResult>().Subject;
var dto = created.Value.Should().BeOfType<JournalLogEntryResponseDto>().Subject;
dto.TaskItemId.Should().BeNull();
dto.LinkedTaskDeleted.Should().BeFalse();
var created = Assert.IsType<CreatedAtRouteResult>(result.Result);
var dto = Assert.IsType<JournalLogEntryResponseDto>(created.Value);
Assert.Null(dto.TaskItemId);
Assert.False(dto.LinkedTaskDeleted);
_taskRepo.Verify(r => r.GetByIdAsync(It.IsAny<int>()), Times.Never);
}

Expand All @@ -128,11 +128,11 @@ public async Task Create_ShouldLinkTask_WhenTaskItemIdValid()

var result = await _controller.Create(1, new CreateJournalLogEntryDto { Content = "Did work", TaskItemId = 10 });

var created = result.Result.Should().BeOfType<CreatedAtRouteResult>().Subject;
var dto = created.Value.Should().BeOfType<JournalLogEntryResponseDto>().Subject;
dto.TaskItemId.Should().Be(10);
dto.LinkedTaskTitle.Should().Be("Build feature");
dto.LinkedTaskDeleted.Should().BeFalse();
var created = Assert.IsType<CreatedAtRouteResult>(result.Result);
var dto = Assert.IsType<JournalLogEntryResponseDto>(created.Value);
Assert.Equal(10, dto.TaskItemId);
Assert.Equal("Build feature", dto.LinkedTaskTitle);
Assert.False(dto.LinkedTaskDeleted);
}

[Fact]
Expand All @@ -144,7 +144,7 @@ public async Task Create_ShouldReturnBadRequest_WhenTaskItemIdNotFound()

var result = await _controller.Create(1, new CreateJournalLogEntryDto { Content = "Work", TaskItemId = 99 });

result.Result.Should().BeOfType<BadRequestObjectResult>();
Assert.IsType<BadRequestObjectResult>(result.Result);
_logRepo.Verify(s => s.AddAsync(It.IsAny<JournalLogEntry>()), Times.Never);
}

Expand All @@ -157,7 +157,7 @@ public async Task Update_ShouldReturnNotFound_WhenEntryMissing()

var result = await _controller.Update(99, 1, new UpdateJournalLogEntryDto { Content = "x" });

result.Result.Should().BeOfType<NotFoundResult>();
Assert.IsType<NotFoundResult>(result.Result);
}

[Fact]
Expand All @@ -168,7 +168,7 @@ public async Task Update_ShouldReturnNotFound_WhenLogMissing()

var result = await _controller.Update(1, 22, new UpdateJournalLogEntryDto { Content = "x" });

result.Result.Should().BeOfType<NotFoundResult>();
Assert.IsType<NotFoundResult>(result.Result);
}

[Fact]
Expand All @@ -182,7 +182,7 @@ public async Task Update_ShouldReturnBadRequest_WhenTaskItemIdNotFound()

var result = await _controller.Update(1, 3, new UpdateJournalLogEntryDto { Content = "New", TaskItemId = 42 });

result.Result.Should().BeOfType<BadRequestObjectResult>();
Assert.IsType<BadRequestObjectResult>(result.Result);
_logRepo.Verify(r => r.UpdateAsync(It.IsAny<JournalLogEntry>()), Times.Never);
}

Expand All @@ -198,11 +198,11 @@ public async Task Update_ShouldChangeLink_WhenTaskItemIdValid()

var result = await _controller.Update(1, 3, new UpdateJournalLogEntryDto { Content = "Updated", TaskItemId = 20 });

var ok = result.Result.Should().BeOfType<OkObjectResult>().Subject;
var dto = ok.Value.Should().BeOfType<JournalLogEntryResponseDto>().Subject;
dto.TaskItemId.Should().Be(20);
dto.LinkedTaskTitle.Should().Be("New Task");
dto.LinkedTaskDeleted.Should().BeFalse();
var ok = Assert.IsType<OkObjectResult>(result.Result);
var dto = Assert.IsType<JournalLogEntryResponseDto>(ok.Value);
Assert.Equal(20, dto.TaskItemId);
Assert.Equal("New Task", dto.LinkedTaskTitle);
Assert.False(dto.LinkedTaskDeleted);
_logRepo.Verify(r => r.UpdateAsync(existing), Times.Once);
}

Expand All @@ -223,11 +223,11 @@ public async Task Update_ShouldClearLink_WhenTaskItemIdIsNull()

var result = await _controller.Update(1, 3, new UpdateJournalLogEntryDto { Content = "Updated" });

var ok = result.Result.Should().BeOfType<OkObjectResult>().Subject;
var dto = ok.Value.Should().BeOfType<JournalLogEntryResponseDto>().Subject;
dto.TaskItemId.Should().BeNull();
dto.LinkedTaskTitle.Should().BeNull();
dto.LinkedTaskDeleted.Should().BeFalse();
var ok = Assert.IsType<OkObjectResult>(result.Result);
var dto = Assert.IsType<JournalLogEntryResponseDto>(ok.Value);
Assert.Null(dto.TaskItemId);
Assert.Null(dto.LinkedTaskTitle);
Assert.False(dto.LinkedTaskDeleted);
_taskRepo.Verify(r => r.GetByIdAsync(It.IsAny<int>()), Times.Never);
}

Expand All @@ -242,7 +242,7 @@ public async Task Delete_ShouldReturnNoContent_WhenFound()

var result = await _controller.Delete(1, 4);

result.Should().BeOfType<NoContentResult>();
Assert.IsType<NoContentResult>(result);
_logRepo.Verify(s => s.DeleteAsync(1, 4), Times.Once);
}
}
50 changes: 25 additions & 25 deletions TaskFlow.Api.Tests/Controllers/V1/JournalNotesControllerV1Tests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using FluentAssertions;
using FluentValidation;
using FluentValidation.Results;
using Microsoft.AspNetCore.Mvc;
Expand Down Expand Up @@ -39,9 +38,9 @@ public async Task GetAll_ShouldReturn200_WithNotes_WhenEntryExists()

var result = await _controller.GetAll(1);

var ok = result.Result.Should().BeOfType<OkObjectResult>().Subject;
var dtos = ok.Value.Should().BeAssignableTo<IEnumerable<JournalNoteResponseDto>>().Subject;
dtos.Should().HaveCount(2);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var dtos = Assert.IsAssignableFrom<IEnumerable<JournalNoteResponseDto>>(ok.Value);
Assert.Equal(2, dtos.Count());
}

[Fact]
Expand All @@ -51,7 +50,7 @@ public async Task GetAll_ShouldReturn404_WhenEntryMissing()

var result = await _controller.GetAll(99);

result.Result.Should().BeOfType<NotFoundResult>();
Assert.IsType<NotFoundResult>(result.Result);
}

// ── GetById ─────────────────────────────────────────────────────────────
Expand All @@ -64,10 +63,10 @@ public async Task GetById_ShouldReturn200_WhenFound()

var result = await _controller.Get(1, 5);

var ok = result.Result.Should().BeOfType<OkObjectResult>().Subject;
var dto = ok.Value.Should().BeOfType<JournalNoteResponseDto>().Subject;
dto.Id.Should().Be(5);
dto.JournalEntryId.Should().Be(1);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var dto = Assert.IsType<JournalNoteResponseDto>(ok.Value);
Assert.Equal(5, dto.Id);
Assert.Equal(1, dto.JournalEntryId);
}

[Fact]
Expand All @@ -78,7 +77,7 @@ public async Task GetById_ShouldReturn404_WhenNoteMissing()

var result = await _controller.Get(1, 99);

result.Result.Should().BeOfType<NotFoundResult>();
Assert.IsType<NotFoundResult>(result.Result);
}

[Fact]
Expand All @@ -88,7 +87,7 @@ public async Task GetById_ShouldReturn404_WhenEntryMissing()

var result = await _controller.Get(99, 1);

result.Result.Should().BeOfType<NotFoundResult>();
Assert.IsType<NotFoundResult>(result.Result);
}

// ── Create ──────────────────────────────────────────────────────────────
Expand All @@ -104,9 +103,10 @@ public async Task Create_ShouldReturn201_WhenValid()

var result = await _controller.Create(1, new CreateJournalNoteDto { Content = "A note" });

var created = result.Result.Should().BeOfType<CreatedAtRouteResult>().Subject;
created.RouteName.Should().Be("GetJournalNoteV1");
created.RouteValues.Should().ContainKey("id").WhoseValue.Should().Be(7);
var created = Assert.IsType<CreatedAtRouteResult>(result.Result);
Assert.Equal("GetJournalNoteV1", created.RouteName);
Assert.True(created.RouteValues!.TryGetValue("id", out var createdId));
Assert.Equal(7, createdId);
}

[Fact]
Expand All @@ -116,7 +116,7 @@ public async Task Create_ShouldReturn404_WhenEntryMissing()

var result = await _controller.Create(99, new CreateJournalNoteDto { Content = "x" });

result.Result.Should().BeOfType<NotFoundResult>();
Assert.IsType<NotFoundResult>(result.Result);
_noteRepo.Verify(s => s.AddAsync(It.IsAny<JournalNote>()), Times.Never);
}

Expand All @@ -129,7 +129,7 @@ public async Task Create_ShouldReturn400_WhenContentIsEmpty()

var result = await _controller.Create(1, new CreateJournalNoteDto { Content = string.Empty });

result.Result.Should().BeOfType<BadRequestObjectResult>();
Assert.IsType<BadRequestObjectResult>(result.Result);
_noteRepo.Verify(s => s.AddAsync(It.IsAny<JournalNote>()), Times.Never);
}

Expand All @@ -145,9 +145,9 @@ public async Task Update_ShouldReturn200_WhenValid()

var result = await _controller.Update(1, 3, new UpdateJournalNoteDto { Content = "Updated" });

var ok = result.Result.Should().BeOfType<OkObjectResult>().Subject;
var dto = ok.Value.Should().BeOfType<JournalNoteResponseDto>().Subject;
dto.Content.Should().Be("Updated");
var ok = Assert.IsType<OkObjectResult>(result.Result);
var dto = Assert.IsType<JournalNoteResponseDto>(ok.Value);
Assert.Equal("Updated", dto.Content);
_noteRepo.Verify(s => s.UpdateAsync(It.IsAny<JournalNote>()), Times.Once);
}

Expand All @@ -158,7 +158,7 @@ public async Task Update_ShouldReturn404_WhenEntryMissing()

var result = await _controller.Update(99, 1, new UpdateJournalNoteDto { Content = "x" });

result.Result.Should().BeOfType<NotFoundResult>();
Assert.IsType<NotFoundResult>(result.Result);
}

[Fact]
Expand All @@ -169,7 +169,7 @@ public async Task Update_ShouldReturn404_WhenNoteMissing()

var result = await _controller.Update(1, 99, new UpdateJournalNoteDto { Content = "x" });

result.Result.Should().BeOfType<NotFoundResult>();
Assert.IsType<NotFoundResult>(result.Result);
}

[Fact]
Expand All @@ -182,7 +182,7 @@ public async Task Update_ShouldReturn400_WhenContentIsEmpty()

var result = await _controller.Update(1, 3, new UpdateJournalNoteDto { Content = string.Empty });

result.Result.Should().BeOfType<BadRequestObjectResult>();
Assert.IsType<BadRequestObjectResult>(result.Result);
_noteRepo.Verify(s => s.UpdateAsync(It.IsAny<JournalNote>()), Times.Never);
}

Expand All @@ -196,7 +196,7 @@ public async Task Delete_ShouldReturn204_WhenFound()

var result = await _controller.Delete(1, 4);

result.Should().BeOfType<NoContentResult>();
Assert.IsType<NoContentResult>(result);
_noteRepo.Verify(s => s.DeleteAsync(1, 4), Times.Once);
}

Expand All @@ -207,7 +207,7 @@ public async Task Delete_ShouldReturn404_WhenEntryMissing()

var result = await _controller.Delete(99, 1);

result.Should().BeOfType<NotFoundResult>();
Assert.IsType<NotFoundResult>(result);
_noteRepo.Verify(s => s.DeleteAsync(It.IsAny<int>(), It.IsAny<int>()), Times.Never);
}

Expand All @@ -219,7 +219,7 @@ public async Task Delete_ShouldReturn404_WhenNoteMissing()

var result = await _controller.Delete(1, 99);

result.Should().BeOfType<NotFoundResult>();
Assert.IsType<NotFoundResult>(result);
_noteRepo.Verify(s => s.DeleteAsync(It.IsAny<int>(), It.IsAny<int>()), Times.Never);
}
}
Loading
Loading