diff --git a/Freman.Sample.Web.IntegrationTests/Api/NotesApiTests.cs b/Freman.Sample.Web.IntegrationTests/Api/NotesApiTests.cs new file mode 100644 index 0000000..5371f0d --- /dev/null +++ b/Freman.Sample.Web.IntegrationTests/Api/NotesApiTests.cs @@ -0,0 +1,70 @@ +using System.Net; +using System.Net.Http.Json; +using Freman.Sample.Web.Contracts; +using Shouldly; +using Freman.Sample.Web.IntegrationTests.TestHost; + +namespace Freman.Sample.Web.IntegrationTests.Api; + +[Collection(IntegrationTestCollection.Name)] +public class NotesApiTests(IntegrationTestFixture fixture) : IAsyncLifetime +{ + private readonly HttpClient _client = fixture.Factory.CreateClient(); + + public async ValueTask InitializeAsync() + { + await fixture.ResetDatabaseAsync(); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + [Fact] + public async Task GetNotes_InitiallyEmpty_Returns200AndEmptyList() + { + var resp = await _client.GetAsync("/api/notes", TestContext.Current.CancellationToken); + resp.StatusCode.ShouldBe(HttpStatusCode.OK); + + var notes = await resp.Content.ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); + notes.ShouldNotBeNull(); + notes!.Count.ShouldBe(0); + } + + [Fact] + public async Task PostNote_ThenGetNotes_ReturnsTheNewNote() + { + var uniqueText = $"test note {Guid.NewGuid():N}"; + + var post = await _client.PostAsJsonAsync("/api/notes", + new CreateNoteRequest { Text = uniqueText }, + cancellationToken: TestContext.Current.CancellationToken); + + post.StatusCode.ShouldBe(HttpStatusCode.Created); + + var notes = await _client.GetFromJsonAsync>("/api/notes", cancellationToken: TestContext.Current.CancellationToken); + notes.ShouldNotBeNull(); + notes!.Any(n => n.Text == uniqueText).ShouldBeTrue(); + } + + [Fact] + public async Task DeleteNote_RemovesIt() + { + var post = await _client.PostAsJsonAsync("/api/notes", new CreateNoteRequest { Text = "delete me" }, + cancellationToken: TestContext.Current.CancellationToken); + var created = await post.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); + created.ShouldNotBeNull(); + + var del = await _client.DeleteAsync($"/api/notes/{created!.Id}", TestContext.Current.CancellationToken); + del.StatusCode.ShouldBe(HttpStatusCode.NoContent); + + var notes = await _client.GetFromJsonAsync>("/api/notes", cancellationToken: TestContext.Current.CancellationToken); + notes.ShouldNotBeNull(); + notes!.Any(n => n.Id == created.Id).ShouldBeFalse(); + } + + [Fact] + public async Task DeleteMissingNote_Returns404() + { + var del = await _client.DeleteAsync("/api/notes/123456", TestContext.Current.CancellationToken); + del.StatusCode.ShouldBe(HttpStatusCode.NotFound); + } +} \ No newline at end of file diff --git a/Freman.Sample.Web.IntegrationTests/Freman.Sample.Web.IntegrationTests.csproj b/Freman.Sample.Web.IntegrationTests/Freman.Sample.Web.IntegrationTests.csproj new file mode 100644 index 0000000..c5e3384 --- /dev/null +++ b/Freman.Sample.Web.IntegrationTests/Freman.Sample.Web.IntegrationTests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Freman.Sample.Web.IntegrationTests/TestHost/IntegrationTestCollection.cs b/Freman.Sample.Web.IntegrationTests/TestHost/IntegrationTestCollection.cs new file mode 100644 index 0000000..0310f6f --- /dev/null +++ b/Freman.Sample.Web.IntegrationTests/TestHost/IntegrationTestCollection.cs @@ -0,0 +1,7 @@ +namespace Freman.Sample.Web.IntegrationTests.TestHost; + +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class IntegrationTestCollection : ICollectionFixture +{ + public const string Name = "Integration tests"; +} \ No newline at end of file diff --git a/Freman.Sample.Web.IntegrationTests/TestHost/IntegrationTestFixture.cs b/Freman.Sample.Web.IntegrationTests/TestHost/IntegrationTestFixture.cs new file mode 100644 index 0000000..17ee480 --- /dev/null +++ b/Freman.Sample.Web.IntegrationTests/TestHost/IntegrationTestFixture.cs @@ -0,0 +1,43 @@ +using Freman.Sample.Web.Data; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Testcontainers.PostgreSql; + +namespace Freman.Sample.Web.IntegrationTests.TestHost; + +public sealed class IntegrationTestFixture : WebApplicationFactory, IAsyncLifetime +{ + private readonly PostgreSqlContainer _pg = new PostgreSqlBuilder("postgres:16") + .WithDatabase("testdb") + .WithUsername("appuser") + .WithPassword("testpassword") + .Build(); + + public SampleWebFactory Factory { get; private set; } = null!; + + public async ValueTask InitializeAsync() + { + await _pg.StartAsync(); + + Factory = new SampleWebFactory(_pg.GetConnectionString()); + + // Apply migrations once + using var scope = Factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + } + + public new async ValueTask DisposeAsync() + { + await Factory.DisposeAsync(); + await _pg.DisposeAsync(); + } + + public async Task ResetDatabaseAsync() + { + using var scope = Factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.ExecuteSqlRawAsync(@"TRUNCATE TABLE notes RESTART IDENTITY;"); + } +} \ No newline at end of file diff --git a/Freman.Sample.Web.IntegrationTests/TestHost/SampleWebFactory.cs b/Freman.Sample.Web.IntegrationTests/TestHost/SampleWebFactory.cs new file mode 100644 index 0000000..125ceda --- /dev/null +++ b/Freman.Sample.Web.IntegrationTests/TestHost/SampleWebFactory.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; + +namespace Freman.Sample.Web.IntegrationTests.TestHost; + +public sealed class SampleWebFactory(string connectionString) : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureAppConfiguration((_, config) => + { + var overrides = new Dictionary + { + ["ConnectionStrings:AppDb"] = connectionString, + ["ApplyMigrationsOnStartup"] = "false" + }; + + config.AddInMemoryCollection(overrides); + }); + } +} \ No newline at end of file diff --git a/Freman.Sample.Web.UnitTests/Contracts/CreateNoteRequestTests.cs b/Freman.Sample.Web.UnitTests/Contracts/CreateNoteRequestTests.cs new file mode 100644 index 0000000..3c67118 --- /dev/null +++ b/Freman.Sample.Web.UnitTests/Contracts/CreateNoteRequestTests.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; +using Freman.Sample.Web.Contracts; +using Shouldly; + +namespace Freman.Sample.Web.UnitTests.Contracts; + +public class CreateNoteRequestTests +{ + [Fact] + public void CreateNoteRequest_EmptyText_IsInvalid() + { + var model = new CreateNoteRequest { Text = "" }; + + var results = Validate(model); + + results.Count.ShouldBeGreaterThan(0); + } + + [Fact] + public void CreateNoteRequest_TooLong_IsInvalid() + { + var model = new CreateNoteRequest { Text = new string('a', 501) }; + + var results = Validate(model); + + results.Count.ShouldBeGreaterThan(0); + } + + [Fact] + public void CreateNoteRequest_NormalText_IsValid() + { + var model = new CreateNoteRequest { Text = "hello" }; + + var results = Validate(model); + + results.Count.ShouldBe(0); + } + + private static List Validate(object model) + { + var results = new List(); + var ctx = new ValidationContext(model); + Validator.TryValidateObject(model, ctx, results, validateAllProperties: true); + return results; + } +} \ No newline at end of file diff --git a/Freman.Sample.Web.UnitTests/Freman.Sample.Web.UnitTests.csproj b/Freman.Sample.Web.UnitTests/Freman.Sample.Web.UnitTests.csproj new file mode 100644 index 0000000..0f55375 --- /dev/null +++ b/Freman.Sample.Web.UnitTests/Freman.Sample.Web.UnitTests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + true + enable + enable + Freman.Sample.Web.UnitTests + Freman.Sample.Web.UnitTests + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Freman.Sample.Web.sln b/Freman.Sample.Web.sln index 5559a2f..c1de33f 100644 --- a/Freman.Sample.Web.sln +++ b/Freman.Sample.Web.sln @@ -11,23 +11,82 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Freman.Sample.Web.Contracts", "Freman.Sample.Web.Contracts\Freman.Sample.Web.Contracts.csproj", "{E92F3324-DD83-4B66-BF80-B287C6C5597D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Freman.Sample.Web.UnitTests", "Freman.Sample.Web.UnitTests\Freman.Sample.Web.UnitTests.csproj", "{DA0BF730-6BC5-47E0-8A31-F97A494B7ABB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Freman.Sample.Web.IntegrationTests", "Freman.Sample.Web.IntegrationTests\Freman.Sample.Web.IntegrationTests.csproj", "{265A416F-B516-43EA-9407-A6B05FABFD4D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {FF628414-CE48-41D6-A139-3835F423F9C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FF628414-CE48-41D6-A139-3835F423F9C3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FF628414-CE48-41D6-A139-3835F423F9C3}.Debug|x64.ActiveCfg = Debug|Any CPU + {FF628414-CE48-41D6-A139-3835F423F9C3}.Debug|x64.Build.0 = Debug|Any CPU + {FF628414-CE48-41D6-A139-3835F423F9C3}.Debug|x86.ActiveCfg = Debug|Any CPU + {FF628414-CE48-41D6-A139-3835F423F9C3}.Debug|x86.Build.0 = Debug|Any CPU {FF628414-CE48-41D6-A139-3835F423F9C3}.Release|Any CPU.ActiveCfg = Release|Any CPU {FF628414-CE48-41D6-A139-3835F423F9C3}.Release|Any CPU.Build.0 = Release|Any CPU + {FF628414-CE48-41D6-A139-3835F423F9C3}.Release|x64.ActiveCfg = Release|Any CPU + {FF628414-CE48-41D6-A139-3835F423F9C3}.Release|x64.Build.0 = Release|Any CPU + {FF628414-CE48-41D6-A139-3835F423F9C3}.Release|x86.ActiveCfg = Release|Any CPU + {FF628414-CE48-41D6-A139-3835F423F9C3}.Release|x86.Build.0 = Release|Any CPU {7FCB89A6-518D-404C-B782-9A5A6F86FEC5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7FCB89A6-518D-404C-B782-9A5A6F86FEC5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7FCB89A6-518D-404C-B782-9A5A6F86FEC5}.Debug|x64.ActiveCfg = Debug|Any CPU + {7FCB89A6-518D-404C-B782-9A5A6F86FEC5}.Debug|x64.Build.0 = Debug|Any CPU + {7FCB89A6-518D-404C-B782-9A5A6F86FEC5}.Debug|x86.ActiveCfg = Debug|Any CPU + {7FCB89A6-518D-404C-B782-9A5A6F86FEC5}.Debug|x86.Build.0 = Debug|Any CPU {7FCB89A6-518D-404C-B782-9A5A6F86FEC5}.Release|Any CPU.ActiveCfg = Release|Any CPU {7FCB89A6-518D-404C-B782-9A5A6F86FEC5}.Release|Any CPU.Build.0 = Release|Any CPU + {7FCB89A6-518D-404C-B782-9A5A6F86FEC5}.Release|x64.ActiveCfg = Release|Any CPU + {7FCB89A6-518D-404C-B782-9A5A6F86FEC5}.Release|x64.Build.0 = Release|Any CPU + {7FCB89A6-518D-404C-B782-9A5A6F86FEC5}.Release|x86.ActiveCfg = Release|Any CPU + {7FCB89A6-518D-404C-B782-9A5A6F86FEC5}.Release|x86.Build.0 = Release|Any CPU {E92F3324-DD83-4B66-BF80-B287C6C5597D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E92F3324-DD83-4B66-BF80-B287C6C5597D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E92F3324-DD83-4B66-BF80-B287C6C5597D}.Debug|x64.ActiveCfg = Debug|Any CPU + {E92F3324-DD83-4B66-BF80-B287C6C5597D}.Debug|x64.Build.0 = Debug|Any CPU + {E92F3324-DD83-4B66-BF80-B287C6C5597D}.Debug|x86.ActiveCfg = Debug|Any CPU + {E92F3324-DD83-4B66-BF80-B287C6C5597D}.Debug|x86.Build.0 = Debug|Any CPU {E92F3324-DD83-4B66-BF80-B287C6C5597D}.Release|Any CPU.ActiveCfg = Release|Any CPU {E92F3324-DD83-4B66-BF80-B287C6C5597D}.Release|Any CPU.Build.0 = Release|Any CPU + {E92F3324-DD83-4B66-BF80-B287C6C5597D}.Release|x64.ActiveCfg = Release|Any CPU + {E92F3324-DD83-4B66-BF80-B287C6C5597D}.Release|x64.Build.0 = Release|Any CPU + {E92F3324-DD83-4B66-BF80-B287C6C5597D}.Release|x86.ActiveCfg = Release|Any CPU + {E92F3324-DD83-4B66-BF80-B287C6C5597D}.Release|x86.Build.0 = Release|Any CPU + {DA0BF730-6BC5-47E0-8A31-F97A494B7ABB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DA0BF730-6BC5-47E0-8A31-F97A494B7ABB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DA0BF730-6BC5-47E0-8A31-F97A494B7ABB}.Debug|x64.ActiveCfg = Debug|Any CPU + {DA0BF730-6BC5-47E0-8A31-F97A494B7ABB}.Debug|x64.Build.0 = Debug|Any CPU + {DA0BF730-6BC5-47E0-8A31-F97A494B7ABB}.Debug|x86.ActiveCfg = Debug|Any CPU + {DA0BF730-6BC5-47E0-8A31-F97A494B7ABB}.Debug|x86.Build.0 = Debug|Any CPU + {DA0BF730-6BC5-47E0-8A31-F97A494B7ABB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DA0BF730-6BC5-47E0-8A31-F97A494B7ABB}.Release|Any CPU.Build.0 = Release|Any CPU + {DA0BF730-6BC5-47E0-8A31-F97A494B7ABB}.Release|x64.ActiveCfg = Release|Any CPU + {DA0BF730-6BC5-47E0-8A31-F97A494B7ABB}.Release|x64.Build.0 = Release|Any CPU + {DA0BF730-6BC5-47E0-8A31-F97A494B7ABB}.Release|x86.ActiveCfg = Release|Any CPU + {DA0BF730-6BC5-47E0-8A31-F97A494B7ABB}.Release|x86.Build.0 = Release|Any CPU + {265A416F-B516-43EA-9407-A6B05FABFD4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {265A416F-B516-43EA-9407-A6B05FABFD4D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {265A416F-B516-43EA-9407-A6B05FABFD4D}.Debug|x64.ActiveCfg = Debug|Any CPU + {265A416F-B516-43EA-9407-A6B05FABFD4D}.Debug|x64.Build.0 = Debug|Any CPU + {265A416F-B516-43EA-9407-A6B05FABFD4D}.Debug|x86.ActiveCfg = Debug|Any CPU + {265A416F-B516-43EA-9407-A6B05FABFD4D}.Debug|x86.Build.0 = Debug|Any CPU + {265A416F-B516-43EA-9407-A6B05FABFD4D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {265A416F-B516-43EA-9407-A6B05FABFD4D}.Release|Any CPU.Build.0 = Release|Any CPU + {265A416F-B516-43EA-9407-A6B05FABFD4D}.Release|x64.ActiveCfg = Release|Any CPU + {265A416F-B516-43EA-9407-A6B05FABFD4D}.Release|x64.Build.0 = Release|Any CPU + {265A416F-B516-43EA-9407-A6B05FABFD4D}.Release|x86.ActiveCfg = Release|Any CPU + {265A416F-B516-43EA-9407-A6B05FABFD4D}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection EndGlobal diff --git a/Freman.Sample.Web.sln.DotSettings.user b/Freman.Sample.Web.sln.DotSettings.user new file mode 100644 index 0000000..a9c14af --- /dev/null +++ b/Freman.Sample.Web.sln.DotSettings.user @@ -0,0 +1,14 @@ + + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from &lt;Freman.Sample.Web.IntegrationTests&gt;" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Or> + <Project Location="C:\Users\smitt\RiderProjects\Freman.Sample.Web\Freman.Sample.Web.IntegrationTests" Presentation="&lt;Freman.Sample.Web.IntegrationTests&gt;" /> + <Project Location="C:\Users\smitt\RiderProjects\Freman.Sample.Web\Freman.Sample.Web.UnitTests" Presentation="&lt;Freman.Sample.Web.UnitTests&gt;" /> + </Or> +</SessionState> \ No newline at end of file diff --git a/Freman.Sample.Web/Freman.Sample.Web/Endpoints/NotesEndpoints.cs b/Freman.Sample.Web/Freman.Sample.Web/Endpoints/NotesEndpoints.cs index d128ed5..ac08f57 100644 --- a/Freman.Sample.Web/Freman.Sample.Web/Endpoints/NotesEndpoints.cs +++ b/Freman.Sample.Web/Freman.Sample.Web/Endpoints/NotesEndpoints.cs @@ -10,10 +10,11 @@ public static class NotesEndpoints { var notesApi = app.MapGroup("/api/notes"); - notesApi.MapGet("/", async (AppDbContext db, CancellationToken ct) => + notesApi.MapGet("", async (AppDbContext db, CancellationToken ct) => { var notes = await db.Notes .OrderByDescending(n => n.CreatedUtc) + .ThenByDescending(n => n.Id) .Take(50) .Select(n => new NoteDto(n.Id, n.Text, n.CreatedUtc)) .ToListAsync(ct); @@ -21,14 +22,16 @@ public static class NotesEndpoints return Results.Ok(notes); }); - notesApi.MapPost("/", async (CreateNoteRequest request, AppDbContext db, CancellationToken ct) => + notesApi.MapPost("", async (CreateNoteRequest request, AppDbContext db, CancellationToken ct) => { var text = (request.Text ?? "").Trim(); if (string.IsNullOrWhiteSpace(text)) return Results.BadRequest("Text is required."); if (text.Length > 500) + { return Results.BadRequest("Text must be 500 characters or less."); + } var note = new NoteEntity { @@ -39,14 +42,16 @@ public static class NotesEndpoints db.Notes.Add(note); await db.SaveChangesAsync(ct); - return Results.Created($"/api/notes/{note.Id}", new NoteDto(note.Id, note.Text, note.CreatedUtc)); + return Results.Created($"{note.Id}", new NoteDto(note.Id, note.Text, note.CreatedUtc)); }); - notesApi.MapDelete("/{id:int}", async (int id, AppDbContext db, CancellationToken ct) => + notesApi.MapDelete("{id:int}", async (int id, AppDbContext db, CancellationToken ct) => { var note = await db.Notes.FirstOrDefaultAsync(n => n.Id == id, ct); if (note is null) + { return Results.NotFound(); + } db.Notes.Remove(note); await db.SaveChangesAsync(ct); diff --git a/Freman.Sample.Web/Freman.Sample.Web/Program.cs b/Freman.Sample.Web/Freman.Sample.Web/Program.cs index 38b6274..0eb5df6 100644 --- a/Freman.Sample.Web/Freman.Sample.Web/Program.cs +++ b/Freman.Sample.Web/Freman.Sample.Web/Program.cs @@ -34,7 +34,12 @@ else app.UseHsts(); } -app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); + +// Only rewrite status codes for non-API routes (UI). APIs should return their real status codes. +app.UseWhen( + context => !context.Request.Path.StartsWithSegments("/api"), + uiApp => uiApp.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true)); + app.UseHttpsRedirection(); app.UseAntiforgery(); @@ -48,4 +53,6 @@ app.MapRazorComponents() .AddInteractiveWebAssemblyRenderMode() .AddAdditionalAssemblies(typeof(Freman.Sample.Web.Client._Imports).Assembly); -app.Run(); \ No newline at end of file +app.Run(); + +public partial class Program; \ No newline at end of file