@yosr-ai/template-dotnet 0.0.0-stage → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +14 -2
  4. package/features/auth-api-key/files/src/App/Core/ApiKeyMiddleware.cs +33 -0
  5. package/features/auth-api-key/files/tests/App.Tests/ApiKeyTests.cs +27 -0
  6. package/features/data-efcore/files/src/App/App.csproj +16 -0
  7. package/features/data-efcore/files/src/App/Core/AppDbContext.cs +22 -0
  8. package/features/data-efcore/files/src/App/Core/AppSettings.cs +30 -0
  9. package/features/data-efcore/files/src/App/Features/Notes/EfNoteStore.cs +46 -0
  10. package/features/data-efcore/files/src/App/Features/Notes/NoteEntity.cs +24 -0
  11. package/features/data-efcore/files/src/App/Program.cs +46 -0
  12. package/features/data-efcore/files/src/App/packages.lock.json +74 -0
  13. package/features/data-efcore/files/tests/App.Tests/ApiFactory.cs +70 -0
  14. package/features/data-efcore/files/tests/App.Tests/packages.lock.json +511 -0
  15. package/features/data-efcore/snippets/program-usings.txt +1 -0
  16. package/features/data-efcore/snippets/readme-db.md +3 -0
  17. package/features/data-efcore/snippets/service-registration.txt +3 -0
  18. package/features/deploy-docker/files/Dockerfile +19 -0
  19. package/features/deploy-docker/files/compose.yaml +12 -0
  20. package/features/deploy-docker/snippets/readme-docker.md +3 -0
  21. package/features/deploy-docker-postgres/snippets/app-config.yaml +3 -0
  22. package/features/deploy-docker-postgres/snippets/app-env.yaml +1 -0
  23. package/features/deploy-docker-postgres/snippets/compose-header.yaml +1 -0
  24. package/features/deploy-docker-postgres/snippets/compose-volumes.yaml +2 -0
  25. package/features/deploy-docker-postgres/snippets/postgres-service.yaml +13 -0
  26. package/features/deploy-docker-postgres/snippets/readme-docker.md +3 -0
  27. package/features/errors-result/files/src/App/Features/Notes/NotesEndpoints.cs +52 -0
  28. package/features/errors-result/files/src/App/Features/Notes/NotesService.cs +56 -0
  29. package/features/errors-result/files/src/App/Shared/Result.cs +8 -0
  30. package/features/errors-result/files/tests/App.Tests/NotesServiceTests.cs +37 -0
  31. package/files/.env.example +7 -0
  32. package/files/App.sln +24 -0
  33. package/files/Directory.Build.props +9 -0
  34. package/files/README.md +20 -0
  35. package/files/gitignore +9 -0
  36. package/files/global.json +6 -0
  37. package/files/src/App/App.csproj +13 -0
  38. package/files/src/App/Core/AppSettings.cs +25 -0
  39. package/files/src/App/Features/Notes/INoteStore.cs +12 -0
  40. package/files/src/App/Features/Notes/MemoryNoteStore.cs +54 -0
  41. package/files/src/App/Features/Notes/Note.cs +24 -0
  42. package/files/src/App/Features/Notes/NotesEndpoints.cs +63 -0
  43. package/files/src/App/Features/Notes/NotesService.cs +38 -0
  44. package/files/src/App/Program.cs +26 -0
  45. package/files/src/App/Properties/launchSettings.json +12 -0
  46. package/files/src/App/Shared/NotFoundException.cs +9 -0
  47. package/files/src/App/appsettings.json +9 -0
  48. package/files/src/App/packages.lock.json +21 -0
  49. package/files/tests/App.Tests/ApiFactory.cs +14 -0
  50. package/files/tests/App.Tests/App.Tests.csproj +28 -0
  51. package/files/tests/App.Tests/GlobalUsings.cs +1 -0
  52. package/files/tests/App.Tests/NotesApiTests.cs +62 -0
  53. package/files/tests/App.Tests/NotesServiceTests.cs +36 -0
  54. package/files/tests/App.Tests/packages.lock.json +428 -0
  55. package/package.json +26 -4
  56. package/rules/architecture.md +5 -0
  57. package/rules/auth-api-key.md +3 -0
  58. package/rules/commands.md +7 -0
  59. package/rules/data-efcore.md +3 -0
  60. package/rules/data-none.md +3 -0
  61. package/rules/errors-result.md +3 -0
  62. package/rules/errors-throw.md +3 -0
  63. package/rules/layout-feature.md +5 -0
  64. package/rules/layout-flat.md +3 -0
  65. package/rules/layout-layer.md +3 -0
  66. package/rules/project-overview.md +5 -0
  67. package/seed.template.json +716 -0
  68. package/style/.gitkeep +0 -0
