diff --git a/.dockerignore b/.dockerignore index 29a98aa..ce19690 100644 --- a/.dockerignore +++ b/.dockerignore @@ -33,6 +33,7 @@ README.md **/*Tests.cs **/*TestsRelated.cs **/TestsConfig/** +**/HttpClientTestBase.cs **/bin/* **/obj/* diff --git a/Api/Api.csproj b/Api/Api.csproj index f1f7922..1041a1a 100644 --- a/Api/Api.csproj +++ b/Api/Api.csproj @@ -13,6 +13,7 @@ + all @@ -23,6 +24,7 @@ + all diff --git a/Api/Features/Tracking/TrackingControllerTests.cs b/Api/Features/Tracking/TrackingControllerTests.cs new file mode 100644 index 0000000..eeb7d61 --- /dev/null +++ b/Api/Features/Tracking/TrackingControllerTests.cs @@ -0,0 +1,41 @@ +using Api.Features.Tracking.CreateWorkEntry; +using Application.Commands; +using Application.TestsConfig; +using Core.Entities; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace Api.Features.Tracking; + +[IntegrationTest] +public class TrackingControllerTests : HttpClientTestBase +{ + public TrackingControllerTests(WebApplicationFactory factory) : base(factory) + { + } + + [Fact] + public async Task CreateWorkEntryAsync_ShouldThrowValidationErrorIfAtLeastOneOfRequiredFieldsIsEmpty() + { + var createWorkEntryRequest = new CreateWorkEntryRequest + { + Title = "", + StartTime = new DateTime(2025, 11, 24, 9, 0, 0), + EndTime = new DateTime(2025, 11, 24, 10, 0, 0), + TaskId = "#2231", + Description = "Task description", + }; + + var response = await HttpClient.PostAsJsonAsync("/api/time/tracking/work-entries", createWorkEntryRequest); + + Assert.NotNull(response); + Assert.Equal(StatusCodes.Status400BadRequest, (int)response.StatusCode); + + var validationProblemDetails = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(validationProblemDetails); + Assert.Equal("Fill in all the fields", validationProblemDetails.Detail); + Assert.Equal("Validation error", validationProblemDetails.Title); + } +} diff --git a/Api/HttpClientTestBase.cs b/Api/HttpClientTestBase.cs new file mode 100644 index 0000000..cc57217 --- /dev/null +++ b/Api/HttpClientTestBase.cs @@ -0,0 +1,84 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Api; +using Application; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +public class HttpClientTestBase : IClassFixture>, IAsyncLifetime +{ + protected const long EMPLOYEE_ID = 1; + protected const long TENANT_ID = 777; + + protected HttpClient HttpClient = null!; + private WebApplicationFactory _factory = null!; + + public HttpClientTestBase(WebApplicationFactory factory) + { + _factory = factory; + } + + public async Task InitializeAsync() + { + _factory = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + // Replacing the authentication service with a fake + services + .AddAuthentication("Test") + .AddScheme("Test", options => { }); + + // Add fake mockClaimsProbider + var mockClaimsProvider = new Mock(); + mockClaimsProvider + .Setup(x => x.EmployeeId) + .Returns(EMPLOYEE_ID); + mockClaimsProvider + .Setup(x => x.TenantId) + .Returns(TENANT_ID); + + services.AddScoped(_ => mockClaimsProvider.Object); + }); + }); + + HttpClient = _factory.CreateClient(); + } + + public async Task DisposeAsync() + { + HttpClient.Dispose(); + _factory.Dispose(); + } +} + +public class FakeAuthHandler : AuthenticationHandler +{ + public FakeAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder + ) : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + var claims = new[] + { + // Adding permissions claims to bypass permissions + new Claim("permissions", UserClaimsProvider.CanManagePersonalTimeTracker), + new Claim("permissions", UserClaimsProvider.AUTO_TESTS_ONLY_IsWorkEntriesHardDeleteAllowed) + }; + + var identity = new ClaimsIdentity(claims, "Test"); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, "Test"); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} diff --git a/Api/Program.cs b/Api/Program.cs index cb6cb84..0c657ad 100644 --- a/Api/Program.cs +++ b/Api/Program.cs @@ -1,4 +1,6 @@ using Application; +using Hellang.Middleware.ProblemDetails; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.EntityFrameworkCore; using TourmalineCore.AspNetCore.JwtAuthentication.Core; @@ -34,12 +36,46 @@ public static void Main(string[] args) builder.Services.AddApplication(configuration); + builder.Services.AddProblemDetails(options => + { + options.IncludeExceptionDetails = (ctx, ex) => builder.Environment.IsDevelopment(); + + options.Map(ex => + { + return new ProblemDetails + { + Title = "Invalid time range", + Status = StatusCodes.Status400BadRequest, + Detail = ex.Message, + }; + }); + }); + + builder.Services.AddControllers() + .ConfigureApiBehaviorOptions(options => + { + options.InvalidModelStateResponseFactory = context => + { + var problemDetails = new ValidationProblemDetails(context.ModelState) + { + Title = "Validation error", + Status = StatusCodes.Status400BadRequest, + Detail = "Fill in all the fields", + Instance = context.HttpContext.Request.Path + }; + + throw new ProblemDetailsException(problemDetails); + }; + }); + var authenticationOptions = configuration.GetSection(nameof(AuthenticationOptions)).Get(); builder.Services.Configure(configuration.GetSection(nameof(AuthenticationOptions))); builder.Services.AddJwtAuthentication(authenticationOptions).WithUserClaimsProvider(UserClaimsProvider.PermissionClaimType); var app = builder.Build(); + app.UseProblemDetails(); + app.MapOpenApi("api/swagger/openapi/v1.json"); app.UseSwaggerUI(options => @@ -48,14 +84,18 @@ public static void Main(string[] args) options.RoutePrefix = "api/swagger"; }); - using (var serviceScope = app.Services.CreateScope()) - { - var context = serviceScope.ServiceProvider.GetRequiredService(); - context.Database.Migrate(); - } + MigrateDatabase(app.Services); app.MapControllers(); app.Run(); } + + private static void MigrateDatabase(IServiceProvider serviceProvider) + { + using var serviceScope = serviceProvider.CreateScope(); + + var context = serviceScope.ServiceProvider.GetRequiredService(); + context.Database.Migrate(); + } } diff --git a/Application/AppDbContext.cs b/Application/AppDbContext.cs index c22d60a..51f3477 100644 --- a/Application/AppDbContext.cs +++ b/Application/AppDbContext.cs @@ -25,22 +25,28 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder .Entity() - .Property(p => p.Duration) + .Property(entry => entry.Duration) .HasComputedColumnSql("end_time - start_time", stored: true); modelBuilder .Entity() - .Property(e => e.StartTime) + .Property(entry => entry.StartTime) .HasColumnType("timestamp without time zone"); modelBuilder .Entity() - .Property(e => e.EndTime) + .Property(entry => entry.EndTime) .HasColumnType("timestamp without time zone"); modelBuilder .Entity() - .ToTable(b => b.HasCheckConstraint("ck_work_entries_type_not_zero", "\"type\" <> 0")); + .ToTable(table => table.HasCheckConstraint("ck_work_entries_type_not_zero", "\"type\" <> 0")); + + modelBuilder + .Entity() + .ToTable(table => table.HasCheckConstraint( + "ck_work_entries_end_time_is_greater_than_start_time", + "\"end_time\" > \"start_time\"")); base.OnModelCreating(modelBuilder); } diff --git a/Application/Commands/CreateWorkEntryCommand.cs b/Application/Commands/CreateWorkEntryCommand.cs index 723d7c7..5568104 100644 --- a/Application/Commands/CreateWorkEntryCommand.cs +++ b/Application/Commands/CreateWorkEntryCommand.cs @@ -1,4 +1,6 @@ using Core.Entities; +using Microsoft.EntityFrameworkCore; +using Npgsql; namespace Application.Commands; @@ -33,24 +35,35 @@ IClaimsProvider claimsProvider public async Task ExecuteAsync(CreateWorkEntryCommandParams createWorkEntryCommandParams) { - var workEntry = new WorkEntry + try { - TenantId = _claimsProvider.TenantId, - EmployeeId = _claimsProvider.EmployeeId, - Title = createWorkEntryCommandParams.Title, - StartTime = createWorkEntryCommandParams.StartTime, - EndTime = createWorkEntryCommandParams.EndTime, - TaskId = createWorkEntryCommandParams.TaskId, - Description = createWorkEntryCommandParams.Description, - Type = createWorkEntryCommandParams.Type, - }; - - await _context - .WorkEntries - .AddAsync(workEntry); - - await _context.SaveChangesAsync(); - - return workEntry.Id; + var workEntry = new WorkEntry + { + TenantId = _claimsProvider.TenantId, + EmployeeId = _claimsProvider.EmployeeId, + Title = createWorkEntryCommandParams.Title, + StartTime = createWorkEntryCommandParams.StartTime, + EndTime = createWorkEntryCommandParams.EndTime, + TaskId = createWorkEntryCommandParams.TaskId, + Description = createWorkEntryCommandParams.Description, + Type = createWorkEntryCommandParams.Type, + }; + + await _context + .WorkEntries + .AddAsync(workEntry); + + await _context.SaveChangesAsync(); + + return workEntry.Id; + } + catch (DbUpdateException ex) when (ex.InnerException is PostgresException pgEx && + pgEx.ConstraintName == "ck_work_entries_end_time_is_greater_than_start_time") + { + throw new InvalidTimeRangeException( + "End time must be greater than start time", + ex + ); + } } } diff --git a/Application/Commands/CreateWorkEntryCommandTests.cs b/Application/Commands/CreateWorkEntryCommandTests.cs index 1e063ca..8f85f17 100644 --- a/Application/Commands/CreateWorkEntryCommandTests.cs +++ b/Application/Commands/CreateWorkEntryCommandTests.cs @@ -68,4 +68,31 @@ public async Task CreateWorkEntryAsync_ShouldThrowErrorIfTypeIsUnspecified() Assert.Contains("ck_work_entries_type_not_zero", ex.InnerException?.Message); } + + [Fact] + public async Task CreateWorkEntryAsync_ShouldThrowInvalidTimeRangeExceptionIfStartTimeIsGreaterEndTime() + { + var context = CreateTenantDbContext(); + + var mockClaimsProvider = GetMockClaimsProvider(); + + var createWorkEntryCommand = new CreateWorkEntryCommand(context, mockClaimsProvider); + + var createWorkEntryCommandParams = new CreateWorkEntryCommandParams + { + Title = "Task 1", + StartTime = new DateTime(2025, 11, 24, 11, 0, 0), + EndTime = new DateTime(2025, 11, 24, 10, 0, 0), + TaskId = "#2231", + Description = "Task description", + Type = EventType.Task + }; + + InvalidTimeRangeException ex = await Assert.ThrowsAsync( + async () => await createWorkEntryCommand.ExecuteAsync(createWorkEntryCommandParams) + ); + + Assert.Contains("ck_work_entries_end_time_is_greater_than_start_time", ex.InnerException?.InnerException?.Message); + Assert.Equal("End time must be greater than start time", ex.Message); + } } diff --git a/Application/Commands/UpdateWorkEntryCommand.cs b/Application/Commands/UpdateWorkEntryCommand.cs index d8f7e8f..5339e97 100644 --- a/Application/Commands/UpdateWorkEntryCommand.cs +++ b/Application/Commands/UpdateWorkEntryCommand.cs @@ -1,5 +1,6 @@ using Core.Entities; using Microsoft.EntityFrameworkCore; +using Npgsql; namespace Application.Commands; @@ -36,17 +37,27 @@ IClaimsProvider claimsProvider public async Task ExecuteAsync(UpdateWorkEntryCommandParams updateWorkEntryCommandParams) { - await _context - .QueryableWithinTenant() - .Where(x => x.EmployeeId == _claimsProvider.EmployeeId) - .Where(x => x.Id == updateWorkEntryCommandParams.Id) - .ExecuteUpdateAsync(setters => setters - .SetProperty(x => x.Title, updateWorkEntryCommandParams.Title) - .SetProperty(x => x.StartTime, updateWorkEntryCommandParams.StartTime) - .SetProperty(x => x.EndTime, updateWorkEntryCommandParams.EndTime) - .SetProperty(x => x.TaskId, updateWorkEntryCommandParams.TaskId) - .SetProperty(x => x.Description, updateWorkEntryCommandParams.Description) - .SetProperty(x => x.Type, updateWorkEntryCommandParams.Type) + try + { + await _context + .QueryableWithinTenant() + .Where(x => x.EmployeeId == _claimsProvider.EmployeeId) + .Where(x => x.Id == updateWorkEntryCommandParams.Id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Title, updateWorkEntryCommandParams.Title) + .SetProperty(x => x.StartTime, updateWorkEntryCommandParams.StartTime) + .SetProperty(x => x.EndTime, updateWorkEntryCommandParams.EndTime) + .SetProperty(x => x.TaskId, updateWorkEntryCommandParams.TaskId) + .SetProperty(x => x.Description, updateWorkEntryCommandParams.Description) + .SetProperty(x => x.Type, updateWorkEntryCommandParams.Type) + ); + } + catch (PostgresException pgEx) when (pgEx.ConstraintName == "ck_work_entries_end_time_is_greater_than_start_time") + { + throw new InvalidTimeRangeException( + "End time must be greater than start time", + pgEx ); + } } } diff --git a/Application/Commands/UpdateWorkEntryCommandTests.cs b/Application/Commands/UpdateWorkEntryCommandTests.cs index 5b96a3f..a29c955 100644 --- a/Application/Commands/UpdateWorkEntryCommandTests.cs +++ b/Application/Commands/UpdateWorkEntryCommandTests.cs @@ -51,4 +51,43 @@ public async Task UpdateWorkEntryAsync_ShouldUpdateWorkEntryDataInDb() Assert.Equal(updateWorkEntryCommandParams.Description, updatedWorkEntry.Description); Assert.Equal(updateWorkEntryCommandParams.EndTime - updateWorkEntryCommandParams.StartTime, updatedWorkEntry.Duration); } + + [Fact] + public async Task UpdateWorkEntryAsync_ShouldThrowInvalidTimeRangeExceptionIfStartTimeIsGreaterEndTime() + { + var context = CreateTenantDbContext(); + + var mockClaimsProvider = GetMockClaimsProvider(); + + var updateWorkEntryCommand = new UpdateWorkEntryCommand(context, mockClaimsProvider); + + var workEntry = await SaveEntityAsync(context, new WorkEntry + { + EmployeeId = EMPLOYEE_ID, + Title = "Task 1", + StartTime = new DateTime(2025, 11, 24, 9, 0, 0), + EndTime = new DateTime(2025, 11, 24, 10, 0, 0), + TaskId = "#2231", + Description = "Task description", + Type = EventType.Task + }); + + var updateWorkEntryCommandParams = new UpdateWorkEntryCommandParams + { + Id = workEntry.Id, + Title = "Task 2", + StartTime = new DateTime(2025, 11, 25, 12, 0, 0), + EndTime = new DateTime(2025, 11, 25, 11, 0, 0), + TaskId = "#22", + Description = "Task description", + Type = EventType.Task + }; + + InvalidTimeRangeException ex = await Assert.ThrowsAsync( + async () => await updateWorkEntryCommand.ExecuteAsync(updateWorkEntryCommandParams) + ); + + Assert.Contains("ck_work_entries_end_time_is_greater_than_start_time", ex.InnerException?.Message); + Assert.Equal("End time must be greater than start time", ex.Message); + } } diff --git a/Application/Exceptions/InvalidTimeRangeException.cs b/Application/Exceptions/InvalidTimeRangeException.cs new file mode 100644 index 0000000..f97eee3 --- /dev/null +++ b/Application/Exceptions/InvalidTimeRangeException.cs @@ -0,0 +1,6 @@ +public class InvalidTimeRangeException : Exception +{ + public InvalidTimeRangeException() { } + public InvalidTimeRangeException(string message) : base(message) { } + public InvalidTimeRangeException(string message, Exception inner) : base(message, inner) { } +} diff --git a/Application/Migrations/20251219093235_AddEndTimeIsGreaterThanStartTimeConstraintToWorkEntries.Designer.cs b/Application/Migrations/20251219093235_AddEndTimeIsGreaterThanStartTimeConstraintToWorkEntries.Designer.cs new file mode 100644 index 0000000..f04ee64 --- /dev/null +++ b/Application/Migrations/20251219093235_AddEndTimeIsGreaterThanStartTimeConstraintToWorkEntries.Designer.cs @@ -0,0 +1,99 @@ +// +using System; +using Application; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Application.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20251219093235_AddEndTimeIsGreaterThanStartTimeConstraintToWorkEntries")] + partial class AddEndTimeIsGreaterThanStartTimeConstraintToWorkEntries + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Core.Entities.WorkEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Duration") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("interval") + .HasColumnName("duration") + .HasComputedColumnSql("end_time - start_time", true); + + b.Property("EmployeeId") + .HasColumnType("bigint") + .HasColumnName("employee_id"); + + b.Property("EndTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("end_time"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("StartTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("start_time"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("task_id"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasColumnName("tenant_id"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_work_entries"); + + b.ToTable("work_entries", null, t => + { + t.HasCheckConstraint("ck_work_entries_end_time_is_greater_than_start_time", "\"end_time\" > \"start_time\""); + + t.HasCheckConstraint("ck_work_entries_type_not_zero", "\"type\" <> 0"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Application/Migrations/20251219093235_AddEndTimeIsGreaterThanStartTimeConstraintToWorkEntries.cs b/Application/Migrations/20251219093235_AddEndTimeIsGreaterThanStartTimeConstraintToWorkEntries.cs new file mode 100644 index 0000000..9b7511c --- /dev/null +++ b/Application/Migrations/20251219093235_AddEndTimeIsGreaterThanStartTimeConstraintToWorkEntries.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Application.Migrations +{ + /// + public partial class AddEndTimeIsGreaterThanStartTimeConstraintToWorkEntries : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddCheckConstraint( + name: "ck_work_entries_end_time_is_greater_than_start_time", + table: "work_entries", + sql: "\"end_time\" > \"start_time\""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "ck_work_entries_end_time_is_greater_than_start_time", + table: "work_entries"); + } + } +} diff --git a/Application/Migrations/AppDbContextModelSnapshot.cs b/Application/Migrations/AppDbContextModelSnapshot.cs index 185b764..2d9a4d5 100644 --- a/Application/Migrations/AppDbContextModelSnapshot.cs +++ b/Application/Migrations/AppDbContextModelSnapshot.cs @@ -85,6 +85,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("work_entries", null, t => { + t.HasCheckConstraint("ck_work_entries_end_time_is_greater_than_start_time", "\"end_time\" > \"start_time\""); + t.HasCheckConstraint("ck_work_entries_type_not_zero", "\"type\" <> 0"); }); }); diff --git a/README.md b/README.md index 6d28b70..37ca56c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # inner-circle-time-api -[![coverage](https://img.shields.io/badge/e2e_coverage-42.99%25-crimson)](https://github.com/TourmalineCore/inner-circle-time-api/actions/workflows/calculate-tests-coverage-on-pull-request.yml) -[![coverage](https://img.shields.io/badge/units_coverage-23.56%25-crimson)](https://github.com/TourmalineCore/inner-circle-time-api/actions/workflows/calculate-tests-coverage-on-pull-request.yml) -[![coverage](https://img.shields.io/badge/integration_coverage-61.61%25-orange)](https://github.com/TourmalineCore/inner-circle-time-api/actions/workflows/calculate-tests-coverage-on-pull-request.yml) -[![coverage](https://img.shields.io/badge/full_coverage-95.29%25-forestgreen)](https://github.com/TourmalineCore/inner-circle-time-api/actions/workflows/calculate-tests-coverage-on-pull-request.yml) +[![coverage](https://img.shields.io/badge/e2e_coverage-35.25%25-crimson)](https://github.com/TourmalineCore/inner-circle-time-api/actions/workflows/calculate-tests-coverage-on-pull-request.yml) +[![coverage](https://img.shields.io/badge/units_coverage-18.55%25-crimson)](https://github.com/TourmalineCore/inner-circle-time-api/actions/workflows/calculate-tests-coverage-on-pull-request.yml) +[![coverage](https://img.shields.io/badge/integration_coverage-73.76%25-yellow)](https://github.com/TourmalineCore/inner-circle-time-api/actions/workflows/calculate-tests-coverage-on-pull-request.yml) +[![coverage](https://img.shields.io/badge/full_coverage-95.14%25-forestgreen)](https://github.com/TourmalineCore/inner-circle-time-api/actions/workflows/calculate-tests-coverage-on-pull-request.yml) This repo contains Inner Circle Time API.