From a4fba0369b3ba745137e71ac8b41aacbafd8da9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Festim=20Re=C3=A7i?= <45433214+FestimReqi@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:32:53 +0200 Subject: [PATCH] feat: add project management API --- README.md | 26 ++- .../Controllers/ProjectsController.cs | 83 ++++++++ .../ErrorHandling/ApiExceptionHandler.cs | 41 ++++ .../FCode.TraceHub.Api.http | 31 ++- src/FCode.TraceHub.Api/Program.cs | 22 +- .../Projects/IProjectRepository.cs | 22 ++ .../Projects/IProjectService.cs | 22 ++ .../Projects/ProjectContracts.cs | 20 ++ .../Projects/ProjectExceptions.cs | 31 +++ .../Projects/ProjectService.cs | 155 ++++++++++++++ .../DependencyInjection.cs | 3 + .../Repositories/ProjectRepository.cs | 66 ++++++ .../Api/ProjectEndpointsTests.cs | 134 ++++++++++++ .../FCode.TraceHub.IntegrationTests.csproj | 1 + .../Persistence/PostgreSqlFixture.cs | 44 ++++ .../Application/ProjectServiceTests.cs | 192 ++++++++++++++++++ 16 files changed, 890 insertions(+), 3 deletions(-) create mode 100644 src/FCode.TraceHub.Api/Controllers/ProjectsController.cs create mode 100644 src/FCode.TraceHub.Api/ErrorHandling/ApiExceptionHandler.cs create mode 100644 src/FCode.TraceHub.Application/Projects/IProjectRepository.cs create mode 100644 src/FCode.TraceHub.Application/Projects/IProjectService.cs create mode 100644 src/FCode.TraceHub.Application/Projects/ProjectContracts.cs create mode 100644 src/FCode.TraceHub.Application/Projects/ProjectExceptions.cs create mode 100644 src/FCode.TraceHub.Application/Projects/ProjectService.cs create mode 100644 src/FCode.TraceHub.Infrastructure/Persistence/Repositories/ProjectRepository.cs create mode 100644 tests/FCode.TraceHub.IntegrationTests/Api/ProjectEndpointsTests.cs create mode 100644 tests/FCode.TraceHub.UnitTests/Application/ProjectServiceTests.cs diff --git a/README.md b/README.md index 5e99789..5365d28 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,12 @@ It aims to help development teams inspect API requests, application errors, audi - Sample ASP.NET Core application - Foundation for an installable ASP.NET Core SDK - GitHub Actions continuous integration +- Project management REST API ## Planned Features - Project, environment, and API key persistence with EF Core and PostgreSQL -- Project and environment management API +- Environment management API - API key creation and revocation endpoints - Request, exception, audit, and email event ingestion - Sensitive-data redaction @@ -120,6 +121,29 @@ Apply database migrations manually before starting the API: dotnet ef database update --project src/FCode.TraceHub.Infrastructure --startup-project src/FCode.TraceHub.Api ``` +## Project API + +Projects can be created, listed, retrieved, updated, and archived through +`/api/projects`. Slugs are normalized to lowercase, and archived projects are +retained with `isActive` set to `false`. + +```bash +curl -X POST http://localhost:5177/api/projects \ + -H "Content-Type: application/json" \ + -d '{"name":"TraceHub API","slug":"tracehub-api","description":"Main API"}' + +curl http://localhost:5177/api/projects +curl http://localhost:5177/api/projects/{id} + +curl -X PUT http://localhost:5177/api/projects/{id} \ + -H "Content-Type: application/json" \ + -d '{"name":"TraceHub API","slug":"tracehub-api","description":"Updated API"}' + +curl -X DELETE http://localhost:5177/api/projects/{id} +``` + +Validation, missing-resource, and duplicate-slug responses use ProblemDetails. + Check dependencies for known vulnerabilities: ```bash diff --git a/src/FCode.TraceHub.Api/Controllers/ProjectsController.cs b/src/FCode.TraceHub.Api/Controllers/ProjectsController.cs new file mode 100644 index 0000000..d6941ce --- /dev/null +++ b/src/FCode.TraceHub.Api/Controllers/ProjectsController.cs @@ -0,0 +1,83 @@ +using FCode.TraceHub.Application.Projects; +using Microsoft.AspNetCore.Mvc; + +namespace FCode.TraceHub.Api.Controllers; + +[ApiController] +[Route("api/projects")] +[Produces("application/json")] +[Tags("Projects")] +public sealed class ProjectsController(IProjectService projectService) : + ControllerBase +{ + [HttpPost(Name = "CreateProject")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType( + StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Create( + CreateProjectRequest request, + CancellationToken cancellationToken) + { + var project = await projectService.CreateAsync( + request, + cancellationToken); + + return CreatedAtAction( + nameof(GetById), + new { id = project.Id }, + project); + } + + [HttpGet(Name = "ListProjects")] + [ProducesResponseType>( + StatusCodes.Status200OK)] + public async Task>> List( + CancellationToken cancellationToken) + { + var projects = await projectService.ListAsync(cancellationToken); + return Ok(projects); + } + + [HttpGet("{id:guid}", Name = "GetProjectById")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById( + Guid id, + CancellationToken cancellationToken) + { + var project = await projectService.GetByIdAsync( + id, + cancellationToken); + return Ok(project); + } + + [HttpPut("{id:guid}", Name = "UpdateProject")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType( + StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Update( + Guid id, + UpdateProjectRequest request, + CancellationToken cancellationToken) + { + var project = await projectService.UpdateAsync( + id, + request, + cancellationToken); + return Ok(project); + } + + [HttpDelete("{id:guid}", Name = "ArchiveProject")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Archive( + Guid id, + CancellationToken cancellationToken) + { + await projectService.ArchiveAsync(id, cancellationToken); + return NoContent(); + } +} diff --git a/src/FCode.TraceHub.Api/ErrorHandling/ApiExceptionHandler.cs b/src/FCode.TraceHub.Api/ErrorHandling/ApiExceptionHandler.cs new file mode 100644 index 0000000..73e0180 --- /dev/null +++ b/src/FCode.TraceHub.Api/ErrorHandling/ApiExceptionHandler.cs @@ -0,0 +1,41 @@ +using FCode.TraceHub.Application.Projects; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; + +namespace FCode.TraceHub.Api.ErrorHandling; + +public sealed class ApiExceptionHandler : IExceptionHandler +{ + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + IResult? result = exception switch + { + ProjectValidationException validationException => + Results.ValidationProblem( + validationException.Errors, + statusCode: StatusCodes.Status400BadRequest, + title: "Project validation failed."), + ProjectNotFoundException notFoundException => Results.Problem( + statusCode: StatusCodes.Status404NotFound, + title: "Project not found.", + detail: notFoundException.Message), + DuplicateProjectSlugException conflictException => + Results.Problem( + statusCode: StatusCodes.Status409Conflict, + title: "Project slug conflict.", + detail: conflictException.Message), + _ => null + }; + + if (result is null) + { + return false; + } + + await result.ExecuteAsync(httpContext); + return true; + } +} diff --git a/src/FCode.TraceHub.Api/FCode.TraceHub.Api.http b/src/FCode.TraceHub.Api/FCode.TraceHub.Api.http index 1f12bc9..a87a50c 100644 --- a/src/FCode.TraceHub.Api/FCode.TraceHub.Api.http +++ b/src/FCode.TraceHub.Api/FCode.TraceHub.Api.http @@ -1,6 +1,35 @@ @FCode.TraceHub.Api_HostAddress = http://localhost:5177 -GET {{FCode.TraceHub.Api_HostAddress}}/weatherforecast/ +POST {{FCode.TraceHub.Api_HostAddress}}/api/projects +Content-Type: application/json + +{ + "name": "TraceHub API", + "slug": "tracehub-api", + "description": "Main TraceHub API" +} + +### + +GET {{FCode.TraceHub.Api_HostAddress}}/api/projects Accept: application/json ### + +GET {{FCode.TraceHub.Api_HostAddress}}/api/projects/{{projectId}} +Accept: application/json + +### + +PUT {{FCode.TraceHub.Api_HostAddress}}/api/projects/{{projectId}} +Content-Type: application/json + +{ + "name": "TraceHub API", + "slug": "tracehub-api", + "description": "Updated TraceHub API" +} + +### + +DELETE {{FCode.TraceHub.Api_HostAddress}}/api/projects/{{projectId}} diff --git a/src/FCode.TraceHub.Api/Program.cs b/src/FCode.TraceHub.Api/Program.cs index afc3889..de98d6f 100644 --- a/src/FCode.TraceHub.Api/Program.cs +++ b/src/FCode.TraceHub.Api/Program.cs @@ -1,17 +1,35 @@ +using FCode.TraceHub.Api.ErrorHandling; +using FCode.TraceHub.Application.Projects; using FCode.TraceHub.Infrastructure; var builder = WebApplication.CreateBuilder(args); builder.Services.AddInfrastructure(builder.Configuration); +builder.Services.AddScoped(); +builder.Services.AddProblemDetails(); +builder.Services.AddExceptionHandler(); // Add services to the container. builder.Services.AddControllers(); // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddOpenApi(); +builder.Services.AddOpenApi(options => +{ + options.AddDocumentTransformer((document, _, _) => + { + document.Info.Title = "FCODE TraceHub API"; + document.Info.Version = "v1"; + document.Info.Description = + "Project management and TraceHub platform API."; + + return Task.CompletedTask; + }); +}); var app = builder.Build(); +app.UseExceptionHandler(); + // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { @@ -26,3 +44,5 @@ app.MapHealthChecks("/health"); app.Run(); + +public partial class Program; diff --git a/src/FCode.TraceHub.Application/Projects/IProjectRepository.cs b/src/FCode.TraceHub.Application/Projects/IProjectRepository.cs new file mode 100644 index 0000000..1087326 --- /dev/null +++ b/src/FCode.TraceHub.Application/Projects/IProjectRepository.cs @@ -0,0 +1,22 @@ +using FCode.TraceHub.Domain.Projects; + +namespace FCode.TraceHub.Application.Projects; + +public interface IProjectRepository +{ + Task AddAsync(Project project, CancellationToken cancellationToken); + + Task GetByIdAsync( + Guid id, + CancellationToken cancellationToken); + + Task> ListAsync( + CancellationToken cancellationToken); + + Task SlugExistsAsync( + string slug, + Guid? excludedProjectId, + CancellationToken cancellationToken); + + Task SaveChangesAsync(CancellationToken cancellationToken); +} diff --git a/src/FCode.TraceHub.Application/Projects/IProjectService.cs b/src/FCode.TraceHub.Application/Projects/IProjectService.cs new file mode 100644 index 0000000..9ce63d2 --- /dev/null +++ b/src/FCode.TraceHub.Application/Projects/IProjectService.cs @@ -0,0 +1,22 @@ +namespace FCode.TraceHub.Application.Projects; + +public interface IProjectService +{ + Task CreateAsync( + CreateProjectRequest request, + CancellationToken cancellationToken); + + Task GetByIdAsync( + Guid id, + CancellationToken cancellationToken); + + Task> ListAsync( + CancellationToken cancellationToken); + + Task UpdateAsync( + Guid id, + UpdateProjectRequest request, + CancellationToken cancellationToken); + + Task ArchiveAsync(Guid id, CancellationToken cancellationToken); +} diff --git a/src/FCode.TraceHub.Application/Projects/ProjectContracts.cs b/src/FCode.TraceHub.Application/Projects/ProjectContracts.cs new file mode 100644 index 0000000..f6bf46d --- /dev/null +++ b/src/FCode.TraceHub.Application/Projects/ProjectContracts.cs @@ -0,0 +1,20 @@ +namespace FCode.TraceHub.Application.Projects; + +public sealed record CreateProjectRequest( + string? Name, + string? Slug, + string? Description); + +public sealed record UpdateProjectRequest( + string? Name, + string? Slug, + string? Description); + +public sealed record ProjectResponse( + Guid Id, + string Name, + string Slug, + string? Description, + bool IsActive, + DateTime CreatedAtUtc, + DateTime? UpdatedAtUtc); diff --git a/src/FCode.TraceHub.Application/Projects/ProjectExceptions.cs b/src/FCode.TraceHub.Application/Projects/ProjectExceptions.cs new file mode 100644 index 0000000..6c221bb --- /dev/null +++ b/src/FCode.TraceHub.Application/Projects/ProjectExceptions.cs @@ -0,0 +1,31 @@ +namespace FCode.TraceHub.Application.Projects; + +public sealed class ProjectValidationException( + IReadOnlyDictionary errors) : Exception( + "One or more project values are invalid.") +{ + public IReadOnlyDictionary Errors { get; } = errors; +} + +public sealed class ProjectNotFoundException(Guid projectId) : Exception( + $"Project '{projectId}' was not found.") +{ + public Guid ProjectId { get; } = projectId; +} + +public sealed class DuplicateProjectSlugException : Exception +{ + public DuplicateProjectSlugException(string slug) + : base($"A project with slug '{slug}' already exists.") + { + Slug = slug; + } + + public DuplicateProjectSlugException(string slug, Exception innerException) + : base($"A project with slug '{slug}' already exists.", innerException) + { + Slug = slug; + } + + public string Slug { get; } +} diff --git a/src/FCode.TraceHub.Application/Projects/ProjectService.cs b/src/FCode.TraceHub.Application/Projects/ProjectService.cs new file mode 100644 index 0000000..eb4ab01 --- /dev/null +++ b/src/FCode.TraceHub.Application/Projects/ProjectService.cs @@ -0,0 +1,155 @@ +using FCode.TraceHub.Domain.Projects; + +namespace FCode.TraceHub.Application.Projects; + +public sealed class ProjectService(IProjectRepository repository) : + IProjectService +{ + public const int NameMaxLength = 200; + public const int SlugMaxLength = 100; + public const int DescriptionMaxLength = 1000; + + public async Task CreateAsync( + CreateProjectRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + Validate(request.Name, request.Slug, request.Description); + + var normalizedSlug = NormalizeSlug(request.Slug!); + if (await repository.SlugExistsAsync( + normalizedSlug, + null, + cancellationToken)) + { + throw new DuplicateProjectSlugException(normalizedSlug); + } + + var project = new Project( + request.Name!, + normalizedSlug, + request.Description); + + await repository.AddAsync(project, cancellationToken); + await repository.SaveChangesAsync(cancellationToken); + + return Map(project); + } + + public async Task GetByIdAsync( + Guid id, + CancellationToken cancellationToken) + { + var project = await GetRequiredProjectAsync(id, cancellationToken); + return Map(project); + } + + public async Task> ListAsync( + CancellationToken cancellationToken) + { + var projects = await repository.ListAsync(cancellationToken); + return projects.Select(Map).ToArray(); + } + + public async Task UpdateAsync( + Guid id, + UpdateProjectRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + Validate(request.Name, request.Slug, request.Description); + + var project = await GetRequiredProjectAsync(id, cancellationToken); + var normalizedSlug = NormalizeSlug(request.Slug!); + + if (await repository.SlugExistsAsync( + normalizedSlug, + project.Id, + cancellationToken)) + { + throw new DuplicateProjectSlugException(normalizedSlug); + } + + project.Update(request.Name!, normalizedSlug, request.Description); + await repository.SaveChangesAsync(cancellationToken); + + return Map(project); + } + + public async Task ArchiveAsync( + Guid id, + CancellationToken cancellationToken) + { + var project = await GetRequiredProjectAsync(id, cancellationToken); + + if (project.IsActive) + { + project.Deactivate(); + await repository.SaveChangesAsync(cancellationToken); + } + } + + private async Task GetRequiredProjectAsync( + Guid id, + CancellationToken cancellationToken) + { + if (id == Guid.Empty) + { + throw new ProjectNotFoundException(id); + } + + return await repository.GetByIdAsync(id, cancellationToken) + ?? throw new ProjectNotFoundException(id); + } + + private static void Validate( + string? name, + string? slug, + string? description) + { + var errors = new Dictionary(); + + if (string.IsNullOrWhiteSpace(name)) + { + errors[nameof(name)] = ["Project name is required."]; + } + else if (name.Trim().Length > NameMaxLength) + { + errors[nameof(name)] = + [$"Project name must not exceed {NameMaxLength} characters."]; + } + + if (string.IsNullOrWhiteSpace(slug)) + { + errors[nameof(slug)] = ["Project slug is required."]; + } + else if (slug.Trim().Length > SlugMaxLength) + { + errors[nameof(slug)] = + [$"Project slug must not exceed {SlugMaxLength} characters."]; + } + + if (description?.Trim().Length > DescriptionMaxLength) + { + errors[nameof(description)] = + [$"Project description must not exceed {DescriptionMaxLength} characters."]; + } + + if (errors.Count > 0) + { + throw new ProjectValidationException(errors); + } + } + + private static string NormalizeSlug(string slug) => + slug.Trim().ToLowerInvariant(); + + private static ProjectResponse Map(Project project) => new( + project.Id, + project.Name, + project.Slug, + project.Description, + project.IsActive, + project.CreatedAtUtc, + project.UpdatedAtUtc); +} diff --git a/src/FCode.TraceHub.Infrastructure/DependencyInjection.cs b/src/FCode.TraceHub.Infrastructure/DependencyInjection.cs index bd12c2d..8494b93 100644 --- a/src/FCode.TraceHub.Infrastructure/DependencyInjection.cs +++ b/src/FCode.TraceHub.Infrastructure/DependencyInjection.cs @@ -1,5 +1,7 @@ using FCode.TraceHub.Application.Security; +using FCode.TraceHub.Application.Projects; using FCode.TraceHub.Infrastructure.Persistence; +using FCode.TraceHub.Infrastructure.Persistence.Repositories; using FCode.TraceHub.Infrastructure.Security; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -30,6 +32,7 @@ public static IServiceCollection AddInfrastructure( .AddDbContextCheck("tracehub-database"); services.AddSingleton(); + services.AddScoped(); return services; } diff --git a/src/FCode.TraceHub.Infrastructure/Persistence/Repositories/ProjectRepository.cs b/src/FCode.TraceHub.Infrastructure/Persistence/Repositories/ProjectRepository.cs new file mode 100644 index 0000000..db7862f --- /dev/null +++ b/src/FCode.TraceHub.Infrastructure/Persistence/Repositories/ProjectRepository.cs @@ -0,0 +1,66 @@ +using FCode.TraceHub.Application.Projects; +using FCode.TraceHub.Domain.Projects; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace FCode.TraceHub.Infrastructure.Persistence.Repositories; + +public sealed class ProjectRepository(TraceHubDbContext dbContext) : + IProjectRepository +{ + public async Task AddAsync( + Project project, + CancellationToken cancellationToken) + { + await dbContext.Projects.AddAsync(project, cancellationToken); + } + + public Task GetByIdAsync( + Guid id, + CancellationToken cancellationToken) => + dbContext.Projects.SingleOrDefaultAsync( + project => project.Id == id, + cancellationToken); + + public async Task> ListAsync( + CancellationToken cancellationToken) => + await dbContext.Projects + .AsNoTracking() + .OrderBy(project => project.Name) + .ThenBy(project => project.Id) + .ToListAsync(cancellationToken); + + public Task SlugExistsAsync( + string slug, + Guid? excludedProjectId, + CancellationToken cancellationToken) => + dbContext.Projects.AnyAsync( + project => + project.Slug == slug && + (!excludedProjectId.HasValue || + project.Id != excludedProjectId.Value), + cancellationToken); + + public async Task SaveChangesAsync( + CancellationToken cancellationToken) + { + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException exception) + when (exception.InnerException is PostgresException + { + SqlState: PostgresErrorCodes.UniqueViolation, + ConstraintName: "IX_Projects_Slug" + }) + { + var slug = dbContext.ChangeTracker + .Entries() + .Select(entry => entry.Entity.Slug) + .FirstOrDefault() ?? "unknown"; + + throw new DuplicateProjectSlugException(slug, exception); + } + } +} diff --git a/tests/FCode.TraceHub.IntegrationTests/Api/ProjectEndpointsTests.cs b/tests/FCode.TraceHub.IntegrationTests/Api/ProjectEndpointsTests.cs new file mode 100644 index 0000000..3a68010 --- /dev/null +++ b/tests/FCode.TraceHub.IntegrationTests/Api/ProjectEndpointsTests.cs @@ -0,0 +1,134 @@ +using System.Net; +using System.Net.Http.Json; +using FCode.TraceHub.Application.Projects; +using FCode.TraceHub.IntegrationTests.Persistence; +using Microsoft.AspNetCore.Mvc; + +namespace FCode.TraceHub.IntegrationTests.Api; + +[Collection(PostgreSqlCollection.Name)] +public sealed class ProjectEndpointsTests(PostgreSqlFixture fixture) +{ + [Fact] + public async Task ProjectLifecycle_ReturnsExpectedResponses() + { + var suffix = Guid.NewGuid().ToString("N"); + var createResponse = await fixture.Client.PostAsJsonAsync( + "/api/projects", + new CreateProjectRequest( + $"Project {suffix}", + $" PROJECT-{suffix} ", + "Created by integration test"), + CancellationToken.None); + + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + var created = await createResponse.Content + .ReadFromJsonAsync( + CancellationToken.None); + Assert.NotNull(created); + Assert.Equal($"project-{suffix}", created.Slug); + Assert.EndsWith( + $"/api/projects/{created.Id}", + createResponse.Headers.Location?.ToString()); + + var getResponse = await fixture.Client.GetAsync( + $"/api/projects/{created.Id}", + CancellationToken.None); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + + var list = await fixture.Client.GetFromJsonAsync( + "/api/projects", + CancellationToken.None); + Assert.Contains(list!, project => project.Id == created.Id); + + var updateResponse = await fixture.Client.PutAsJsonAsync( + $"/api/projects/{created.Id}", + new UpdateProjectRequest( + $"Updated {suffix}", + $" UPDATED-{suffix} ", + null), + CancellationToken.None); + Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode); + var updated = await updateResponse.Content + .ReadFromJsonAsync( + CancellationToken.None); + Assert.Equal($"updated-{suffix}", updated!.Slug); + + var deleteResponse = await fixture.Client.DeleteAsync( + $"/api/projects/{created.Id}", + CancellationToken.None); + Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); + + var archived = await fixture.Client.GetFromJsonAsync( + $"/api/projects/{created.Id}", + CancellationToken.None); + Assert.False(archived!.IsActive); + } + + [Fact] + public async Task Create_WithInvalidValues_ReturnsValidationProblem() + { + var response = await fixture.Client.PostAsJsonAsync( + "/api/projects", + new CreateProjectRequest(" ", " ", null), + CancellationToken.None); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var problem = await response.Content + .ReadFromJsonAsync( + CancellationToken.None); + Assert.NotNull(problem); + Assert.Contains("name", problem.Errors.Keys); + Assert.Contains("slug", problem.Errors.Keys); + } + + [Fact] + public async Task Create_WithDuplicateSlug_ReturnsConflictProblem() + { + var suffix = Guid.NewGuid().ToString("N"); + var slug = $"duplicate-{suffix}"; + var firstResponse = await fixture.Client.PostAsJsonAsync( + "/api/projects", + new CreateProjectRequest("First", slug, null), + CancellationToken.None); + firstResponse.EnsureSuccessStatusCode(); + + var duplicateResponse = await fixture.Client.PostAsJsonAsync( + "/api/projects", + new CreateProjectRequest("Second", $" {slug.ToUpperInvariant()} ", null), + CancellationToken.None); + + Assert.Equal(HttpStatusCode.Conflict, duplicateResponse.StatusCode); + var problem = await duplicateResponse.Content + .ReadFromJsonAsync( + CancellationToken.None); + Assert.Equal("Project slug conflict.", problem!.Title); + } + + [Fact] + public async Task MissingProjectEndpoints_ReturnNotFoundProblem() + { + var id = Guid.NewGuid(); + + var getResponse = await fixture.Client.GetAsync( + $"/api/projects/{id}", + CancellationToken.None); + Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); + + var updateResponse = await fixture.Client.PutAsJsonAsync( + $"/api/projects/{id}", + new UpdateProjectRequest("Missing", "missing", null), + CancellationToken.None); + Assert.Equal(HttpStatusCode.NotFound, updateResponse.StatusCode); + + var deleteResponse = await fixture.Client.DeleteAsync( + $"/api/projects/{id}", + CancellationToken.None); + Assert.Equal(HttpStatusCode.NotFound, deleteResponse.StatusCode); + + var problem = await getResponse.Content + .ReadFromJsonAsync( + CancellationToken.None); + Assert.Equal("Project not found.", problem!.Title); + } +} diff --git a/tests/FCode.TraceHub.IntegrationTests/FCode.TraceHub.IntegrationTests.csproj b/tests/FCode.TraceHub.IntegrationTests/FCode.TraceHub.IntegrationTests.csproj index 9a4657f..573d68e 100644 --- a/tests/FCode.TraceHub.IntegrationTests/FCode.TraceHub.IntegrationTests.csproj +++ b/tests/FCode.TraceHub.IntegrationTests/FCode.TraceHub.IntegrationTests.csproj @@ -10,6 +10,7 @@ + diff --git a/tests/FCode.TraceHub.IntegrationTests/Persistence/PostgreSqlFixture.cs b/tests/FCode.TraceHub.IntegrationTests/Persistence/PostgreSqlFixture.cs index d2e177c..7dfe5a1 100644 --- a/tests/FCode.TraceHub.IntegrationTests/Persistence/PostgreSqlFixture.cs +++ b/tests/FCode.TraceHub.IntegrationTests/Persistence/PostgreSqlFixture.cs @@ -1,4 +1,9 @@ using FCode.TraceHub.Infrastructure.Persistence; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.EntityFrameworkCore; using Testcontainers.PostgreSql; @@ -15,16 +20,32 @@ public sealed class PostgreSqlFixture : IAsyncLifetime public string ConnectionString => _container.GetConnectionString(); + public HttpClient Client { get; private set; } = null!; + + private WebApplicationFactory? ApiFactory { get; set; } + public async Task InitializeAsync() { await _container.StartAsync(); await using var dbContext = CreateDbContext(); await dbContext.Database.MigrateAsync(); + + ApiFactory = new TraceHubApiFactory(ConnectionString); + Client = ApiFactory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); } public async Task DisposeAsync() { + Client?.Dispose(); + if (ApiFactory is not null) + { + await ApiFactory.DisposeAsync(); + } + await _container.DisposeAsync(); } @@ -38,6 +59,29 @@ public TraceHubDbContext CreateDbContext() } } +internal sealed class TraceHubApiFactory(string connectionString) : + WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureAppConfiguration((_, configuration) => + { + configuration.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:TraceHubDatabase"] = connectionString + }); + }); + + builder.ConfigureServices(services => + { + services.RemoveAll>(); + services.RemoveAll(); + services.AddDbContext(options => + options.UseNpgsql(connectionString)); + }); + } +} + [CollectionDefinition(Name)] public sealed class PostgreSqlCollection : ICollectionFixture diff --git a/tests/FCode.TraceHub.UnitTests/Application/ProjectServiceTests.cs b/tests/FCode.TraceHub.UnitTests/Application/ProjectServiceTests.cs new file mode 100644 index 0000000..b970bc6 --- /dev/null +++ b/tests/FCode.TraceHub.UnitTests/Application/ProjectServiceTests.cs @@ -0,0 +1,192 @@ +using FCode.TraceHub.Application.Projects; +using FCode.TraceHub.Domain.Projects; + +namespace FCode.TraceHub.UnitTests.Application; + +public sealed class ProjectServiceTests +{ + [Fact] + public async Task CreateAsync_CreatesNormalizedProject() + { + var repository = new FakeProjectRepository(); + var service = new ProjectService(repository); + + var result = await service.CreateAsync( + new CreateProjectRequest( + " TraceHub ", + " TRACEHUB ", + " Monitoring "), + CancellationToken.None); + + Assert.Equal("TraceHub", result.Name); + Assert.Equal("tracehub", result.Slug); + Assert.Equal("Monitoring", result.Description); + Assert.True(result.IsActive); + Assert.Equal(1, repository.SaveCount); + } + + [Fact] + public async Task CreateAsync_RejectsMissingAndOversizedValues() + { + var service = new ProjectService(new FakeProjectRepository()); + + var exception = await Assert.ThrowsAsync( + () => service.CreateAsync( + new CreateProjectRequest( + " ", + new string('s', ProjectService.SlugMaxLength + 1), + null), + CancellationToken.None)); + + Assert.Contains("name", exception.Errors.Keys); + Assert.Contains("slug", exception.Errors.Keys); + } + + [Fact] + public async Task CreateAsync_RejectsDuplicateNormalizedSlug() + { + var repository = new FakeProjectRepository(); + repository.Projects.Add(new Project("Existing", "existing")); + var service = new ProjectService(repository); + + var exception = await Assert.ThrowsAsync( + () => service.CreateAsync( + new CreateProjectRequest("New", " EXISTING ", null), + CancellationToken.None)); + + Assert.Equal("existing", exception.Slug); + } + + [Fact] + public async Task GetByIdAsync_ReturnsProject() + { + var repository = new FakeProjectRepository(); + var project = new Project("TraceHub", "tracehub"); + repository.Projects.Add(project); + var service = new ProjectService(repository); + + var result = await service.GetByIdAsync( + project.Id, + CancellationToken.None); + + Assert.Equal(project.Id, result.Id); + } + + [Fact] + public async Task ListAsync_ReturnsMappedProjects() + { + var repository = new FakeProjectRepository(); + repository.Projects.Add(new Project("First", "first")); + repository.Projects.Add(new Project("Second", "second")); + var service = new ProjectService(repository); + + var result = await service.ListAsync( + CancellationToken.None); + + Assert.Equal(2, result.Count); + Assert.All(result, project => Assert.True(project.IsActive)); + } + + [Fact] + public async Task UpdateAsync_UpdatesAndNormalizesProject() + { + var repository = new FakeProjectRepository(); + var project = new Project("Old", "old"); + repository.Projects.Add(project); + var service = new ProjectService(repository); + + var result = await service.UpdateAsync( + project.Id, + new UpdateProjectRequest(" New ", " NEW-SLUG ", " Updated "), + CancellationToken.None); + + Assert.Equal("New", result.Name); + Assert.Equal("new-slug", result.Slug); + Assert.Equal("Updated", result.Description); + Assert.NotNull(result.UpdatedAtUtc); + } + + [Fact] + public async Task UpdateAsync_RejectsAnotherProjectsSlug() + { + var repository = new FakeProjectRepository(); + var project = new Project("First", "first"); + repository.Projects.Add(project); + repository.Projects.Add(new Project("Second", "second")); + var service = new ProjectService(repository); + + var exception = await Assert.ThrowsAsync( + () => service.UpdateAsync( + project.Id, + new UpdateProjectRequest("First", " SECOND ", null), + CancellationToken.None)); + + Assert.Equal("second", exception.Slug); + } + + [Fact] + public async Task ArchiveAsync_DeactivatesProject() + { + var repository = new FakeProjectRepository(); + var project = new Project("TraceHub", "tracehub"); + repository.Projects.Add(project); + var service = new ProjectService(repository); + + await service.ArchiveAsync( + project.Id, + CancellationToken.None); + + Assert.False(project.IsActive); + Assert.Equal(1, repository.SaveCount); + } + + [Fact] + public async Task GetByIdAsync_RejectsUnknownProject() + { + var service = new ProjectService(new FakeProjectRepository()); + + await Assert.ThrowsAsync( + () => service.GetByIdAsync( + Guid.NewGuid(), + CancellationToken.None)); + } + + private sealed class FakeProjectRepository : IProjectRepository + { + public List Projects { get; } = []; + + public int SaveCount { get; private set; } + + public Task AddAsync( + Project project, + CancellationToken cancellationToken) + { + Projects.Add(project); + return Task.CompletedTask; + } + + public Task GetByIdAsync( + Guid id, + CancellationToken cancellationToken) => + Task.FromResult(Projects.SingleOrDefault(project => + project.Id == id)); + + public Task> ListAsync( + CancellationToken cancellationToken) => + Task.FromResult>(Projects.ToArray()); + + public Task SlugExistsAsync( + string slug, + Guid? excludedProjectId, + CancellationToken cancellationToken) => + Task.FromResult(Projects.Any(project => + project.Slug == slug && + project.Id != excludedProjectId)); + + public Task SaveChangesAsync(CancellationToken cancellationToken) + { + SaveCount++; + return Task.CompletedTask; + } + } +}