@@ -0,0 +1,56 @@
1
+ using App.Shared;
2
+
3
+ namespace App.Features.Notes;
4
+
5
+ public sealed class NotesService(INoteStore store)
6
+ {
7
+ public Task<IReadOnlyList<Note>> ListAsync(CancellationToken cancellationToken = default) =>
8
+ store.ListAsync(cancellationToken);
9
+
10
+ public async Task<Result<Note>> GetAsync(int id, CancellationToken cancellationToken = default)
11
+ {
12
+ var note = await store.GetAsync(id, cancellationToken);
13
+ if (note is null) return new Result<Note>.Err($"Note {id} was not found.");
14
+ return new Result<Note>.Ok(note);
15
+ }
16
+
17
+ public async Task<Result<Note>> CreateAsync(NoteCreate input, CancellationToken cancellationToken = default)
18
+ {
19
+ string title;
20
+ try
21
+ {
22
+ title = RequireTitle(input.Title);
23
+ }
24
+ catch (ArgumentException ex)
25
+ {
26
+ return new Result<Note>.Err(ex.Message);
27
+ }
28
+
29
+ var note = await store.AddAsync(title, input.Body, cancellationToken);
30
+ return new Result<Note>.Ok(note);
31
+ }
32
+
33
+ public async Task<Result<Note>> UpdateAsync(int id, NoteUpdate input, CancellationToken cancellationToken = default)
34
+ {
35
+ try
36
+ {
37
+ if (input.Title is not null) RequireTitle(input.Title);
38
+ }
39
+ catch (ArgumentException ex)
40
+ {
41
+ return new Result<Note>.Err(ex.Message);
42
+ }
43
+
44
+ var note = await store.UpdateAsync(id, input.Title, input.Body, cancellationToken);
45
+ if (note is null) return new Result<Note>.Err($"Note {id} was not found.");
46
+ return new Result<Note>.Ok(note);
47
+ }
48
+
49
+ private static string RequireTitle(string? title)
50
+ {
51
+ var trimmed = title?.Trim() ?? string.Empty;
52
+ if (trimmed.Length == 0)
53
+ throw new ArgumentException("Title must not be blank.", nameof(title));
54
+ return trimmed;
55
+ }
56
+ }
@@ -0,0 +1,8 @@
1
+ namespace App.Shared;
2
+
3
+ public abstract record Result<T>
4
+ {
5
+ public sealed record Ok(T Value) : Result<T>;
6
+
7
+ public sealed record Err(string Message) : Result<T>;
8
+ }
@@ -0,0 +1,37 @@
1
+ using App.Features.Notes;
2
+ using App.Shared;
3
+
4
+ namespace App.Tests;
5
+
6
+ public sealed class NotesServiceTests
7
+ {
8
+ private static NotesService Service() => new(new MemoryNoteStore());
9
+
10
+ [Fact]
11
+ public async Task Create_then_get_returns_the_note()
12
+ {
13
+ var notes = Service();
14
+ var created = Assert.IsType<Result<Note>.Ok>(await notes.CreateAsync(new NoteCreate("Hello", "Body")));
15
+ var found = Assert.IsType<Result<Note>.Ok>(await notes.GetAsync(created.Value.Id));
16
+ Assert.Equal("Hello", found.Value.Title);
17
+ Assert.Equal("Body", found.Value.Body);
18
+ }
19
+
20
+ [Fact]
21
+ public async Task Get_missing_note_returns_err()
22
+ {
23
+ var notes = Service();
24
+ Assert.IsType<Result<Note>.Err>(await notes.GetAsync(99));
25
+ }
26
+
27
+ [Fact]
28
+ public async Task Update_changes_only_given_fields()
29
+ {
30
+ var notes = Service();
31
+ var created = Assert.IsType<Result<Note>.Ok>(await notes.CreateAsync(new NoteCreate("Hello", "Body")));
32
+ var updated = Assert.IsType<Result<Note>.Ok>(
33
+ await notes.UpdateAsync(created.Value.Id, new NoteUpdate("Hi", null)));
34
+ Assert.Equal("Hi", updated.Value.Title);
35
+ Assert.Equal("Body", updated.Value.Body);
36
+ }
37
+ }
@@ -0,0 +1,7 @@
1
+ # Application display name
2
+ APP_NAME=__seed_projectName__
3
+
4
+ # Comma-separated browser origins allowed by CORS (Vite default included)
5
+ CORS_ORIGINS=http://localhost:5173
6
+
7
+ # @seed:anchor env-vars
package/files/App.sln ADDED
@@ -0,0 +1,24 @@
1
+ Microsoft Visual Studio Solution File, Format Version 12.00
2
+ # Visual Studio Version 17
3
+ VisualStudioVersion = 17.0.31903.59
4
+ MinimumVisualStudioVersion = 10.0.40219.1
5
+ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "src\App\App.csproj", "{A1111111-1111-1111-1111-111111111111}"
6
+ EndProject
7
+ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.Tests", "tests\App.Tests\App.Tests.csproj", "{B2222222-2222-2222-2222-222222222222}"
8
+ EndProject
9
+ Global
10
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
11
+ Debug|Any CPU = Debug|Any CPU
12
+ Release|Any CPU = Release|Any CPU
13
+ EndGlobalSection
14
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
15
+ {A1111111-1111-1111-1111-111111111111}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
16
+ {A1111111-1111-1111-1111-111111111111}.Debug|Any CPU.Build.0 = Debug|Any CPU
17
+ {A1111111-1111-1111-1111-111111111111}.Release|Any CPU.ActiveCfg = Release|Any CPU
18
+ {A1111111-1111-1111-1111-111111111111}.Release|Any CPU.Build.0 = Release|Any CPU
19
+ {B2222222-2222-2222-2222-222222222222}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
20
+ {B2222222-2222-2222-2222-222222222222}.Debug|Any CPU.Build.0 = Debug|Any CPU
21
+ {B2222222-2222-2222-2222-222222222222}.Release|Any CPU.ActiveCfg = Release|Any CPU
22
+ {B2222222-2222-2222-2222-222222222222}.Release|Any CPU.Build.0 = Release|Any CPU
23
+ EndGlobalSection
24
+ EndGlobal
@@ -0,0 +1,9 @@
1
+ <Project>
2
+ <PropertyGroup>
3
+ <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
4
+ <Nullable>enable</Nullable>
5
+ <ImplicitUsings>enable</ImplicitUsings>
6
+ <!-- Compile warnings fail via `dotnet build -warnaserror` in the gate; keep restore free of TreatWarningsAsErrors so NuGet audit advisories do not block locked restore. -->
7
+ <WarningsNotAsErrors>NU1901;NU1902;NU1903;NU1904</WarningsNotAsErrors>
8
+ </PropertyGroup>
9
+ </Project>
@@ -0,0 +1,20 @@
1
+ # __seed_projectName__
2
+
3
+ Generated by [Yosr Seed](https://seed.yosr.ai) from the `__seed_templateName__@__seed_templateVersion__` template. API-only ASP.NET Core service on .NET 10: minimal API, xUnit, `dotnet format`. Data: __seed_storageLabel__.
4
+
5
+ ## Commands
6
+
7
+ | Task | Command |
8
+ | --- | --- |
9
+ | Restore (locked) | `dotnet restore --locked-mode` |
10
+ | Run | `dotnet run --project src/App` |
11
+ | Format check | `dotnet format --verify-no-changes` |
12
+ | Build | `dotnet build -warnaserror` |
13
+ | Tests | `dotnet test` |
14
+
15
+ ## Layout
16
+
17
+ - `src/App/` — app (Core, Features, Shared)
18
+ - `tests/App.Tests/` — unit + in-process HTTP tests (`WebApplicationFactory`)
19
+
20
+ <!-- @seed:anchor readme-sections -->
@@ -0,0 +1,9 @@
1
+ bin/
2
+ obj/
3
+ .vs/
4
+ *.user
5
+ *.suo
6
+ .env
7
+ .DS_Store
8
+ TestResults/
9
+ *.log
@@ -0,0 +1,6 @@
1
+ {
2
+ "sdk": {
3
+ "version": "10.0.404",
4
+ "rollForward": "latestFeature"
5
+ }
6
+ }
@@ -0,0 +1,13 @@
1
+ <Project Sdk="Microsoft.NET.Sdk.Web">
2
+
3
+ <PropertyGroup>
4
+ <TargetFramework>net10.0</TargetFramework>
5
+ <RootNamespace>App</RootNamespace>
6
+ <AssemblyName>App</AssemblyName>
7
+ </PropertyGroup>
8
+
9
+ <ItemGroup>
10
+ <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.15" />
11
+ </ItemGroup>
12
+
13
+ </Project>
@@ -0,0 +1,25 @@
1
+ namespace App.Core;
2
+
3
+ public sealed class AppSettings
4
+ {
5
+ public required string AppName { get; init; }
6
+
7
+ public IReadOnlyList<string> CorsOrigins { get; init; } = ["http://localhost:5173"];
8
+
9
+ // @seed:anchor settings-fields
10
+
11
+ public static AppSettings FromConfiguration(IConfiguration configuration)
12
+ {
13
+ var cors = configuration["CORS_ORIGINS"]
14
+ ?? Environment.GetEnvironmentVariable("CORS_ORIGINS")
15
+ ?? "http://localhost:5173";
16
+ return new AppSettings
17
+ {
18
+ AppName = configuration["APP_NAME"]
19
+ ?? Environment.GetEnvironmentVariable("APP_NAME")
20
+ ?? "__seed_projectName__",
21
+ CorsOrigins = cors.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
22
+ // @seed:anchor settings-values
23
+ };
24
+ }
25
+ }
@@ -0,0 +1,12 @@
1
+ namespace App.Features.Notes;
2
+
3
+ public interface INoteStore
4
+ {
5
+ Task<IReadOnlyList<Note>> ListAsync(CancellationToken cancellationToken = default);
6
+
7
+ Task<Note?> GetAsync(int id, CancellationToken cancellationToken = default);
8
+
9
+ Task<Note> AddAsync(string title, string? body, CancellationToken cancellationToken = default);
10
+
11
+ Task<Note?> UpdateAsync(int id, string? title, string? body, CancellationToken cancellationToken = default);
12
+ }
@@ -0,0 +1,54 @@
1
+ namespace App.Features.Notes;
2
+
3
+ public sealed class MemoryNoteStore : INoteStore
4
+ {
5
+ private readonly object _gate = new();
6
+ private readonly List<Note> _notes = [];
7
+ private int _nextId = 1;
8
+
9
+ public Task<IReadOnlyList<Note>> ListAsync(CancellationToken cancellationToken = default)
10
+ {
11
+ lock (_gate)
12
+ {
13
+ return Task.FromResult<IReadOnlyList<Note>>(
14
+ _notes.OrderByDescending(note => note.Id).ToList());
15
+ }
16
+ }
17
+
18
+ public Task<Note?> GetAsync(int id, CancellationToken cancellationToken = default)
19
+ {
20
+ lock (_gate)
21
+ {
22
+ return Task.FromResult(_notes.FirstOrDefault(note => note.Id == id));
23
+ }
24
+ }
25
+
26
+ public Task<Note> AddAsync(string title, string? body, CancellationToken cancellationToken = default)
27
+ {
28
+ lock (_gate)
29
+ {
30
+ var note = new Note
31
+ {
32
+ Id = _nextId++,
33
+ Title = title,
34
+ Body = body,
35
+ CreatedAt = DateTimeOffset.UtcNow,
36
+ };
37
+ _notes.Add(note);
38
+ return Task.FromResult(note);
39
+ }
40
+ }
41
+
42
+ public Task<Note?> UpdateAsync(int id, string? title, string? body, CancellationToken cancellationToken = default)
43
+ {
44
+ lock (_gate)
45
+ {
46
+ var note = _notes.FirstOrDefault(item => item.Id == id);
47
+ if (note is null) return Task.FromResult<Note?>(null);
48
+ if (title is not null) note.Title = title;
49
+ if (body is not null) note.Body = body;
50
+ note.UpdatedAt = DateTimeOffset.UtcNow;
51
+ return Task.FromResult<Note?>(note);
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,24 @@
1
+ namespace App.Features.Notes;
2
+
3
+ public sealed class Note
4
+ {
5
+ public required int Id { get; init; }
6
+
7
+ public required string Title { get; set; }
8
+
9
+ public string? Body { get; set; }
10
+
11
+ public required DateTimeOffset CreatedAt { get; init; }
12
+
13
+ public DateTimeOffset? UpdatedAt { get; set; }
14
+ }
15
+
16
+ public sealed record NoteCreate(string Title, string? Body);
17
+
18
+ public sealed record NoteUpdate(string? Title, string? Body);
19
+
20
+ public sealed record NoteResponse(int Id, string Title, string? Body, DateTimeOffset CreatedAt, DateTimeOffset? UpdatedAt)
21
+ {
22
+ public static NoteResponse From(Note note) =>
23
+ new(note.Id, note.Title, note.Body, note.CreatedAt, note.UpdatedAt);
24
+ }
@@ -0,0 +1,63 @@
1
+ using App.Shared;
2
+
3
+ namespace App.Features.Notes;
4
+
5
+ public static class NotesEndpoints
6
+ {
7
+ public static RouteGroupBuilder MapNotesEndpoints(this IEndpointRouteBuilder app)
8
+ {
9
+ var group = app.MapGroup("/notes");
10
+ // @seed:anchor notes-group
11
+
12
+ group.MapGet("/", async (NotesService notes, CancellationToken cancellationToken) =>
13
+ {
14
+ var items = await notes.ListAsync(cancellationToken);
15
+ return Results.Ok(items.Select(NoteResponse.From));
16
+ });
17
+
18
+ group.MapGet("/{id:int}", async (int id, NotesService notes, CancellationToken cancellationToken) =>
19
+ {
20
+ try
21
+ {
22
+ var note = await notes.GetAsync(id, cancellationToken);
23
+ return Results.Ok(NoteResponse.From(note));
24
+ }
25
+ catch (NotFoundException ex)
26
+ {
27
+ return Results.NotFound(new { detail = ex.Message });
28
+ }
29
+ });
30
+
31
+ group.MapPost("/", async (NoteCreate body, NotesService notes, CancellationToken cancellationToken) =>
32
+ {
33
+ try
34
+ {
35
+ var note = await notes.CreateAsync(body, cancellationToken);
36
+ return Results.Created($"/notes/{note.Id}", NoteResponse.From(note));
37
+ }
38
+ catch (ArgumentException ex)
39
+ {
40
+ return Results.BadRequest(new { detail = ex.Message });
41
+ }
42
+ });
43
+
44
+ group.MapPatch("/{id:int}", async (int id, NoteUpdate body, NotesService notes, CancellationToken cancellationToken) =>
45
+ {
46
+ try
47
+ {
48
+ var note = await notes.UpdateAsync(id, body, cancellationToken);
49
+ return Results.Ok(NoteResponse.From(note));
50
+ }
51
+ catch (ArgumentException ex)
52
+ {
53
+ return Results.BadRequest(new { detail = ex.Message });
54
+ }
55
+ catch (NotFoundException ex)
56
+ {
57
+ return Results.NotFound(new { detail = ex.Message });
58
+ }
59
+ });
60
+
61
+ return group;
62
+ }
63
+ }
@@ -0,0 +1,38 @@
1
+ using App.Shared;
2
+
3
+ namespace App.Features.Notes;
4
+
5
+ public sealed class NotesService(INoteStore store)
6
+ {
7
+ public Task<IReadOnlyList<Note>> ListAsync(CancellationToken cancellationToken = default) =>
8
+ store.ListAsync(cancellationToken);
9
+
10
+ public async Task<Note> GetAsync(int id, CancellationToken cancellationToken = default)
11
+ {
12
+ var note = await store.GetAsync(id, cancellationToken);
13
+ if (note is null) throw new NotFoundException($"Note {id} was not found.");
14
+ return note;
15
+ }
16
+
17
+ public Task<Note> CreateAsync(NoteCreate input, CancellationToken cancellationToken = default)
18
+ {
19
+ var title = RequireTitle(input.Title);
20
+ return store.AddAsync(title, input.Body, cancellationToken);
21
+ }
22
+
23
+ public async Task<Note> UpdateAsync(int id, NoteUpdate input, CancellationToken cancellationToken = default)
24
+ {
25
+ if (input.Title is not null) RequireTitle(input.Title);
26
+ var note = await store.UpdateAsync(id, input.Title, input.Body, cancellationToken);
27
+ if (note is null) throw new NotFoundException($"Note {id} was not found.");
28
+ return note;
29
+ }
30
+
31
+ private static string RequireTitle(string? title)
32
+ {
33
+ var trimmed = title?.Trim() ?? string.Empty;
34
+ if (trimmed.Length == 0)
35
+ throw new ArgumentException("Title must not be blank.", nameof(title));
36
+ return trimmed;
37
+ }
38
+ }
@@ -0,0 +1,26 @@
1
+ using App.Core;
2
+ using App.Features.Notes;
3
+ using App.Shared;
4
+ // @seed:anchor program-usings
5
+
6
+ var builder = WebApplication.CreateBuilder(args);
7
+
8
+ var settings = AppSettings.FromConfiguration(builder.Configuration);
9
+ builder.Services.AddSingleton(settings);
10
+ // @seed:anchor service-registration
11
+ builder.Services.AddSingleton<INoteStore, MemoryNoteStore>();
12
+ builder.Services.AddSingleton<NotesService>();
13
+
14
+ builder.Services.AddOpenApi();
15
+
16
+ var app = builder.Build();
17
+
18
+ // @seed:anchor middleware
19
+
20
+ app.MapOpenApi();
21
+ app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
22
+ app.MapNotesEndpoints();
23
+
24
+ app.Run();
25
+
26
+ public partial class Program;
@@ -0,0 +1,12 @@
1
+ {
2
+ "profiles": {
3
+ "http": {
4
+ "commandName": "Project",
5
+ "dotnetRunMessages": true,
6
+ "applicationUrl": "http://localhost:5080",
7
+ "environmentVariables": {
8
+ "ASPNETCORE_ENVIRONMENT": "Development"
9
+ }
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,9 @@
1
+ namespace App.Shared;
2
+
3
+ public sealed class NotFoundException : Exception
4
+ {
5
+ public NotFoundException(string message)
6
+ : base(message)
7
+ {
8
+ }
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "Logging": {
3
+ "LogLevel": {
4
+ "Default": "Information",
5
+ "Microsoft.AspNetCore": "Warning"
6
+ }
7
+ },
8
+ "AllowedHosts": "*"
9
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "version": 1,
3
+ "dependencies": {
4
+ "net10.0": {
5
+ "Microsoft.AspNetCore.OpenApi": {
6
+ "type": "Direct",
7
+ "requested": "[10.0.15, )",
8
+ "resolved": "10.0.15",
9
+ "contentHash": "pptFDPMWyPDDtt68s/HsvLYeHJ0WQtntjyve6ztp/5MrLOEzdZvafYIyWSeErtexIGaB8XGYCnnUK8T/Vn4MwQ==",
10
+ "dependencies": {
11
+ "Microsoft.OpenApi": "[2.13.0, 3.0.0)"
12
+ }
13
+ },
14
+ "Microsoft.OpenApi": {
15
+ "type": "Transitive",
16
+ "resolved": "2.13.0",
17
+ "contentHash": "wavlgBYnn7uU/ZGhFHNl0UlDOlwz+6KRduKkqdCCbfxxANaQ1rmBpETVGdwXanAM6twQOxsvI3CBEGF4OScbeQ=="
18
+ }
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,14 @@
1
+ using Microsoft.AspNetCore.Hosting;
2
+ using Microsoft.AspNetCore.Mvc.Testing;
3
+ // @seed:anchor factory-usings
4
+
5
+ namespace App.Tests;
6
+
7
+ public sealed class ApiFactory : WebApplicationFactory<Program>
8
+ {
9
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
10
+ {
11
+ builder.UseEnvironment("Development");
12
+ // @seed:anchor factory-configure
13
+ }
14
+ }
@@ -0,0 +1,28 @@
1
+ <Project Sdk="Microsoft.NET.Sdk">
2
+
3
+ <PropertyGroup>
4
+ <TargetFramework>net10.0</TargetFramework>
5
+ <RootNamespace>App.Tests</RootNamespace>
6
+ <AssemblyName>App.Tests</AssemblyName>
7
+ <IsPackable>false</IsPackable>
8
+ </PropertyGroup>
9
+
10
+ <ItemGroup>
11
+ <PackageReference Include="coverlet.collector" Version="6.0.4">
12
+ <PrivateAssets>all</PrivateAssets>
13
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
14
+ </PackageReference>
15
+ <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.15" />
16
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
17
+ <PackageReference Include="xunit" Version="2.9.3" />
18
+ <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
19
+ <PrivateAssets>all</PrivateAssets>
20
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
21
+ </PackageReference>
22
+ </ItemGroup>
23
+
24
+ <ItemGroup>
25
+ <ProjectReference Include="..\..\src\App\App.csproj" />
26
+ </ItemGroup>
27
+
28
+ </Project>
@@ -0,0 +1 @@
1
+ global using Xunit;
@@ -0,0 +1,62 @@
1
+ using System.Net;
2
+ using System.Net.Http.Json;
3
+
4
+ namespace App.Tests;
5
+
6
+ public sealed class NotesApiTests : IClassFixture<ApiFactory>
7
+ {
8
+ private readonly HttpClient _client;
9
+
10
+ public NotesApiTests(ApiFactory factory)
11
+ {
12
+ _client = factory.CreateClient();
13
+ // @seed:anchor test-client-headers
14
+ }
15
+
16
+ [Fact]
17
+ public async Task Health_is_ok()
18
+ {
19
+ var response = await _client.GetAsync("/health");
20
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
21
+ var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
22
+ Assert.Equal("ok", body?["status"]);
23
+ }
24
+
25
+ [Fact]
26
+ public async Task Create_list_get_and_update_a_note()
27
+ {
28
+ var created = await _client.PostAsJsonAsync("/notes", new { title = "Hello", body = "World" });
29
+ Assert.Equal(HttpStatusCode.Created, created.StatusCode);
30
+ var note = await created.Content.ReadFromJsonAsync<NoteDto>();
31
+ Assert.NotNull(note);
32
+ Assert.Equal("Hello", note.Title);
33
+
34
+ var list = await _client.GetFromJsonAsync<List<NoteDto>>("/notes");
35
+ Assert.Contains(list!, item => item.Id == note.Id);
36
+
37
+ var got = await _client.GetFromJsonAsync<NoteDto>($"/notes/{note.Id}");
38
+ Assert.Equal("Hello", got!.Title);
39
+
40
+ var patched = await _client.PatchAsJsonAsync($"/notes/{note.Id}", new { title = "Hi" });
41
+ Assert.Equal(HttpStatusCode.OK, patched.StatusCode);
42
+ var updated = await patched.Content.ReadFromJsonAsync<NoteDto>();
43
+ Assert.Equal("Hi", updated!.Title);
44
+ Assert.Equal("World", updated.Body);
45
+ }
46
+
47
+ [Fact]
48
+ public async Task Missing_note_is_404()
49
+ {
50
+ var response = await _client.GetAsync("/notes/999");
51
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
52
+ }
53
+
54
+ [Fact]
55
+ public async Task Blank_title_is_rejected()
56
+ {
57
+ var response = await _client.PostAsJsonAsync("/notes", new { title = " ", body = "x" });
58
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
59
+ }
60
+
61
+ private sealed record NoteDto(int Id, string Title, string? Body);
62
+ }
@@ -0,0 +1,36 @@
1
+ using App.Features.Notes;
2
+ using App.Shared;
3
+
4
+ namespace App.Tests;
5
+
6
+ public sealed class NotesServiceTests
7
+ {
8
+ private static NotesService Service() => new(new MemoryNoteStore());
9
+
10
+ [Fact]
11
+ public async Task Create_then_get_returns_the_note()
12
+ {
13
+ var notes = Service();
14
+ var created = await notes.CreateAsync(new NoteCreate("Hello", "Body"));
15
+ var found = await notes.GetAsync(created.Id);
16
+ Assert.Equal("Hello", found.Title);
17
+ Assert.Equal("Body", found.Body);
18
+ }
19
+
20
+ [Fact]
21
+ public async Task Get_missing_note_throws()
22
+ {
23
+ var notes = Service();
24
+ await Assert.ThrowsAsync<NotFoundException>(() => notes.GetAsync(99));
25
+ }
26
+
27
+ [Fact]
28
+ public async Task Update_changes_only_given_fields()
29
+ {
30
+ var notes = Service();
31
+ var created = await notes.CreateAsync(new NoteCreate("Hello", "Body"));
32
+ var updated = await notes.UpdateAsync(created.Id, new NoteUpdate("Hi", null));
33
+ Assert.Equal("Hi", updated.Title);
34
+ Assert.Equal("Body", updated.Body);
35
+ }
36
+ }