Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3cdbbbd
feat: #8: add validation configurations
MDI74 Dec 17, 2025
8b1e8a7
feat: #8: add HttpClientTest and CreateWorkEntryAsync_ShouldThrowVali…
MDI74 Dec 18, 2025
b434247
Merge branch 'master' into feature/#8-add-validation-using-problem-de…
MDI74 Dec 18, 2025
2fa98c7
fix: fix lint
MDI74 Dec 18, 2025
1b9b6fa
fix: fix lint
MDI74 Dec 18, 2025
bbbdf68
docs(readme): bring test coverage score up to date
Dec 18, 2025
3055aba
ci: #8: add HttpClientTest to .dockerignore
MDI74 Dec 18, 2025
b497c35
Merge branch 'feature/#8-add-validation-using-problem-details' of git…
MDI74 Dec 18, 2025
d98207c
fix: #8: fix file name in .dockerignore
MDI74 Dec 18, 2025
3fb0934
refactor: rename TrackingControllerValidationTests to TrackingControl…
akovylyaeva Dec 18, 2025
0f2250b
docs(readme): bring test coverage score up to date
Dec 18, 2025
2644224
refactor: #8: change CreateWorkEntryCommandParams to CreateWorkEntryR…
MDI74 Dec 18, 2025
fcfce92
docs(readme): bring test coverage score up to date
Dec 18, 2025
d994e05
refactor: #8: remove ValidationConfigurations and move problemDetails…
MDI74 Dec 18, 2025
a612666
docs(readme): bring test coverage score up to date
Dec 18, 2025
172a664
refactor: extract database migration to separate method
MDI74 Dec 18, 2025
504592e
docs(readme): bring test coverage score up to date
Dec 18, 2025
1f2620b
refactor: remove Type field from problem details object
MDI74 Dec 18, 2025
ce171f4
docs(readme): bring test coverage score up to date
Dec 18, 2025
5abd603
feat: add check that start time is not greater end time and create cu…
MDI74 Dec 19, 2025
469f93c
docs(readme): bring test coverage score up to date
Dec 19, 2025
654d890
refactor: change lambda expression parameter name
MDI74 Dec 19, 2025
d97bbf0
feat: add handling an error where the start time cannot be greater th…
MDI74 Dec 20, 2025
e5a1a13
docs(readme): bring test coverage score up to date
Dec 20, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ README.md
**/*Tests.cs
**/*TestsRelated.cs
**/TestsConfig/**
**/HttpClientTestBase.cs

**/bin/*
**/obj/*
Expand Down
2 changes: 2 additions & 0 deletions Api/Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

<!-- Condition to exclude tests related packages -->
<ItemGroup Condition="'$(EXCLUDE_UNIT_TESTS_FROM_BUILD)' != 'true'">
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.11" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3">
<PrivateAssets>all</PrivateAssets>
Expand All @@ -23,6 +24,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Hellang.Middleware.ProblemDetails" Version="6.5.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.7">
<PrivateAssets>all</PrivateAssets>
Expand Down
41 changes: 41 additions & 0 deletions Api/Features/Tracking/TrackingControllerTests.cs
Original file line number Diff line number Diff line change
@@ -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<Program> 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<ValidationProblemDetails>();

Assert.NotNull(validationProblemDetails);
Assert.Equal("Fill in all the fields", validationProblemDetails.Detail);
Assert.Equal("Validation error", validationProblemDetails.Title);
}
}
84 changes: 84 additions & 0 deletions Api/HttpClientTestBase.cs
Original file line number Diff line number Diff line change
@@ -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<WebApplicationFactory<Program>>, IAsyncLifetime
{
protected const long EMPLOYEE_ID = 1;
protected const long TENANT_ID = 777;

protected HttpClient HttpClient = null!;
private WebApplicationFactory<Program> _factory = null!;

public HttpClientTestBase(WebApplicationFactory<Program> 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<AuthenticationSchemeOptions, FakeAuthHandler>("Test", options => { });

// Add fake mockClaimsProbider
var mockClaimsProvider = new Mock<IClaimsProvider>();
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<AuthenticationSchemeOptions>
{
public FakeAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder
) : base(options, logger, encoder)
{
}

protected override Task<AuthenticateResult> 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));
}
}
50 changes: 45 additions & 5 deletions Api/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<InvalidTimeRangeException>(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<AuthenticationOptions>();
builder.Services.Configure<AuthenticationOptions>(configuration.GetSection(nameof(AuthenticationOptions)));
builder.Services.AddJwtAuthentication(authenticationOptions).WithUserClaimsProvider<UserClaimsProvider>(UserClaimsProvider.PermissionClaimType);

var app = builder.Build();

app.UseProblemDetails();

app.MapOpenApi("api/swagger/openapi/v1.json");

app.UseSwaggerUI(options =>
Expand All @@ -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<AppDbContext>();
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<AppDbContext>();
context.Database.Migrate();
}
}
14 changes: 10 additions & 4 deletions Application/AppDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,28 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)

modelBuilder
.Entity<WorkEntry>()
.Property(p => p.Duration)
.Property(entry => entry.Duration)
.HasComputedColumnSql("end_time - start_time", stored: true);

modelBuilder
.Entity<WorkEntry>()
.Property(e => e.StartTime)
.Property(entry => entry.StartTime)
.HasColumnType("timestamp without time zone");

modelBuilder
.Entity<WorkEntry>()
.Property(e => e.EndTime)
.Property(entry => entry.EndTime)
.HasColumnType("timestamp without time zone");

modelBuilder
.Entity<WorkEntry>()
.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<WorkEntry>()
.ToTable(table => table.HasCheckConstraint(
"ck_work_entries_end_time_is_greater_than_start_time",
"\"end_time\" > \"start_time\""));

base.OnModelCreating(modelBuilder);
}
Expand Down
49 changes: 31 additions & 18 deletions Application/Commands/CreateWorkEntryCommand.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Core.Entities;
using Microsoft.EntityFrameworkCore;
using Npgsql;

namespace Application.Commands;

Expand Down Expand Up @@ -33,24 +35,35 @@ IClaimsProvider claimsProvider

public async Task<long> 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
);
}
}
}
27 changes: 27 additions & 0 deletions Application/Commands/CreateWorkEntryCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<InvalidTimeRangeException>(
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);
}
}
Loading