Skip to content

Commit 0d9b0e6

Browse files
committed
test: cover the API end to end with Testcontainers integration tests
Boot the real Program.cs through WebApplicationFactory against a SQL Server started by Testcontainers, so requests exercise the actual middleware pipeline, DI graph, EF Core SQL Server provider and migrations instead of substitutes. Respawn truncates the schema between tests while a single container is shared by the whole suite. The 48 tests cover JWT issuing and validation (wrong signing key, expired and tampered tokens), [Authorize] and role policies, per-user data isolation on transactions, the HTTP error contract, and the database constraints the InMemory provider cannot enforce: the unique index on category slugs, the transaction foreign keys, ON DELETE CASCADE and decimal(18,2) round-tripping. Running them exposed four defects that the service-level unit tests could not see, fixed here: - [ApiController] rejects invalid models before the action runs, so every `if (!ModelState.IsValid)` block was dead code and validation failures came back as RFC 7807 ProblemDetails while every other error used the ResultViewModel envelope. Shape them centrally through InvalidModelStateResponseFactory and drop the unreachable branches. - Registering a duplicate email returned 500: the controller caught only DbUpdateException while the service throws InvalidOperationException. - Updating a category to a slug owned by another category returned 500 when the unique index rejected it; validate the slug and answer 400. - Creating or updating a transaction with an unknown CategoryId returned 500 on the foreign key; check the category up front and answer 400. Also make Program public so the test host can reference it, and drop the unused MSTest.TestFramework reference from the API project.
1 parent 357742c commit 0d9b0e6

20 files changed

Lines changed: 1094 additions & 38 deletions

README.md

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ business-management system (sales, products, payment methods).
2121
- Password hashing via ASP.NET Core `PasswordHasher` (PBKDF2)
2222
- FluentValidation-style data annotations
2323
- Swagger / OpenAPI
24-
- xUnit + FluentAssertions + EF Core InMemory (unit tests)
24+
- xUnit + FluentAssertions — unit tests on EF Core InMemory, integration tests
25+
against a real SQL Server started by Testcontainers
2526
- Docker Compose for the database
2627

2728
**Frontend** (`Trust-Finance.Web/`)
@@ -46,6 +47,9 @@ Trust-Finance.Api/ ASP.NET Core Web API
4647
Extensions/ ModelState + ClaimsPrincipal helpers
4748
Migrations/ EF Core migrations
4849
Trust-Finance.Tests/ xUnit unit tests for the service layer
50+
Trust-Finance.IntegrationTests/
51+
Infrastructure/ Testcontainers + WebApplicationFactory harness
52+
Api/ HTTP-level tests per endpoint group
4953
Trust-Finance.Web/ React + TypeScript SPA (see its own README)
5054
```
5155

@@ -120,13 +124,37 @@ so no CORS configuration is required locally.
120124
## Tests
121125

122126
```bash
123-
dotnet test
127+
dotnet test # everything
128+
dotnet test Trust-Finance.Tests # unit tests only
129+
dotnet test Trust-Finance.IntegrationTests # integration tests only
124130
```
125131

126-
Unit tests cover the service layer (business rules such as rejecting duplicate
132+
**Unit tests** cover the service layer (business rules such as rejecting duplicate
127133
emails and duplicate category slugs) and run against EF Core InMemory, so they
128134
do not need SQL Server.
129135

136+
**Integration tests** boot the API through `WebApplicationFactory<Program>` — the
137+
same `Program.cs` that runs in production — against a throwaway SQL Server that
138+
[Testcontainers](https://testcontainers.com/) starts for the test run. Nothing is
139+
mocked or substituted: requests go through the real middleware pipeline, the real
140+
DI graph, the real EF Core SQL Server provider and the real migrations. A single
141+
container is shared across the suite and [Respawn](https://github.com/jbogard/Respawn)
142+
truncates every table between tests.
143+
144+
They cover what only shows up once the whole stack is wired together:
145+
146+
- JWT issuing and validation — wrong signing key, expired and tampered tokens
147+
- `[Authorize]` and role policies — 401 for anonymous callers, 403 for a
148+
non-admin reaching the admin area
149+
- Per-user data isolation — one user cannot read, update or delete another
150+
user's transactions
151+
- Database constraints the InMemory provider does not enforce — the unique index
152+
on category slugs, the transaction foreign keys, `ON DELETE CASCADE`, and
153+
`decimal(18,2)` round-tripping
154+
- The HTTP error contract — status codes and the `ResultViewModel` envelope
155+
156+
The only requirement is a running Docker daemon.
157+
130158
---
131159

132160
## Roadmap
@@ -135,4 +163,5 @@ do not need SQL Server.
135163
- Multi-tenancy (organizations, per-org roles, EF Core global query filters)
136164
- Sales module (products, sale items with frozen prices, payment methods)
137165
- Server-side reporting endpoints with pagination and date filtering
138-
- Integration tests with Testcontainers, global error handling, CI/CD to Azure
166+
- Global exception-handling middleware to replace the per-controller try/catch
167+
- CI/CD to Azure

Trust-Finance.Api/Controllers/AccountController.cs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
using Microsoft.AspNetCore.Mvc;
22
using Microsoft.EntityFrameworkCore;
3-
using TF.Extensions;
43
using TF.Models;
54
using TF.Services;
65
using TF.ViewModels;
@@ -16,14 +15,16 @@ public async Task<IActionResult> Register(
1615
[FromBody] RegisterUserViewModel model,
1716
[FromServices] AccountService service)
1817
{
19-
if (!ModelState.IsValid)
20-
return BadRequest(new ResultViewModel<User>(ModelState.GetErrors()));
21-
2218
try
2319
{
2420
var user = await service.RegisterAsync(model);
2521
return Created($"api/users/{user.Id}", new ResultViewModel<User>(user));
2622
}
23+
catch (InvalidOperationException e)
24+
{
25+
// Broken business rule (e.g. the email is already taken).
26+
return BadRequest(new ResultViewModel<User>(e.Message));
27+
}
2728
catch (DbUpdateException e)
2829
{
2930
return BadRequest(new ResultViewModel<User>(e.Message));
@@ -36,9 +37,6 @@ public async Task<IActionResult> Login(
3637
[FromServices] AccountService service,
3738
[FromServices] TokenService tokenService)
3839
{
39-
if (!ModelState.IsValid)
40-
return BadRequest(new ResultViewModel<string>(ModelState.GetErrors()));
41-
4240
try
4341
{
4442
var token = await service.LoginAsync(model, tokenService);

Trust-Finance.Api/Controllers/CategoryController.cs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
using Microsoft.AspNetCore.Authorization;
22
using Microsoft.AspNetCore.Mvc;
3-
using TF.Extensions;
43
using TF.Models;
54
using TF.ViewModels;
65
using Trust_Finance.Services;
@@ -33,9 +32,6 @@ public async Task<IActionResult> Post(
3332
[FromBody] EditorCategoryViewModel model,
3433
[FromServices] CategoryService service)
3534
{
36-
if (!ModelState.IsValid)
37-
return BadRequest(new ResultViewModel<Category>(ModelState.GetErrors()));
38-
3935
try
4036
{
4137
var category = await service.CreateAsync(model.Name, model.Slug);
@@ -55,9 +51,6 @@ public async Task<IActionResult> Put(
5551
[FromBody] EditorCategoryViewModel model,
5652
[FromServices] CategoryService service)
5753
{
58-
if (!ModelState.IsValid)
59-
return BadRequest(new ResultViewModel<Category>(ModelState.GetErrors()));
60-
6154
try
6255
{
6356
var category = await service.UpdateAsync(id, model.Name, model.Slug);
@@ -67,6 +60,10 @@ public async Task<IActionResult> Put(
6760
{
6861
return NotFound(new ResultViewModel<Category>("Category not found"));
6962
}
63+
catch (InvalidOperationException ex)
64+
{
65+
return BadRequest(new ResultViewModel<Category>(ex.Message));
66+
}
7067
}
7168

7269
[HttpDelete("{id:int}")]

Trust-Finance.Api/Controllers/TransactionController.cs

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -40,21 +40,25 @@ public async Task<IActionResult> Post(
4040
[FromBody] EditorTransactionViewModel model,
4141
[FromServices] TransactionService service)
4242
{
43-
if (!ModelState.IsValid)
44-
return BadRequest(new ResultViewModel<Transaction>(ModelState.GetErrors()));
45-
4643
var userId = User.GetUserId();
4744

48-
var transaction = await service.CreateAsync(
49-
model.Description,
50-
model.Amount,
51-
model.Date,
52-
model.CategoryId,
53-
userId);
45+
try
46+
{
47+
var transaction = await service.CreateAsync(
48+
model.Description,
49+
model.Amount,
50+
model.Date,
51+
model.CategoryId,
52+
userId);
5453

55-
return Created(
56-
$"api/transactions/{transaction.Id}",
57-
new ResultViewModel<Transaction>(transaction));
54+
return Created(
55+
$"api/transactions/{transaction.Id}",
56+
new ResultViewModel<Transaction>(transaction));
57+
}
58+
catch (InvalidOperationException ex)
59+
{
60+
return BadRequest(new ResultViewModel<Transaction>(ex.Message));
61+
}
5862
}
5963

6064
[HttpPut("{id:int}")]
@@ -63,9 +67,6 @@ public async Task<IActionResult> Put(
6367
[FromBody] EditorTransactionViewModel model,
6468
[FromServices] TransactionService service)
6569
{
66-
if (!ModelState.IsValid)
67-
return BadRequest(new ResultViewModel<Transaction>(ModelState.GetErrors()));
68-
6970
try
7071
{
7172
var userId = User.GetUserId();
@@ -84,6 +85,10 @@ public async Task<IActionResult> Put(
8485
{
8586
return NotFound(new ResultViewModel<Transaction>("Transaction not found"));
8687
}
88+
catch (InvalidOperationException ex)
89+
{
90+
return BadRequest(new ResultViewModel<Transaction>(ex.Message));
91+
}
8792
}
8893

8994
[HttpDelete("{id:int}")]

Trust-Finance.Api/Controllers/UserController.cs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ namespace TF.Controllers
55
using TF.Models;
66
using TF.Data;
77
using TF.ViewModels;
8-
using TF.Extensions;
98
using Microsoft.AspNetCore.Authorization;
109

1110
[Route("api/[controller]")]
@@ -58,9 +57,6 @@ public async Task<IActionResult> PutAsync(
5857
[FromBody] RegisterUserViewModel model,
5958
[FromServices] TFDataContext context)
6059
{
61-
if (!ModelState.IsValid)
62-
return BadRequest(new ResultViewModel<User>(ModelState.GetErrors()));
63-
6460
try
6561
{
6662
var user = await context.Users.FirstOrDefaultAsync(x => x.Id == id);

Trust-Finance.Api/Program.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
using TF.Data;
22
using Microsoft.EntityFrameworkCore;
33
using TF.Services;
4+
using TF.Extensions;
5+
using TF.ViewModels;
46
using Microsoft.AspNetCore.Authentication.JwtBearer;
7+
using Microsoft.AspNetCore.Mvc;
58
using Microsoft.IdentityModel.Tokens;
69
using System.Text;
710

@@ -36,6 +39,15 @@
3639
builder.Services.AddControllers();
3740
builder.Services.AddEndpointsApiExplorer();
3841

42+
// [ApiController] rejects invalid models before the action ever runs, so validation
43+
// failures have to be shaped here. Without this they come back as RFC 7807
44+
// ProblemDetails while every other error uses the ResultViewModel envelope.
45+
builder.Services.Configure<ApiBehaviorOptions>(options =>
46+
{
47+
options.InvalidModelStateResponseFactory = context =>
48+
new BadRequestObjectResult(new ResultViewModel<string>(context.ModelState.GetErrors()));
49+
});
50+
3951
// Swagger
4052
builder.Services.AddSwaggerGen(c =>
4153
{
@@ -102,3 +114,7 @@
102114
app.UseAuthorization();
103115
app.MapControllers();
104116
app.Run();
117+
118+
// Top-level statements compile into an internal Program class. Making it public
119+
// lets WebApplicationFactory<Program> boot this exact pipeline in integration tests.
120+
public partial class Program { }

Trust-Finance.Api/Services/CategoryService.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ public async Task<Category> UpdateAsync(int id, string name, string slug)
4646
var category = await GetByIdAsync(id)
4747
?? throw new KeyNotFoundException("Category not found");
4848

49+
// Slug is backed by a unique index; without this check the update surfaces as an
50+
// unhandled DbUpdateException instead of a 400.
51+
var slugTaken = await _context
52+
.Categories
53+
.AsNoTracking()
54+
.AnyAsync(x => x.Slug == slug && x.Id != id);
55+
56+
if (slugTaken)
57+
throw new InvalidOperationException("Slug already exists");
58+
4959
category.Name = name;
5060
category.Slug = slug;
5161

Trust-Finance.Api/Services/TransactionService.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ public async Task<Transaction> CreateAsync(
3535
int categoryId,
3636
int userId)
3737
{
38+
await EnsureCategoryExistsAsync(categoryId);
39+
3840
var transaction = new Transaction
3941
{
4042
Description = description,
@@ -61,6 +63,8 @@ public async Task<Transaction> UpdateAsync(
6163
var transaction = await GetByIdAsync(id, userId)
6264
?? throw new KeyNotFoundException("Transaction not found");
6365

66+
await EnsureCategoryExistsAsync(categoryId);
67+
6468
transaction.Description = description;
6569
transaction.Amount = amount;
6670
transaction.Date = date;
@@ -80,4 +84,19 @@ public async Task<Transaction> DeleteAsync(int id, int userId)
8084

8185
return transaction;
8286
}
87+
88+
/// <summary>
89+
/// CategoryId is a foreign key: an unknown value fails at SaveChanges with a database
90+
/// error rather than a readable rejection, so it is validated up front.
91+
/// </summary>
92+
private async Task EnsureCategoryExistsAsync(int categoryId)
93+
{
94+
var exists = await _context
95+
.Categories
96+
.AsNoTracking()
97+
.AnyAsync(c => c.Id == categoryId);
98+
99+
if (!exists)
100+
throw new InvalidOperationException("Category not found");
101+
}
83102
}

Trust-Finance.Api/Trust-Finance.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
<PrivateAssets>all</PrivateAssets>
2323
</PackageReference>
2424
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.4" />
25-
<PackageReference Include="MSTest.TestFramework" Version="3.6.1" />
2625
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
2726
</ItemGroup>
2827

0 commit comments

Comments
 (0)