diff --git a/.editorconfig b/.editorconfig index 849a2dd..4bc7aeb 100644 --- a/.editorconfig +++ b/.editorconfig @@ -121,7 +121,7 @@ csharp_style_prefer_readonly_struct = true csharp_style_prefer_readonly_struct_member = true # Code-block preferences -csharp_prefer_braces = true:error +csharp_prefer_braces = false:none csharp_prefer_simple_using_statement = true:error csharp_prefer_system_threading_lock = true csharp_style_namespace_declarations = file_scoped:error @@ -244,4 +244,4 @@ dotnet_naming_style.pascal_case.capitalization = pascal_case dotnet_naming_style.begins_with_i.required_prefix = I dotnet_naming_style.begins_with_i.required_suffix = dotnet_naming_style.begins_with_i.word_separator = -dotnet_naming_style.begins_with_i.capitalization = pascal_case +dotnet_naming_style.begins_with_i.capitalization = pascal_case \ No newline at end of file diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index e91cfb4..0000000 --- a/.gitattributes +++ /dev/null @@ -1,67 +0,0 @@ -############################################################################### -# Set default behavior to automatically normalize line endings. -############################################################################### -* text=auto - -############################################################################### -# Set default behavior for command prompt diff. -# -# This is need for earlier builds of msysgit that does not have it on by -# default for csharp files. -# Note: This is only used by command line -############################################################################### -#*.cs diff=csharp - -############################################################################### -# Set the merge driver for project and solution files -# -# Merging from the command prompt will add diff markers to the files if there -# are conflicts (Merging from VS is not affected by the settings below, in VS -# the diff markers are never inserted). Diff markers may cause the following -# file extensions to fail to load in VS. An alternative would be to treat -# these files as binary and thus will always conflict and require user -# intervention with every merge. To do so, just uncomment the entries below -############################################################################### -#*.sln merge=binary -#*.csproj merge=binary -#*.vbproj merge=binary -#*.vcxproj merge=binary -#*.vcproj merge=binary -#*.dbproj merge=binary -#*.fsproj merge=binary -#*.lsproj merge=binary -#*.wixproj merge=binary -#*.modelproj merge=binary -#*.sqlproj merge=binary -#*.wwaproj merge=binary - -############################################################################### -# behavior for image files -# -# image files are treated as binary by default. -############################################################################### -#*.jpg binary -#*.png binary -#*.gif binary - -############################################################################### -# diff behavior for common document formats -# -# Convert binary document formats to text before diffing them. This feature -# is only available from the command line. Turn it on by uncommenting the -# entries below. -############################################################################### -#*.doc diff=astextplain -#*.DOC diff=astextplain -#*.docx diff=astextplain -#*.DOCX diff=astextplain -#*.dot diff=astextplain -#*.DOT diff=astextplain -#*.pdf diff=astextplain -#*.PDF diff=astextplain -#*.rtf diff=astextplain -#*.RTF diff=astextplain - -# Use CRLF for C# files -*.cs text eol=crlf -*.csproj text eol=crlf diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index b528813..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,17 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "nuget" - directory: "/" - schedule: - interval: "weekly" - open-pull-requests-limit: 5 - labels: - - "dependencies" - - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - open-pull-requests-limit: 5 - labels: - - "dependencies" \ No newline at end of file diff --git a/.github/labeler.yml b/.github/labeler.yml deleted file mode 100644 index 9502beb..0000000 --- a/.github/labeler.yml +++ /dev/null @@ -1,19 +0,0 @@ -backend: - - changed-files: - - any-glob-to-any-file: ['src/**', '!src/**/*.test.*'] - -tests: - - changed-files: - - any-glob-to-any-file: ['**/*.Tests/**', '**/*Tests.cs', '**/*Test.cs'] - -infrastructure: - - changed-files: - - any-glob-to-any-file: ['.github/**', 'Dockerfile*', 'docker-compose*', '*.yml', '*.yaml'] - -database: - - changed-files: - - any-glob-to-any-file: ['**/Migrations/**', '**/DbContext*', '**/*.sql'] - -dependencies: - - changed-files: - - any-glob-to-any-file: ['**/*.csproj', '**/Directory.Packages.props', '**/NuGet.Config'] \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 8dceb03..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Continuous Integration - -on: - push: - branches: - - master - pull_request: - branches: - - master - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - dotnet-version: ["10.x.x"] - - steps: - - uses: actions/checkout@v6 - - name: Setup dotnet ${{ matrix.dotnet-version }} - uses: actions/setup-dotnet@v4 - with: - dotnet-version: ${{ matrix.dotnet-version }} - - name: Display dotnet version - run: dotnet --version - - - name: Install dependencies - run: dotnet restore - - - name: build - run: dotnet build --no-restore - - - name: Unit Tests - run: dotnet test --no-restore --no-build --collect:"XPlat Code Coverage" --logger trx --results-directory "TestResults-${{ matrix.dotnet-version }}" - - - name: Upload dotnet test results - uses: actions/upload-artifact@v4 - with: - name: dotnet-results-${{ matrix.dotnet-version }} - path: TestResults-${{ matrix.dotnet-version }} - if: ${{ always() }} - - - name: Install dotnet format - run: dotnet tool install -g dotnet-format - - - name: Run dotnet linter - run: dotnet format --verify-no-changes - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - with: - files: "TestResults-${{ matrix.dotnet-version }}/**/coverage.cobertura.xml" - fail_ci_if_error: false - if: ${{ always() }} diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml deleted file mode 100644 index 24bb130..0000000 --- a/.github/workflows/labeler.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Label PRs - -on: - pull_request_target: - types: [opened, synchronize, reopened] - -jobs: - labeler: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - uses: actions/labeler@v5 - with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" - configuration-path: .github/labeler.yml \ No newline at end of file diff --git a/.github/workflows/master_app-awapi-scrum-centralus-03.yml b/.github/workflows/master_app-awapi-scrum-centralus-03.yml deleted file mode 100644 index 07b2c3b..0000000 --- a/.github/workflows/master_app-awapi-scrum-centralus-03.yml +++ /dev/null @@ -1,65 +0,0 @@ -# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy -# More GitHub Actions for Azure: https://github.com/Azure/actions - -name: Deploy - app-awapi-scrum-centralus-03 - -on: - push: - branches: - - master - workflow_dispatch: - -jobs: - build: - runs-on: windows-latest - permissions: - contents: read #This is required for actions/checkout - - steps: - - uses: actions/checkout@v4 - - - name: Set up .NET Core - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '10.x' - - - name: Build with dotnet - run: dotnet build --configuration Release - - - name: dotnet publish - run: dotnet publish -c Release -o "${{env.DOTNET_ROOT}}/myapp" - - - name: Upload artifact for deployment job - uses: actions/upload-artifact@v4 - with: - name: .net-app - path: ${{env.DOTNET_ROOT}}/myapp - - deploy: - runs-on: windows-latest - needs: build - permissions: - id-token: write #This is required for requesting the JWT - contents: read #This is required for actions/checkout - - steps: - - name: Download artifact from build job - uses: actions/download-artifact@v4 - with: - name: .net-app - - - name: Login to Azure - uses: azure/login@v2 - with: - client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_8AB6E917B0CE4368B07BE3E984A7F778 }} - tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_8ADD74451CF544BDAC4CE9049E400B16 }} - subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_61DA8EF64169488485D2CBDB9C3F695C }} - - - name: Deploy to Azure Web App - id: deploy-to-webapp - uses: azure/webapps-deploy@v3 - with: - app-name: 'app-awapi-scrum-centralus-03' - slot-name: 'Production' - package: . - diff --git a/.github/workflows/sonar-scan.yml b/.github/workflows/sonar-scan.yml deleted file mode 100644 index 90ccd03..0000000 --- a/.github/workflows/sonar-scan.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: SonarQube Analysis - -on: - pull_request: - types: [opened, synchronize, reopened] - push: - branches: - - master - -jobs: - build: - runs-on: ubuntu-latest - - strategy: - matrix: - dotnet-version: ["10.x.x"] - - steps: - - uses: actions/checkout@v6 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: ${{ matrix.dotnet-version }} - - - name: Setup JDK 17 - uses: actions/setup-java@v4 - with: - java-version: 17 - distribution: zulu - - - name: Install SonarScanner - run: | - dotnet tool update --global dotnet-sonarscanner - echo "$HOME/.dotnet/tools" >> $GITHUB_PATH - - - name: Run SonarScanner and tests - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: | - dotnet-sonarscanner begin \ - /k:"algowars_server" \ - /o:"algowars" \ - /d:sonar.token="$SONAR_TOKEN" \ - /d:sonar.cs.opencover.reportsPaths="TestResults/coverage.opencover.xml" \ - /d:sonar.cs.vstest.reportsPaths="TestResults/*.trx" - - dotnet restore - dotnet build --no-restore - dotnet test --no-restore --no-build --collect:"XPlat Code Coverage" --logger trx --results-directory TestResults - - dotnet-sonarscanner end /d:sonar.token="$SONAR_TOKEN" - - name: Upload dotnet test results - uses: actions/upload-artifact@v4 - with: - name: dotnet-results - path: TestResults - if: ${{ always() }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index d92b0dd..0000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Mark stale issues and PRs - -on: - schedule: - - cron: '0 0 * * *' - workflow_dispatch: - -jobs: - stale: - runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write - steps: - - uses: actions/stale@v9 - with: - stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed in 14 days if no further activity occurs.' - stale-pr-message: 'This PR has been automatically marked as stale because it has not had recent activity. It will be closed in 14 days if no further activity occurs.' - close-issue-message: 'This issue was closed automatically after 14 days of inactivity.' - close-pr-message: 'This PR was closed automatically after 14 days of inactivity.' - days-before-stale: 60 - days-before-close: 14 - stale-issue-label: 'stale' - stale-pr-label: 'stale' - exempt-issue-labels: 'pinned,security,P1' - exempt-pr-labels: 'pinned,security' \ No newline at end of file diff --git a/Algowars.Api/Algowars.Api.csproj b/Algowars.Api/Algowars.Api.csproj new file mode 100644 index 0000000..d58aa60 --- /dev/null +++ b/Algowars.Api/Algowars.Api.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + f1204ecb-b2c7-4ab2-a772-a27e6871af93 + + + + + + + + + + all + compile; runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/Algowars.Api/Algowars.Api.http b/Algowars.Api/Algowars.Api.http new file mode 100644 index 0000000..503e3e7 --- /dev/null +++ b/Algowars.Api/Algowars.Api.http @@ -0,0 +1,6 @@ +@Algowars.Api_HostAddress = http://localhost:5041 + +GET {{Algowars.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/Algowars.Api/ApiServiceRegistration.cs b/Algowars.Api/ApiServiceRegistration.cs new file mode 100644 index 0000000..9f8c6dd --- /dev/null +++ b/Algowars.Api/ApiServiceRegistration.cs @@ -0,0 +1,62 @@ +using Algowars.Api.Extensions; +using Algowars.Api.Middleware; +using Algowars.Api.Settings; +using Algowars.Infrastructure; +using Asp.Versioning; +using Scalar.AspNetCore; + +namespace Algowars.Api; + +public static class ApiServiceRegistration +{ + private const string CorsPolicyName = "AllowedOrigins"; + + public static IServiceCollection AddApi(this IServiceCollection services, IConfiguration configuration) + { + services.AddControllers(); + services.AddApiVersioning(options => + { + options.DefaultApiVersion = new ApiVersion(1); + options.AssumeDefaultVersionWhenUnspecified = true; + }); + services.AddOpenApi(); + + var corsOptions = configuration.GetSection(CorsOptions.SectionName).Get() + ?? throw new InvalidOperationException($"Configuration section '{CorsOptions.SectionName}' is missing or invalid."); + + services.AddSingleton(corsOptions); + services.AddCors(options => + { + options.AddPolicy(CorsPolicyName, policy => + { + policy.WithOrigins(corsOptions.AllowedOrigins) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); + }); + }); + + services.AddAuth0(configuration); + services.AddAppInsights(configuration); + services.AddScoped(); + + return services; + } + + public static async Task UseApi(this WebApplication app) + { + if (app.Environment.IsDevelopment()) + { + app.MapOpenApi(); + app.MapScalarApiReference(); + await app.Services.MigrateAsync(); + } + + app.UseHttpsRedirection(); + app.UseCors(CorsPolicyName); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseMiddleware(); + app.MapControllers(); + } +} diff --git a/Algowars.Api/Attributes/RequireUserAttribute.cs b/Algowars.Api/Attributes/RequireUserAttribute.cs new file mode 100644 index 0000000..3c50735 --- /dev/null +++ b/Algowars.Api/Attributes/RequireUserAttribute.cs @@ -0,0 +1,6 @@ +namespace Algowars.Api.Attributes; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false)] +public sealed class RequireUserAttribute : Attribute +{ +} diff --git a/src/PublicApi/Authorization/RbacHandler.cs b/Algowars.Api/Authorization/RbacHandler.cs similarity index 88% rename from src/PublicApi/Authorization/RbacHandler.cs rename to Algowars.Api/Authorization/RbacHandler.cs index 9c202a2..c736e40 100644 --- a/src/PublicApi/Authorization/RbacHandler.cs +++ b/Algowars.Api/Authorization/RbacHandler.cs @@ -1,6 +1,6 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; -namespace PublicApi.Authorization; +namespace Algowars.Api.Authorization; public sealed class RbacHandler : AuthorizationHandler { diff --git a/src/PublicApi/Authorization/RbacRequirement.cs b/Algowars.Api/Authorization/RbacRequirement.cs similarity index 70% rename from src/PublicApi/Authorization/RbacRequirement.cs rename to Algowars.Api/Authorization/RbacRequirement.cs index c140d48..0842c3f 100644 --- a/src/PublicApi/Authorization/RbacRequirement.cs +++ b/Algowars.Api/Authorization/RbacRequirement.cs @@ -1,6 +1,6 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; -namespace PublicApi.Authorization; +namespace Algowars.Api.Authorization; public sealed class RbacRequirement(string permission) : IAuthorizationRequirement { diff --git a/Algowars.Api/Configuration/Auth0Options.cs b/Algowars.Api/Configuration/Auth0Options.cs new file mode 100644 index 0000000..4078b83 --- /dev/null +++ b/Algowars.Api/Configuration/Auth0Options.cs @@ -0,0 +1,11 @@ +using Algowars.Application.Settings; + +namespace Algowars.Api.Settings; + +public sealed class Auth0Options : IOption +{ + public static string SectionName => "Auth0"; + + public required string Domain { get; init; } + public required string Audience { get; init; } +} diff --git a/Algowars.Api/Configuration/CorsOptions.cs b/Algowars.Api/Configuration/CorsOptions.cs new file mode 100644 index 0000000..e00cf5a --- /dev/null +++ b/Algowars.Api/Configuration/CorsOptions.cs @@ -0,0 +1,10 @@ +using Algowars.Application.Settings; + +namespace Algowars.Api.Settings; + +public sealed class CorsOptions : IOption +{ + public static string SectionName => "Cors"; + + public string[] AllowedOrigins { get; init; } = []; +} diff --git a/Algowars.Api/Controllers/ProblemController.cs b/Algowars.Api/Controllers/ProblemController.cs new file mode 100644 index 0000000..58f01b8 --- /dev/null +++ b/Algowars.Api/Controllers/ProblemController.cs @@ -0,0 +1,42 @@ + +using Algowars.Api.Requests.Problem; +using Algowars.Application.Pagination; +using Algowars.Application.Problems.Dtos; +using Algowars.Application.Services.Problems; +using Ardalis.Result.AspNetCore; +using Microsoft.AspNetCore.Mvc; +namespace Algowars.Api.Controllers; + +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +public sealed class ProblemController(IProblemService problemService) : ControllerBase +{ + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> GetProblems( + [FromQuery] GetProblemsPageableRequest query, CancellationToken cancellationToken) + { + return this.ToActionResult(await problemService.GetProblemsPageableAsync(new PaginationRequest + { + Page = query.Page, + Size = query.Size, + Timestamp = query.Timestamp + }, cancellationToken)); + } + + [HttpGet("{slug}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetProblemBySlug(string slug, CancellationToken cancellationToken) + { + return this.ToActionResult(await problemService.GetProblemWithSetupsBySlug(slug, cancellationToken)); + } + + [HttpGet("{slug}/setup")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetProblemSetup(string slug, [FromQuery] Guid languageVersionId, CancellationToken cancellationToken) + { + return this.ToActionResult(await problemService.GetProblemSetupAsync(slug, languageVersionId, cancellationToken)); + } +} diff --git a/Algowars.Api/Controllers/UserController.cs b/Algowars.Api/Controllers/UserController.cs new file mode 100644 index 0000000..f41f3ea --- /dev/null +++ b/Algowars.Api/Controllers/UserController.cs @@ -0,0 +1,54 @@ +using Algowars.Api.Attributes; +using Algowars.Api.Requests.User; +using Algowars.Api.Responses.User; +using Algowars.Application; +using Algowars.Application.Services.Users; +using Algowars.Application.Users.Dtos; +using Ardalis.Result; +using Ardalis.Result.AspNetCore; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace Algowars.Api.Controllers; + +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +public sealed class UserController(IUserService userService, UserContext userContext) : ControllerBase +{ + [HttpGet] + [RequireUser] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetAccount() + { + if (userContext.User is null) + return NotFound(); + + return Ok(UserResponse.FromDto(userContext.User)); + } + + [HttpPut] + [RequireUser] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> UpsertAccount( + [FromBody] UpsertUserRequest request, CancellationToken cancellationToken) + { + string? sub = GetSub(); + + if(string.IsNullOrEmpty(sub)) + { + return this.ToActionResult(Result.Invalid(new ValidationError("sub", "User sub is missing"))); + } + + return this.ToActionResult(await userService.UpsertAccountAsync( + sub, + new UpsertUserDto(request.Username, request.Picture, request.Bio), + cancellationToken)); + } + + private string? GetSub() => User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + +} diff --git a/Algowars.Api/Extensions/ApplicationInsightsExtensions.cs b/Algowars.Api/Extensions/ApplicationInsightsExtensions.cs new file mode 100644 index 0000000..546fe41 --- /dev/null +++ b/Algowars.Api/Extensions/ApplicationInsightsExtensions.cs @@ -0,0 +1,12 @@ +namespace Algowars.Api.Extensions; + +public static class ApplicationInsightsExtensions +{ + public static IServiceCollection AddAppInsights( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddApplicationInsightsTelemetry(configuration); + return services; + } +} diff --git a/Algowars.Api/Extensions/AuthenticationExtensions.cs b/Algowars.Api/Extensions/AuthenticationExtensions.cs new file mode 100644 index 0000000..fa908e3 --- /dev/null +++ b/Algowars.Api/Extensions/AuthenticationExtensions.cs @@ -0,0 +1,31 @@ +using Algowars.Api.Settings; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; + +namespace Algowars.Api.Extensions; + +public static class AuthenticationExtensions +{ + public static IServiceCollection AddAuth0( + this IServiceCollection services, + IConfiguration configuration) + { + var auth0Options = configuration.GetSection(Auth0Options.SectionName).Get() + ?? throw new InvalidOperationException($"Configuration section '{Auth0Options.SectionName}' is missing or invalid."); + + services.AddSingleton(auth0Options); + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = $"https://{auth0Options.Domain}/"; + options.Audience = auth0Options.Audience; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateAudience = true, + ValidateIssuerSigningKey = true, + }; + }); + + return services; + } +} diff --git a/Algowars.Api/LoggingEventIds.cs b/Algowars.Api/LoggingEventIds.cs new file mode 100644 index 0000000..74585c3 --- /dev/null +++ b/Algowars.Api/LoggingEventIds.cs @@ -0,0 +1,17 @@ +namespace Algowars.Api; + +internal static class LoggingEventIds +{ + internal static class Accounts + { + public const int ContextMissingSub = 1000; + public const int ContextResolveFailed = 1001; + public const int ContextPublicEndpoint = 1002; + } + + internal static class Exceptions + { + public const int UnhandledException = 5000; + public const int UnhandledExceptionWithPath = 5001; + } +} diff --git a/src/PublicApi/Middleware/ExceptionMiddlewareExtensions.cs b/Algowars.Api/Middleware/UseGlobalExceptionHandler.cs similarity index 95% rename from src/PublicApi/Middleware/ExceptionMiddlewareExtensions.cs rename to Algowars.Api/Middleware/UseGlobalExceptionHandler.cs index 0049a9e..155495d 100644 --- a/src/PublicApi/Middleware/ExceptionMiddlewareExtensions.cs +++ b/Algowars.Api/Middleware/UseGlobalExceptionHandler.cs @@ -1,10 +1,8 @@ -using ApplicationCore.Logging; -using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights; using Microsoft.AspNetCore.Diagnostics; -using System.Net; using System.Text.Json; -namespace PublicApi.Middleware; +namespace Algowars.Api.Middleware; public static class ExceptionMiddlewareExtensions { diff --git a/Algowars.Api/Middleware/UserContextMiddleware.cs b/Algowars.Api/Middleware/UserContextMiddleware.cs new file mode 100644 index 0000000..f072817 --- /dev/null +++ b/Algowars.Api/Middleware/UserContextMiddleware.cs @@ -0,0 +1,73 @@ +using Algowars.Api.Attributes; +using Algowars.Application; +using Algowars.Application.Services.Users; +using Microsoft.ApplicationInsights.DataContracts; +using System.Security.Claims; + +namespace Algowars.Api.Middleware; + +public partial class AccountContextMiddleware( + IUserService userService, + UserContext userContext, + ILogger logger +) : IMiddleware +{ + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + var endpoint = context.GetEndpoint(); + bool requiresUser = endpoint?.Metadata.GetMetadata() != null; + + string? sub = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + + if (requiresUser && !string.IsNullOrEmpty(sub)) + { + var result = await userService.GetBySubAsync(sub, context.RequestAborted); + if (result.IsSuccess) + { + userContext.User = result.Value; + userContext.Permissions = [.. context.User + .FindAll("permissions") + .Select(c => c.Value)]; + + var requestTelemetry = context.Features.Get(); + if (requestTelemetry is not null) + { + requestTelemetry.Properties.TryAdd("account.id", userContext.User.Id.ToString()); + requestTelemetry.Properties.TryAdd("account.username", userContext.User.Username); + } + } + else + { + LogResolveFailed(sub, context.Request.Path, string.Join(", ", result.Errors)); + } + } + else if (requiresUser) + { + LogMissingSub(context.Request.Path); + } + else + { + LogPublicEndpoint(context.Request.Path); + } + + await next(context); + } + + [LoggerMessage( + EventId = LoggingEventIds.Accounts.ContextMissingSub, + Level = LogLevel.Warning, + Message = "Account context: missing sub claim on [RequiresUser] endpoint {path}")] + private partial void LogMissingSub(string path); + + [LoggerMessage( + EventId = LoggingEventIds.Accounts.ContextResolveFailed, + Level = LogLevel.Warning, + Message = "Account context: failed to resolve account for sub {sub} on {path}: {errors}")] + private partial void LogResolveFailed(string sub, string path, string errors); + + [LoggerMessage( + EventId = LoggingEventIds.Accounts.ContextPublicEndpoint, + Level = LogLevel.Debug, + Message = "Account context: public endpoint {path}, skipping user resolution")] + private partial void LogPublicEndpoint(string path); +} \ No newline at end of file diff --git a/Algowars.Api/Program.cs b/Algowars.Api/Program.cs new file mode 100644 index 0000000..835263e --- /dev/null +++ b/Algowars.Api/Program.cs @@ -0,0 +1,17 @@ +using Algowars.Api; +using Algowars.Api.Middleware; +using Algowars.Application; +using Algowars.Infrastructure; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddApi(builder.Configuration); +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(builder.Configuration); + +var app = builder.Build(); + +await app.UseApi(); + +app.UseGlobalExceptionHandler(); +app.Run(); diff --git a/Algowars.Api/Requests/Problem/GetProblemsPageableRequest.cs b/Algowars.Api/Requests/Problem/GetProblemsPageableRequest.cs new file mode 100644 index 0000000..270179c --- /dev/null +++ b/Algowars.Api/Requests/Problem/GetProblemsPageableRequest.cs @@ -0,0 +1,3 @@ +namespace Algowars.Api.Requests.Problem; + +public sealed record GetProblemsPageableRequest(int Page, int Size, DateTime Timestamp); diff --git a/Algowars.Api/Requests/User/UpsertUserRequest.cs b/Algowars.Api/Requests/User/UpsertUserRequest.cs new file mode 100644 index 0000000..3184e9f --- /dev/null +++ b/Algowars.Api/Requests/User/UpsertUserRequest.cs @@ -0,0 +1,3 @@ +namespace Algowars.Api.Requests.User; + +public sealed record UpsertUserRequest(string? Username, string? Picture, string? Bio); diff --git a/Algowars.Api/Responses/User/UserResponse.cs b/Algowars.Api/Responses/User/UserResponse.cs new file mode 100644 index 0000000..2ba0848 --- /dev/null +++ b/Algowars.Api/Responses/User/UserResponse.cs @@ -0,0 +1,8 @@ +using Algowars.Application.Users.Dtos; + +namespace Algowars.Api.Responses.User; + +public sealed record UserResponse(Guid Id, string Username, string? ImageUrl, DateTime? UsernameLastChangedAt) +{ + public static UserResponse FromDto(UserDto dto) => new(dto.Id, dto.Username, dto.ImageUrl, dto.UsernameLastChangedAt); +} diff --git a/src/PublicApi/appsettings.Development.json b/Algowars.Api/appsettings.Development.json similarity index 56% rename from src/PublicApi/appsettings.Development.json rename to Algowars.Api/appsettings.Development.json index 5fa4b26..f49f210 100644 --- a/src/PublicApi/appsettings.Development.json +++ b/Algowars.Api/appsettings.Development.json @@ -1,35 +1,18 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, "Auth0": { - "Audience": "", "Domain": "", - "Management": { - "ClientId": "" - } - }, - "Cors": { - "AllowedOrigins": [ - "http://localhost:3000" - ] + "Audience": "" }, "ConnectionStrings": { "DefaultConnection": "Host=localhost;Port=5432;Database=algowars;Username=myuser;Password=mypassword" }, - "ExecutionEngines": { - "Judge0": { - "Enabled": true, - "RunWorker": true, - "BaseUrl": "", - "ApiKey": "", - "Host": "", - "ShouldWait": false, - "IsEncoded": true, - "DefaultTimeoutInSeconds": 10 + "Cors": { + "AllowedOrigins": [ "http://localhost:3000" ] + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" } }, "MessageBus": { diff --git a/Algowars.Api/appsettings.json b/Algowars.Api/appsettings.json new file mode 100644 index 0000000..feff1f4 --- /dev/null +++ b/Algowars.Api/appsettings.json @@ -0,0 +1,19 @@ +{ + "AllowedHosts": "*", + "ApplicationInsights": { + "ConnectionString": "" + }, + "Auth0": { + "Domain": "", + "Audience": "" + }, + "Cors": { + "AllowedOrigins": [] + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Algowars.Application/Algowars.Application.csproj b/Algowars.Application/Algowars.Application.csproj new file mode 100644 index 0000000..82df8ea --- /dev/null +++ b/Algowars.Application/Algowars.Application.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/Algowars.Application/ApplicationServiceRegistration.cs b/Algowars.Application/ApplicationServiceRegistration.cs new file mode 100644 index 0000000..81b3b29 --- /dev/null +++ b/Algowars.Application/ApplicationServiceRegistration.cs @@ -0,0 +1,43 @@ +using Algowars.Application.Services.Problems; +using Algowars.Application.Services.Users; +using Algowars.Domain.SeedWork; +using Algowars.Domain.Submissions.Entities; +using Algowars.Domain.Submissions.Factories; +using Algowars.Domain.Users.Entities; +using Algowars.Domain.Users.Factories; +using FluentValidation; +using Microsoft.Extensions.DependencyInjection; + +namespace Algowars.Application; + +public static class ApplicationServiceRegistration +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(ApplicationServiceRegistration).Assembly)); + services.AddValidatorsFromAssembly(typeof(ApplicationServiceRegistration).Assembly, includeInternalTypes: true); + + services.AddFactories(); + services.AddServices(); + services.AddScoped(); + + return services; + } + + private static IServiceCollection AddFactories(this IServiceCollection services) + { + services.AddScoped, UserFactory>(); + services.AddScoped, SubmissionFactory>(); + + return services; + } + + private static IServiceCollection AddServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/src/ApplicationCore/Commands/AbstractCommandHandler.cs b/Algowars.Application/Commands/AbstractCommandHandler.cs similarity index 97% rename from src/ApplicationCore/Commands/AbstractCommandHandler.cs rename to Algowars.Application/Commands/AbstractCommandHandler.cs index 7df1496..6547da4 100644 --- a/src/ApplicationCore/Commands/AbstractCommandHandler.cs +++ b/Algowars.Application/Commands/AbstractCommandHandler.cs @@ -1,3 +1,4 @@ +using Algowars.Application.Commands; using Ardalis.Result; using FluentValidation; diff --git a/src/ApplicationCore/Commands/ICommand.cs b/Algowars.Application/Commands/ICommand.cs similarity index 67% rename from src/ApplicationCore/Commands/ICommand.cs rename to Algowars.Application/Commands/ICommand.cs index 2ff105e..e66698c 100644 --- a/src/ApplicationCore/Commands/ICommand.cs +++ b/Algowars.Application/Commands/ICommand.cs @@ -1,7 +1,7 @@ -using Ardalis.Result; +using Ardalis.Result; using MediatR; -namespace ApplicationCore.Commands; +namespace Algowars.Application.Commands; public interface ICommand : IRequest> { } diff --git a/src/ApplicationCore/Commands/ICommandHandler.cs b/Algowars.Application/Commands/ICommandHandler.cs similarity index 81% rename from src/ApplicationCore/Commands/ICommandHandler.cs rename to Algowars.Application/Commands/ICommandHandler.cs index 6ae6b17..0895fa6 100644 --- a/src/ApplicationCore/Commands/ICommandHandler.cs +++ b/Algowars.Application/Commands/ICommandHandler.cs @@ -1,7 +1,7 @@ -using Ardalis.Result; +using Ardalis.Result; using MediatR; -namespace ApplicationCore.Commands; +namespace Algowars.Application.Commands; public interface ICommandHandler : IRequestHandler> diff --git a/Algowars.Application/Commands/Submissions/CreateSubmission/CreateSubmissionCommand.cs b/Algowars.Application/Commands/Submissions/CreateSubmission/CreateSubmissionCommand.cs new file mode 100644 index 0000000..79ac29a --- /dev/null +++ b/Algowars.Application/Commands/Submissions/CreateSubmission/CreateSubmissionCommand.cs @@ -0,0 +1,10 @@ +using Algowars.Domain.Submissions.Enums; +using MediatR; + +namespace Algowars.Application.Commands.Submissions.CreateSubmission; + +internal sealed record CreateSubmissionCommand( + Guid ProblemSetupId, + SubmissionType Type, + string Code, + Guid CreatedById) : ICommand; diff --git a/Algowars.Application/Commands/Submissions/CreateSubmission/CreateSubmissionHandler.cs b/Algowars.Application/Commands/Submissions/CreateSubmission/CreateSubmissionHandler.cs new file mode 100644 index 0000000..4a659f9 --- /dev/null +++ b/Algowars.Application/Commands/Submissions/CreateSubmission/CreateSubmissionHandler.cs @@ -0,0 +1,41 @@ +using Algowars.Application.Messaging; +using Algowars.Application.Messaging.Messages; +using Algowars.Domain.SeedWork; +using Algowars.Domain.Submissions; +using Algowars.Domain.Submissions.Entities; +using Algowars.Domain.Submissions.Factories; +using Algowars.Domain.Submissions.ValueObjects; +using Algowars.Domain.TestSuites; +using ApplicationCore.Commands; +using Ardalis.Result; +using FluentValidation; +using MediatR; + +namespace Algowars.Application.Commands.Submissions.CreateSubmission; + +internal sealed partial class CreateSubmissionHandler( + IValidator validator, + IAggregateFactory submissionFactory, + ISubmissionWriteRepository submissionRepository, + ITestSuiteWriteRepository testSuiteRepository, + IMessagePublisher messagePublisher) : AbstractCommandHandler(validator) +{ + protected override async Task> HandleValidated(CreateSubmissionCommand request, CancellationToken cancellationToken) + { + var testCaseIds = await testSuiteRepository.FindTestCaseIdsByProblemSetupIdAsync( + request.ProblemSetupId, cancellationToken); + + var submission = submissionFactory.Create(new CreateSubmissionParams( + request.CreatedById, + request.ProblemSetupId, + request.Type, + new SourceCode(request.Code), + testCaseIds)); + + await submissionRepository.AddAsync(submission, cancellationToken); + + await messagePublisher.PublishAsync(new SubmissionCreatedMessage(submission.Id), cancellationToken); + + return Result.Success(); + } +} diff --git a/Algowars.Application/Commands/Submissions/CreateSubmission/CreateSubmissionValidator.cs b/Algowars.Application/Commands/Submissions/CreateSubmission/CreateSubmissionValidator.cs new file mode 100644 index 0000000..ac1958c --- /dev/null +++ b/Algowars.Application/Commands/Submissions/CreateSubmission/CreateSubmissionValidator.cs @@ -0,0 +1,15 @@ + +using Algowars.Domain.Submissions.ValueObjects; +using FluentValidation; + +namespace Algowars.Application.Commands.Submissions.CreateSubmission; + +internal sealed class CreateSubmissionValidator : AbstractValidator +{ + public CreateSubmissionValidator() + { + RuleFor(x => x.ProblemSetupId).NotEmpty(); + RuleFor(x => x.Code).NotEmpty().MaximumLength(SourceCode.MaxLength); + RuleFor(x => x.CreatedById).NotEmpty(); + } +} diff --git a/Algowars.Application/Commands/Users/UpsertUser/UpsertUserCommand.cs b/Algowars.Application/Commands/Users/UpsertUser/UpsertUserCommand.cs new file mode 100644 index 0000000..ee6b087 --- /dev/null +++ b/Algowars.Application/Commands/Users/UpsertUser/UpsertUserCommand.cs @@ -0,0 +1,7 @@ +using Algowars.Application.Commands; +using Algowars.Application.Users.Dtos; +using MediatR; + +namespace Algowars.Application.Commands.Users.UpsertUser; + +internal sealed record UpsertUserCommand(string Sub, string? Username, string? ImageUrl, string? Bio) : ICommand; diff --git a/Algowars.Application/Commands/Users/UpsertUser/UpsertUserHandler.cs b/Algowars.Application/Commands/Users/UpsertUser/UpsertUserHandler.cs new file mode 100644 index 0000000..1287b7d --- /dev/null +++ b/Algowars.Application/Commands/Users/UpsertUser/UpsertUserHandler.cs @@ -0,0 +1,56 @@ +using Algowars.Application.Services.Users; +using Algowars.Application.Commands; +using Ardalis.Result; +using FluentValidation; +using MediatR; +using Algowars.Domain.SeedWork; +using Algowars.Domain.Users; +using Algowars.Domain.Users.Entities; +using Algowars.Domain.Users.Factories; +using Algowars.Domain.Users.ValueObjects; +using ApplicationCore.Commands; + +namespace Algowars.Application.Commands.Users.UpsertUser; + +internal sealed partial class UpsertUserHandler( + IValidator validator, + IAggregateFactory userFactory, + IUsernameGeneratorService usernameGenerator, + IUserWriteRepository userRepository) : AbstractCommandHandler(validator) +{ + protected override async Task> HandleValidated(UpsertUserCommand request, CancellationToken cancellationToken) + { + User? user = await userRepository.FindBySubAsync(request.Sub, cancellationToken); + + if (user is null) + { + string username = string.IsNullOrWhiteSpace(request.Username) + ? usernameGenerator.Generate() + : request.Username; + + User newUser = userFactory.Create(new CreateUserParams(username, request.Sub, request.ImageUrl)); + newUser.UpdateBio(request.Bio is not null ? new Bio(request.Bio) : null); + await userRepository.AddAsync(newUser, cancellationToken); + } + else + { + bool usernameChanged = !string.IsNullOrWhiteSpace(request.Username) + && request.Username != user.Username.Value; + + if (usernameChanged) + { + if (user.UsernameLastChangedAt.HasValue && + DateTime.UtcNow - user.UsernameLastChangedAt.Value < TimeSpan.FromDays(User.MaxDaysUntilUsernameChange)) + return Result.Invalid(new ValidationError("Username", "Username can only be changed once every 30 days.")); + + user.ChangeUsername(new Username(request.Username!)); + } + + user.UpdateBio(request.Bio is not null ? new Bio(request.Bio) : null); + user.UpdateImageUrl(request.ImageUrl is not null ? new ImageUrl(request.ImageUrl) : null); + await userRepository.UpdateAsync(user, cancellationToken); + } + + return Result.Success(Unit.Value); + } +} diff --git a/Algowars.Application/Commands/Users/UpsertUser/UpsertUserValidator.cs b/Algowars.Application/Commands/Users/UpsertUser/UpsertUserValidator.cs new file mode 100644 index 0000000..f7ec2b2 --- /dev/null +++ b/Algowars.Application/Commands/Users/UpsertUser/UpsertUserValidator.cs @@ -0,0 +1,18 @@ +using Algowars.Domain.Users.ValueObjects; +using FluentValidation; + +namespace Algowars.Application.Commands.Users.UpsertUser; + +internal sealed class UpsertUserValidator : AbstractValidator +{ + public UpsertUserValidator() + { + RuleFor(x => x.Username) + .MaximumLength(Username.MaxLength) + .When(x => x.Username is not null); + RuleFor(x => x.ImageUrl) + .MaximumLength(ImageUrl.MaxLength); + RuleFor(x => x.Bio) + .MaximumLength(Bio.MaxLength); + } +} diff --git a/Algowars.Application/Configuration/ConnectionStringOptions.cs b/Algowars.Application/Configuration/ConnectionStringOptions.cs new file mode 100644 index 0000000..99902ad --- /dev/null +++ b/Algowars.Application/Configuration/ConnectionStringOptions.cs @@ -0,0 +1,10 @@ + + +namespace Algowars.Application.Settings; + +public sealed class ConnectionStringOptions : IOption +{ + public static string SectionName => "ConnectionStrings"; + + public required string DefaultConnection { get; init; } +} \ No newline at end of file diff --git a/src/Infrastructure/Configuration/MessageBusOptions.cs b/Algowars.Application/Configuration/MessageBusOptions.cs similarity index 74% rename from src/Infrastructure/Configuration/MessageBusOptions.cs rename to Algowars.Application/Configuration/MessageBusOptions.cs index a7df64e..68be3f6 100644 --- a/src/Infrastructure/Configuration/MessageBusOptions.cs +++ b/Algowars.Application/Configuration/MessageBusOptions.cs @@ -1,8 +1,10 @@ -namespace Infrastructure.Configuration; +using Algowars.Application.Settings; -public sealed class MessageBusOptions +namespace Algowars.Application.Configuration; + +public sealed class MessageBusOptions : IOption { - public const string SectionName = "MessageBus"; + public static string SectionName => "MessageBus"; public string Transport { get; init; } = "RabbitMQ"; diff --git a/Algowars.Application/Configuration/Option.cs b/Algowars.Application/Configuration/Option.cs new file mode 100644 index 0000000..bb6c9c4 --- /dev/null +++ b/Algowars.Application/Configuration/Option.cs @@ -0,0 +1,6 @@ +namespace Algowars.Application.Settings; + +public interface IOption +{ + static abstract string SectionName { get; } +} \ No newline at end of file diff --git a/Algowars.Application/Languages/LanguageReadRepository.cs b/Algowars.Application/Languages/LanguageReadRepository.cs new file mode 100644 index 0000000..4b83c83 --- /dev/null +++ b/Algowars.Application/Languages/LanguageReadRepository.cs @@ -0,0 +1,8 @@ +using Algowars.Domain.Languages.Entities; + +namespace Algowars.Application.Languages; + +public interface ILanguageReadRepository +{ + Task> FindLanguagesByVersionId(IEnumerable versionIds, CancellationToken cancellationToken); +} diff --git a/src/ApplicationCore/Interfaces/Messaging/IMessagePublisher.cs b/Algowars.Application/Messaging/IMessagePublisher.cs similarity index 74% rename from src/ApplicationCore/Interfaces/Messaging/IMessagePublisher.cs rename to Algowars.Application/Messaging/IMessagePublisher.cs index c864f95..9609897 100644 --- a/src/ApplicationCore/Interfaces/Messaging/IMessagePublisher.cs +++ b/Algowars.Application/Messaging/IMessagePublisher.cs @@ -1,7 +1,7 @@ -namespace ApplicationCore.Interfaces.Messaging; +namespace Algowars.Application.Messaging; public interface IMessagePublisher { Task PublishAsync(T message, CancellationToken cancellationToken = default) where T : class; -} \ No newline at end of file +} diff --git a/Algowars.Application/Messaging/Messages/SubmissionCreatedMessage.cs b/Algowars.Application/Messaging/Messages/SubmissionCreatedMessage.cs new file mode 100644 index 0000000..81e33b6 --- /dev/null +++ b/Algowars.Application/Messaging/Messages/SubmissionCreatedMessage.cs @@ -0,0 +1,3 @@ +namespace Algowars.Application.Messaging.Messages; + +public sealed record SubmissionCreatedMessage(Guid SubmissionId); diff --git a/src/ApplicationCore/Common/Pagination/PaginatedResult.cs b/Algowars.Application/Pagination/PageResult.cs similarity index 63% rename from src/ApplicationCore/Common/Pagination/PaginatedResult.cs rename to Algowars.Application/Pagination/PageResult.cs index 0e94b53..159b7ba 100644 --- a/src/ApplicationCore/Common/Pagination/PaginatedResult.cs +++ b/Algowars.Application/Pagination/PageResult.cs @@ -1,14 +1,20 @@ -namespace ApplicationCore.Common.Pagination; +namespace Algowars.Application.Pagination; -public sealed class PaginatedResult +public sealed class PageResult { public required IReadOnlyList Results { get; init; } + public required int Total { get; init; } + public required int Page { get; init; } + public required int Size { get; init; } - private int TotalPages => Size > 0 && Total > 0 ? (int)Math.Ceiling((double)Total / Size) : 0; + public int TotalPages => Size > 0 && Total > 0 ? (int)Math.Ceiling((double)Total / Size) : 0; + public bool HasPrevious => Page > 1; + public bool HasNext => Page < TotalPages; + public int Offset => (Page - 1) * Size; -} \ No newline at end of file +} diff --git a/Algowars.Application/Pagination/PaginationRequest.cs b/Algowars.Application/Pagination/PaginationRequest.cs new file mode 100644 index 0000000..31e6e67 --- /dev/null +++ b/Algowars.Application/Pagination/PaginationRequest.cs @@ -0,0 +1,10 @@ +namespace Algowars.Application.Pagination; + +public class PaginationRequest +{ + public required int Page { get; init; } + + public required int Size { get; init; } + + public DateTime Timestamp { get; init; } +} diff --git a/src/ApplicationCore/Common/Pagination/SortDirection.cs b/Algowars.Application/Pagination/SortDirection.cs similarity index 51% rename from src/ApplicationCore/Common/Pagination/SortDirection.cs rename to Algowars.Application/Pagination/SortDirection.cs index d1b70ae..9ffc29d 100644 --- a/src/ApplicationCore/Common/Pagination/SortDirection.cs +++ b/Algowars.Application/Pagination/SortDirection.cs @@ -1,4 +1,4 @@ -namespace ApplicationCore.Common.Pagination; +namespace Algowars.Application.Pagination; public enum SortDirection { diff --git a/Algowars.Application/Problems/Dtos/ProblemDto.cs b/Algowars.Application/Problems/Dtos/ProblemDto.cs new file mode 100644 index 0000000..8484acc --- /dev/null +++ b/Algowars.Application/Problems/Dtos/ProblemDto.cs @@ -0,0 +1,5 @@ +using Algowars.Domain.Problems.Enums; + +namespace Algowars.Application.Problems.Dtos; + +public sealed record ProblemDto(Guid Id, string Slug, string Title, DifficultyTier DifficultyTier, ProblemStatus Status); diff --git a/Algowars.Application/Problems/Dtos/ProblemSetupDto.cs b/Algowars.Application/Problems/Dtos/ProblemSetupDto.cs new file mode 100644 index 0000000..0c26c59 --- /dev/null +++ b/Algowars.Application/Problems/Dtos/ProblemSetupDto.cs @@ -0,0 +1,5 @@ +namespace Algowars.Application.Problems.Dtos; + +public sealed record ProblemSetupTestCaseDto(string Inputs, string ExpectedOutput); + +public sealed record ProblemSetupDto(Guid Id, string InitialCode, IEnumerable TestCases); \ No newline at end of file diff --git a/Algowars.Application/Problems/Dtos/ProblemSetupLanguageDto.cs b/Algowars.Application/Problems/Dtos/ProblemSetupLanguageDto.cs new file mode 100644 index 0000000..c8e31fb --- /dev/null +++ b/Algowars.Application/Problems/Dtos/ProblemSetupLanguageDto.cs @@ -0,0 +1,6 @@ + +namespace Algowars.Application.Problems.Dtos; + +public sealed record ProblemSetupLanguageVersionDto(Guid Id, string Version); + +public sealed record ProblemSetupLanguageDto(Guid Id, string Name, IEnumerable Versions); \ No newline at end of file diff --git a/Algowars.Application/Problems/Dtos/ProblemWithSetupsDto.cs b/Algowars.Application/Problems/Dtos/ProblemWithSetupsDto.cs new file mode 100644 index 0000000..7f97446 --- /dev/null +++ b/Algowars.Application/Problems/Dtos/ProblemWithSetupsDto.cs @@ -0,0 +1,5 @@ +using Algowars.Domain.Problems.Enums; + +namespace Algowars.Application.Problems.Dtos; + +public sealed record ProblemWithSetupsDto(Guid Id, string Slug, string Title, DifficultyTier DifficultyTier, string Question, IEnumerable AvailableLanguages); diff --git a/Algowars.Application/Problems/IProblemReadRepository.cs b/Algowars.Application/Problems/IProblemReadRepository.cs new file mode 100644 index 0000000..5cdb15e --- /dev/null +++ b/Algowars.Application/Problems/IProblemReadRepository.cs @@ -0,0 +1,12 @@ +using Algowars.Application.Pagination; +using Algowars.Application.Problems.Dtos; +using Algowars.Domain.Problems.Entities; + +namespace Algowars.Application.Problems; + +public interface IProblemReadRepository +{ + Task> GetPagedAsync(PaginationRequest pagination, CancellationToken cancellationToken = default); + + Task FindBySlugAsync(string slug, CancellationToken cancellationToken = default); +} diff --git a/src/ApplicationCore/Queries/IQuery.cs b/Algowars.Application/Queries/IQuery.cs similarity index 53% rename from src/ApplicationCore/Queries/IQuery.cs rename to Algowars.Application/Queries/IQuery.cs index 420f99a..3787de6 100644 --- a/src/ApplicationCore/Queries/IQuery.cs +++ b/Algowars.Application/Queries/IQuery.cs @@ -1,6 +1,6 @@ -using Ardalis.Result; +using Ardalis.Result; using MediatR; -namespace ApplicationCore.Queries; +namespace Algowars.Application.Queries; -public interface IQuery : IRequest> { } \ No newline at end of file +public interface IQuery : IRequest> { } diff --git a/src/ApplicationCore/Queries/IQueryHandler.cs b/Algowars.Application/Queries/IQueryHandler.cs similarity index 70% rename from src/ApplicationCore/Queries/IQueryHandler.cs rename to Algowars.Application/Queries/IQueryHandler.cs index c1ced49..8e4f2c0 100644 --- a/src/ApplicationCore/Queries/IQueryHandler.cs +++ b/Algowars.Application/Queries/IQueryHandler.cs @@ -1,7 +1,7 @@ -using Ardalis.Result; +using Ardalis.Result; using MediatR; -namespace ApplicationCore.Queries; +namespace Algowars.Application.Queries; public interface IQueryHandler : IRequestHandler> where TQuery : IQuery diff --git a/Algowars.Application/Queries/Problems/GetProblemBySlug/GetProblemBySlugHandler.cs b/Algowars.Application/Queries/Problems/GetProblemBySlug/GetProblemBySlugHandler.cs new file mode 100644 index 0000000..dd36173 --- /dev/null +++ b/Algowars.Application/Queries/Problems/GetProblemBySlug/GetProblemBySlugHandler.cs @@ -0,0 +1,37 @@ +using Algowars.Application.Languages; +using Algowars.Application.Problems; +using Algowars.Application.Problems.Dtos; +using Ardalis.Result; +namespace Algowars.Application.Queries.Problems.GetProblemBySlug; + +internal sealed class GetProblemBySlugHandler(IProblemReadRepository problemReadRepository, ILanguageReadRepository languageReadRepository) : IQueryHandler +{ + public async Task> Handle(GetProblemBySlugQuery request, CancellationToken cancellationToken) + { + var problem = await problemReadRepository.FindBySlugAsync(request.Slug, cancellationToken); + + if (problem is null) + { + return Result.NotFound(); + } + + var languages = await languageReadRepository.FindLanguagesByVersionId(problem.AvailableLanguageVersionIds(), cancellationToken); + + return Result.Success( + new ProblemWithSetupsDto( + Id: problem.Id, + Slug: problem.Slug, + Title: problem.Title, + DifficultyTier: problem.Difficulty.Tier, + Question: problem.Question, + AvailableLanguages: languages.Select(language => new ProblemSetupLanguageDto( + language.Id, + language.Name, + Versions: language.Versions.Select(version => new ProblemSetupLanguageVersionDto( + version.Id, version.Version + )) + ) + )) + ); + } +} diff --git a/Algowars.Application/Queries/Problems/GetProblemBySlug/GetProblemBySlugQuery.cs b/Algowars.Application/Queries/Problems/GetProblemBySlug/GetProblemBySlugQuery.cs new file mode 100644 index 0000000..bd3ccb9 --- /dev/null +++ b/Algowars.Application/Queries/Problems/GetProblemBySlug/GetProblemBySlugQuery.cs @@ -0,0 +1,5 @@ +using Algowars.Application.Problems.Dtos; + +namespace Algowars.Application.Queries.Problems.GetProblemBySlug; + +public sealed record GetProblemBySlugQuery(string Slug) : IQuery; diff --git a/Algowars.Application/Queries/Problems/GetProblemSetup/GetProblemSetupHandler.cs b/Algowars.Application/Queries/Problems/GetProblemSetup/GetProblemSetupHandler.cs new file mode 100644 index 0000000..a6497e5 --- /dev/null +++ b/Algowars.Application/Queries/Problems/GetProblemSetup/GetProblemSetupHandler.cs @@ -0,0 +1,34 @@ +using Algowars.Application.Problems; +using Algowars.Application.Problems.Dtos; +using Ardalis.Result; + +namespace Algowars.Application.Queries.Problems.GetProblemSetup; + +internal sealed class GetProblemSetupHandler(IProblemReadRepository problemReadRepository) : IQueryHandler +{ + public async Task> Handle(GetProblemSetupQuery request, CancellationToken cancellationToken) + { + var problem = await problemReadRepository.FindBySlugAsync(request.Slug, cancellationToken); + + if (problem is null) + { + return Result.NotFound(); + } + + var foundSetup = problem.FindSetupByLanguageVersionId(request.LanguageVersionId); + + if (foundSetup is null) + { + return Result.NotFound(); + } + + return Result.Success(new ProblemSetupDto( + foundSetup.Id, + foundSetup.InitialCode, + foundSetup.PublicTestSuites().SelectMany(testSuite => testSuite.TestCases, (testSuite, testCase) => new ProblemSetupTestCaseDto( + string.Join(", ", testCase.Inputs), + string.Join(", ", testCase.ExpectedOutputs) + )) + )); + } +} diff --git a/Algowars.Application/Queries/Problems/GetProblemSetup/GetProblemSetupQuery.cs b/Algowars.Application/Queries/Problems/GetProblemSetup/GetProblemSetupQuery.cs new file mode 100644 index 0000000..eba2eb4 --- /dev/null +++ b/Algowars.Application/Queries/Problems/GetProblemSetup/GetProblemSetupQuery.cs @@ -0,0 +1,5 @@ +using Algowars.Application.Problems.Dtos; + +namespace Algowars.Application.Queries.Problems.GetProblemSetup; + +public sealed record GetProblemSetupQuery(string Slug, Guid LanguageVersionId) : IQuery; \ No newline at end of file diff --git a/Algowars.Application/Queries/Problems/GetProblemsPageable/GetProblemsPageableHandler.cs b/Algowars.Application/Queries/Problems/GetProblemsPageable/GetProblemsPageableHandler.cs new file mode 100644 index 0000000..268147e --- /dev/null +++ b/Algowars.Application/Queries/Problems/GetProblemsPageable/GetProblemsPageableHandler.cs @@ -0,0 +1,16 @@ +using Algowars.Application.Pagination; +using Algowars.Application.Problems; +using Algowars.Application.Problems.Dtos; +using Ardalis.Result; + +namespace Algowars.Application.Queries.Problems.GetProblemsPageable; + +internal sealed class GetProblemsPageableHandler(IProblemReadRepository problemReadRepository) : IQueryHandler> +{ + public async Task>> Handle(GetProblemsPageableQuery request, CancellationToken cancellationToken) + { + var result = await problemReadRepository.GetPagedAsync(request.PaginationRequest, cancellationToken); + + return Result.Success(result); + } +} diff --git a/Algowars.Application/Queries/Problems/GetProblemsPageable/GetProblemsPageableQuery.cs b/Algowars.Application/Queries/Problems/GetProblemsPageable/GetProblemsPageableQuery.cs new file mode 100644 index 0000000..9d83de6 --- /dev/null +++ b/Algowars.Application/Queries/Problems/GetProblemsPageable/GetProblemsPageableQuery.cs @@ -0,0 +1,6 @@ +using Algowars.Application.Pagination; +using Algowars.Application.Problems.Dtos; + +namespace Algowars.Application.Queries.Problems.GetProblemsPageable; + +public sealed record GetProblemsPageableQuery(PaginationRequest PaginationRequest) : IQuery>; \ No newline at end of file diff --git a/Algowars.Application/Queries/Users/GetUserBySub/GetUserBySubHandler.cs b/Algowars.Application/Queries/Users/GetUserBySub/GetUserBySubHandler.cs new file mode 100644 index 0000000..8fda718 --- /dev/null +++ b/Algowars.Application/Queries/Users/GetUserBySub/GetUserBySubHandler.cs @@ -0,0 +1,19 @@ +using Algowars.Application.Users; +using Algowars.Application.Users.Dtos; +using Ardalis.Result; + +namespace Algowars.Application.Queries.Users.GetUserBySub; + +internal sealed class GetUserBySubHandler(IUserReadRepository userReadRepository) + : IQueryHandler +{ + public async Task> Handle(GetUserBySubQuery request, CancellationToken cancellationToken) + { + var user = await userReadRepository.FindBySubAsync(request.Sub, cancellationToken); + + if (user is null) + return Result.NotFound(); + + return Result.Success(user); + } +} diff --git a/Algowars.Application/Queries/Users/GetUserBySub/GetUserBySubQuery.cs b/Algowars.Application/Queries/Users/GetUserBySub/GetUserBySubQuery.cs new file mode 100644 index 0000000..c52ceea --- /dev/null +++ b/Algowars.Application/Queries/Users/GetUserBySub/GetUserBySubQuery.cs @@ -0,0 +1,6 @@ +using Algowars.Application.Queries; +using Algowars.Application.Users.Dtos; + +namespace Algowars.Application.Queries.Users.GetUserBySub; + +public sealed record GetUserBySubQuery(string Sub) : IQuery; diff --git a/Algowars.Application/Services/Problems/ProblemService.cs b/Algowars.Application/Services/Problems/ProblemService.cs new file mode 100644 index 0000000..6c7b459 --- /dev/null +++ b/Algowars.Application/Services/Problems/ProblemService.cs @@ -0,0 +1,42 @@ +using Algowars.Application.Pagination; +using Algowars.Application.Problems.Dtos; +using Algowars.Application.Queries.Problems.GetProblemBySlug; +using Algowars.Application.Queries.Problems.GetProblemSetup; +using Algowars.Application.Queries.Problems.GetProblemsPageable; +using Ardalis.Result; +using MediatR; + +namespace Algowars.Application.Services.Problems; + +public interface IProblemService +{ + Task> GetProblemSetupAsync(string slug, Guid languageVersionId, CancellationToken cancellationToken); + + Task>> GetProblemsPageableAsync(PaginationRequest paginationRequest, CancellationToken cancellationToken); + + Task> GetProblemWithSetupsBySlug(string slug, CancellationToken cancellationToken); +} + +internal sealed class ProblemService(IMediator mediator) : IProblemService +{ + public async Task> GetProblemSetupAsync(string slug, Guid languageVersionId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetProblemSetupQuery(slug, languageVersionId), cancellationToken); + + return result; + } + + public async Task>> GetProblemsPageableAsync(PaginationRequest paginationRequest, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetProblemsPageableQuery(paginationRequest), cancellationToken); + + return result; + } + + public async Task> GetProblemWithSetupsBySlug(string slug, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetProblemBySlugQuery(slug), cancellationToken); + + return result; + } +} diff --git a/Algowars.Application/Services/Users/UserService.cs b/Algowars.Application/Services/Users/UserService.cs new file mode 100644 index 0000000..187b0cf --- /dev/null +++ b/Algowars.Application/Services/Users/UserService.cs @@ -0,0 +1,30 @@ +using Algowars.Application.Commands.Users.UpsertUser; +using Algowars.Application.Queries.Users.GetUserBySub; +using Algowars.Application.Users.Dtos; +using Ardalis.Result; +using MediatR; + +namespace Algowars.Application.Services.Users; + +public interface IUserService +{ + Task> GetBySubAsync(string sub, CancellationToken cancellationToken); + + Task> UpsertAccountAsync(string sub, UpsertUserDto request, CancellationToken cancellationToken); +} + + +internal sealed class UserService(IMediator mediator) : IUserService +{ + public async Task> GetBySubAsync(string sub, CancellationToken cancellationToken = default) + { + var result = await mediator.Send(new GetUserBySubQuery(sub), cancellationToken); + return result; + } + + public async Task> UpsertAccountAsync(string sub, UpsertUserDto request, CancellationToken cancellationToken) + { + var result = await mediator.Send(new UpsertUserCommand(sub, request.Username, request.ImageUrl, request.Bio), cancellationToken); + return result; + } +} diff --git a/Algowars.Application/Services/Users/UsernameGeneratorService.cs b/Algowars.Application/Services/Users/UsernameGeneratorService.cs new file mode 100644 index 0000000..696eb70 --- /dev/null +++ b/Algowars.Application/Services/Users/UsernameGeneratorService.cs @@ -0,0 +1,34 @@ +using Bogus; + +namespace Algowars.Application.Services.Users; + +public interface IUsernameGeneratorService +{ + string Generate(); +} + +internal sealed class UsernameGeneratorService : IUsernameGeneratorService +{ + private readonly Faker _faker = new(); + + public string Generate() + { + string adjective = Clean(_faker.Hacker.Adjective()); + string noun = Clean(_faker.Hacker.Noun()); + int number = _faker.Random.Int(10, 99); + + return $"{Truncate(adjective, 8)}_{Truncate(noun, 7)}{number}"; + } + + private static string Clean(string value) + { + char[] chars = [.. value + .Select(c => c == ' ' ? '_' : c) + .Where(c => char.IsLetterOrDigit(c) || c == '_' || c == '-')]; + return new string(chars); + } + + private static string Truncate(string value, int maxLength) + => value.Length <= maxLength ? value : value[..maxLength]; +} + diff --git a/Algowars.Application/UserContext.cs b/Algowars.Application/UserContext.cs new file mode 100644 index 0000000..f9363d3 --- /dev/null +++ b/Algowars.Application/UserContext.cs @@ -0,0 +1,15 @@ +using Algowars.Application.Users.Dtos; + +namespace Algowars.Application; + +public sealed class UserContext +{ + public UserDto? User { get; set; } + + public IReadOnlyList Permissions { get; set; } = []; + + public bool IsAuthenticated => User is not null; + + public bool HasPermission(string permission) => + Permissions.Contains(permission); +} diff --git a/Algowars.Application/Users/Dtos/UpsertUserDto.cs b/Algowars.Application/Users/Dtos/UpsertUserDto.cs new file mode 100644 index 0000000..c62c198 --- /dev/null +++ b/Algowars.Application/Users/Dtos/UpsertUserDto.cs @@ -0,0 +1,3 @@ +namespace Algowars.Application.Users.Dtos; + +public sealed record UpsertUserDto(string? Username, string? ImageUrl, string? Bio); \ No newline at end of file diff --git a/Algowars.Application/Users/Dtos/UserDto.cs b/Algowars.Application/Users/Dtos/UserDto.cs new file mode 100644 index 0000000..b654d75 --- /dev/null +++ b/Algowars.Application/Users/Dtos/UserDto.cs @@ -0,0 +1,3 @@ +namespace Algowars.Application.Users.Dtos; + +public sealed record UserDto(Guid Id, string Sub, string Username, string? ImageUrl, DateTime? UsernameLastChangedAt); diff --git a/Algowars.Application/Users/IUserReadRepository.cs b/Algowars.Application/Users/IUserReadRepository.cs new file mode 100644 index 0000000..39ce88c --- /dev/null +++ b/Algowars.Application/Users/IUserReadRepository.cs @@ -0,0 +1,9 @@ +using Algowars.Application.Users.Dtos; + +namespace Algowars.Application.Users; + +public interface IUserReadRepository +{ + Task FindBySubAsync(string sub, CancellationToken cancellationToken); + Task FindByIdAsync(Guid id, CancellationToken cancellationToken); +} diff --git a/Algowars.Domain.Tests/Algowars.Domain.Tests.csproj b/Algowars.Domain.Tests/Algowars.Domain.Tests.csproj new file mode 100644 index 0000000..9290214 --- /dev/null +++ b/Algowars.Domain.Tests/Algowars.Domain.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/Algowars.Domain.Tests/Language/Entities/LanguageTests.cs b/Algowars.Domain.Tests/Language/Entities/LanguageTests.cs new file mode 100644 index 0000000..7d2d98c --- /dev/null +++ b/Algowars.Domain.Tests/Language/Entities/LanguageTests.cs @@ -0,0 +1,149 @@ +using Algowars.Domain.Languages.Entities; +using Algowars.Domain.Languages.Enums; +using Algowars.Domain.Languages.Exceptions; +using Algowars.Domain.Languages.ValueObjects; +using LanguageEntity = Algowars.Domain.Languages.Entities.Language; + +namespace Algowars.Domain.Tests.Language.Entities; + +public class LanguageTests +{ + private static readonly LanguageName ValidName = new("Python"); + private static readonly LanguageSlug ValidSlug = new("python"); + private static readonly LanguageVersion ValidVersion = new("3.11"); + private static readonly Judge0Id ValidJudge0Id = new(109); + + private static LanguageEntity CreateLanguage() => new(ValidName, ValidSlug); + + [Test] + public void Activate_WhenInactive_SetsStatusToActive() + { + var language = CreateLanguage(); + language.Deactivate(); + + language.Activate(); + + Assert.That(language.Status, Is.EqualTo(LanguageStatus.Active)); + } + + [Test] + public void AddVersion_AddsToVersionsCollection() + { + var language = CreateLanguage(); + + language.AddVersion(ValidVersion, ValidJudge0Id); + + Assert.That(language.Versions, Has.Count.EqualTo(1)); + } + + [Test] + public void AddVersion_MultipleVersions_AllAdded() + { + var language = CreateLanguage(); + + language.AddVersion(new LanguageVersion("3.10"), new Judge0Id(100)); + language.AddVersion(new LanguageVersion("3.11"), new Judge0Id(109)); + + Assert.That(language.Versions, Has.Count.EqualTo(2)); + } + + [Test] + public void AddVersion_ReturnsEntryWithActiveStatus() + { + var language = CreateLanguage(); + + LanguageVersionEntry entry = language.AddVersion(ValidVersion, ValidJudge0Id); + + using (Assert.EnterMultipleScope()) + { + Assert.That(entry.IsActive, Is.True); + Assert.That(entry.Version, Is.EqualTo(ValidVersion)); + Assert.That(entry.Judge0Id, Is.EqualTo(ValidJudge0Id)); + } + } + + [Test] + public void Constructor_SetsNameAndSlug() + { + var language = CreateLanguage(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(language.Name, Is.EqualTo(ValidName)); + Assert.That(language.Slug, Is.EqualTo(ValidSlug)); + } + } + + [Test] + public void Constructor_SetsStatusToActive() + { + var language = CreateLanguage(); + + Assert.That(language.Status, Is.EqualTo(LanguageStatus.Active)); + } + + [Test] + public void Constructor_VersionsIsEmpty() + { + var language = CreateLanguage(); + + Assert.That(language.Versions, Is.Empty); + } + + [Test] + public void Deactivate_SetsStatusToInactive() + { + var language = CreateLanguage(); + + language.Deactivate(); + + Assert.That(language.Status, Is.EqualTo(LanguageStatus.Inactive)); + } + + [Test] + public void DeprecateVersion_DoesNotRemoveVersion() + { + var language = CreateLanguage(); + LanguageVersionEntry entry = language.AddVersion(ValidVersion, ValidJudge0Id); + + language.DeprecateVersion(entry.Id); + + Assert.That(language.Versions, Has.Count.EqualTo(1)); + } + + [Test] + public void DeprecateVersion_SetsVersionToDeprecated() + { + LanguageEntity language = CreateLanguage(); + LanguageVersionEntry entry = language.AddVersion(ValidVersion, ValidJudge0Id); + + language.DeprecateVersion(entry.Id); + + Assert.That(entry.Status, Is.EqualTo(LanguageVersionStatus.Deprecated)); + } + + [Test] + public void DeprecateVersion_UnknownVersionId_ThrowsLanguageVersionNotFoundException() + { + var language = CreateLanguage(); + + Assert.Throws(() => language.DeprecateVersion(Guid.NewGuid())); + } + + [Test] + public void IsActive_WhenActive_IsTrue() + { + var language = CreateLanguage(); + + Assert.That(language.IsActive, Is.True); + } + + [Test] + public void IsActive_WhenInactive_IsFalse() + { + var language = CreateLanguage(); + language.Deactivate(); + + Assert.That(language.IsActive, Is.False); + } +} diff --git a/Algowars.Domain.Tests/Language/Entities/LanguageVersionEntryTests.cs b/Algowars.Domain.Tests/Language/Entities/LanguageVersionEntryTests.cs new file mode 100644 index 0000000..aa54f68 --- /dev/null +++ b/Algowars.Domain.Tests/Language/Entities/LanguageVersionEntryTests.cs @@ -0,0 +1,65 @@ +using Algowars.Domain.Languages.Entities; +using Algowars.Domain.Languages.Enums; +using Algowars.Domain.Languages.ValueObjects; +using LanguageEntity = Algowars.Domain.Languages.Entities.Language; + +namespace Algowars.Domain.Tests.Language.Entities; + +public class LanguageVersionEntryTests +{ + private static readonly LanguageName ValidName = new("Python"); + private static readonly LanguageSlug ValidSlug = new("python"); + private static readonly LanguageVersion ValidVersion = new("3.11"); + private static readonly Judge0Id ValidJudge0Id = new(109); + + private static LanguageEntity CreateLanguage() => new(ValidName, ValidSlug); + + [Test] + public void Deprecate_SetsIsActiveToFalse() + { + LanguageEntity language = CreateLanguage(); + LanguageVersionEntry entry = language.AddVersion(ValidVersion, ValidJudge0Id); + + entry.Deprecate(); + + Assert.That(entry.IsActive, Is.False); + } + + [Test] + public void Deprecate_SetsStatusToDeprecated() + { + LanguageEntity language = CreateLanguage(); + LanguageVersionEntry entry = language.AddVersion(ValidVersion, ValidJudge0Id); + + entry.Deprecate(); + + Assert.That(entry.Status, Is.EqualTo(LanguageVersionStatus.Deprecated)); + } + + [Test] + public void InitialStatus_IsActive() + { + LanguageEntity language = CreateLanguage(); + LanguageVersionEntry entry = language.AddVersion(ValidVersion, ValidJudge0Id); + + Assert.That(entry.Status, Is.EqualTo(LanguageVersionStatus.Active)); + } + + [Test] + public void IsActive_WhenActive_IsTrue() + { + LanguageEntity language = CreateLanguage(); + LanguageVersionEntry entry = language.AddVersion(ValidVersion, ValidJudge0Id); + + Assert.That(entry.IsActive, Is.True); + } + + [Test] + public void Version_SetCorrectly() + { + LanguageEntity language = CreateLanguage(); + LanguageVersionEntry entry = language.AddVersion(ValidVersion, ValidJudge0Id); + + Assert.That(entry.Version, Is.EqualTo(ValidVersion)); + } +} diff --git a/Algowars.Domain.Tests/Language/ValueObjects/LanguageNameTests.cs b/Algowars.Domain.Tests/Language/ValueObjects/LanguageNameTests.cs new file mode 100644 index 0000000..5c9c9fe --- /dev/null +++ b/Algowars.Domain.Tests/Language/ValueObjects/LanguageNameTests.cs @@ -0,0 +1,77 @@ +using Algowars.Domain.Languages.Exceptions; +using Algowars.Domain.Languages.ValueObjects; + +namespace Algowars.Domain.Tests.Language.ValueObjects; + +public class LanguageNameTests +{ + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string value = new('a', LanguageName.MaxLength); + + Assert.That(() => new LanguageName(value), Throws.Nothing); + } + + [Test] + public void Constructor_AtMinLength_Succeeds() + { + Assert.That(() => new LanguageName("C"), Throws.Nothing); + } + + [Test] + public void Constructor_EmptyString_ThrowsInvalidLanguageNameException() + { + Assert.Throws(() => new LanguageName(string.Empty)); + } + + [Test] + public void Constructor_ExceedsMaxLength_ThrowsInvalidLanguageNameException() + { + string value = new('a', LanguageName.MaxLength + 1); + + Assert.Throws(() => new LanguageName(value)); + } + + [Test] + public void Constructor_WhitespaceOnly_ThrowsInvalidLanguageNameException() + { + Assert.Throws(() => new LanguageName(" ")); + } + + [Test] + public void Equality_DifferentValues_AreNotEqual() + { + var a = new LanguageName("Python"); + var b = new LanguageName("JavaScript"); + + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new LanguageName("Python"); + var b = new LanguageName("Python"); + + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void ImplicitConversion_ReturnsValue() + { + var name = new LanguageName("Python"); + + string result = name; + + Assert.That(result, Is.EqualTo("Python")); + } + + [Test] + public void ToString_ReturnsValue() + { + var name = new LanguageName("Python"); + + Assert.That(name.ToString(), Is.EqualTo("Python")); + } +} diff --git a/Algowars.Domain.Tests/Language/ValueObjects/LanguageSlugTests.cs b/Algowars.Domain.Tests/Language/ValueObjects/LanguageSlugTests.cs new file mode 100644 index 0000000..f9104e2 --- /dev/null +++ b/Algowars.Domain.Tests/Language/ValueObjects/LanguageSlugTests.cs @@ -0,0 +1,127 @@ +using Algowars.Domain.Languages.Exceptions; +using Algowars.Domain.Languages.ValueObjects; + +namespace Algowars.Domain.Tests.Language.ValueObjects; + +public class LanguageSlugTests +{ + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string value = new('a', LanguageSlug.MaxLength); + + Assert.That(() => new LanguageSlug(value), Throws.Nothing); + } + + [Test] + public void Constructor_AtMinLength_Succeeds() + { + Assert.That(() => new LanguageSlug("c"), Throws.Nothing); + } + + [Test] + public void Constructor_EmptyString_ThrowsInvalidLanguageSlugException() + { + Assert.Throws(() => new LanguageSlug(string.Empty)); + } + + [Test] + public void Constructor_ExceedsMaxLength_ThrowsInvalidLanguageSlugException() + { + string value = new('a', LanguageSlug.MaxLength + 1); + + Assert.Throws(() => new LanguageSlug(value)); + } + + [Test] + public void Constructor_WhitespaceOnly_ThrowsInvalidLanguageSlugException() + { + Assert.Throws(() => new LanguageSlug(" ")); + } + + [TestCase("Python")] + [TestCase("PYTHON")] + [TestCase("-python")] + [TestCase("python-")] + [TestCase("python--311")] + [TestCase("python 311")] + public void Constructor_InvalidFormat_ThrowsInvalidLanguageSlugException(string value) + { + Assert.Throws(() => new LanguageSlug(value)); + } + + [TestCase("python")] + [TestCase("cpp")] + [TestCase("python-311")] + [TestCase("c")] + public void Constructor_ValidFormat_Succeeds(string value) + { + Assert.That(() => new LanguageSlug(value), Throws.Nothing); + } + + [Test] + public void Equality_DifferentValues_AreNotEqual() + { + var a = new LanguageSlug("python"); + var b = new LanguageSlug("cpp"); + + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new LanguageSlug("python"); + var b = new LanguageSlug("python"); + + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void FromName_CollapseMultipleSpaces() + { + var name = new LanguageName("C Sharp"); + + var slug = LanguageSlug.FromName(name); + + Assert.That(slug.Value, Is.EqualTo("c-sharp")); + } + + [Test] + public void FromName_GeneratesValidSlug() + { + var name = new LanguageName("Python"); + + var slug = LanguageSlug.FromName(name); + + Assert.That(slug.Value, Is.EqualTo("python")); + } + + [Test] + public void FromName_StripsSpecialCharacters() + { + var name = new LanguageName("C++"); + + var slug = LanguageSlug.FromName(name); + + Assert.That(slug.Value, Is.EqualTo("c")); + } + + [Test] + public void ImplicitConversion_ReturnsValue() + { + var slug = new LanguageSlug("python"); + + string result = slug; + + Assert.That(result, Is.EqualTo("python")); + } + + [Test] + public void ToString_ReturnsValue() + { + var slug = new LanguageSlug("python"); + + Assert.That(slug.ToString(), Is.EqualTo("python")); + } +} diff --git a/Algowars.Domain.Tests/Language/ValueObjects/LanguageVersionTests.cs b/Algowars.Domain.Tests/Language/ValueObjects/LanguageVersionTests.cs new file mode 100644 index 0000000..5b83bb5 --- /dev/null +++ b/Algowars.Domain.Tests/Language/ValueObjects/LanguageVersionTests.cs @@ -0,0 +1,71 @@ +using Algowars.Domain.Languages.Exceptions; +using Algowars.Domain.Languages.ValueObjects; + +namespace Algowars.Domain.Tests.Language.ValueObjects; + +public class LanguageVersionTests +{ + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string value = new('a', LanguageVersion.MaxLength); + + Assert.That(() => new LanguageVersion(value), Throws.Nothing); + } + + [Test] + public void Constructor_EmptyString_ThrowsInvalidLanguageVersionException() + { + Assert.Throws(() => new LanguageVersion(string.Empty)); + } + + [Test] + public void Constructor_ExceedsMaxLength_ThrowsInvalidLanguageVersionException() + { + string value = new('a', LanguageVersion.MaxLength + 1); + + Assert.Throws(() => new LanguageVersion(value)); + } + + [Test] + public void Constructor_WhitespaceOnly_ThrowsInvalidLanguageVersionException() + { + Assert.Throws(() => new LanguageVersion(" ")); + } + + [Test] + public void Equality_DifferentValues_AreNotEqual() + { + var a = new LanguageVersion("3.11"); + var b = new LanguageVersion("3.12"); + + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new LanguageVersion("3.11"); + var b = new LanguageVersion("3.11"); + + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void ImplicitConversion_ReturnsValue() + { + var version = new LanguageVersion("3.11"); + + string result = version; + + Assert.That(result, Is.EqualTo("3.11")); + } + + [Test] + public void ToString_ReturnsValue() + { + var version = new LanguageVersion("3.11"); + + Assert.That(version.ToString(), Is.EqualTo("3.11")); + } +} diff --git a/Algowars.Domain.Tests/Problem/Entities/ProblemTests.cs b/Algowars.Domain.Tests/Problem/Entities/ProblemTests.cs new file mode 100644 index 0000000..4777c84 --- /dev/null +++ b/Algowars.Domain.Tests/Problem/Entities/ProblemTests.cs @@ -0,0 +1,164 @@ +using Algowars.Domain.Problems.Enums; +using Algowars.Domain.Problems.ValueObjects; +using ProblemEntity = Algowars.Domain.Problems.Entities.Problem; + +namespace Algowars.Domain.Tests.Problem.Entities; + +public class ProblemTests +{ + private static readonly Slug ValidSlug = new("two-sum"); + private static readonly Title ValidTitle = new("Two Sum"); + private static readonly Question ValidQuestion = new(new string('a', Question.MinLength)); + private static readonly Difficulty ValidDifficulty = new(500); + private static readonly TimeLimit ValidTimeLimit = new(1000); + private static readonly MemoryLimit ValidMemoryLimit = new(64); + + private static ProblemEntity CreateProblem() => + new(ValidSlug, ValidTitle, ValidQuestion, ValidDifficulty, ValidTimeLimit, ValidMemoryLimit); + + [Test] + public void Constructor_SetsSlug() + { + ProblemEntity problem = CreateProblem(); + + Assert.That(problem.Slug, Is.EqualTo(ValidSlug)); + } + + [Test] + public void Constructor_SetsContentFields() + { + ProblemEntity problem = CreateProblem(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(problem.Title, Is.EqualTo(ValidTitle)); + Assert.That(problem.Question, Is.EqualTo(ValidQuestion)); + Assert.That(problem.Difficulty, Is.EqualTo(ValidDifficulty)); + Assert.That(problem.TimeLimit, Is.EqualTo(ValidTimeLimit)); + Assert.That(problem.MemoryLimit, Is.EqualTo(ValidMemoryLimit)); + } + } + + [Test] + public void Constructor_SetsStatusToDraft() + { + ProblemEntity problem = CreateProblem(); + + Assert.That(problem.Status, Is.EqualTo(ProblemStatus.Draft)); + } + + [Test] + public void Constructor_SetsCreatedAt() + { + DateTime before = DateTime.UtcNow; + ProblemEntity problem = CreateProblem(); + + Assert.That(problem.CreatedAt, Is.GreaterThanOrEqualTo(before)); + } + + [Test] + public void Archive_SetsStatusToArchived() + { + ProblemEntity problem = CreateProblem(); + + problem.Archive(); + + Assert.That(problem.Status, Is.EqualTo(ProblemStatus.Archived)); + } + + [Test] + public void Publish_SetsStatusToPublished() + { + ProblemEntity problem = CreateProblem(); + + problem.Publish(); + + Assert.That(problem.Status, Is.EqualTo(ProblemStatus.Published)); + } + + [Test] + public void UpdateContent_UpdatesAllContentFields() + { + ProblemEntity problem = CreateProblem(); + Title newTitle = new("Three Sum"); + Question newQuestion = new(new string('b', Question.MinLength)); + Difficulty newDifficulty = new(1500); + TimeLimit newTimeLimit = new(2000); + MemoryLimit newMemoryLimit = new(128); + + problem.UpdateContent(newTitle, newQuestion, newDifficulty, newTimeLimit, newMemoryLimit); + + using (Assert.EnterMultipleScope()) + { + Assert.That(problem.Title, Is.EqualTo(newTitle)); + Assert.That(problem.Question, Is.EqualTo(newQuestion)); + Assert.That(problem.Difficulty, Is.EqualTo(newDifficulty)); + Assert.That(problem.TimeLimit, Is.EqualTo(newTimeLimit)); + Assert.That(problem.MemoryLimit, Is.EqualTo(newMemoryLimit)); + } + } + + [Test] + public void UpdateContent_AddsHistoryEntry() + { + ProblemEntity problem = CreateProblem(); + + problem.UpdateContent( + new Title("Three Sum"), + new Question(new string('b', Question.MinLength)), + new Difficulty(1500), + new TimeLimit(2000), + new MemoryLimit(128)); + + Assert.That(problem.History, Has.Count.EqualTo(1)); + } + + [Test] + public void UpdateContent_MultipleUpdates_AddsMultipleHistoryEntries() + { + ProblemEntity problem = CreateProblem(); + + problem.UpdateContent(new Title("Three Sum"), new Question(new string('b', Question.MinLength)), new Difficulty(1500), new TimeLimit(2000), new MemoryLimit(128)); + problem.UpdateContent(new Title("Four Sum"), new Question(new string('c', Question.MinLength)), new Difficulty(2500), new TimeLimit(3000), new MemoryLimit(256)); + + Assert.That(problem.History, Has.Count.EqualTo(2)); + } + + [Test] + public void UpdateSlug_ChangesSlug() + { + ProblemEntity problem = CreateProblem(); + Slug newSlug = new("three-sum"); + + problem.UpdateSlug(newSlug); + + Assert.That(problem.Slug, Is.EqualTo(newSlug)); + } + + [Test] + public void AddSetup_AddsToSetups() + { + ProblemEntity problem = CreateProblem(); + + problem.AddSetup(Guid.NewGuid(), "def twoSum():", "twoSum"); + + Assert.That(problem.Setups, Has.Count.EqualTo(1)); + } + + [Test] + public void AddSetup_SetsProperties() + { + ProblemEntity problem = CreateProblem(); + Guid langVersionId = Guid.NewGuid(); + + Algowars.Domain.Problems.Entities.ProblemSetup setup = problem.AddSetup(langVersionId, "def twoSum():", "twoSum"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(setup.LanguageVersionId, Is.EqualTo(langVersionId)); + Assert.That(setup.InitialCode, Is.EqualTo("def twoSum():")); + Assert.That(setup.FunctionName, Is.EqualTo("twoSum")); + } + } +} + diff --git a/Algowars.Domain.Tests/Problem/ValueObjects/DifficultyTests.cs b/Algowars.Domain.Tests/Problem/ValueObjects/DifficultyTests.cs new file mode 100644 index 0000000..02037b2 --- /dev/null +++ b/Algowars.Domain.Tests/Problem/ValueObjects/DifficultyTests.cs @@ -0,0 +1,63 @@ +using Algowars.Domain.Problems.Enums; +using Algowars.Domain.Problems.Exceptions; +using Algowars.Domain.Problems.ValueObjects; + +namespace Algowars.Domain.Tests.Problem.ValueObjects; + +public class DifficultyTests +{ + [Test] + public void Constructor_AtMinValue_Succeeds() + { + var difficulty = new Difficulty(Difficulty.MinValue); + + Assert.That(difficulty.Value, Is.EqualTo(Difficulty.MinValue)); + } + + [Test] + public void Constructor_BelowMinValue_ThrowsInvalidDifficultyException() + { + Assert.Throws(() => new Difficulty(Difficulty.MinValue - 1)); + } + + [Test] + public void Equality_DifferentValues_AreNotEqual() + { + var a = new Difficulty(100); + var b = new Difficulty(200); + + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new Difficulty(500); + var b = new Difficulty(500); + + Assert.That(a, Is.EqualTo(b)); + } + + [TestCase(0, DifficultyTier.Easy)] + [TestCase(500, DifficultyTier.Easy)] + [TestCase(1000, DifficultyTier.Easy)] + [TestCase(1001, DifficultyTier.Medium)] + [TestCase(1500, DifficultyTier.Medium)] + [TestCase(2000, DifficultyTier.Medium)] + [TestCase(2001, DifficultyTier.Hard)] + [TestCase(3000, DifficultyTier.Hard)] + public void Tier_ReturnsCorrectTierForValue(int value, DifficultyTier expectedTier) + { + var difficulty = new Difficulty(value); + + Assert.That(difficulty.Tier, Is.EqualTo(expectedTier)); + } + + [Test] + public void ToString_IncludesValueAndTier() + { + var difficulty = new Difficulty(500); + + Assert.That(difficulty.ToString(), Does.Contain("500").And.Contain("Easy")); + } +} diff --git a/Algowars.Domain.Tests/Problem/ValueObjects/MemoryLimitTests.cs b/Algowars.Domain.Tests/Problem/ValueObjects/MemoryLimitTests.cs new file mode 100644 index 0000000..19ef437 --- /dev/null +++ b/Algowars.Domain.Tests/Problem/ValueObjects/MemoryLimitTests.cs @@ -0,0 +1,57 @@ +using Algowars.Domain.Problems.Exceptions; +using Algowars.Domain.Problems.ValueObjects; + +namespace Algowars.Domain.Tests.Problem.ValueObjects; + +public class MemoryLimitTests +{ + [Test] + public void Constructor_AtMaxMegabytes_Succeeds() + { + Assert.That(() => new MemoryLimit(MemoryLimit.MaxMegabytes), Throws.Nothing); + } + + [Test] + public void Constructor_AtMinMegabytes_Succeeds() + { + Assert.That(() => new MemoryLimit(MemoryLimit.MinMegabytes), Throws.Nothing); + } + + [Test] + public void Constructor_AboveMaxMegabytes_ThrowsInvalidMemoryLimitException() + { + Assert.Throws(() => new MemoryLimit(MemoryLimit.MaxMegabytes + 1)); + } + + [Test] + public void Constructor_BelowMinMegabytes_ThrowsInvalidMemoryLimitException() + { + Assert.Throws(() => new MemoryLimit(MemoryLimit.MinMegabytes - 1)); + } + + [Test] + public void Equality_DifferentValues_AreNotEqual() + { + var a = new MemoryLimit(64); + var b = new MemoryLimit(128); + + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new MemoryLimit(64); + var b = new MemoryLimit(64); + + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void ToString_IncludesMegabytes() + { + var memoryLimit = new MemoryLimit(64); + + Assert.That(memoryLimit.ToString(), Is.EqualTo("64MB")); + } +} diff --git a/Algowars.Domain.Tests/Problem/ValueObjects/QuestionTests.cs b/Algowars.Domain.Tests/Problem/ValueObjects/QuestionTests.cs new file mode 100644 index 0000000..933ec1a --- /dev/null +++ b/Algowars.Domain.Tests/Problem/ValueObjects/QuestionTests.cs @@ -0,0 +1,87 @@ +using Algowars.Domain.Problems.Exceptions; +using Algowars.Domain.Problems.ValueObjects; + +namespace Algowars.Domain.Tests.Problem.ValueObjects; + +public class QuestionTests +{ + private static string ValidQuestion => new('a', Question.MinLength); + + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string value = new('a', Question.MaxLength); + + Assert.That(() => new Question(value), Throws.Nothing); + } + + [Test] + public void Constructor_AtMinLength_Succeeds() + { + Assert.That(() => new Question(ValidQuestion), Throws.Nothing); + } + + [Test] + public void Constructor_BelowMinLength_ThrowsInvalidQuestionException() + { + string value = new('a', Question.MinLength - 1); + + Assert.Throws(() => new Question(value)); + } + + [Test] + public void Constructor_EmptyString_ThrowsInvalidQuestionException() + { + Assert.Throws(() => new Question(string.Empty)); + } + + [Test] + public void Constructor_ExceedsMaxLength_ThrowsInvalidQuestionException() + { + string value = new('a', Question.MaxLength + 1); + + Assert.Throws(() => new Question(value)); + } + + [Test] + public void Constructor_WhitespaceOnly_ThrowsInvalidQuestionException() + { + Assert.Throws(() => new Question(" ")); + } + + [Test] + public void Equality_DifferentValues_AreNotEqual() + { + var a = new Question(new string('a', Question.MinLength)); + var b = new Question(new string('b', Question.MinLength)); + + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new Question(ValidQuestion); + var b = new Question(ValidQuestion); + + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void ImplicitConversion_ReturnsValue() + { + var question = new Question(ValidQuestion); + + string result = question; + + Assert.That(result, Is.EqualTo(ValidQuestion)); + } + + [Test] + public void ToString_ReturnsValue() + { + var question = new Question(ValidQuestion); + + Assert.That(question.ToString(), Is.EqualTo(ValidQuestion)); + } +} diff --git a/Algowars.Domain.Tests/Problem/ValueObjects/SlugTests.cs b/Algowars.Domain.Tests/Problem/ValueObjects/SlugTests.cs new file mode 100644 index 0000000..ed4d2f2 --- /dev/null +++ b/Algowars.Domain.Tests/Problem/ValueObjects/SlugTests.cs @@ -0,0 +1,139 @@ +using Algowars.Domain.Problems.Exceptions; +using Algowars.Domain.Problems.ValueObjects; + +namespace Algowars.Domain.Tests.Problem.ValueObjects; + +public class SlugTests +{ + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string repeated = string.Concat(Enumerable.Repeat("a", Slug.MaxLength)); + + Assert.That(() => new Slug(repeated), Throws.Nothing); + } + + [Test] + public void Constructor_AtMinLength_Succeeds() + { + string value = new('a', Slug.MinLength); + + Assert.That(() => new Slug(value), Throws.Nothing); + } + + [Test] + public void Constructor_BelowMinLength_ThrowsInvalidSlugException() + { + string value = new('a', Slug.MinLength - 1); + + Assert.Throws(() => new Slug(value)); + } + + [Test] + public void Constructor_EmptyString_ThrowsInvalidSlugException() + { + Assert.Throws(() => new Slug(string.Empty)); + } + + [Test] + public void Constructor_ExceedsMaxLength_ThrowsInvalidSlugException() + { + string value = new('a', Slug.MaxLength + 1); + + Assert.Throws(() => new Slug(value)); + } + + [Test] + [TestCase("Two-Sum")] + [TestCase("TWOSUM")] + [TestCase("-two-sum")] + [TestCase("two-sum-")] + [TestCase("two--sum")] + [TestCase("two sum")] + public void Constructor_InvalidFormat_ThrowsInvalidSlugException(string value) + { + Assert.Throws(() => new Slug(value)); + } + + [Test] + [TestCase("two-sum")] + [TestCase("twosum")] + [TestCase("two-sum-123")] + [TestCase("abc")] + public void Constructor_ValidFormat_Succeeds(string value) + { + Assert.That(() => new Slug(value), Throws.Nothing); + } + + [Test] + public void Constructor_WhitespaceOnly_ThrowsInvalidSlugException() + { + Assert.Throws(() => new Slug(" ")); + } + + [Test] + public void Equality_DifferentValues_AreNotEqual() + { + var a = new Slug("two-sum"); + var b = new Slug("three-sum"); + + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new Slug("two-sum"); + var b = new Slug("two-sum"); + + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void FromTitle_GeneratesValidSlug() + { + var title = new Title("Two Sum"); + + var slug = Slug.FromTitle(title); + + Assert.That(slug.Value, Is.EqualTo("two-sum")); + } + + [Test] + public void FromTitle_StripsSpecialCharacters() + { + var title = new Title("Two Sum!"); + + var slug = Slug.FromTitle(title); + + Assert.That(slug.Value, Is.EqualTo("two-sum")); + } + + [Test] + public void FromTitle_CollapseMultipleSpaces() + { + var title = new Title("Two Sum"); + + var slug = Slug.FromTitle(title); + + Assert.That(slug.Value, Is.EqualTo("two-sum")); + } + + [Test] + public void ImplicitConversion_ReturnsValue() + { + var slug = new Slug("two-sum"); + + string result = slug; + + Assert.That(result, Is.EqualTo("two-sum")); + } + + [Test] + public void ToString_ReturnsValue() + { + var slug = new Slug("two-sum"); + + Assert.That(slug.ToString(), Is.EqualTo("two-sum")); + } +} diff --git a/Algowars.Domain.Tests/Problem/ValueObjects/TimeLimitTests.cs b/Algowars.Domain.Tests/Problem/ValueObjects/TimeLimitTests.cs new file mode 100644 index 0000000..790edef --- /dev/null +++ b/Algowars.Domain.Tests/Problem/ValueObjects/TimeLimitTests.cs @@ -0,0 +1,57 @@ +using Algowars.Domain.Problems.Exceptions; +using Algowars.Domain.Problems.ValueObjects; + +namespace Algowars.Domain.Tests.Problem.ValueObjects; + +public class TimeLimitTests +{ + [Test] + public void Constructor_AtMaxMilliseconds_Succeeds() + { + Assert.That(() => new TimeLimit(TimeLimit.MaxMilliseconds), Throws.Nothing); + } + + [Test] + public void Constructor_AtMinMilliseconds_Succeeds() + { + Assert.That(() => new TimeLimit(TimeLimit.MinMilliseconds), Throws.Nothing); + } + + [Test] + public void Constructor_AboveMaxMilliseconds_ThrowsInvalidTimeLimitException() + { + Assert.Throws(() => new TimeLimit(TimeLimit.MaxMilliseconds + 1)); + } + + [Test] + public void Constructor_BelowMinMilliseconds_ThrowsInvalidTimeLimitException() + { + Assert.Throws(() => new TimeLimit(TimeLimit.MinMilliseconds - 1)); + } + + [Test] + public void Equality_DifferentValues_AreNotEqual() + { + var a = new TimeLimit(1000); + var b = new TimeLimit(2000); + + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new TimeLimit(1000); + var b = new TimeLimit(1000); + + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void ToString_IncludesMilliseconds() + { + var timeLimit = new TimeLimit(1000); + + Assert.That(timeLimit.ToString(), Is.EqualTo("1000ms")); + } +} diff --git a/Algowars.Domain.Tests/Problem/ValueObjects/TitleTests.cs b/Algowars.Domain.Tests/Problem/ValueObjects/TitleTests.cs new file mode 100644 index 0000000..294b238 --- /dev/null +++ b/Algowars.Domain.Tests/Problem/ValueObjects/TitleTests.cs @@ -0,0 +1,87 @@ +using Algowars.Domain.Problems.Exceptions; +using Algowars.Domain.Problems.ValueObjects; + +namespace Algowars.Domain.Tests.Problem.ValueObjects; + +public class TitleTests +{ + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string value = new('a', Title.MaxLength); + + Assert.That(() => new Title(value), Throws.Nothing); + } + + [Test] + public void Constructor_AtMinLength_Succeeds() + { + string value = new('a', Title.MinLength); + + Assert.That(() => new Title(value), Throws.Nothing); + } + + [Test] + public void Constructor_EmptyString_ThrowsInvalidTitleException() + { + Assert.Throws(() => new Title(string.Empty)); + } + + [Test] + public void Constructor_ExceedsMaxLength_ThrowsInvalidTitleException() + { + string value = new('a', Title.MaxLength + 1); + + Assert.Throws(() => new Title(value)); + } + + [Test] + public void Constructor_BelowMinLength_ThrowsInvalidTitleException() + { + string value = new('a', Title.MinLength - 1); + + Assert.Throws(() => new Title(value)); + } + + [Test] + public void Constructor_WhitespaceOnly_ThrowsInvalidTitleException() + { + Assert.Throws(() => new Title(" ")); + } + + [Test] + public void Equality_DifferentValues_AreNotEqual() + { + var a = new Title("Two Sum"); + var b = new Title("Three Sum"); + + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new Title("Two Sum"); + var b = new Title("Two Sum"); + + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void ImplicitConversion_ReturnsValue() + { + var title = new Title("Two Sum"); + + string result = title; + + Assert.That(result, Is.EqualTo("Two Sum")); + } + + [Test] + public void ToString_ReturnsValue() + { + var title = new Title("Two Sum"); + + Assert.That(title.ToString(), Is.EqualTo("Two Sum")); + } +} diff --git a/Algowars.Domain.Tests/Submissions/Entities/SubmissionResultTests.cs b/Algowars.Domain.Tests/Submissions/Entities/SubmissionResultTests.cs new file mode 100644 index 0000000..d9a36c5 --- /dev/null +++ b/Algowars.Domain.Tests/Submissions/Entities/SubmissionResultTests.cs @@ -0,0 +1,131 @@ +using Algowars.Domain.Submissions.Entities; +using Algowars.Domain.Submissions.Enums; +using Algowars.Domain.Submissions.ValueObjects; + +namespace Algowars.Domain.Tests.Submissions.Entities; + +public class SubmissionResultTests +{ + private static readonly SourceCode ValidSourceCode = new("int main() {}"); + + private static Submission CreateSubmission(params Guid[] testCaseIds) => + new(Guid.NewGuid(), Guid.NewGuid(), SubmissionType.Submit, ValidSourceCode, testCaseIds); + + [Test] + public void InitialStatus_IsPending() + { + var testCaseId = Guid.NewGuid(); + var submission = CreateSubmission(testCaseId); + + var result = submission.Results.First(); + + Assert.That(result.Status, Is.EqualTo(SubmissionResultStatus.Pending)); + } + + [Test] + public void IsTerminal_WhenPending_IsFalse() + { + var testCaseId = Guid.NewGuid(); + var submission = CreateSubmission(testCaseId); + + Assert.That(submission.Results.First().IsTerminal, Is.False); + } + + [Test] + public void IsTerminal_WhenProcessing_IsFalse() + { + var testCaseId = Guid.NewGuid(); + var submission = CreateSubmission(testCaseId); + submission.UpdateResult(testCaseId, SubmissionResultStatus.Processing); + + Assert.That(submission.Results.First().IsTerminal, Is.False); + } + + [TestCase(SubmissionResultStatus.Accepted)] + [TestCase(SubmissionResultStatus.WrongAnswer)] + [TestCase(SubmissionResultStatus.TimeLimitExceeded)] + [TestCase(SubmissionResultStatus.MemoryLimitExceeded)] + [TestCase(SubmissionResultStatus.RuntimeError)] + [TestCase(SubmissionResultStatus.CompileError)] + public void IsTerminal_WhenTerminalStatus_IsTrue(SubmissionResultStatus status) + { + var testCaseId = Guid.NewGuid(); + var submission = CreateSubmission(testCaseId); + submission.UpdateResult(testCaseId, status); + + Assert.That(submission.Results.First().IsTerminal, Is.True); + } + + [Test] + public void Update_SetsAllOutputFields() + { + var testCaseId = Guid.NewGuid(); + var submission = CreateSubmission(testCaseId); + + submission.UpdateResult(testCaseId, SubmissionResultStatus.WrongAnswer, + runtime: 150, + memoryUsed: 64, + actualOutput: "wrong", + standardOutput: "stdout", + standardError: "stderr", + compileOutput: "compile"); + + var result = submission.Results.First(); + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Status, Is.EqualTo(SubmissionResultStatus.WrongAnswer)); + Assert.That(result.Runtime, Is.EqualTo(150)); + Assert.That(result.MemoryUsed, Is.EqualTo(64)); + Assert.That(result.ActualOutput, Is.EqualTo("wrong")); + Assert.That(result.StandardOutput, Is.EqualTo("stdout")); + Assert.That(result.StandardError, Is.EqualTo("stderr")); + Assert.That(result.CompileOutput, Is.EqualTo("compile")); + } + } + + [Test] + public void Update_NullableFields_DefaultToNull() + { + var testCaseId = Guid.NewGuid(); + var submission = CreateSubmission(testCaseId); + + submission.UpdateResult(testCaseId, SubmissionResultStatus.Accepted); + + var result = submission.Results.First(); + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Runtime, Is.Null); + Assert.That(result.MemoryUsed, Is.Null); + Assert.That(result.ActualOutput, Is.Null); + Assert.That(result.StandardOutput, Is.Null); + Assert.That(result.StandardError, Is.Null); + Assert.That(result.CompileOutput, Is.Null); + } + } + + [Test] + public void Update_CalledTwice_OverwritesPreviousValues() + { + var testCaseId = Guid.NewGuid(); + var submission = CreateSubmission(testCaseId); + submission.UpdateResult(testCaseId, SubmissionResultStatus.Processing, runtime: 100); + + submission.UpdateResult(testCaseId, SubmissionResultStatus.Accepted, runtime: 200); + + var result = submission.Results.First(); + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Status, Is.EqualTo(SubmissionResultStatus.Accepted)); + Assert.That(result.Runtime, Is.EqualTo(200)); + } + } + + [Test] + public void TestCaseId_SetCorrectly() + { + var testCaseId = Guid.NewGuid(); + var submission = CreateSubmission(testCaseId); + + Assert.That(submission.Results.First().TestCaseId, Is.EqualTo(testCaseId)); + } +} diff --git a/Algowars.Domain.Tests/Submissions/Entities/SubmissionTests.cs b/Algowars.Domain.Tests/Submissions/Entities/SubmissionTests.cs new file mode 100644 index 0000000..0e0b5e3 --- /dev/null +++ b/Algowars.Domain.Tests/Submissions/Entities/SubmissionTests.cs @@ -0,0 +1,185 @@ +using Algowars.Domain.Submissions.Entities; +using Algowars.Domain.Submissions.Enums; +using Algowars.Domain.Submissions.Exceptions; +using Algowars.Domain.Submissions.ValueObjects; + +namespace Algowars.Domain.Tests.Submissions.Entities; + +public class SubmissionTests +{ + private static readonly Guid UserId = Guid.NewGuid(); + private static readonly Guid ProblemSetupId = Guid.NewGuid(); + private static readonly SourceCode ValidSourceCode = new("int main() {}"); + private static readonly Guid TestCaseId1 = Guid.NewGuid(); + private static readonly Guid TestCaseId2 = Guid.NewGuid(); + + private static Submission CreateSubmission( + SubmissionType type = SubmissionType.Submit, + IEnumerable? testCaseIds = null) => + new(UserId, ProblemSetupId, type, ValidSourceCode, + testCaseIds ?? [TestCaseId1, TestCaseId2]); + + [Test] + public void Complete_AllAccepted_SetsStatusToAccepted() + { + var submission = CreateSubmission(); + submission.UpdateResult(TestCaseId1, SubmissionResultStatus.Accepted); + submission.UpdateResult(TestCaseId2, SubmissionResultStatus.Accepted); + + submission.Complete(); + + Assert.That(submission.Status, Is.EqualTo(SubmissionStatus.Accepted)); + } + + [Test] + public void Complete_AnyNonAccepted_SetsStatusToWrongAnswer() + { + var submission = CreateSubmission(); + submission.UpdateResult(TestCaseId1, SubmissionResultStatus.Accepted); + submission.UpdateResult(TestCaseId2, SubmissionResultStatus.WrongAnswer); + + submission.Complete(); + + Assert.That(submission.Status, Is.EqualTo(SubmissionStatus.WrongAnswer)); + } + + [TestCase(SubmissionResultStatus.TimeLimitExceeded)] + [TestCase(SubmissionResultStatus.MemoryLimitExceeded)] + [TestCase(SubmissionResultStatus.RuntimeError)] + [TestCase(SubmissionResultStatus.CompileError)] + public void Complete_AnyFailureStatus_SetsStatusToWrongAnswer(SubmissionResultStatus failureStatus) + { + var submission = CreateSubmission(); + submission.UpdateResult(TestCaseId1, SubmissionResultStatus.Accepted); + submission.UpdateResult(TestCaseId2, failureStatus); + + submission.Complete(); + + Assert.That(submission.Status, Is.EqualTo(SubmissionStatus.WrongAnswer)); + } + + [Test] + public void Complete_WithPendingResult_ThrowsSubmissionNotCompleteException() + { + var submission = CreateSubmission(); + submission.UpdateResult(TestCaseId1, SubmissionResultStatus.Accepted); + + Assert.Throws(() => submission.Complete()); + } + + [Test] + public void Complete_WithProcessingResult_ThrowsSubmissionNotCompleteException() + { + var submission = CreateSubmission(); + submission.UpdateResult(TestCaseId1, SubmissionResultStatus.Accepted); + submission.UpdateResult(TestCaseId2, SubmissionResultStatus.Processing); + + Assert.Throws(() => submission.Complete()); + } + + [Test] + public void Constructor_SetsInitialProperties() + { + var submission = CreateSubmission(SubmissionType.Run); + + using (Assert.EnterMultipleScope()) + { + Assert.That(submission.UserId, Is.EqualTo(UserId)); + Assert.That(submission.ProblemSetupId, Is.EqualTo(ProblemSetupId)); + Assert.That(submission.Type, Is.EqualTo(SubmissionType.Run)); + Assert.That(submission.SourceCode, Is.EqualTo(ValidSourceCode)); + Assert.That(submission.Status, Is.EqualTo(SubmissionStatus.Queued)); + } + } + + [Test] + public void Constructor_CreatesResultPerTestCaseId() + { + var submission = CreateSubmission(); + + Assert.That(submission.Results, Has.Count.EqualTo(2)); + } + + [Test] + public void Constructor_AllResultsInitiallyPending() + { + var submission = CreateSubmission(); + + Assert.That(submission.Results.All(r => r.Status == SubmissionResultStatus.Pending), Is.True); + } + + [Test] + public void Constructor_WithNoTestCases_ResultsIsEmpty() + { + var submission = CreateSubmission(testCaseIds: []); + + Assert.That(submission.Results, Is.Empty); + } + + [Test] + public void StartRunning_FromQueued_SetsStatusToRunning() + { + var submission = CreateSubmission(); + + submission.StartRunning(); + + Assert.That(submission.Status, Is.EqualTo(SubmissionStatus.Running)); + } + + [Test] + public void StartRunning_WhenAlreadyRunning_ThrowsInvalidSubmissionStateException() + { + var submission = CreateSubmission(); + submission.StartRunning(); + + Assert.Throws(() => submission.StartRunning()); + } + + [Test] + public void UpdateResult_UnknownTestCaseId_ThrowsSubmissionResultNotFoundException() + { + var submission = CreateSubmission(); + + Assert.Throws(() => + submission.UpdateResult(Guid.NewGuid(), SubmissionResultStatus.Accepted)); + } + + [Test] + public void UpdateResult_UpdatesMatchingResult() + { + var submission = CreateSubmission(); + + submission.UpdateResult(TestCaseId1, SubmissionResultStatus.Accepted, runtime: 100, memoryUsed: 32); + + var result = submission.Results.First(r => r.TestCaseId == TestCaseId1); + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Status, Is.EqualTo(SubmissionResultStatus.Accepted)); + Assert.That(result.Runtime, Is.EqualTo(100)); + Assert.That(result.MemoryUsed, Is.EqualTo(32)); + } + } + + [Test] + public void UpdateResult_Overwrite_UpdatesExistingResult() + { + var submission = CreateSubmission(); + submission.UpdateResult(TestCaseId1, SubmissionResultStatus.Processing); + + submission.UpdateResult(TestCaseId1, SubmissionResultStatus.Accepted, runtime: 50); + + var result = submission.Results.First(r => r.TestCaseId == TestCaseId1); + Assert.That(result.Status, Is.EqualTo(SubmissionResultStatus.Accepted)); + } + + [Test] + public void UpdateResult_DoesNotAffectOtherResults() + { + var submission = CreateSubmission(); + + submission.UpdateResult(TestCaseId1, SubmissionResultStatus.Accepted); + + var other = submission.Results.First(r => r.TestCaseId == TestCaseId2); + Assert.That(other.Status, Is.EqualTo(SubmissionResultStatus.Pending)); + } +} diff --git a/Algowars.Domain.Tests/Submissions/ValueObjects/SourceCodeTests.cs b/Algowars.Domain.Tests/Submissions/ValueObjects/SourceCodeTests.cs new file mode 100644 index 0000000..295f339 --- /dev/null +++ b/Algowars.Domain.Tests/Submissions/ValueObjects/SourceCodeTests.cs @@ -0,0 +1,71 @@ +using Algowars.Domain.Submissions.Exceptions; +using Algowars.Domain.Submissions.ValueObjects; + +namespace Algowars.Domain.Tests.Submissions.ValueObjects; + +public class SourceCodeTests +{ + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string value = new('a', SourceCode.MaxLength); + + Assert.That(() => new SourceCode(value), Throws.Nothing); + } + + [Test] + public void Constructor_EmptyString_ThrowsInvalidSourceCodeException() + { + Assert.Throws(() => new SourceCode(string.Empty)); + } + + [Test] + public void Constructor_ExceedsMaxLength_ThrowsInvalidSourceCodeException() + { + string value = new('a', SourceCode.MaxLength + 1); + + Assert.Throws(() => new SourceCode(value)); + } + + [Test] + public void Constructor_WhitespaceOnly_ThrowsInvalidSourceCodeException() + { + Assert.Throws(() => new SourceCode(" ")); + } + + [Test] + public void Equality_DifferentValues_AreNotEqual() + { + var a = new SourceCode("int main() {}"); + var b = new SourceCode("def solve(): pass"); + + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new SourceCode("int main() {}"); + var b = new SourceCode("int main() {}"); + + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void ImplicitConversion_ReturnsValue() + { + var code = new SourceCode("int main() {}"); + + string result = code; + + Assert.That(result, Is.EqualTo("int main() {}")); + } + + [Test] + public void ToString_ReturnsValue() + { + var code = new SourceCode("int main() {}"); + + Assert.That(code.ToString(), Is.EqualTo("int main() {}")); + } +} diff --git a/Algowars.Domain.Tests/User/Entities/UserTests.cs b/Algowars.Domain.Tests/User/Entities/UserTests.cs new file mode 100644 index 0000000..4fe4dbb --- /dev/null +++ b/Algowars.Domain.Tests/User/Entities/UserTests.cs @@ -0,0 +1,241 @@ +using Algowars.Domain.Users.Exceptions; +using Algowars.Domain.Users.ValueObjects; +using UserEntity = Algowars.Domain.Users.Entities.User; + +namespace Algowars.Domain.Tests.User.Entities; + +public class UserTests +{ + private static readonly Username ValidUsername = new("alice"); + private const string ValidSub = "auth0|abc123"; + + [Test] + public void ChangeUsername_DoesNotAffectOtherProperties() + { + var user = new UserEntity(ValidUsername, ValidSub); + var originalId = user.Id; + string originalSub = user.Sub; + + user.ChangeUsername(new Username("bob")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(user.Id, Is.EqualTo(originalId)); + Assert.That(user.Sub, Is.EqualTo(originalSub)); + } + } + + [Test] + public void ChangeUsername_FirstChange_Succeeds() + { + var user = new UserEntity(ValidUsername, ValidSub); + var newUsername = new Username("bob"); + + user.ChangeUsername(newUsername); + + Assert.That(user.Username, Is.EqualTo(newUsername)); + } + + [Test] + public void ChangeUsername_SetsUsernameLastChangedAt() + { + var user = new UserEntity(ValidUsername, ValidSub); + var before = DateTime.UtcNow; + + user.ChangeUsername(new Username("bob")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(user.UsernameLastChangedAt, Is.Not.Null); + Assert.That(user.UsernameLastChangedAt, Is.GreaterThanOrEqualTo(before)); + } + } + + [Test] + public void ChangeUsername_ValidUsername_UpdatesUsername() + { + var user = new UserEntity(ValidUsername, ValidSub); + var newUsername = new Username("bob"); + + user.ChangeUsername(newUsername); + + Assert.That(user.Username, Is.EqualTo(newUsername)); + } + + [Test] + public void ChangeUsername_WithinCooldown_ThrowsUsernameCooldownException() + { + var user = new UserEntity(ValidUsername, ValidSub); + user.ChangeUsername(new Username("bob")); + + Assert.Throws(() => user.ChangeUsername(new Username("charlie"))); + } + + [Test] + public void Constructor_BioIsNullByDefault() + { + var user = new UserEntity(ValidUsername, ValidSub); + Assert.That(user.Bio, Is.Null); + } + + [Test] + public void Constructor_EmptyOrWhitespaceSub_ThrowsInvalidUserSubException([Values("", " ", " ")] string sub) + { + Assert.Throws(() => new UserEntity(ValidUsername, sub)); + } + + [Test] + public void Constructor_GeneratesNonEmptyId() + { + var user = new UserEntity(ValidUsername, ValidSub); + Assert.That(user.Id, Is.Not.EqualTo(Guid.Empty)); + } + + [Test] + public void Constructor_GeneratesUniqueIds() + { + var user1 = new UserEntity(ValidUsername, ValidSub); + var user2 = new UserEntity(ValidUsername, ValidSub); + + Assert.That(user1.Id, Is.Not.EqualTo(user2.Id)); + } + + [Test] + public void Constructor_ImageUrlIsNullByDefault() + { + var user = new UserEntity(ValidUsername, ValidSub); + Assert.That(user.ImageUrl, Is.Null); + } + + [Test] + public void Constructor_NullUsername_ThrowsInvalidUsernameException() + { + Assert.Throws(() => new UserEntity(null!, ValidSub)); + } + + [Test] + public void Constructor_SetsSubCorrectly() + { + var user = new UserEntity(ValidUsername, ValidSub); + Assert.That(user.Sub, Is.EqualTo(ValidSub)); + } + + [Test] + public void Constructor_SubWithSpecialCharacters_Succeeds() + { + var user = new UserEntity(ValidUsername, "google-oauth2|abc.123-xyz"); + Assert.That(user.Sub, Is.EqualTo("google-oauth2|abc.123-xyz")); + } + + [Test] + public void Constructor_ValidArguments_CreatesUser() + { + var user = new UserEntity(ValidUsername, ValidSub); + + using (Assert.EnterMultipleScope()) + { + Assert.That(user.Id, Is.Not.EqualTo(Guid.Empty)); + Assert.That(user.Sub, Is.EqualTo(ValidSub)); + Assert.That(user.Username, Is.EqualTo(ValidUsername)); + } + } + + [Test] + public void Equals_DifferentInstances_AreNotEqual() + { + var user1 = new UserEntity(ValidUsername, ValidSub); + var user2 = new UserEntity(ValidUsername, ValidSub); + + Assert.That(user1, Is.Not.EqualTo(user2)); + } + + [Test] + public void Equals_Null_IsNotEqual() + { + var user = new UserEntity(ValidUsername, ValidSub); + Assert.That(user.Equals(null), Is.False); + } + + [Test] + public void Equals_SameInstance_IsEqual() + { + var user = new UserEntity(ValidUsername, ValidSub); + Assert.That(user, Is.EqualTo(user)); + } + + [Test] + public void GetHashCode_DifferentUsers_ReturnDifferentHashes() + { + var user1 = new UserEntity(ValidUsername, ValidSub); + var user2 = new UserEntity(ValidUsername, ValidSub); + + Assert.That(user1.GetHashCode(), Is.Not.EqualTo(user2.GetHashCode())); + } + + [Test] + public void GetHashCode_SameUser_ReturnsSameHash() + { + var user = new UserEntity(ValidUsername, ValidSub); + Assert.That(user.GetHashCode(), Is.EqualTo(user.GetHashCode())); + } + + [Test] + public void UpdateBio_DoesNotAffectOtherProperties() + { + var user = new UserEntity(ValidUsername, ValidSub); + var originalId = user.Id; + string originalSub = user.Sub; + + user.UpdateBio(new Bio("Some bio.")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(user.Id, Is.EqualTo(originalId)); + Assert.That(user.Sub, Is.EqualTo(originalSub)); + } + } + + [Test] + public void UpdateBio_Null_ClearsBio() + { + var user = new UserEntity(ValidUsername, ValidSub); + user.UpdateBio(new Bio("Some bio.")); + + user.UpdateBio(null); + + Assert.That(user.Bio, Is.Null); + } + + [Test] + public void UpdateBio_ValidBio_SetsBio() + { + var user = new UserEntity(ValidUsername, ValidSub); + var bio = new Bio("I love competitive programming."); + + user.UpdateBio(bio); + + Assert.That(user.Bio, Is.EqualTo(bio)); + } + + [Test] + public void UpdateImageUrl_Null_ClearsImageUrl() + { + var user = new UserEntity(ValidUsername, ValidSub); + user.UpdateImageUrl(new ImageUrl("https://example.com/avatar.png")); + + user.UpdateImageUrl(null); + + Assert.That(user.ImageUrl, Is.Null); + } + + [Test] + public void UpdateImageUrl_ValidUrl_SetsImageUrl() + { + var user = new UserEntity(ValidUsername, ValidSub); + var imageUrl = new ImageUrl("https://example.com/avatar.png"); + + user.UpdateImageUrl(imageUrl); + + Assert.That(user.ImageUrl, Is.EqualTo(imageUrl)); + } +} diff --git a/Algowars.Domain.Tests/User/ValueObjects/BioTests.cs b/Algowars.Domain.Tests/User/ValueObjects/BioTests.cs new file mode 100644 index 0000000..b611b6f --- /dev/null +++ b/Algowars.Domain.Tests/User/ValueObjects/BioTests.cs @@ -0,0 +1,79 @@ +using Algowars.Domain.Users.Exceptions; +using Algowars.Domain.Users.ValueObjects; + +namespace Algowars.Domain.Tests.User.ValueObjects; + +public class BioTests +{ + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string atMax = new('a', Bio.MaxLength); + Assert.DoesNotThrow(() => new Bio(atMax)); + } + + [Test] + public void Constructor_EmptyOrWhitespace_ThrowsInvalidBioException([Values("", " ", " ", null)] string? value) + { + Assert.Throws(() => new Bio(value!)); + } + + [Test] + public void Constructor_FarExceedsMaxLength_ThrowsInvalidBioException() + { + string veryLong = new('a', 10000); + Assert.Throws(() => new Bio(veryLong)); + } + + [Test] + public void Constructor_OneAboveMaxLength_ThrowsInvalidBioException() + { + string tooLong = new('a', Bio.MaxLength + 1); + Assert.Throws(() => new Bio(tooLong)); + } + + [Test] + public void Constructor_ValidValue_SetsValue() + { + var bio = new Bio("I love competitive programming."); + Assert.That(bio.Value, Is.EqualTo("I love competitive programming.")); + } + + [Test] + public void Equality_DifferentValue_AreNotEqual() + { + var a = new Bio("Hello world."); + var b = new Bio("Goodbye world."); + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new Bio("Hello world."); + var b = new Bio("Hello world."); + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void ImplicitOperator_ReturnsStringValue() + { + var bio = new Bio("Hello world."); + string value = bio; + Assert.That(value, Is.EqualTo("Hello world.")); + } + + [Test] + public void ToString_MatchesImplicitOperator() + { + var bio = new Bio("Hello world."); + Assert.That(bio.ToString(), Is.EqualTo((string)bio)); + } + + [Test] + public void ToString_ReturnsValue() + { + var bio = new Bio("Hello world."); + Assert.That(bio.ToString(), Is.EqualTo("Hello world.")); + } +} diff --git a/Algowars.Domain.Tests/User/ValueObjects/ImageUrlTests.cs b/Algowars.Domain.Tests/User/ValueObjects/ImageUrlTests.cs new file mode 100644 index 0000000..be6bc49 --- /dev/null +++ b/Algowars.Domain.Tests/User/ValueObjects/ImageUrlTests.cs @@ -0,0 +1,84 @@ +using Algowars.Domain.Users.Exceptions; +using Algowars.Domain.Users.ValueObjects; + +namespace Algowars.Domain.Tests.User.ValueObjects; + +public class ImageUrlTests +{ + private const string ValidHttpUrl = "https://example.com/avatar.png"; + + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string path = new('a', ImageUrl.MaxLength - "https://x.co/".Length); + string atMax = $"https://x.co/{path}"; + Assert.DoesNotThrow(() => new ImageUrl(atMax)); + } + + [Test] + public void Constructor_EmptyOrWhitespace_ThrowsInvalidImageUrlException([Values("", " ", " ", null)] string? value) + { + Assert.Throws(() => new ImageUrl(value!)); + } + + [Test] + public void Constructor_ExceedsMaxLength_ThrowsInvalidImageUrlException() + { + string path = new('a', ImageUrl.MaxLength); + string tooLong = $"https://x.co/{path}"; + Assert.Throws(() => new ImageUrl(tooLong)); + } + + [Test] + public void Constructor_InvalidUrl_ThrowsInvalidImageUrlException( + [Values("not-a-url", "ftp://example.com/file.png", "example.com/avatar.png", "//example.com/avatar.png")] string value) + { + Assert.Throws(() => new ImageUrl(value)); + } + + [Test] + public void Constructor_ValidUrl_SetsValue( + [Values("https://example.com/avatar.png", "http://example.com/avatar.jpg", "https://avatars.githubusercontent.com/u/12345")] string value) + { + var imageUrl = new ImageUrl(value); + Assert.That(imageUrl.Value, Is.EqualTo(value)); + } + + [Test] + public void Equality_DifferentValue_AreNotEqual() + { + var a = new ImageUrl(ValidHttpUrl); + var b = new ImageUrl("https://example.com/other.png"); + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new ImageUrl(ValidHttpUrl); + var b = new ImageUrl(ValidHttpUrl); + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void ImplicitOperator_ReturnsStringValue() + { + var imageUrl = new ImageUrl(ValidHttpUrl); + string value = imageUrl; + Assert.That(value, Is.EqualTo(ValidHttpUrl)); + } + + [Test] + public void ToString_MatchesImplicitOperator() + { + var imageUrl = new ImageUrl(ValidHttpUrl); + Assert.That(imageUrl.ToString(), Is.EqualTo((string)imageUrl)); + } + + [Test] + public void ToString_ReturnsValue() + { + var imageUrl = new ImageUrl(ValidHttpUrl); + Assert.That(imageUrl.ToString(), Is.EqualTo(ValidHttpUrl)); + } +} diff --git a/Algowars.Domain.Tests/User/ValueObjects/UsernameTests.cs b/Algowars.Domain.Tests/User/ValueObjects/UsernameTests.cs new file mode 100644 index 0000000..125572f --- /dev/null +++ b/Algowars.Domain.Tests/User/ValueObjects/UsernameTests.cs @@ -0,0 +1,114 @@ +using Algowars.Domain.Users.Exceptions; +using Algowars.Domain.Users.ValueObjects; + +namespace Algowars.Domain.Tests.User.ValueObjects; + +public class UsernameTests +{ + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string atMax = new('a', Username.MaxLength); + Assert.DoesNotThrow(() => new Username(atMax)); + } + + [Test] + public void Constructor_AtMinLength_Succeeds() + { + string atMin = new('a', Username.MinLength); + Assert.DoesNotThrow(() => new Username(atMin)); + } + + [Test] + public void Constructor_EmptyOrWhitespace_ThrowsInvalidUsernameException([Values("", " ", " ", null)] string? value) + { + Assert.Throws(() => new Username(value!)); + } + + [Test] + public void Constructor_FarExceedsMaxLength_ThrowsInvalidUsernameException() + { + string veryLong = new('a', 1000); + Assert.Throws(() => new Username(veryLong)); + } + + [Test] + public void Constructor_OneAboveMaxLength_ThrowsInvalidUsernameException() + { + string tooLong = new('a', Username.MaxLength + 1); + Assert.Throws(() => new Username(tooLong)); + } + + [Test] + public void Constructor_OneBelowMinLength_ThrowsInvalidUsernameException() + { + string tooShort = new('a', Username.MinLength - 1); + Assert.Throws(() => new Username(tooShort)); + } + + [Test] + public void Constructor_ValidCharacterVariations_Succeeds([Values("alice123", "ALICE", "Alice", "123", "a")] string value) + { + Assert.DoesNotThrow(() => new Username(value)); + } + + [Test] + public void Constructor_ValidValue_SetsValue() + { + var username = new Username("alice"); + Assert.That(username.Value, Is.EqualTo("alice")); + } + + [Test] + public void Equality_DifferentCasing_AreNotEqual() + { + var a = new Username("alice"); + var b = new Username("Alice"); + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_DifferentValue_AreNotEqual() + { + var a = new Username("alice"); + var b = new Username("bob"); + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameReference_AreEqual() + { + var a = new Username("alice"); + Assert.That(a, Is.EqualTo(a)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new Username("alice"); + var b = new Username("alice"); + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void ImplicitOperator_ReturnsStringValue() + { + var username = new Username("alice"); + string value = username; + Assert.That(value, Is.EqualTo("alice")); + } + + [Test] + public void ToString_MatchesImplicitOperator() + { + var username = new Username("alice"); + Assert.That(username.ToString(), Is.EqualTo((string)username)); + } + + [Test] + public void ToString_ReturnsValue() + { + var username = new Username("alice"); + Assert.That(username.ToString(), Is.EqualTo("alice")); + } +} diff --git a/Algowars.Domain/Algowars.Domain.csproj b/Algowars.Domain/Algowars.Domain.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/Algowars.Domain/Algowars.Domain.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/Algowars.Domain/Languages/Entities/Language.cs b/Algowars.Domain/Languages/Entities/Language.cs new file mode 100644 index 0000000..ef56c5a --- /dev/null +++ b/Algowars.Domain/Languages/Entities/Language.cs @@ -0,0 +1,51 @@ +using Algowars.Domain.Languages.Enums; +using Algowars.Domain.Languages.Exceptions; +using Algowars.Domain.Languages.ValueObjects; +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Languages.Entities; + +public sealed class Language : AggregateRoot +{ + public Language(LanguageName name, LanguageSlug slug) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Slug = slug ?? throw new ArgumentNullException(nameof(slug)); + Status = LanguageStatus.Active; + } + + public void Activate() + { + Status = LanguageStatus.Active; + } + + public LanguageVersionEntry AddVersion(LanguageVersion version, Judge0Id judge0Id) + { + var entry = new LanguageVersionEntry(version, judge0Id); + _versions.Add(entry); + return entry; + } + + public void Deactivate() + { + Status = LanguageStatus.Inactive; + } + + public void DeprecateVersion(Guid versionId) + { + var version = _versions.FirstOrDefault(v => v.Id == versionId) + ?? throw new LanguageVersionNotFoundException(versionId); + + version.Deprecate(); + } + + private Language() { } + + public bool IsActive => Status == LanguageStatus.Active; + public LanguageName Name { get; private set; } = null!; + public LanguageSlug Slug { get; private set; } = null!; + public LanguageStatus Status { get; private set; } + public IReadOnlyCollection Versions => _versions.AsReadOnly(); + + private readonly List _versions = []; +} diff --git a/Algowars.Domain/Languages/Entities/LanguageVersionEntry.cs b/Algowars.Domain/Languages/Entities/LanguageVersionEntry.cs new file mode 100644 index 0000000..61f8ccc --- /dev/null +++ b/Algowars.Domain/Languages/Entities/LanguageVersionEntry.cs @@ -0,0 +1,27 @@ +using Algowars.Domain.Languages.Enums; +using Algowars.Domain.Languages.ValueObjects; +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Languages.Entities; + +public sealed class LanguageVersionEntry : Entity +{ + internal LanguageVersionEntry(LanguageVersion version, Judge0Id judge0Id) + { + Version = version ?? throw new ArgumentNullException(nameof(version)); + Judge0Id = judge0Id ?? throw new ArgumentNullException(nameof(judge0Id)); + Status = LanguageVersionStatus.Active; + } + + public void Deprecate() + { + Status = LanguageVersionStatus.Deprecated; + } + + private LanguageVersionEntry() { } + + public bool IsActive => Status == LanguageVersionStatus.Active; + public Judge0Id Judge0Id { get; private set; } = null!; + public LanguageVersionStatus Status { get; private set; } + public LanguageVersion Version { get; private set; } = null!; +} diff --git a/Algowars.Domain/Languages/Enums/LanguageStatus.cs b/Algowars.Domain/Languages/Enums/LanguageStatus.cs new file mode 100644 index 0000000..340e1ff --- /dev/null +++ b/Algowars.Domain/Languages/Enums/LanguageStatus.cs @@ -0,0 +1,7 @@ +namespace Algowars.Domain.Languages.Enums; + +public enum LanguageStatus +{ + Active, + Inactive +} diff --git a/Algowars.Domain/Languages/Enums/LanguageVersionStatus.cs b/Algowars.Domain/Languages/Enums/LanguageVersionStatus.cs new file mode 100644 index 0000000..a26869f --- /dev/null +++ b/Algowars.Domain/Languages/Enums/LanguageVersionStatus.cs @@ -0,0 +1,7 @@ +namespace Algowars.Domain.Languages.Enums; + +public enum LanguageVersionStatus +{ + Active, + Deprecated +} diff --git a/Algowars.Domain/Languages/Exceptions/InvalidJudge0IdException.cs b/Algowars.Domain/Languages/Exceptions/InvalidJudge0IdException.cs new file mode 100644 index 0000000..14912c5 --- /dev/null +++ b/Algowars.Domain/Languages/Exceptions/InvalidJudge0IdException.cs @@ -0,0 +1,7 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Languages.Exceptions; + +public sealed class InvalidJudge0IdException(string message) : DomainException(message) +{ +} diff --git a/Algowars.Domain/Languages/Exceptions/InvalidLanguageNameException.cs b/Algowars.Domain/Languages/Exceptions/InvalidLanguageNameException.cs new file mode 100644 index 0000000..82e370f --- /dev/null +++ b/Algowars.Domain/Languages/Exceptions/InvalidLanguageNameException.cs @@ -0,0 +1,9 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Languages.Exceptions; + +public sealed class InvalidLanguageNameException : DomainException +{ + public InvalidLanguageNameException(string reason) + : base($"Language name is invalid: {reason}") { } +} diff --git a/Algowars.Domain/Languages/Exceptions/InvalidLanguageSlugException.cs b/Algowars.Domain/Languages/Exceptions/InvalidLanguageSlugException.cs new file mode 100644 index 0000000..0d45114 --- /dev/null +++ b/Algowars.Domain/Languages/Exceptions/InvalidLanguageSlugException.cs @@ -0,0 +1,7 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Languages.Exceptions; + +public sealed class InvalidLanguageSlugException(string reason) : DomainException($"Language slug is invalid: {reason}") +{ +} diff --git a/Algowars.Domain/Languages/Exceptions/InvalidLanguageVersionException.cs b/Algowars.Domain/Languages/Exceptions/InvalidLanguageVersionException.cs new file mode 100644 index 0000000..373bd02 --- /dev/null +++ b/Algowars.Domain/Languages/Exceptions/InvalidLanguageVersionException.cs @@ -0,0 +1,7 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Languages.Exceptions; + +public sealed class InvalidLanguageVersionException(string reason) : DomainException($"Language version is invalid: {reason}") +{ +} diff --git a/Algowars.Domain/Languages/Exceptions/LanguageVersionNotFoundException.cs b/Algowars.Domain/Languages/Exceptions/LanguageVersionNotFoundException.cs new file mode 100644 index 0000000..4176e89 --- /dev/null +++ b/Algowars.Domain/Languages/Exceptions/LanguageVersionNotFoundException.cs @@ -0,0 +1,7 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Languages.Exceptions; + +public sealed class LanguageVersionNotFoundException(Guid versionId) : DomainException($"Language version with ID '{versionId}' was not found.") +{ +} diff --git a/Algowars.Domain/Languages/ILanguageRepository.cs b/Algowars.Domain/Languages/ILanguageRepository.cs new file mode 100644 index 0000000..4e6dfd6 --- /dev/null +++ b/Algowars.Domain/Languages/ILanguageRepository.cs @@ -0,0 +1,9 @@ +using Algowars.Domain.Languages.ValueObjects; +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Languages; + +public interface ILanguageRepository : IRepository +{ + Task FindBySlugAsync(LanguageSlug slug, CancellationToken cancellationToken = default); +} diff --git a/Algowars.Domain/Languages/ValueObjects/Judge0Id.cs b/Algowars.Domain/Languages/ValueObjects/Judge0Id.cs new file mode 100644 index 0000000..524601e --- /dev/null +++ b/Algowars.Domain/Languages/ValueObjects/Judge0Id.cs @@ -0,0 +1,18 @@ +using Algowars.Domain.Languages.Exceptions; + +namespace Algowars.Domain.Languages.ValueObjects; + +public sealed record Judge0Id +{ + public Judge0Id(int value) + { + if (value <= 0) + throw new InvalidJudge0IdException($"Judge0 id must be a positive integer, got {value}."); + + Value = value; + } + + public override string ToString() => Value.ToString(); + + public int Value { get; } +} diff --git a/Algowars.Domain/Languages/ValueObjects/LanguageName.cs b/Algowars.Domain/Languages/ValueObjects/LanguageName.cs new file mode 100644 index 0000000..18ca8a0 --- /dev/null +++ b/Algowars.Domain/Languages/ValueObjects/LanguageName.cs @@ -0,0 +1,24 @@ +using Algowars.Domain.Languages.Exceptions; + +namespace Algowars.Domain.Languages.ValueObjects; + +public sealed record LanguageName +{ + public LanguageName(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidLanguageNameException("Name cannot be empty."); + + if (value.Length < MinLength || value.Length > MaxLength) + throw new InvalidLanguageNameException($"Name must be between {MinLength} and {MaxLength} characters."); + + Value = value; + } + + public static implicit operator string(LanguageName name) => name.Value; + public override string ToString() => Value; + + public static readonly int MaxLength = 100; + public static readonly int MinLength = 1; + public string Value { get; } +} diff --git a/Algowars.Domain/Languages/ValueObjects/LanguageSlug.cs b/Algowars.Domain/Languages/ValueObjects/LanguageSlug.cs new file mode 100644 index 0000000..41304af --- /dev/null +++ b/Algowars.Domain/Languages/ValueObjects/LanguageSlug.cs @@ -0,0 +1,43 @@ +using System.Text.RegularExpressions; +using Algowars.Domain.Languages.Exceptions; + +namespace Algowars.Domain.Languages.ValueObjects; + +public sealed record LanguageSlug +{ + private static readonly Regex ValidSlugPattern = new(@"^[a-z0-9]+(?:-[a-z0-9]+)*$", RegexOptions.Compiled); + private static readonly Regex InvalidSlugCharactersPattern = new(@"[^a-z0-9\s-]", RegexOptions.Compiled); + private static readonly Regex MultipleWhitespacePattern = new(@"\s+", RegexOptions.Compiled); + private static readonly Regex MultipleHyphensPattern = new(@"-+", RegexOptions.Compiled); + + public LanguageSlug(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidLanguageSlugException("Slug cannot be empty."); + + if (value.Length < MinLength || value.Length > MaxLength) + throw new InvalidLanguageSlugException($"Slug must be between {MinLength} and {MaxLength} characters."); + + if (!ValidSlugPattern.IsMatch(value)) + throw new InvalidLanguageSlugException("Slug must be lowercase, contain only letters, numbers, and hyphens, and cannot start or end with a hyphen."); + + Value = value; + } + + public static LanguageSlug FromName(LanguageName name) + { + string slug = name.Value.ToLowerInvariant(); + slug = InvalidSlugCharactersPattern.Replace(slug, string.Empty); + slug = MultipleWhitespacePattern.Replace(slug, "-"); + slug = MultipleHyphensPattern.Replace(slug, "-"); + slug = slug.Trim('-'); + return new LanguageSlug(slug); + } + + public static implicit operator string(LanguageSlug slug) => slug.Value; + public override string ToString() => Value; + + public static readonly int MaxLength = 100; + public static readonly int MinLength = 1; + public string Value { get; } +} diff --git a/Algowars.Domain/Languages/ValueObjects/LanguageVersion.cs b/Algowars.Domain/Languages/ValueObjects/LanguageVersion.cs new file mode 100644 index 0000000..bc672df --- /dev/null +++ b/Algowars.Domain/Languages/ValueObjects/LanguageVersion.cs @@ -0,0 +1,23 @@ +using Algowars.Domain.Languages.Exceptions; + +namespace Algowars.Domain.Languages.ValueObjects; + +public sealed record LanguageVersion +{ + public LanguageVersion(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidLanguageVersionException("Version cannot be empty."); + + if (value.Length > MaxLength) + throw new InvalidLanguageVersionException($"Version cannot exceed {MaxLength} characters."); + + Value = value; + } + + public static implicit operator string(LanguageVersion version) => version.Value; + public override string ToString() => Value; + + public static readonly int MaxLength = 50; + public string Value { get; } +} diff --git a/Algowars.Domain/Problems/Entities/Problem.cs b/Algowars.Domain/Problems/Entities/Problem.cs new file mode 100644 index 0000000..663a97c --- /dev/null +++ b/Algowars.Domain/Problems/Entities/Problem.cs @@ -0,0 +1,75 @@ +using Algowars.Domain.Problems.Enums; +using Algowars.Domain.Problems.ValueObjects; +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Problems.Entities; + +public sealed class Problem : AggregateRoot +{ + public Problem(Slug slug, Title title, Question question, Difficulty difficulty, TimeLimit timeLimit, MemoryLimit memoryLimit) + { + Slug = slug ?? throw new ArgumentNullException(nameof(slug)); + Title = title ?? throw new ArgumentNullException(nameof(title)); + Question = question ?? throw new ArgumentNullException(nameof(question)); + Difficulty = difficulty ?? throw new ArgumentNullException(nameof(difficulty)); + TimeLimit = timeLimit ?? throw new ArgumentNullException(nameof(timeLimit)); + MemoryLimit = memoryLimit ?? throw new ArgumentNullException(nameof(memoryLimit)); + Status = ProblemStatus.Draft; + CreatedAt = DateTime.UtcNow; + } + + public void Archive() + { + Status = ProblemStatus.Archived; + } + + public void Publish() + { + Status = ProblemStatus.Published; + } + + public void UpdateContent(Title title, Question question, Difficulty difficulty, TimeLimit timeLimit, MemoryLimit memoryLimit) + { + Title = title ?? throw new ArgumentNullException(nameof(title)); + Question = question ?? throw new ArgumentNullException(nameof(question)); + Difficulty = difficulty ?? throw new ArgumentNullException(nameof(difficulty)); + TimeLimit = timeLimit ?? throw new ArgumentNullException(nameof(timeLimit)); + MemoryLimit = memoryLimit ?? throw new ArgumentNullException(nameof(memoryLimit)); + + _history.Add(new ProblemHistory(Title, Question, Difficulty, TimeLimit, MemoryLimit)); + } + + public void UpdateSlug(Slug slug) + { + Slug = slug ?? throw new ArgumentNullException(nameof(slug)); + } + + public ProblemSetup AddSetup(Guid languageVersionId, string initialCode, string functionName) + { + var setup = new ProblemSetup(languageVersionId, initialCode, functionName); + _setups.Add(setup); + return setup; + } + + private Problem() { } + + public IEnumerable AvailableLanguageVersionIds() => _setups.Select(setup => setup.LanguageVersionId); + + public ProblemSetup? FindSetupByLanguageVersionId(Guid languageVersionId) => _setups.SingleOrDefault(setup => setup.LanguageVersionId == languageVersionId); + + public Slug Slug { get; private set; } + public Title Title { get; private set; } + public Question Question { get; private set; } + public Difficulty Difficulty { get; private set; } + public TimeLimit TimeLimit { get; private set; } + public MemoryLimit MemoryLimit { get; private set; } + public ProblemStatus Status { get; private set; } + public DateTime CreatedAt { get; private set; } + + public IReadOnlyCollection History => _history.AsReadOnly(); + public IReadOnlyCollection Setups => _setups.AsReadOnly(); + + private readonly List _history = []; + private readonly List _setups = []; +} + \ No newline at end of file diff --git a/Algowars.Domain/Problems/Entities/ProblemHistory.cs b/Algowars.Domain/Problems/Entities/ProblemHistory.cs new file mode 100644 index 0000000..84ea157 --- /dev/null +++ b/Algowars.Domain/Problems/Entities/ProblemHistory.cs @@ -0,0 +1,26 @@ +using Algowars.Domain.Problems.ValueObjects; +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Problems.Entities; + +public sealed class ProblemHistory : Entity +{ + internal ProblemHistory(Title title, Question question, Difficulty difficulty, TimeLimit timeLimit, MemoryLimit memoryLimit) + { + Title = title ?? throw new ArgumentNullException(nameof(title)); + Question = question ?? throw new ArgumentNullException(nameof(question)); + Difficulty = difficulty ?? throw new ArgumentNullException(nameof(difficulty)); + TimeLimit = timeLimit ?? throw new ArgumentNullException(nameof(timeLimit)); + MemoryLimit = memoryLimit ?? throw new ArgumentNullException(nameof(memoryLimit)); + CreatedAt = DateTime.UtcNow; + } + + private ProblemHistory() { } + + public Title Title { get; private set; } = null!; + public Question Question { get; private set; } = null!; + public Difficulty Difficulty { get; private set; } = null!; + public TimeLimit TimeLimit { get; private set; } = null!; + public MemoryLimit MemoryLimit { get; private set; } = null!; + public DateTime CreatedAt { get; private set; } +} diff --git a/Algowars.Domain/Problems/Entities/ProblemSetup.cs b/Algowars.Domain/Problems/Entities/ProblemSetup.cs new file mode 100644 index 0000000..18129a9 --- /dev/null +++ b/Algowars.Domain/Problems/Entities/ProblemSetup.cs @@ -0,0 +1,35 @@ +using Algowars.Domain.SeedWork; +using Algowars.Domain.TestSuites.Entities; +using Algowars.Domain.TestSuites.Enums; + +namespace Algowars.Domain.Problems.Entities; + +public sealed class ProblemSetup : Entity +{ + internal ProblemSetup(Guid languageVersionId, string initialCode, string functionName) + { + LanguageVersionId = languageVersionId != Guid.Empty + ? languageVersionId + : throw new ArgumentException("Language version id must not be empty.", nameof(languageVersionId)); + + InitialCode = !string.IsNullOrWhiteSpace(initialCode) + ? initialCode + : throw new ArgumentException("Initial code must not be empty.", nameof(initialCode)); + + FunctionName = !string.IsNullOrWhiteSpace(functionName) + ? functionName + : throw new ArgumentException("Function name must not be empty.", nameof(functionName)); + } + + private ProblemSetup() { } + + public IEnumerable PublicTestSuites() => [.. _testSuites.Where(testSuite => testSuite.Type == TestSuiteType.Sample)]; + + public Guid LanguageVersionId { get; private set; } + public string InitialCode { get; private set; } = null!; + public string FunctionName { get; private set; } = null!; + + public IReadOnlyCollection TestSuites => _testSuites.AsReadOnly(); + + private readonly List _testSuites = []; +} diff --git a/Algowars.Domain/Problems/Enums/DifficultyTier.cs b/Algowars.Domain/Problems/Enums/DifficultyTier.cs new file mode 100644 index 0000000..0d429d1 --- /dev/null +++ b/Algowars.Domain/Problems/Enums/DifficultyTier.cs @@ -0,0 +1,8 @@ +namespace Algowars.Domain.Problems.Enums; + +public enum DifficultyTier +{ + Easy = 1, + Medium = 2, + Hard = 3 +} diff --git a/Algowars.Domain/Problems/Enums/ProblemStatus.cs b/Algowars.Domain/Problems/Enums/ProblemStatus.cs new file mode 100644 index 0000000..fe43c0e --- /dev/null +++ b/Algowars.Domain/Problems/Enums/ProblemStatus.cs @@ -0,0 +1,8 @@ +namespace Algowars.Domain.Problems.Enums; + +public enum ProblemStatus +{ + Draft = 1, + Published = 2, + Archived = 3 +} diff --git a/Algowars.Domain/Problems/Exceptions/InvalidDifficultyException.cs b/Algowars.Domain/Problems/Exceptions/InvalidDifficultyException.cs new file mode 100644 index 0000000..5ea2a3b --- /dev/null +++ b/Algowars.Domain/Problems/Exceptions/InvalidDifficultyException.cs @@ -0,0 +1,7 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Problems.Exceptions; + +public sealed class InvalidDifficultyException(string reason) : DomainException($"Difficulty is invalid: {reason}") +{ +} diff --git a/Algowars.Domain/Problems/Exceptions/InvalidMemoryLimitException.cs b/Algowars.Domain/Problems/Exceptions/InvalidMemoryLimitException.cs new file mode 100644 index 0000000..911af7e --- /dev/null +++ b/Algowars.Domain/Problems/Exceptions/InvalidMemoryLimitException.cs @@ -0,0 +1,7 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Problems.Exceptions; + +public sealed class InvalidMemoryLimitException(string reason) : DomainException($"Memory limit is invalid: {reason}") +{ +} diff --git a/Algowars.Domain/Problems/Exceptions/InvalidQuestionException.cs b/Algowars.Domain/Problems/Exceptions/InvalidQuestionException.cs new file mode 100644 index 0000000..33f7094 --- /dev/null +++ b/Algowars.Domain/Problems/Exceptions/InvalidQuestionException.cs @@ -0,0 +1,7 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Problems.Exceptions; + +public sealed class InvalidQuestionException(string reason) : DomainException($"Question is invalid: {reason}") +{ +} diff --git a/Algowars.Domain/Problems/Exceptions/InvalidSlugException.cs b/Algowars.Domain/Problems/Exceptions/InvalidSlugException.cs new file mode 100644 index 0000000..25ff126 --- /dev/null +++ b/Algowars.Domain/Problems/Exceptions/InvalidSlugException.cs @@ -0,0 +1,9 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Problems.Exceptions; + +public sealed class InvalidSlugException : DomainException +{ + public InvalidSlugException(string reason) + : base($"Slug is invalid: {reason}") { } +} diff --git a/Algowars.Domain/Problems/Exceptions/InvalidTimeLimitException.cs b/Algowars.Domain/Problems/Exceptions/InvalidTimeLimitException.cs new file mode 100644 index 0000000..e4a8ebc --- /dev/null +++ b/Algowars.Domain/Problems/Exceptions/InvalidTimeLimitException.cs @@ -0,0 +1,7 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Problems.Exceptions; + +public sealed class InvalidTimeLimitException(string reason) : DomainException($"Time limit is invalid: {reason}") +{ +} diff --git a/Algowars.Domain/Problems/Exceptions/InvalidTitleException.cs b/Algowars.Domain/Problems/Exceptions/InvalidTitleException.cs new file mode 100644 index 0000000..f63a8f7 --- /dev/null +++ b/Algowars.Domain/Problems/Exceptions/InvalidTitleException.cs @@ -0,0 +1,7 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Problems.Exceptions; + +public sealed class InvalidTitleException(string reason) : DomainException($"Title is invalid: {reason}") +{ +} diff --git a/Algowars.Domain/Problems/Exceptions/ProblemSetupNotFoundException.cs b/Algowars.Domain/Problems/Exceptions/ProblemSetupNotFoundException.cs new file mode 100644 index 0000000..1332ca6 --- /dev/null +++ b/Algowars.Domain/Problems/Exceptions/ProblemSetupNotFoundException.cs @@ -0,0 +1,8 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Problems.Exceptions; + +public sealed class ProblemSetupNotFoundException(Guid setupId) + : DomainException($"Problem setup '{setupId}' was not found.") +{ +} diff --git a/Algowars.Domain/Problems/IProblemRepository.cs b/Algowars.Domain/Problems/IProblemRepository.cs new file mode 100644 index 0000000..3a0d2cd --- /dev/null +++ b/Algowars.Domain/Problems/IProblemRepository.cs @@ -0,0 +1,9 @@ +using Algowars.Domain.Problems.ValueObjects; +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Problems; + +public interface IProblemRepository : IRepository +{ + Task FindBySlugAsync(Slug slug, CancellationToken cancellationToken = default); +} diff --git a/Algowars.Domain/Problems/ValueObjects/Difficulty.cs b/Algowars.Domain/Problems/ValueObjects/Difficulty.cs new file mode 100644 index 0000000..9424a34 --- /dev/null +++ b/Algowars.Domain/Problems/ValueObjects/Difficulty.cs @@ -0,0 +1,33 @@ +using Algowars.Domain.Problems.Enums; +using Algowars.Domain.Problems.Exceptions; + +namespace Algowars.Domain.Problems.ValueObjects; + +public sealed record Difficulty +{ + public Difficulty(int value) + { + if (value < MinValue) + throw new InvalidDifficultyException($"Difficulty cannot be less than {MinValue}."); + + Value = value; + } + + public override string ToString() => $"{Value} ({Tier})"; + + public static readonly int EasyMax = 1000; + public static readonly int EasyMin = 0; + public static readonly int HardMin = 2001; + public static readonly int MediumMax = 2000; + public static readonly int MediumMin = 1001; + public static readonly int MinValue = 0; + + public DifficultyTier Tier => Value switch + { + <= 1000 => DifficultyTier.Easy, + <= 2000 => DifficultyTier.Medium, + _ => DifficultyTier.Hard + }; + + public int Value { get; } +} diff --git a/Algowars.Domain/Problems/ValueObjects/MemoryLimit.cs b/Algowars.Domain/Problems/ValueObjects/MemoryLimit.cs new file mode 100644 index 0000000..8ad7297 --- /dev/null +++ b/Algowars.Domain/Problems/ValueObjects/MemoryLimit.cs @@ -0,0 +1,20 @@ +using Algowars.Domain.Problems.Exceptions; + +namespace Algowars.Domain.Problems.ValueObjects; + +public sealed record MemoryLimit +{ + public MemoryLimit(int megabytes) + { + if (megabytes < MinMegabytes || megabytes > MaxMegabytes) + throw new InvalidMemoryLimitException($"Memory limit must be between {MinMegabytes}MB and {MaxMegabytes}MB."); + + Megabytes = megabytes; + } + + public override string ToString() => $"{Megabytes}MB"; + + public static readonly int MaxMegabytes = 512; + public static readonly int MinMegabytes = 16; + public int Megabytes { get; } +} diff --git a/Algowars.Domain/Problems/ValueObjects/Question.cs b/Algowars.Domain/Problems/ValueObjects/Question.cs new file mode 100644 index 0000000..1fcab96 --- /dev/null +++ b/Algowars.Domain/Problems/ValueObjects/Question.cs @@ -0,0 +1,24 @@ +using Algowars.Domain.Problems.Exceptions; + +namespace Algowars.Domain.Problems.ValueObjects; + +public sealed record Question +{ + public Question(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidQuestionException("Question cannot be empty."); + + if (value.Length < MinLength || value.Length > MaxLength) + throw new InvalidQuestionException($"Question must be between {MinLength} and {MaxLength} characters."); + + Value = value; + } + + public static implicit operator string(Question question) => question.Value; + public override string ToString() => Value; + + public static readonly int MaxLength = 10000; + public static readonly int MinLength = 50; + public string Value { get; } +} diff --git a/Algowars.Domain/Problems/ValueObjects/Slug.cs b/Algowars.Domain/Problems/ValueObjects/Slug.cs new file mode 100644 index 0000000..3e1c41f --- /dev/null +++ b/Algowars.Domain/Problems/ValueObjects/Slug.cs @@ -0,0 +1,43 @@ +using System.Text.RegularExpressions; +using Algowars.Domain.Problems.Exceptions; + +namespace Algowars.Domain.Problems.ValueObjects; + +public sealed record Slug +{ + private static readonly Regex ValidSlugPattern = new(@"^[a-z0-9]+(?:-[a-z0-9]+)*$", RegexOptions.Compiled); + private static readonly Regex InvalidSlugCharactersPattern = new(@"[^a-z0-9\s-]", RegexOptions.Compiled); + private static readonly Regex MultipleWhitespacePattern = new(@"\s+", RegexOptions.Compiled); + private static readonly Regex MultipleHyphensPattern = new(@"-+", RegexOptions.Compiled); + + public Slug(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidSlugException("Slug cannot be empty."); + + if (value.Length < MinLength || value.Length > MaxLength) + throw new InvalidSlugException($"Slug must be between {MinLength} and {MaxLength} characters."); + + if (!ValidSlugPattern.IsMatch(value)) + throw new InvalidSlugException("Slug must be lowercase, contain only letters, numbers, and hyphens, and cannot start or end with a hyphen."); + + Value = value; + } + + public static Slug FromTitle(Title title) + { + string slug = title.Value.ToLowerInvariant(); + slug = InvalidSlugCharactersPattern.Replace(slug, string.Empty); + slug = MultipleWhitespacePattern.Replace(slug, "-"); + slug = MultipleHyphensPattern.Replace(slug, "-"); + slug = slug.Trim('-'); + return new Slug(slug); + } + + public static implicit operator string(Slug slug) => slug.Value; + public override string ToString() => Value; + + public static readonly int MaxLength = 200; + public static readonly int MinLength = 3; + public string Value { get; } +} diff --git a/Algowars.Domain/Problems/ValueObjects/TimeLimit.cs b/Algowars.Domain/Problems/ValueObjects/TimeLimit.cs new file mode 100644 index 0000000..04ecd8b --- /dev/null +++ b/Algowars.Domain/Problems/ValueObjects/TimeLimit.cs @@ -0,0 +1,20 @@ +using Algowars.Domain.Problems.Exceptions; + +namespace Algowars.Domain.Problems.ValueObjects; + +public sealed record TimeLimit +{ + public TimeLimit(int milliseconds) + { + if (milliseconds < MinMilliseconds || milliseconds > MaxMilliseconds) + throw new InvalidTimeLimitException($"Time limit must be between {MinMilliseconds}ms and {MaxMilliseconds}ms."); + + Milliseconds = milliseconds; + } + + public override string ToString() => $"{Milliseconds}ms"; + + public static readonly int MaxMilliseconds = 10000; + public static readonly int MinMilliseconds = 100; + public int Milliseconds { get; } +} diff --git a/Algowars.Domain/Problems/ValueObjects/Title.cs b/Algowars.Domain/Problems/ValueObjects/Title.cs new file mode 100644 index 0000000..3cbdec3 --- /dev/null +++ b/Algowars.Domain/Problems/ValueObjects/Title.cs @@ -0,0 +1,24 @@ +using Algowars.Domain.Problems.Exceptions; + +namespace Algowars.Domain.Problems.ValueObjects; + +public sealed record Title +{ + public Title(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidTitleException("Title cannot be empty."); + + if (value.Length < MinLength || value.Length > MaxLength) + throw new InvalidTitleException($"Title must be between {MinLength} and {MaxLength} characters."); + + Value = value; + } + + public static implicit operator string(Title title) => title.Value; + public override string ToString() => Value; + + public static readonly int MaxLength = 200; + public static readonly int MinLength = 3; + public string Value { get; } +} diff --git a/Algowars.Domain/SeedWork/AggregateRoot.cs b/Algowars.Domain/SeedWork/AggregateRoot.cs new file mode 100644 index 0000000..9d92b00 --- /dev/null +++ b/Algowars.Domain/SeedWork/AggregateRoot.cs @@ -0,0 +1,5 @@ +namespace Algowars.Domain.SeedWork; + +public abstract class AggregateRoot : Entity +{ +} \ No newline at end of file diff --git a/Algowars.Domain/SeedWork/DomainException.cs b/Algowars.Domain/SeedWork/DomainException.cs new file mode 100644 index 0000000..3700ce4 --- /dev/null +++ b/Algowars.Domain/SeedWork/DomainException.cs @@ -0,0 +1,5 @@ +namespace Algowars.Domain.SeedWork; + +public abstract class DomainException(string message) : Exception(message) +{ +} \ No newline at end of file diff --git a/Algowars.Domain/SeedWork/Entity.cs b/Algowars.Domain/SeedWork/Entity.cs new file mode 100644 index 0000000..ba45d81 --- /dev/null +++ b/Algowars.Domain/SeedWork/Entity.cs @@ -0,0 +1,32 @@ +namespace Algowars.Domain.SeedWork; + +public abstract class Entity +{ + public Guid Id { get; protected set; } + + protected Entity() + { + Id = Guid.NewGuid(); + } + + public override bool Equals(object? obj) + { + if (obj is not Entity other) return false; + if (ReferenceEquals(this, other)) return true; + if (GetType() != other.GetType()) return false; + return Id == other.Id; + } + + public override int GetHashCode() => Id.GetHashCode(); + + public static bool operator ==(Entity? left, Entity? right) + { + if (left is null) return right is null; + return left.Equals(right); + } + + public static bool operator !=(Entity? left, Entity? right) + { + return !(left == right); + } +} diff --git a/Algowars.Domain/SeedWork/IAggregateFactory.cs b/Algowars.Domain/SeedWork/IAggregateFactory.cs new file mode 100644 index 0000000..589f135 --- /dev/null +++ b/Algowars.Domain/SeedWork/IAggregateFactory.cs @@ -0,0 +1,7 @@ +namespace Algowars.Domain.SeedWork; + +public interface IAggregateFactory + where TAggregate : AggregateRoot +{ + TAggregate Create(TParams parameters); +} diff --git a/Algowars.Domain/SeedWork/IRepository.cs b/Algowars.Domain/SeedWork/IRepository.cs new file mode 100644 index 0000000..0aa6ca3 --- /dev/null +++ b/Algowars.Domain/SeedWork/IRepository.cs @@ -0,0 +1,8 @@ +namespace Algowars.Domain.SeedWork; + +public interface IRepository where T : AggregateRoot +{ + Task AddAsync(T entity, CancellationToken cancellationToken = default); + Task UpdateAsync(T entity, CancellationToken cancellationToken = default); + Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default); +} diff --git a/Algowars.Domain/Submissions/Entities/Submission.cs b/Algowars.Domain/Submissions/Entities/Submission.cs new file mode 100644 index 0000000..16f026e --- /dev/null +++ b/Algowars.Domain/Submissions/Entities/Submission.cs @@ -0,0 +1,71 @@ +using Algowars.Domain.SeedWork; +using Algowars.Domain.Submissions.Enums; +using Algowars.Domain.Submissions.Exceptions; +using Algowars.Domain.Submissions.ValueObjects; + +namespace Algowars.Domain.Submissions.Entities; + +public sealed class Submission : AggregateRoot +{ + public Submission( + Guid userId, + Guid problemSetupId, + SubmissionType type, + SourceCode sourceCode, + IEnumerable testCaseIds) + { + UserId = userId; + ProblemSetupId = problemSetupId; + Type = type; + SourceCode = sourceCode ?? throw new ArgumentNullException(nameof(sourceCode)); + Status = SubmissionStatus.Queued; + + foreach (Guid testCaseId in testCaseIds) + _results.Add(new SubmissionResult(testCaseId)); + } + + public void Complete() + { + if (_results.Any(r => !r.IsTerminal)) + throw new SubmissionNotCompleteException(); + + Status = _results.All(r => r.Status == SubmissionResultStatus.Accepted) + ? SubmissionStatus.Accepted + : SubmissionStatus.WrongAnswer; + } + + public void StartRunning() + { + if (Status != SubmissionStatus.Queued) + throw new InvalidSubmissionStateException("Only a queued submission can be started."); + + Status = SubmissionStatus.Running; + } + + public void UpdateResult( + Guid testCaseId, + SubmissionResultStatus status, + int? runtime = null, + int? memoryUsed = null, + string? actualOutput = null, + string? standardOutput = null, + string? standardError = null, + string? compileOutput = null) + { + SubmissionResult result = _results.FirstOrDefault(r => r.TestCaseId == testCaseId) + ?? throw new SubmissionResultNotFoundException(testCaseId); + + result.Update(status, runtime, memoryUsed, actualOutput, standardOutput, standardError, compileOutput); + } + + private Submission() { } + + public Guid ProblemSetupId { get; private set; } + public IReadOnlyCollection Results => _results.AsReadOnly(); + public SourceCode SourceCode { get; private set; } = null!; + public SubmissionStatus Status { get; private set; } + public SubmissionType Type { get; private set; } + public Guid UserId { get; private set; } + + private readonly List _results = []; +} diff --git a/Algowars.Domain/Submissions/Entities/SubmissionResult.cs b/Algowars.Domain/Submissions/Entities/SubmissionResult.cs new file mode 100644 index 0000000..1984dec --- /dev/null +++ b/Algowars.Domain/Submissions/Entities/SubmissionResult.cs @@ -0,0 +1,45 @@ +using Algowars.Domain.Submissions.Enums; +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Submissions.Entities; + +public sealed class SubmissionResult : Entity +{ + internal SubmissionResult(Guid testCaseId) + { + TestCaseId = testCaseId; + Status = SubmissionResultStatus.Pending; + } + + public void Update( + SubmissionResultStatus status, + int? runtime = null, + int? memoryUsed = null, + string? actualOutput = null, + string? standardOutput = null, + string? standardError = null, + string? compileOutput = null) + { + Status = status; + Runtime = runtime; + MemoryUsed = memoryUsed; + ActualOutput = actualOutput; + StandardOutput = standardOutput; + StandardError = standardError; + CompileOutput = compileOutput; + } + + public bool IsTerminal => Status is not SubmissionResultStatus.Pending + and not SubmissionResultStatus.Processing; + + private SubmissionResult() { } + + public string? ActualOutput { get; private set; } + public string? CompileOutput { get; private set; } + public int? MemoryUsed { get; private set; } + public int? Runtime { get; private set; } + public string? StandardError { get; private set; } + public string? StandardOutput { get; private set; } + public SubmissionResultStatus Status { get; private set; } + public Guid TestCaseId { get; private set; } +} diff --git a/Algowars.Domain/Submissions/Enums/SubmissionResultStatus.cs b/Algowars.Domain/Submissions/Enums/SubmissionResultStatus.cs new file mode 100644 index 0000000..5ec86cd --- /dev/null +++ b/Algowars.Domain/Submissions/Enums/SubmissionResultStatus.cs @@ -0,0 +1,13 @@ +namespace Algowars.Domain.Submissions.Enums; + +public enum SubmissionResultStatus +{ + Pending, + Processing, + Accepted, + WrongAnswer, + TimeLimitExceeded, + MemoryLimitExceeded, + RuntimeError, + CompileError +} diff --git a/Algowars.Domain/Submissions/Enums/SubmissionStatus.cs b/Algowars.Domain/Submissions/Enums/SubmissionStatus.cs new file mode 100644 index 0000000..b507222 --- /dev/null +++ b/Algowars.Domain/Submissions/Enums/SubmissionStatus.cs @@ -0,0 +1,9 @@ +namespace Algowars.Domain.Submissions.Enums; + +public enum SubmissionStatus +{ + Queued, + Running, + Accepted, + WrongAnswer +} diff --git a/Algowars.Domain/Submissions/Enums/SubmissionType.cs b/Algowars.Domain/Submissions/Enums/SubmissionType.cs new file mode 100644 index 0000000..8c8aa9d --- /dev/null +++ b/Algowars.Domain/Submissions/Enums/SubmissionType.cs @@ -0,0 +1,7 @@ +namespace Algowars.Domain.Submissions.Enums; + +public enum SubmissionType +{ + Run, + Submit +} diff --git a/Algowars.Domain/Submissions/Exceptions/InvalidSourceCodeException.cs b/Algowars.Domain/Submissions/Exceptions/InvalidSourceCodeException.cs new file mode 100644 index 0000000..c00bb35 --- /dev/null +++ b/Algowars.Domain/Submissions/Exceptions/InvalidSourceCodeException.cs @@ -0,0 +1,9 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Submissions.Exceptions; + +public sealed class InvalidSourceCodeException : DomainException +{ + public InvalidSourceCodeException(string reason) + : base($"Source code is invalid: {reason}") { } +} diff --git a/Algowars.Domain/Submissions/Exceptions/InvalidSubmissionStateException.cs b/Algowars.Domain/Submissions/Exceptions/InvalidSubmissionStateException.cs new file mode 100644 index 0000000..48fcb9b --- /dev/null +++ b/Algowars.Domain/Submissions/Exceptions/InvalidSubmissionStateException.cs @@ -0,0 +1,9 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Submissions.Exceptions; + +public sealed class InvalidSubmissionStateException : DomainException +{ + public InvalidSubmissionStateException(string reason) + : base($"Submission state transition is invalid: {reason}") { } +} diff --git a/Algowars.Domain/Submissions/Exceptions/SubmissionNotCompleteException.cs b/Algowars.Domain/Submissions/Exceptions/SubmissionNotCompleteException.cs new file mode 100644 index 0000000..f4f559d --- /dev/null +++ b/Algowars.Domain/Submissions/Exceptions/SubmissionNotCompleteException.cs @@ -0,0 +1,9 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Submissions.Exceptions; + +public sealed class SubmissionNotCompleteException : DomainException +{ + public SubmissionNotCompleteException() + : base("Submission cannot be completed because one or more results are still pending or processing.") { } +} diff --git a/Algowars.Domain/Submissions/Exceptions/SubmissionResultNotFoundException.cs b/Algowars.Domain/Submissions/Exceptions/SubmissionResultNotFoundException.cs new file mode 100644 index 0000000..a266b83 --- /dev/null +++ b/Algowars.Domain/Submissions/Exceptions/SubmissionResultNotFoundException.cs @@ -0,0 +1,9 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Submissions.Exceptions; + +public sealed class SubmissionResultNotFoundException : DomainException +{ + public SubmissionResultNotFoundException(Guid testCaseId) + : base($"Submission result for test case '{testCaseId}' was not found.") { } +} diff --git a/Algowars.Domain/Submissions/Factories/SubmissionFactory.cs b/Algowars.Domain/Submissions/Factories/SubmissionFactory.cs new file mode 100644 index 0000000..7f2555a --- /dev/null +++ b/Algowars.Domain/Submissions/Factories/SubmissionFactory.cs @@ -0,0 +1,26 @@ +using Algowars.Domain.SeedWork; +using Algowars.Domain.Submissions.Entities; +using Algowars.Domain.Submissions.Enums; +using Algowars.Domain.Submissions.ValueObjects; + +namespace Algowars.Domain.Submissions.Factories; + +public sealed record CreateSubmissionParams( + Guid UserId, + Guid ProblemSetupId, + SubmissionType Type, + SourceCode SourceCode, + IEnumerable TestCaseIds); + +public sealed class SubmissionFactory : IAggregateFactory +{ + public Submission Create(CreateSubmissionParams parameters) + { + return new Submission( + parameters.UserId, + parameters.ProblemSetupId, + parameters.Type, + parameters.SourceCode, + parameters.TestCaseIds); + } +} diff --git a/Algowars.Domain/Submissions/ISubmissionWriteRepository.cs b/Algowars.Domain/Submissions/ISubmissionWriteRepository.cs new file mode 100644 index 0000000..2abd709 --- /dev/null +++ b/Algowars.Domain/Submissions/ISubmissionWriteRepository.cs @@ -0,0 +1,8 @@ +using Algowars.Domain.SeedWork; +using Algowars.Domain.Submissions.Entities; + +namespace Algowars.Domain.Submissions; + +public interface ISubmissionWriteRepository : IRepository +{ +} diff --git a/Algowars.Domain/Submissions/ValueObjects/SourceCode.cs b/Algowars.Domain/Submissions/ValueObjects/SourceCode.cs new file mode 100644 index 0000000..35bb4a4 --- /dev/null +++ b/Algowars.Domain/Submissions/ValueObjects/SourceCode.cs @@ -0,0 +1,23 @@ +using Algowars.Domain.Submissions.Exceptions; + +namespace Algowars.Domain.Submissions.ValueObjects; + +public sealed record SourceCode +{ + public SourceCode(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidSourceCodeException("Source code cannot be empty."); + + if (value.Length > MaxLength) + throw new InvalidSourceCodeException($"Source code cannot exceed {MaxLength} characters."); + + Value = value; + } + + public static implicit operator string(SourceCode code) => code.Value; + public override string ToString() => Value; + + public static readonly int MaxLength = 65536; + public string Value { get; } +} diff --git a/Algowars.Domain/TestSuites/Entities/TestCase.cs b/Algowars.Domain/TestSuites/Entities/TestCase.cs new file mode 100644 index 0000000..f0b518c --- /dev/null +++ b/Algowars.Domain/TestSuites/Entities/TestCase.cs @@ -0,0 +1,40 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.TestSuites.Entities; + +public sealed class TestCase : Entity +{ + internal TestCase(string name, string? description = null) + { + Name = !string.IsNullOrWhiteSpace(name) + ? name + : throw new ArgumentException("Name must not be empty.", nameof(name)); + + Description = description; + } + + public TestCaseInput AddInput(string value, string valueType) + { + var input = new TestCaseInput(value, valueType); + _inputs.Add(input); + return input; + } + + public TestCaseExpectedOutput AddExpectedOutput(string value, string valueType) + { + var output = new TestCaseExpectedOutput(value, valueType); + _expectedOutputs.Add(output); + return output; + } + + private TestCase() { } + + public string Name { get; private set; } = null!; + public string? Description { get; private set; } + + public IReadOnlyCollection Inputs => _inputs.AsReadOnly(); + public IReadOnlyCollection ExpectedOutputs => _expectedOutputs.AsReadOnly(); + + private readonly List _inputs = []; + private readonly List _expectedOutputs = []; +} diff --git a/Algowars.Domain/TestSuites/Entities/TestCaseExpectedOutput.cs b/Algowars.Domain/TestSuites/Entities/TestCaseExpectedOutput.cs new file mode 100644 index 0000000..d2186f2 --- /dev/null +++ b/Algowars.Domain/TestSuites/Entities/TestCaseExpectedOutput.cs @@ -0,0 +1,22 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.TestSuites.Entities; + +public sealed class TestCaseExpectedOutput : Entity +{ + internal TestCaseExpectedOutput(string value, string valueType) + { + Value = !string.IsNullOrWhiteSpace(value) + ? value + : throw new ArgumentException("Value must not be empty.", nameof(value)); + + ValueType = !string.IsNullOrWhiteSpace(valueType) + ? valueType + : throw new ArgumentException("Value type must not be empty.", nameof(valueType)); + } + + private TestCaseExpectedOutput() { } + + public string Value { get; private set; } = null!; + public string ValueType { get; private set; } = null!; +} diff --git a/Algowars.Domain/TestSuites/Entities/TestCaseInput.cs b/Algowars.Domain/TestSuites/Entities/TestCaseInput.cs new file mode 100644 index 0000000..d7ef97e --- /dev/null +++ b/Algowars.Domain/TestSuites/Entities/TestCaseInput.cs @@ -0,0 +1,22 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.TestSuites.Entities; + +public sealed class TestCaseInput : Entity +{ + internal TestCaseInput(string value, string valueType) + { + Value = !string.IsNullOrWhiteSpace(value) + ? value + : throw new ArgumentException("Value must not be empty.", nameof(value)); + + ValueType = !string.IsNullOrWhiteSpace(valueType) + ? valueType + : throw new ArgumentException("Value type must not be empty.", nameof(valueType)); + } + + private TestCaseInput() { } + + public string Value { get; private set; } = null!; + public string ValueType { get; private set; } = null!; +} diff --git a/Algowars.Domain/TestSuites/Entities/TestSuite.cs b/Algowars.Domain/TestSuites/Entities/TestSuite.cs new file mode 100644 index 0000000..5bff694 --- /dev/null +++ b/Algowars.Domain/TestSuites/Entities/TestSuite.cs @@ -0,0 +1,34 @@ +using Algowars.Domain.SeedWork; +using Algowars.Domain.TestSuites.Enums; + +namespace Algowars.Domain.TestSuites.Entities; + +public sealed class TestSuite : AggregateRoot +{ + public TestSuite(string name, TestSuiteType type) + { + Name = !string.IsNullOrWhiteSpace(name) + ? name + : throw new ArgumentException("Name must not be empty.", nameof(name)); + + Type = type; + CreatedAt = DateTime.UtcNow; + } + + public TestCase AddTestCase(string name, string? description = null) + { + var testCase = new TestCase(name, description); + _testCases.Add(testCase); + return testCase; + } + + private TestSuite() { } + + public string Name { get; private set; } = null!; + public TestSuiteType Type { get; private set; } + public DateTime CreatedAt { get; private set; } + + public IReadOnlyCollection TestCases => _testCases.AsReadOnly(); + + private readonly List _testCases = []; +} diff --git a/Algowars.Domain/TestSuites/Enums/TestSuiteType.cs b/Algowars.Domain/TestSuites/Enums/TestSuiteType.cs new file mode 100644 index 0000000..2e0fea4 --- /dev/null +++ b/Algowars.Domain/TestSuites/Enums/TestSuiteType.cs @@ -0,0 +1,7 @@ +namespace Algowars.Domain.TestSuites.Enums; + +public enum TestSuiteType +{ + Sample = 1, + Hidden = 2 +} diff --git a/Algowars.Domain/TestSuites/ITestSuiteWriteRepository.cs b/Algowars.Domain/TestSuites/ITestSuiteWriteRepository.cs new file mode 100644 index 0000000..099ba5d --- /dev/null +++ b/Algowars.Domain/TestSuites/ITestSuiteWriteRepository.cs @@ -0,0 +1,9 @@ +using Algowars.Domain.SeedWork; +using Algowars.Domain.TestSuites.Entities; + +namespace Algowars.Domain.TestSuites; + +public interface ITestSuiteWriteRepository : IRepository +{ + Task> FindTestCaseIdsByProblemSetupIdAsync(Guid problemSetupId, CancellationToken cancellationToken = default); +} diff --git a/Algowars.Domain/Users/Entities/User.cs b/Algowars.Domain/Users/Entities/User.cs new file mode 100644 index 0000000..973c0d4 --- /dev/null +++ b/Algowars.Domain/Users/Entities/User.cs @@ -0,0 +1,42 @@ +using Algowars.Domain.SeedWork; +using Algowars.Domain.Users.Exceptions; +using Algowars.Domain.Users.ValueObjects; + +namespace Algowars.Domain.Users.Entities; + +public sealed class User(Username username, string sub) : AggregateRoot +{ + public void ChangeUsername(Username username) + { + if (UsernameLastChangedAt.HasValue && + DateTime.UtcNow - UsernameLastChangedAt.Value < TimeSpan.FromDays(MaxDaysUntilUsernameChange)) + throw new UsernameCooldownException(UsernameLastChangedAt.Value); + + Username = username; + UsernameLastChangedAt = DateTime.UtcNow; + } + + public void UpdateBio(Bio? bio) + { + Bio = bio; + } + + public void UpdateImageUrl(ImageUrl? imageUrl) + { + ImageUrl = imageUrl; + } + + public Bio? Bio { get; private set; } + + public ImageUrl? ImageUrl { get; private set; } + + public string Sub { get; private set; } = string.IsNullOrWhiteSpace(sub) + ? throw new InvalidUserSubException() + : sub; + + public Username Username { get; private set; } = username ?? throw new InvalidUsernameException("Username is required."); + + public DateTime? UsernameLastChangedAt { get; private set; } + + public static readonly int MaxDaysUntilUsernameChange = 30; +} diff --git a/Algowars.Domain/Users/Exceptions/InvalidBioException.cs b/Algowars.Domain/Users/Exceptions/InvalidBioException.cs new file mode 100644 index 0000000..98d271c --- /dev/null +++ b/Algowars.Domain/Users/Exceptions/InvalidBioException.cs @@ -0,0 +1,9 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Users.Exceptions; + +public sealed class InvalidBioException : DomainException +{ + public InvalidBioException(string reason) + : base($"Bio is invalid: {reason}") { } +} \ No newline at end of file diff --git a/Algowars.Domain/Users/Exceptions/InvalidImageUrlException.cs b/Algowars.Domain/Users/Exceptions/InvalidImageUrlException.cs new file mode 100644 index 0000000..997d0f1 --- /dev/null +++ b/Algowars.Domain/Users/Exceptions/InvalidImageUrlException.cs @@ -0,0 +1,7 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Users.Exceptions; + +public sealed class InvalidImageUrlException(string reason) : DomainException($"Image URL is invalid: {reason}") +{ +} \ No newline at end of file diff --git a/Algowars.Domain/Users/Exceptions/InvalidUserSubException.cs b/Algowars.Domain/Users/Exceptions/InvalidUserSubException.cs new file mode 100644 index 0000000..8b7c9ae --- /dev/null +++ b/Algowars.Domain/Users/Exceptions/InvalidUserSubException.cs @@ -0,0 +1,9 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Users.Exceptions; + +public sealed class InvalidUserSubException : DomainException +{ + public InvalidUserSubException() + : base("User sub is required.") { } +} \ No newline at end of file diff --git a/Algowars.Domain/Users/Exceptions/InvalidUsernameException.cs b/Algowars.Domain/Users/Exceptions/InvalidUsernameException.cs new file mode 100644 index 0000000..371f27a --- /dev/null +++ b/Algowars.Domain/Users/Exceptions/InvalidUsernameException.cs @@ -0,0 +1,7 @@ +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Users.Exceptions; + +public sealed class InvalidUsernameException(string reason) : DomainException($"Username is invalid: {reason}") +{ +} \ No newline at end of file diff --git a/Algowars.Domain/Users/Exceptions/UsernameCooldownException.cs b/Algowars.Domain/Users/Exceptions/UsernameCooldownException.cs new file mode 100644 index 0000000..8b392a4 --- /dev/null +++ b/Algowars.Domain/Users/Exceptions/UsernameCooldownException.cs @@ -0,0 +1,9 @@ + + +using Algowars.Domain.SeedWork; + +namespace Algowars.Domain.Users.Exceptions; + +public sealed class UsernameCooldownException(DateTime lastChangedAt) : DomainException($"Username can only be changed once every 30 days. Last changed at: {lastChangedAt}.") +{ +} diff --git a/Algowars.Domain/Users/Factories/UserFactory.cs b/Algowars.Domain/Users/Factories/UserFactory.cs new file mode 100644 index 0000000..6ef613e --- /dev/null +++ b/Algowars.Domain/Users/Factories/UserFactory.cs @@ -0,0 +1,19 @@ +using Algowars.Domain.SeedWork; +using Algowars.Domain.Users.Entities; +using Algowars.Domain.Users.ValueObjects; + +namespace Algowars.Domain.Users.Factories; + +public sealed record CreateUserParams(string Username, string Sub, string? ImageUrl); + +public sealed class UserFactory : IAggregateFactory +{ + public User Create(CreateUserParams parameters) + { + var username = new Username(parameters.Username); + var imageUrl = parameters.ImageUrl is not null ? new ImageUrl(parameters.ImageUrl) : null; + var user = new User(username, parameters.Sub); + user.UpdateImageUrl(imageUrl); + return user; + } +} diff --git a/Algowars.Domain/Users/IUserWriteRepository.cs b/Algowars.Domain/Users/IUserWriteRepository.cs new file mode 100644 index 0000000..32cc548 --- /dev/null +++ b/Algowars.Domain/Users/IUserWriteRepository.cs @@ -0,0 +1,11 @@ +using Algowars.Domain.SeedWork; +using Algowars.Domain.Users.Entities; +using Algowars.Domain.Users.ValueObjects; + +namespace Algowars.Domain.Users; + +public interface IUserWriteRepository : IRepository +{ + Task FindBySubAsync(string sub, CancellationToken cancellationToken = default); + Task FindByUsername(Username username, CancellationToken cancellationToken = default); +} diff --git a/Algowars.Domain/Users/ValueObjects/Bio.cs b/Algowars.Domain/Users/ValueObjects/Bio.cs new file mode 100644 index 0000000..bb5ddc5 --- /dev/null +++ b/Algowars.Domain/Users/ValueObjects/Bio.cs @@ -0,0 +1,26 @@ +using Algowars.Domain.Users.Exceptions; + +namespace Algowars.Domain.Users.ValueObjects; + +public sealed record Bio +{ + public Bio(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidBioException("Bio cannot be empty."); + + if (value.Length > MaxLength) + throw new InvalidBioException($"Bio cannot exceed {MaxLength} characters."); + + Value = value; + } + + public static implicit operator string(Bio bio) => bio.Value; + + public override string ToString() => Value; + + + public static readonly int MaxLength = 500; + + public string Value { get; } +} diff --git a/Algowars.Domain/Users/ValueObjects/ImageUrl.cs b/Algowars.Domain/Users/ValueObjects/ImageUrl.cs new file mode 100644 index 0000000..e5b1f42 --- /dev/null +++ b/Algowars.Domain/Users/ValueObjects/ImageUrl.cs @@ -0,0 +1,30 @@ +using Algowars.Domain.Users.Exceptions; + +namespace Algowars.Domain.Users.ValueObjects; + +public sealed record ImageUrl +{ + public ImageUrl(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidImageUrlException("Image URL cannot be empty."); + + if (value.Length > MaxLength) + throw new InvalidImageUrlException($"Image URL cannot exceed {MaxLength} characters."); + + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + throw new InvalidImageUrlException("Image URL must be a valid HTTP or HTTPS URL."); + + Value = value; + } + + public static implicit operator string(ImageUrl url) => url.Value; + + public override string ToString() => Value; + + + public static readonly int MaxLength = 2048; + + public string Value { get; } +} diff --git a/Algowars.Domain/Users/ValueObjects/Username.cs b/Algowars.Domain/Users/ValueObjects/Username.cs new file mode 100644 index 0000000..9cf6d4b --- /dev/null +++ b/Algowars.Domain/Users/ValueObjects/Username.cs @@ -0,0 +1,29 @@ +using Algowars.Domain.Users.Exceptions; + +namespace Algowars.Domain.Users.ValueObjects; + +public sealed record Username +{ + public Username(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidUsernameException("Username cannot be null or whitespace."); + + if (value.Length < MinLength || value.Length > MaxLength) + throw new InvalidUsernameException($"Username must be between {MinLength} and {MaxLength} characters."); + + if (!value.All(c => char.IsLetterOrDigit(c) || c == '_' || c == '-')) + throw new InvalidUsernameException("Username can only contain letters, digits, underscores, or hyphens."); + Value = value; + } + + public static implicit operator string(Username username) => username.Value; + + public override string ToString() => Value; + + public static readonly int MaxLength = 20; + + public static readonly int MinLength = 1; + + public string Value { get; } +} diff --git a/Algowars.Infrastructure/Algowars.Infrastructure.csproj b/Algowars.Infrastructure/Algowars.Infrastructure.csproj new file mode 100644 index 0000000..3c58acb --- /dev/null +++ b/Algowars.Infrastructure/Algowars.Infrastructure.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + \ No newline at end of file diff --git a/Algowars.Infrastructure/InfrastructureServiceRegistration.cs b/Algowars.Infrastructure/InfrastructureServiceRegistration.cs new file mode 100644 index 0000000..bf7de3a --- /dev/null +++ b/Algowars.Infrastructure/InfrastructureServiceRegistration.cs @@ -0,0 +1,144 @@ +using Algowars.Application.Configuration; +using Algowars.Application.Languages; +using Algowars.Application.Messaging; +using Algowars.Application.Problems; +using Algowars.Application.Settings; +using Algowars.Application.Users; +using Algowars.Domain.Submissions; +using Algowars.Domain.TestSuites; +using Algowars.Domain.Users; +using Algowars.Infrastructure.Messaging; +using Algowars.Infrastructure.Messaging.Consumers; +using Algowars.Infrastructure.Persistence; +using Algowars.Infrastructure.Persistence.Seeders; +using Algowars.Infrastructure.Repositories; +using Algowars.Infrastructure.Settings; +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Algowars.Infrastructure; + +public static class InfrastructureServiceRegistration +{ + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddOptions(configuration); + + services.AddPersistence(); + services.AddRepositories(); + + services.AddMessageBus(configuration); + + services.AddSeeder(); + + return services; + } + + + private static IServiceCollection AddMessageBus(this IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + + services.AddMassTransit(bus => + { + bus.AddConsumer(); + + var opts = configuration + .GetSection(MessageBusOptions.SectionName) + .Get() ?? new MessageBusOptions(); + + if (opts.Transport.Equals("AzureServiceBus", StringComparison.OrdinalIgnoreCase)) + { + bus.UsingAzureServiceBus((ctx, cfg) => + { + var busOpts = ctx.GetRequiredService(); + cfg.Host(busOpts.AzureServiceBus.ConnectionString); + cfg.ConfigureEndpoints(ctx); + }); + } + else + { + bus.UsingRabbitMq((ctx, cfg) => + { + var busOpts = ctx.GetRequiredService(); + cfg.Host(busOpts.RabbitMQ.Host, busOpts.RabbitMQ.VirtualHost, h => + { + h.Username(busOpts.RabbitMQ.Username); + h.Password(busOpts.RabbitMQ.Password); + }); + cfg.ConfigureEndpoints(ctx); + }); + } + }); + + return services; + } + + private static IServiceCollection AddOptions(this IServiceCollection services, IConfiguration configuration) + { + services.AddOption(configuration); + services.AddOption(configuration); + + return services; + } + + private static IServiceCollection AddPersistence(this IServiceCollection services) + { + services.AddDbContext((serviceProvider, dbOptions) => + { + var options = serviceProvider + .GetRequiredService(); + + dbOptions.UseNpgsql(options.DefaultConnection); + }); + + return services; + } + + private static IServiceCollection AddRepositories(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } + + private static IServiceCollection AddSeeder(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + return services; + } + + public static async Task MigrateAsync(this IServiceProvider services) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + } + + public static async Task SeedAsync(this IServiceProvider services, SeederOptions options, CancellationToken cancellationToken = default) + { + using var scope = services.CreateScope(); + + if (options.SeedStaticData) + { + var languageSeeder = scope.ServiceProvider.GetRequiredService(); + await languageSeeder.SeedAsync(cancellationToken); + } + + if (options.SeedDemoData) + { + var demoSeeder = scope.ServiceProvider.GetRequiredService(); + await demoSeeder.SeedAsync(cancellationToken); + } + } +} \ No newline at end of file diff --git a/Algowars.Infrastructure/Messaging/Consumers/SubmissionCreatedConsumer.cs b/Algowars.Infrastructure/Messaging/Consumers/SubmissionCreatedConsumer.cs new file mode 100644 index 0000000..47daa58 --- /dev/null +++ b/Algowars.Infrastructure/Messaging/Consumers/SubmissionCreatedConsumer.cs @@ -0,0 +1,20 @@ +using Algowars.Application.Messaging.Messages; +using MassTransit; +using Microsoft.Extensions.Logging; + +namespace Algowars.Infrastructure.Messaging.Consumers; + +public sealed partial class SubmissionCreatedConsumer( + ILogger logger +) : IConsumer +{ + public Task Consume(ConsumeContext context) + { + LogSubmissionReceived(context.Message.SubmissionId); + return Task.CompletedTask; + } + + [LoggerMessage(Level = LogLevel.Information, + Message = "Received SubmissionCreatedMessage for submission {SubmissionId}")] + private partial void LogSubmissionReceived(Guid SubmissionId); +} diff --git a/src/Infrastructure/Messaging/MassTransitMessagePublisher.cs b/Algowars.Infrastructure/Messaging/MassTransitMessagePublisher.cs similarity index 78% rename from src/Infrastructure/Messaging/MassTransitMessagePublisher.cs rename to Algowars.Infrastructure/Messaging/MassTransitMessagePublisher.cs index 5b626b9..b8c0e01 100644 --- a/src/Infrastructure/Messaging/MassTransitMessagePublisher.cs +++ b/Algowars.Infrastructure/Messaging/MassTransitMessagePublisher.cs @@ -1,11 +1,11 @@ -using ApplicationCore.Interfaces.Messaging; +using Algowars.Application.Messaging; using MassTransit; -namespace Infrastructure.Messaging; +namespace Algowars.Infrastructure.Messaging; internal sealed class MassTransitMessagePublisher(IPublishEndpoint publishEndpoint) : IMessagePublisher { public Task PublishAsync(T message, CancellationToken cancellationToken = default) where T : class => publishEndpoint.Publish(message, cancellationToken); -} \ No newline at end of file +} diff --git a/Algowars.Infrastructure/Migrations/20260619031407_InitialMigration.Designer.cs b/Algowars.Infrastructure/Migrations/20260619031407_InitialMigration.Designer.cs new file mode 100644 index 0000000..7322227 --- /dev/null +++ b/Algowars.Infrastructure/Migrations/20260619031407_InitialMigration.Designer.cs @@ -0,0 +1,74 @@ +// +using System; +using Algowars.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Algowars.Infrastructure.Migrations +{ + [DbContext(typeof(AlgowarsDbContext))] + [Migration("20260619031407_InitialMigration")] + partial class InitialMigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Algowars.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Algowars.Infrastructure/Migrations/20260619031407_InitialMigration.cs b/Algowars.Infrastructure/Migrations/20260619031407_InitialMigration.cs new file mode 100644 index 0000000..4d2e72e --- /dev/null +++ b/Algowars.Infrastructure/Migrations/20260619031407_InitialMigration.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Algowars.Infrastructure.Migrations +{ + /// + public partial class InitialMigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "users", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + bio = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + image_url = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + sub = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + username = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + username_last_changed_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_users", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "IX_users_sub", + table: "users", + column: "sub", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_users_username", + table: "users", + column: "username", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "users"); + } + } +} diff --git a/Algowars.Infrastructure/Migrations/20260621220618_MigrateOldDatabase.Designer.cs b/Algowars.Infrastructure/Migrations/20260621220618_MigrateOldDatabase.Designer.cs new file mode 100644 index 0000000..ddf8288 --- /dev/null +++ b/Algowars.Infrastructure/Migrations/20260621220618_MigrateOldDatabase.Designer.cs @@ -0,0 +1,409 @@ +// +using System; +using Algowars.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Algowars.Infrastructure.Migrations +{ + [DbContext(typeof(AlgowarsDbContext))] + [Migration("20260621220618_MigrateOldDatabase")] + partial class MigrateOldDatabase + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Algowars.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("Algowars.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Algowars.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("Algowars.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("Algowars.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("Algowars.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("Algowars.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("Algowars.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Algowars.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Algowars.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Algowars.Infrastructure/Migrations/20260621220618_MigrateOldDatabase.cs b/Algowars.Infrastructure/Migrations/20260621220618_MigrateOldDatabase.cs new file mode 100644 index 0000000..3ea650c --- /dev/null +++ b/Algowars.Infrastructure/Migrations/20260621220618_MigrateOldDatabase.cs @@ -0,0 +1,233 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Algowars.Infrastructure.Migrations; + /// + public partial class MigrateOldDatabase : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "problems", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + slug = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + question = table.Column(type: "character varying(10000)", maxLength: 10000, nullable: false), + difficulty = table.Column(type: "integer", nullable: false), + time_limit = table.Column(type: "integer", nullable: false), + memory_limit = table.Column(type: "integer", nullable: false), + status = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_problems", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "test_suites", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + type = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_test_suites", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "problem_history", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + question = table.Column(type: "character varying(10000)", maxLength: 10000, nullable: false), + difficulty = table.Column(type: "integer", nullable: false), + time_limit = table.Column(type: "integer", nullable: false), + memory_limit = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + problem_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_problem_history", x => x.id); + table.ForeignKey( + name: "FK_problem_history_problems_problem_id", + column: x => x.problem_id, + principalTable: "problems", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "problem_setups", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + language_version_id = table.Column(type: "uuid", nullable: false), + initial_code = table.Column(type: "text", nullable: false), + function_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + problem_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_problem_setups", x => x.id); + table.ForeignKey( + name: "FK_problem_setups_problems_problem_id", + column: x => x.problem_id, + principalTable: "problems", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "test_cases", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + description = table.Column(type: "text", nullable: true), + test_suite_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_test_cases", x => x.id); + table.ForeignKey( + name: "FK_test_cases_test_suites_test_suite_id", + column: x => x.test_suite_id, + principalTable: "test_suites", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "problem_setup_test_suites", + columns: table => new + { + problem_setup_id = table.Column(type: "uuid", nullable: false), + test_suite_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_problem_setup_test_suites", x => new { x.problem_setup_id, x.test_suite_id }); + table.ForeignKey( + name: "FK_problem_setup_test_suites_problem_setups_problem_setup_id", + column: x => x.problem_setup_id, + principalTable: "problem_setups", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_problem_setup_test_suites_test_suites_test_suite_id", + column: x => x.test_suite_id, + principalTable: "test_suites", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "test_case_expected_outputs", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + value = table.Column(type: "text", nullable: false), + value_type = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + test_case_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_test_case_expected_outputs", x => x.id); + table.ForeignKey( + name: "FK_test_case_expected_outputs_test_cases_test_case_id", + column: x => x.test_case_id, + principalTable: "test_cases", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "test_case_inputs", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + value = table.Column(type: "text", nullable: false), + value_type = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + test_case_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_test_case_inputs", x => x.id); + table.ForeignKey( + name: "FK_test_case_inputs_test_cases_test_case_id", + column: x => x.test_case_id, + principalTable: "test_cases", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_problem_history_problem_id", + table: "problem_history", + column: "problem_id"); + + migrationBuilder.CreateIndex( + name: "IX_problem_setup_test_suites_test_suite_id", + table: "problem_setup_test_suites", + column: "test_suite_id"); + + migrationBuilder.CreateIndex( + name: "IX_problem_setups_problem_id", + table: "problem_setups", + column: "problem_id"); + + migrationBuilder.CreateIndex( + name: "IX_test_case_expected_outputs_test_case_id", + table: "test_case_expected_outputs", + column: "test_case_id"); + + migrationBuilder.CreateIndex( + name: "IX_test_case_inputs_test_case_id", + table: "test_case_inputs", + column: "test_case_id"); + + migrationBuilder.CreateIndex( + name: "IX_test_cases_test_suite_id", + table: "test_cases", + column: "test_suite_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "problem_history"); + + migrationBuilder.DropTable( + name: "problem_setup_test_suites"); + + migrationBuilder.DropTable( + name: "test_case_expected_outputs"); + + migrationBuilder.DropTable( + name: "test_case_inputs"); + + migrationBuilder.DropTable( + name: "problem_setups"); + + migrationBuilder.DropTable( + name: "test_cases"); + + migrationBuilder.DropTable( + name: "problems"); + + migrationBuilder.DropTable( + name: "test_suites"); + } + } diff --git a/Algowars.Infrastructure/Migrations/20260622032111_MigrateOldDatabase2.Designer.cs b/Algowars.Infrastructure/Migrations/20260622032111_MigrateOldDatabase2.Designer.cs new file mode 100644 index 0000000..2c0138e --- /dev/null +++ b/Algowars.Infrastructure/Migrations/20260622032111_MigrateOldDatabase2.Designer.cs @@ -0,0 +1,483 @@ +// +using System; +using Algowars.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Algowars.Infrastructure.Migrations +{ + [DbContext(typeof(AlgowarsDbContext))] + [Migration("20260622032111_MigrateOldDatabase2")] + partial class MigrateOldDatabase2 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Algowars.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("Algowars.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Algowars.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("Algowars.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Algowars.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("Algowars.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("Algowars.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("Algowars.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("Algowars.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("Algowars.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Algowars.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Algowars.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("Algowars.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Algowars.Infrastructure/Migrations/20260622032111_MigrateOldDatabase2.cs b/Algowars.Infrastructure/Migrations/20260622032111_MigrateOldDatabase2.cs new file mode 100644 index 0000000..13a791b --- /dev/null +++ b/Algowars.Infrastructure/Migrations/20260622032111_MigrateOldDatabase2.cs @@ -0,0 +1,65 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Algowars.Infrastructure.Migrations +{ + /// + public partial class MigrateOldDatabase2 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "languages", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + slug = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + status = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_languages", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "language_versions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + judge0_id = table.Column(type: "integer", nullable: false), + status = table.Column(type: "integer", nullable: false), + version = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + language_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_language_versions", x => x.id); + table.ForeignKey( + name: "FK_language_versions_languages_language_id", + column: x => x.language_id, + principalTable: "languages", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_language_versions_language_id", + table: "language_versions", + column: "language_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "language_versions"); + + migrationBuilder.DropTable( + name: "languages"); + } + } +} diff --git a/Algowars.Infrastructure/Migrations/AlgoWarsDbContextModelSnapshot.cs b/Algowars.Infrastructure/Migrations/AlgoWarsDbContextModelSnapshot.cs new file mode 100644 index 0000000..fdfed38 --- /dev/null +++ b/Algowars.Infrastructure/Migrations/AlgoWarsDbContextModelSnapshot.cs @@ -0,0 +1,480 @@ +// +using System; +using Algowars.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Algowars.Infrastructure.Migrations +{ + [DbContext(typeof(AlgowarsDbContext))] + partial class AlgowarsDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Algowars.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("Algowars.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("Algowars.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Algowars.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("Algowars.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Algowars.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("Algowars.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("Algowars.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("Algowars.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("Algowars.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("Algowars.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Algowars.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Algowars.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("Algowars.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("Algowars.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Algowars.Infrastructure/Persistence/AlgoWarsDbContext.cs b/Algowars.Infrastructure/Persistence/AlgoWarsDbContext.cs new file mode 100644 index 0000000..77b9946 --- /dev/null +++ b/Algowars.Infrastructure/Persistence/AlgoWarsDbContext.cs @@ -0,0 +1,33 @@ +using Algowars.Domain.Languages.Entities; +using Algowars.Domain.Languages.Enums; +using Algowars.Domain.Problems.Entities; +using Algowars.Domain.Problems.Enums; +using Algowars.Domain.Submissions.Entities; +using Algowars.Domain.TestSuites.Entities; +using Algowars.Domain.Users.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Algowars.Infrastructure.Persistence; + +internal sealed class AlgowarsDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Languages { get; init; } + + public DbSet Problems { get; init; } + + public DbSet Submissions { get; init; } + + public DbSet TestSuites { get; init; } + + public DbSet Users { get; init; } + + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AlgowarsDbContext).Assembly); + + modelBuilder.Entity().HasQueryFilter(problem => problem.Status == ProblemStatus.Published); + modelBuilder.Entity().HasQueryFilter(language => language.Status == LanguageStatus.Active); + modelBuilder.Entity().HasQueryFilter(version => version.Status == LanguageVersionStatus.Active); + } +} \ No newline at end of file diff --git a/Algowars.Infrastructure/Persistence/Configuration/LanguageConfiguration.cs b/Algowars.Infrastructure/Persistence/Configuration/LanguageConfiguration.cs new file mode 100644 index 0000000..c8ea7f4 --- /dev/null +++ b/Algowars.Infrastructure/Persistence/Configuration/LanguageConfiguration.cs @@ -0,0 +1,50 @@ +using Algowars.Domain.Languages.Entities; +using Algowars.Domain.Languages.ValueObjects; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Algowars.Infrastructure.Persistence.Configuration; + +internal sealed class LanguageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("languages"); + + builder.HasKey(l => l.Id); + + builder.Property(l => l.Id) + .HasColumnName("id"); + + builder.Property(l => l.Name) + .HasColumnName("name") + .HasMaxLength(LanguageName.MaxLength) + .IsRequired() + .HasConversion( + n => n.Value, + v => new LanguageName(v)); + + builder.Property(l => l.Slug) + .HasColumnName("slug") + .HasMaxLength(LanguageSlug.MaxLength) + .IsRequired() + .HasConversion( + s => s.Value, + v => new LanguageSlug(v)); + + builder.Property(l => l.Status) + .HasColumnName("status") + .HasConversion() + .IsRequired(); + + builder.HasMany(l => l.Versions) + .WithOne() + .HasForeignKey("language_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(l => l.Versions) + .HasField("_versions") + .UsePropertyAccessMode(PropertyAccessMode.Field); + } +} diff --git a/Algowars.Infrastructure/Persistence/Configuration/LanguageVersionConfiguration.cs b/Algowars.Infrastructure/Persistence/Configuration/LanguageVersionConfiguration.cs new file mode 100644 index 0000000..a14846f --- /dev/null +++ b/Algowars.Infrastructure/Persistence/Configuration/LanguageVersionConfiguration.cs @@ -0,0 +1,43 @@ +using Algowars.Domain.Languages.Entities; +using Algowars.Domain.Languages.ValueObjects; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Algowars.Infrastructure.Persistence.Configuration; + +internal sealed class LanguageVersionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("language_versions"); + + builder.HasKey(v => v.Id); + + builder.Property(v => v.Id) + .HasColumnName("id"); + + builder.Property("language_id") + .HasColumnName("language_id") + .IsRequired(); + + builder.Property(v => v.Version) + .HasColumnName("version") + .HasMaxLength(LanguageVersion.MaxLength) + .IsRequired() + .HasConversion( + v => v.Value, + v => new LanguageVersion(v)); + + builder.Property(v => v.Judge0Id) + .HasColumnName("judge0_id") + .IsRequired() + .HasConversion( + j => j.Value, + v => new Judge0Id(v)); + + builder.Property(v => v.Status) + .HasColumnName("status") + .HasConversion() + .IsRequired(); + } +} diff --git a/Algowars.Infrastructure/Persistence/Configuration/ProblemConfiguration.cs b/Algowars.Infrastructure/Persistence/Configuration/ProblemConfiguration.cs new file mode 100644 index 0000000..995e3df --- /dev/null +++ b/Algowars.Infrastructure/Persistence/Configuration/ProblemConfiguration.cs @@ -0,0 +1,93 @@ +using Algowars.Domain.Problems.Entities; +using Algowars.Domain.Problems.ValueObjects; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Algowars.Infrastructure.Persistence.Configuration; + +internal sealed class ProblemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("problems"); + + builder.HasKey(p => p.Id); + + builder.Property(p => p.Id) + .HasColumnName("id"); + + builder.OwnsOne(p => p.Slug, slug => + { + slug.Property(s => s.Value) + .HasColumnName("slug") + .HasMaxLength(Slug.MaxLength) + .IsRequired(); + }); + + builder.Property(p => p.Title) + .HasColumnName("title") + .HasMaxLength(Title.MaxLength) + .IsRequired() + .HasConversion( + t => t.Value, + v => new Title(v)); + + builder.Property(p => p.Question) + .HasColumnName("question") + .HasMaxLength(Question.MaxLength) + .IsRequired() + .HasConversion( + q => q.Value, + v => new Question(v)); + + builder.Property(p => p.Difficulty) + .HasColumnName("difficulty") + .IsRequired() + .HasConversion( + d => d.Value, + v => new Difficulty(v)); + + builder.Property(p => p.TimeLimit) + .HasColumnName("time_limit") + .IsRequired() + .HasConversion( + t => t.Milliseconds, + v => new TimeLimit(v)); + + builder.Property(p => p.MemoryLimit) + .HasColumnName("memory_limit") + .IsRequired() + .HasConversion( + m => m.Megabytes, + v => new MemoryLimit(v)); + + builder.Property(p => p.Status) + .HasColumnName("status") + .HasConversion() + .IsRequired(); + + builder.Property(p => p.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + builder.HasMany(p => p.History) + .WithOne() + .HasForeignKey("problem_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(p => p.History) + .HasField("_history") + .UsePropertyAccessMode(PropertyAccessMode.Field); + + builder.HasMany(p => p.Setups) + .WithOne() + .HasForeignKey("problem_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(p => p.Setups) + .HasField("_setups") + .UsePropertyAccessMode(PropertyAccessMode.Field); + } +} diff --git a/Algowars.Infrastructure/Persistence/Configuration/ProblemHistoryConfiguration.cs b/Algowars.Infrastructure/Persistence/Configuration/ProblemHistoryConfiguration.cs new file mode 100644 index 0000000..e4641cb --- /dev/null +++ b/Algowars.Infrastructure/Persistence/Configuration/ProblemHistoryConfiguration.cs @@ -0,0 +1,64 @@ +using Algowars.Domain.Problems.Entities; +using Algowars.Domain.Problems.ValueObjects; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Algowars.Infrastructure.Persistence.Configuration; + +internal sealed class ProblemHistoryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("problem_history"); + + builder.HasKey(h => h.Id); + + builder.Property(h => h.Id) + .HasColumnName("id"); + + builder.Property("problem_id") + .HasColumnName("problem_id") + .IsRequired(); + + builder.Property(h => h.Title) + .HasColumnName("title") + .HasMaxLength(Title.MaxLength) + .IsRequired() + .HasConversion( + t => t.Value, + v => new Title(v)); + + builder.Property(h => h.Question) + .HasColumnName("question") + .HasMaxLength(Question.MaxLength) + .IsRequired() + .HasConversion( + q => q.Value, + v => new Question(v)); + + builder.Property(h => h.Difficulty) + .HasColumnName("difficulty") + .IsRequired() + .HasConversion( + d => d.Value, + v => new Difficulty(v)); + + builder.Property(h => h.TimeLimit) + .HasColumnName("time_limit") + .IsRequired() + .HasConversion( + t => t.Milliseconds, + v => new TimeLimit(v)); + + builder.Property(h => h.MemoryLimit) + .HasColumnName("memory_limit") + .IsRequired() + .HasConversion( + m => m.Megabytes, + v => new MemoryLimit(v)); + + builder.Property(h => h.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + } +} diff --git a/Algowars.Infrastructure/Persistence/Configuration/ProblemSetupConfiguration.cs b/Algowars.Infrastructure/Persistence/Configuration/ProblemSetupConfiguration.cs new file mode 100644 index 0000000..234feb0 --- /dev/null +++ b/Algowars.Infrastructure/Persistence/Configuration/ProblemSetupConfiguration.cs @@ -0,0 +1,58 @@ +using Algowars.Domain.Problems.Entities; +using Algowars.Domain.TestSuites.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Algowars.Infrastructure.Persistence.Configuration; + +internal sealed class ProblemSetupConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("problem_setups"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .HasColumnName("id"); + + builder.Property("problem_id") + .HasColumnName("problem_id") + .IsRequired(); + + builder.Property(s => s.LanguageVersionId) + .HasColumnName("language_version_id") + .IsRequired(); + + builder.Property(s => s.InitialCode) + .HasColumnName("initial_code") + .IsRequired(); + + builder.Property(s => s.FunctionName) + .HasColumnName("function_name") + .HasMaxLength(200) + .IsRequired(); + + builder.HasMany(s => s.TestSuites) + .WithMany() + .UsingEntity>( + "problem_setup_test_suites", + r => r.HasOne() + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade), + l => l.HasOne() + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade), + j => + { + j.HasKey("problem_setup_id", "test_suite_id"); + j.ToTable("problem_setup_test_suites"); + }); + + builder.Navigation(s => s.TestSuites) + .HasField("_testSuites") + .UsePropertyAccessMode(PropertyAccessMode.Field); + } +} diff --git a/Algowars.Infrastructure/Persistence/Configuration/SubmissionConfiguration.cs b/Algowars.Infrastructure/Persistence/Configuration/SubmissionConfiguration.cs new file mode 100644 index 0000000..90080fd --- /dev/null +++ b/Algowars.Infrastructure/Persistence/Configuration/SubmissionConfiguration.cs @@ -0,0 +1,53 @@ +using Algowars.Domain.Submissions.Entities; +using Algowars.Domain.Submissions.ValueObjects; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Algowars.Infrastructure.Persistence.Configuration; + +internal sealed class SubmissionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("submissions"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .HasColumnName("id"); + + builder.Property(s => s.UserId) + .HasColumnName("user_id") + .IsRequired(); + + builder.Property(s => s.ProblemSetupId) + .HasColumnName("problem_setup_id") + .IsRequired(); + + builder.Property(s => s.Type) + .HasColumnName("type") + .IsRequired(); + + builder.Property(s => s.Status) + .HasColumnName("status") + .IsRequired(); + + builder.Property(s => s.SourceCode) + .HasConversion( + v => v.Value, + v => new SourceCode(v)) + .HasColumnName("source_code") + .HasMaxLength(SourceCode.MaxLength) + .IsRequired(); + + builder.HasMany(s => s.Results) + .WithOne() + .HasForeignKey("submission_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(s => s.Results) + .HasField("_results") + .UsePropertyAccessMode(PropertyAccessMode.Field); + } +} \ No newline at end of file diff --git a/Algowars.Infrastructure/Persistence/Configuration/SubmissionResultConfiguration.cs b/Algowars.Infrastructure/Persistence/Configuration/SubmissionResultConfiguration.cs new file mode 100644 index 0000000..a584414 --- /dev/null +++ b/Algowars.Infrastructure/Persistence/Configuration/SubmissionResultConfiguration.cs @@ -0,0 +1,57 @@ +using Algowars.Domain.Submissions.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Algowars.Infrastructure.Persistence.Configuration; + +internal sealed class SubmissionResultConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("submission_results"); + + builder.HasKey(r => r.Id); + + builder.Property(r => r.Id) + .HasColumnName("id"); + + builder.Property("submission_id") + .HasColumnName("submission_id") + .IsRequired(); + + builder.Property(r => r.TestCaseId) + .HasColumnName("test_case_id") + .IsRequired(); + + builder.Property(r => r.Status) + .HasColumnName("status") + .HasConversion() + .IsRequired(); + + builder.Property(r => r.Runtime) + .HasColumnName("runtime"); + + builder.Property(r => r.MemoryUsed) + .HasColumnName("memory_used"); + + builder.Property(r => r.ActualOutput) + .HasColumnName("actual_output"); + + builder.Property(r => r.StandardOutput) + .HasColumnName("standard_output"); + + builder.Property(r => r.StandardError) + .HasColumnName("standard_error"); + + builder.Property(r => r.CompileOutput) + .HasColumnName("compile_output"); + + builder.Property("execution_id") + .HasColumnName("execution_id") + .HasMaxLength(100); + + builder.Property("evaluation_id") + .HasColumnName("evaluation_id") + .HasMaxLength(100); + } +} diff --git a/Algowars.Infrastructure/Persistence/Configuration/TestCaseConfiguration.cs b/Algowars.Infrastructure/Persistence/Configuration/TestCaseConfiguration.cs new file mode 100644 index 0000000..64e5ee6 --- /dev/null +++ b/Algowars.Infrastructure/Persistence/Configuration/TestCaseConfiguration.cs @@ -0,0 +1,51 @@ +using Algowars.Domain.TestSuites.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Algowars.Infrastructure.Persistence.Configuration; + +internal sealed class TestCaseConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("test_cases"); + + builder.HasKey(t => t.Id); + + builder.Property(t => t.Id) + .HasColumnName("id"); + + builder.Property("test_suite_id") + .HasColumnName("test_suite_id") + .IsRequired(); + + builder.Property(t => t.Name) + .HasColumnName("name") + .HasMaxLength(200) + .IsRequired(); + + builder.Property(t => t.Description) + .HasColumnName("description") + .IsRequired(false); + + builder.HasMany(t => t.Inputs) + .WithOne() + .HasForeignKey("test_case_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(t => t.Inputs) + .HasField("_inputs") + .UsePropertyAccessMode(PropertyAccessMode.Field); + + builder.HasMany(t => t.ExpectedOutputs) + .WithOne() + .HasForeignKey("test_case_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(t => t.ExpectedOutputs) + .HasField("_expectedOutputs") + .UsePropertyAccessMode(PropertyAccessMode.Field); + } +} diff --git a/Algowars.Infrastructure/Persistence/Configuration/TestCaseExpectedOutputConfiguration.cs b/Algowars.Infrastructure/Persistence/Configuration/TestCaseExpectedOutputConfiguration.cs new file mode 100644 index 0000000..a17962b --- /dev/null +++ b/Algowars.Infrastructure/Persistence/Configuration/TestCaseExpectedOutputConfiguration.cs @@ -0,0 +1,31 @@ +using Algowars.Domain.TestSuites.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Algowars.Infrastructure.Persistence.Configuration; + +internal sealed class TestCaseExpectedOutputConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("test_case_expected_outputs"); + + builder.HasKey(o => o.Id); + + builder.Property(o => o.Id) + .HasColumnName("id"); + + builder.Property("test_case_id") + .HasColumnName("test_case_id") + .IsRequired(); + + builder.Property(o => o.Value) + .HasColumnName("value") + .IsRequired(); + + builder.Property(o => o.ValueType) + .HasColumnName("value_type") + .HasMaxLength(100) + .IsRequired(); + } +} diff --git a/Algowars.Infrastructure/Persistence/Configuration/TestCaseInputConfiguration.cs b/Algowars.Infrastructure/Persistence/Configuration/TestCaseInputConfiguration.cs new file mode 100644 index 0000000..a0dead9 --- /dev/null +++ b/Algowars.Infrastructure/Persistence/Configuration/TestCaseInputConfiguration.cs @@ -0,0 +1,31 @@ +using Algowars.Domain.TestSuites.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Algowars.Infrastructure.Persistence.Configuration; + +internal sealed class TestCaseInputConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("test_case_inputs"); + + builder.HasKey(i => i.Id); + + builder.Property(i => i.Id) + .HasColumnName("id"); + + builder.Property("test_case_id") + .HasColumnName("test_case_id") + .IsRequired(); + + builder.Property(i => i.Value) + .HasColumnName("value") + .IsRequired(); + + builder.Property(i => i.ValueType) + .HasColumnName("value_type") + .HasMaxLength(100) + .IsRequired(); + } +} diff --git a/Algowars.Infrastructure/Persistence/Configuration/TestSuiteConfiguration.cs b/Algowars.Infrastructure/Persistence/Configuration/TestSuiteConfiguration.cs new file mode 100644 index 0000000..760472e --- /dev/null +++ b/Algowars.Infrastructure/Persistence/Configuration/TestSuiteConfiguration.cs @@ -0,0 +1,42 @@ +using Algowars.Domain.TestSuites.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Algowars.Infrastructure.Persistence.Configuration; + +internal sealed class TestSuiteConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("test_suites"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .HasColumnName("id"); + + builder.Property(s => s.Name) + .HasColumnName("name") + .HasMaxLength(200) + .IsRequired(); + + builder.Property(s => s.Type) + .HasColumnName("type") + .HasConversion() + .IsRequired(); + + builder.Property(s => s.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + builder.HasMany(s => s.TestCases) + .WithOne() + .HasForeignKey("test_suite_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(s => s.TestCases) + .HasField("_testCases") + .UsePropertyAccessMode(PropertyAccessMode.Field); + } +} diff --git a/Algowars.Infrastructure/Persistence/Configuration/UserConfiguration.cs b/Algowars.Infrastructure/Persistence/Configuration/UserConfiguration.cs new file mode 100644 index 0000000..d39174d --- /dev/null +++ b/Algowars.Infrastructure/Persistence/Configuration/UserConfiguration.cs @@ -0,0 +1,55 @@ +using Algowars.Domain.Users.Entities; +using Algowars.Domain.Users.ValueObjects; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Algowars.Infrastructure.Persistence.Configuration; + +internal sealed class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("users"); + + builder.HasKey(u => u.Id); + + builder.Property(u => u.Id) + .HasColumnName("id"); + + builder.Property(u => u.Sub) + .HasColumnName("sub") + .HasMaxLength(255) + .IsRequired(); + + builder.HasIndex(u => u.Sub) + .IsUnique(); + + builder.Property(u => u.Username) + .HasColumnName("username") + .HasMaxLength(Username.MaxLength) + .IsRequired() + .HasConversion( + u => u.Value, + v => new Username(v)); + + builder.HasIndex(u => u.Username) + .IsUnique(); + + builder.Property(u => u.Bio) + .HasColumnName("bio") + .HasMaxLength(Bio.MaxLength) + .HasConversion( + b => b != null ? b.Value : null, + v => v != null ? new Bio(v) : null); + + builder.Property(u => u.ImageUrl) + .HasColumnName("image_url") + .HasMaxLength(ImageUrl.MaxLength) + .HasConversion( + i => i != null ? i.Value : null, + v => v != null ? new ImageUrl(v) : null); + + builder.Property(u => u.UsernameLastChangedAt) + .HasColumnName("username_last_changed_at"); + } +} \ No newline at end of file diff --git a/Algowars.Infrastructure/Persistence/Seeders/DemoDataSeeder.cs b/Algowars.Infrastructure/Persistence/Seeders/DemoDataSeeder.cs new file mode 100644 index 0000000..1d6f86e --- /dev/null +++ b/Algowars.Infrastructure/Persistence/Seeders/DemoDataSeeder.cs @@ -0,0 +1,114 @@ +using Algowars.Domain.Languages.Entities; +using Algowars.Domain.Languages.ValueObjects; +using Algowars.Domain.Problems.Entities; +using Algowars.Domain.Problems.ValueObjects; +using Algowars.Domain.TestSuites.Entities; +using Algowars.Domain.TestSuites.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Algowars.Infrastructure.Persistence.Seeders; + +internal sealed class DemoDataSeeder(AlgowarsDbContext context) : ISeeder +{ + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + if (await context.Problems.AnyAsync(cancellationToken)) + return; + + LanguageVersionEntry jsVersion = await context.Languages + .Where(l => l.Slug == new LanguageSlug("javascript")) + .SelectMany(l => l.Versions) + .FirstAsync(cancellationToken); + + LanguageVersionEntry tsVersion = await context.Languages + .Where(l => l.Slug == new LanguageSlug("typescript")) + .SelectMany(l => l.Versions) + .FirstAsync(cancellationToken); + + LanguageVersionEntry pyVersion = await context.Languages + .Where(l => l.Slug == new LanguageSlug("python")) + .SelectMany(l => l.Versions) + .FirstAsync(cancellationToken); + + Problem twoSum = new( + new Slug("two-sum"), + new Title("Two Sum"), + new Question("Given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target. You may assume that each input would have exactly one solution, and you may not use the same element twice."), + new Difficulty(500), + new TimeLimit(1000), + new MemoryLimit(64)); + + twoSum.Publish(); + + ProblemSetup jsSetup = twoSum.AddSetup( + jsVersion.Id, + "function twoSum(nums, target) {\n \n}", + "twoSum"); + + ProblemSetup tsSetup = twoSum.AddSetup( + tsVersion.Id, + "function twoSum(nums: number[], target: number): number[] {\n \n}", + "twoSum"); + + ProblemSetup pySetup = twoSum.AddSetup( + pyVersion.Id, + "def two_sum(nums: list[int], target: int) -> list[int]:\n pass", + "two_sum"); + + context.Problems.Add(twoSum); + await context.SaveChangesAsync(cancellationToken); + + TestSuite sampleSuite = BuildTwoSumSampleSuite(); + TestSuite hiddenSuite = BuildTwoSumHiddenSuite(); + + context.TestSuites.AddRange(sampleSuite, hiddenSuite); + await context.SaveChangesAsync(cancellationToken); + + Guid[] setupIds = [jsSetup.Id, tsSetup.Id, pySetup.Id]; + foreach (Guid id in setupIds) + { + ProblemSetup setup = await context.Set() + .Include(s => s.TestSuites) + .FirstAsync(s => s.Id == id, cancellationToken); + + ((List)setup.TestSuites).Add(sampleSuite); + ((List)setup.TestSuites).Add(hiddenSuite); + } + + await context.SaveChangesAsync(cancellationToken); + } + + private static TestSuite BuildTwoSumSampleSuite() + { + TestSuite suite = new("Two Sum - Sample Cases", TestSuiteType.Sample); + + TestCase case1 = suite.AddTestCase("Example 1"); + case1.AddInput("[2,7,11,15]", "integer_array"); + case1.AddInput("9", "integer"); + case1.AddExpectedOutput("[0,1]", "integer_array"); + + TestCase case2 = suite.AddTestCase("Example 2"); + case2.AddInput("[3,2,4]", "integer_array"); + case2.AddInput("6", "integer"); + case2.AddExpectedOutput("[1,2]", "integer_array"); + + return suite; + } + + private static TestSuite BuildTwoSumHiddenSuite() + { + TestSuite suite = new("Two Sum - Hidden Cases", TestSuiteType.Hidden); + + TestCase case1 = suite.AddTestCase("Hidden 1"); + case1.AddInput("[1,2,3,4,5]", "integer_array"); + case1.AddInput("9", "integer"); + case1.AddExpectedOutput("[3,4]", "integer_array"); + + TestCase case2 = suite.AddTestCase("Hidden 2"); + case2.AddInput("[0,4,3,0]", "integer_array"); + case2.AddInput("0", "integer"); + case2.AddExpectedOutput("[0,3]", "integer_array"); + + return suite; + } +} diff --git a/Algowars.Infrastructure/Persistence/Seeders/ISeeder.cs b/Algowars.Infrastructure/Persistence/Seeders/ISeeder.cs new file mode 100644 index 0000000..a9f4906 --- /dev/null +++ b/Algowars.Infrastructure/Persistence/Seeders/ISeeder.cs @@ -0,0 +1,6 @@ +namespace Algowars.Infrastructure.Persistence.Seeders; + +internal interface ISeeder +{ + Task SeedAsync(CancellationToken cancellationToken = default); +} diff --git a/Algowars.Infrastructure/Persistence/Seeders/LanguageSeeder.cs b/Algowars.Infrastructure/Persistence/Seeders/LanguageSeeder.cs new file mode 100644 index 0000000..c47fa6c --- /dev/null +++ b/Algowars.Infrastructure/Persistence/Seeders/LanguageSeeder.cs @@ -0,0 +1,26 @@ +using Algowars.Domain.Languages.Entities; +using Algowars.Domain.Languages.ValueObjects; +using Microsoft.EntityFrameworkCore; + +namespace Algowars.Infrastructure.Persistence.Seeders; + +internal sealed class LanguageSeeder(AlgowarsDbContext context) : ISeeder +{ + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + if (await context.Languages.AnyAsync(cancellationToken)) + return; + + Language javascript = new(new LanguageName("JavaScript"), new LanguageSlug("javascript")); + javascript.AddVersion(new LanguageVersion("Node.js 22.08.0"), new Judge0Id(102)); + + Language typescript = new(new LanguageName("TypeScript"), new LanguageSlug("typescript")); + typescript.AddVersion(new LanguageVersion("5.6.2"), new Judge0Id(101)); + + Language python = new(new LanguageName("Python"), new LanguageSlug("python")); + python.AddVersion(new LanguageVersion("3.13.2"), new Judge0Id(109)); + + context.Languages.AddRange(javascript, typescript, python); + await context.SaveChangesAsync(cancellationToken); + } +} diff --git a/Algowars.Infrastructure/Persistence/Seeders/SeederOptions.cs b/Algowars.Infrastructure/Persistence/Seeders/SeederOptions.cs new file mode 100644 index 0000000..a572287 --- /dev/null +++ b/Algowars.Infrastructure/Persistence/Seeders/SeederOptions.cs @@ -0,0 +1,16 @@ +namespace Algowars.Infrastructure.Persistence.Seeders; + +public sealed class SeederOptions +{ + /// + /// Seeds reference data that never changes (languages and their versions). + /// Safe to run in any environment. + /// + public bool SeedStaticData { get; init; } + + /// + /// Seeds demo/sample data (example problems, test suites). + /// Intended for development and staging environments only. + /// + public bool SeedDemoData { get; init; } +} diff --git a/Algowars.Infrastructure/Repositories/LanguageReadRepository.cs b/Algowars.Infrastructure/Repositories/LanguageReadRepository.cs new file mode 100644 index 0000000..2ce1864 --- /dev/null +++ b/Algowars.Infrastructure/Repositories/LanguageReadRepository.cs @@ -0,0 +1,17 @@ +using Algowars.Application.Languages; +using Algowars.Domain.Languages.Entities; +using Algowars.Domain.Languages.Enums; +using Algowars.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Algowars.Infrastructure.Repositories; + +internal sealed class LanguageReadRepository(AlgowarsDbContext context) : ILanguageReadRepository +{ + public async Task> FindLanguagesByVersionId(IEnumerable versionIds, CancellationToken cancellationToken) + => await context.Languages + .AsNoTracking() + .Include(l => l.Versions) + .Where(l => l.Versions.Any(v => versionIds.Contains(v.Id))) + .ToListAsync(cancellationToken); +} diff --git a/Algowars.Infrastructure/Repositories/ProblemReadRepository.cs b/Algowars.Infrastructure/Repositories/ProblemReadRepository.cs new file mode 100644 index 0000000..7c84f8b --- /dev/null +++ b/Algowars.Infrastructure/Repositories/ProblemReadRepository.cs @@ -0,0 +1,59 @@ +using Algowars.Application.Pagination; +using Algowars.Application.Problems; +using Algowars.Application.Problems.Dtos; +using Algowars.Domain.Problems.Entities; +using Algowars.Domain.Problems.Enums; +using Algowars.Domain.Problems.ValueObjects; +using Algowars.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Algowars.Infrastructure.Repositories; + +internal sealed class ProblemReadRepository(AlgowarsDbContext context) : IProblemReadRepository +{ + public async Task FindBySlugAsync(string slug, CancellationToken cancellationToken) + => await context.Problems.AsNoTracking().Include(problem => problem.Setups).Where(problem => problem.Slug.Value == slug).SingleOrDefaultAsync(cancellationToken); + + public async Task> GetPagedAsync(PaginationRequest pagination, CancellationToken cancellationToken = default) + { + int offset = (pagination.Page - 1) * pagination.Size; + + var query = context.Problems + .AsNoTracking() + .Where(p => p.Status == ProblemStatus.Published); + + int total = await query.CountAsync(cancellationToken); + + var items = await query + .OrderBy(p => p.Id) + .Skip(offset) + .Take(pagination.Size) + .Select(p => new + { + p.Id, + Slug = p.Slug.Value, + Title = p.Title.Value, + DifficultyValue = p.Difficulty.Value, + p.Status + }) + .ToListAsync(cancellationToken); + + return new PageResult + { + Results = [.. items.Select(x => new ProblemDto( + x.Id, + x.Slug, + x.Title, + x.DifficultyValue <= Difficulty.EasyMax + ? DifficultyTier.Easy + : x.DifficultyValue <= Difficulty.MediumMax + ? DifficultyTier.Medium + : DifficultyTier.Hard, + x.Status + ))], + Total = total, + Page = pagination.Page, + Size = pagination.Size + }; + } +} diff --git a/Algowars.Infrastructure/Repositories/SubmissionWriteRepository.cs b/Algowars.Infrastructure/Repositories/SubmissionWriteRepository.cs new file mode 100644 index 0000000..d10362b --- /dev/null +++ b/Algowars.Infrastructure/Repositories/SubmissionWriteRepository.cs @@ -0,0 +1,26 @@ +using Algowars.Domain.Submissions; +using Algowars.Domain.Submissions.Entities; +using Algowars.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Algowars.Infrastructure.Repositories; + +internal sealed class SubmissionWriteRepository(AlgowarsDbContext context) : ISubmissionWriteRepository +{ + public async Task AddAsync(Submission entity, CancellationToken cancellationToken = default) + { + await context.Submissions.AddAsync(entity, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await context.Submissions.FirstOrDefaultAsync(s => s.Id == id, cancellationToken); + } + + public async Task UpdateAsync(Submission entity, CancellationToken cancellationToken = default) + { + context.Submissions.Update(entity); + await context.SaveChangesAsync(cancellationToken); + } +} diff --git a/Algowars.Infrastructure/Repositories/TestSuiteWriteRepository.cs b/Algowars.Infrastructure/Repositories/TestSuiteWriteRepository.cs new file mode 100644 index 0000000..0c7c09f --- /dev/null +++ b/Algowars.Infrastructure/Repositories/TestSuiteWriteRepository.cs @@ -0,0 +1,42 @@ +using Algowars.Domain.TestSuites; +using Algowars.Domain.TestSuites.Entities; +using Algowars.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Algowars.Infrastructure.Repositories; + +internal sealed class TestSuiteWriteRepository(AlgowarsDbContext context) : ITestSuiteWriteRepository +{ + public async Task AddAsync(TestSuite testSuite, CancellationToken cancellationToken = default) + { + await context.TestSuites.AddAsync(testSuite, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateAsync(TestSuite testSuite, CancellationToken cancellationToken = default) + { + context.TestSuites.Update(testSuite); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await context.TestSuites + .Include(s => s.TestCases) + .FirstOrDefaultAsync(s => s.Id == id, cancellationToken); + } + + public async Task> FindTestCaseIdsByProblemSetupIdAsync( + Guid problemSetupId, + CancellationToken cancellationToken = default) + { + return await context.Problems + .AsNoTracking() + .SelectMany(p => p.Setups) + .Where(s => s.Id == problemSetupId) + .SelectMany(s => s.TestSuites) + .SelectMany(ts => ts.TestCases) + .Select(tc => tc.Id) + .ToListAsync(cancellationToken); + } +} diff --git a/Algowars.Infrastructure/Repositories/UserReadRepository.cs b/Algowars.Infrastructure/Repositories/UserReadRepository.cs new file mode 100644 index 0000000..ad4db03 --- /dev/null +++ b/Algowars.Infrastructure/Repositories/UserReadRepository.cs @@ -0,0 +1,25 @@ +using Algowars.Application.Users; +using Algowars.Application.Users.Dtos; +using Algowars.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Algowars.Infrastructure.Repositories; + +internal sealed class UserReadRepository(AlgowarsDbContext context) : IUserReadRepository +{ + public async Task FindBySubAsync(string sub, CancellationToken cancellationToken) + { + return await context.Users + .Where(u => u.Sub == sub) + .Select(u => new UserDto(u.Id, u.Sub, u.Username.Value, u.ImageUrl != null ? u.ImageUrl.Value : null, UsernameLastChangedAt: u.UsernameLastChangedAt)) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken) + { + return await context.Users + .Where(u => u.Id == id) + .Select(u => new UserDto(u.Id, u.Sub, u.Username.Value, u.ImageUrl != null ? u.ImageUrl.Value : null, UsernameLastChangedAt: u.UsernameLastChangedAt)) + .FirstOrDefaultAsync(cancellationToken); + } +} diff --git a/Algowars.Infrastructure/Repositories/UserWriteRepository.cs b/Algowars.Infrastructure/Repositories/UserWriteRepository.cs new file mode 100644 index 0000000..91099ff --- /dev/null +++ b/Algowars.Infrastructure/Repositories/UserWriteRepository.cs @@ -0,0 +1,38 @@ +using Algowars.Domain.Users; +using Algowars.Domain.Users.Entities; +using Algowars.Domain.Users.ValueObjects; +using Algowars.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Algowars.Infrastructure.Repositories; + +internal sealed class UserWriteRepository(AlgowarsDbContext context) : IUserWriteRepository +{ + public async Task AddAsync(User user, CancellationToken cancellationToken) + { + await context.Users.AddAsync(user, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken) + { + return await context.Users.FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + } + + public async Task FindBySubAsync(string sub, CancellationToken cancellationToken) + { + return await context.Users.FirstOrDefaultAsync(u => u.Sub == sub, cancellationToken); + } + + public async Task UpdateAsync(User user, CancellationToken cancellationToken) + { + context.Users.Update(user); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task FindByUsername(Username username, CancellationToken cancellationToken) + { + return await context.Users.FirstOrDefaultAsync(u => u.Username == username, cancellationToken); + } +} + diff --git a/Algowars.Infrastructure/Settings/OptionExtensions.cs b/Algowars.Infrastructure/Settings/OptionExtensions.cs new file mode 100644 index 0000000..031f3e0 --- /dev/null +++ b/Algowars.Infrastructure/Settings/OptionExtensions.cs @@ -0,0 +1,21 @@ +using Algowars.Application.Settings; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Algowars.Infrastructure.Settings; + +public static class OptionExtensions +{ + public static IServiceCollection AddOption( + this IServiceCollection services, + IConfiguration configuration) + where T : class, IOption + { + var instance = configuration.GetSection(T.SectionName).Get() + ?? throw new InvalidOperationException( + $"Configuration section '{T.SectionName}' is missing or invalid."); + + services.AddSingleton(instance); + return services; + } +} \ No newline at end of file diff --git a/Algowars.Seeder/Algowars.Seeder.csproj b/Algowars.Seeder/Algowars.Seeder.csproj new file mode 100644 index 0000000..8ffcef6 --- /dev/null +++ b/Algowars.Seeder/Algowars.Seeder.csproj @@ -0,0 +1,26 @@ + + + + Exe + net10.0 + enable + enable + f1204ecb-b2c7-4ab2-a772-a27e6871af93 + + + + + + + + + + + + + + + + + + diff --git a/Algowars.Seeder/Program.cs b/Algowars.Seeder/Program.cs new file mode 100644 index 0000000..2678985 --- /dev/null +++ b/Algowars.Seeder/Program.cs @@ -0,0 +1,39 @@ +using Algowars.Infrastructure; +using Algowars.Infrastructure.Persistence.Seeders; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +bool seedStatic = args.Contains("--static") || args.Contains("--all"); +bool seedDemo = args.Contains("--demo") || args.Contains("--all"); + +if (!seedStatic && !seedDemo) +{ + Console.WriteLine("Usage: dotnet run --project Algowars.Seeder -- [--static] [--demo] [--all]"); + Console.WriteLine(); + Console.WriteLine(" --static Seed reference data (languages and versions)"); + Console.WriteLine(" --demo Seed demo data (example problems and test suites)"); + Console.WriteLine(" --all Seed everything"); + return 1; +} + +IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: true) + .AddUserSecrets(optional: true) + .AddEnvironmentVariables() + .Build(); + +ServiceCollection services = new(); +services.AddInfrastructure(configuration); + +await using ServiceProvider provider = services.BuildServiceProvider(); +IServiceProvider sp = provider; + +await sp.MigrateAsync(); +await sp.SeedAsync(new SeederOptions +{ + SeedStaticData = seedStatic, + SeedDemoData = seedDemo +}); + +Console.WriteLine("Done."); +return 0; diff --git a/Algowars.Seeder/appsettings.json b/Algowars.Seeder/appsettings.json new file mode 100644 index 0000000..7f07c90 --- /dev/null +++ b/Algowars.Seeder/appsettings.json @@ -0,0 +1,5 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "" + } +} diff --git a/Algowars.UnitTests/Algowars.UnitTests.csproj b/Algowars.UnitTests/Algowars.UnitTests.csproj new file mode 100644 index 0000000..1cb0eda --- /dev/null +++ b/Algowars.UnitTests/Algowars.UnitTests.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + latest + enable + enable + false + + + + + + + + + + + + + + + diff --git a/Algowars.slnx b/Algowars.slnx deleted file mode 100644 index 4d6aaf5..0000000 --- a/Algowars.slnx +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..03322b2 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,44 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index 261eeb9..0000000 --- a/LICENSE.txt +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/README.md b/README.md index 9f54250..d807fb8 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,52 @@ # Algowars Server -[![ci](https://github.com/algowars/server/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/algowars/server/actions/workflows/ci.yml) -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=algowars_server&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=algowars_server) -[![codecov](https://codecov.io/gh/algowars/server/branch/master/graph/badge.svg)](https://codecov.io/gh/algowars/server) +Backend for Algowars, a competitive programming platform. Built with .NET. -## Local user secrets setup +## Requirements -The scripts below populate user secrets for `src/PublicApi/PublicApi.csproj`. -CORS is configured in `src/PublicApi/appsettings.Development.json`. +- .NET 10 SDK +- docker +- dotnet-ef CLI -### PowerShell (Windows) +``` +dotnet tool install --global dotnet-ef +``` -From the repository root: +### Getting started -```powershell -./scripts/setup-user-secrets.ps1 +To get started with the project. You need a postgresql server and a message broker. You can use the docker compose file to spin this up. To do so run the command: + +``` +docker-compose up ``` -### Bash (Linux/macOS/Git Bash) +### Migrations -From the repository root: +Migrations are managed in `Algowars.Infrastructure`. To add a new migration: + +``` +dotnet ef migrations add --project Algowars.Infrastructure --startup-project Algowars.Api +``` -```bash -./scripts/setup-user-secrets.sh +To apply migrations against the local database (requires Aspire to be running): + +``` +dotnet ef database update --project Algowars.Infrastructure --startup-project Algowars.Api +``` + +To apply migrations against an external database: + +``` +dotnet ef database update --project Algowars.Infrastructure --connection "" ``` -### Prompt behavior +### Project structure -- Press `Enter` to use the shown default value. -- Type `skip` to leave a key unchanged. -- Set `MessageBus:Transport` to `RabbitMQ` or `AzureServiceBus`. - - `RabbitMQ`: prompts only RabbitMQ settings. - - `AzureServiceBus`: prompts only Azure Service Bus connection string. +| Project | Description | +| -------------------------- | ----------------------------------- | +| `Algowars.AppHost` | Aspire orchestration | +| `Algowars.Api` | HTTP API | +| `Algowars.Application` | Application logic and handlers | +| `Algowars.Domain` | Domain models | +| `Algowars.Infrastructure` | EF Core, repositories, persistence | +| `Algowars.ServiceDefaults` | Shared Aspire service configuration | diff --git a/Server.slnx b/Server.slnx new file mode 100644 index 0000000..29073d2 --- /dev/null +++ b/Server.slnx @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/aspire.config.json b/aspire.config.json new file mode 100644 index 0000000..38a0f70 --- /dev/null +++ b/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "language": "csharp" + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 918a191..54eeace 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,38 +1,38 @@ services: - rabbitmq: - image: rabbitmq:3-management - container_name: algowars-rabbitmq + postgres: + image: postgres:17 + container_name: aw-pg ports: - - "5672:5672" # AMQP - - "15672:15672" # Management UI (http://localhost:15672, guest/guest) + - "5432:5432" environment: - RABBITMQ_DEFAULT_USER: guest - RABBITMQ_DEFAULT_PASS: guest + POSTGRES_USER: myuser + POSTGRES_PASSWORD: mypassword + POSTGRES_DB: algowars volumes: - - rabbitmq_data:/var/lib/rabbitmq + - pgdata:/var/lib/postgresql/data healthcheck: - test: ["CMD", "rabbitmq-diagnostics", "ping"] + test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s timeout: 5s retries: 5 - postgres: - image: postgres:17 - container_name: algowars-postgres + rabbitmq: + image: rabbitmq:4-management + container_name: aw-rabbitmq ports: - - "5432:5432" + - "5672:5672" + - "15672:15672" environment: - POSTGRES_USER: myuser - POSTGRES_PASSWORD: mypassword - POSTGRES_DB: algowars + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest volumes: - - postgres_data:/var/lib/postgresql/data + - rabbitmqdata:/var/lib/rabbitmq healthcheck: - test: ["CMD-SHELL", "pg_isready -U myuser"] + test: ["CMD", "rabbitmq-diagnostics", "ping"] interval: 10s timeout: 5s retries: 5 volumes: - rabbitmq_data: - postgres_data: + pgdata: + rabbitmqdata: diff --git a/global.json b/global.json deleted file mode 100644 index f72210c..0000000 --- a/global.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "sdk": { - "version": "10.0.100", - "rollForward": "latestFeature" - } -} \ No newline at end of file diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..9e0277e --- /dev/null +++ b/nuget.config @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/scripts/setup-user-secrets.ps1 b/scripts/setup-user-secrets.ps1 index b0a8ed9..e39aee8 100644 --- a/scripts/setup-user-secrets.ps1 +++ b/scripts/setup-user-secrets.ps1 @@ -2,7 +2,7 @@ $ErrorActionPreference = 'Stop' $scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path $repoRoot = Split-Path -Parent $scriptRoot -$projectPath = Join-Path $repoRoot 'src/PublicApi/PublicApi.csproj' +$projectPath = Join-Path $repoRoot 'Algowars.Api/Algowars.Api.csproj' $skipToken = '__SKIP__' if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { @@ -122,4 +122,4 @@ else { } Write-Host '' -Write-Host 'Done. User secrets have been applied to PublicApi.' -ForegroundColor Cyan +Write-Host 'Done. User secrets have been applied to PublicApi.' -ForegroundColor Cyan \ No newline at end of file diff --git a/scripts/setup-user-secrets.sh b/scripts/setup-user-secrets.sh index 7472b19..afa1837 100644 --- a/scripts/setup-user-secrets.sh +++ b/scripts/setup-user-secrets.sh @@ -148,4 +148,4 @@ else fi echo -echo "Done. User secrets have been applied to PublicApi." +echo "Done. User secrets have been applied to PublicApi." \ No newline at end of file diff --git a/src/ApplicationCore/ApplicationCore.csproj b/src/ApplicationCore/ApplicationCore.csproj deleted file mode 100644 index 51b41c7..0000000 --- a/src/ApplicationCore/ApplicationCore.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - net10.0 - enable - enable - - - - - - - - - - - diff --git a/src/ApplicationCore/Commands/Accounts/CreateAccount/CreateAccountCommand.cs b/src/ApplicationCore/Commands/Accounts/CreateAccount/CreateAccountCommand.cs deleted file mode 100644 index 914b48d..0000000 --- a/src/ApplicationCore/Commands/Accounts/CreateAccount/CreateAccountCommand.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace ApplicationCore.Commands.Accounts.CreateAccount; - -public sealed record CreateAccountCommand(string Username, string Sub, string ImageUrl) - : ICommand; \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Accounts/CreateAccount/CreateAccountHandler.cs b/src/ApplicationCore/Commands/Accounts/CreateAccount/CreateAccountHandler.cs deleted file mode 100644 index 3bf3ab4..0000000 --- a/src/ApplicationCore/Commands/Accounts/CreateAccount/CreateAccountHandler.cs +++ /dev/null @@ -1,64 +0,0 @@ -using ApplicationCore.Domain.Accounts; -using ApplicationCore.Interfaces.Repositories; -using ApplicationCore.Logging; -using Ardalis.Result; -using FluentValidation; -using Microsoft.Extensions.Logging; - -namespace ApplicationCore.Commands.Accounts.CreateAccount; - -public sealed partial class CreateAccountHandler( - IAccountRepository accounts, - ILogger logger, - IValidator validator -) : AbstractCommandHandler(validator) -{ - protected override async Task> HandleValidated( - CreateAccountCommand request, - CancellationToken cancellationToken - ) - { - var id = Guid.NewGuid(); - - var account = new AccountModel - { - Id = id, - Username = request.Username, - Sub = request.Sub, - ImageUrl = request.ImageUrl, - LastModifiedById = null, - }; - - try - { - await accounts.AddAsync(account, cancellationToken); - } - catch (Exception ex) - { - LogCreateFailed(logger, request.Username, request.Sub, ex.Message); - return Result.Error("Unexpected error creating account."); - } - - LogCreated(logger, id, request.Sub); - return Result.Success(id); - } - - [LoggerMessage( - EventId = LoggingEventIds.Accounts.Created, - Level = LogLevel.Information, - Message = "Created account {accountId} for sub {sub}" - )] - private static partial void LogCreated(ILogger logger, Guid accountId, string sub); - - [LoggerMessage( - EventId = LoggingEventIds.Accounts.CreateFailed, - Level = LogLevel.Error, - Message = "Failed to create account for {username}/{sub}. DB message: {dbMessage}" - )] - private static partial void LogCreateFailed( - ILogger logger, - string username, - string sub, - string dbMessage - ); -} \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Accounts/CreateAccount/CreateAccountValidator.cs b/src/ApplicationCore/Commands/Accounts/CreateAccount/CreateAccountValidator.cs deleted file mode 100644 index c9b7900..0000000 --- a/src/ApplicationCore/Commands/Accounts/CreateAccount/CreateAccountValidator.cs +++ /dev/null @@ -1,44 +0,0 @@ -using ApplicationCore.Interfaces.Repositories; -using FluentValidation; - -namespace ApplicationCore.Commands.Accounts.CreateAccount; - -public sealed class CreateAccountValidator : AbstractValidator -{ - public CreateAccountValidator(IAccountRepository accounts) - { - RuleFor(x => x.Username) - .NotEmpty() - .MaximumLength(16) - .Must(IsValidUsername) - .WithMessage("Username contains invalid characters") - .MustAsync( - async (username, ct) => await accounts.GetByUsernameAsync(username, ct) is null - ) - .WithMessage("Username already exists"); - - RuleFor(x => x.Sub) - .NotEmpty() - .MustAsync(async (sub, ct) => await accounts.GetBySubAsync(sub, ct) is null) - .WithMessage("Account already exists"); - - RuleFor(x => x.ImageUrl) - .Must(IsValidUrl) - .When(x => !string.IsNullOrWhiteSpace(x.ImageUrl)) - .WithMessage("ImageUrl must be a valid URL"); - - RuleFor(x => x) - .MustAsync( - async (cmd, ct) => - await accounts.GetByUsernameOrSubAsync(cmd.Username, cmd.Sub, ct) is null - ) - .WithMessage("Username already exists"); - } - - private static bool IsValidUsername(string username) => - username.All(c => char.IsLetterOrDigit(c) || c is '_' or '-'); - - private static bool IsValidUrl(string url) => - Uri.TryCreate(url, UriKind.Absolute, out var uri) - && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); -} \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Accounts/UpdateProfileSettings/UpdateProfileSettingsCommand.cs b/src/ApplicationCore/Commands/Accounts/UpdateProfileSettings/UpdateProfileSettingsCommand.cs deleted file mode 100644 index 552b7c8..0000000 --- a/src/ApplicationCore/Commands/Accounts/UpdateProfileSettings/UpdateProfileSettingsCommand.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace ApplicationCore.Commands.Accounts.UpdateProfileSettings; - -public sealed record UpdateProfileSettingsCommand(Guid AccountId, string? Bio) : ICommand; - -public sealed record UpdateProfileSettingsResult(string? Bio); \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Accounts/UpdateProfileSettings/UpdateProfileSettingsHandler.cs b/src/ApplicationCore/Commands/Accounts/UpdateProfileSettings/UpdateProfileSettingsHandler.cs deleted file mode 100644 index 371ad46..0000000 --- a/src/ApplicationCore/Commands/Accounts/UpdateProfileSettings/UpdateProfileSettingsHandler.cs +++ /dev/null @@ -1,28 +0,0 @@ -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; -using FluentValidation; - -namespace ApplicationCore.Commands.Accounts.UpdateProfileSettings; - -public sealed class UpdateProfileSettingsHandler( - IAccountRepository accounts, - IValidator validator -) : AbstractCommandHandler(validator) -{ - protected override async Task> HandleValidated( - UpdateProfileSettingsCommand request, - CancellationToken cancellationToken - ) - { - var account = await accounts.GetByIdAsync(request.AccountId, cancellationToken); - - if (account is null) - { - return Result.NotFound(); - } - - await accounts.UpdateAboutAsync(account.Id, request.Bio, cancellationToken); - - return Result.Success(new UpdateProfileSettingsResult(request.Bio)); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Accounts/UpdateProfileSettings/UpdateProfileSettingsValidator.cs b/src/ApplicationCore/Commands/Accounts/UpdateProfileSettings/UpdateProfileSettingsValidator.cs deleted file mode 100644 index fa00ef1..0000000 --- a/src/ApplicationCore/Commands/Accounts/UpdateProfileSettings/UpdateProfileSettingsValidator.cs +++ /dev/null @@ -1,16 +0,0 @@ -using FluentValidation; - -namespace ApplicationCore.Commands.Accounts.UpdateProfileSettings; - -public sealed class UpdateProfileSettingsValidator : AbstractValidator -{ - public UpdateProfileSettingsValidator() - { - RuleFor(x => x.AccountId) - .NotEmpty(); - - RuleFor(x => x.Bio) - .MaximumLength(255) - .WithName("Bio"); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Accounts/UpdateUsername/UpdateUsernameCommand.cs b/src/ApplicationCore/Commands/Accounts/UpdateUsername/UpdateUsernameCommand.cs deleted file mode 100644 index 6384a5d..0000000 --- a/src/ApplicationCore/Commands/Accounts/UpdateUsername/UpdateUsernameCommand.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace ApplicationCore.Commands.Accounts.UpdateUsername; - -public sealed record UpdateUsernameCommand(Guid AccountId, string NewUsername, DateTime? UsernameLastChangedAt) : ICommand; - -public sealed record UpdateUsernameResult(Guid Id, string Username, DateTime UsernameLastChangedAt); \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Accounts/UpdateUsername/UpdateUsernameHandler.cs b/src/ApplicationCore/Commands/Accounts/UpdateUsername/UpdateUsernameHandler.cs deleted file mode 100644 index 59c1df3..0000000 --- a/src/ApplicationCore/Commands/Accounts/UpdateUsername/UpdateUsernameHandler.cs +++ /dev/null @@ -1,42 +0,0 @@ -using ApplicationCore.Domain.Accounts; -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; -using FluentValidation; - -namespace ApplicationCore.Commands.Accounts.UpdateUsername; - -public sealed class UpdateUsernameHandler( - IAccountRepository accounts, - IValidator validator -) : AbstractCommandHandler(validator) -{ - protected override async Task> HandleValidated( - UpdateUsernameCommand request, - CancellationToken cancellationToken - ) - { - AccountModel? account = await accounts.GetByIdAsync(request.AccountId, cancellationToken); - - if (account is null) - { - return Result.NotFound(); - } - - bool usernameTaken = await accounts.ExistsByUsernameAsync(request.NewUsername, cancellationToken); - - if (usernameTaken) - { - return Result.Invalid(new ValidationError("Username", "Username is already taken.")); - } - - account.ChangeUsername(request.NewUsername); - - await accounts.UpdateUsernameAsync(account.Id, account.Username, account.UsernameLastChangedAt!.Value, cancellationToken); - - return Result.Success(new UpdateUsernameResult( - account.Id, - account.Username, - account.UsernameLastChangedAt!.Value - )); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Accounts/UpdateUsername/UpdateUsernameValidator.cs b/src/ApplicationCore/Commands/Accounts/UpdateUsername/UpdateUsernameValidator.cs deleted file mode 100644 index d24e58a..0000000 --- a/src/ApplicationCore/Commands/Accounts/UpdateUsername/UpdateUsernameValidator.cs +++ /dev/null @@ -1,29 +0,0 @@ -using FluentValidation; - -namespace ApplicationCore.Commands.Accounts.UpdateUsername; - -public sealed class UpdateUsernameValidator : AbstractValidator -{ - private const int CooldownDays = 30; - - public UpdateUsernameValidator() - { - RuleFor(x => x.AccountId) - .NotEmpty(); - - RuleFor(x => x.NewUsername) - .NotEmpty() - .MinimumLength(3) - .MaximumLength(36) - .Matches(@"^[a-zA-Z0-9_-]+$") - .WithName("Username") - .WithMessage("Username may only contain letters, numbers, underscores, and hyphens."); - - RuleFor(x => x.UsernameLastChangedAt) - .Must(lastChanged => - lastChanged is null || - (DateTime.UtcNow - lastChanged.Value).TotalDays >= CooldownDays - ) - .WithMessage($"Username can only be changed once every {CooldownDays} days."); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Accounts/UpsertAccount/UpsertAccountCommand.cs b/src/ApplicationCore/Commands/Accounts/UpsertAccount/UpsertAccountCommand.cs deleted file mode 100644 index 748f14e..0000000 --- a/src/ApplicationCore/Commands/Accounts/UpsertAccount/UpsertAccountCommand.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace ApplicationCore.Commands.Accounts.UpsertAccount; - -public sealed record UpsertAccountCommand(string Sub, string? ImageUrl) : ICommand; - -public sealed record AccountUpsertResult(Guid Id, string Username, string? ImageUrl, DateTime CreatedOn); \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Accounts/UpsertAccount/UpsertAccountHandler.cs b/src/ApplicationCore/Commands/Accounts/UpsertAccount/UpsertAccountHandler.cs deleted file mode 100644 index 8522418..0000000 --- a/src/ApplicationCore/Commands/Accounts/UpsertAccount/UpsertAccountHandler.cs +++ /dev/null @@ -1,65 +0,0 @@ -using ApplicationCore.Domain.Accounts; -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; -using Bogus; -using FluentValidation; - -namespace ApplicationCore.Commands.Accounts.UpsertAccount; - -public sealed class UpsertAccountHandler( - IAccountRepository accounts, - IValidator validator -) : AbstractCommandHandler(validator) -{ - private static readonly Faker Faker = new(); - - protected override async Task> HandleValidated( - UpsertAccountCommand request, - CancellationToken cancellationToken - ) - { - AccountModel? existing = await accounts.GetBySubAsync(request.Sub, cancellationToken); - - if (existing is not null) - { - await accounts.UpdateImageUrlAsync(existing.Id, request.ImageUrl, cancellationToken); - - return Result.Success(new AccountUpsertResult( - existing.Id, - existing.Username, - request.ImageUrl, - existing.CreatedOn - )); - } - - Guid id = Guid.NewGuid(); - string username = await GenerateUniqueUsernameAsync(accounts, cancellationToken); - - AccountModel account = new() - { - Id = id, - Username = username, - Sub = request.Sub, - ImageUrl = request.ImageUrl, - LastModifiedById = null, - }; - - await accounts.AddAsync(account, cancellationToken); - - return Result.Success(new AccountUpsertResult(id, username, request.ImageUrl, DateTime.UtcNow)); - } - - private static async Task GenerateUniqueUsernameAsync( - IAccountRepository accounts, - CancellationToken cancellationToken - ) - { - string usernameBase = $"{Faker.Hacker.Adjective()}_{Faker.Hacker.Noun()}" - .ToLowerInvariant() - .Replace(" ", "_"); - - int count = await accounts.CountByUsernameBaseAsync(usernameBase, cancellationToken); - - return count == 0 ? usernameBase : $"{usernameBase}_{count}"; - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Accounts/UpsertAccount/UpsertAccountValidator.cs b/src/ApplicationCore/Commands/Accounts/UpsertAccount/UpsertAccountValidator.cs deleted file mode 100644 index 030b029..0000000 --- a/src/ApplicationCore/Commands/Accounts/UpsertAccount/UpsertAccountValidator.cs +++ /dev/null @@ -1,13 +0,0 @@ -using FluentValidation; - -namespace ApplicationCore.Commands.Accounts.UpsertAccount; - -public sealed class UpsertAccountValidator : AbstractValidator -{ - public UpsertAccountValidator() - { - RuleFor(x => x.Sub) - .NotEmpty() - .MaximumLength(255); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Submissions/CreateSubmission/CreateSubmissionCommand.cs b/src/ApplicationCore/Commands/Submissions/CreateSubmission/CreateSubmissionCommand.cs deleted file mode 100644 index 8f875dc..0000000 --- a/src/ApplicationCore/Commands/Submissions/CreateSubmission/CreateSubmissionCommand.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace ApplicationCore.Commands.Submissions.CreateSubmission; - -public sealed record CreateSubmissionCommand(int ProblemSetupId, string Code, Guid CreatedById) - : ICommand; \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Submissions/CreateSubmission/CreateSubmissionHandler.cs b/src/ApplicationCore/Commands/Submissions/CreateSubmission/CreateSubmissionHandler.cs deleted file mode 100644 index c5ed93a..0000000 --- a/src/ApplicationCore/Commands/Submissions/CreateSubmission/CreateSubmissionHandler.cs +++ /dev/null @@ -1,66 +0,0 @@ -using ApplicationCore.Domain.Submissions; -using ApplicationCore.Interfaces.Messaging; -using ApplicationCore.Interfaces.Repositories; -using ApplicationCore.Logging; -using ApplicationCore.Messaging; -using Ardalis.Result; -using FluentValidation; -using Microsoft.Extensions.Logging; - -namespace ApplicationCore.Commands.Submissions.CreateSubmission; - -public sealed partial class CreateSubmissionHandler( - ISubmissionRepository submissionRepository, - IMessagePublisher messagePublisher, - IValidator validator, - ILogger logger -) : AbstractCommandHandler(validator) -{ - private readonly ILogger _logger = logger; - protected override async Task> HandleValidated( - CreateSubmissionCommand request, - CancellationToken cancellationToken - ) - { - var submission = new SubmissionModel - { - Id = Guid.NewGuid(), - Code = request.Code, - ProblemSetupId = request.ProblemSetupId, - CreatedOn = DateTime.UtcNow, - CreatedById = request.CreatedById, - }; - - try - { - var outboxId = await submissionRepository.SaveAsync(submission, cancellationToken); - - await messagePublisher.PublishAsync( - new SubmissionCreatedMessage { SubmissionId = submission.Id, OutboxId = outboxId }, - cancellationToken - ); - } - catch (Exception ex) - { - LogCreateFailed(submission.Id, request.ProblemSetupId, request.CreatedById, ex); - return Result.Error("Unexpected error creating submission."); - } - - LogCreated(submission.Id, request.ProblemSetupId, request.CreatedById); - return Result.Success(submission.Id); - } - - [LoggerMessage( - EventId = LoggingEventIds.Submissions.Created, - Level = LogLevel.Information, - Message = "Submission {submissionId} created for setup {problemSetupId} by account {createdById}" - )] - private partial void LogCreated(Guid submissionId, int problemSetupId, Guid createdById); - - [LoggerMessage( - EventId = LoggingEventIds.Submissions.CreateFailed, - Level = LogLevel.Error, - Message = "Failed to create submission for setup {problemSetupId} by account {createdById} (attempted id {submissionId})" - )] - private partial void LogCreateFailed(Guid submissionId, int problemSetupId, Guid createdById, Exception ex); -} \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Submissions/CreateSubmission/CreateSubmissionValidator.cs b/src/ApplicationCore/Commands/Submissions/CreateSubmission/CreateSubmissionValidator.cs deleted file mode 100644 index 04977c8..0000000 --- a/src/ApplicationCore/Commands/Submissions/CreateSubmission/CreateSubmissionValidator.cs +++ /dev/null @@ -1,15 +0,0 @@ -using FluentValidation; - -namespace ApplicationCore.Commands.Submissions.CreateSubmission; - -public sealed class CreateSubmissionValidator : AbstractValidator -{ - public CreateSubmissionValidator() - { - RuleFor(x => x.Code).NotEmpty().MaximumLength(50_000); - - RuleFor(x => x.ProblemSetupId).GreaterThan(0); - - RuleFor(x => x.CreatedById).NotEmpty(); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Submissions/FinalizeEvaluation/FinalizeEvaluationCommand.cs b/src/ApplicationCore/Commands/Submissions/FinalizeEvaluation/FinalizeEvaluationCommand.cs deleted file mode 100644 index a8e9f59..0000000 --- a/src/ApplicationCore/Commands/Submissions/FinalizeEvaluation/FinalizeEvaluationCommand.cs +++ /dev/null @@ -1,6 +0,0 @@ -using MediatR; - -namespace ApplicationCore.Commands.Submissions.FinalizeEvaluation; - -public sealed record FinalizeEvaluationCommand(IEnumerable OutboxIds, DateTime Now) - : ICommand; \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Submissions/FinalizeEvaluation/FinalizeEvaluationHandler.cs b/src/ApplicationCore/Commands/Submissions/FinalizeEvaluation/FinalizeEvaluationHandler.cs deleted file mode 100644 index 18b5bc2..0000000 --- a/src/ApplicationCore/Commands/Submissions/FinalizeEvaluation/FinalizeEvaluationHandler.cs +++ /dev/null @@ -1,33 +0,0 @@ -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; -using FluentValidation; -using MediatR; - -namespace ApplicationCore.Commands.Submissions.FinalizeEvaluation; - -public sealed class FinalizeEvaluationHandler( - ISubmissionRepository submissionRepository, - IValidator validator -) : AbstractCommandHandler(validator) -{ - protected override async Task> HandleValidated( - FinalizeEvaluationCommand request, - CancellationToken cancellationToken - ) - { - try - { - await submissionRepository.FinalizeEvaluationAsync( - request.OutboxIds, - request.Now, - cancellationToken - ); - - return Result.Success(); - } - catch (Exception ex) - { - return Result.Error(ex.Message); - } - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Submissions/FinalizeEvaluation/FinalizeEvaluationValidator.cs b/src/ApplicationCore/Commands/Submissions/FinalizeEvaluation/FinalizeEvaluationValidator.cs deleted file mode 100644 index ec3ee27..0000000 --- a/src/ApplicationCore/Commands/Submissions/FinalizeEvaluation/FinalizeEvaluationValidator.cs +++ /dev/null @@ -1,11 +0,0 @@ -using FluentValidation; - -namespace ApplicationCore.Commands.Submissions.FinalizeEvaluation; - -public sealed class FinalizeEvaluationValidator : AbstractValidator -{ - public FinalizeEvaluationValidator() - { - RuleFor(x => x.OutboxIds).NotNull(); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Submissions/IncrementSubmissionOutboxes/IncrementSubmissionOutboxesCommand.cs b/src/ApplicationCore/Commands/Submissions/IncrementSubmissionOutboxes/IncrementSubmissionOutboxesCommand.cs deleted file mode 100644 index a523f6f..0000000 --- a/src/ApplicationCore/Commands/Submissions/IncrementSubmissionOutboxes/IncrementSubmissionOutboxesCommand.cs +++ /dev/null @@ -1,8 +0,0 @@ -using MediatR; - -namespace ApplicationCore.Commands.Submissions.IncrementSubmissionOutboxes; - -public sealed record IncrementSubmissionOutboxesCommand( - IEnumerable OutboxIds, - DateTime Timestamp -) : ICommand; \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Submissions/IncrementSubmissionOutboxes/IncrementSubmissionOutboxesHandler.cs b/src/ApplicationCore/Commands/Submissions/IncrementSubmissionOutboxes/IncrementSubmissionOutboxesHandler.cs deleted file mode 100644 index 8058b18..0000000 --- a/src/ApplicationCore/Commands/Submissions/IncrementSubmissionOutboxes/IncrementSubmissionOutboxesHandler.cs +++ /dev/null @@ -1,33 +0,0 @@ -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; -using FluentValidation; -using MediatR; - -namespace ApplicationCore.Commands.Submissions.IncrementSubmissionOutboxes; - -public sealed class IncrementSubmissionOutboxesHandler( - ISubmissionRepository submissionRepository, - IValidator validator -) : AbstractCommandHandler(validator) -{ - protected override async Task> HandleValidated( - IncrementSubmissionOutboxesCommand request, - CancellationToken cancellationToken - ) - { - try - { - await submissionRepository.IncrementOutboxesCountAsync( - request.OutboxIds, - request.Timestamp, - cancellationToken - ); - - return Result.Success(); - } - catch (Exception ex) - { - return Result.Error(ex.Message); - } - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Submissions/IncrementSubmissionOutboxes/IncrementSubmissionOutboxesValidator.cs b/src/ApplicationCore/Commands/Submissions/IncrementSubmissionOutboxes/IncrementSubmissionOutboxesValidator.cs deleted file mode 100644 index 82657da..0000000 --- a/src/ApplicationCore/Commands/Submissions/IncrementSubmissionOutboxes/IncrementSubmissionOutboxesValidator.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentValidation; - -namespace ApplicationCore.Commands.Submissions.IncrementSubmissionOutboxes; - -public sealed class IncrementSubmissionOutboxesValidator - : AbstractValidator -{ - public IncrementSubmissionOutboxesValidator() - { - RuleFor(x => x.OutboxIds).NotEmpty(); - - RuleFor(x => x.Timestamp).LessThanOrEqualTo(DateTime.UtcNow); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Submissions/ProcessEvaluation/ProcessEvaluationCommand.cs b/src/ApplicationCore/Commands/Submissions/ProcessEvaluation/ProcessEvaluationCommand.cs deleted file mode 100644 index e767bd5..0000000 --- a/src/ApplicationCore/Commands/Submissions/ProcessEvaluation/ProcessEvaluationCommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -using ApplicationCore.Domain.Submissions; -using MediatR; - -namespace ApplicationCore.Commands.Submissions.ProcessEvaluation; - -public sealed record ProcessEvaluationCommand(IEnumerable Submissions) - : ICommand; \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Submissions/ProcessEvaluation/ProcessEvaluationHandler.cs b/src/ApplicationCore/Commands/Submissions/ProcessEvaluation/ProcessEvaluationHandler.cs deleted file mode 100644 index 6f19025..0000000 --- a/src/ApplicationCore/Commands/Submissions/ProcessEvaluation/ProcessEvaluationHandler.cs +++ /dev/null @@ -1,32 +0,0 @@ -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; -using FluentValidation; -using MediatR; - -namespace ApplicationCore.Commands.Submissions.ProcessEvaluation; - -public sealed class ProcessEvaluationHandler( - ISubmissionRepository submissionRepository, - IValidator validator -) : AbstractCommandHandler(validator) -{ - protected override async Task> HandleValidated( - ProcessEvaluationCommand request, - CancellationToken cancellationToken - ) - { - try - { - await submissionRepository.ProcessEvaluationAsync( - request.Submissions, - cancellationToken - ); - - return Result.Success(); - } - catch (Exception ex) - { - return Result.Error(ex.Message); - } - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Submissions/ProcessEvaluation/ProcessEvaluationValidator.cs b/src/ApplicationCore/Commands/Submissions/ProcessEvaluation/ProcessEvaluationValidator.cs deleted file mode 100644 index 1f3236a..0000000 --- a/src/ApplicationCore/Commands/Submissions/ProcessEvaluation/ProcessEvaluationValidator.cs +++ /dev/null @@ -1,11 +0,0 @@ -using FluentValidation; - -namespace ApplicationCore.Commands.Submissions.ProcessEvaluation; - -public sealed class ProcessEvaluationValidator : AbstractValidator -{ - public ProcessEvaluationValidator() - { - RuleFor(x => x.Submissions).NotNull(); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Submissions/ProcessPollingSubmissionExecutions/ProcessPollingSubmissionExecutionsCommand.cs b/src/ApplicationCore/Commands/Submissions/ProcessPollingSubmissionExecutions/ProcessPollingSubmissionExecutionsCommand.cs deleted file mode 100644 index 65f91f8..0000000 --- a/src/ApplicationCore/Commands/Submissions/ProcessPollingSubmissionExecutions/ProcessPollingSubmissionExecutionsCommand.cs +++ /dev/null @@ -1,6 +0,0 @@ -using ApplicationCore.Domain.Submissions; -using MediatR; - -namespace ApplicationCore.Commands.Submissions.ProcessPollingSubmissionExecutions; - -public sealed record ProcessPollingSubmissionExecutionsCommand(IEnumerable Submissions) : ICommand; \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Submissions/ProcessPollingSubmissionExecutions/ProcessPollingSubmissionExecutionsHandler.cs b/src/ApplicationCore/Commands/Submissions/ProcessPollingSubmissionExecutions/ProcessPollingSubmissionExecutionsHandler.cs deleted file mode 100644 index d8d079c..0000000 --- a/src/ApplicationCore/Commands/Submissions/ProcessPollingSubmissionExecutions/ProcessPollingSubmissionExecutionsHandler.cs +++ /dev/null @@ -1,17 +0,0 @@ -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; -using FluentValidation; -using MediatR; - -namespace ApplicationCore.Commands.Submissions.ProcessPollingSubmissionExecutions; - -public sealed class ProcessPollingSubmissionExecutionsHandler(ISubmissionRepository submissionRepository, IValidator validator) : AbstractCommandHandler(validator) -{ - protected override async Task> HandleValidated(ProcessPollingSubmissionExecutionsCommand request, CancellationToken cancellationToken) - { - await submissionRepository.ProcessPollingSubmissionExecutionsAsync( - request.Submissions, cancellationToken); - - return Result.Success(); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Submissions/ProcessPollingSubmissionExecutions/ProcessPollingSubmissionExecutionsValidator.cs b/src/ApplicationCore/Commands/Submissions/ProcessPollingSubmissionExecutions/ProcessPollingSubmissionExecutionsValidator.cs deleted file mode 100644 index c04c4a3..0000000 --- a/src/ApplicationCore/Commands/Submissions/ProcessPollingSubmissionExecutions/ProcessPollingSubmissionExecutionsValidator.cs +++ /dev/null @@ -1,11 +0,0 @@ -using FluentValidation; - -namespace ApplicationCore.Commands.Submissions.ProcessPollingSubmissionExecutions; - -public class ProcessPollingSubmissionExecutionsValidator : AbstractValidator -{ - public ProcessPollingSubmissionExecutionsValidator() - { - RuleFor(x => x.Submissions).NotNull(); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Submissions/ProcessSubmissionExecutions/ProcessSubmissionExecutionsCommand.cs b/src/ApplicationCore/Commands/Submissions/ProcessSubmissionExecutions/ProcessSubmissionExecutionsCommand.cs deleted file mode 100644 index 4168009..0000000 --- a/src/ApplicationCore/Commands/Submissions/ProcessSubmissionExecutions/ProcessSubmissionExecutionsCommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -using ApplicationCore.Domain.Submissions; -using MediatR; - -namespace ApplicationCore.Commands.Submissions.ProcessSubmissionExecutions; - -public sealed record ProcessSubmissionExecutionsCommand(IEnumerable Submissions) - : ICommand; \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Submissions/ProcessSubmissionExecutions/ProcessSubmissionExecutionsHandler.cs b/src/ApplicationCore/Commands/Submissions/ProcessSubmissionExecutions/ProcessSubmissionExecutionsHandler.cs deleted file mode 100644 index 4b33816..0000000 --- a/src/ApplicationCore/Commands/Submissions/ProcessSubmissionExecutions/ProcessSubmissionExecutionsHandler.cs +++ /dev/null @@ -1,32 +0,0 @@ -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; -using FluentValidation; -using MediatR; - -namespace ApplicationCore.Commands.Submissions.ProcessSubmissionExecutions; - -public sealed class ProcessSubmissionExecutionsHandler( - ISubmissionRepository submissionRepository, - IValidator validator -) : AbstractCommandHandler(validator) -{ - protected override async Task> HandleValidated( - ProcessSubmissionExecutionsCommand request, - CancellationToken cancellationToken - ) - { - try - { - await submissionRepository.ProcessSubmissionInitializationAsync( - request.Submissions, - cancellationToken - ); - - return Result.Success(); - } - catch (Exception ex) - { - return Result.Error(ex.Message); - } - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Submissions/ProcessSubmissionExecutions/ProcessSubmissionExecutionsValidator.cs b/src/ApplicationCore/Commands/Submissions/ProcessSubmissionExecutions/ProcessSubmissionExecutionsValidator.cs deleted file mode 100644 index 179de42..0000000 --- a/src/ApplicationCore/Commands/Submissions/ProcessSubmissionExecutions/ProcessSubmissionExecutionsValidator.cs +++ /dev/null @@ -1,15 +0,0 @@ -using FluentValidation; -using System; -using System.Collections.Generic; -using System.Text; - -namespace ApplicationCore.Commands.Submissions.ProcessSubmissionExecutions; - -public sealed class ProcessSubmissionExecutionsValidator - : AbstractValidator -{ - public ProcessSubmissionExecutionsValidator() - { - RuleFor(x => x.Submissions).NotNull(); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Submissions/SaveExecutionTokens/SaveExecutionTokensCommand.cs b/src/ApplicationCore/Commands/Submissions/SaveExecutionTokens/SaveExecutionTokensCommand.cs deleted file mode 100644 index 3c57aa3..0000000 --- a/src/ApplicationCore/Commands/Submissions/SaveExecutionTokens/SaveExecutionTokensCommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -using ApplicationCore.Domain.Submissions; -using MediatR; - -namespace ApplicationCore.Commands.Submissions.SaveExecutionTokens; - -public sealed record SaveExecutionTokensCommand(IEnumerable Submissions) - : ICommand; \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Submissions/SaveExecutionTokens/SaveExecutionTokensHandler.cs b/src/ApplicationCore/Commands/Submissions/SaveExecutionTokens/SaveExecutionTokensHandler.cs deleted file mode 100644 index 1b3a8b3..0000000 --- a/src/ApplicationCore/Commands/Submissions/SaveExecutionTokens/SaveExecutionTokensHandler.cs +++ /dev/null @@ -1,32 +0,0 @@ -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; -using FluentValidation; -using MediatR; - -namespace ApplicationCore.Commands.Submissions.SaveExecutionTokens; - -public sealed class SaveExecutionTokensHandler( - ISubmissionRepository submissionRepository, - IValidator validator -) : AbstractCommandHandler(validator) -{ - protected override async Task> HandleValidated( - SaveExecutionTokensCommand request, - CancellationToken cancellationToken - ) - { - try - { - await submissionRepository.SaveExecutionTokensAsync( - request.Submissions, - cancellationToken - ); - - return Result.Success(); - } - catch (Exception ex) - { - return Result.Error(ex.Message); - } - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Commands/Submissions/SaveExecutionTokens/SaveExecutionTokensValidator.cs b/src/ApplicationCore/Commands/Submissions/SaveExecutionTokens/SaveExecutionTokensValidator.cs deleted file mode 100644 index 8307f72..0000000 --- a/src/ApplicationCore/Commands/Submissions/SaveExecutionTokens/SaveExecutionTokensValidator.cs +++ /dev/null @@ -1,11 +0,0 @@ -using FluentValidation; - -namespace ApplicationCore.Commands.Submissions.SaveExecutionTokens; - -public sealed class SaveExecutionTokensValidator : AbstractValidator -{ - public SaveExecutionTokensValidator() - { - RuleFor(x => x.Submissions).NotNull(); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Common/Pagination/PaginationRequest.cs b/src/ApplicationCore/Common/Pagination/PaginationRequest.cs deleted file mode 100644 index ac3203e..0000000 --- a/src/ApplicationCore/Common/Pagination/PaginationRequest.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace ApplicationCore.Common.Pagination; - -public sealed class PaginationRequest -{ - public required int Page { get; init; } - - public required int Size { get; init; } - - public string? Query { get; init; } - - public DateTime Timestamp { get; init; } = DateTime.UtcNow; - public SortDirection Direction { get; init; } = SortDirection.Desc; -} \ No newline at end of file diff --git a/src/ApplicationCore/DependencyInjection.cs b/src/ApplicationCore/DependencyInjection.cs deleted file mode 100644 index a01885b..0000000 --- a/src/ApplicationCore/DependencyInjection.cs +++ /dev/null @@ -1,32 +0,0 @@ -using ApplicationCore.Interfaces.Services; -using ApplicationCore.Mappings; -using ApplicationCore.Services; -using FluentValidation; -using Mapster; -using MediatR; -using Microsoft.Extensions.DependencyInjection; -using System.Reflection; - -namespace ApplicationCore; - -public static class DependencyInjection -{ - public static IServiceCollection AddApplicationCore(this IServiceCollection services) - { - var assembly = Assembly.GetExecutingAssembly(); - - services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly((assembly))); - services.AddValidatorsFromAssembly(assembly); - - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - TypeAdapterConfig.GlobalSettings.Scan(typeof(ProblemMappings).Assembly); - - return services; - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Accounts/AccountContext.cs b/src/ApplicationCore/Domain/Accounts/AccountContext.cs deleted file mode 100644 index 4a67348..0000000 --- a/src/ApplicationCore/Domain/Accounts/AccountContext.cs +++ /dev/null @@ -1,13 +0,0 @@ -using ApplicationCore.Dtos.Accounts; - -namespace ApplicationCore.Domain.Accounts; - -public interface IAccountContext -{ - AccountDto? Account { get; set; } -} - -public sealed class AccountContext : IAccountContext -{ - public AccountDto? Account { get; set; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Accounts/AccountModel.cs b/src/ApplicationCore/Domain/Accounts/AccountModel.cs deleted file mode 100644 index a3b8968..0000000 --- a/src/ApplicationCore/Domain/Accounts/AccountModel.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace ApplicationCore.Domain.Accounts; - -public sealed class AccountModel : BaseModel -{ - private string _username = string.Empty; - - public string? Sub { get; init; } - - public string? About { get; set; } - - public required string Username - { - get => _username; - init => _username = value; - } - - public string? PreviousUsername { get; private set; } - - public DateTime? UsernameLastChangedAt { get; private set; } - - public string? ImageUrl { get; init; } - - public DateTime CreatedOn { get; init; } - - public DateTime? LastModifiedOn { get; set; } - - public Guid? LastModifiedById { get; set; } - - public void ChangeUsername(string newUsername) - { - if (Username == newUsername) - { - return; - } - - PreviousUsername = Username; - _username = newUsername; - UsernameLastChangedAt = DateTime.UtcNow; - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/BaseAuditableModel.cs b/src/ApplicationCore/Domain/BaseAuditableModel.cs deleted file mode 100644 index aeb1643..0000000 --- a/src/ApplicationCore/Domain/BaseAuditableModel.cs +++ /dev/null @@ -1,19 +0,0 @@ -using ApplicationCore.Domain.Accounts; - -namespace ApplicationCore.Domain; - -public abstract class BaseAuditableModel : BaseModel - where TId : notnull -{ - public DateTime CreatedOn { get; set; } - - public Guid? CreatedById { get; set; } - - public AccountModel? CreatedBy { get; init; } - - public DateTime? LastModifiedOn { get; set; } - - public Guid LastModifiedById { get; set; } - - public DateTime? DeletedOn { get; set; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/BaseModel.cs b/src/ApplicationCore/Domain/BaseModel.cs deleted file mode 100644 index f8bc46e..0000000 --- a/src/ApplicationCore/Domain/BaseModel.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ApplicationCore.Domain; - -public abstract class BaseModel - where TId : notnull -{ - public TId? Id { get; set; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/CodeExecution/CodeBuildResult.cs b/src/ApplicationCore/Domain/CodeExecution/CodeBuildResult.cs deleted file mode 100644 index 5825160..0000000 --- a/src/ApplicationCore/Domain/CodeExecution/CodeBuildResult.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace ApplicationCore.Domain.CodeExecution; - -public sealed class CodeBuildResult -{ - public required string FinalCode { get; init; } - - public required string FunctionName { get; init; } - - public string? Inputs { get; init; } - - public string? ExpectedOutput { get; init; } - - public string? InputTypeName { get; init; } - - public int LanguageId { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/CodeExecution/CodeBuilderContext.cs b/src/ApplicationCore/Domain/CodeExecution/CodeBuilderContext.cs deleted file mode 100644 index 7d5294e..0000000 --- a/src/ApplicationCore/Domain/CodeExecution/CodeBuilderContext.cs +++ /dev/null @@ -1,22 +0,0 @@ -using ApplicationCore.Domain.Problems.TestSuites; - -namespace ApplicationCore.Domain.CodeExecution; - -public sealed class CodeBuilderContext -{ - public required string Code { get; init; } - - public required string Template { get; init; } - - public required string FunctionName { get; init; } - - public int? LanguageVersionId { get; init; } - - public int? Judge0LanguageId { get; init; } - - public required IEnumerable Inputs { get; init; } - - public required TestCaseExpectedOutputModel ExpectedOutput { get; init; } - - public string? InputTypeName { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/CodeExecution/CodeExecutionContext.cs b/src/ApplicationCore/Domain/CodeExecution/CodeExecutionContext.cs deleted file mode 100644 index 272c690..0000000 --- a/src/ApplicationCore/Domain/CodeExecution/CodeExecutionContext.cs +++ /dev/null @@ -1,16 +0,0 @@ -using ApplicationCore.Domain.Problems.ProblemSetups; - -namespace ApplicationCore.Domain.CodeExecution; - -public sealed class CodeExecutionContext -{ - public Guid? SubmissionId { get; set; } - - public required ProblemSetupModel Setup { get; set; } - - public required string Code { get; set; } - - public required IEnumerable BuiltResults { get; set; } = []; - - public required Guid CreatedById { get; set; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/CodeExecution/Judge0/Judge0BatchGetResponse.cs b/src/ApplicationCore/Domain/CodeExecution/Judge0/Judge0BatchGetResponse.cs deleted file mode 100644 index 50c8a9d..0000000 --- a/src/ApplicationCore/Domain/CodeExecution/Judge0/Judge0BatchGetResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Text.Json.Serialization; - -namespace ApplicationCore.Domain.CodeExecution.Judge0; - -public sealed record Judge0BatchGetResponse -{ - [JsonPropertyName("submissions")] - public required List Submissions { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/CodeExecution/Judge0/Judge0BatchRequest.cs b/src/ApplicationCore/Domain/CodeExecution/Judge0/Judge0BatchRequest.cs deleted file mode 100644 index 893414a..0000000 --- a/src/ApplicationCore/Domain/CodeExecution/Judge0/Judge0BatchRequest.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Text.Json.Serialization; - -namespace ApplicationCore.Domain.CodeExecution.Judge0; - -public sealed class Judge0BatchRequest -{ - [JsonPropertyName("submissions")] - public required IEnumerable Submissions { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/CodeExecution/Judge0/Judge0StatusModel.cs b/src/ApplicationCore/Domain/CodeExecution/Judge0/Judge0StatusModel.cs deleted file mode 100644 index 1fcd488..0000000 --- a/src/ApplicationCore/Domain/CodeExecution/Judge0/Judge0StatusModel.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ApplicationCore.Domain.CodeExecution.Judge0; - -public record Judge0StatusModel -{ - public int Id { get; init; } - public string? Description { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/CodeExecution/Judge0/Judge0SubmissionRequest.cs b/src/ApplicationCore/Domain/CodeExecution/Judge0/Judge0SubmissionRequest.cs deleted file mode 100644 index 6e449a0..0000000 --- a/src/ApplicationCore/Domain/CodeExecution/Judge0/Judge0SubmissionRequest.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Text.Json.Serialization; - -namespace ApplicationCore.Domain.CodeExecution.Judge0; - -public class Judge0SubmissionRequest -{ - [JsonPropertyName("language_id")] - public int LanguageId { get; init; } - - [JsonPropertyName("source_code")] - public string? SourceCode { get; init; } - - [JsonPropertyName("stdin")] - public string? StdIn { get; init; } - - [JsonPropertyName("expected_output")] - public string? ExpectedOutput { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/CodeExecution/Judge0/Judge0SubmissionResponse.cs b/src/ApplicationCore/Domain/CodeExecution/Judge0/Judge0SubmissionResponse.cs deleted file mode 100644 index 77dd638..0000000 --- a/src/ApplicationCore/Domain/CodeExecution/Judge0/Judge0SubmissionResponse.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Text.Json.Serialization; - -namespace ApplicationCore.Domain.CodeExecution.Judge0; - -public sealed record Judge0SubmissionResponse -{ - [JsonPropertyName("token")] - public Guid Token { get; init; } - - [JsonPropertyName("source_code")] - public string? SourceCode { get; init; } - - [JsonPropertyName("language_id")] - public int LanguageId { get; init; } - - [JsonPropertyName("stdin")] - public string? Stdin { get; init; } - - [JsonPropertyName("expected_output")] - public string? ExpectedOutput { get; init; } - - [JsonPropertyName("stdout")] - public string? Stdout { get; init; } - - [JsonPropertyName("time")] - public string? Time { get; init; } - - [JsonPropertyName("memory")] - public int? Memory { get; init; } - - [JsonPropertyName("stderr")] - public string? Stderr { get; init; } - - [JsonPropertyName("status")] - public required Judge0StatusModel Status { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/CodeExecution/Judge0/Judge0SubmissionTokenOnlyResponse.cs b/src/ApplicationCore/Domain/CodeExecution/Judge0/Judge0SubmissionTokenOnlyResponse.cs deleted file mode 100644 index 672b605..0000000 --- a/src/ApplicationCore/Domain/CodeExecution/Judge0/Judge0SubmissionTokenOnlyResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Text.Json.Serialization; - -namespace ApplicationCore.Domain.CodeExecution.Judge0; - -public class Judge0SubmissionTokenOnlyResponse -{ - [JsonPropertyName("token")] - public Guid Token { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Problems/Languages/LanguageVersion.cs b/src/ApplicationCore/Domain/Problems/Languages/LanguageVersion.cs deleted file mode 100644 index 96e1cd2..0000000 --- a/src/ApplicationCore/Domain/Problems/Languages/LanguageVersion.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace ApplicationCore.Domain.Problems.Languages; - -public sealed class LanguageVersion : BaseModel -{ - public required string Version { get; init; } - - public string? InitialCode { get; init; } - - public int ProgrammingLanguageId { get; init; } - public ProgrammingLanguage? ProgrammingLanguage { get; init; } - - public int? Judge0LanguageId { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Problems/Languages/ProgrammingLanguage.cs b/src/ApplicationCore/Domain/Problems/Languages/ProgrammingLanguage.cs deleted file mode 100644 index 0182015..0000000 --- a/src/ApplicationCore/Domain/Problems/Languages/ProgrammingLanguage.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace ApplicationCore.Domain.Problems.Languages; - -public class ProgrammingLanguage : BaseAuditableModel -{ - public required string Name { get; init; } - - public bool IsArchived { get; init; } - - public IEnumerable Versions { get; init; } = []; -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Problems/ProblemModel.cs b/src/ApplicationCore/Domain/Problems/ProblemModel.cs deleted file mode 100644 index 3c06ce5..0000000 --- a/src/ApplicationCore/Domain/Problems/ProblemModel.cs +++ /dev/null @@ -1,55 +0,0 @@ -using ApplicationCore.Domain.Problems.Languages; -using ApplicationCore.Domain.Problems.ProblemSetups; - -namespace ApplicationCore.Domain.Problems; - -public sealed class ProblemModel : BaseAuditableModel -{ - public required string Title { get; init; } - - public required string Slug { get; init; } - - public required string Question { get; init; } - - public required IEnumerable Tags { get; init; } - - public int Difficulty { get; init; } - - public ProblemStatus Status { get; init; } - - public ICollection ProblemSetups { get; init; } = []; - - public int Version { get; init; } - - public IEnumerable GetAvailableLanguages() - { - if (ProblemSetups.Count == 0) - { - return []; - } - - return ProblemSetups - .Where(s => s.LanguageVersion?.ProgrammingLanguage != null) - .GroupBy(s => s.LanguageVersion!.ProgrammingLanguage!) - .Select(g => - { - var language = g.Key; - - var versions = g.Select(s => s.LanguageVersion!) - .GroupBy(v => v.Id) - .Select(vg => vg.First()) - .OrderBy(v => v.Version) - .ToList(); - - return new ProgrammingLanguage - { - Id = language.Id, - Name = language.Name, - IsArchived = language.IsArchived, - Versions = versions, - }; - }) - .DistinctBy(s => s.Id) - .ToList(); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Problems/ProblemSetups/ProblemSetupModel.cs b/src/ApplicationCore/Domain/Problems/ProblemSetups/ProblemSetupModel.cs deleted file mode 100644 index 9a1277a..0000000 --- a/src/ApplicationCore/Domain/Problems/ProblemSetups/ProblemSetupModel.cs +++ /dev/null @@ -1,27 +0,0 @@ -using ApplicationCore.Domain.Problems.Languages; -using ApplicationCore.Domain.Problems.TestSuites; - -namespace ApplicationCore.Domain.Problems.ProblemSetups; - -public sealed class ProblemSetupModel -{ - public required int Id { get; init; } - - public required Guid ProblemId { get; init; } - - public ProblemModel? Problem { get; init; } - - public required string InitialCode { get; init; } - - public string? FunctionName { get; init; } - - public required int LanguageVersionId { get; init; } - - public LanguageVersion? LanguageVersion { get; init; } - - public int Version { get; init; } - - public HarnessTemplate? HarnessTemplate { get; init; } - - public IEnumerable TestSuites { get; init; } = []; -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Problems/ProblemStatus.cs b/src/ApplicationCore/Domain/Problems/ProblemStatus.cs deleted file mode 100644 index d453f3e..0000000 --- a/src/ApplicationCore/Domain/Problems/ProblemStatus.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ApplicationCore.Domain.Problems; - -public enum ProblemStatus -{ - Draft = 1, - Pending = 2, - Published = 3, - Rejected = 4, -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Problems/TagModel.cs b/src/ApplicationCore/Domain/Problems/TagModel.cs deleted file mode 100644 index 0bf3410..0000000 --- a/src/ApplicationCore/Domain/Problems/TagModel.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace ApplicationCore.Domain.Problems; - -public sealed class TagModel -{ - public int Id { get; init; } - - public required string Value { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Problems/TestSuites/HarnessTemplate.cs b/src/ApplicationCore/Domain/Problems/TestSuites/HarnessTemplate.cs deleted file mode 100644 index a251a16..0000000 --- a/src/ApplicationCore/Domain/Problems/TestSuites/HarnessTemplate.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace ApplicationCore.Domain.Problems.TestSuites; - -public sealed class HarnessTemplate -{ - public required int Id { get; init; } - - public required string Template { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Problems/TestSuites/TestCaseExpectedOutputModel.cs b/src/ApplicationCore/Domain/Problems/TestSuites/TestCaseExpectedOutputModel.cs deleted file mode 100644 index 1362a03..0000000 --- a/src/ApplicationCore/Domain/Problems/TestSuites/TestCaseExpectedOutputModel.cs +++ /dev/null @@ -1,16 +0,0 @@ - - -namespace ApplicationCore.Domain.Problems.TestSuites; - -public sealed class TestCaseExpectedOutputModel -{ - public int Id { get; set; } - - public int TestCaseId { get; set; } - - public string Value { get; set; } - - public int OutputValueTypeId { get; set; } - - public TestCaseOutputTypeModel OutputType { get; set; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Problems/TestSuites/TestCaseInputParamModel.cs b/src/ApplicationCore/Domain/Problems/TestSuites/TestCaseInputParamModel.cs deleted file mode 100644 index 246b0c2..0000000 --- a/src/ApplicationCore/Domain/Problems/TestSuites/TestCaseInputParamModel.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ApplicationCore.Domain.Problems.TestSuites; - -public sealed class TestCaseInputParamModel -{ - public int Id { get; set; } - - public required string Value { get; set; } - - public int TestCaseInputValueTypeId { get; set; } - - public required TestCaseInputValueTypeModel InputType { get; set; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Problems/TestSuites/TestCaseInputValueTypeModel.cs b/src/ApplicationCore/Domain/Problems/TestSuites/TestCaseInputValueTypeModel.cs deleted file mode 100644 index e23c819..0000000 --- a/src/ApplicationCore/Domain/Problems/TestSuites/TestCaseInputValueTypeModel.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace ApplicationCore.Domain.Problems.TestSuites; - -public sealed class TestCaseInputValueTypeModel -{ - public int Id { get; set; } - - public required string Name { get; set; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Problems/TestSuites/TestCaseModel.cs b/src/ApplicationCore/Domain/Problems/TestSuites/TestCaseModel.cs deleted file mode 100644 index 34c217e..0000000 --- a/src/ApplicationCore/Domain/Problems/TestSuites/TestCaseModel.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace ApplicationCore.Domain.Problems.TestSuites; - -public sealed class TestCaseModel -{ - public required int Id { get; init; } - - public IEnumerable Inputs { get; init; } - - public required TestCaseExpectedOutputModel ExpectedOutput { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Problems/TestSuites/TestCaseOutputTypeModel.cs b/src/ApplicationCore/Domain/Problems/TestSuites/TestCaseOutputTypeModel.cs deleted file mode 100644 index 635eab9..0000000 --- a/src/ApplicationCore/Domain/Problems/TestSuites/TestCaseOutputTypeModel.cs +++ /dev/null @@ -1,10 +0,0 @@ - - -namespace ApplicationCore.Domain.Problems.TestSuites; - -public sealed class TestCaseOutputTypeModel -{ - public int Id { get; set; } - - public string? Name { get; set; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Problems/TestSuites/TestSuiteModel.cs b/src/ApplicationCore/Domain/Problems/TestSuites/TestSuiteModel.cs deleted file mode 100644 index c9675fb..0000000 --- a/src/ApplicationCore/Domain/Problems/TestSuites/TestSuiteModel.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ApplicationCore.Domain.Problems.TestSuites; - -public sealed class TestSuiteModel : BaseAuditableModel -{ - public string? Name { get; init; } - - public string? Description { get; init; } - - public TestSuiteType TestSuiteType { get; init; } - - public required IEnumerable TestCases { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Problems/TestSuites/TestSuiteType.cs b/src/ApplicationCore/Domain/Problems/TestSuites/TestSuiteType.cs deleted file mode 100644 index df16d96..0000000 --- a/src/ApplicationCore/Domain/Problems/TestSuites/TestSuiteType.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ApplicationCore.Domain.Problems.TestSuites; - -public enum TestSuiteType -{ - Public = 1, - Hidden = 2, -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Submissions/Outboxes/SubmissionOutboxModel.cs b/src/ApplicationCore/Domain/Submissions/Outboxes/SubmissionOutboxModel.cs deleted file mode 100644 index 11b01f0..0000000 --- a/src/ApplicationCore/Domain/Submissions/Outboxes/SubmissionOutboxModel.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace ApplicationCore.Domain.Submissions.Outboxes; - -public sealed class SubmissionOutboxModel -{ - public Guid Id { get; init; } - - public required Guid SubmissionId { get; init; } - - public required SubmissionModel Submission { get; init; } - - public required SubmissionOutboxType Type { get; init; } - - public required SubmissionOutboxStatus Status { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Submissions/Outboxes/SubmissionOutboxStatus.cs b/src/ApplicationCore/Domain/Submissions/Outboxes/SubmissionOutboxStatus.cs deleted file mode 100644 index 96f6428..0000000 --- a/src/ApplicationCore/Domain/Submissions/Outboxes/SubmissionOutboxStatus.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ApplicationCore.Domain.Submissions.Outboxes; - -public enum SubmissionOutboxStatus -{ - Pending = 1, - Processing = 2, - Completed = 3, - Failed = 4, -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Submissions/Outboxes/SubmissionOutboxType.cs b/src/ApplicationCore/Domain/Submissions/Outboxes/SubmissionOutboxType.cs deleted file mode 100644 index f79ff6b..0000000 --- a/src/ApplicationCore/Domain/Submissions/Outboxes/SubmissionOutboxType.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace ApplicationCore.Domain.Submissions.Outboxes; - -public enum SubmissionOutboxType -{ - Initialized = 1, - Execute, - PollExecution, - Evaluate, - EvaluationPoll -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Submissions/SubmissionModel.cs b/src/ApplicationCore/Domain/Submissions/SubmissionModel.cs deleted file mode 100644 index 5001c3a..0000000 --- a/src/ApplicationCore/Domain/Submissions/SubmissionModel.cs +++ /dev/null @@ -1,63 +0,0 @@ -using ApplicationCore.Domain.Accounts; -using ApplicationCore.Domain.Problems.Languages; - -namespace ApplicationCore.Domain.Submissions; - -public sealed class SubmissionModel -{ - public required Guid Id { get; init; } - - public string? Code { get; init; } - - public int ProblemSetupId { get; init; } - - public LanguageVersion LanguageVersion { get; init; } - - public DateTime CreatedOn { get; init; } - - public DateTime? CompletedAt { get; set; } - - public Guid CreatedById { get; init; } - - public AccountModel? CreatedBy { get; init; } - - public IEnumerable Results { get; init; } = []; - - public IEnumerable GetResultTokens() - { - return Results.Select(result => result.Id); - } - - public SubmissionStatus GetOverallStatus() - { - if ( - !Results.Any() - || Results.Any(r => r.Status is SubmissionStatus.InQueue or SubmissionStatus.Processing) - ) - { - return SubmissionStatus.Processing; - } - - return Results.All(r => r.Status == SubmissionStatus.Accepted) - ? SubmissionStatus.Accepted - : SubmissionStatus.WrongAnswer; - } - - public int GetAverageRuntimeMs() - { - if (!Results.Any()) - { - return 0; - } - return (int)(Results.Average(r => r.RuntimeMs) ?? 0); - } - - public int GetAverageMemoryKb() - { - if (!Results.Any()) - { - return 0; - } - return (int)(Results.Average(r => r.MemoryKb) ?? 0); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Submissions/SubmissionResult.cs b/src/ApplicationCore/Domain/Submissions/SubmissionResult.cs deleted file mode 100644 index 0e246d6..0000000 --- a/src/ApplicationCore/Domain/Submissions/SubmissionResult.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace ApplicationCore.Domain.Submissions; - -public sealed class SubmissionResult -{ - public Guid Id { get; set; } - - public Guid ExecutionId { get; set; } - - public Guid? ResultId { get; set; } - - public required SubmissionStatus Status { get; set; } - - public DateTime? StartedAt { get; set; } - - public DateTime? FinishedAt { get; set; } - - public string? Stdout { get; set; } - - public string? ProgramOutput { get; set; } - - public string? Stderr { get; set; } - - public int? RuntimeMs { get; set; } - - public int? MemoryKb { get; set; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Domain/Submissions/SubmissionStatus.cs b/src/ApplicationCore/Domain/Submissions/SubmissionStatus.cs deleted file mode 100644 index cba82e1..0000000 --- a/src/ApplicationCore/Domain/Submissions/SubmissionStatus.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace ApplicationCore.Domain.Submissions; - -public enum SubmissionStatus -{ - Accepted = 1, - WrongAnswer = 2, - - InQueue = 3, - Processing = 4, - TimeLimitExceeded = 5, - CompilationError = 6, - - RuntimeErrorSigSegv = 7, - RuntimeErrorSigXfsz = 8, - RuntimeErrorSigFpe = 9, - RuntimeErrorSigAbrt = 10, - RuntimeErrorNzec = 11, - RuntimeErrorOther = 12, - - InternalError = 13, - ExecFormatError = 14, -} \ No newline at end of file diff --git a/src/ApplicationCore/Dtos/Accounts/AccountDto.cs b/src/ApplicationCore/Dtos/Accounts/AccountDto.cs deleted file mode 100644 index 2689949..0000000 --- a/src/ApplicationCore/Dtos/Accounts/AccountDto.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace ApplicationCore.Dtos.Accounts; - -public sealed record AccountDto -{ - public Guid? Id { get; init; } - - public required string Username { get; init; } - - public string? ImageUrl { get; init; } - - public IEnumerable Permissions { get; init; } = []; - - public required DateTime CreatedOn { get; init; } - - public string? About { get; init; } - - public DateTime? UsernameLastChangedAt { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Dtos/Accounts/ProfileAggregateDto.cs b/src/ApplicationCore/Dtos/Accounts/ProfileAggregateDto.cs deleted file mode 100644 index 1ec3ba9..0000000 --- a/src/ApplicationCore/Dtos/Accounts/ProfileAggregateDto.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace ApplicationCore.Dtos.Accounts; - -public sealed record ProfileAggregateDto(AccountDto Profile); \ No newline at end of file diff --git a/src/ApplicationCore/Dtos/Accounts/ProfileSettingsDto.cs b/src/ApplicationCore/Dtos/Accounts/ProfileSettingsDto.cs deleted file mode 100644 index ec46633..0000000 --- a/src/ApplicationCore/Dtos/Accounts/ProfileSettingsDto.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace ApplicationCore.Dtos.Accounts; - -public sealed record ProfileSettingsDto(string Username, DateTime? UsernameLastChangedAt, string Bio); \ No newline at end of file diff --git a/src/ApplicationCore/Dtos/Languages/LanguageVersionDto.cs b/src/ApplicationCore/Dtos/Languages/LanguageVersionDto.cs deleted file mode 100644 index d2e1610..0000000 --- a/src/ApplicationCore/Dtos/Languages/LanguageVersionDto.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ApplicationCore.Dtos.Languages; - -public sealed record LanguageVersionDto -{ - public required int Id { get; init; } - - public required string Version { get; init; } - - public string? InitialCode { get; init; } - - public ProgrammingLanguageDto? ProgrammingLanguage { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Dtos/Languages/ProgrammingLanguageDto.cs b/src/ApplicationCore/Dtos/Languages/ProgrammingLanguageDto.cs deleted file mode 100644 index 97d785a..0000000 --- a/src/ApplicationCore/Dtos/Languages/ProgrammingLanguageDto.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ApplicationCore.Dtos.Languages; - -public sealed record ProgrammingLanguageDto -{ - public required int Id { get; init; } - - public required string Name { get; init; } - - public bool IsArchived { get; init; } - - public IEnumerable Versions { get; init; } = []; -} \ No newline at end of file diff --git a/src/ApplicationCore/Dtos/Problems/CreateProblemDto.cs b/src/ApplicationCore/Dtos/Problems/CreateProblemDto.cs deleted file mode 100644 index 9462efe..0000000 --- a/src/ApplicationCore/Dtos/Problems/CreateProblemDto.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace ApplicationCore.Dtos.Problems; - -public sealed record CreateProblemDto( - string Title, - int EstimatedDifficulty, - string Question, - IEnumerable Tags -); \ No newline at end of file diff --git a/src/ApplicationCore/Dtos/Problems/ProblemDto.cs b/src/ApplicationCore/Dtos/Problems/ProblemDto.cs deleted file mode 100644 index ea18b08..0000000 --- a/src/ApplicationCore/Dtos/Problems/ProblemDto.cs +++ /dev/null @@ -1,22 +0,0 @@ -using ApplicationCore.Dtos.Languages; - -namespace ApplicationCore.Dtos.Problems; - -public record ProblemDto -{ - public required Guid Id { get; init; } - - public required string Title { get; init; } - - public required string Slug { get; init; } - - public string? Question { get; init; } - - public required List Tags { get; init; } - - public required int Difficulty { get; init; } - - public required int Version { get; init; } - - public required IEnumerable AvailableLanguages { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Dtos/Problems/ProblemSetupDto.cs b/src/ApplicationCore/Dtos/Problems/ProblemSetupDto.cs deleted file mode 100644 index 0c26a62..0000000 --- a/src/ApplicationCore/Dtos/Problems/ProblemSetupDto.cs +++ /dev/null @@ -1,16 +0,0 @@ -using ApplicationCore.Dtos.Problems.Tests; - -namespace ApplicationCore.Dtos.Problems; - -public record ProblemSetupDto -{ - public int Id { get; init; } - - public int Version { get; init; } - - public required string InitialCode { get; init; } - - public int LanguageVersionId { get; init; } - - public required IEnumerable TestSuites { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Dtos/Problems/ProblemSubmissionDto.cs b/src/ApplicationCore/Dtos/Problems/ProblemSubmissionDto.cs deleted file mode 100644 index d6420af..0000000 --- a/src/ApplicationCore/Dtos/Problems/ProblemSubmissionDto.cs +++ /dev/null @@ -1,14 +0,0 @@ -using ApplicationCore.Dtos.Accounts; - -namespace ApplicationCore.Dtos.Problems; - -public sealed record ProblemSubmissionDto( - AccountDto CreatedBy, - string Code, - string Status, - string Language, - string LanguageVersion, - DateTime CreatedOn, - int RuntimeMs, - int MemoryKb -); \ No newline at end of file diff --git a/src/ApplicationCore/Dtos/Problems/Tests/TestCaseDto.cs b/src/ApplicationCore/Dtos/Problems/Tests/TestCaseDto.cs deleted file mode 100644 index a713195..0000000 --- a/src/ApplicationCore/Dtos/Problems/Tests/TestCaseDto.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ApplicationCore.Dtos.Problems.Tests; - -public record TestCaseDto -{ - public string Input { get; init; } = string.Empty; - public string ExpectedOutput { get; init; } = string.Empty; -} \ No newline at end of file diff --git a/src/ApplicationCore/Dtos/Problems/Tests/TestSuiteDto.cs b/src/ApplicationCore/Dtos/Problems/Tests/TestSuiteDto.cs deleted file mode 100644 index 9cb9b23..0000000 --- a/src/ApplicationCore/Dtos/Problems/Tests/TestSuiteDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace ApplicationCore.Dtos.Problems.Tests; - -public record TestSuiteDto -{ - public required IEnumerable TestCases { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Dtos/Submissions/GetSubmissionsPaginatedRequest.cs b/src/ApplicationCore/Dtos/Submissions/GetSubmissionsPaginatedRequest.cs deleted file mode 100644 index 2a53a6f..0000000 --- a/src/ApplicationCore/Dtos/Submissions/GetSubmissionsPaginatedRequest.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace ApplicationCore.Dtos.Submissions; - -public sealed class GetSubmissionsPaginatedRequest -{ - public required Guid ProblemId { get; init; } - public int Page { get; init; } = 1; - public int Size { get; init; } = 25; - public DateTime Timestamp { get; init; } = DateTime.UtcNow; - public Guid? FilterByUserId { get; init; } - public bool AcceptedOnly { get; init; } = true; -} \ No newline at end of file diff --git a/src/ApplicationCore/Dtos/Submissions/SubmissionDto.cs b/src/ApplicationCore/Dtos/Submissions/SubmissionDto.cs deleted file mode 100644 index e0c99f3..0000000 --- a/src/ApplicationCore/Dtos/Submissions/SubmissionDto.cs +++ /dev/null @@ -1,14 +0,0 @@ -using ApplicationCore.Dtos.Accounts; - -namespace ApplicationCore.Dtos.Submissions; - -public sealed class SubmissionDto -{ - public required Guid Id { get; init; } - public required int ProblemSetupId { get; init; } - public required string Status { get; init; } - public required string Code { get; init; } - public required DateTime CreatedOn { get; init; } - public DateTime? CompletedAt { get; init; } - public AccountDto? CreatedBy { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Dtos/Submissions/SubmissionResultDto.cs b/src/ApplicationCore/Dtos/Submissions/SubmissionResultDto.cs deleted file mode 100644 index b74bd1e..0000000 --- a/src/ApplicationCore/Dtos/Submissions/SubmissionResultDto.cs +++ /dev/null @@ -1,12 +0,0 @@ -using ApplicationCore.Domain.Submissions; - -namespace ApplicationCore.Dtos.Submissions; - -public sealed class SubmissionResultDto -{ - public required Guid Id { get; init; } - public required string Status { get; init; } - public int? RuntimeMs { get; init; } - public int? MemoryKb { get; init; } - public DateTime? FinishedAt { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Dtos/Submissions/SubmissionStatusDto.cs b/src/ApplicationCore/Dtos/Submissions/SubmissionStatusDto.cs deleted file mode 100644 index aedf97f..0000000 --- a/src/ApplicationCore/Dtos/Submissions/SubmissionStatusDto.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace ApplicationCore.Dtos.Submissions; - -public sealed class SubmissionStatusDto -{ - public required Guid SubmissionId { get; init; } - public required string Status { get; init; } - public required IEnumerable TestCases { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Dtos/Submissions/SubmissionTestCaseResultDto.cs b/src/ApplicationCore/Dtos/Submissions/SubmissionTestCaseResultDto.cs deleted file mode 100644 index 1fe79e9..0000000 --- a/src/ApplicationCore/Dtos/Submissions/SubmissionTestCaseResultDto.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace ApplicationCore.Dtos.Submissions; - -public sealed class SubmissionTestCaseResultDto -{ - public string Input { get; init; } = string.Empty; - public string ExpectedOutput { get; init; } = string.Empty; - public string ActualOutput { get; init; } = string.Empty; - public string? ErrorOutput { get; init; } - public int Status { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Interfaces/Clients/IJudge0Client.cs b/src/ApplicationCore/Interfaces/Clients/IJudge0Client.cs deleted file mode 100644 index 14ea798..0000000 --- a/src/ApplicationCore/Interfaces/Clients/IJudge0Client.cs +++ /dev/null @@ -1,19 +0,0 @@ -using ApplicationCore.Domain.CodeExecution.Judge0; -using Ardalis.Result; - -namespace ApplicationCore.Interfaces.Clients; - -public interface IJudge0Client -{ - Task>> GetAsync( - IEnumerable tokens, - CancellationToken cancellationToken, - IEnumerable? fields = null - ); - - Task>> SubmitAsync( - IEnumerable reqs, - CancellationToken cancellationToken, - IEnumerable? fields = null - ); -} \ No newline at end of file diff --git a/src/ApplicationCore/Interfaces/Repositories/IAccountRepository.cs b/src/ApplicationCore/Interfaces/Repositories/IAccountRepository.cs deleted file mode 100644 index 4fdd491..0000000 --- a/src/ApplicationCore/Interfaces/Repositories/IAccountRepository.cs +++ /dev/null @@ -1,32 +0,0 @@ -using ApplicationCore.Domain.Accounts; - -namespace ApplicationCore.Interfaces.Repositories; - -public interface IAccountRepository -{ - Task AddAsync(AccountModel accountModel, CancellationToken cancellationToken); - - Task ExistsAsync(Guid id, CancellationToken cancellationToken); - - Task GetByIdAsync(Guid id, CancellationToken cancellationToken); - - Task GetBySubAsync(string sub, CancellationToken cancellationToken); - - Task GetByUsernameAsync(string username, CancellationToken cancellationToken); - - Task GetByUsernameOrSubAsync( - string username, - string sub, - CancellationToken cancellationToken - ); - - Task UpdateImageUrlAsync(Guid id, string? imageUrl, CancellationToken cancellationToken); - - Task UpdateUsernameAsync(Guid id, string username, DateTime usernameLastChangedAt, CancellationToken cancellationToken); - - Task UpdateAboutAsync(Guid id, string? about, CancellationToken cancellationToken); - - Task ExistsByUsernameAsync(string username, CancellationToken cancellationToken); - - Task CountByUsernameBaseAsync(string usernameBase, CancellationToken cancellationToken); -} \ No newline at end of file diff --git a/src/ApplicationCore/Interfaces/Repositories/IProblemRepository.cs b/src/ApplicationCore/Interfaces/Repositories/IProblemRepository.cs deleted file mode 100644 index a00528c..0000000 --- a/src/ApplicationCore/Interfaces/Repositories/IProblemRepository.cs +++ /dev/null @@ -1,33 +0,0 @@ -using ApplicationCore.Common.Pagination; -using ApplicationCore.Domain.Problems; -using ApplicationCore.Domain.Problems.Languages; -using ApplicationCore.Domain.Problems.ProblemSetups; - -namespace ApplicationCore.Interfaces.Repositories; - -public interface IProblemRepository -{ - Task CreateProblemAsync(ProblemModel problem, CancellationToken cancellationToken); - - Task> GetAvailableLanguagesAsync( - CancellationToken cancellationToken - ); - - Task GetProblemBySlugAsync(string slug, CancellationToken cancellationToken); - - Task GetProblemSetupAsync( - Guid problemId, - int languageVersionId, - CancellationToken cancellationToken - ); - - Task> GetProblemsAsync( - PaginationRequest pagination, - CancellationToken cancellationToken - ); - - Task> GetProblemSetupsAsync( - IEnumerable problemSetupIds, - CancellationToken cancellationToken - ); -} \ No newline at end of file diff --git a/src/ApplicationCore/Interfaces/Repositories/ISubmissionRepository.cs b/src/ApplicationCore/Interfaces/Repositories/ISubmissionRepository.cs deleted file mode 100644 index 7a88cd4..0000000 --- a/src/ApplicationCore/Interfaces/Repositories/ISubmissionRepository.cs +++ /dev/null @@ -1,50 +0,0 @@ -using ApplicationCore.Common.Pagination; -using ApplicationCore.Domain.Submissions; -using ApplicationCore.Domain.Submissions.Outboxes; - -namespace ApplicationCore.Interfaces.Repositories; - -public interface ISubmissionRepository -{ - Task> GetSubmissionOutboxesAsync( - CancellationToken cancellationToken - ); - - Task SaveAsync(SubmissionModel submission, CancellationToken cancellationToken); - - Task IncrementOutboxesCountAsync( - IEnumerable outboxIds, - DateTime now, - CancellationToken cancellationToken - ); - - Task SaveExecutionTokensAsync( - IEnumerable submissions, - CancellationToken cancellationToken - ); - - Task ProcessPollingSubmissionExecutionsAsync( - IEnumerable submissionModels, - CancellationToken cancellationToken - ); - - Task ProcessEvaluationAsync( - IEnumerable submissions, - CancellationToken cancellationToken - ); - - Task FinalizeEvaluationAsync( - IEnumerable outboxIds, - DateTime now, - CancellationToken cancellationToken - ); - - Task ProcessSubmissionInitializationAsync( - IEnumerable submissions, - CancellationToken cancellationToken - ); - - Task> GetSubmissionsByProblemId(Guid problemId, Guid? accountId, PaginationRequest pagination, SubmissionStatus? statusFilter, CancellationToken cancellationToken); - - Task GetSubmissionByIdAsync(Guid submissionId, CancellationToken cancellationToken); -} \ No newline at end of file diff --git a/src/ApplicationCore/Interfaces/Services/IAccountAppService.cs b/src/ApplicationCore/Interfaces/Services/IAccountAppService.cs deleted file mode 100644 index 279d685..0000000 --- a/src/ApplicationCore/Interfaces/Services/IAccountAppService.cs +++ /dev/null @@ -1,48 +0,0 @@ -using ApplicationCore.Commands.Accounts.UpdateProfileSettings; -using ApplicationCore.Commands.Accounts.UpdateUsername; -using ApplicationCore.Commands.Accounts.UpsertAccount; -using ApplicationCore.Dtos.Accounts; -using Ardalis.Result; - -namespace ApplicationCore.Interfaces.Services; - -public interface IAccountAppService -{ - Task> CreateAsync( - string username, - string sub, - string imageUrl, - CancellationToken cancellationToken - ); - - Task> GetAccountBySubAsync(string sub, CancellationToken cancellationToken); - - Task> GetProfileAggregateAsync( - string username, - CancellationToken cancellationToken - ); - - Task> GetProfileSettingsAsync( - string sub, - CancellationToken cancellationToken - ); - - Task> UpsertAccountAsync( - string sub, - string? imageUrl, - CancellationToken cancellationToken - ); - - Task> UpdateUsernameAsync( - Guid accountId, - string newUsername, - DateTime? usernameLastChangedAt, - CancellationToken cancellationToken - ); - - Task> UpdateProfileSettingsAsync( - Guid accountId, - string? bio, - CancellationToken cancellationToken - ); -} \ No newline at end of file diff --git a/src/ApplicationCore/Interfaces/Services/ICodeBuilderService.cs b/src/ApplicationCore/Interfaces/Services/ICodeBuilderService.cs deleted file mode 100644 index 9026c03..0000000 --- a/src/ApplicationCore/Interfaces/Services/ICodeBuilderService.cs +++ /dev/null @@ -1,9 +0,0 @@ -using ApplicationCore.Domain.CodeExecution; -using Ardalis.Result; - -namespace ApplicationCore.Interfaces.Services; - -public interface ICodeBuilderService -{ - Result> Build(IEnumerable contexts); -} \ No newline at end of file diff --git a/src/ApplicationCore/Interfaces/Services/ICodeExecutionService.cs b/src/ApplicationCore/Interfaces/Services/ICodeExecutionService.cs deleted file mode 100644 index 1ccdcec..0000000 --- a/src/ApplicationCore/Interfaces/Services/ICodeExecutionService.cs +++ /dev/null @@ -1,18 +0,0 @@ -using ApplicationCore.Domain.CodeExecution; -using ApplicationCore.Domain.Submissions; -using Ardalis.Result; - -namespace ApplicationCore.Interfaces.Services; - -public interface ICodeExecutionService -{ - Task>> ExecuteAsync( - IEnumerable contexts, - CancellationToken cancellationToken - ); - - Task>> GetSubmissionResultsAsync( - IEnumerable submissions, - CancellationToken cancellationToken - ); -} \ No newline at end of file diff --git a/src/ApplicationCore/Interfaces/Services/IExecutionComparisonService.cs b/src/ApplicationCore/Interfaces/Services/IExecutionComparisonService.cs deleted file mode 100644 index d2ce5c1..0000000 --- a/src/ApplicationCore/Interfaces/Services/IExecutionComparisonService.cs +++ /dev/null @@ -1,8 +0,0 @@ -using ApplicationCore.Domain.Submissions; - -namespace ApplicationCore.Interfaces.Services; - -public interface IExecutionComparisonService -{ - SubmissionStatus Compare(string? actualOutput, string expectedOutput); -} \ No newline at end of file diff --git a/src/ApplicationCore/Interfaces/Services/IProblemAppService.cs b/src/ApplicationCore/Interfaces/Services/IProblemAppService.cs deleted file mode 100644 index 0445d29..0000000 --- a/src/ApplicationCore/Interfaces/Services/IProblemAppService.cs +++ /dev/null @@ -1,37 +0,0 @@ -using ApplicationCore.Common.Pagination; -using ApplicationCore.Domain.Problems.ProblemSetups; -using ApplicationCore.Dtos.Languages; -using ApplicationCore.Dtos.Problems; -using Ardalis.Result; - -namespace ApplicationCore.Interfaces.Services; - -public interface IProblemAppService -{ - Task>> GetAvailableLanguagesAsync( - CancellationToken cancellationToken - ); - - Task> GetProblemBySlugAsync( - string slug, - CancellationToken cancellationToken - ); - - Task>> GetProblemsPaginatedAsync( - int pageNumber, - int pageSize, - DateTime timestamp, - CancellationToken cancellationToken - ); - - Task>> GetProblemSetupsForExecutionAsync( - IEnumerable setupIds, - CancellationToken cancellationToken - ); - - Task> GetProblemSetupAsync( - Guid problemId, - int languageVersionId, - CancellationToken cancellationToken - ); -} \ No newline at end of file diff --git a/src/ApplicationCore/Interfaces/Services/ISlugService.cs b/src/ApplicationCore/Interfaces/Services/ISlugService.cs deleted file mode 100644 index 02d5274..0000000 --- a/src/ApplicationCore/Interfaces/Services/ISlugService.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace ApplicationCore.Interfaces.Services; - -public interface ISlugService -{ - string GenerateSlug(string input); -} \ No newline at end of file diff --git a/src/ApplicationCore/Interfaces/Services/ISubmissionAppService.cs b/src/ApplicationCore/Interfaces/Services/ISubmissionAppService.cs deleted file mode 100644 index 018d9f3..0000000 --- a/src/ApplicationCore/Interfaces/Services/ISubmissionAppService.cs +++ /dev/null @@ -1,70 +0,0 @@ -using ApplicationCore.Common.Pagination; -using ApplicationCore.Domain.Submissions; -using ApplicationCore.Domain.Submissions.Outboxes; -using ApplicationCore.Dtos.Problems; -using ApplicationCore.Dtos.Submissions; -using Ardalis.Result; -using MediatR; - -namespace ApplicationCore.Interfaces.Services; - -public interface ISubmissionAppService -{ - Task> CreateAsync( - int problemSetupId, - string code, - Guid createdById, - CancellationToken cancellationToken - ); - - Task>> GetSubmissionOutboxesAsync( - CancellationToken cancellationToken - ); - - Task> IncrementOutboxesCountAsync( - IEnumerable outboxIds, - DateTime timestamp, - CancellationToken cancellationToken - ); - - Task> SaveExecutionTokensAsync( - IEnumerable results, - CancellationToken cancellationToken - ); - - Task> ProcessSubmissionExecutionAsync( - IEnumerable results, - CancellationToken cancellationToken - ); - - Task> ProcessPollingSubmissionExecutionsAsync( - IEnumerable results, - CancellationToken cancellationToken - ); - - Task> ProcessEvaluationAsync( - IEnumerable results, - CancellationToken cancellationToken - ); - - Task> FinalizeEvaluationAsync( - IEnumerable outboxIds, - DateTime now, - CancellationToken cancellationToken - ); - - Task>> GetSolutionsAsync( - Guid problemId, - PaginationRequest paginationRequest, - CancellationToken cancellationToken - ); - - Task>> GetSubmissionsPaginatedAsync( - Guid problemId, - Guid accountId, - PaginationRequest paginationRequest, - CancellationToken cancellationToken = default - ); - - Task> GetSubmissionStatusAsync(Guid submissionId, CancellationToken cancellationToken); -} \ No newline at end of file diff --git a/src/ApplicationCore/Logging/LoggingEventIds.cs b/src/ApplicationCore/Logging/LoggingEventIds.cs deleted file mode 100644 index 5529832..0000000 --- a/src/ApplicationCore/Logging/LoggingEventIds.cs +++ /dev/null @@ -1,89 +0,0 @@ -namespace ApplicationCore.Logging; - -public static class LoggingEventIds -{ - public static class Accounts - { - public const int UsernameInvalid = 2000; - public const int DuplicateUsername = 2001; - public const int DuplicateSub = 2002; - public const int DuplicateUsernameOrSub = 2003; - - public const int CreateAttempt = 2100; - public const int Created = 2101; - public const int CreateDuplicateDetectedPreQuery = 2102; - public const int CreateDuplicateRace = 2103; - public const int NotFoundBySub = 2104; - public const int CreateFailed = 2105; - - public const int UpdateUsernameFailed = 2301; - - public const int ContextMissingSub = 2200; - public const int ContextResolveFailed = 2201; - } - - public static class Exceptions - { - public const int UnhandledException = 1000; - public const int UnhandledExceptionWithPath = 1001; - } - - public static class Jobs - { - public const int Started = 3000; - public const int Completed = 3001; - public const int Failed = 3002; - - public const int SubmissionExecutionProcessing = 3100; - - public const int PollSubmissionExecutionPolling = 3200; - - public const int EvaluateSubmissionEvaluating = 3300; - public const int EvaluateSubmissionEvaluated = 3301; - - public const int PollEvaluationFinalizing = 3400; - } - - public static class Submissions - { - public const int Created = 4000; - public const int CreateFailed = 4001; - - // Stage 1: SubmissionCreatedConsumer - public const int Stage1Started = 4100; - public const int Stage1OutboxNotFound = 4101; - public const int Stage1SetupFailed = 4102; - public const int Stage1BuildFailed = 4103; - public const int Stage1ExecutionFailed = 4104; - public const int Stage1Completed = 4105; - - // Stage 2: SubmissionExecutedConsumer (poll Judge0) - public const int Stage2Started = 4200; - public const int Stage2OutboxNotFound = 4201; - public const int Stage2PollFailed = 4202; - public const int Stage2StillProcessing = 4203; - public const int Stage2Completed = 4204; - - // Stage 3: SubmissionReadyToEvaluateConsumer - public const int Stage3Started = 4300; - public const int Stage3OutboxNotFound = 4301; - public const int Stage3SetupNotFound = 4302; - public const int Stage3Completed = 4303; - - // Stage 4: SubmissionEvaluationPollConsumer - public const int Stage4Started = 4400; - public const int Stage4OutboxNotFound = 4401; - public const int Stage4Completed = 4402; - } - - public static class Judge0 - { - public const int SubmitStarted = 5000; - public const int SubmitCompleted = 5001; - public const int SubmitFailed = 5002; - - public const int GetStarted = 5100; - public const int GetCompleted = 5101; - public const int GetFailed = 5102; - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Mappings/ProblemMappings.cs b/src/ApplicationCore/Mappings/ProblemMappings.cs deleted file mode 100644 index d550776..0000000 --- a/src/ApplicationCore/Mappings/ProblemMappings.cs +++ /dev/null @@ -1,22 +0,0 @@ -using ApplicationCore.Domain.Problems; -using ApplicationCore.Domain.Problems.Languages; -using ApplicationCore.Dtos.Languages; -using ApplicationCore.Dtos.Problems; -using Mapster; - -namespace ApplicationCore.Mappings; - -public sealed class ProblemMappings : IRegister -{ - public void Register(TypeAdapterConfig config) - { - config - .NewConfig() - .Map(dest => dest.Tags, src => src.Tags.Select(tag => tag.Value).ToList()) - .Map(d => d.AvailableLanguages, s => s.GetAvailableLanguages()); - - config.NewConfig(); - - config.NewConfig(); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Messaging/SubmissionCreatedMessage.cs b/src/ApplicationCore/Messaging/SubmissionCreatedMessage.cs deleted file mode 100644 index ccd0f15..0000000 --- a/src/ApplicationCore/Messaging/SubmissionCreatedMessage.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ApplicationCore.Messaging; - -public sealed record SubmissionCreatedMessage -{ - public required Guid SubmissionId { get; init; } - public required Guid OutboxId { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Messaging/SubmissionEvaluationPollMessage.cs b/src/ApplicationCore/Messaging/SubmissionEvaluationPollMessage.cs deleted file mode 100644 index baf3fb6..0000000 --- a/src/ApplicationCore/Messaging/SubmissionEvaluationPollMessage.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ApplicationCore.Messaging; - -public sealed record SubmissionEvaluationPollMessage -{ - public required Guid SubmissionId { get; init; } - public required Guid OutboxId { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Messaging/SubmissionExecutedMessage.cs b/src/ApplicationCore/Messaging/SubmissionExecutedMessage.cs deleted file mode 100644 index ce56402..0000000 --- a/src/ApplicationCore/Messaging/SubmissionExecutedMessage.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ApplicationCore.Messaging; - -public sealed record SubmissionExecutedMessage -{ - public required Guid SubmissionId { get; init; } - public required Guid OutboxId { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Messaging/SubmissionExecutionPollMessage.cs b/src/ApplicationCore/Messaging/SubmissionExecutionPollMessage.cs deleted file mode 100644 index bfce494..0000000 --- a/src/ApplicationCore/Messaging/SubmissionExecutionPollMessage.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ApplicationCore.Messaging; - -public sealed record SubmissionExecutionPollMessage -{ - public required Guid SubmissionId { get; init; } - public required Guid OutboxId { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Messaging/SubmissionReadyToEvaluateMessage.cs b/src/ApplicationCore/Messaging/SubmissionReadyToEvaluateMessage.cs deleted file mode 100644 index fae4765..0000000 --- a/src/ApplicationCore/Messaging/SubmissionReadyToEvaluateMessage.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ApplicationCore.Messaging; - -public sealed record SubmissionReadyToEvaluateMessage -{ - public required Guid SubmissionId { get; init; } - public required Guid OutboxId { get; init; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Accounts/GetAccountBySub/GetAccountBySubHandler.cs b/src/ApplicationCore/Queries/Accounts/GetAccountBySub/GetAccountBySubHandler.cs deleted file mode 100644 index e556493..0000000 --- a/src/ApplicationCore/Queries/Accounts/GetAccountBySub/GetAccountBySubHandler.cs +++ /dev/null @@ -1,41 +0,0 @@ -using ApplicationCore.Dtos.Accounts; -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; - -namespace ApplicationCore.Queries.Accounts.GetAccountBySub; - -public sealed class GetAccountBySubHandler(IAccountRepository repository) - : IQueryHandler -{ - public async Task> Handle(GetAccountBySubQuery request, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(request.Sub)) - { - return Result.Invalid([new ValidationError(nameof(request.Sub), "Sub is required")]); - } - - try - { - var account = await repository.GetBySubAsync(request.Sub, ct); - - if (account is null) - { - return Result.NotFound(); - } - - var dto = new AccountDto - { - Id = account.Id, - Username = account.Username, - ImageUrl = account.ImageUrl, - CreatedOn = account.CreatedOn, - UsernameLastChangedAt = account.UsernameLastChangedAt, - }; - return Result.Success(dto); - } - catch (Exception ex) - { - return Result.Error(ex.Message); - } - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Accounts/GetAccountBySub/GetAccountBySubQuery.cs b/src/ApplicationCore/Queries/Accounts/GetAccountBySub/GetAccountBySubQuery.cs deleted file mode 100644 index 9e741dc..0000000 --- a/src/ApplicationCore/Queries/Accounts/GetAccountBySub/GetAccountBySubQuery.cs +++ /dev/null @@ -1,5 +0,0 @@ -using ApplicationCore.Dtos.Accounts; - -namespace ApplicationCore.Queries.Accounts.GetAccountBySub; - -public sealed record GetAccountBySubQuery(string Sub) : IQuery; \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Accounts/GetProfileAggregate/GetProfileAggregateHandler.cs b/src/ApplicationCore/Queries/Accounts/GetProfileAggregate/GetProfileAggregateHandler.cs deleted file mode 100644 index e043475..0000000 --- a/src/ApplicationCore/Queries/Accounts/GetProfileAggregate/GetProfileAggregateHandler.cs +++ /dev/null @@ -1,49 +0,0 @@ -using ApplicationCore.Dtos.Accounts; -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; - -namespace ApplicationCore.Queries.Accounts.GetProfileAggregate; - -public sealed class GetProfileAggregateHandler(IAccountRepository repository) - : IQueryHandler -{ - public async Task> Handle( - GetProfileAggregateQuery request, - CancellationToken cancellationToken - ) - { - if (string.IsNullOrWhiteSpace(request.Username)) - { - return Result.Invalid([ - new ValidationError(nameof(request.Username), "Username is required"), - ]); - } - - try - { - var profile = await repository.GetByUsernameAsync(request.Username, cancellationToken); - - if (profile is null) - { - return Result.NotFound(); - } - - var dto = new ProfileAggregateDto( - new AccountDto - { - Id = profile.Id, - Username = profile.Username, - About = profile.About, - ImageUrl = profile.ImageUrl, - CreatedOn = profile.CreatedOn, - } - ); - - return Result.Success(dto); - } - catch (Exception ex) - { - return Result.Error(ex.Message); - } - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Accounts/GetProfileAggregate/GetProfileAggregateQuery.cs b/src/ApplicationCore/Queries/Accounts/GetProfileAggregate/GetProfileAggregateQuery.cs deleted file mode 100644 index 5bf57ad..0000000 --- a/src/ApplicationCore/Queries/Accounts/GetProfileAggregate/GetProfileAggregateQuery.cs +++ /dev/null @@ -1,5 +0,0 @@ -using ApplicationCore.Dtos.Accounts; - -namespace ApplicationCore.Queries.Accounts.GetProfileAggregate; - -public record GetProfileAggregateQuery(string Username) : IQuery; \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Accounts/GetProfileSettings/GetProfileSettingsHandler.cs b/src/ApplicationCore/Queries/Accounts/GetProfileSettings/GetProfileSettingsHandler.cs deleted file mode 100644 index 68bf07e..0000000 --- a/src/ApplicationCore/Queries/Accounts/GetProfileSettings/GetProfileSettingsHandler.cs +++ /dev/null @@ -1,28 +0,0 @@ -using ApplicationCore.Dtos.Accounts; -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; - -namespace ApplicationCore.Queries.Accounts.GetProfileSettings; - -public sealed class GetProfileSettingsHandler(IAccountRepository accountRepository) - : IQueryHandler -{ - public async Task> Handle( - GetProfileSettingsQuery request, - CancellationToken cancellationToken - ) - { - try - { - var account = await accountRepository.GetBySubAsync(request.Sub, cancellationToken); - - return account is null - ? Result.NotFound() - : Result.Success(new ProfileSettingsDto(account.Username, account.UsernameLastChangedAt, account.About ?? "")); - } - catch (Exception ex) - { - return Result.Error(ex.Message); - } - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Accounts/GetProfileSettings/GetProfileSettingsQuery.cs b/src/ApplicationCore/Queries/Accounts/GetProfileSettings/GetProfileSettingsQuery.cs deleted file mode 100644 index b149390..0000000 --- a/src/ApplicationCore/Queries/Accounts/GetProfileSettings/GetProfileSettingsQuery.cs +++ /dev/null @@ -1,5 +0,0 @@ -using ApplicationCore.Dtos.Accounts; - -namespace ApplicationCore.Queries.Accounts.GetProfileSettings; - -public sealed record GetProfileSettingsQuery(string Sub) : IQuery; \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Problems/GetAvailableLanguages/GetAvailableLanguagesHandler.cs b/src/ApplicationCore/Queries/Problems/GetAvailableLanguages/GetAvailableLanguagesHandler.cs deleted file mode 100644 index e437feb..0000000 --- a/src/ApplicationCore/Queries/Problems/GetAvailableLanguages/GetAvailableLanguagesHandler.cs +++ /dev/null @@ -1,38 +0,0 @@ -using ApplicationCore.Dtos.Languages; -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; - -namespace ApplicationCore.Queries.Problems.GetAvailableLanguages; - -public sealed class GetAvailableLanguagesHandler(IProblemRepository problemRepository) - : IQueryHandler> -{ - public async Task>> Handle( - GetAvailableLanguagesQuery request, - CancellationToken cancellationToken - ) - { - try - { - var languages = await problemRepository.GetAvailableLanguagesAsync(cancellationToken); - - return Result.Success( - languages.Select(language => new ProgrammingLanguageDto - { - Id = language.Id, - Name = language.Name, - Versions = language.Versions.Select(version => new LanguageVersionDto - { - Id = version.Id, - Version = version.Version, - InitialCode = version.InitialCode, - }), - }) - ); - } - catch (Exception ex) - { - return Result.Error(ex.Message); - } - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Problems/GetAvailableLanguages/GetAvailableLanguagesQuery.cs b/src/ApplicationCore/Queries/Problems/GetAvailableLanguages/GetAvailableLanguagesQuery.cs deleted file mode 100644 index 3e35ed9..0000000 --- a/src/ApplicationCore/Queries/Problems/GetAvailableLanguages/GetAvailableLanguagesQuery.cs +++ /dev/null @@ -1,5 +0,0 @@ -using ApplicationCore.Dtos.Languages; - -namespace ApplicationCore.Queries.Problems.GetAvailableLanguages; - -public sealed record GetAvailableLanguagesQuery() : IQuery>; \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Problems/GetProblemBySlug/GetProblemBySlugHandler.cs b/src/ApplicationCore/Queries/Problems/GetProblemBySlug/GetProblemBySlugHandler.cs deleted file mode 100644 index c634c34..0000000 --- a/src/ApplicationCore/Queries/Problems/GetProblemBySlug/GetProblemBySlugHandler.cs +++ /dev/null @@ -1,32 +0,0 @@ -using ApplicationCore.Dtos.Problems; -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; -using Mapster; - -namespace ApplicationCore.Queries.Problems.GetProblemBySlug; - -public sealed class GetProblemBySlugHandler(IProblemRepository problemRepository) - : IQueryHandler -{ - public async Task> Handle( - GetProblemBySlugQuery request, - CancellationToken cancellationToken - ) - { - try - { - var problem = await problemRepository.GetProblemBySlugAsync( - request.Slug, - cancellationToken - ); - - return problem is null - ? Result.NotFound() - : Result.Success(problem.Adapt()); - } - catch (Exception ex) - { - return Result.Error(ex.Message); - } - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Problems/GetProblemBySlug/GetProblemBySlugQuery.cs b/src/ApplicationCore/Queries/Problems/GetProblemBySlug/GetProblemBySlugQuery.cs deleted file mode 100644 index 713ef12..0000000 --- a/src/ApplicationCore/Queries/Problems/GetProblemBySlug/GetProblemBySlugQuery.cs +++ /dev/null @@ -1,5 +0,0 @@ -using ApplicationCore.Dtos.Problems; - -namespace ApplicationCore.Queries.Problems.GetProblemBySlug; - -public sealed record GetProblemBySlugQuery(string Slug) : IQuery; \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Problems/GetProblemSetup/GetProblemSetupHandler.cs b/src/ApplicationCore/Queries/Problems/GetProblemSetup/GetProblemSetupHandler.cs deleted file mode 100644 index 1f66dba..0000000 --- a/src/ApplicationCore/Queries/Problems/GetProblemSetup/GetProblemSetupHandler.cs +++ /dev/null @@ -1,52 +0,0 @@ -using ApplicationCore.Domain.Problems.TestSuites; -using ApplicationCore.Dtos.Problems; -using ApplicationCore.Dtos.Problems.Tests; -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; - -namespace ApplicationCore.Queries.Problems.GetProblemSetup; - - -public sealed class GetProblemSetupHandler(IProblemRepository problemRepository) - : IQueryHandler -{ - private readonly IProblemRepository _problemRepository = - problemRepository ?? throw new ArgumentNullException(nameof(problemRepository)); - - public async Task> Handle( - GetProblemSetupQuery request, - CancellationToken cancellationToken - ) - { - var setup = ( - await _problemRepository.GetProblemSetupAsync( - request.ProblemId, - request.LanguageVersionId, - cancellationToken - ) - ); - - if (setup is null) - { - return Result.NotFound(); - } - - return new ProblemSetupDto() - { - Id = setup.Id, - Version = setup.Version, - InitialCode = setup.InitialCode, - LanguageVersionId = setup.LanguageVersionId, - TestSuites = setup - .TestSuites.Where(ts => ts.TestSuiteType == TestSuiteType.Public) - .Select(ts => new TestSuiteDto() - { - TestCases = ts.TestCases.Select(tc => new TestCaseDto() - { - Input = string.Join(",", tc.Inputs.Select(input => input.Value.Trim())), - ExpectedOutput = tc.ExpectedOutput.Value, - }), - }), - }; - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Problems/GetProblemSetup/GetProblemSetupQuery.cs b/src/ApplicationCore/Queries/Problems/GetProblemSetup/GetProblemSetupQuery.cs deleted file mode 100644 index 0a2513b..0000000 --- a/src/ApplicationCore/Queries/Problems/GetProblemSetup/GetProblemSetupQuery.cs +++ /dev/null @@ -1,6 +0,0 @@ -using ApplicationCore.Dtos.Problems; - -namespace ApplicationCore.Queries.Problems.GetProblemSetup; - -public sealed record GetProblemSetupQuery(Guid ProblemId, int LanguageVersionId) - : IQuery; \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Problems/GetProblemSetupsForExecution/GetProblemSetupsForExecutionHandler.cs b/src/ApplicationCore/Queries/Problems/GetProblemSetupsForExecution/GetProblemSetupsForExecutionHandler.cs deleted file mode 100644 index 7f3f200..0000000 --- a/src/ApplicationCore/Queries/Problems/GetProblemSetupsForExecution/GetProblemSetupsForExecutionHandler.cs +++ /dev/null @@ -1,26 +0,0 @@ -using ApplicationCore.Domain.Problems.ProblemSetups; -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; - -namespace ApplicationCore.Queries.Problems.GetProblemSetupsForExecution; - -public sealed class GetProblemSetupsForExecutionHandler(IProblemRepository problemRepository) - : IQueryHandler> -{ - public async Task>> Handle( - GetProblemSetupsForExecutionQuery request, - CancellationToken cancellationToken - ) - { - try - { - return Result.Success( - await problemRepository.GetProblemSetupsAsync(request.SetupIds, cancellationToken) - ); - } - catch (Exception ex) - { - return Result.Error(ex.Message); - } - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Problems/GetProblemSetupsForExecution/GetProblemSetupsForExecutionQuery.cs b/src/ApplicationCore/Queries/Problems/GetProblemSetupsForExecution/GetProblemSetupsForExecutionQuery.cs deleted file mode 100644 index 79e9221..0000000 --- a/src/ApplicationCore/Queries/Problems/GetProblemSetupsForExecution/GetProblemSetupsForExecutionQuery.cs +++ /dev/null @@ -1,6 +0,0 @@ -using ApplicationCore.Domain.Problems.ProblemSetups; - -namespace ApplicationCore.Queries.Problems.GetProblemSetupsForExecution; - -public sealed record GetProblemSetupsForExecutionQuery(IEnumerable SetupIds) - : IQuery>; \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Problems/GetProblemsPageable/GetProblemsPageableHandler.cs b/src/ApplicationCore/Queries/Problems/GetProblemsPageable/GetProblemsPageableHandler.cs deleted file mode 100644 index 213d558..0000000 --- a/src/ApplicationCore/Queries/Problems/GetProblemsPageable/GetProblemsPageableHandler.cs +++ /dev/null @@ -1,53 +0,0 @@ -using ApplicationCore.Common.Pagination; -using ApplicationCore.Dtos.Problems; -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; - -namespace ApplicationCore.Queries.Problems.GetProblemsPageable; - -public sealed class GetProblemsPageableHandler(IProblemRepository repository) - : IQueryHandler> -{ - private readonly IProblemRepository _repository = repository; - - public async Task>> Handle( - GetProblemsPageableQuery request, - CancellationToken cancellationToken - ) - { - try - { - var problemPage = await _repository.GetProblemsAsync( - request.Pagination, - cancellationToken - ); - - var dtoItems = problemPage - .Results.Select(p => new ProblemDto - { - Id = p.Id, - Title = p.Title, - Slug = p.Slug, - Tags = [.. p.Tags.Select(t => t.Value)], - Difficulty = p.Difficulty, - Version = p.Version, - AvailableLanguages = [], - }) - .ToList(); - - var dtoPage = new PaginatedResult - { - Results = dtoItems, - Total = problemPage.Total, - Page = problemPage.Page, - Size = problemPage.Size, - }; - - return Result.Success(dtoPage); - } - catch (Exception ex) - { - return Result.Error(ex.Message); - } - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Problems/GetProblemsPageable/GetProblemsPageableQuery.cs b/src/ApplicationCore/Queries/Problems/GetProblemsPageable/GetProblemsPageableQuery.cs deleted file mode 100644 index 50117d4..0000000 --- a/src/ApplicationCore/Queries/Problems/GetProblemsPageable/GetProblemsPageableQuery.cs +++ /dev/null @@ -1,7 +0,0 @@ -using ApplicationCore.Common.Pagination; -using ApplicationCore.Dtos.Problems; - -namespace ApplicationCore.Queries.Problems.GetProblemsPageable; - -public sealed record GetProblemsPageableQuery(PaginationRequest Pagination) - : IQuery>; \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Submissions/GetSolutionsByProblemIdQuery/GetSolutionsByProblemIdHandler.cs b/src/ApplicationCore/Queries/Submissions/GetSolutionsByProblemIdQuery/GetSolutionsByProblemIdHandler.cs deleted file mode 100644 index 082e222..0000000 --- a/src/ApplicationCore/Queries/Submissions/GetSolutionsByProblemIdQuery/GetSolutionsByProblemIdHandler.cs +++ /dev/null @@ -1,40 +0,0 @@ -using ApplicationCore.Common.Pagination; -using ApplicationCore.Domain.Submissions; -using ApplicationCore.Dtos.Accounts; -using ApplicationCore.Dtos.Problems; -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; - -namespace ApplicationCore.Queries.Submissions.GetSolutionsByProblemIdQuery; - -public sealed class GetSolutionsByProblemIdHandler(ISubmissionRepository submissionRepository) : IQueryHandler> -{ - public async Task>> Handle(GetSolutionsByProblemIdQuery request, CancellationToken cancellationToken) - { - var pageResult = await submissionRepository.GetSubmissionsByProblemId(problemId: request.ProblemId, accountId: null, pagination: request.Pagination, statusFilter: SubmissionStatus.Accepted, cancellationToken: cancellationToken); - - var dtos = pageResult.Results.Select(submission => new ProblemSubmissionDto( - CreatedBy: submission?.CreatedBy != null ? new AccountDto - { - Username = submission.CreatedBy.Username, - CreatedOn = submission.CreatedBy.CreatedOn, - ImageUrl = submission.CreatedBy.ImageUrl - } : null, - Code: submission.Code, - Status: submission.GetOverallStatus().ToString(), - Language: submission.LanguageVersion.ProgrammingLanguage.Name, - LanguageVersion: submission.LanguageVersion.Version, - CreatedOn: submission.CreatedOn, - RuntimeMs: submission.GetAverageRuntimeMs(), - MemoryKb: submission.GetAverageMemoryKb() - )) ?? []; - - return Result.Success(new PaginatedResult - { - Results = [.. dtos], - Total = pageResult.Total, - Page = pageResult.Page, - Size = pageResult.Size, - }); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Submissions/GetSolutionsByProblemIdQuery/GetSolutionsByProblemIdQuery.cs b/src/ApplicationCore/Queries/Submissions/GetSolutionsByProblemIdQuery/GetSolutionsByProblemIdQuery.cs deleted file mode 100644 index cc198ca..0000000 --- a/src/ApplicationCore/Queries/Submissions/GetSolutionsByProblemIdQuery/GetSolutionsByProblemIdQuery.cs +++ /dev/null @@ -1,10 +0,0 @@ -using ApplicationCore.Common.Pagination; -using ApplicationCore.Domain.Submissions; -using ApplicationCore.Dtos.Problems; - -namespace ApplicationCore.Queries.Submissions.GetSolutionsByProblemIdQuery; - -public sealed record GetSolutionsByProblemIdQuery( - Guid ProblemId, - PaginationRequest Pagination -) : IQuery>; \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Submissions/GetSubmissionOutboxes/GetSubmissionOutboxesHandler.cs b/src/ApplicationCore/Queries/Submissions/GetSubmissionOutboxes/GetSubmissionOutboxesHandler.cs deleted file mode 100644 index eaa9349..0000000 --- a/src/ApplicationCore/Queries/Submissions/GetSubmissionOutboxes/GetSubmissionOutboxesHandler.cs +++ /dev/null @@ -1,26 +0,0 @@ -using ApplicationCore.Domain.Submissions.Outboxes; -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; - -namespace ApplicationCore.Queries.Submissions.GetSubmissionOutboxes; - -public class GetSubmissionOutboxesHandler(ISubmissionRepository submissionRepository) - : IQueryHandler> -{ - public async Task>> Handle( - GetSubmissionOutboxesQuery request, - CancellationToken cancellationToken - ) - { - try - { - return Result.Success( - await submissionRepository.GetSubmissionOutboxesAsync(cancellationToken) - ); - } - catch (Exception ex) - { - return Result.Error(ex.Message); - } - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Submissions/GetSubmissionOutboxes/GetSubmissionOutboxesQuery.cs b/src/ApplicationCore/Queries/Submissions/GetSubmissionOutboxes/GetSubmissionOutboxesQuery.cs deleted file mode 100644 index d6c5fb4..0000000 --- a/src/ApplicationCore/Queries/Submissions/GetSubmissionOutboxes/GetSubmissionOutboxesQuery.cs +++ /dev/null @@ -1,5 +0,0 @@ -using ApplicationCore.Domain.Submissions.Outboxes; - -namespace ApplicationCore.Queries.Submissions.GetSubmissionOutboxes; - -public sealed record GetSubmissionOutboxesQuery() : IQuery>; \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Submissions/GetSubmissionStatus/GetSubmissionStatusHandler.cs b/src/ApplicationCore/Queries/Submissions/GetSubmissionStatus/GetSubmissionStatusHandler.cs deleted file mode 100644 index 415930a..0000000 --- a/src/ApplicationCore/Queries/Submissions/GetSubmissionStatus/GetSubmissionStatusHandler.cs +++ /dev/null @@ -1,64 +0,0 @@ -using ApplicationCore.Domain.Problems.TestSuites; -using ApplicationCore.Dtos.Submissions; -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; - -namespace ApplicationCore.Queries.Submissions.GetSubmissionStatus; - -public sealed class GetSubmissionStatusHandler( - ISubmissionRepository submissionRepository, - IProblemRepository problemRepository -) : IQueryHandler -{ - public async Task> Handle(GetSubmissionStatusQuery request, CancellationToken cancellationToken) - { - var submission = await submissionRepository.GetSubmissionByIdAsync(request.SubmissionId, cancellationToken); - - if (submission is null) - { - return Result.NotFound(); - } - - var results = submission.Results.ToList(); - - if (results.Count == 0) - { - return Result.Success(new SubmissionStatusDto - { - SubmissionId = submission.Id, - Status = submission.GetOverallStatus().ToString(), - TestCases = [], - }); - } - - var setups = await problemRepository.GetProblemSetupsAsync([submission.ProblemSetupId], cancellationToken); - var setup = setups.FirstOrDefault(); - - List testCases = setup is not null - ? setup.TestSuites.SelectMany(ts => ts.TestCases).ToList() - : []; - - var testCaseDtos = results.Select((result, i) => - { - var testCase = i < testCases.Count ? testCases[i] : null; - - return new SubmissionTestCaseResultDto - { - Input = testCase is not null - ? string.Join(",", testCase.Inputs.Select(inp => inp.Value.Trim())) - : string.Empty, - ExpectedOutput = testCase?.ExpectedOutput.Value ?? string.Empty, - ActualOutput = result.ProgramOutput ?? result.Stdout ?? string.Empty, - ErrorOutput = result.Stderr, - Status = (int)result.Status, - }; - }); - - return Result.Success(new SubmissionStatusDto - { - SubmissionId = submission.Id, - Status = submission.GetOverallStatus().ToString(), - TestCases = testCaseDtos.ToList(), - }); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Submissions/GetSubmissionStatus/GetSubmissionStatusQuery.cs b/src/ApplicationCore/Queries/Submissions/GetSubmissionStatus/GetSubmissionStatusQuery.cs deleted file mode 100644 index 9b170b4..0000000 --- a/src/ApplicationCore/Queries/Submissions/GetSubmissionStatus/GetSubmissionStatusQuery.cs +++ /dev/null @@ -1,5 +0,0 @@ -using ApplicationCore.Dtos.Submissions; - -namespace ApplicationCore.Queries.Submissions.GetSubmissionStatus; - -public sealed record GetSubmissionStatusQuery(Guid SubmissionId) : IQuery; \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Submissions/GetSubmissionsPaginated/GetSubmissionsPaginatedHandler.cs b/src/ApplicationCore/Queries/Submissions/GetSubmissionsPaginated/GetSubmissionsPaginatedHandler.cs deleted file mode 100644 index ef15e43..0000000 --- a/src/ApplicationCore/Queries/Submissions/GetSubmissionsPaginated/GetSubmissionsPaginatedHandler.cs +++ /dev/null @@ -1,55 +0,0 @@ -using ApplicationCore.Common.Pagination; -using ApplicationCore.Domain.Submissions; -using ApplicationCore.Dtos.Accounts; -using ApplicationCore.Dtos.Submissions; -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; - -namespace ApplicationCore.Queries.Submissions.GetSubmissionsPaginated; - -public sealed class GetSubmissionsPaginatedHandler(ISubmissionRepository repository) - : IQueryHandler> -{ - public async Task>> Handle( - GetSubmissionsPaginatedQuery request, - CancellationToken cancellationToken - ) - { - SubmissionStatus? statusFilter = request.AcceptedOnly ? SubmissionStatus.Accepted : null; - - var page = await repository.GetSubmissionsByProblemId( - request.ProblemId, - request.FilterByUserId, - request.Pagination, - statusFilter, - cancellationToken - ); - - var dtoItems = page.Results - .Select(s => new SubmissionDto - { - Id = s.Id, - ProblemSetupId = s.ProblemSetupId, - Status = s.GetOverallStatus().ToString(), - Code = s.Code ?? string.Empty, - CreatedOn = s.CreatedOn, - CompletedAt = s.CompletedAt, - CreatedBy = s.CreatedBy is null ? null : new AccountDto - { - Id = s.CreatedBy.Id, - Username = s.CreatedBy.Username, - ImageUrl = s.CreatedBy.ImageUrl, - CreatedOn = s.CreatedBy.CreatedOn, - }, - }) - .ToList(); - - return Result.Success(new PaginatedResult - { - Results = dtoItems, - Total = page.Total, - Page = page.Page, - Size = page.Size, - }); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Submissions/GetSubmissionsPaginated/GetSubmissionsPaginatedQuery.cs b/src/ApplicationCore/Queries/Submissions/GetSubmissionsPaginated/GetSubmissionsPaginatedQuery.cs deleted file mode 100644 index a7c1af9..0000000 --- a/src/ApplicationCore/Queries/Submissions/GetSubmissionsPaginated/GetSubmissionsPaginatedQuery.cs +++ /dev/null @@ -1,11 +0,0 @@ -using ApplicationCore.Common.Pagination; -using ApplicationCore.Dtos.Submissions; - -namespace ApplicationCore.Queries.Submissions.GetSubmissionsPaginated; - -public sealed record GetSubmissionsPaginatedQuery( - Guid ProblemId, - PaginationRequest Pagination, - Guid? FilterByUserId = null, - bool AcceptedOnly = true -) : IQuery>; \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Submissions/GetUserSubmissionsByProblemIdQuery/GetUserSubmissionsByProblemIdHandler.cs b/src/ApplicationCore/Queries/Submissions/GetUserSubmissionsByProblemIdQuery/GetUserSubmissionsByProblemIdHandler.cs deleted file mode 100644 index 4a68f0e..0000000 --- a/src/ApplicationCore/Queries/Submissions/GetUserSubmissionsByProblemIdQuery/GetUserSubmissionsByProblemIdHandler.cs +++ /dev/null @@ -1,39 +0,0 @@ -using ApplicationCore.Common.Pagination; -using ApplicationCore.Dtos.Accounts; -using ApplicationCore.Dtos.Problems; -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; - -namespace ApplicationCore.Queries.Submissions.GetUserSubmissionsByProblemIdQuery; - -public sealed class GetUserSubmissionsByProblemIdHandler(ISubmissionRepository submissionRepository) : IQueryHandler> -{ - public async Task>> Handle(GetUserSubmissionsByProblemIdQuery request, CancellationToken cancellationToken) - { - var pageResult = await submissionRepository.GetSubmissionsByProblemId(request.ProblemId, request.AccountId, request.Pagination, request.StatusFilter, cancellationToken); - - var dtos = pageResult.Results.Select(submission => new ProblemSubmissionDto( - CreatedBy: submission?.CreatedBy != null ? new AccountDto - { - Username = submission.CreatedBy.Username, - CreatedOn = submission.CreatedBy.CreatedOn, - ImageUrl = submission.CreatedBy.ImageUrl - } : null, - Code: submission.Code, - Status: submission.GetOverallStatus().ToString(), - Language: submission.LanguageVersion.ProgrammingLanguage.Name, - LanguageVersion: submission.LanguageVersion.Version, - CreatedOn: submission.CreatedOn, - RuntimeMs: submission.GetAverageRuntimeMs(), - MemoryKb: submission.GetAverageMemoryKb() - )) ?? []; - - return Result.Success(new PaginatedResult - { - Results = [.. dtos], - Total = pageResult.Total, - Page = pageResult.Page, - Size = pageResult.Size, - }); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Queries/Submissions/GetUserSubmissionsByProblemIdQuery/GetUserSubmissionsByProblemIdQuery.cs b/src/ApplicationCore/Queries/Submissions/GetUserSubmissionsByProblemIdQuery/GetUserSubmissionsByProblemIdQuery.cs deleted file mode 100644 index 0890e5e..0000000 --- a/src/ApplicationCore/Queries/Submissions/GetUserSubmissionsByProblemIdQuery/GetUserSubmissionsByProblemIdQuery.cs +++ /dev/null @@ -1,12 +0,0 @@ -using ApplicationCore.Common.Pagination; -using ApplicationCore.Domain.Submissions; -using ApplicationCore.Dtos.Problems; - -namespace ApplicationCore.Queries.Submissions.GetUserSubmissionsByProblemIdQuery; - -public sealed record GetUserSubmissionsByProblemIdQuery( - Guid ProblemId, - Guid AccountId, - PaginationRequest Pagination, - SubmissionStatus? StatusFilter = null -) : IQuery>; \ No newline at end of file diff --git a/src/ApplicationCore/Services/AccountAppService.cs b/src/ApplicationCore/Services/AccountAppService.cs deleted file mode 100644 index 1f838fa..0000000 --- a/src/ApplicationCore/Services/AccountAppService.cs +++ /dev/null @@ -1,96 +0,0 @@ -using ApplicationCore.Commands.Accounts.CreateAccount; -using ApplicationCore.Commands.Accounts.UpdateProfileSettings; -using ApplicationCore.Commands.Accounts.UpdateUsername; -using ApplicationCore.Commands.Accounts.UpsertAccount; -using ApplicationCore.Dtos.Accounts; -using ApplicationCore.Interfaces.Services; -using ApplicationCore.Queries.Accounts.GetAccountBySub; -using ApplicationCore.Queries.Accounts.GetProfileAggregate; -using ApplicationCore.Queries.Accounts.GetProfileSettings; -using Ardalis.Result; -using MediatR; -using System; -using System.Collections.Generic; -using System.Text; - -namespace ApplicationCore.Services; - -public sealed class AccountAppService(IMediator mediator) : IAccountAppService -{ - public async Task> CreateAsync( - string username, - string sub, - string imageUrl, - CancellationToken cancellationToken - ) - { - var command = new CreateAccountCommand(username, sub, imageUrl); - - var result = await mediator.Send(command, cancellationToken); - - return result; - } - - public async Task> GetAccountBySubAsync( - string sub, - CancellationToken cancellationToken - ) - { - var query = new GetAccountBySubQuery(sub); - - return await mediator.Send(query, cancellationToken); - } - - public async Task> GetProfileAggregateAsync( - string username, - CancellationToken cancellationToken - ) - { - var query = new GetProfileAggregateQuery(username); - - return await mediator.Send(query, cancellationToken); - } - - public async Task> GetProfileSettingsAsync( - string sub, - CancellationToken cancellationToken - ) - { - var query = new GetProfileSettingsQuery(sub); - - return await mediator.Send(query, cancellationToken); - } - public async Task> UpsertAccountAsync( - string sub, - string? imageUrl, - CancellationToken cancellationToken - ) - { - var command = new UpsertAccountCommand(sub, imageUrl); - - return await mediator.Send(command, cancellationToken); - } - - public async Task> UpdateUsernameAsync( - Guid accountId, - string newUsername, - DateTime? usernameLastChangedAt, - CancellationToken cancellationToken - ) - { - var command = new UpdateUsernameCommand(accountId, newUsername, usernameLastChangedAt); - - return await mediator.Send(command, cancellationToken); - } - - public async Task> UpdateProfileSettingsAsync( - Guid accountId, - string? bio, - CancellationToken cancellationToken - ) - { - var command = new UpdateProfileSettingsCommand(accountId, bio); - - return await mediator.Send(command, cancellationToken); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Services/CodeBuilderService.cs b/src/ApplicationCore/Services/CodeBuilderService.cs deleted file mode 100644 index d1d105f..0000000 --- a/src/ApplicationCore/Services/CodeBuilderService.cs +++ /dev/null @@ -1,112 +0,0 @@ -using ApplicationCore.Domain.CodeExecution; -using ApplicationCore.Interfaces.Services; -using Ardalis.Result; - -namespace ApplicationCore.Services; - -public sealed class CodeBuilderService : ICodeBuilderService -{ - public Result> Build(IEnumerable contexts) - { - var results = new List(); - - foreach (var context in contexts) - { - var validation = ValidateContext(context); - if (validation != null) - { - return validation; - } - - string joinedInputs = string.Join( - ",", - context.Inputs.Select(input => input.Value.Trim()) - ); - - string finalCode = RenderTemplate( - context.Template, - context.Code, - context.FunctionName, - context.InputTypeName ?? "", - joinedInputs - ); - - results.Add( - new CodeBuildResult - { - FinalCode = finalCode, - FunctionName = context.FunctionName, - Inputs = joinedInputs, - ExpectedOutput = context.ExpectedOutput.Value, - InputTypeName = context.InputTypeName, - LanguageId = context.Judge0LanguageId ?? 0, - } - ); - } - - return Result>.Success(results); - } - - private static Result>? ValidateContext( - CodeBuilderContext? context - ) - { - if (context == null) - { - return Result>.Invalid( - new ValidationError("One of the contexts is null.") - ); - } - - if (string.IsNullOrWhiteSpace(context.Code)) - { - return Result>.Invalid( - new ValidationError("Initial code is required.") - ); - } - - if (string.IsNullOrWhiteSpace(context.FunctionName)) - { - return Result>.Invalid( - new ValidationError("Function name is required.") - ); - } - - if (string.IsNullOrWhiteSpace(context.Template)) - { - return Result>.Invalid( - new ValidationError("Harness template is required.") - ); - } - - return null; - } - - private static string RenderTemplate( - string template, - string userCode, - string functionName, - string inputTypeName, - string joinedInputs - ) - { - string inputParser = ResolveInputParser(inputTypeName); - - return template - .Replace("{{USER_CODE}}", userCode) - .Replace("{{FUNCTION_NAME}}", functionName) - .Replace("{{INPUT_PARSER}}", inputParser) - .Replace("{{INPUTS}}", joinedInputs); - } - - private static string ResolveInputParser(string inputTypeName) - { - return inputTypeName switch - { - "number" => "const value = parseInt(data.toString(), 10);", - "array:number" => "const value = data.toString().split(',').map(Number);", - "string" => "const value = data.toString().trim();", - _ => "const value = data.toString();", - }; - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Services/CodeExecutionService.cs b/src/ApplicationCore/Services/CodeExecutionService.cs deleted file mode 100644 index 5eefd1b..0000000 --- a/src/ApplicationCore/Services/CodeExecutionService.cs +++ /dev/null @@ -1,174 +0,0 @@ -using ApplicationCore.Domain.CodeExecution; -using ApplicationCore.Domain.CodeExecution.Judge0; -using ApplicationCore.Domain.Submissions; -using ApplicationCore.Interfaces.Clients; -using ApplicationCore.Interfaces.Services; -using Ardalis.Result; - -namespace ApplicationCore.Services; - -public sealed class CodeExecutionService(IJudge0Client judge0Client) : ICodeExecutionService -{ - public async Task>> ExecuteAsync( - IEnumerable contexts, - CancellationToken cancellationToken - ) - { - var contextList = contexts.ToList(); - if (contextList.Count == 0) - { - return Result.Success(Enumerable.Empty()); - } - - var submissions = new List(); - var judge0Requests = new List(); - var indexMap = new List<(List Results, int ResultIndex)>(); - - foreach (var context in contextList) - { - var results = new List(); - - foreach (var buildResult in context.BuiltResults) - { - if (buildResult.LanguageId == 0) - { - return Result.Error( - $"No Judge0 language mapping found for this problem setup. Ensure the language version has an engine mapping configured." - ); - } - - judge0Requests.Add( - new Judge0SubmissionRequest - { - LanguageId = buildResult.LanguageId, - SourceCode = buildResult.FinalCode, - StdIn = buildResult.Inputs, - ExpectedOutput = buildResult.ExpectedOutput, - } - ); - - results.Add( - new SubmissionResult { Id = Guid.NewGuid(), Status = SubmissionStatus.InQueue } - ); - - indexMap.Add((results, results.Count - 1)); - } - - var submission = new SubmissionModel - { - Id = context.SubmissionId ?? Guid.NewGuid(), - CreatedById = context.CreatedById, - Results = results, - }; - - submissions.Add(submission); - } - - if (!judge0Requests.Any()) - { - return Result.Success>(submissions); - } - - var judge0Response = await judge0Client.SubmitAsync(judge0Requests, cancellationToken); - - if (!judge0Response.IsSuccess) - { - return Result.Error("Failed to submit code for execution."); - } - - var responseList = judge0Response.Value.ToList(); - - if (responseList.Count != indexMap.Count) - { - return Result.Error("Mismatch between Judge0 responses and submitted jobs."); - } - - for (int i = 0; i < responseList.Count; i++) - { - var response = responseList[i]; - (var results, int resultIndex) = indexMap[i]; - - var result = results[resultIndex]; - result.ExecutionId = response.Token; - result.Status = SubmissionStatus.InQueue; - } - - return Result.Success>(submissions); - } - - public async Task>> GetSubmissionResultsAsync( - IEnumerable submissions, - CancellationToken cancellationToken - ) - { - var submissionList = submissions.ToList(); - if (!submissionList.Any()) - { - return Result.Success(Enumerable.Empty()); - } - - var tokenMap = submissionList - .SelectMany(s => s.Results.Select(r => (Submission: s, Token: r.ExecutionId))) - .ToDictionary(x => x.Token, x => x.Submission); - - var judge0Results = await judge0Client.GetAsync(tokenMap.Keys, cancellationToken); - - if (!judge0Results.IsSuccess) - { - return Result.Error("Failed to retrieve submission results."); - } - - foreach (var result in judge0Results.Value) - { - if (!tokenMap.TryGetValue(result.Token, out var submission)) - { - continue; - } - - var submissionResult = submission.Results.First(r => r.ExecutionId == result.Token); - - submissionResult.Status = MapJudge0SubmissionStatus(result.Status); - submissionResult.Stderr = result.Stderr; - submissionResult.RuntimeMs = decimal.TryParse(result.Time, out decimal seconds) - ? (int)Math.Ceiling(seconds * 1000) - : null; - submissionResult.MemoryKb = result.Memory; - submissionResult.FinishedAt = DateTime.UtcNow; - - string rawStdout = result.Stdout ?? ""; - submissionResult.ProgramOutput = rawStdout; - - if (!string.IsNullOrEmpty(rawStdout)) - { - string[] lines = rawStdout.ReplaceLineEndings("\n").TrimEnd('\n').Split('\n'); - submissionResult.Stdout = lines.Length > 1 ? string.Join("\n", lines[..^1]) : null; - } - } - - return Result.Success>(submissionList); - } - - private static SubmissionStatus MapJudge0SubmissionStatus(Judge0StatusModel status) => - status.Id switch - { - 1 => SubmissionStatus.InQueue, - 2 => SubmissionStatus.Processing, - 3 => SubmissionStatus.Accepted, - 4 => SubmissionStatus.WrongAnswer, - 5 => SubmissionStatus.TimeLimitExceeded, - 6 => SubmissionStatus.CompilationError, - 7 => SubmissionStatus.RuntimeErrorSigSegv, - 8 => SubmissionStatus.RuntimeErrorSigXfsz, - 9 => SubmissionStatus.RuntimeErrorSigFpe, - 10 => SubmissionStatus.RuntimeErrorSigAbrt, - 11 => SubmissionStatus.RuntimeErrorNzec, - 12 => SubmissionStatus.RuntimeErrorOther, - 13 => SubmissionStatus.InternalError, - 14 => SubmissionStatus.ExecFormatError, - _ => throw new ArgumentOutOfRangeException( - nameof(status.Id), - status.Id, - "Unknown Judge0 submission status" - ), - }; -} \ No newline at end of file diff --git a/src/ApplicationCore/Services/ExecutionComparisonService.cs b/src/ApplicationCore/Services/ExecutionComparisonService.cs deleted file mode 100644 index 80379dc..0000000 --- a/src/ApplicationCore/Services/ExecutionComparisonService.cs +++ /dev/null @@ -1,25 +0,0 @@ -using ApplicationCore.Domain.Submissions; -using ApplicationCore.Interfaces.Services; - -namespace ApplicationCore.Services; - -public sealed class ExecutionComparisonService : IExecutionComparisonService -{ - public SubmissionStatus Compare(string? programOutput, string expectedOutput) - { - if (programOutput is null) - { - return SubmissionStatus.WrongAnswer; - } - - string[] lines = programOutput.ReplaceLineEndings("\n").TrimEnd('\n').Split('\n'); - string lastLine = lines[^1]; - - string actual = Normalize(lastLine); - string expected = Normalize(expectedOutput); - - return actual == expected ? SubmissionStatus.Accepted : SubmissionStatus.WrongAnswer; - } - - private static string Normalize(string value) => value.Trim().ReplaceLineEndings("\n"); -} \ No newline at end of file diff --git a/src/ApplicationCore/Services/ProblemAppService.cs b/src/ApplicationCore/Services/ProblemAppService.cs deleted file mode 100644 index 212a70b..0000000 --- a/src/ApplicationCore/Services/ProblemAppService.cs +++ /dev/null @@ -1,96 +0,0 @@ -using ApplicationCore.Common.Pagination; -using ApplicationCore.Domain.Problems.ProblemSetups; -using ApplicationCore.Dtos.Languages; -using ApplicationCore.Dtos.Problems; -using ApplicationCore.Interfaces.Services; -using ApplicationCore.Queries.Problems.GetAvailableLanguages; -using ApplicationCore.Queries.Problems.GetProblemBySlug; -using ApplicationCore.Queries.Problems.GetProblemSetup; -using ApplicationCore.Queries.Problems.GetProblemSetupsForExecution; -using ApplicationCore.Queries.Problems.GetProblemsPageable; -using Ardalis.Result; -using MediatR; - -namespace ApplicationCore.Services; - -public sealed class ProblemAppService(IMediator mediator) : IProblemAppService -{ - public Task>> GetAvailableLanguagesAsync( - CancellationToken cancellationToken - ) - { - var query = new GetAvailableLanguagesQuery(); - - return mediator.Send(query, cancellationToken); - } - - public async Task> GetProblemBySlugAsync( - string slug, - CancellationToken cancellationToken - ) - { - var query = new GetProblemBySlugQuery(slug); - - return await mediator.Send(query, cancellationToken); - } - - public async Task> GetProblemSetupAsync( - Guid problemId, - int languageVersionId, - CancellationToken cancellationToken - ) - { - var query = new GetProblemSetupQuery(problemId, languageVersionId); - - return await mediator.Send(query, cancellationToken); - } - - public async Task>> GetProblemsPaginatedAsync( - int pageNumber, - int pageSize, - DateTime timestamp, - CancellationToken cancellationToken - ) - { - var pagination = new PaginationRequest - { - Page = pageNumber, - Size = pageSize, - Timestamp = timestamp, - }; - var query = new GetProblemsPageableQuery(pagination); - - return await mediator.Send(query, cancellationToken); - } - - public async Task>> GetProblemsPaginatedAsync( - int pageNumber, - int pageSize, - DateTime timestamp, - string query, - CancellationToken cancellationToken - ) - { - var pagination = new PaginationRequest - { - Page = pageNumber, - Size = pageSize, - Timestamp = timestamp, - Query = query, - }; - - var pageableQuery = new GetProblemsPageableQuery(pagination); - - return await mediator.Send(pageableQuery, cancellationToken); - } - - public Task>> GetProblemSetupsForExecutionAsync( - IEnumerable setupIds, - CancellationToken cancellationToken - ) - { - var query = new GetProblemSetupsForExecutionQuery(setupIds); - - return mediator.Send(query, cancellationToken); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Services/SubmissionAppService.cs b/src/ApplicationCore/Services/SubmissionAppService.cs deleted file mode 100644 index e6a607d..0000000 --- a/src/ApplicationCore/Services/SubmissionAppService.cs +++ /dev/null @@ -1,127 +0,0 @@ -using ApplicationCore.Commands.Submissions.CreateSubmission; -using ApplicationCore.Commands.Submissions.FinalizeEvaluation; -using ApplicationCore.Commands.Submissions.IncrementSubmissionOutboxes; -using ApplicationCore.Commands.Submissions.ProcessEvaluation; -using ApplicationCore.Commands.Submissions.ProcessPollingSubmissionExecutions; -using ApplicationCore.Commands.Submissions.ProcessSubmissionExecutions; -using ApplicationCore.Commands.Submissions.SaveExecutionTokens; -using ApplicationCore.Common.Pagination; -using ApplicationCore.Domain.Submissions; -using ApplicationCore.Domain.Submissions.Outboxes; -using ApplicationCore.Dtos.Problems; -using ApplicationCore.Dtos.Submissions; -using ApplicationCore.Interfaces.Services; -using ApplicationCore.Queries.Submissions.GetSolutionsByProblemIdQuery; -using ApplicationCore.Queries.Submissions.GetSubmissionOutboxes; -using ApplicationCore.Queries.Submissions.GetSubmissionsPaginated; -using ApplicationCore.Queries.Submissions.GetSubmissionStatus; -using ApplicationCore.Queries.Submissions.GetUserSubmissionsByProblemIdQuery; -using Ardalis.Result; -using MediatR; -using static ApplicationCore.Logging.LoggingEventIds; - -namespace ApplicationCore.Services; - -public sealed class SubmissionAppService(IMediator mediator) : ISubmissionAppService -{ - public async Task> CreateAsync( - int problemSetupId, - string code, - Guid createdById, - CancellationToken cancellationToken - ) - { - var command = new CreateSubmissionCommand(problemSetupId, code, createdById); - - return await mediator.Send(command, cancellationToken); - } - - public async Task>> GetSubmissionOutboxesAsync( - CancellationToken cancellationToken - ) - { - var query = new GetSubmissionOutboxesQuery(); - - return await mediator.Send(query, cancellationToken); - } - - public async Task> IncrementOutboxesCountAsync( - IEnumerable outboxIds, - DateTime timestamp, - CancellationToken cancellationToken - ) - { - var command = new IncrementSubmissionOutboxesCommand(outboxIds, timestamp); - - return await mediator.Send(command, cancellationToken); - } - - public async Task> SaveExecutionTokensAsync( - IEnumerable results, - CancellationToken cancellationToken - ) - { - var command = new SaveExecutionTokensCommand(results); - - return await mediator.Send(command, cancellationToken); - } - - public async Task> ProcessSubmissionExecutionAsync( - IEnumerable results, - CancellationToken cancellationToken - ) - { - var command = new ProcessSubmissionExecutionsCommand(results); - - return await mediator.Send(command, cancellationToken); - } - - public async Task> ProcessPollingSubmissionExecutionsAsync( - IEnumerable results, - CancellationToken cancellationToken - ) - { - var command = new ProcessPollingSubmissionExecutionsCommand(results); - - return await mediator.Send(command, cancellationToken); - } - - public async Task> ProcessEvaluationAsync( - IEnumerable results, - CancellationToken cancellationToken - ) - { - var command = new ProcessEvaluationCommand(results); - - return await mediator.Send(command, cancellationToken); - } - - public async Task> FinalizeEvaluationAsync( - IEnumerable outboxIds, - DateTime now, - CancellationToken cancellationToken - ) - { - var command = new FinalizeEvaluationCommand(outboxIds, now); - - return await mediator.Send(command, cancellationToken); - } - - public Task>> GetSolutionsAsync(Guid problemId, PaginationRequest paginationRequest, CancellationToken cancellationToken) - { - var query = new GetSolutionsByProblemIdQuery(problemId, paginationRequest); - return mediator.Send(query, cancellationToken); - } - - public Task>> GetSubmissionsPaginatedAsync(Guid problemId, Guid accountId, PaginationRequest paginationRequest, CancellationToken cancellationToken = default) - { - var query = new GetUserSubmissionsByProblemIdQuery(problemId, accountId, paginationRequest); - return mediator.Send(query, cancellationToken); - } - - public Task> GetSubmissionStatusAsync(Guid submissionId, CancellationToken cancellationToken) - { - var query = new GetSubmissionStatusQuery(submissionId); - return mediator.Send(query, cancellationToken); - } -} \ No newline at end of file diff --git a/src/ApplicationCore/Settings/ConnectionStringsSettings.cs b/src/ApplicationCore/Settings/ConnectionStringsSettings.cs deleted file mode 100644 index a5b88b3..0000000 --- a/src/ApplicationCore/Settings/ConnectionStringsSettings.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ApplicationCore.Settings; - -public sealed class ConnectionStringsSettings : ISettings -{ - public static string SectionKey => "ConnectionStrings"; - public required string DefaultConnection { get; set; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Settings/CorsSettings.cs b/src/ApplicationCore/Settings/CorsSettings.cs deleted file mode 100644 index df2e5f9..0000000 --- a/src/ApplicationCore/Settings/CorsSettings.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ApplicationCore.Settings; - -public class CorsSettings : ISettings -{ - public static string SectionKey => "CorsSettings"; - public required IEnumerable AllowedOrigins { get; set; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Settings/ISettings.cs b/src/ApplicationCore/Settings/ISettings.cs deleted file mode 100644 index 6cffbc0..0000000 --- a/src/ApplicationCore/Settings/ISettings.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace ApplicationCore.Settings; - -public interface ISettings -{ - static abstract string SectionKey { get; } -} \ No newline at end of file diff --git a/src/ApplicationCore/Settings/MediatRSettings.cs b/src/ApplicationCore/Settings/MediatRSettings.cs deleted file mode 100644 index ff27011..0000000 --- a/src/ApplicationCore/Settings/MediatRSettings.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace ApplicationCore.Settings; - -public class MediatRSettings : ISettings -{ - public static string SectionKey => "MediatR"; - - public string? LicenseKey { get; set; } -} \ No newline at end of file diff --git a/src/Infrastructure/CodeExecution/Judge0/Judge0Client.cs b/src/Infrastructure/CodeExecution/Judge0/Judge0Client.cs deleted file mode 100644 index ce4562e..0000000 --- a/src/Infrastructure/CodeExecution/Judge0/Judge0Client.cs +++ /dev/null @@ -1,185 +0,0 @@ -using ApplicationCore.Domain.CodeExecution.Judge0; -using ApplicationCore.Domain.Submissions; -using ApplicationCore.Interfaces.Clients; -using ApplicationCore.Logging; -using Ardalis.Result; -using Infrastructure.Configuration; -using Microsoft.AspNetCore.WebUtilities; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using System.Diagnostics; -using System.Net.Http.Json; -using System.Text.Json; - -namespace Infrastructure.CodeExecution.Judge0; - -public sealed partial class Judge0Client( - HttpClient httpClient, - IOptions judge0Options, - JsonSerializerOptions jsonOptions, - ILogger logger -) : IJudge0Client -{ - private readonly ILogger _logger = logger; - private readonly Judge0Options _judge0Options = judge0Options.Value; - private const int BatchSize = 20; - - public async Task>> GetAsync( - IEnumerable tokens, - CancellationToken cancellationToken, - IEnumerable? fields = null - ) - { - var tokenList = tokens.ToList(); - LogGetStarted(tokenList.Count); - var sw = Stopwatch.StartNew(); - - try - { - var allSubmissions = new List(); - - foreach (var batch in tokenList.Chunk(BatchSize)) - { - var query = new Dictionary() - { - ["tokens"] = string.Join(",", batch), - ["fields"] = fields is not null ? string.Join(",", fields) : "*", - }; - - string uri = QueryHelpers.AddQueryString("submissions/batch", query); - - var response = await httpClient.GetAsync(uri, cancellationToken); - - response.EnsureSuccessStatusCode(); - - var batchResult = await response.Content.ReadFromJsonAsync( - jsonOptions, - cancellationToken - ); - - if (batchResult?.Submissions is { Count: > 0 }) - { - allSubmissions.AddRange(batchResult.Submissions); - } - } - - if (allSubmissions.Count == 0) - { - return Result.Error("No submissions found"); - } - - LogGetCompleted(tokenList.Count, sw.ElapsedMilliseconds); - return Result.Success(allSubmissions); - } - catch (HttpRequestException ex) - { - LogGetFailed(tokenList.Count, sw.ElapsedMilliseconds, ex); - return Result.Error($"HTTP request failed: {ex.Message}"); - } - catch (JsonException ex) - { - LogGetFailed(tokenList.Count, sw.ElapsedMilliseconds, ex); - return Result.Error($"JSON deserialization failed: {ex.Message}"); - } - catch (Exception ex) - { - LogGetFailed(tokenList.Count, sw.ElapsedMilliseconds, ex); - return Result.Error(ex.Message); - } - } - - public async Task>> SubmitAsync( - IEnumerable reqs, - CancellationToken cancellationToken, - IEnumerable? fields = null - ) - { - var reqList = reqs.ToList(); - LogSubmitStarted(reqList.Count); - var sw = Stopwatch.StartNew(); - - try - { - var allResponses = new List(); - - foreach (var batch in reqList.Chunk(BatchSize)) - { - var query = new Dictionary() - { - ["base64_encoded"] = _judge0Options.IsEncoded.ToString().ToLowerInvariant(), - ["fields"] = fields is not null ? string.Join(",", fields) : "*", - }; - - string uri = QueryHelpers.AddQueryString("submissions/batch", query); - - var payload = new Judge0BatchRequest { Submissions = batch }; - - var response = await httpClient.PostAsJsonAsync(uri, payload, cancellationToken); - - response.EnsureSuccessStatusCode(); - - var body = await response.Content.ReadFromJsonAsync< - List - >(jsonOptions, cancellationToken); - - if (body is { Count: > 0 }) - { - allResponses.AddRange( - body.Select(result => new Judge0SubmissionResponse - { - Token = result.Token, - Status = new Judge0StatusModel { Id = (int)SubmissionStatus.InQueue }, - }) - ); - } - } - - if (allResponses.Count == 0) - { - return Result.Error("No submissions found"); - } - - LogSubmitCompleted(reqList.Count, sw.ElapsedMilliseconds); - return Result.Success(allResponses); - } - catch (HttpRequestException ex) - { - LogSubmitFailed(reqList.Count, sw.ElapsedMilliseconds, ex); - return Result.Error($"HTTP request failed: {ex.Message}"); - } - catch (JsonException ex) - { - LogSubmitFailed(reqList.Count, sw.ElapsedMilliseconds, ex); - return Result.Error($"JSON deserialization failed: {ex.Message}"); - } - catch (Exception ex) - { - LogSubmitFailed(reqList.Count, sw.ElapsedMilliseconds, ex); - return Result.Error(ex.Message); - } - } - - [LoggerMessage(EventId = LoggingEventIds.Judge0.SubmitStarted, Level = LogLevel.Information, - Message = "Judge0: Submitting {count} submission(s)")] - private partial void LogSubmitStarted(int count); - - [LoggerMessage(EventId = LoggingEventIds.Judge0.SubmitCompleted, Level = LogLevel.Information, - Message = "Judge0: Submitted {count} submission(s) in {elapsedMs}ms")] - private partial void LogSubmitCompleted(int count, long elapsedMs); - - [LoggerMessage(EventId = LoggingEventIds.Judge0.SubmitFailed, Level = LogLevel.Error, - Message = "Judge0: Submit failed for {count} submission(s) after {elapsedMs}ms")] - private partial void LogSubmitFailed(int count, long elapsedMs, Exception ex); - - [LoggerMessage(EventId = LoggingEventIds.Judge0.GetStarted, Level = LogLevel.Information, - Message = "Judge0: Polling {count} token(s)")] - private partial void LogGetStarted(int count); - - [LoggerMessage(EventId = LoggingEventIds.Judge0.GetCompleted, Level = LogLevel.Information, - Message = "Judge0: Polled {count} token(s) in {elapsedMs}ms")] - private partial void LogGetCompleted(int count, long elapsedMs); - - [LoggerMessage(EventId = LoggingEventIds.Judge0.GetFailed, Level = LogLevel.Error, - Message = "Judge0: Poll failed for {count} token(s) after {elapsedMs}ms")] - private partial void LogGetFailed(int count, long elapsedMs, Exception ex); -} \ No newline at end of file diff --git a/src/Infrastructure/Configuration/Auth0ManagementOptions.cs b/src/Infrastructure/Configuration/Auth0ManagementOptions.cs deleted file mode 100644 index b9b28c4..0000000 --- a/src/Infrastructure/Configuration/Auth0ManagementOptions.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Infrastructure.Configuration; - -public sealed class Auth0ManagementOptions -{ - public required string ClientId { get; init; } -} \ No newline at end of file diff --git a/src/Infrastructure/Configuration/Auth0Options.cs b/src/Infrastructure/Configuration/Auth0Options.cs deleted file mode 100644 index b136d47..0000000 --- a/src/Infrastructure/Configuration/Auth0Options.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Infrastructure.Configuration; - -public sealed class Auth0Options -{ - public required string Audience { get; init; } - - public required string Domain { get; init; } - - public required Auth0ManagementOptions Management { get; init; } -} \ No newline at end of file diff --git a/src/Infrastructure/Configuration/ConnectionStringOptions.cs b/src/Infrastructure/Configuration/ConnectionStringOptions.cs deleted file mode 100644 index 02d4b56..0000000 --- a/src/Infrastructure/Configuration/ConnectionStringOptions.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Infrastructure.Configuration; - -public sealed class ConnectionStringOptions -{ - public const string SectionName = "ConnectionStrings"; - - public required string DefaultConnection { get; init; } -} \ No newline at end of file diff --git a/src/Infrastructure/Configuration/CorsOptions.cs b/src/Infrastructure/Configuration/CorsOptions.cs deleted file mode 100644 index 8a408a9..0000000 --- a/src/Infrastructure/Configuration/CorsOptions.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Infrastructure.Configuration; - -public sealed class CorsOptions -{ - public string[] AllowedOrigins { get; init; } = []; -} \ No newline at end of file diff --git a/src/Infrastructure/Configuration/ExecutionEnginesOptions.cs b/src/Infrastructure/Configuration/ExecutionEnginesOptions.cs deleted file mode 100644 index 6f14898..0000000 --- a/src/Infrastructure/Configuration/ExecutionEnginesOptions.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Infrastructure.Configuration; - -public sealed class ExecutionEnginesOptions -{ - public required Judge0Options Judge0 { get; init; } -} \ No newline at end of file diff --git a/src/Infrastructure/Configuration/Judge0Options.cs b/src/Infrastructure/Configuration/Judge0Options.cs deleted file mode 100644 index a823d6b..0000000 --- a/src/Infrastructure/Configuration/Judge0Options.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Infrastructure.Configuration; - -public sealed class Judge0Options -{ - public bool Enabled { get; init; } - - public bool RunWorker { get; init; } - - public required string BaseUrl { get; init; } - - public required string ApiKey { get; init; } - - public required string Host { get; init; } - - public bool ShouldWait { get; init; } - - public bool IsEncoded { get; init; } - - public int DefaultTimeoutInSeconds { get; init; } -} \ No newline at end of file diff --git a/src/Infrastructure/Configuration/LogLevelOptions.cs b/src/Infrastructure/Configuration/LogLevelOptions.cs deleted file mode 100644 index 3a67473..0000000 --- a/src/Infrastructure/Configuration/LogLevelOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace Infrastructure.Configuration; - -internal class LogLevelOptions -{ -} \ No newline at end of file diff --git a/src/Infrastructure/Configuration/LoggingOptions.cs b/src/Infrastructure/Configuration/LoggingOptions.cs deleted file mode 100644 index 0b7aeea..0000000 --- a/src/Infrastructure/Configuration/LoggingOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace Infrastructure.Configuration; - -internal class LoggingOptions -{ -} \ No newline at end of file diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs deleted file mode 100644 index cbf9a3d..0000000 --- a/src/Infrastructure/DependencyInjection.cs +++ /dev/null @@ -1,220 +0,0 @@ -using ApplicationCore.Interfaces.Clients; -using ApplicationCore.Interfaces.Messaging; -using ApplicationCore.Interfaces.Repositories; -using ApplicationCore.Interfaces.Services; -using Infrastructure.CodeExecution.Judge0; -using Infrastructure.Configuration; -using Infrastructure.Jobs; -using Infrastructure.Jobs.JobHandlers; -using Infrastructure.Messaging; -using Infrastructure.Messaging.Consumers; -using Infrastructure.Persistence; -using Infrastructure.Repositories; -using Infrastructure.Services; -using MassTransit; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Quartz; -using System.Text.Json; - -namespace Infrastructure; - -public static class DependencyInjection -{ - public static IServiceCollection AddInfrastructure( - this IServiceCollection services, - IConfiguration configuration - ) - { - services - .AddOptions(configuration) - .AddPersistence(configuration) - .AddRepositories() - .AddServices() - .AddMessageBus(configuration) - .AddJudge0Client(configuration) - .AddJobs(); - - return services; - } - - private static IServiceCollection AddOptions( - this IServiceCollection services, - IConfiguration configuration - ) - { - services - .AddOptions() - .Bind(configuration.GetSection(ConnectionStringOptions.SectionName)) - .Validate( - o => !string.IsNullOrWhiteSpace(o.DefaultConnection), - "DefaultConnection connection string is required" - ) - .ValidateOnStart(); - - services - .AddOptions() - .Bind(configuration.GetSection("ExecutionEngines")) - .ValidateOnStart(); - - services.AddSingleton( - new JsonSerializerOptions(JsonSerializerDefaults.Web) - { - PropertyNameCaseInsensitive = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = System - .Text - .Json - .Serialization - .JsonIgnoreCondition - .WhenWritingNull, - } - ); - - return services; - } - - private static IServiceCollection AddPersistence( - this IServiceCollection services, - IConfiguration configuration - ) - { - services.AddDbContext( - (sp, o) => - { - var cs = sp.GetRequiredService>().Value; - o.UseNpgsql(cs.DefaultConnection); - } - ); - - return services; - } - - private static IServiceCollection AddRepositories(this IServiceCollection services) - { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - return services; - } - - private static IServiceCollection AddServices(this IServiceCollection services) - { - services.AddScoped(); - - return services; - } - - private static IServiceCollection AddMessageBus( - this IServiceCollection services, - IConfiguration configuration - ) - { - services - .AddOptions() - .Bind(configuration.GetSection(MessageBusOptions.SectionName)) - .ValidateOnStart(); - - services.AddScoped(); - - services.AddMassTransit(bus => - { - bus.AddConsumer(); - bus.AddConsumer(); - bus.AddConsumer(); - bus.AddConsumer(); - - string transport = configuration - .GetSection(MessageBusOptions.SectionName) - .GetValue("Transport") ?? "RabbitMQ"; - - if (transport.Equals("AzureServiceBus", StringComparison.OrdinalIgnoreCase)) - { - bus.UsingAzureServiceBus((ctx, cfg) => - { - var opts = ctx.GetRequiredService>().Value; - cfg.Host(opts.AzureServiceBus.ConnectionString); - cfg.ConfigureEndpoints(ctx); - }); - } - else - { - bus.UsingRabbitMq((ctx, cfg) => - { - var opts = ctx.GetRequiredService>().Value; - cfg.Host(opts.RabbitMQ.Host, opts.RabbitMQ.VirtualHost, h => - { - h.Username(opts.RabbitMQ.Username); - h.Password(opts.RabbitMQ.Password); - }); - cfg.ConfigureEndpoints(ctx); - }); - } - }); - - return services; - } - - private static IServiceCollection AddJobs(this IServiceCollection services) - { - services.AddQuartz(q => - { - q.AddJobAndTrigger(JobType.SubmissionExecution, intervalInMinutes: 60); - q.AddJobAndTrigger(JobType.PollSubmissionExecution, intervalInMinutes: 60); - q.AddJobAndTrigger(JobType.EvaluateSubmission, intervalInMinutes: 60); - q.AddJobAndTrigger(JobType.PollEvaluation, intervalInMinutes: 60); - }); - - services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); - - return services; - } - - private static void AddJobAndTrigger(this IServiceCollectionQuartzConfigurator q, JobType jobType, int intervalInMinutes) - where T : IJob - { - string jobName = jobType.ToString(); - var jobKey = new JobKey(jobName); - - q.AddJob(opts => opts.WithIdentity(jobKey)); - q.AddTrigger(opts => opts - .ForJob(jobKey) - .WithIdentity($"{jobName}-trigger") - .WithSimpleSchedule(s => s - .WithIntervalInMinutes(intervalInMinutes) - .RepeatForever() - ) - ); - } - - private static IServiceCollection AddJudge0Client( - this IServiceCollection services, - IConfiguration configuration - ) - { - services.AddHttpClient( - (serviceProvider, client) => - { - var judge0 = serviceProvider - .GetRequiredService>() - .Value.Judge0; - - string baseUrl = judge0.BaseUrl.EndsWith('/') - ? judge0.BaseUrl - : judge0.BaseUrl + "/"; - - client.BaseAddress = new Uri(baseUrl); - client.Timeout = TimeSpan.FromSeconds(judge0.DefaultTimeoutInSeconds); - - client.DefaultRequestHeaders.Add("x-rapidapi-host", judge0.Host); - client.DefaultRequestHeaders.Add("x-rapidapi-key", judge0.ApiKey); - client.DefaultRequestHeaders.Add("Accept", "application/json"); - } - ); - - return services; - } -} \ No newline at end of file diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj deleted file mode 100644 index 64db651..0000000 --- a/src/Infrastructure/Infrastructure.csproj +++ /dev/null @@ -1,38 +0,0 @@ - - - net10.0 - enable - enable - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - ..\..\..\..\..\.nuget\packages\microsoft.aspnetcore.app.ref\10.0.0\ref\net10.0\Microsoft.Extensions.Hosting.Abstractions.dll - - - diff --git a/src/Infrastructure/Jobs/JobBase.cs b/src/Infrastructure/Jobs/JobBase.cs deleted file mode 100644 index fe7f416..0000000 --- a/src/Infrastructure/Jobs/JobBase.cs +++ /dev/null @@ -1,50 +0,0 @@ -using ApplicationCore.Logging; -using Microsoft.Extensions.Logging; -using Quartz; - -namespace Infrastructure.Jobs; - -public abstract partial class JobBase : IJob -{ - public abstract JobType JobType { get; } - - protected abstract ILogger Logger { get; } - - public async Task Execute(IJobExecutionContext context) - { - LogJobStarted(Logger, JobType); - try - { - await ExecuteJobAsync(context.CancellationToken); - LogJobCompleted(Logger, JobType); - } - catch (Exception ex) - { - LogJobFailed(Logger, JobType, ex); - throw new JobExecutionException(ex, refireImmediately: false); - } - } - - protected abstract Task ExecuteJobAsync(CancellationToken cancellationToken); - - [LoggerMessage( - EventId = LoggingEventIds.Jobs.Started, - Level = LogLevel.Information, - Message = "Job {jobType} started" - )] - private static partial void LogJobStarted(ILogger logger, JobType jobType); - - [LoggerMessage( - EventId = LoggingEventIds.Jobs.Completed, - Level = LogLevel.Information, - Message = "Job {jobType} completed" - )] - private static partial void LogJobCompleted(ILogger logger, JobType jobType); - - [LoggerMessage( - EventId = LoggingEventIds.Jobs.Failed, - Level = LogLevel.Error, - Message = "Job {jobType} failed" - )] - private static partial void LogJobFailed(ILogger logger, JobType jobType, Exception ex); -} \ No newline at end of file diff --git a/src/Infrastructure/Jobs/JobHandlers/EvaluateSubmissionHandler.cs b/src/Infrastructure/Jobs/JobHandlers/EvaluateSubmissionHandler.cs deleted file mode 100644 index 69f7460..0000000 --- a/src/Infrastructure/Jobs/JobHandlers/EvaluateSubmissionHandler.cs +++ /dev/null @@ -1,118 +0,0 @@ -using ApplicationCore.Domain.Submissions; -using ApplicationCore.Domain.Submissions.Outboxes; -using ApplicationCore.Interfaces.Services; -using ApplicationCore.Logging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Quartz; - -namespace Infrastructure.Jobs.JobHandlers; - -/// -/// Recovery sweep for Stage 3: compares each result's stdout against its -/// expected output and transitions outbox Evaluate → EvaluationPoll. -/// Mirrors . -/// -[DisallowConcurrentExecution] -public sealed partial class EvaluateSubmissionHandler( - IServiceScopeFactory serviceScopeFactory, - ILogger logger -) : JobBase -{ - public override JobType JobType => JobType.EvaluateSubmission; - - protected override ILogger Logger => logger; - - protected override async Task ExecuteJobAsync(CancellationToken cancellationToken) - { - using var scope = serviceScopeFactory.CreateScope(); - var submissionAppService = scope.ServiceProvider.GetRequiredService(); - var problemAppService = scope.ServiceProvider.GetRequiredService(); - var comparisonService = scope.ServiceProvider.GetRequiredService(); - - var outboxResults = await submissionAppService.GetSubmissionOutboxesAsync(cancellationToken); - - if (!outboxResults.IsSuccess || !outboxResults.Value.Any()) - { - return; - } - - var outboxes = outboxResults.Value - .Where(o => o.Type == SubmissionOutboxType.Evaluate) - .ToList(); - - if (outboxes.Count == 0) - { - return; - } - - LogEvaluating(logger, outboxes.Count); - - var setupIds = outboxes.Select(o => o.Submission.ProblemSetupId).Distinct(); - var setupsMap = ( - await problemAppService.GetProblemSetupsForExecutionAsync(setupIds, cancellationToken) - ).Value.ToDictionary(s => s.Id); - - var evaluated = new List(); - - foreach (var outbox in outboxes) - { - if (!setupsMap.TryGetValue(outbox.Submission.ProblemSetupId, out var setup)) - { - continue; - } - - var expectedOutputs = setup.TestSuites - .SelectMany(ts => ts.TestCases) - .Select(tc => tc.ExpectedOutput.Value) - .ToList(); - - var results = outbox.Submission.Results.ToList(); - - for (int i = 0; i < results.Count; i++) - { - if (results[i].Status != SubmissionStatus.Processing) - { - continue; - } - - string expected = i < expectedOutputs.Count ? expectedOutputs[i] : string.Empty; - results[i].Status = comparisonService.Compare(results[i].ProgramOutput, expected); - } - - evaluated.Add(new SubmissionModel - { - Id = outbox.SubmissionId, - CreatedById = outbox.Submission.CreatedById, - Results = results, - }); - } - - if (evaluated.Count == 0) - { - return; - } - - LogEvaluated(logger, evaluated.Count); - - var outboxIds = outboxes.Select(o => o.Id).ToList(); - var now = DateTime.UtcNow; - - await submissionAppService.IncrementOutboxesCountAsync(outboxIds, now, cancellationToken); - await submissionAppService.ProcessEvaluationAsync(evaluated, cancellationToken); - } - - [LoggerMessage( - EventId = LoggingEventIds.Jobs.EvaluateSubmissionEvaluating, - Level = LogLevel.Information, - Message = "Evaluating {count} submission outboxes" - )] - private static partial void LogEvaluating(ILogger logger, int count); - - [LoggerMessage( - EventId = LoggingEventIds.Jobs.EvaluateSubmissionEvaluated, - Level = LogLevel.Information, - Message = "Evaluated {count} submissions" - )] - private static partial void LogEvaluated(ILogger logger, int count); -} \ No newline at end of file diff --git a/src/Infrastructure/Jobs/JobHandlers/PollEvaluationHandler.cs b/src/Infrastructure/Jobs/JobHandlers/PollEvaluationHandler.cs deleted file mode 100644 index 246810d..0000000 --- a/src/Infrastructure/Jobs/JobHandlers/PollEvaluationHandler.cs +++ /dev/null @@ -1,60 +0,0 @@ -using ApplicationCore.Domain.Submissions.Outboxes; -using ApplicationCore.Interfaces.Services; -using ApplicationCore.Logging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Quartz; - -namespace Infrastructure.Jobs.JobHandlers; - -/// -/// Recovery sweep for Stage 4: finalizes outboxes stuck in EvaluationPoll. -/// Mirrors . -/// -[DisallowConcurrentExecution] -public sealed partial class PollEvaluationHandler( - IServiceScopeFactory serviceScopeFactory, - ILogger logger -) : JobBase -{ - public override JobType JobType => JobType.PollEvaluation; - - protected override ILogger Logger => logger; - - protected override async Task ExecuteJobAsync(CancellationToken cancellationToken) - { - using var scope = serviceScopeFactory.CreateScope(); - var submissionAppService = scope.ServiceProvider.GetRequiredService(); - - var outboxResults = await submissionAppService.GetSubmissionOutboxesAsync(cancellationToken); - - if (!outboxResults.IsSuccess || !outboxResults.Value.Any()) - { - return; - } - - var outboxIds = outboxResults.Value - .Where(o => o.Type == SubmissionOutboxType.EvaluationPoll) - .Select(o => o.Id) - .ToList(); - - if (outboxIds.Count == 0) - { - return; - } - - LogFinalizing(logger, outboxIds.Count); - - var now = DateTime.UtcNow; - - await submissionAppService.IncrementOutboxesCountAsync(outboxIds, now, cancellationToken); - await submissionAppService.FinalizeEvaluationAsync(outboxIds, now, cancellationToken); - } - - [LoggerMessage( - EventId = LoggingEventIds.Jobs.PollEvaluationFinalizing, - Level = LogLevel.Information, - Message = "Finalizing {count} evaluation outboxes" - )] - private static partial void LogFinalizing(ILogger logger, int count); -} \ No newline at end of file diff --git a/src/Infrastructure/Jobs/JobHandlers/PollSubmissionExecutionHander.cs b/src/Infrastructure/Jobs/JobHandlers/PollSubmissionExecutionHander.cs deleted file mode 100644 index b1d256f..0000000 --- a/src/Infrastructure/Jobs/JobHandlers/PollSubmissionExecutionHander.cs +++ /dev/null @@ -1,69 +0,0 @@ -using ApplicationCore.Domain.Submissions.Outboxes; -using ApplicationCore.Interfaces.Services; -using ApplicationCore.Logging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Quartz; - -namespace Infrastructure.Jobs.JobHandlers; - -[DisallowConcurrentExecution] -internal sealed partial class PollSubmissionExecutionHander( - IServiceScopeFactory serviceScopeFactory, - ILogger logger -) : JobBase -{ - public override JobType JobType => JobType.PollSubmissionExecution; - - protected override ILogger Logger => logger; - - protected override async Task ExecuteJobAsync(CancellationToken cancellationToken) - { - using var scope = serviceScopeFactory.CreateScope(); - var submissionAppService = - scope.ServiceProvider.GetRequiredService(); - var codeExecutionService = - scope.ServiceProvider.GetRequiredService(); - - var outboxResults = await submissionAppService.GetSubmissionOutboxesAsync( - cancellationToken - ); - - if (!outboxResults.IsSuccess || !outboxResults.Value.Any()) - { - return; - } - - var outboxes = outboxResults - .Value.Where(outbox => outbox.Type == SubmissionOutboxType.PollExecution) - .ToList(); - - if (outboxes.Count == 0) - { - return; - } - - LogPolling(logger, outboxes.Count); - - var submissionResults = await codeExecutionService.GetSubmissionResultsAsync( - outboxes.Select(o => o.Submission), - cancellationToken - ); - - var outboxIds = outboxes.Select(outbox => outbox.Id).ToList(); - var now = DateTime.UtcNow; - await submissionAppService.IncrementOutboxesCountAsync(outboxIds, now, cancellationToken); - - await submissionAppService.ProcessPollingSubmissionExecutionsAsync( - submissionResults.Value, - cancellationToken - ); - } - - [LoggerMessage( - EventId = LoggingEventIds.Jobs.PollSubmissionExecutionPolling, - Level = LogLevel.Information, - Message = "Polling {count} submission execution outboxes" - )] - private static partial void LogPolling(ILogger logger, int count); -} \ No newline at end of file diff --git a/src/Infrastructure/Jobs/JobHandlers/SubmissionExecutionHandler.cs b/src/Infrastructure/Jobs/JobHandlers/SubmissionExecutionHandler.cs deleted file mode 100644 index 877955c..0000000 --- a/src/Infrastructure/Jobs/JobHandlers/SubmissionExecutionHandler.cs +++ /dev/null @@ -1,111 +0,0 @@ -using ApplicationCore.Domain.CodeExecution; -using ApplicationCore.Domain.Submissions.Outboxes; -using ApplicationCore.Interfaces.Services; -using ApplicationCore.Logging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Quartz; - -namespace Infrastructure.Jobs.JobHandlers; - -[DisallowConcurrentExecution] -public sealed partial class SubmissionExecutionHandler( - IServiceScopeFactory serviceScopeFactory, - ILogger logger -) : JobBase -{ - public override JobType JobType => JobType.SubmissionExecution; - - protected override ILogger Logger => logger; - - protected override async Task ExecuteJobAsync(CancellationToken cancellationToken) - { - using var scope = serviceScopeFactory.CreateScope(); - var submissionAppService = - scope.ServiceProvider.GetRequiredService(); - var problemAppService = scope.ServiceProvider.GetRequiredService(); - var codeBuilderService = scope.ServiceProvider.GetRequiredService(); - var codeExecutionService = - scope.ServiceProvider.GetRequiredService(); - - var outboxResults = await submissionAppService.GetSubmissionOutboxesAsync( - cancellationToken - ); - - if (!outboxResults.IsSuccess || !outboxResults.Value.Any()) - { - return; - } - - var outboxes = outboxResults - .Value.Where(outbox => outbox.Type == SubmissionOutboxType.Initialized) - .ToList(); - - var setupsMap = ( - await problemAppService.GetProblemSetupsForExecutionAsync( - outboxes.Select(outbox => outbox.Submission.ProblemSetupId), - cancellationToken - ) - ).Value.ToDictionary(setup => setup.Id); - - var executionContexts = outboxes - .Select(outbox => - { - var setup = setupsMap[outbox.Submission.ProblemSetupId]; - - var builderContexts = setup - .TestSuites.SelectMany(ts => ts.TestCases) - .Select(tc => new CodeBuilderContext - { - Code = outbox.Submission.Code ?? "", - Template = setup.HarnessTemplate?.Template ?? "", - FunctionName = setup.FunctionName ?? string.Empty, - LanguageVersionId = setup.LanguageVersionId, - Judge0LanguageId = setup.LanguageVersion?.Judge0LanguageId, - Inputs = tc.Inputs, - ExpectedOutput = tc.ExpectedOutput, - }); - - var buildResults = codeBuilderService.Build(builderContexts); - - return new CodeExecutionContext - { - SubmissionId = outbox.SubmissionId, - Setup = setup, - Code = outbox.Submission.Code ?? "", - CreatedById = outbox.Submission.CreatedById, - BuiltResults = buildResults.Value, - }; - }) - .ToList(); - - if (executionContexts.Count == 0) - { - return; - } - - LogProcessing(logger, executionContexts.Count); - - var outboxIds = outboxes.Select(outbox => outbox.Id).ToList(); - var now = DateTime.UtcNow; - - await submissionAppService.IncrementOutboxesCountAsync(outboxIds, now, cancellationToken); - - var submissionResults = await codeExecutionService.ExecuteAsync( - executionContexts, - cancellationToken - ); - - await submissionAppService.ProcessSubmissionExecutionAsync( - submissionResults.Value, - cancellationToken - ); - } - - [LoggerMessage( - EventId = LoggingEventIds.Jobs.SubmissionExecutionProcessing, - Level = LogLevel.Information, - Message = "Processing {count} submission execution outboxes" - )] - private static partial void LogProcessing(ILogger logger, int count); -} \ No newline at end of file diff --git a/src/Infrastructure/Jobs/JobType.cs b/src/Infrastructure/Jobs/JobType.cs deleted file mode 100644 index dcac772..0000000 --- a/src/Infrastructure/Jobs/JobType.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Infrastructure.Jobs; - -public enum JobType -{ - SubmissionExecution = 1, - PollSubmissionExecution, - EvaluateSubmission, - PollEvaluation, -} \ No newline at end of file diff --git a/src/Infrastructure/Mappings/AccountMappings.cs b/src/Infrastructure/Mappings/AccountMappings.cs deleted file mode 100644 index d0a4be9..0000000 --- a/src/Infrastructure/Mappings/AccountMappings.cs +++ /dev/null @@ -1,22 +0,0 @@ -using ApplicationCore.Domain.Accounts; -using Infrastructure.Persistence.Entities.Account; -using Mapster; - -namespace Infrastructure.Mappings; - -public sealed class AccountMappings : IRegister -{ - public void Register(TypeAdapterConfig config) - { - config.NewConfig() - .Map(dest => dest.Id, src => src.Id) - .Map(dest => dest.PreviousUsername, src => src.PreviousUsername) - .Map(dest => dest.UsernameLastChangedAt, src => src.UsernameLastChangedAt) - .Map(dest => dest.About, src => src.About); - - config.NewConfig() - .Map(dest => dest.PreviousUsername, src => src.PreviousUsername) - .Map(dest => dest.UsernameLastChangedAt, src => src.UsernameLastChangedAt) - .Map(dest => dest.About, src => src.About); - } -} \ No newline at end of file diff --git a/src/Infrastructure/Mappings/ProblemMappings.cs b/src/Infrastructure/Mappings/ProblemMappings.cs deleted file mode 100644 index 013802e..0000000 --- a/src/Infrastructure/Mappings/ProblemMappings.cs +++ /dev/null @@ -1,40 +0,0 @@ -using ApplicationCore.Domain.Problems; -using ApplicationCore.Domain.Problems.Languages; -using ApplicationCore.Domain.Problems.ProblemSetups; -using ApplicationCore.Domain.Problems.TestSuites; -using Infrastructure.Persistence.Entities.Language; -using Infrastructure.Persistence.Entities.Problem; -using Infrastructure.Persistence.Entities.TestSuite; -using Mapster; -using System; -using System.Collections.Generic; -using System.Text; - -namespace Infrastructure.Mappings; - -public sealed class ProblemMappings : IRegister -{ - public void Register(TypeAdapterConfig config) - { - config.NewConfig(); - - config - .NewConfig() - .Ignore(s => s.Status) - .Map(d => d.Status, s => (ProblemStatus)s.StatusId); - - config - .NewConfig() - .Map(dest => dest.LanguageVersion, src => src.LanguageVersion) - .Map(dest => dest.TestSuites, src => src.TestSuites); - - config - .NewConfig() - .Map(dest => dest.ProgrammingLanguageId, src => src.ProgrammingLanguageId); - - config - .NewConfig() - .Map(dest => dest.TestSuiteType, src => (TestSuiteType)src.TestSuiteTypeId) - .Map(dest => dest.TestCases, src => src.TestCases); - } -} \ No newline at end of file diff --git a/src/Infrastructure/Mappings/SubmissionMappings.cs b/src/Infrastructure/Mappings/SubmissionMappings.cs deleted file mode 100644 index cca16e4..0000000 --- a/src/Infrastructure/Mappings/SubmissionMappings.cs +++ /dev/null @@ -1,26 +0,0 @@ -using ApplicationCore.Domain.Accounts; -using ApplicationCore.Domain.Submissions; -using Infrastructure.Persistence.Entities.Account; -using Infrastructure.Persistence.Entities.Submission; -using Mapster; - -namespace Infrastructure.Mappings; - -public sealed class SubmissionMappings : IRegister -{ - public void Register(TypeAdapterConfig config) - { - config.NewConfig() - .Ignore(dest => dest.PreviousUsername) - .Ignore(dest => dest.UsernameLastChangedAt); - - config.NewConfig() - .Map(dest => dest.Id, src => src.Id) - .Map(dest => dest.Code, src => src.Code) - .Map(dest => dest.ProblemSetupId, src => src.ProblemSetupId) - .Map(dest => dest.CreatedOn, src => src.CreatedOn) - .Map(dest => dest.CompletedAt, src => src.CompletedAt) - .Map(dest => dest.CreatedById, src => src.CreatedById) - .Map(dest => dest.CreatedBy, src => src.CreatedBy); - } -} \ No newline at end of file diff --git a/src/Infrastructure/Messaging/Consumers/SubmissionCreatedConsumer.cs b/src/Infrastructure/Messaging/Consumers/SubmissionCreatedConsumer.cs deleted file mode 100644 index 618adc0..0000000 --- a/src/Infrastructure/Messaging/Consumers/SubmissionCreatedConsumer.cs +++ /dev/null @@ -1,158 +0,0 @@ -using ApplicationCore.Domain.CodeExecution; -using ApplicationCore.Domain.Submissions.Outboxes; -using ApplicationCore.Interfaces.Services; -using ApplicationCore.Logging; -using ApplicationCore.Messaging; -using MassTransit; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Infrastructure.Messaging.Consumers; - -/// -/// Stage 1: Build submission code and send to Judge0. -/// Stores the Judge0 execution tokens (ExecutionId) on each SubmissionResult, -/// transitions the outbox Initialized to PollExecution, then publishes -/// SubmissionExecutionPollMessage to kick off polling. -/// -public sealed partial class SubmissionCreatedConsumer( - IServiceScopeFactory serviceScopeFactory, - ILogger logger -) : IConsumer -{ - private readonly ILogger _logger = logger; - public async Task Consume(ConsumeContext context) - { - CancellationToken cancellationToken = context.CancellationToken; - using IServiceScope scope = serviceScopeFactory.CreateScope(); - - LogStage1Started(context.Message.SubmissionId, context.Message.OutboxId); - - ISubmissionAppService submissionAppService = scope.ServiceProvider.GetRequiredService(); - IProblemAppService problemAppService = scope.ServiceProvider.GetRequiredService(); - ICodeBuilderService codeBuilderService = scope.ServiceProvider.GetRequiredService(); - ICodeExecutionService codeExecutionService = scope.ServiceProvider.GetRequiredService(); - - Ardalis.Result.Result> outboxResults = await submissionAppService.GetSubmissionOutboxesAsync(cancellationToken); - - if (!outboxResults.IsSuccess || !outboxResults.Value.Any()) - { - LogStage1OutboxNotFound(context.Message.SubmissionId, context.Message.OutboxId); - return; - } - - SubmissionOutboxModel? outbox = outboxResults.Value.FirstOrDefault(o => - o.Id == context.Message.OutboxId - && o.Type == SubmissionOutboxType.Initialized); - - if (outbox is null) - { - LogStage1OutboxNotFound(context.Message.SubmissionId, context.Message.OutboxId); - return; - } - - Ardalis.Result.Result> setupResult = await problemAppService.GetProblemSetupsForExecutionAsync( - [outbox.Submission.ProblemSetupId], - cancellationToken - ); - - if (!setupResult.IsSuccess) - { - LogStage1SetupFailed(context.Message.SubmissionId, outbox.Submission.ProblemSetupId, string.Join(", ", setupResult.Errors)); - return; - } - - ApplicationCore.Domain.Problems.ProblemSetups.ProblemSetupModel? setup = setupResult.Value.FirstOrDefault(s => s.Id == outbox.Submission.ProblemSetupId); - - if (setup is null) - { - LogStage1SetupFailed(context.Message.SubmissionId, outbox.Submission.ProblemSetupId, "Setup not found after query"); - return; - } - - System.Collections.Generic.IEnumerable builderContexts = setup.TestSuites - .SelectMany(ts => ts.TestCases) - .Select(tc => new CodeBuilderContext - { - Code = outbox.Submission.Code ?? "", - Template = setup.HarnessTemplate?.Template ?? "", - FunctionName = setup.FunctionName ?? string.Empty, - LanguageVersionId = setup.LanguageVersionId, - Judge0LanguageId = setup.LanguageVersion?.Judge0LanguageId, - Inputs = tc.Inputs, - ExpectedOutput = tc.ExpectedOutput, - }) - .ToList(); - - if (!builderContexts.Any()) - { - LogStage1BuildFailed(context.Message.SubmissionId, setup.Id, "No test cases found for setup"); - return; - } - - Ardalis.Result.Result> buildResult = codeBuilderService.Build(builderContexts); - - if (!buildResult.IsSuccess) - { - LogStage1BuildFailed(context.Message.SubmissionId, setup.Id, string.Join(", ", buildResult.Errors)); - return; - } - - CodeExecutionContext executionContext = new() - { - SubmissionId = outbox.SubmissionId, - Setup = setup, - Code = outbox.Submission.Code ?? "", - CreatedById = outbox.Submission.CreatedById, - BuiltResults = buildResult.Value, - }; - - DateTime now = DateTime.UtcNow; - await submissionAppService.IncrementOutboxesCountAsync([outbox.Id], now, cancellationToken); - - Ardalis.Result.Result> executeResult = await codeExecutionService.ExecuteAsync( - [executionContext], - cancellationToken - ); - - if (!executeResult.IsSuccess) - { - LogStage1ExecutionFailed(context.Message.SubmissionId, string.Join(", ", executeResult.Errors)); - return; - } - - await submissionAppService.SaveExecutionTokensAsync(executeResult.Value, cancellationToken); - - await context.Publish(new SubmissionExecutionPollMessage - { - SubmissionId = context.Message.SubmissionId, - OutboxId = context.Message.OutboxId, - }, cancellationToken); - - LogStage1Completed(context.Message.SubmissionId, context.Message.OutboxId); - } - - [LoggerMessage(EventId = LoggingEventIds.Submissions.Stage1Started, Level = LogLevel.Information, - Message = "Stage1: Starting submission execution for {submissionId} (outbox {outboxId})")] - private partial void LogStage1Started(Guid submissionId, Guid outboxId); - - [LoggerMessage(EventId = LoggingEventIds.Submissions.Stage1OutboxNotFound, Level = LogLevel.Warning, - Message = "Stage1: Outbox not found or not in Initialized state for submission {submissionId} (outbox {outboxId}) — may have already been processed")] - private partial void LogStage1OutboxNotFound(Guid submissionId, Guid outboxId); - - [LoggerMessage(EventId = LoggingEventIds.Submissions.Stage1SetupFailed, Level = LogLevel.Error, - Message = "Stage1: Failed to get problem setup {setupId} for submission {submissionId}: {errors}")] - private partial void LogStage1SetupFailed(Guid submissionId, int setupId, string errors); - - [LoggerMessage(EventId = LoggingEventIds.Submissions.Stage1BuildFailed, Level = LogLevel.Error, - Message = "Stage1: Code build failed for submission {submissionId} (setup {setupId}): {errors}")] - private partial void LogStage1BuildFailed(Guid submissionId, int setupId, string errors); - - [LoggerMessage(EventId = LoggingEventIds.Submissions.Stage1ExecutionFailed, Level = LogLevel.Error, - Message = "Stage1: Execution failed for submission {submissionId}: {errors}")] - private partial void LogStage1ExecutionFailed(Guid submissionId, string errors); - - [LoggerMessage(EventId = LoggingEventIds.Submissions.Stage1Completed, Level = LogLevel.Information, - Message = "Stage1: Completed submission execution for {submissionId} (outbox {outboxId})")] - private partial void LogStage1Completed(Guid submissionId, Guid outboxId); -} \ No newline at end of file diff --git a/src/Infrastructure/Messaging/Consumers/SubmissionEvaluationPollConsumer.cs b/src/Infrastructure/Messaging/Consumers/SubmissionEvaluationPollConsumer.cs deleted file mode 100644 index b3484a3..0000000 --- a/src/Infrastructure/Messaging/Consumers/SubmissionEvaluationPollConsumer.cs +++ /dev/null @@ -1,67 +0,0 @@ -using ApplicationCore.Domain.Submissions.Outboxes; -using ApplicationCore.Interfaces.Services; -using ApplicationCore.Logging; -using ApplicationCore.Messaging; -using MassTransit; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Infrastructure.Messaging.Consumers; - -/// -/// Stage 4: Final stage — increments attempt count and sets FinalizedOn -/// on the outbox, completing the submission pipeline. -/// -public sealed partial class SubmissionEvaluationPollConsumer( - IServiceScopeFactory serviceScopeFactory, - ILogger logger -) : IConsumer -{ - private readonly ILogger _logger = logger; - public async Task Consume(ConsumeContext context) - { - var cancellationToken = context.CancellationToken; - using var scope = serviceScopeFactory.CreateScope(); - - LogStage4Started(context.Message.SubmissionId, context.Message.OutboxId); - - var submissionAppService = scope.ServiceProvider.GetRequiredService(); - - var outboxResults = await submissionAppService.GetSubmissionOutboxesAsync(cancellationToken); - - if (!outboxResults.IsSuccess || !outboxResults.Value.Any()) - { - LogStage4OutboxNotFound(context.Message.SubmissionId, context.Message.OutboxId); - return; - } - - var outbox = outboxResults.Value.FirstOrDefault(o => - o.SubmissionId == context.Message.SubmissionId - && o.Type == SubmissionOutboxType.EvaluationPoll); - - if (outbox is null) - { - LogStage4OutboxNotFound(context.Message.SubmissionId, context.Message.OutboxId); - return; - } - - var now = DateTime.UtcNow; - - await submissionAppService.IncrementOutboxesCountAsync([outbox.Id], now, cancellationToken); - await submissionAppService.FinalizeEvaluationAsync([outbox.Id], now, cancellationToken); - - LogStage4Completed(context.Message.SubmissionId, context.Message.OutboxId); - } - - [LoggerMessage(EventId = LoggingEventIds.Submissions.Stage4Started, Level = LogLevel.Information, - Message = "Stage4: Finalizing evaluation for submission {submissionId} (outbox {outboxId})")] - private partial void LogStage4Started(Guid submissionId, Guid outboxId); - - [LoggerMessage(EventId = LoggingEventIds.Submissions.Stage4OutboxNotFound, Level = LogLevel.Warning, - Message = "Stage4: Outbox not found or not in EvaluationPoll state for submission {submissionId} (outbox {outboxId}) — may have already been processed")] - private partial void LogStage4OutboxNotFound(Guid submissionId, Guid outboxId); - - [LoggerMessage(EventId = LoggingEventIds.Submissions.Stage4Completed, Level = LogLevel.Information, - Message = "Stage4: Submission {submissionId} finalized (outbox {outboxId})")] - private partial void LogStage4Completed(Guid submissionId, Guid outboxId); -} \ No newline at end of file diff --git a/src/Infrastructure/Messaging/Consumers/SubmissionExecutedConsumer.cs b/src/Infrastructure/Messaging/Consumers/SubmissionExecutedConsumer.cs deleted file mode 100644 index af6361f..0000000 --- a/src/Infrastructure/Messaging/Consumers/SubmissionExecutedConsumer.cs +++ /dev/null @@ -1,126 +0,0 @@ -using ApplicationCore.Domain.Submissions; -using ApplicationCore.Domain.Submissions.Outboxes; -using ApplicationCore.Interfaces.Services; -using ApplicationCore.Logging; -using ApplicationCore.Messaging; -using MassTransit; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Infrastructure.Messaging.Consumers; - -/// -/// Stage 2: Poll Judge0 for results using the stored ExecutionId tokens. -/// Increments the attempt count, persists stdout/status, transitions -/// outbox PollExecution → Evaluate once all results are finished, then -/// publishes . -/// If results are still processing, re-publishes -/// to poll again on the next delivery. -/// -public sealed partial class SubmissionExecutedConsumer( - IServiceScopeFactory serviceScopeFactory, - ILogger logger -) : IConsumer -{ - private readonly ILogger _logger = logger; - public async Task Consume(ConsumeContext context) - { - var cancellationToken = context.CancellationToken; - using var scope = serviceScopeFactory.CreateScope(); - - LogStage2Started(context.Message.SubmissionId, context.Message.OutboxId); - - var submissionAppService = scope.ServiceProvider.GetRequiredService(); - var codeExecutionService = scope.ServiceProvider.GetRequiredService(); - - var outboxResults = await submissionAppService.GetSubmissionOutboxesAsync(cancellationToken); - - if (!outboxResults.IsSuccess || !outboxResults.Value.Any()) - { - LogStage2OutboxNotFound(context.Message.SubmissionId, context.Message.OutboxId); - return; - } - - var outbox = outboxResults.Value.FirstOrDefault(o => - o.SubmissionId == context.Message.SubmissionId - && o.Type == SubmissionOutboxType.PollExecution); - - if (outbox is null) - { - LogStage2OutboxNotFound(context.Message.SubmissionId, context.Message.OutboxId); - return; - } - - var now = DateTime.UtcNow; - await submissionAppService.IncrementOutboxesCountAsync([outbox.Id], now, cancellationToken); - - var pollResult = await codeExecutionService.GetSubmissionResultsAsync( - [outbox.Submission], - cancellationToken - ); - - if (!pollResult.IsSuccess) - { - LogStage2PollFailed(context.Message.SubmissionId, string.Join(", ", pollResult.Errors)); - return; - } - - var submission = pollResult.Value.FirstOrDefault(); - - if (submission is null) - { - LogStage2PollFailed(context.Message.SubmissionId, "No submission returned from poll"); - return; - } - - await submissionAppService.ProcessPollingSubmissionExecutionsAsync( - pollResult.Value, - cancellationToken - ); - - bool allFinished = submission.Results.All(r => - r.Status is not SubmissionStatus.InQueue - and not SubmissionStatus.Processing); - - if (!allFinished) - { - LogStage2StillProcessing(context.Message.SubmissionId); - - await context.Publish(new SubmissionExecutionPollMessage - { - SubmissionId = context.Message.SubmissionId, - OutboxId = context.Message.OutboxId, - }, cancellationToken); - - return; - } - - await context.Publish(new SubmissionReadyToEvaluateMessage - { - SubmissionId = context.Message.SubmissionId, - OutboxId = context.Message.OutboxId, - }, cancellationToken); - - LogStage2Completed(context.Message.SubmissionId, context.Message.OutboxId); - } - - [LoggerMessage(EventId = LoggingEventIds.Submissions.Stage2Started, Level = LogLevel.Information, - Message = "Stage2: Polling execution results for submission {submissionId} (outbox {outboxId})")] - private partial void LogStage2Started(Guid submissionId, Guid outboxId); - - [LoggerMessage(EventId = LoggingEventIds.Submissions.Stage2OutboxNotFound, Level = LogLevel.Warning, - Message = "Stage2: Outbox not found or not in PollExecution state for submission {submissionId} (outbox {outboxId}) — may have already been processed")] - private partial void LogStage2OutboxNotFound(Guid submissionId, Guid outboxId); - - [LoggerMessage(EventId = LoggingEventIds.Submissions.Stage2PollFailed, Level = LogLevel.Error, - Message = "Stage2: Poll failed for submission {submissionId}: {errors}")] - private partial void LogStage2PollFailed(Guid submissionId, string errors); - - [LoggerMessage(EventId = LoggingEventIds.Submissions.Stage2StillProcessing, Level = LogLevel.Information, - Message = "Stage2: Submission {submissionId} still processing, re-queuing poll")] - private partial void LogStage2StillProcessing(Guid submissionId); - - [LoggerMessage(EventId = LoggingEventIds.Submissions.Stage2Completed, Level = LogLevel.Information, - Message = "Stage2: All results received for submission {submissionId} (outbox {outboxId}), advancing to evaluation")] - private partial void LogStage2Completed(Guid submissionId, Guid outboxId); -} \ No newline at end of file diff --git a/src/Infrastructure/Messaging/Consumers/SubmissionReadyToEvaluateConsumer.cs b/src/Infrastructure/Messaging/Consumers/SubmissionReadyToEvaluateConsumer.cs deleted file mode 100644 index 26e565c..0000000 --- a/src/Infrastructure/Messaging/Consumers/SubmissionReadyToEvaluateConsumer.cs +++ /dev/null @@ -1,128 +0,0 @@ -using ApplicationCore.Domain.Submissions; -using ApplicationCore.Domain.Submissions.Outboxes; -using ApplicationCore.Interfaces.Services; -using ApplicationCore.Logging; -using ApplicationCore.Messaging; -using MassTransit; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Infrastructure.Messaging.Consumers; - -/// -/// Stage 3: Compare each result's stdout against its expected output using -/// . Persists Accepted/WrongAnswer -/// statuses, transitions outbox Evaluate → EvaluationPoll, then publishes -/// . -/// -public sealed partial class SubmissionReadyToEvaluateConsumer( - IServiceScopeFactory serviceScopeFactory, - ILogger logger -) : IConsumer -{ - private readonly ILogger _logger = logger; - public async Task Consume(ConsumeContext context) - { - var cancellationToken = context.CancellationToken; - using var scope = serviceScopeFactory.CreateScope(); - - LogStage3Started(context.Message.SubmissionId, context.Message.OutboxId); - - var submissionAppService = scope.ServiceProvider.GetRequiredService(); - var problemAppService = scope.ServiceProvider.GetRequiredService(); - var comparisonService = scope.ServiceProvider.GetRequiredService(); - - var outboxResults = await submissionAppService.GetSubmissionOutboxesAsync(cancellationToken); - - if (!outboxResults.IsSuccess || !outboxResults.Value.Any()) - { - LogStage3OutboxNotFound(context.Message.SubmissionId, context.Message.OutboxId); - return; - } - - var outbox = outboxResults.Value.FirstOrDefault(o => - o.SubmissionId == context.Message.SubmissionId - && o.Type == SubmissionOutboxType.Evaluate); - - if (outbox is null) - { - LogStage3OutboxNotFound(context.Message.SubmissionId, context.Message.OutboxId); - return; - } - - // Reload the problem setup to get expected outputs per test case - var setupResult = await problemAppService.GetProblemSetupsForExecutionAsync( - [outbox.Submission.ProblemSetupId], - cancellationToken - ); - - if (!setupResult.IsSuccess) - { - LogStage3SetupNotFound(context.Message.SubmissionId, outbox.Submission.ProblemSetupId); - return; - } - - var setup = setupResult.Value.FirstOrDefault(s => s.Id == outbox.Submission.ProblemSetupId); - - if (setup is null) - { - LogStage3SetupNotFound(context.Message.SubmissionId, outbox.Submission.ProblemSetupId); - return; - } - - var expectedOutputs = setup.TestSuites - .SelectMany(ts => ts.TestCases) - .Select(tc => tc.ExpectedOutput.Value) - .ToList(); - - var results = outbox.Submission.Results.ToList(); - - for (int i = 0; i < results.Count; i++) - { - if (results[i].Status != SubmissionStatus.Processing) - { - continue; - } - - string expected = i < expectedOutputs.Count ? expectedOutputs[i] : string.Empty; - results[i].Status = comparisonService.Compare(results[i].ProgramOutput, expected); - } - - var evaluated = new SubmissionModel - { - Id = outbox.SubmissionId, - CreatedById = outbox.Submission.CreatedById, - Results = results, - }; - - var now = DateTime.UtcNow; - await submissionAppService.IncrementOutboxesCountAsync([outbox.Id], now, cancellationToken); - - // Persist comparison results and transition Evaluate → EvaluationPoll - await submissionAppService.ProcessEvaluationAsync([evaluated], cancellationToken); - - await context.Publish(new SubmissionEvaluationPollMessage - { - SubmissionId = context.Message.SubmissionId, - OutboxId = context.Message.OutboxId, - }, cancellationToken); - - LogStage3Completed(context.Message.SubmissionId, context.Message.OutboxId); - } - - [LoggerMessage(EventId = LoggingEventIds.Submissions.Stage3Started, Level = LogLevel.Information, - Message = "Stage3: Starting evaluation for submission {submissionId} (outbox {outboxId})")] - private partial void LogStage3Started(Guid submissionId, Guid outboxId); - - [LoggerMessage(EventId = LoggingEventIds.Submissions.Stage3OutboxNotFound, Level = LogLevel.Warning, - Message = "Stage3: Outbox not found or not in Evaluate state for submission {submissionId} (outbox {outboxId}) — may have already been processed")] - private partial void LogStage3OutboxNotFound(Guid submissionId, Guid outboxId); - - [LoggerMessage(EventId = LoggingEventIds.Submissions.Stage3SetupNotFound, Level = LogLevel.Error, - Message = "Stage3: Problem setup {setupId} not found for submission {submissionId}")] - private partial void LogStage3SetupNotFound(Guid submissionId, int setupId); - - [LoggerMessage(EventId = LoggingEventIds.Submissions.Stage3Completed, Level = LogLevel.Information, - Message = "Stage3: Evaluation complete for submission {submissionId} (outbox {outboxId})")] - private partial void LogStage3Completed(Guid submissionId, Guid outboxId); -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/AppDbContext.cs b/src/Infrastructure/Persistence/AppDbContext.cs deleted file mode 100644 index 8ca4f03..0000000 --- a/src/Infrastructure/Persistence/AppDbContext.cs +++ /dev/null @@ -1,155 +0,0 @@ -using Infrastructure.Persistence.Entities.Account; -using Infrastructure.Persistence.Entities.Language; -using Infrastructure.Persistence.Entities.Problem; -using Infrastructure.Persistence.Entities.Submission; -using Infrastructure.Persistence.Entities.Submission.Outbox; -using Infrastructure.Persistence.Entities.TestSuite; -using Microsoft.EntityFrameworkCore; -namespace Infrastructure.Persistence; - -public sealed class AppDbContext(DbContextOptions options) : DbContext(options) -{ - public DbSet Accounts { get; set; } - - public DbSet HarnessTemplates { get; set; } - - public DbSet LanguageVersions { get; set; } - - public DbSet LanguageVersionEngineMappings { get; set; } - - public DbSet Problems { get; set; } - - public DbSet ProblemHistories { get; set; } - - public DbSet ProblemSetups { get; set; } - - public DbSet ProblemStatuses { get; set; } - - public DbSet ProgrammingLanguages { get; set; } - - public DbSet SubmissionOutboxes { get; set; } - - public DbSet SubmissionOutboxStatuses { get; set; } - - public DbSet SubmissionOutboxTypes { get; set; } - - public DbSet Submissions { get; set; } - - public DbSet SubmissionResults { get; set; } - - public DbSet SubmissionStatuses { get; set; } - - public DbSet SubmissionStatusTypes { get; set; } - - public DbSet Tags { get; set; } - - public DbSet TestCases { get; set; } - - public DbSet TestCaseExpectedOutputs { get; set; } - - public DbSet TestCasesInputsValueTypes { get; set; } - - public DbSet TestCasesOutputTypes { get; set; } - - public DbSet TestSuites { get; set; } - - public DbSet TestSuiteTypes { get; set; } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - ModelProblems(modelBuilder); - ModelProblemSetupsTestSuites(modelBuilder); - ModelTestSuiteTestCases(modelBuilder); - ModelTestCaseInputs(modelBuilder); - ModelTestCaseExpectedOutput(modelBuilder); - ModelLanguageVersionEngineMappings(modelBuilder); - } - - private static void ModelProblems(ModelBuilder modelBuilder) - { - modelBuilder - .Entity() - .HasMany(p => p.Tags) - .WithMany(t => t.Problems) - .UsingEntity>( - "problem_tags", - j => j.HasOne().WithMany().HasForeignKey("tag_id"), - j => j.HasOne().WithMany().HasForeignKey("problem_id") - ); - } - - private static void ModelProblemSetupsTestSuites(ModelBuilder modelBuilder) - { - modelBuilder - .Entity() - .HasMany(ps => ps.TestSuites) - .WithMany(ts => ts.Setups) - .UsingEntity>( - "problem_setup_test_suites", - j => - j.HasOne() - .WithMany() - .HasForeignKey("test_suite_id") - .HasConstraintName("fk_problem_setup_test_suites_test_suite"), - j => - j.HasOne() - .WithMany() - .HasForeignKey("problem_setup_id") - .HasConstraintName("fk_problem_setup_test_suites_problem_setup"), - j => - { - j.ToTable("problem_setup_test_suites"); - j.HasKey("problem_setup_id", "test_suite_id"); - } - ); - } - - private static void ModelTestSuiteTestCases(ModelBuilder modelBuilder) - { - modelBuilder - .Entity() - .HasMany(ts => ts.TestCases) - .WithOne(tc => tc.TestSuite) - .HasForeignKey(tc => tc.TestSuiteId) - .HasConstraintName("fk_test_cases_test_suite_id"); - } - - private static void ModelTestCaseInputs(ModelBuilder modelBuilder) - { - modelBuilder - .Entity() - .HasMany(tc => tc.InputParams) - .WithOne(i => i.TestCase) - .HasForeignKey(i => i.TestCaseId) - .HasConstraintName("fk_test_cases_inputs_test_case_id"); - } - - private static void ModelTestCaseExpectedOutput(ModelBuilder modelBuilder) - { - modelBuilder - .Entity() - .HasOne(tc => tc.ExpectedOutput) - .WithOne(o => o.TestCase) - .HasForeignKey(o => o.TestCaseId) - .HasConstraintName("fk_test_cases_expected_outputs_test_case_id"); - - modelBuilder - .Entity() - .HasOne(o => o.OutputType) - .WithMany() - .HasForeignKey(o => o.OutputValueTypeId) - .HasConstraintName("fk_test_cases_expected_outputs_output_type"); - } - - private static void ModelLanguageVersionEngineMappings(ModelBuilder modelBuilder) - { - modelBuilder - .Entity() - .HasMany(lv => lv.EngineMappings) - .WithOne(m => m.LanguageVersion) - .HasForeignKey(m => m.ProgrammingLanguageVersionId) - .HasConstraintName("fk_lang_version_engine_mappings_language_version"); - } -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/Account/AccountEntity.cs b/src/Infrastructure/Persistence/Entities/Account/AccountEntity.cs deleted file mode 100644 index ee7c74e..0000000 --- a/src/Infrastructure/Persistence/Entities/Account/AccountEntity.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.Account; - -[Table("accounts")] -public sealed class AccountEntity -{ - [Key] - [Column("id")] - public Guid Id { get; set; } - - [Column("username"), Required, MaxLength(36)] - public required string Username { get; set; } - - [Column("sub"), Required, MaxLength(255)] - public required string Sub { get; set; } - - [MaxLength(300)] - [Column("image_url")] - public string? ImageUrl { get; set; } - - [Column("created_on")] - public DateTime CreatedOn { get; set; } - - [Column("last_modified_on")] - public DateTime? LastModifiedOn { get; set; } - - [Column("last_modified_by_id")] - public Guid? LastModifiedById { get; set; } - - [ForeignKey(nameof(LastModifiedById))] - public AccountEntity? LastModifiedByAccount { get; set; } - - [Column("deleted_on")] - public DateTime? DeletedOn { get; set; } - - [Column("previous_username"), MaxLength(36)] - public string? PreviousUsername { get; set; } - - [Column("username_last_changed_at")] - public DateTime? UsernameLastChangedAt { get; set; } - - [Column("about"), MaxLength(255)] - public string? About { get; set; } -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/BaseAuditableEntity.cs b/src/Infrastructure/Persistence/Entities/BaseAuditableEntity.cs deleted file mode 100644 index 2c712b3..0000000 --- a/src/Infrastructure/Persistence/Entities/BaseAuditableEntity.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Infrastructure.Persistence.Entities.Account; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities; - -public abstract class BaseAuditableEntity -{ - [Column("created_on")] - public DateTime CreatedOn { get; set; } - - [Column("created_by_id")] - public Guid? CreatedById { get; set; } - - [ForeignKey(nameof(CreatedById))] - public AccountEntity? CreatedBy { get; set; } - - [Column("last_modified_on")] - public DateTime? LastModifiedOn { get; set; } - - [Column("last_modified_by_id")] - public Guid? LastModifiedById { get; set; } - - [ForeignKey(nameof(LastModifiedById))] - public AccountEntity? LastModifiedByAccount { get; set; } - - [Column("deleted_on")] - public DateTime? DeletedOn { get; set; } -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/Language/LanguageVersionEngineMappingEntity.cs b/src/Infrastructure/Persistence/Entities/Language/LanguageVersionEngineMappingEntity.cs deleted file mode 100644 index 18a99fb..0000000 --- a/src/Infrastructure/Persistence/Entities/Language/LanguageVersionEngineMappingEntity.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.Language; - -[Table("language_version_engine_mappings")] -public sealed class LanguageVersionEngineMappingEntity -{ - [Key, Column("id")] - public int Id { get; init; } - - [Column("programming_language_version_id")] - public int ProgrammingLanguageVersionId { get; init; } - - [ForeignKey(nameof(ProgrammingLanguageVersionId))] - public LanguageVersionEntity? LanguageVersion { get; init; } - - [Column("engine_id")] - public int EngineId { get; init; } - - [Column("engine_language_id")] - public int EngineLanguageId { get; init; } - - [Column("engine_language_name")] - public string? EngineLanguageName { get; init; } -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/Language/LanguageVersionEntity.cs b/src/Infrastructure/Persistence/Entities/Language/LanguageVersionEntity.cs deleted file mode 100644 index d34cf15..0000000 --- a/src/Infrastructure/Persistence/Entities/Language/LanguageVersionEntity.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Infrastructure.Persistence.Entities.Problem; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.Language; - -[Table("programming_language_versions")] -public sealed class LanguageVersionEntity : BaseAuditableEntity -{ - [Key] - [Column("id")] - public int Id { get; init; } - - [Required] - [MaxLength(20)] - [Column("version")] - public required string Version { get; init; } - - [Column("programming_language_id")] - public int ProgrammingLanguageId { get; init; } - - [Column("initial_code")] - public string? InitialCode { get; init; } - - [ForeignKey(nameof(ProgrammingLanguageId))] - public ProgrammingLanguageEntity? ProgrammingLanguage { get; set; } - - public IEnumerable ProblemSetups { get; set; } = []; - - public IEnumerable EngineMappings { get; set; } = []; -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/Language/ProgrammingLanguageEntity.cs b/src/Infrastructure/Persistence/Entities/Language/ProgrammingLanguageEntity.cs deleted file mode 100644 index 87683bf..0000000 --- a/src/Infrastructure/Persistence/Entities/Language/ProgrammingLanguageEntity.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.Language; - -[Table("programming_languages")] -public sealed class ProgrammingLanguageEntity : BaseAuditableEntity -{ - [Key] - [Column("id")] - public int Id { get; init; } - - [Required] - [MaxLength(50)] - [Column("name")] - public required string Name { get; init; } - - [Column("is_archived")] - public bool IsArchived { get; init; } - - public required ICollection Versions { get; set; } -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/Problem/HarnessTemplateEntity.cs b/src/Infrastructure/Persistence/Entities/Problem/HarnessTemplateEntity.cs deleted file mode 100644 index b16a98c..0000000 --- a/src/Infrastructure/Persistence/Entities/Problem/HarnessTemplateEntity.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.Problem; - -[Table("harness_templates")] -public sealed class HarnessTemplateEntity -{ - [Key] - [Column("id")] - public int Id { get; set; } - - [Required, Column("template")] - public required string Template { get; set; } - - public IEnumerable ProblemSetups { get; set; } = []; -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/Problem/ProblemEntity.cs b/src/Infrastructure/Persistence/Entities/Problem/ProblemEntity.cs deleted file mode 100644 index 576f63f..0000000 --- a/src/Infrastructure/Persistence/Entities/Problem/ProblemEntity.cs +++ /dev/null @@ -1,44 +0,0 @@ -using ApplicationCore.Domain.Problems; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.Problem; - -[Table("problems")] -public sealed class ProblemEntity : BaseAuditableEntity -{ - [Key] - [Column("id")] - public Guid Id { get; set; } - - [Required] - [MaxLength(100)] - [Column("title")] - public required string Title { get; set; } - - [Required] - [MaxLength(100)] - [Column("slug")] - public required string Slug { get; set; } - - [Required] - [Column("question", TypeName = "text")] - public required string Question { get; set; } - - [Required] - [Column("difficulty")] - public required int Difficulty { get; set; } - - public required ICollection Tags { get; set; } - - [Column("status_id")] - public required int StatusId { get; set; } = (int)ProblemStatus.Pending; - - [Column("version")] - public int Version { get; set; } = 1; - - [ForeignKey(nameof(StatusId))] - public ProblemStatusEntity? Status { get; set; } - - public ICollection ProblemSetups { get; set; } = []; -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/Problem/ProblemHistoryEntity.cs b/src/Infrastructure/Persistence/Entities/Problem/ProblemHistoryEntity.cs deleted file mode 100644 index edab569..0000000 --- a/src/Infrastructure/Persistence/Entities/Problem/ProblemHistoryEntity.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.Problem; - -[Table("problem_history")] -public sealed class ProblemHistoryEntity -{ - [Key] - [Column("id")] - public Guid Id { get; set; } - - [Column("problem_id")] - public Guid ProblemId { get; set; } - - [Column("version")] - public int Version { get; set; } - - [Column("title")] - public required string Title { get; set; } - - [Column("slug")] - public required string Slug { get; set; } - - [Column("difficulty")] - public int Difficulty { get; set; } - - [Column("question")] - public required string Question { get; set; } - - [Column("archived_on")] - public DateTime ArchivedOn { get; set; } - - [Column("archived_by_id")] - public Guid? ArchivedById { get; set; } - - [Column("archive_reason")] - public string? ArchiveReason { get; set; } -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/Problem/ProblemSetupEntity.cs b/src/Infrastructure/Persistence/Entities/Problem/ProblemSetupEntity.cs deleted file mode 100644 index 5861731..0000000 --- a/src/Infrastructure/Persistence/Entities/Problem/ProblemSetupEntity.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Infrastructure.Persistence.Entities.Language; -using Infrastructure.Persistence.Entities.TestSuite; -using Microsoft.EntityFrameworkCore; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.Problem; - -[Table("problem_setups")] -public sealed class ProblemSetupEntity : BaseAuditableEntity -{ - [Key] - [Column("id")] - public int Id { get; set; } - - [Column("problem_id")] - public Guid ProblemId { get; set; } - - [ForeignKey(nameof(ProblemId))] - public ProblemEntity? Problem { get; set; } - - [Column("programming_language_version_id")] - public int ProgrammingLanguageVersionId { get; set; } - - [ForeignKey(nameof(ProgrammingLanguageVersionId))] - public LanguageVersionEntity? LanguageVersion { get; set; } - - [Column("version")] - public int Version { get; set; } - - [Column("initial_code")] - public string? InitialCode { get; set; } - - [Column("function_name")] - public string? FunctionName { get; set; } - - [Column("harness_template_id")] - public int HarnessTemplateId { get; set; } - - [ForeignKey(nameof(HarnessTemplateId))] - public HarnessTemplateEntity? HarnessTemplate { get; set; } - - public IEnumerable TestSuites { get; set; } = []; -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/Problem/ProblemStatus.cs b/src/Infrastructure/Persistence/Entities/Problem/ProblemStatus.cs deleted file mode 100644 index 005a030..0000000 --- a/src/Infrastructure/Persistence/Entities/Problem/ProblemStatus.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.Problem; - -[Table("problem_statuses")] -public sealed class ProblemStatusEntity : BaseAuditableEntity -{ - [Key] - [Column("id")] - public int Id { get; init; } - - [Column("description")] - [MaxLength(100)] - public string? Description { get; init; } - - public ICollection? Problems { get; set; } -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/Problem/TagEntity.cs b/src/Infrastructure/Persistence/Entities/Problem/TagEntity.cs deleted file mode 100644 index 0d28f28..0000000 --- a/src/Infrastructure/Persistence/Entities/Problem/TagEntity.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.Problem; - -[Table("tags")] -public sealed class TagEntity -{ - [Key] - [Column("id")] - public int Id { get; set; } - - [MaxLength(50)] - [Column("value")] - public required string Value { get; set; } - - public ICollection? Problems { get; set; } -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/Submission/Outbox/SubmissionOutboxEntity.cs b/src/Infrastructure/Persistence/Entities/Submission/Outbox/SubmissionOutboxEntity.cs deleted file mode 100644 index 21171ad..0000000 --- a/src/Infrastructure/Persistence/Entities/Submission/Outbox/SubmissionOutboxEntity.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.Submission.Outbox; - -[Table("submission_outbox")] -public sealed class SubmissionOutboxEntity -{ - [Key, Column("id")] - public Guid Id { get; set; } - - [Required, Column("submission_id")] - public required Guid SubmissionId { get; set; } - - [ForeignKey(nameof(SubmissionId))] - public SubmissionEntity? Submission { get; set; } - - [Required, Column("submission_outbox_type_id")] - public required int SubmissionOutboxTypeId { get; set; } - - [ForeignKey(nameof(SubmissionOutboxTypeId))] - public SubmissionOutboxTypeEntity? SubmissionOutboxType { get; set; } - - [Required, Column("submission_outbox_status_id")] - public required int SubmissionOutboxStatusId { get; set; } - - [ForeignKey(nameof(SubmissionOutboxStatusId))] - public SubmissionOutboxStatusEntity? SubmissionOutboxStatus { get; set; } - - [Column("attempt_count")] - public int AttemptCount { get; set; } - - [Column("next_attempt_on")] - public DateTime? NextAttemptOn { get; set; } - - [Column("last_error"), MaxLength(255)] - public string? LastError { get; set; } - - [Column("created_on")] - public DateTime CreatedOn { get; set; } - - [Column("process_on")] - public DateTime? ProcessOn { get; set; } - - [Column("finalized_on")] - public DateTime? FinalizedOn { get; set; } -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/Submission/Outbox/SubmissionOutboxStatusEntity.cs b/src/Infrastructure/Persistence/Entities/Submission/Outbox/SubmissionOutboxStatusEntity.cs deleted file mode 100644 index 6639ab5..0000000 --- a/src/Infrastructure/Persistence/Entities/Submission/Outbox/SubmissionOutboxStatusEntity.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.Submission.Outbox; - -[Table("submission_outbox_statuses")] -public sealed class SubmissionOutboxStatusEntity -{ - [Key, Column("id")] - public int Id { get; set; } - - [Required, Column("name"), MaxLength(100)] - public required string Name { get; set; } - - [Column("description"), MaxLength(500)] - public string? Description { get; set; } - - [Column("created_on")] - public DateTime CreatedOn { get; set; } -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/Submission/Outbox/SubmissionOutboxTypeEntity.cs b/src/Infrastructure/Persistence/Entities/Submission/Outbox/SubmissionOutboxTypeEntity.cs deleted file mode 100644 index 4f67490..0000000 --- a/src/Infrastructure/Persistence/Entities/Submission/Outbox/SubmissionOutboxTypeEntity.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.Submission.Outbox; - -[Table("submission_outbox_types")] -public sealed class SubmissionOutboxTypeEntity -{ - [Key, Column("id")] - public int Id { get; set; } - - [Required, Column("name"), MaxLength(100)] - public required string Name { get; set; } - - [Column("description"), MaxLength(500)] - public string? Description { get; set; } - - [Column("created_on")] - public DateTime CreatedOn { get; set; } -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/Submission/SubmissionEntity.cs b/src/Infrastructure/Persistence/Entities/Submission/SubmissionEntity.cs deleted file mode 100644 index c4085d0..0000000 --- a/src/Infrastructure/Persistence/Entities/Submission/SubmissionEntity.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Infrastructure.Persistence.Entities.Account; -using Infrastructure.Persistence.Entities.Problem; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.Submission; - -[Table("submissions")] -public sealed class SubmissionEntity -{ - [Key, Column("id")] - public Guid Id { get; set; } - - [Column("code")] - public required string Code { get; set; } - - [Required, Column("problem_setup_id")] - public required int ProblemSetupId { get; set; } - - [ForeignKey(nameof(ProblemSetupId))] - public ProblemSetupEntity? ProblemSetup { get; set; } - - [Column("completed_at")] - public DateTime? CompletedAt { get; set; } - - [Column("created_on")] - public DateTime CreatedOn { get; set; } - - [Required, Column("created_by_id")] - public Guid CreatedById { get; set; } - - [ForeignKey(nameof(CreatedById))] - public AccountEntity? CreatedBy { get; set; } - - public List Results { get; set; } = []; -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/Submission/SubmissionResultEntity.cs b/src/Infrastructure/Persistence/Entities/Submission/SubmissionResultEntity.cs deleted file mode 100644 index 200f930..0000000 --- a/src/Infrastructure/Persistence/Entities/Submission/SubmissionResultEntity.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Infrastructure.Persistence.Entities.Account; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.Submission; - -[Table("submission_results")] -public sealed class SubmissionResultEntity -{ - [Key, Column("id")] - public Guid Id { get; set; } - - [Required, Column("submission_id")] - public Guid SubmissionId { get; set; } - - [ForeignKey(nameof(SubmissionId))] - public SubmissionEntity? Submission { get; set; } - - [Column("status_id")] - public required int StatusId { get; set; } - - [ForeignKey(nameof(StatusId))] - public SubmissionStatusEntity? Status { get; set; } - - [Column("started_at")] - public DateTime? StartedAt { get; set; } - - [Column("finished_at")] - public DateTime? FinishedAt { get; set; } - - [Column("stdout")] - public string? Stdout { get; set; } - - [Column("program_output")] - public string? ProgramOutput { get; set; } - - [Column("execution_id")] - public Guid ExecutionId { get; set; } - - [Column("result_id")] - public Guid? ResultId { get; set; } - - [Column("stderr")] - public string? Stderr { get; set; } - - [Column("runtime_ms")] - public int? RuntimeMs { get; set; } - - [Column("memory_kb")] - public int? MemoryKb { get; set; } - - [Column("created_on")] - public DateTime CreatedOn { get; set; } - - [Column("created_by_id")] - public Guid? CreatedById { get; set; } - - [ForeignKey(nameof(CreatedById))] - public AccountEntity? CreatedBy { get; set; } - - [Column("last_modified_on")] - public DateTime? LastModifiedOn { get; set; } - - [Column("last_modified_by_id")] - public Guid? LastModifiedById { get; set; } - - [ForeignKey(nameof(LastModifiedById))] - public AccountEntity? LastModifiedByAccount { get; set; } - - [Column("deleted_on")] - public DateTime? DeletedOn { get; set; } -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/Submission/SubmissionStatusEntity.cs b/src/Infrastructure/Persistence/Entities/Submission/SubmissionStatusEntity.cs deleted file mode 100644 index e667db0..0000000 --- a/src/Infrastructure/Persistence/Entities/Submission/SubmissionStatusEntity.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.Submission; - -[Table("submission_statuses")] -public sealed class SubmissionStatusEntity -{ - [Key, Column("id")] - public int Id { get; set; } - - [Required, MaxLength(100)] - public required string Name { get; set; } - - [Required] - public string? Description { get; set; } - - [Column("status_type_id")] - public int StatusTypeId { get; set; } - - [ForeignKey(nameof(StatusTypeId))] - public SubmissionStatusTypeEntity? StatusType { get; set; } -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/Submission/SubmissionStatusTypeEntity.cs b/src/Infrastructure/Persistence/Entities/Submission/SubmissionStatusTypeEntity.cs deleted file mode 100644 index b54cece..0000000 --- a/src/Infrastructure/Persistence/Entities/Submission/SubmissionStatusTypeEntity.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.Submission; - -[Table("submission_status_types")] -public sealed class SubmissionStatusTypeEntity -{ - [Key, Column("id")] - public int Id { get; set; } - - [Required, MaxLength(100)] - public required string Name { get; set; } - - [Required] - public string? Description { get; set; } -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/TestSuite/TestCaseEntity.cs b/src/Infrastructure/Persistence/Entities/TestSuite/TestCaseEntity.cs deleted file mode 100644 index ac3ce81..0000000 --- a/src/Infrastructure/Persistence/Entities/TestSuite/TestCaseEntity.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.TestSuite; - -[Table("test_cases")] -public sealed class TestCaseEntity -{ - [Key, Column("id")] - public int Id { get; set; } - - [Column("test_suite_id")] - public int TestSuiteId { get; set; } - - [Column("name"), MaxLength(100)] - public string? Name { get; set; } - - [Column("description"), MaxLength(200)] - public string? Description { get; set; } - - [ForeignKey(nameof(TestSuiteId))] - public TestSuiteEntity? TestSuite { get; set; } - - public IEnumerable InputParams { get; set; } = []; - - public TestCaseExpectedOutputEntity? ExpectedOutput { get; set; } -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/TestSuite/TestCaseExpectedOutputEntity.cs b/src/Infrastructure/Persistence/Entities/TestSuite/TestCaseExpectedOutputEntity.cs deleted file mode 100644 index ac6dd7a..0000000 --- a/src/Infrastructure/Persistence/Entities/TestSuite/TestCaseExpectedOutputEntity.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.TestSuite; - -[Table("test_cases_expected_outputs")] -public sealed class TestCaseExpectedOutputEntity -{ - [Key, Column("id")] - public int Id { get; set; } - - [Column("test_case_id")] - public int TestCaseId { get; set; } - - [ForeignKey(nameof(TestCaseId))] - public TestCaseEntity? TestCase { get; set; } - - [Column("value")] - public required string Value { get; set; } - - [Column("output_value_type_id")] - public int OutputValueTypeId { get; set; } - - [ForeignKey(nameof(OutputValueTypeId))] - public TestCasesOutputTypeEntity? OutputType { get; set; } -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/TestSuite/TestCaseInputEntity.cs b/src/Infrastructure/Persistence/Entities/TestSuite/TestCaseInputEntity.cs deleted file mode 100644 index a1a2b71..0000000 --- a/src/Infrastructure/Persistence/Entities/TestSuite/TestCaseInputEntity.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.TestSuite; - -[Table("test_cases_inputs")] -public sealed class TestCaseInputEntity -{ - [Key, Column("id")] - public int Id { get; set; } - - [Column("test_case_id")] - public int TestCaseId { get; set; } - - [ForeignKey(nameof(TestCaseId))] - public TestCaseEntity? TestCase { get; set; } - - [Column("value")] - public required string Value { get; set; } - - [Column("test_cases_inputs_value_type_id")] - public int TestCasesInputsValueTypeId { get; set; } - - [ForeignKey(nameof(TestCasesInputsValueTypeId))] - public TestCasesInputsValueTypeEntity? TestCasesInputsValueType { get; set; } -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/TestSuite/TestCasesInputsValueTypeEntity.cs b/src/Infrastructure/Persistence/Entities/TestSuite/TestCasesInputsValueTypeEntity.cs deleted file mode 100644 index 269f0c7..0000000 --- a/src/Infrastructure/Persistence/Entities/TestSuite/TestCasesInputsValueTypeEntity.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.TestSuite; - -[Table("test_cases_inputs_value_types")] -public sealed class TestCasesInputsValueTypeEntity -{ - [Key, Column("id")] - public int Id { get; set; } - - [Column("name"), MaxLength(50)] - public required string Name { get; set; } -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/TestSuite/TestCasesOutputTypeEntity.cs b/src/Infrastructure/Persistence/Entities/TestSuite/TestCasesOutputTypeEntity.cs deleted file mode 100644 index ec01719..0000000 --- a/src/Infrastructure/Persistence/Entities/TestSuite/TestCasesOutputTypeEntity.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; -using System.Text; - -namespace Infrastructure.Persistence.Entities.TestSuite; - -[Table("test_cases_output_value_types")] -public sealed class TestCasesOutputTypeEntity -{ - [Key, Column("id")] - public int Id { get; set; } - - [Column("name"), MaxLength(50)] - public required string Name { get; set; } -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/TestSuite/TestSuiteEntity.cs b/src/Infrastructure/Persistence/Entities/TestSuite/TestSuiteEntity.cs deleted file mode 100644 index 8bbca25..0000000 --- a/src/Infrastructure/Persistence/Entities/TestSuite/TestSuiteEntity.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Infrastructure.Persistence.Entities.Problem; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.TestSuite; - -[Table("test_suites")] -public sealed class TestSuiteEntity -{ - [Key, Column("id")] - public int Id { get; set; } - - [Required, Column("name"), MaxLength(100)] - public required string Name { get; set; } - - [Column("description"), MaxLength(100)] - public string? Description { get; set; } - - [Column("test_suite_type_id")] - public int TestSuiteTypeId { get; set; } - - [ForeignKey(nameof(TestSuiteTypeId))] - public TestSuiteTypeEntity? TestSuiteType { get; set; } - - public IEnumerable TestCases { get; set; } = []; - - public IEnumerable Setups { get; set; } = []; -} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Entities/TestSuite/TestSuiteTypeEntity.cs b/src/Infrastructure/Persistence/Entities/TestSuite/TestSuiteTypeEntity.cs deleted file mode 100644 index 876cea3..0000000 --- a/src/Infrastructure/Persistence/Entities/TestSuite/TestSuiteTypeEntity.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Infrastructure.Persistence.Entities.TestSuite; - -[Table("test_suite_types")] -public sealed class TestSuiteTypeEntity -{ - [Key] - [Column("id")] - public int Id { get; set; } - - [Column("name"), MaxLength(50)] - public required string Name { get; set; } - - [Column("description"), MaxLength(100)] - public string? Description { get; set; } -} \ No newline at end of file diff --git a/src/Infrastructure/Repositories/AccountRepository.cs b/src/Infrastructure/Repositories/AccountRepository.cs deleted file mode 100644 index fa2c6c1..0000000 --- a/src/Infrastructure/Repositories/AccountRepository.cs +++ /dev/null @@ -1,109 +0,0 @@ -using ApplicationCore.Domain.Accounts; -using ApplicationCore.Interfaces.Repositories; -using Infrastructure.Persistence; -using Infrastructure.Persistence.Entities.Account; -using Mapster; -using Microsoft.EntityFrameworkCore; - -namespace Infrastructure.Repositories; - -public sealed class AccountRepository(AppDbContext db) : IAccountRepository -{ - public async Task AddAsync(AccountModel account, CancellationToken ct) - { - var entity = account.Adapt(); - - db.Accounts.Add(entity); - await db.SaveChangesAsync(ct); - } - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken) - { - return await db - .Accounts.ProjectToType() - .SingleOrDefaultAsync(a => a.Id == id, cancellationToken); - } - - public async Task GetByUsernameOrSubAsync( - string username, - string sub, - CancellationToken cancellationToken - ) - { - return await db - .Accounts.ProjectToType() - .SingleOrDefaultAsync(a => a.Username == username || a.Sub == sub, cancellationToken); - } - - public async Task GetByUsernameAsync( - string username, - CancellationToken cancellationToken - ) - { - return await db - .Accounts.ProjectToType() - .SingleOrDefaultAsync(a => a.Username == username, cancellationToken); - } - - public async Task GetBySubAsync(string sub, CancellationToken cancellationToken) - { - return await db - .Accounts - .Where(a => a.Sub == sub) - .ProjectToType() - .SingleOrDefaultAsync(cancellationToken); - } - - public async Task ExistsAsync(Guid id, CancellationToken cancellationToken) - { - return await db - .Accounts.AsNoTracking() - .SingleOrDefaultAsync(a => a.Id == id, cancellationToken) - is not null; - } - - public async Task UpdateImageUrlAsync(Guid id, string? imageUrl, CancellationToken cancellationToken) - { - await db.Accounts - .Where(a => a.Id == id) - .ExecuteUpdateAsync( - s => s.SetProperty(a => a.ImageUrl, imageUrl), - cancellationToken - ); - } - - public async Task CountByUsernameBaseAsync(string usernameBase, CancellationToken cancellationToken) - { - return await db.Accounts - .Where(a => a.Username == usernameBase || a.Username.StartsWith(usernameBase + "_")) - .CountAsync(cancellationToken); - } - - public async Task UpdateUsernameAsync(Guid id, string username, DateTime usernameLastChangedAt, CancellationToken cancellationToken) - { - await db.Accounts - .Where(a => a.Id == id) - .ExecuteUpdateAsync( - s => s - .SetProperty(a => a.Username, username) - .SetProperty(a => a.UsernameLastChangedAt, usernameLastChangedAt), - cancellationToken - ); - } - - public async Task UpdateAboutAsync(Guid id, string? about, CancellationToken cancellationToken) - { - await db.Accounts - .Where(a => a.Id == id) - .ExecuteUpdateAsync( - s => s.SetProperty(a => a.About, about), - cancellationToken - ); - } - - public async Task ExistsByUsernameAsync(string username, CancellationToken cancellationToken) - { - return await db.Accounts - .AnyAsync(a => a.Username == username, cancellationToken); - } -} \ No newline at end of file diff --git a/src/Infrastructure/Repositories/ProblemRepository.cs b/src/Infrastructure/Repositories/ProblemRepository.cs deleted file mode 100644 index 9d84f4b..0000000 --- a/src/Infrastructure/Repositories/ProblemRepository.cs +++ /dev/null @@ -1,467 +0,0 @@ -using ApplicationCore.Common.Pagination; -using ApplicationCore.Domain.Accounts; -using ApplicationCore.Domain.Problems; -using ApplicationCore.Domain.Problems.Languages; -using ApplicationCore.Domain.Problems.ProblemSetups; -using ApplicationCore.Domain.Problems.TestSuites; -using ApplicationCore.Interfaces.Repositories; -using Infrastructure.Persistence; -using Infrastructure.Persistence.Entities.Problem; -using Mapster; -using Microsoft.EntityFrameworkCore; - -namespace Infrastructure.Repositories; - -public sealed class ProblemRepository(AppDbContext db) : IProblemRepository -{ - private readonly AppDbContext _db = db; - - private const int Judge0EngineId = 1; - - public async Task GetProblemByIdAsync( - Guid problemId, - CancellationToken cancellationToken - ) - { - return await _db - .Problems.Where(problem => problem.Id == problemId) - .Select(problem => new ProblemModel() - { - Id = problem.Id, - Slug = problem.Slug, - Title = problem.Title, - Question = problem.Question, - Difficulty = problem.Difficulty, - CreatedById = problem.CreatedById, - CreatedBy = - problem.CreatedBy != null - ? new AccountModel() - { - Id = problem.CreatedBy.Id, - Username = problem.CreatedBy.Username, - ImageUrl = problem.CreatedBy.ImageUrl, - CreatedOn = problem.CreatedOn, - } - : null, - Tags = problem.Tags.Select(tag => new TagModel() - { - Id = tag.Id, - Value = tag.Value, - }), - ProblemSetups = problem - .ProblemSetups.Select(ps => new ProblemSetupModel - { - Id = ps.Id, - ProblemId = ps.ProblemId, - InitialCode = ps.InitialCode ?? "", - Version = ps.Version, - LanguageVersionId = ps.ProgrammingLanguageVersionId, - LanguageVersion = - ps.LanguageVersion != null - ? new LanguageVersion - { - Id = ps.LanguageVersion.Id, - ProgrammingLanguageId = - ps.LanguageVersion.ProgrammingLanguageId, - ProgrammingLanguage = - ps.LanguageVersion.ProgrammingLanguage != null - ? new ProgrammingLanguage - { - Id = ps.LanguageVersion.ProgrammingLanguage.Id, - Name = ps.LanguageVersion.ProgrammingLanguage.Name, - IsArchived = ps.LanguageVersion - .ProgrammingLanguage - .IsArchived, - Versions = new List(), - } - : null, - Version = ps.LanguageVersion.Version, - } - : null, - }) - .ToList(), - }) - .SingleOrDefaultAsync(cancellationToken); - } - - public async Task CreateProblemAsync( - ProblemModel problem, - CancellationToken cancellationToken - ) - { - var normalizedTags = problem - .Tags.Select(t => t.Value.Trim()) - .Where(t => t.Length > 0) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - var existingTags = await _db - .Tags.Where(t => normalizedTags.Contains(t.Value)) - .ToListAsync(cancellationToken); - - var existingTagValues = existingTags - .Select(t => t.Value) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - var newTags = normalizedTags - .Where(t => !existingTagValues.Contains(t)) - .Select(t => new TagEntity { Value = t }) - .ToList(); - - if (newTags.Count > 0) - { - _db.Tags.AddRange(newTags); - } - - var problemEntity = new ProblemEntity - { - Id = Guid.NewGuid(), - Title = problem.Title, - Slug = problem.Slug, - Question = problem.Question, - Difficulty = problem.Difficulty, - StatusId = (int)ProblemStatus.Pending, - Tags = existingTags.Concat(newTags).ToList(), - }; - - _db.Problems.Add(problemEntity); - - await _db.SaveChangesAsync(cancellationToken); - - return problem.Id; - } - - public async Task> GetAvailableLanguagesAsync( - CancellationToken cancellationToken - ) - { - return await _db - .ProgrammingLanguages.Where(language => - !language.IsArchived && language.DeletedOn == null - ) - .Select(language => new ProgrammingLanguage - { - Id = language.Id, - Name = language.Name, - Versions = language.Versions.Select(version => new LanguageVersion - { - Id = version.Id, - InitialCode = version.InitialCode, - Version = version.Version, - }), - }) - .ToListAsync(cancellationToken); - } - - public async Task GetProblemBySlugAsync( - string slug, - CancellationToken cancellationToken - ) - { - return !string.IsNullOrWhiteSpace(slug) - ? await _db - .Problems.Where(problem => problem.Slug == slug) - .Select(problem => new ProblemModel() - { - Id = problem.Id, - Slug = problem.Slug, - Title = problem.Title, - Question = problem.Question, - Difficulty = problem.Difficulty, - CreatedById = problem.CreatedById, - CreatedBy = - problem.CreatedBy != null - ? new AccountModel() - { - Id = problem.CreatedBy.Id, - Username = problem.CreatedBy.Username, - ImageUrl = problem.CreatedBy.ImageUrl, - CreatedOn = problem.CreatedOn, - } - : null, - Tags = problem.Tags.Select(tag => new TagModel() - { - Id = tag.Id, - Value = tag.Value, - }), - ProblemSetups = problem - .ProblemSetups.Select(ps => new ProblemSetupModel - { - Id = ps.Id, - ProblemId = ps.ProblemId, - InitialCode = ps.InitialCode ?? "", - Version = ps.Version, - LanguageVersionId = ps.ProgrammingLanguageVersionId, - LanguageVersion = - ps.LanguageVersion != null - ? new LanguageVersion - { - Id = ps.LanguageVersion.Id, - ProgrammingLanguageId = - ps.LanguageVersion.ProgrammingLanguageId, - ProgrammingLanguage = - ps.LanguageVersion.ProgrammingLanguage != null - ? new ProgrammingLanguage - { - Id = ps.LanguageVersion.ProgrammingLanguage.Id, - Name = ps.LanguageVersion - .ProgrammingLanguage - .Name, - IsArchived = ps.LanguageVersion - .ProgrammingLanguage - .IsArchived, - Versions = new List(), - } - : null, - Version = ps.LanguageVersion.Version, - } - : null, - }) - .ToList(), - }) - .SingleOrDefaultAsync(cancellationToken) - : null; - } - - public async Task> GetProblemsAsync( - PaginationRequest pagination, - CancellationToken cancellationToken - ) - { - int page = pagination.Page > 0 ? pagination.Page : 1; - int size = pagination.Size > 0 ? pagination.Size : 10; - - var baseQuery = _db - .Problems.Include(p => p.Tags) - .Include(p => p.Status) - .Where(p => - p.CreatedOn <= pagination.Timestamp - && p.DeletedOn == null - && p.StatusId == (int)ProblemStatus.Published - ); - - var ordered = - pagination.Direction == SortDirection.Asc - ? baseQuery.OrderBy(p => p.CreatedOn).ThenBy(p => p.Id) - : baseQuery.OrderByDescending(p => p.CreatedOn).ThenByDescending(p => p.Id); - - int total = await ordered.CountAsync(cancellationToken); - - var problems = await ordered - .Skip((page - 1) * size) - .Take(size) - .Select(problem => new ProblemModel - { - Id = problem.Id, - Title = problem.Title, - Slug = problem.Slug, - Question = problem.Question, - Difficulty = problem.Difficulty, - Tags = problem.Tags.Select(tag => new TagModel { Id = tag.Id, Value = tag.Value }), - Version = problem.Version, - }) - .ToListAsync(cancellationToken); - - return new PaginatedResult - { - Results = problems, - Total = total, - Page = page, - Size = size, - }; - } - - public async Task> GetProblemSetupsAsync( - IEnumerable problemSetupIds, - CancellationToken cancellationToken - ) - { - return await _db - .ProblemSetups.Where(setup => problemSetupIds.Contains(setup.Id)) - .Select(ps => new ProblemSetupModel - { - Id = ps.Id, - ProblemId = ps.ProblemId, - InitialCode = ps.InitialCode ?? "", - Version = ps.Version, - FunctionName = ps.FunctionName, - LanguageVersionId = ps.ProgrammingLanguageVersionId, - LanguageVersion = - ps.LanguageVersion != null - ? new LanguageVersion - { - Id = ps.LanguageVersion.Id, - Version = ps.LanguageVersion.Version, - ProgrammingLanguageId = ps.LanguageVersion.ProgrammingLanguageId, - Judge0LanguageId = ps.LanguageVersion.EngineMappings - .Where(m => m.EngineId == Judge0EngineId) - .Select(m => (int?)m.EngineLanguageId) - .FirstOrDefault(), - ProgrammingLanguage = - ps.LanguageVersion.ProgrammingLanguage != null - ? new ProgrammingLanguage - { - Id = ps.LanguageVersion.ProgrammingLanguage.Id, - Name = ps.LanguageVersion.ProgrammingLanguage.Name, - IsArchived = ps.LanguageVersion - .ProgrammingLanguage - .IsArchived, - Versions = new List(), - } - : null, - } - : null, - HarnessTemplate = - ps.HarnessTemplate != null - ? new HarnessTemplate - { - Id = ps.HarnessTemplate.Id, - Template = ps.HarnessTemplate.Template, - } - : null, - TestSuites = ps - .TestSuites.Select(ts => new TestSuiteModel - { - Id = ts.Id, - Name = ts.Name, - TestSuiteType = (TestSuiteType)ts.TestSuiteTypeId, - TestCases = ts - .TestCases.Select(tc => new TestCaseModel - { - Id = tc.Id, - Inputs = tc.InputParams.Select(param => new TestCaseInputParamModel - { - Id = param.Id, - Value = param.Value, - TestCaseInputValueTypeId = param.TestCasesInputsValueTypeId, - InputType = new TestCaseInputValueTypeModel - { - Id = param.TestCasesInputsValueType.Id, - Name = param.TestCasesInputsValueType.Name, - }, - }), - ExpectedOutput = new TestCaseExpectedOutputModel - { - Id = tc.ExpectedOutput.Id, - TestCaseId = tc.ExpectedOutput.TestCaseId, - Value = tc.ExpectedOutput.Value, - OutputValueTypeId = tc.ExpectedOutput.OutputValueTypeId, - OutputType = new TestCaseOutputTypeModel - { - Id = tc.ExpectedOutput.OutputType.Id, - Name = tc.ExpectedOutput.OutputType.Name - } - } - }) - .ToList(), - }) - .ToList(), - }) - .ToListAsync(cancellationToken); - } - - public async Task GetProblemSetupAsync( - Guid problemId, - int languageVersionId, - CancellationToken cancellationToken - ) - { - return await _db - .ProblemSetups.Where(setup => setup.Problem.Id == problemId && setup.ProgrammingLanguageVersionId == languageVersionId) - .Include(s => s.HarnessTemplate) - .Select(ps => new ProblemSetupModel - { - Id = ps.Id, - ProblemId = ps.ProblemId, - InitialCode = ps.InitialCode ?? "", - Version = ps.Version, - FunctionName = ps.FunctionName, - LanguageVersionId = ps.ProgrammingLanguageVersionId, - LanguageVersion = - ps.LanguageVersion != null - ? new LanguageVersion - { - Id = ps.LanguageVersion.Id, - Version = ps.LanguageVersion.Version, - ProgrammingLanguageId = ps.LanguageVersion.ProgrammingLanguageId, - Judge0LanguageId = ps.LanguageVersion.EngineMappings - .Where(m => m.EngineId == Judge0EngineId) - .Select(m => (int?)m.EngineLanguageId) - .FirstOrDefault(), - ProgrammingLanguage = - ps.LanguageVersion.ProgrammingLanguage != null - ? new ProgrammingLanguage - { - Id = ps.LanguageVersion.ProgrammingLanguage.Id, - Name = ps.LanguageVersion.ProgrammingLanguage.Name, - IsArchived = ps.LanguageVersion - .ProgrammingLanguage - .IsArchived, - Versions = new List(), - } - : null, - } - : null, - HarnessTemplate = - ps.HarnessTemplate != null - ? new HarnessTemplate - { - Id = ps.HarnessTemplate.Id, - Template = ps.HarnessTemplate.Template, - } - : null, - TestSuites = ps - .TestSuites.Select(ts => new TestSuiteModel - { - Id = ts.Id, - Name = ts.Name, - TestSuiteType = (TestSuiteType)ts.TestSuiteTypeId, - TestCases = ts - .TestCases.Select(tc => new TestCaseModel - { - Id = tc.Id, - Inputs = tc.InputParams.Select(param => new TestCaseInputParamModel - { - Id = param.Id, - Value = param.Value, - TestCaseInputValueTypeId = param.TestCasesInputsValueTypeId, - InputType = new TestCaseInputValueTypeModel - { - Id = param.TestCasesInputsValueType.Id, - Name = param.TestCasesInputsValueType.Name, - }, - }), - ExpectedOutput = new TestCaseExpectedOutputModel - { - Id = tc.ExpectedOutput.Id, - TestCaseId = tc.ExpectedOutput.TestCaseId, - Value = tc.ExpectedOutput.Value, - OutputValueTypeId = tc.ExpectedOutput.OutputValueTypeId, - OutputType = new TestCaseOutputTypeModel - { - Id = tc.ExpectedOutput.OutputType.Id, - Name = tc.ExpectedOutput.OutputType.Name, - }, - }, - }) - .ToList(), - }) - .ToList(), - }) - .SingleOrDefaultAsync(cancellationToken); - } - - public async Task GetProblemSetupAsync( - int setupId, - CancellationToken cancellationToken - ) - { - return await _db - .ProblemSetups.Include(ps => ps.LanguageVersion) - .Include(ps => ps.TestSuites) - .ThenInclude(ts => ts.TestCases) - .Where(ps => ps.Id == setupId) - .ProjectToType() - .SingleOrDefaultAsync(cancellationToken); - } -} \ No newline at end of file diff --git a/src/Infrastructure/Repositories/SubmissionRepository.cs b/src/Infrastructure/Repositories/SubmissionRepository.cs deleted file mode 100644 index 6533b4a..0000000 --- a/src/Infrastructure/Repositories/SubmissionRepository.cs +++ /dev/null @@ -1,583 +0,0 @@ -using ApplicationCore.Common.Pagination; -using ApplicationCore.Domain.Accounts; -using ApplicationCore.Domain.Problems.Languages; -using ApplicationCore.Domain.Submissions; -using ApplicationCore.Domain.Submissions.Outboxes; -using ApplicationCore.Interfaces.Repositories; -using EFCore.BulkExtensions; -using Infrastructure.Persistence; -using Infrastructure.Persistence.Entities.Submission; -using Infrastructure.Persistence.Entities.Submission.Outbox; -using Mapster; -using Microsoft.EntityFrameworkCore; -using System.Linq.Expressions; - -namespace Infrastructure.Repositories; - -public sealed class SubmissionRepository(AppDbContext db) : ISubmissionRepository -{ - public async Task> GetSubmissionOutboxesAsync( - CancellationToken cancellationToken - ) - { - return await db - .SubmissionOutboxes.Where(outbox => - outbox.FinalizedOn == null && outbox.AttemptCount < MaxRetryCount - ) - .Include(outbox => outbox.Submission) - .ThenInclude(submission => submission.Results) - .Select(MapOutboxExpr) - .ToListAsync(cancellationToken: cancellationToken); - } - - public async Task SaveAsync( - SubmissionModel submission, - CancellationToken cancellationToken - ) - { - DateTime createdOn = DateTime.UtcNow; - db.Submissions.Add( - new SubmissionEntity - { - Id = submission.Id, - ProblemSetupId = submission.ProblemSetupId, - Code = submission.Code ?? "", - CreatedOn = createdOn, - CreatedById = submission.CreatedById, - } - ); - - var outboxId = Guid.NewGuid(); - db.SubmissionOutboxes.Add( - new SubmissionOutboxEntity - { - Id = outboxId, - SubmissionId = submission.Id, - SubmissionOutboxTypeId = (int)SubmissionOutboxType.Initialized, - SubmissionOutboxStatusId = (int)SubmissionOutboxStatus.Pending, - CreatedOn = createdOn, - } - ); - - await db.SaveChangesAsync(cancellationToken); - return outboxId; - } - - public Task IncrementOutboxesCountAsync( - IEnumerable outboxIds, - DateTime now, - CancellationToken cancellationToken - ) - { - return db - .SubmissionOutboxes.Where(o => outboxIds.Contains(o.Id)) - .ExecuteUpdateAsync( - setters => - setters - .SetProperty(o => o.AttemptCount, o => o.AttemptCount + 1) - .SetProperty(o => o.ProcessOn, now) - .SetProperty(o => o.NextAttemptOn, (DateTime?)null), - cancellationToken: cancellationToken - ); - } - - public async Task ProcessPollingSubmissionExecutionsAsync( - IEnumerable submissionModels, - CancellationToken cancellationToken - ) - { - await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); - - try - { - var resultEntities = submissionModels - .SelectMany( - s => s.Results, - (s, sr) => - new SubmissionResultEntity - { - Id = sr.Id, - SubmissionId = s.Id, - ExecutionId = sr.ExecutionId, - ResultId = sr.ResultId ?? sr.Id, - StatusId = (int)sr.Status, - StartedAt = sr.StartedAt, - FinishedAt = sr.FinishedAt, - Stdout = sr.Stdout, - ProgramOutput = sr.ProgramOutput, - Stderr = sr.Stderr, - RuntimeMs = sr.RuntimeMs, - MemoryKb = sr.MemoryKb, - } - ) - .ToList(); - - if (resultEntities.Count != 0) - { - await db.BulkInsertOrUpdateAsync( - resultEntities, - ResultBulkConfig, - cancellationToken: cancellationToken - ); - - var completedSubmissionIds = submissionModels - .Where(s => - s.Results.Any() - && s.Results.All(r => - r.Status - is not SubmissionStatus.InQueue - and not SubmissionStatus.Processing - ) - ) - .Select(s => s.Id) - .Distinct() - .ToList(); - - if (completedSubmissionIds.Count != 0) - { - await db - .SubmissionOutboxes.Where(outbox => - completedSubmissionIds.Contains(outbox.SubmissionId) - && outbox.SubmissionOutboxTypeId - == (int)SubmissionOutboxType.PollExecution - ) - .ExecuteUpdateAsync( - setters => - setters - .SetProperty( - o => o.SubmissionOutboxTypeId, - (int)SubmissionOutboxType.Evaluate - ) - .SetProperty(o => o.AttemptCount, _ => 0), - cancellationToken: cancellationToken - ); - } - } - - await transaction.CommitAsync(cancellationToken); - } - catch - { - await transaction.RollbackAsync(cancellationToken); - throw; - } - } - - public async Task ProcessSubmissionInitializationAsync( - IEnumerable submissions, - CancellationToken cancellationToken - ) - { - await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); - - try - { - var resultEntities = submissions - .SelectMany( - s => s.Results, - (s, sr) => - new SubmissionResultEntity - { - Id = sr.Id, - SubmissionId = s.Id, - ExecutionId = sr.ExecutionId, - ResultId = sr.Id, - StatusId = (int)SubmissionStatus.InQueue, - CreatedOn = DateTime.UtcNow, - StartedAt = sr.StartedAt, - FinishedAt = sr.FinishedAt, - Stdout = sr.Stdout, - RuntimeMs = sr.RuntimeMs, - MemoryKb = sr.MemoryKb, - } - ) - .ToList(); - - if (resultEntities.Count != 0) - { - await db.BulkInsertOrUpdateAsync( - resultEntities, - ResultBulkConfig, - cancellationToken: cancellationToken - ); - - var submissionIds = resultEntities - .Select(re => re.SubmissionId) - .Distinct() - .ToList(); - - await db - .SubmissionOutboxes.Where(outbox => - submissionIds.Contains(outbox.SubmissionId) - && outbox.SubmissionOutboxTypeId == (int)SubmissionOutboxType.Initialized - ) - .ExecuteUpdateAsync( - setters => - setters - .SetProperty( - o => o.SubmissionOutboxTypeId, - (int)SubmissionOutboxType.PollExecution - ) - .SetProperty(o => o.AttemptCount, _ => 0), - cancellationToken: cancellationToken - ); - } - - await transaction.CommitAsync(cancellationToken); - } - catch - { - await transaction.RollbackAsync(cancellationToken); - throw; - } - } - - private static readonly Expression< - Func - > MapResultExpr = result => new SubmissionResult - { - Id = result.Id, - Status = (SubmissionStatus)result.StatusId, - ExecutionId = result.ExecutionId, - ResultId = result.ResultId, - FinishedAt = result.FinishedAt, - MemoryKb = result.MemoryKb, - RuntimeMs = result.RuntimeMs, - StartedAt = result.StartedAt, - Stdout = result.Stdout, - ProgramOutput = result.ProgramOutput, - Stderr = result.Stderr, - }; - - private static readonly Expression< - Func - > MapOutboxExpr = outbox => new SubmissionOutboxModel - { - Id = outbox.Id, - Status = (SubmissionOutboxStatus)outbox.SubmissionOutboxStatusId, - Type = (SubmissionOutboxType)outbox.SubmissionOutboxTypeId, - SubmissionId = outbox.SubmissionId, - Submission = - outbox.Submission == null - ? null! - : new SubmissionModel - { - Id = outbox.Submission.Id, - ProblemSetupId = outbox.Submission.ProblemSetupId, - Code = outbox.Submission.Code, - CreatedOn = outbox.Submission.CreatedOn, - CreatedById = outbox.Submission.CreatedById, - Results = outbox.Submission.Results.AsQueryable().Select(MapResultExpr), - }, - }; - - private static readonly Expression> MapSubmissionExpr = - submission => new SubmissionModel - { - Id = submission.Id, - Code = submission.Code, - ProblemSetupId = submission.ProblemSetupId, - CreatedOn = submission.CreatedOn, - CompletedAt = submission.CompletedAt, - CreatedById = submission.CreatedById, - LanguageVersion = new LanguageVersion - { - Id = submission.ProblemSetup!.LanguageVersion!.Id, - Version = submission.ProblemSetup.LanguageVersion.Version, - InitialCode = submission.ProblemSetup.LanguageVersion.InitialCode, - ProgrammingLanguageId = submission - .ProblemSetup - .LanguageVersion - .ProgrammingLanguageId, - Judge0LanguageId = null, - ProgrammingLanguage = - submission.ProblemSetup.LanguageVersion.ProgrammingLanguage == null - ? null - : new ProgrammingLanguage - { - Id = submission.ProblemSetup.LanguageVersion.ProgrammingLanguage.Id, - Name = submission.ProblemSetup.LanguageVersion.ProgrammingLanguage.Name, - IsArchived = submission - .ProblemSetup - .LanguageVersion - .ProgrammingLanguage - .IsArchived, - }, - }, - CreatedBy = - submission.CreatedBy == null - ? null - : new AccountModel - { - Username = submission.CreatedBy.Username, - ImageUrl = submission.CreatedBy.ImageUrl, - CreatedOn = submission.CreatedBy.CreatedOn, - Id = submission.CreatedBy.Id, - }, - Results = submission - .Results.Select(result => new SubmissionResult - { - Id = result.Id, - Status = (SubmissionStatus)result.StatusId, - ExecutionId = result.ExecutionId, - ResultId = result.ResultId, - FinishedAt = result.FinishedAt, - MemoryKb = result.MemoryKb, - RuntimeMs = result.RuntimeMs, - StartedAt = result.StartedAt, - Stdout = result.Stdout, - ProgramOutput = result.ProgramOutput, - Stderr = result.Stderr, - }) - .ToList(), - }; - - private const int MaxRetryCount = 5; - - private static readonly BulkConfig ResultBulkConfig = new() - { - PropertiesToExcludeOnUpdate = [nameof(SubmissionResultEntity.CreatedOn)], - }; - - public async Task SaveExecutionTokensAsync( - IEnumerable submissions, - CancellationToken cancellationToken - ) - { - await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); - - try - { - var resultEntities = submissions - .SelectMany( - s => s.Results, - (s, sr) => - new SubmissionResultEntity - { - Id = sr.Id, - SubmissionId = s.Id, - ExecutionId = sr.ExecutionId, - ResultId = sr.ResultId, - StatusId = (int)SubmissionStatus.InQueue, - CreatedOn = DateTime.UtcNow, - StartedAt = sr.StartedAt, - FinishedAt = sr.FinishedAt, - Stdout = sr.Stdout, - RuntimeMs = sr.RuntimeMs, - MemoryKb = sr.MemoryKb, - } - ) - .ToList(); - - if (resultEntities.Count != 0) - { - await db.BulkInsertOrUpdateAsync( - resultEntities, - ResultBulkConfig, - cancellationToken: cancellationToken - ); - - var submissionIds = resultEntities.Select(r => r.SubmissionId).Distinct().ToList(); - - await db - .SubmissionOutboxes.Where(o => - submissionIds.Contains(o.SubmissionId) - && o.SubmissionOutboxTypeId == (int)SubmissionOutboxType.Initialized - ) - .ExecuteUpdateAsync( - setters => - setters - .SetProperty( - o => o.SubmissionOutboxTypeId, - (int)SubmissionOutboxType.PollExecution - ) - .SetProperty(o => o.AttemptCount, _ => 0), - cancellationToken: cancellationToken - ); - } - - await transaction.CommitAsync(cancellationToken); - } - catch - { - await transaction.RollbackAsync(cancellationToken); - throw; - } - } - - public async Task ProcessEvaluationAsync( - IEnumerable submissions, - CancellationToken cancellationToken - ) - { - await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); - - try - { - var resultEntities = submissions - .SelectMany( - s => s.Results, - (s, sr) => - new SubmissionResultEntity - { - Id = sr.Id, - SubmissionId = s.Id, - ExecutionId = sr.ExecutionId, - ResultId = sr.ResultId, - StatusId = (int)sr.Status, - StartedAt = sr.StartedAt, - FinishedAt = sr.FinishedAt, - Stdout = sr.Stdout, - ProgramOutput = sr.ProgramOutput, - Stderr = sr.Stderr, - RuntimeMs = sr.RuntimeMs, - MemoryKb = sr.MemoryKb, - } - ) - .ToList(); - - if (resultEntities.Count != 0) - { - await db.BulkInsertOrUpdateAsync( - resultEntities, - ResultBulkConfig, - cancellationToken: cancellationToken - ); - - var submissionIds = resultEntities.Select(r => r.SubmissionId).Distinct().ToList(); - - await db - .SubmissionOutboxes.Where(o => - submissionIds.Contains(o.SubmissionId) - && o.SubmissionOutboxTypeId == (int)SubmissionOutboxType.Evaluate - ) - .ExecuteUpdateAsync( - setters => - setters - .SetProperty( - o => o.SubmissionOutboxTypeId, - (int)SubmissionOutboxType.EvaluationPoll - ) - .SetProperty(o => o.AttemptCount, _ => 0), - cancellationToken: cancellationToken - ); - } - - await transaction.CommitAsync(cancellationToken); - } - catch - { - await transaction.RollbackAsync(cancellationToken); - throw; - } - } - - public async Task FinalizeEvaluationAsync( - IEnumerable outboxIds, - DateTime now, - CancellationToken cancellationToken - ) - { - var ids = outboxIds.ToList(); - - var submissionIds = await db - .SubmissionOutboxes.Where(o => ids.Contains(o.Id)) - .Select(o => o.SubmissionId) - .Distinct() - .ToListAsync(cancellationToken); - - await db - .Submissions.Where(s => submissionIds.Contains(s.Id)) - .ExecuteUpdateAsync( - setters => setters.SetProperty(s => s.CompletedAt, now), - cancellationToken: cancellationToken - ); - - await db - .SubmissionOutboxes.Where(o => ids.Contains(o.Id)) - .ExecuteUpdateAsync( - setters => - setters.SetProperty(o => o.FinalizedOn, now).SetProperty(o => o.ProcessOn, now), - cancellationToken: cancellationToken - ); - } - - public async Task> GetSubmissionsByProblemId( - Guid problemId, - Guid? accountId, - PaginationRequest pagination, - SubmissionStatus? statusFilter, - CancellationToken cancellationToken - ) - { - IQueryable query = db - .Submissions.Where(s => - s.ProblemSetup!.ProblemId == problemId - && (accountId == null || s.CreatedById == accountId) - && s.CreatedOn <= pagination.Timestamp - ) - .Include(s => s.CreatedBy) - .Include(s => s.Results) - .Include(s => s.ProblemSetup) - .ThenInclude(ps => ps!.LanguageVersion) - .ThenInclude(lv => lv!.ProgrammingLanguage); - - if (statusFilter.HasValue) - { - query = query.Where(s => s.Results.All(r => r.StatusId == (int)statusFilter.Value)); - } - - int total = await query.CountAsync(cancellationToken); - - var submissions = await query - .OrderByDescending(s => s.CreatedOn) - .Skip((pagination.Page - 1) * pagination.Size) - .Take(pagination.Size) - .Select(MapSubmissionExpr) - .ToListAsync(cancellationToken); - - return new PaginatedResult - { - Results = submissions, - Total = total, - Page = pagination.Page, - Size = pagination.Size, - }; - } - - public async Task GetSubmissionByIdAsync(Guid submissionId, CancellationToken cancellationToken) - { - var entity = await db.Submissions - .Include(s => s.Results) - .FirstOrDefaultAsync(s => s.Id == submissionId, cancellationToken); - - if (entity == null) - { - return null; - } - - return new SubmissionModel - { - Id = entity.Id, - Code = entity.Code, - ProblemSetupId = entity.ProblemSetupId, - CreatedOn = entity.CreatedOn, - CompletedAt = entity.CompletedAt, - CreatedById = entity.CreatedById, - Results = entity.Results.Select(r => new SubmissionResult - { - Id = r.Id, - Status = (SubmissionStatus)r.StatusId, - ExecutionId = r.ExecutionId, - ResultId = r.ResultId, - FinishedAt = r.FinishedAt, - MemoryKb = r.MemoryKb, - RuntimeMs = r.RuntimeMs, - StartedAt = r.StartedAt, - Stdout = r.Stdout, - ProgramOutput = r.ProgramOutput, - Stderr = r.Stderr, - }).ToList(), - }; - } -} \ No newline at end of file diff --git a/src/Infrastructure/Services/SlugService.cs b/src/Infrastructure/Services/SlugService.cs deleted file mode 100644 index f9333a1..0000000 --- a/src/Infrastructure/Services/SlugService.cs +++ /dev/null @@ -1,41 +0,0 @@ -using ApplicationCore.Interfaces.Services; -using System.Globalization; -using System.Text; -using System.Text.RegularExpressions; - -namespace Infrastructure.Services; - -public sealed partial class SlugService : ISlugService -{ - public string GenerateSlug(string input) - { - if (string.IsNullOrWhiteSpace(input)) - { - return string.Empty; - } - - string normalized = input - .Normalize(NormalizationForm.FormD) - .Where(c => CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark) - .Aggregate(new StringBuilder(), (sb, c) => sb.Append(c)) - .ToString() - .Normalize(NormalizationForm.FormC); - - normalized = normalized.ToLowerInvariant(); - - normalized = NonAlphaNumericRegex().Replace(normalized, ""); - normalized = WhitespaceRegex().Replace(normalized, "-").Trim('-'); - normalized = MultipleDashRegex().Replace(normalized, "-"); - - return normalized; - } - - [GeneratedRegex(@"[^a-z0-9\s-]", RegexOptions.Compiled)] - private static partial Regex NonAlphaNumericRegex(); - - [GeneratedRegex(@"\s+", RegexOptions.Compiled)] - private static partial Regex WhitespaceRegex(); - - [GeneratedRegex(@"-+", RegexOptions.Compiled)] - private static partial Regex MultipleDashRegex(); -} \ No newline at end of file diff --git a/src/PublicApi/Attributes/GlobalRateLimitAttribute.cs b/src/PublicApi/Attributes/GlobalRateLimitAttribute.cs deleted file mode 100644 index 7e10727..0000000 --- a/src/PublicApi/Attributes/GlobalRateLimitAttribute.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace PublicApi.Attributes; - -[AttributeUsage( - AttributeTargets.Method | AttributeTargets.Class, - AllowMultiple = false, - Inherited = true -)] -public sealed class GlobalRateLimitAttribute(int count, int seconds) : Attribute -{ - public int Count { get; } = count; - public int Seconds { get; } = seconds; - - public string PolicyName => $"Global_{Count}:{Seconds}"; -} \ No newline at end of file diff --git a/src/PublicApi/Attributes/RequireAccountAttribute.cs b/src/PublicApi/Attributes/RequireAccountAttribute.cs deleted file mode 100644 index 59a4eae..0000000 --- a/src/PublicApi/Attributes/RequireAccountAttribute.cs +++ /dev/null @@ -1,5 +0,0 @@ - -namespace PublicApi.Attributes; - -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false)] -public sealed class RequiresAccountAttribute : Attribute { } \ No newline at end of file diff --git a/src/PublicApi/Attributes/UserRateLimitAttribute.cs b/src/PublicApi/Attributes/UserRateLimitAttribute.cs deleted file mode 100644 index 43e984c..0000000 --- a/src/PublicApi/Attributes/UserRateLimitAttribute.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace PublicApi.Attributes; - -[AttributeUsage( - AttributeTargets.Method | AttributeTargets.Class, - AllowMultiple = false, - Inherited = true -)] -public sealed class UserRateLimitAttribute(int count, int seconds) : Attribute -{ - public int Count { get; } = count; - public int Seconds { get; } = seconds; - - public string PolicyName => $"User_{Count}:{Seconds}"; -} \ No newline at end of file diff --git a/src/PublicApi/Contracts/Account/CreateAccountDto.cs b/src/PublicApi/Contracts/Account/CreateAccountDto.cs deleted file mode 100644 index 918b124..0000000 --- a/src/PublicApi/Contracts/Account/CreateAccountDto.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace PublicApi.Contracts.Account; - -public record CreateAccountDto(string Username, string? ImageUrl); \ No newline at end of file diff --git a/src/PublicApi/Contracts/Account/UpdateProfileSettingsDto.cs b/src/PublicApi/Contracts/Account/UpdateProfileSettingsDto.cs deleted file mode 100644 index c4c2420..0000000 --- a/src/PublicApi/Contracts/Account/UpdateProfileSettingsDto.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace PublicApi.Contracts.Account; - -public record UpdateProfileSettingsDto(string? Bio); \ No newline at end of file diff --git a/src/PublicApi/Contracts/Account/UpdateUsernameDto.cs b/src/PublicApi/Contracts/Account/UpdateUsernameDto.cs deleted file mode 100644 index 06584b5..0000000 --- a/src/PublicApi/Contracts/Account/UpdateUsernameDto.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace PublicApi.Contracts.Account; - -public record UpdateUsernameDto(string Username); \ No newline at end of file diff --git a/src/PublicApi/Contracts/Account/UpsertAccountDto.cs b/src/PublicApi/Contracts/Account/UpsertAccountDto.cs deleted file mode 100644 index 17c3cc7..0000000 --- a/src/PublicApi/Contracts/Account/UpsertAccountDto.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace PublicApi.Contracts.Account; - -public record UpsertAccountDto(string? ImageUrl); \ No newline at end of file diff --git a/src/PublicApi/Contracts/Submission/CreateSubmissionDto.cs b/src/PublicApi/Contracts/Submission/CreateSubmissionDto.cs deleted file mode 100644 index 0a82913..0000000 --- a/src/PublicApi/Contracts/Submission/CreateSubmissionDto.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace PublicApi.Contracts.Submission; - -public sealed record CreateSubmissionDto(int ProblemSetupId, string Code); \ No newline at end of file diff --git a/src/PublicApi/Controllers/AccountController.cs b/src/PublicApi/Controllers/AccountController.cs deleted file mode 100644 index 323db03..0000000 --- a/src/PublicApi/Controllers/AccountController.cs +++ /dev/null @@ -1,211 +0,0 @@ -using ApplicationCore.Commands.Accounts.UpdateProfileSettings; -using ApplicationCore.Commands.Accounts.UpdateUsername; -using ApplicationCore.Commands.Accounts.UpsertAccount; -using ApplicationCore.Domain.Accounts; -using ApplicationCore.Dtos.Accounts; -using ApplicationCore.Interfaces.Services; -using Asp.Versioning; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.RateLimiting; -using PublicApi.Attributes; -using PublicApi.Contracts.Account; - -namespace PublicApi.Controllers; - -[ApiController] -[Route("api/v{version:apiVersion}/[controller]")] -[ApiVersion("1.0")] -public sealed partial class AccountController( - IAccountAppService accountAppService, - IAccountContext accountContext -) : BaseApiController -{ - [HttpPut] - [Authorize] - [EnableRateLimiting("ExtraShort")] - [ProducesResponseType(typeof(AccountUpsertResult), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public async Task UpsertAccountAsync( - [FromBody] UpsertAccountDto request, - CancellationToken cancellationToken - ) - { - string? sub = GetSub(); - - if (string.IsNullOrEmpty(sub)) - { - return Unauthorized(); - } - - var result = await accountAppService.UpsertAccountAsync(sub, request.ImageUrl, cancellationToken); - - return ToActionResult(result); - } - - [HttpGet("find/profile/{username}")] - [EnableRateLimiting("Short")] - [ProducesResponseType(typeof(ProfileAggregateDto), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetProfileAsync( - string username, - CancellationToken cancellationToken - ) - { - if (string.IsNullOrEmpty(username)) - { - return BadRequest("Username is required"); - } - - var accountResult = await accountAppService.GetProfileAggregateAsync( - username, - cancellationToken - ); - - if (accountResult.IsSuccess) - { - return Ok(accountResult.Value); - } - - string errors = string.Join(", ", accountResult.Errors); - - return BadRequest(errors); - } - - [HttpGet("find/profile")] - [Authorize] - [EnableRateLimiting("Medium")] - [ProducesResponseType(typeof(AccountDto), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetProfileAsync(CancellationToken cancellationToken) - { - string? sub = GetSub(); - - if (string.IsNullOrEmpty(sub)) - { - return Unauthorized(); - } - - var result = await accountAppService.GetAccountBySubAsync(sub, cancellationToken); - - if (!result.IsSuccess) - { - return ToActionResult(result); - } - - IEnumerable permissions = - [ - .. User.Claims.Where(c => c.Type == "permissions").Select(c => c.Value), - ]; - - return Ok(result.Value with { Permissions = permissions }); - } - - [HttpGet("settings")] - [Authorize] - [RequiresAccount] - [EnableRateLimiting("Medium")] - [ProducesResponseType(typeof(ProfileSettingsDto), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetSettingsAsync(CancellationToken cancellationToken) - { - string? sub = GetSub(); - - if (string.IsNullOrEmpty(sub)) - { - return Unauthorized(); - } - - var result = await accountAppService.GetProfileSettingsAsync(sub, cancellationToken); - - return ToActionResult(result); - } - - [HttpPut("settings")] - [Authorize] - [RequiresAccount] - [EnableRateLimiting("ExtraShort")] - [ProducesResponseType(typeof(UpdateProfileSettingsResult), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task UpdateProfileSettingsAsync( - [FromBody] UpdateProfileSettingsDto request, - CancellationToken cancellationToken - ) - { - if (accountContext.Account is null || accountContext.Account.Id is null) - { - return Unauthorized(); - } - - var result = await accountAppService.UpdateProfileSettingsAsync( - accountContext.Account.Id.Value, - request.Bio, - cancellationToken - ); - - return ToActionResult(result); - } - - [HttpPut("username")] - [Authorize] - [EnableRateLimiting("ExtraShort")] - [ProducesResponseType(typeof(UpdateUsernameResult), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status409Conflict)] - public async Task UpdateUsernameAsync( - [FromBody] UpdateUsernameDto request, - CancellationToken cancellationToken - ) - { - string? sub = GetSub(); - - if (string.IsNullOrEmpty(sub)) - { - return Unauthorized(); - } - - var accountResult = await accountAppService.GetAccountBySubAsync(sub, cancellationToken); - - if (!accountResult.IsSuccess) - { - // Account doesn't exist yet — upsert to ensure it's created before updating username. - // This handles the race condition where the client submits before AccountInitializer completes. - var upsertResult = await accountAppService.UpsertAccountAsync(sub, null, cancellationToken); - - if (!upsertResult.IsSuccess) - { - return ToActionResult(upsertResult); - } - - accountResult = await accountAppService.GetAccountBySubAsync(sub, cancellationToken); - - if (!accountResult.IsSuccess) - { - return ToActionResult(accountResult); - } - } - - var account = accountResult.Value; - - if (account.Id is null) - { - return Unauthorized(); - } - - var result = await accountAppService.UpdateUsernameAsync( - account.Id.Value, - request.Username, - account.UsernameLastChangedAt, - cancellationToken - ); - - return ToActionResult(result); - } -} \ No newline at end of file diff --git a/src/PublicApi/Controllers/BaseApiController.cs b/src/PublicApi/Controllers/BaseApiController.cs deleted file mode 100644 index 52274c4..0000000 --- a/src/PublicApi/Controllers/BaseApiController.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Ardalis.Result; -using Microsoft.AspNetCore.Mvc; -using System.Security.Claims; - -namespace PublicApi.Controllers; - -[ApiController] -public abstract class BaseApiController : ControllerBase -{ - protected string? GetSub() => User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - - protected IActionResult ToActionResult(Result result) - { - return result.Status switch - { - ResultStatus.Ok => Ok(result.Value), - ResultStatus.NotFound => NotFound( - new { Message = "Resource not found", result.Errors } - ), - ResultStatus.Unauthorized => Unauthorized( - new { Message = "Unauthorized", result.Errors } - ), - ResultStatus.Forbidden => Forbid(), - ResultStatus.Invalid => BadRequest( - new - { - Message = result.ValidationErrors is not null - ? string.Join( - ", ", - result.ValidationErrors.Select(e => - string.IsNullOrWhiteSpace(e.Identifier) - ? e.ErrorMessage - : $"{e.Identifier}: {e.ErrorMessage}" - ) - ) - : "Invalid request.", - Errors = result.ValidationErrors?.Select(e => new - { - Field = e.Identifier, - Error = e.ErrorMessage, - }), - } - ), - _ => StatusCode( - 500, - new { Message = "An unexpected error occurred.", Errors = result.Errors } - ), - }; - } -} \ No newline at end of file diff --git a/src/PublicApi/Controllers/ProblemController.cs b/src/PublicApi/Controllers/ProblemController.cs deleted file mode 100644 index 3678c2c..0000000 --- a/src/PublicApi/Controllers/ProblemController.cs +++ /dev/null @@ -1,173 +0,0 @@ -using ApplicationCore.Common.Pagination; -using ApplicationCore.Domain.Accounts; -using ApplicationCore.Dtos.Languages; -using ApplicationCore.Dtos.Problems; -using ApplicationCore.Dtos.Submissions; -using ApplicationCore.Interfaces.Services; -using Asp.Versioning; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.RateLimiting; -using PublicApi.Attributes; - -namespace PublicApi.Controllers; - -[ApiController] -[Route("api/v{version:apiVersion}/[controller]")] -[ApiVersion("1.0")] -public sealed class ProblemController( - IProblemAppService problemAppService, - ISubmissionAppService submissionAppService, - IAccountContext accountContext -) : BaseApiController -{ - [HttpGet("slug/{slug}")] - [EnableRateLimiting("Short")] - [ProducesResponseType(typeof(ProblemDto), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetBySlugAsync( - string slug, - CancellationToken cancellationToken - ) - { - if (string.IsNullOrWhiteSpace(slug)) - { - return BadRequest("Slug is required."); - } - - var problemResult = await problemAppService.GetProblemBySlugAsync(slug, cancellationToken); - - if (problemResult.IsSuccess) - { - return Ok(problemResult.Value); - } - - string errors = string.Join(", ", problemResult.Errors); - - return BadRequest(errors); - } - - [HttpGet("languages")] - [EnableRateLimiting("Short")] - [Authorize(Policy = "read:languages")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task GetAvailableLanguagesAsync(CancellationToken cancellationToken) - { - return ToActionResult( - await problemAppService.GetAvailableLanguagesAsync(cancellationToken) - ); - } - - [HttpGet] - [EnableRateLimiting("Medium")] - [ProducesResponseType(typeof(PaginatedResult), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task GetPageableAsync( - [FromQuery] DateTime timestamp, - [FromQuery] int page = 1, - [FromQuery] int size = 25, - CancellationToken cancellationToken = default - ) - { - if (page < 1 || size < 1) - { - return BadRequest("Page and size must be greater than 0."); - } - - return ToActionResult( - await problemAppService.GetProblemsPaginatedAsync( - page, - size, - timestamp, - cancellationToken - ) - ); - } - - [HttpGet("{problemId:guid}/setup")] - [EnableRateLimiting("ExtraShort")] - [ProducesResponseType(typeof(ProblemSetupDto), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetProblemSetupAsync( - Guid problemId, - [FromQuery] int languageVersionId, - CancellationToken cancellationToken - ) - { - return ToActionResult( - await problemAppService.GetProblemSetupAsync( - problemId, - languageVersionId, - cancellationToken - ) - ); - } - - [HttpGet("{problemId:guid}/solutions")] - [Authorize] - [EnableRateLimiting("Short")] - [ProducesResponseType(typeof(PaginatedResult), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task GetSolutionsAsync( - Guid problemId, - [FromQuery] int page = 1, - [FromQuery] int size = 25, - [FromQuery] DateTime? timestamp = null, - CancellationToken cancellationToken = default) - { - if (page < 1 || size < 1) - { - return BadRequest("Page and size must be greater than 0."); - } - - return ToActionResult( - await submissionAppService.GetSolutionsAsync(problemId, new PaginationRequest - { - Page = page, - Size = size, - Timestamp = timestamp ?? DateTime.UtcNow, - }, cancellationToken) - ); - } - - [HttpGet("{problemId:guid}/submissions")] - [EnableRateLimiting("Short")] - [Authorize] - [RequiresAccount] - [ProducesResponseType(typeof(PaginatedResult), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public async Task GetMySubmissionsAsync( - Guid problemId, - [FromQuery] int page = 1, - [FromQuery] int size = 25, - [FromQuery] DateTime? timestamp = null, - CancellationToken cancellationToken = default) - { - if (page < 1 || size < 1) - { - return BadRequest("Page and size must be greater than 0."); - } - - Guid? accountId = accountContext.Account?.Id; - - if (!accountId.HasValue) - { - return Unauthorized("User must be authenticated to view their submissions."); - } - - return ToActionResult( - await submissionAppService.GetSubmissionsPaginatedAsync(problemId, accountId.Value, new PaginationRequest - { - Page = page, - Size = size, - Timestamp = timestamp ?? DateTime.UtcNow, - }, cancellationToken) - ); - } -} \ No newline at end of file diff --git a/src/PublicApi/Controllers/SubmissionController.cs b/src/PublicApi/Controllers/SubmissionController.cs deleted file mode 100644 index a23919d..0000000 --- a/src/PublicApi/Controllers/SubmissionController.cs +++ /dev/null @@ -1,63 +0,0 @@ -using ApplicationCore.Domain.Accounts; -using ApplicationCore.Dtos.Submissions; -using ApplicationCore.Interfaces.Services; -using Asp.Versioning; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.RateLimiting; -using PublicApi.Attributes; -using PublicApi.Contracts.Submission; - -namespace PublicApi.Controllers; - -[ApiController] -[Route("api/v{version:apiVersion}/[controller]")] -[ApiVersion("1.0")] -public sealed class SubmissionController( - IAccountContext accountContext, - ISubmissionAppService submissionAppService -) : BaseApiController -{ - [HttpPost("execute")] - [Authorize] - [RequiresAccount] - [ProducesResponseType(typeof(Guid), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task CreateSubmissionAsync( - [FromBody] CreateSubmissionDto createSubmissionDto, - CancellationToken cancellationToken - ) - { - if (accountContext.Account is null) - { - return Unauthorized(); - } - - return ToActionResult( - await submissionAppService.CreateAsync( - createSubmissionDto.ProblemSetupId, - createSubmissionDto.Code, - (Guid)accountContext.Account.Id, - cancellationToken - ) - ); - } - - [HttpGet("{submissionId:guid}")] - [Authorize] - [RequiresAccount] - [ProducesResponseType(typeof(SubmissionStatusDto), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetSubmissionStatusAsync( - Guid submissionId, - CancellationToken cancellationToken - ) - { - return ToActionResult( - await submissionAppService.GetSubmissionStatusAsync(submissionId, cancellationToken) - ); - } -} \ No newline at end of file diff --git a/src/PublicApi/Extensions/AuthenticationExtensions.cs b/src/PublicApi/Extensions/AuthenticationExtensions.cs deleted file mode 100644 index cd410a5..0000000 --- a/src/PublicApi/Extensions/AuthenticationExtensions.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.IdentityModel.Tokens; - -namespace PublicApi.Extensions; - -public static class AuthenticationExtensions -{ - public static IServiceCollection AddAuthTokenValidation( - this IServiceCollection services, - IConfiguration configuration - ) - { - services - .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.Authority = $"https://{configuration["Auth0:Domain"]}/"; - options.Audience = configuration["Auth0:Audience"]; - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateAudience = true, - ValidateIssuerSigningKey = true, - }; - }); - - return services; - } -} \ No newline at end of file diff --git a/src/PublicApi/Extensions/AuthorizationExtensions.cs b/src/PublicApi/Extensions/AuthorizationExtensions.cs deleted file mode 100644 index 97b7bf7..0000000 --- a/src/PublicApi/Extensions/AuthorizationExtensions.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using PublicApi.Authorization; - -namespace PublicApi.Extensions; - -public static class AuthorizationExtensions -{ - private static readonly string[] permissions = - [ - "create:problems", - "read:admin-problems", - "read:admin-problem", - "read:languages", - ]; - - public static IServiceCollection AddRbacAuthorization(this IServiceCollection services) - { - services.AddAuthorization(options => - { - foreach (string? permission in permissions) - { - options.AddPolicy( - permission, - policy => policy.Requirements.Add(new RbacRequirement(permission)) - ); - } - }); - - services.AddSingleton(); - - return services; - } -} \ No newline at end of file diff --git a/src/PublicApi/Extensions/MiddlewareExtensions.cs b/src/PublicApi/Extensions/MiddlewareExtensions.cs deleted file mode 100644 index 7c1eae3..0000000 --- a/src/PublicApi/Extensions/MiddlewareExtensions.cs +++ /dev/null @@ -1,11 +0,0 @@ -using PublicApi.Middleware; - -namespace PublicApi.Extensions; - -public static class MiddlewareExtensions -{ - public static IApplicationBuilder UseAccountContext(this IApplicationBuilder app) - { - return app.UseMiddleware(); - } -} \ No newline at end of file diff --git a/src/PublicApi/Extensions/RateLimitRegistrationExtensions.cs b/src/PublicApi/Extensions/RateLimitRegistrationExtensions.cs deleted file mode 100644 index 87634d7..0000000 --- a/src/PublicApi/Extensions/RateLimitRegistrationExtensions.cs +++ /dev/null @@ -1,123 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.RateLimiting; -using Microsoft.Extensions.DependencyInjection; -using PublicApi.Attributes; -using System; -using System.Linq; -using System.Reflection; -using System.Threading.RateLimiting; - -namespace PublicApi; - -public static class RateLimitRegistrationExtensions -{ - public static IServiceCollection RegisterAllUserAndGlobalRateLimitPolicies( - this IServiceCollection services, - Assembly controllersAssembly - ) - { - var userLimits = new HashSet<(int, int)>(); - var globalLimits = new HashSet<(int, int)>(); - - foreach ( - var type in controllersAssembly - .GetTypes() - .Where(t => - t.IsClass && !t.IsAbstract && typeof(ControllerBase).IsAssignableFrom(t) - ) - ) - { - foreach (var attr in type.GetCustomAttributes(true)) - { - userLimits.Add((attr.Count, attr.Seconds)); - } - - foreach (var attr in type.GetCustomAttributes(true)) - { - globalLimits.Add((attr.Count, attr.Seconds)); - } - - foreach ( - var method in type.GetMethods( - BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly - ) - ) - { - foreach (var attr in method.GetCustomAttributes(true)) - { - userLimits.Add((attr.Count, attr.Seconds)); - } - - foreach (var attr in method.GetCustomAttributes(true)) - { - globalLimits.Add((attr.Count, attr.Seconds)); - } - } - } - - services.AddRateLimiter(options => - { - foreach (var (count, seconds) in userLimits) - { - AddUserRateLimitPolicy(options, count, seconds); - } - - foreach (var (count, seconds) in globalLimits) - { - AddGlobalRateLimitPolicy(options, count, seconds); - } - }); - - return services; - } - - public static void AddUserRateLimitPolicy(RateLimiterOptions options, int count, int seconds) - { - string policyName = $"User_{count}:{seconds}"; - - options.AddPolicy( - policyName, - context => - { - string userId = - context.User.FindFirst("sub")?.Value ?? context - .User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier) - ?.Value - ?? context.Connection.RemoteIpAddress?.ToString() - ?? "anonymous"; - - return RateLimitPartition.GetFixedWindowLimiter( - partitionKey: userId, - factory: _ => new FixedWindowRateLimiterOptions - { - PermitLimit = count, - Window = TimeSpan.FromSeconds(seconds), - QueueLimit = 0, - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - } - ); - } - ); - } - - public static void AddGlobalRateLimitPolicy(RateLimiterOptions options, int count, int seconds) - { - string policyName = $"Global_{count}:{seconds}"; - - options.AddPolicy( - policyName, - context => - RateLimitPartition.GetFixedWindowLimiter( - partitionKey: "global", - factory: _ => new FixedWindowRateLimiterOptions - { - PermitLimit = count, - Window = TimeSpan.FromSeconds(seconds), - QueueLimit = 0, - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - } - ) - ); - } -} \ No newline at end of file diff --git a/src/PublicApi/Extensions/SettingsRegistrationExtensions.cs b/src/PublicApi/Extensions/SettingsRegistrationExtensions.cs deleted file mode 100644 index db67c03..0000000 --- a/src/PublicApi/Extensions/SettingsRegistrationExtensions.cs +++ /dev/null @@ -1,24 +0,0 @@ -using ApplicationCore.Settings; - -namespace PublicApi.Extensions; - -public static class SettingsRegistrationExtensions -{ - public static void RegisterAppSettings( - this IServiceCollection services, - IConfiguration configuration - ) - { - RegisterSetting(services, configuration); - RegisterSetting(services, configuration); - } - - private static void RegisterSetting( - IServiceCollection services, - IConfiguration configuration - ) - where T : class, ISettings - { - services.Configure(configuration.GetSection(T.SectionKey)); - } -} \ No newline at end of file diff --git a/src/PublicApi/Filters/WrapResponseAttribute.cs b/src/PublicApi/Filters/WrapResponseAttribute.cs deleted file mode 100644 index 86eaf01..0000000 --- a/src/PublicApi/Filters/WrapResponseAttribute.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; - -namespace PublicApi.Filters; - -public class WrapResponseAttribute : ActionFilterAttribute -{ - public override void OnResultExecuting(ResultExecutingContext context) - { - if (context.Result is ObjectResult objectResult && objectResult.Value is not null) - { - if (objectResult.StatusCode >= 200 && objectResult.StatusCode < 300) - { - if (!IsAlreadyWrapped(objectResult.Value)) - { - context.Result = new JsonResult(new { data = objectResult.Value }) - { - StatusCode = objectResult.StatusCode, - }; - } - } - } - - base.OnResultExecuting(context); - } - - private static bool IsAlreadyWrapped(object value) - { - var type = value.GetType(); - return type.GetProperty("data") != null; - } -} \ No newline at end of file diff --git a/src/PublicApi/Middleware/AccountContextMiddleware.cs b/src/PublicApi/Middleware/AccountContextMiddleware.cs deleted file mode 100644 index 3a3c36f..0000000 --- a/src/PublicApi/Middleware/AccountContextMiddleware.cs +++ /dev/null @@ -1,71 +0,0 @@ -using ApplicationCore.Logging; -using Microsoft.ApplicationInsights.DataContracts; -using PublicApi.Attributes; - -namespace PublicApi.Middleware; - -using ApplicationCore.Domain.Accounts; -using ApplicationCore.Interfaces.Services; -using Microsoft.AspNetCore.Http; -using System.Security.Claims; -using System.Threading.Tasks; - -public partial class AccountContextMiddleware( - IAccountAppService accountAppService, - IAccountContext accountContext, - ILogger logger -) : IMiddleware -{ - private readonly ILogger _logger = logger; - public async Task InvokeAsync(HttpContext context, RequestDelegate next) - { - var endpoint = context.GetEndpoint(); - if (endpoint?.Metadata.GetMetadata() == null) - { - await next(context); - return; - } - - string? sub = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(sub)) - { - LogMissingSub(context.Request.Path); - await next(context); - return; - } - - var result = await accountAppService.GetAccountBySubAsync(sub, context.RequestAborted); - if (result.IsSuccess) - { - accountContext.Account = result.Value; - - var requestTelemetry = context.Features.Get(); - if (requestTelemetry is not null && accountContext.Account is not null) - { - var account = accountContext.Account; - requestTelemetry.Properties.TryAdd("account.id", account.Id?.ToString() ?? string.Empty); - requestTelemetry.Properties.TryAdd("account.username", account.Username); - requestTelemetry.Properties.TryAdd("account.permissions", string.Join(",", account.Permissions)); - requestTelemetry.Properties.TryAdd("account.createdOn", account.CreatedOn.ToString("O")); - } - } - else - { - LogResolveFailed(sub, context.Request.Path, string.Join(", ", result.Errors)); - } - - await next(context); - } - - [LoggerMessage( - EventId = LoggingEventIds.Accounts.ContextMissingSub, - Level = LogLevel.Warning, - Message = "Account context: missing sub claim on [RequiresAccount] endpoint {path}")] - private partial void LogMissingSub(string path); - - [LoggerMessage( - EventId = LoggingEventIds.Accounts.ContextResolveFailed, - Level = LogLevel.Warning, - Message = "Account context: failed to resolve account for sub {sub} on {path}: {errors}")] - private partial void LogResolveFailed(string sub, string path, string errors); -} \ No newline at end of file diff --git a/src/PublicApi/Middleware/ApplicationBuilderExtensions.cs b/src/PublicApi/Middleware/ApplicationBuilderExtensions.cs deleted file mode 100644 index c986885..0000000 --- a/src/PublicApi/Middleware/ApplicationBuilderExtensions.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace PublicApi.Middleware; - -public static class ApplicationBuilderExtensions -{ - public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app) - { - app.UseXContentTypeOptions(); - app.UseReferrerPolicy(opts => opts.NoReferrer()); - app.UseXXssProtection(options => options.EnabledWithBlockMode()); - app.UseXfo(options => options.Deny()); - app.UseCsp(options => - options.DefaultSources(s => s.Self()).StyleSources(s => s.Self().UnsafeInline()) - ); - - return app; - } -} \ No newline at end of file diff --git a/src/PublicApi/Program.cs b/src/PublicApi/Program.cs deleted file mode 100644 index 8952b7d..0000000 --- a/src/PublicApi/Program.cs +++ /dev/null @@ -1,78 +0,0 @@ -using ApplicationCore; -using ApplicationCore.Domain.Accounts; -using Asp.Versioning; -using Infrastructure; -using PublicApi; -using PublicApi.Extensions; -using PublicApi.Middleware; -using Scalar.AspNetCore; - -var builder = WebApplication.CreateBuilder(args); - -builder.Services.RegisterAppSettings(builder.Configuration); - -builder.Services.AddApplicationCore(); -builder.Services.AddInfrastructure(builder.Configuration); - -builder.Services.AddControllers(); - -builder.Services.RegisterAllUserAndGlobalRateLimitPolicies(typeof(Program).Assembly); - -builder.Services.AddAuthTokenValidation(builder.Configuration); -builder.Services.AddRbacAuthorization(); - -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddApiVersioning(o => -{ - o.DefaultApiVersion = new ApiVersion(1, 0); - o.AssumeDefaultVersionWhenUnspecified = true; - o.ReportApiVersions = true; -}); - -builder.Services.AddMediatR(cfg => -{ - cfg.LicenseKey = builder.Configuration.GetSection("MediatRSettings:LicenseKey").Get(); - cfg.RegisterServicesFromAssembly(typeof(Program).Assembly); -}); - -builder.Services.AddOpenApi(); - -if (!builder.Environment.IsDevelopment()) -{ - builder.Services.AddApplicationInsightsTelemetry(builder.Configuration); -} - -string[] allowedOrigins = - builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? []; - -builder.Services.AddCors(options => -{ - options.AddDefaultPolicy(policy => - policy.WithOrigins(allowedOrigins).AllowAnyHeader().AllowAnyMethod().AllowCredentials() - ); -}); - -var app = builder.Build(); - -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); - app.MapScalarApiReference(options => - { - options.WithOpenApiRoutePattern("/openapi/{documentName}.json"); - }); -} - -if (!app.Environment.IsDevelopment()) -{ - app.UseHttpsRedirection(); -} -app.UseCors(); -app.UseGlobalExceptionHandler(); -app.UseAuthentication(); -app.UseAuthorization(); -app.UseAccountContext(); -app.MapControllers(); - -app.Run(); \ No newline at end of file diff --git a/src/PublicApi/PublicApi.csproj b/src/PublicApi/PublicApi.csproj deleted file mode 100644 index 1bc4cb0..0000000 --- a/src/PublicApi/PublicApi.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - net10.0 - enable - enable - fc934385-92ad-4500-8c5d-dfae2e8ecc14 - - - - - - - - - - - - - - - - - diff --git a/src/PublicApi/PublicApi.http b/src/PublicApi/PublicApi.http deleted file mode 100644 index e092f04..0000000 --- a/src/PublicApi/PublicApi.http +++ /dev/null @@ -1,6 +0,0 @@ -@PublicApi_HostAddress = http://localhost:5198 - -GET {{PublicApi_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/src/PublicApi/ServiceCollectionExtensions.cs b/src/PublicApi/ServiceCollectionExtensions.cs deleted file mode 100644 index 973c726..0000000 --- a/src/PublicApi/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Asp.Versioning; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.RateLimiting; -using Microsoft.IdentityModel.Tokens; -using PublicApi.Authorization; -using PublicApi.Filters; -using System.Threading.RateLimiting; - -namespace PublicApi; - -public static class ServiceCollectionExtensions -{ - public static IServiceCollection AddApiServices( - this IServiceCollection services, - IConfiguration configuration - ) - { - services.AddOpenApi(); - services.AddControllers(options => options.Filters.Add()); - services.AddApiVersioning(o => - { - o.DefaultApiVersion = new ApiVersion(1, 0); - o.AssumeDefaultVersionWhenUnspecified = true; - o.ReportApiVersions = true; - }); - - return services; - } -} \ No newline at end of file diff --git a/src/PublicApi/appsettings.json b/src/PublicApi/appsettings.json deleted file mode 100644 index 467112f..0000000 --- a/src/PublicApi/appsettings.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - }, - "ApplicationInsights": { - "LogLevel": { - "Default": "Warning" - } - } - }, - "AllowedHosts": "*", - "MessageBus": { - "Transport": "RabbitMQ", - "RabbitMQ": { - "Host": "localhost", - "VirtualHost": "/", - "Username": "guest", - "Password": "guest" - }, - "AzureServiceBus": { - "ConnectionString": "" - } - } -} diff --git a/tests/UnitTests/ApplicationCore/Commands/Accounts/CreateAccountHandlerTests.cs b/tests/UnitTests/ApplicationCore/Commands/Accounts/CreateAccountHandlerTests.cs deleted file mode 100644 index 9adb929..0000000 --- a/tests/UnitTests/ApplicationCore/Commands/Accounts/CreateAccountHandlerTests.cs +++ /dev/null @@ -1,78 +0,0 @@ -using ApplicationCore.Commands.Accounts.CreateAccount; -using ApplicationCore.Domain.Accounts; -using ApplicationCore.Interfaces.Repositories; -using FluentValidation; -using Microsoft.Extensions.Logging; -using Moq; - -namespace UnitTests.ApplicationCore.Commands.Accounts; - -[TestFixture] -public sealed class CreateAccountHandlerTests -{ - private Mock _accounts; - private Mock> _logger; - private Mock> _validator; - - private CreateAccountHandler _handler; - - [SetUp] - public void SetUp() - { - _accounts = new(); - _logger = new(); - _validator = new(); - - _validator - .Setup(v => - v.ValidateAsync(It.IsAny(), It.IsAny()) - ) - .ReturnsAsync(new FluentValidation.Results.ValidationResult()); - - _handler = new CreateAccountHandler(_accounts.Object, _logger.Object, _validator.Object); - } - - [Test] - public async Task Handle_creates_account_successfully() - { - _accounts - .Setup(a => a.AddAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - - var command = new CreateAccountCommand("user1", "sub1", "http://image.url"); - - var result = await _handler.Handle(command, CancellationToken.None); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result.IsSuccess, Is.True); - Assert.That(result.Value, Is.Not.EqualTo(Guid.Empty)); - _accounts.Verify( - a => - a.AddAsync( - It.Is(acc => acc.Username == "user1" && acc.Sub == "sub1"), - It.IsAny() - ), - Times.Once - ); - } - } - - [Test] - public async Task Handle_returns_error_when_exception_occurs() - { - _accounts - .Setup(a => a.AddAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new Exception("DB failure")); - - var command = new CreateAccountCommand("user2", "sub2", ""); - - var result = await _handler.Handle(command, CancellationToken.None); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result.IsSuccess, Is.False); - Assert.That(result.Errors, Has.Some.EqualTo("Unexpected error creating account.")); - } - } -} \ No newline at end of file diff --git a/tests/UnitTests/ApplicationCore/Commands/Accounts/CreateAccountValidatorTests.cs b/tests/UnitTests/ApplicationCore/Commands/Accounts/CreateAccountValidatorTests.cs deleted file mode 100644 index 4afc04f..0000000 --- a/tests/UnitTests/ApplicationCore/Commands/Accounts/CreateAccountValidatorTests.cs +++ /dev/null @@ -1,131 +0,0 @@ -using ApplicationCore.Commands.Accounts.CreateAccount; -using ApplicationCore.Domain.Accounts; -using ApplicationCore.Interfaces.Repositories; -using Moq; - -namespace UnitTests.ApplicationCore.Commands.Accounts; - -[TestFixture] -public sealed class CreateAccountValidatorTests -{ - private Mock _accounts = null!; - private CreateAccountValidator _validator = null!; - - [SetUp] - public void SetUp() - { - _accounts = new Mock(); - _validator = new CreateAccountValidator(_accounts.Object); - } - - [Test] - public async Task Validator_passes_for_valid_command() - { - _accounts - .Setup(a => a.GetByUsernameAsync("user1", It.IsAny())) - .ReturnsAsync((AccountModel?)null); - _accounts - .Setup(a => a.GetBySubAsync("sub1", It.IsAny())) - .ReturnsAsync((AccountModel?)null); - _accounts - .Setup(a => a.GetByUsernameOrSubAsync("user1", "sub1", It.IsAny())) - .ReturnsAsync((AccountModel?)null); - - var command = new CreateAccountCommand("user1", "sub1", "http://example.com"); - var result = await _validator.ValidateAsync(command); - - Assert.That(result.IsValid, Is.True); - } - - [Test] - public async Task Validator_fails_if_username_is_invalid() - { - var command = new CreateAccountCommand("user$invalid", "sub1", ""); - var result = await _validator.ValidateAsync(command); - - Assert.Multiple(() => - { - Assert.That(result.IsValid, Is.False); - Assert.That( - result.Errors, - Has.Some.Property("ErrorMessage").Contains("Username contains invalid characters") - ); - }); - } - - [Test] - public async Task Validator_fails_if_username_already_exists() - { - _accounts - .Setup(a => a.GetByUsernameAsync("user1", It.IsAny())) - .ReturnsAsync(new AccountModel() { Username = "test" }); - - var command = new CreateAccountCommand("user1", "sub1", ""); - var result = await _validator.ValidateAsync(command); - - Assert.Multiple(() => - { - Assert.That(result.IsValid, Is.False); - Assert.That( - result.Errors, - Has.Some.Property("ErrorMessage").Contains("Username already exists") - ); - }); - } - - [Test] - public async Task Validator_fails_if_sub_already_exists() - { - _accounts - .Setup(a => a.GetBySubAsync("sub1", It.IsAny())) - .ReturnsAsync(new AccountModel() { Username = "test" }); - - var command = new CreateAccountCommand("user1", "sub1", ""); - var result = await _validator.ValidateAsync(command); - - Assert.Multiple(() => - { - Assert.That(result.IsValid, Is.False); - Assert.That( - result.Errors, - Has.Some.Property("ErrorMessage").Contains("Account already exists") - ); - }); - } - - [Test] - public async Task Validator_fails_if_username_or_sub_exists() - { - _accounts - .Setup(a => a.GetByUsernameOrSubAsync("user1", "sub1", It.IsAny())) - .ReturnsAsync(new AccountModel() { Username = "test" }); - - var command = new CreateAccountCommand("user1", "sub1", ""); - var result = await _validator.ValidateAsync(command); - - Assert.Multiple(() => - { - Assert.That(result.IsValid, Is.False); - Assert.That( - result.Errors, - Has.Some.Property("ErrorMessage").Contains("Username already exists") - ); - }); - } - - [Test] - public async Task Validator_fails_if_imageUrl_is_invalid() - { - var command = new CreateAccountCommand("user1", "sub1", "ftp://invalid.url"); - var result = await _validator.ValidateAsync(command); - - Assert.Multiple(() => - { - Assert.That(result.IsValid, Is.False); - Assert.That( - result.Errors, - Has.Some.Property("ErrorMessage").Contains("ImageUrl must be a valid URL") - ); - }); - } -} \ No newline at end of file diff --git a/tests/UnitTests/ApplicationCore/Commands/Submissions/CreateSubmissionHandlerTests.cs b/tests/UnitTests/ApplicationCore/Commands/Submissions/CreateSubmissionHandlerTests.cs deleted file mode 100644 index 5cc6ff3..0000000 --- a/tests/UnitTests/ApplicationCore/Commands/Submissions/CreateSubmissionHandlerTests.cs +++ /dev/null @@ -1,73 +0,0 @@ -using ApplicationCore.Commands.Submissions.CreateSubmission; -using ApplicationCore.Domain.Submissions; -using ApplicationCore.Interfaces.Messaging; -using ApplicationCore.Interfaces.Repositories; -using FluentValidation; -using Microsoft.Extensions.Logging; -using Moq; - -namespace UnitTests.ApplicationCore.Commands.Submissions; - -[TestFixture] -public sealed class CreateSubmissionHandlerTests -{ - private Mock _mockSubmissionRepository; - private Mock _mockMessagePublisher; - private Mock> _mockValidator; - private Mock> _mockLogger; - - private CreateSubmissionHandler _sut; - - [SetUp] - public void SetUp() - { - _mockSubmissionRepository = new(); - _mockMessagePublisher = new(); - _mockValidator = new(); - _mockLogger = new(); - - _mockValidator - .Setup(v => - v.ValidateAsync(It.IsAny(), It.IsAny()) - ) - .ReturnsAsync(new FluentValidation.Results.ValidationResult()); - - _sut = new CreateSubmissionHandler( - _mockSubmissionRepository.Object, - _mockMessagePublisher.Object, - _mockValidator.Object, - _mockLogger.Object - ); - } - - [Test] - public async Task Handle_creates_submission_successfully() - { - _mockSubmissionRepository - .Setup(a => a.SaveAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(Guid.NewGuid()); - - var command = new CreateSubmissionCommand(1, "code", Guid.NewGuid()); - - var result = await _sut.Handle(command, CancellationToken.None); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result.IsSuccess, Is.True); - Assert.That(result.Value, Is.Not.EqualTo(Guid.Empty)); - - _mockSubmissionRepository.Verify( - a => - a.SaveAsync( - It.Is(s => - s.ProblemSetupId == command.ProblemSetupId - && s.Code == command.Code - && s.CreatedById == command.CreatedById - ), - It.IsAny() - ), - Times.Once - ); - } - } -} \ No newline at end of file diff --git a/tests/UnitTests/ApplicationCore/Commands/Submissions/CreateSubmissionValidatorTests.cs b/tests/UnitTests/ApplicationCore/Commands/Submissions/CreateSubmissionValidatorTests.cs deleted file mode 100644 index 037db33..0000000 --- a/tests/UnitTests/ApplicationCore/Commands/Submissions/CreateSubmissionValidatorTests.cs +++ /dev/null @@ -1,55 +0,0 @@ -using ApplicationCore.Commands.Submissions.CreateSubmission; -using Moq; - -namespace UnitTests.ApplicationCore.Commands.Submissions; - -[TestFixture] -public sealed class CreateSubmissionValidatorTests -{ - private CreateSubmissionValidator _sut; - - [SetUp] - public void SetUp() - { - _sut = new CreateSubmissionValidator(); - } - - [Test] - public void Should_Pass_For_Valid_Command() - { - var command = new CreateSubmissionCommand(1, "print('Hello, World!')", Guid.NewGuid()); - var result = _sut.Validate(command); - Assert.That(result.IsValid, Is.True); - } - - [Test] - public void Code_Should_Fail_If_Empty() - { - var command = new CreateSubmissionCommand(1, "", Guid.NewGuid()); - var result = _sut.Validate(command); - - Assert.That(result.IsValid, Is.False); - } - - [Test] - public void ProblemSetupId_Should_Fail_If_Non_Positive() - { - var command = new CreateSubmissionCommand(0, "print('Hello, World!')", Guid.NewGuid()); - var result = _sut.Validate(command); - Assert.Multiple(() => - { - Assert.That(result.IsValid, Is.False); - }); - } - - [Test] - public void CreatedById_Should_Fail_If_Empty() - { - var command = new CreateSubmissionCommand(1, "print('Hello, World!')", Guid.Empty); - var result = _sut.Validate(command); - Assert.Multiple(() => - { - Assert.That(result.IsValid, Is.False); - }); - } -} \ No newline at end of file diff --git a/tests/UnitTests/ApplicationCore/Commands/Submissions/IncrementSubmissionOutboxesHandlerTests.cs.cs b/tests/UnitTests/ApplicationCore/Commands/Submissions/IncrementSubmissionOutboxesHandlerTests.cs.cs deleted file mode 100644 index a7fcd57..0000000 --- a/tests/UnitTests/ApplicationCore/Commands/Submissions/IncrementSubmissionOutboxesHandlerTests.cs.cs +++ /dev/null @@ -1,92 +0,0 @@ -using ApplicationCore.Commands.Submissions.IncrementSubmissionOutboxes; -using ApplicationCore.Interfaces.Repositories; -using Ardalis.Result; -using FluentValidation; -using FluentValidation.Results; -using Moq; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace UnitTests.ApplicationCore.Commands.Submissions; - -[TestFixture] -internal class IncrementSubmissionOutboxesHandlerTests -{ - private IncrementSubmissionOutboxesHandler _sut; - private Mock _mockSubmissionRepository; - private Mock> _mockValidator; - - [SetUp] - public void SetUp() - { - _mockSubmissionRepository = new(); - - _mockValidator = new(); - _mockValidator - .Setup(v => - v.ValidateAsync( - It.IsAny(), - It.IsAny() - ) - ) - .ReturnsAsync(new ValidationResult()); - - _sut = new IncrementSubmissionOutboxesHandler( - _mockSubmissionRepository.Object, - _mockValidator.Object - ); - } - - [Test] - public async Task Handle_ShouldIncrementOutboxesCountSuccessfully() - { - _mockSubmissionRepository - .Setup(r => - r.IncrementOutboxesCountAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny() - ) - ) - .Returns(Task.CompletedTask); - - var command = new IncrementSubmissionOutboxesCommand( - [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()], - DateTime.UtcNow - ); - var result = await _sut.Handle(command, CancellationToken.None); - - Assert.That(result.IsSuccess, Is.True); - _mockSubmissionRepository.Verify( - r => - r.IncrementOutboxesCountAsync( - command.OutboxIds, - command.Timestamp, - It.IsAny() - ), - Times.Once - ); - } - - [Test] - public async Task Handle_RepositoryError_ShouldReturnErrorResult() - { - _mockSubmissionRepository - .Setup(r => - r.IncrementOutboxesCountAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny() - ) - ) - .ThrowsAsync(new Exception("Database error")); - var command = new IncrementSubmissionOutboxesCommand( - [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()], - DateTime.UtcNow - ); - var result = await _sut.Handle(command, CancellationToken.None); - Assert.That(result.IsError, Is.True); - } -} \ No newline at end of file diff --git a/tests/UnitTests/ApplicationCore/Commands/Submissions/IncrementSubmissionOutboxesValidatorTests.cs b/tests/UnitTests/ApplicationCore/Commands/Submissions/IncrementSubmissionOutboxesValidatorTests.cs deleted file mode 100644 index 279e231..0000000 --- a/tests/UnitTests/ApplicationCore/Commands/Submissions/IncrementSubmissionOutboxesValidatorTests.cs +++ /dev/null @@ -1,69 +0,0 @@ -using ApplicationCore.Commands.Submissions.IncrementSubmissionOutboxes; -using FluentValidation.Results; -using NUnit.Framework; -using System; -using System.Collections.Generic; - -namespace UnitTests.ApplicationCore.Commands.Submissions; - -[TestFixture] -internal class IncrementSubmissionOutboxesValidatorTests -{ - private IncrementSubmissionOutboxesValidator _validator = null!; - - [SetUp] - public void SetUp() - { - _validator = new IncrementSubmissionOutboxesValidator(); - } - - [Test] - public void Validate_WithValidCommand_ShouldBeValid() - { - var command = new IncrementSubmissionOutboxesCommand( - [Guid.NewGuid()], - DateTime.UtcNow.AddSeconds(-1) - ); - - ValidationResult result = _validator.Validate(command); - - Assert.That(result.IsValid, Is.True); - } - - [Test] - public void Validate_WithEmptyOutboxIds_ShouldBeInvalid() - { - var command = new IncrementSubmissionOutboxesCommand([], DateTime.UtcNow.AddSeconds(-1)); - - ValidationResult result = _validator.Validate(command); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result.IsValid, Is.False); - Assert.That( - result.Errors, - Has.Exactly(1).Matches(e => e.PropertyName == "OutboxIds") - ); - } - } - - [Test] - public void Validate_WithFutureTimestamp_ShouldBeInvalid() - { - var command = new IncrementSubmissionOutboxesCommand( - [Guid.NewGuid()], - DateTime.UtcNow.AddMinutes(5) - ); - - ValidationResult result = _validator.Validate(command); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result.IsValid, Is.False); - Assert.That( - result.Errors, - Has.Exactly(1).Matches(e => e.PropertyName == "Timestamp") - ); - } - } -} \ No newline at end of file diff --git a/tests/UnitTests/ApplicationCore/Commands/Submissions/ProcessSubmissionExecutionsHandlerTests.cs b/tests/UnitTests/ApplicationCore/Commands/Submissions/ProcessSubmissionExecutionsHandlerTests.cs deleted file mode 100644 index 6580566..0000000 --- a/tests/UnitTests/ApplicationCore/Commands/Submissions/ProcessSubmissionExecutionsHandlerTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using ApplicationCore.Commands.Submissions.ProcessSubmissionExecutions; -using ApplicationCore.Interfaces.Repositories; -using FluentValidation; -using FluentValidation.Results; -using Moq; - -namespace UnitTests.ApplicationCore.Commands.Submissions; - -[TestFixture] -internal class ProcessSubmissionExecutionsHandlerTests -{ - private Mock _mockSubmissionRepository = null!; - private Mock> _mockValidator = null!; - - private ProcessSubmissionExecutionsHandler _sut = null!; - - [SetUp] - public void SetUp() - { - _mockSubmissionRepository = new Mock(); - _mockValidator = new Mock>(); - - _mockValidator - .Setup(v => - v.ValidateAsync( - It.IsAny(), - It.IsAny() - ) - ) - .ReturnsAsync(new ValidationResult()); - - _sut = new ProcessSubmissionExecutionsHandler( - _mockSubmissionRepository.Object, - _mockValidator.Object - ); - } -} \ No newline at end of file diff --git a/tests/UnitTests/ApplicationCore/Commands/Submissions/ProcessSubmissionExecutionsValidatorTests.cs b/tests/UnitTests/ApplicationCore/Commands/Submissions/ProcessSubmissionExecutionsValidatorTests.cs deleted file mode 100644 index 866483a..0000000 --- a/tests/UnitTests/ApplicationCore/Commands/Submissions/ProcessSubmissionExecutionsValidatorTests.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace UnitTests.ApplicationCore.Commands.Submissions; - -internal class ProcessSubmissionExecutionsValidatorTests -{ -} \ No newline at end of file diff --git a/tests/UnitTests/ApplicationCore/Common/Pagination/PaginatedResultTests.cs b/tests/UnitTests/ApplicationCore/Common/Pagination/PaginatedResultTests.cs deleted file mode 100644 index 175f75e..0000000 --- a/tests/UnitTests/ApplicationCore/Common/Pagination/PaginatedResultTests.cs +++ /dev/null @@ -1,113 +0,0 @@ -using ApplicationCore.Common.Pagination; - -namespace UnitTests.ApplicationCore.Common.Pagination; - -[TestFixture] -public sealed class PaginatedResultTests -{ - [Test] - public void HasPrevious_is_false_on_first_page() - { - var result = new PaginatedResult - { - Results = [1, 2], - Total = 10, - Page = 1, - Size = 2, - }; - - Assert.That(result.HasPrevious, Is.False); - } - - [Test] - public void HasPrevious_is_true_when_page_greater_than_one() - { - var result = new PaginatedResult - { - Results = [3, 4], - Total = 10, - Page = 2, - Size = 2, - }; - - Assert.That(result.HasPrevious, Is.True); - } - - [Test] - public void HasNext_is_true_when_not_on_last_page() - { - var result = new PaginatedResult - { - Results = [1, 2], - Total = 10, - Page = 1, - Size = 2, - }; - - Assert.That(result.HasNext, Is.True); - } - - [Test] - public void HasNext_is_false_on_last_page() - { - var result = new PaginatedResult - { - Results = [9, 10], - Total = 10, - Page = 5, - Size = 2, - }; - - Assert.That(result.HasNext, Is.False); - } - - [Test] - public void Offset_is_calculated_correctly() - { - var result = new PaginatedResult - { - Results = [5, 6], - Total = 10, - Page = 3, - Size = 2, - }; - - Assert.That(result.Offset, Is.EqualTo(4)); - } - - [Test] - public void HasNext_is_false_when_size_is_zero() - { - var result = new PaginatedResult - { - Results = [], - Total = 10, - Page = 1, - Size = 0, - }; - - Assert.Multiple(() => - { - Assert.That(result.HasNext, Is.False); - Assert.That(result.Offset, Is.EqualTo(0)); - }); - } - - [Test] - public void HasNext_is_false_when_total_is_zero() - { - var result = new PaginatedResult - { - Results = [], - Total = 0, - Page = 1, - Size = 10, - }; - - Assert.Multiple(() => - { - Assert.That(result.HasNext, Is.False); - Assert.That(result.HasPrevious, Is.False); - }); - } -} \ No newline at end of file diff --git a/tests/UnitTests/ApplicationCore/Domain/Accounts/AccountModelTests.cs b/tests/UnitTests/ApplicationCore/Domain/Accounts/AccountModelTests.cs deleted file mode 100644 index 254bdf7..0000000 --- a/tests/UnitTests/ApplicationCore/Domain/Accounts/AccountModelTests.cs +++ /dev/null @@ -1,68 +0,0 @@ -using ApplicationCore.Domain.Accounts; - -namespace UnitTests.ApplicationCore.Domain.Accounts; - -[TestFixture] -public sealed class AccountModelTests -{ - [Test] - public void Creating_account_with_username_sets_value() - { - var account = new AccountModel { Username = "user1" }; - - Assert.That(account.Username, Is.EqualTo("user1")); - } - - [Test] - public void Optional_properties_can_be_set() - { - const string sub = "auth0|123"; - const string imageUrl = "https://example.com/avatar.png"; - var createdOn = DateTime.UtcNow; - - var account = new AccountModel - { - Username = "user1", - Sub = sub, - ImageUrl = imageUrl, - CreatedOn = createdOn, - }; - - using (Assert.EnterMultipleScope()) - { - Assert.That(account.Sub, Is.EqualTo(sub)); - Assert.That(account.ImageUrl, Is.EqualTo(imageUrl)); - Assert.That(account.CreatedOn, Is.EqualTo(createdOn)); - } - } - - [Test] - public void Last_modified_fields_default_to_null() - { - var account = new AccountModel { Username = "user1" }; - - using (Assert.EnterMultipleScope()) - { - Assert.That(account.LastModifiedOn, Is.Null); - Assert.That(account.LastModifiedById, Is.Null); - } - } - - [Test] - public void Last_modified_fields_can_be_updated() - { - var modifiedOn = DateTime.UtcNow; - var modifiedBy = Guid.NewGuid(); - - var account = new AccountModel { Username = "user1" }; - - account.LastModifiedOn = modifiedOn; - account.LastModifiedById = modifiedBy; - - using (Assert.EnterMultipleScope()) - { - Assert.That(account.LastModifiedOn, Is.EqualTo(modifiedOn)); - Assert.That(account.LastModifiedById, Is.EqualTo(modifiedBy)); - } - } -} \ No newline at end of file diff --git a/tests/UnitTests/ApplicationCore/Domain/BaseAuditableModelTests.cs b/tests/UnitTests/ApplicationCore/Domain/BaseAuditableModelTests.cs deleted file mode 100644 index 387e20c..0000000 --- a/tests/UnitTests/ApplicationCore/Domain/BaseAuditableModelTests.cs +++ /dev/null @@ -1,77 +0,0 @@ -using ApplicationCore.Domain; -using ApplicationCore.Domain.Accounts; - -namespace UnitTests.ApplicationCore.Domain; - -[TestFixture] -public sealed class BaseAuditableModelTests -{ - private sealed class TestAuditableModel : BaseAuditableModel { } - - [Test] - public void All_properties_are_null_or_default_by_default() - { - var entity = new TestAuditableModel(); - - using (Assert.EnterMultipleScope()) - { - Assert.That(entity.CreatedOn, Is.EqualTo(default(DateTime))); - Assert.That(entity.CreatedById, Is.Null); - Assert.That(entity.CreatedBy, Is.Null); - Assert.That(entity.LastModifiedOn, Is.Null); - Assert.That(entity.LastModifiedById, Is.EqualTo(default(Guid))); - Assert.That(entity.DeletedOn, Is.Null); - } - } - - [Test] - public void Created_properties_can_be_set() - { - var createdOn = DateTime.UtcNow; - var createdById = Guid.NewGuid(); - var account = new AccountModel { Username = "creator" }; - - var entity = new TestAuditableModel - { - CreatedOn = createdOn, - CreatedById = createdById, - CreatedBy = account, - }; - - using (Assert.EnterMultipleScope()) - { - Assert.That(entity.CreatedOn, Is.EqualTo(createdOn)); - Assert.That(entity.CreatedById, Is.EqualTo(createdById)); - Assert.That(entity.CreatedBy, Is.EqualTo(account)); - } - } - - [Test] - public void Last_modified_properties_can_be_set() - { - var modifiedOn = DateTime.UtcNow; - var modifiedById = Guid.NewGuid(); - - var entity = new TestAuditableModel - { - LastModifiedOn = modifiedOn, - LastModifiedById = modifiedById, - }; - - using (Assert.EnterMultipleScope()) - { - Assert.That(entity.LastModifiedOn, Is.EqualTo(modifiedOn)); - Assert.That(entity.LastModifiedById, Is.EqualTo(modifiedById)); - } - } - - [Test] - public void Deleted_on_can_be_set() - { - var deletedOn = DateTime.UtcNow; - - var entity = new TestAuditableModel { DeletedOn = deletedOn }; - - Assert.That(entity.DeletedOn, Is.EqualTo(deletedOn)); - } -} \ No newline at end of file diff --git a/tests/UnitTests/ApplicationCore/Domain/BaseModelTests.cs b/tests/UnitTests/ApplicationCore/Domain/BaseModelTests.cs deleted file mode 100644 index 5721c25..0000000 --- a/tests/UnitTests/ApplicationCore/Domain/BaseModelTests.cs +++ /dev/null @@ -1,31 +0,0 @@ -using ApplicationCore.Domain; - -namespace UnitTests.ApplicationCore.Domain; - -[TestFixture] -public sealed class BaseModelTests -{ - private sealed class TestModel : BaseModel { } - - [Test] - public void Id_can_be_set() - { - var id = Guid.NewGuid(); - var entity = new TestModel { Id = id }; - - Assert.That(entity.Id, Is.EqualTo(id)); - } - - [Test] - public void Id_can_be_updated() - { - var firstId = Guid.NewGuid(); - var secondId = Guid.NewGuid(); - - var entity = new TestModel { Id = firstId }; - - entity.Id = secondId; - - Assert.That(entity.Id, Is.EqualTo(secondId)); - } -} \ No newline at end of file diff --git a/tests/UnitTests/ApplicationCore/Domain/Problems/Languages/LanguageVersionTests.cs b/tests/UnitTests/ApplicationCore/Domain/Problems/Languages/LanguageVersionTests.cs deleted file mode 100644 index d7f6d7e..0000000 --- a/tests/UnitTests/ApplicationCore/Domain/Problems/Languages/LanguageVersionTests.cs +++ /dev/null @@ -1,74 +0,0 @@ -using ApplicationCore.Domain.Problems.Languages; - -namespace UnitTests.ApplicationCore.Domain.Problems.Languages; - -[TestFixture] -public sealed class LanguageVersionTests -{ - [Test] - public void Creating_language_version_with_required_version_sets_value() - { - var language = new ProgrammingLanguage - { - Id = 1, - Name = "C#", - IsArchived = false - }; - - var languageVersion = new LanguageVersion - { - Version = "12", - ProgrammingLanguageId = language.Id, - ProgrammingLanguage = language - }; - - Assert.That(languageVersion.Version, Is.EqualTo("12")); - } - - [Test] - public void Optional_initial_code_can_be_set() - { - var language = new ProgrammingLanguage - { - Id = 1, - Name = "Python", - IsArchived = false - }; - - const string code = "print('hello world')"; - - var languageVersion = new LanguageVersion - { - Version = "3.12", - InitialCode = code, - ProgrammingLanguageId = language.Id, - ProgrammingLanguage = language - }; - - Assert.That(languageVersion.InitialCode, Is.EqualTo(code)); - } - - [Test] - public void Programming_language_relationship_is_set_correctly() - { - var language = new ProgrammingLanguage - { - Id = 5, - Name = "Java", - IsArchived = false - }; - - var languageVersion = new LanguageVersion - { - Version = "21", - ProgrammingLanguageId = 5, - ProgrammingLanguage = language - }; - - using (Assert.EnterMultipleScope()) - { - Assert.That(languageVersion.ProgrammingLanguageId, Is.EqualTo(5)); - Assert.That(languageVersion.ProgrammingLanguage, Is.EqualTo(language)); - } - } -} \ No newline at end of file diff --git a/tests/UnitTests/ApplicationCore/Domain/Problems/Languages/ProgrammingLanguageTests.cs b/tests/UnitTests/ApplicationCore/Domain/Problems/Languages/ProgrammingLanguageTests.cs deleted file mode 100644 index 00e2a23..0000000 --- a/tests/UnitTests/ApplicationCore/Domain/Problems/Languages/ProgrammingLanguageTests.cs +++ /dev/null @@ -1,84 +0,0 @@ -using ApplicationCore.Domain.Problems.Languages; - -namespace UnitTests.ApplicationCore.Domain.Problems.Languages; - -[TestFixture] -public sealed class ProgrammingLanguageTests -{ - [Test] - public void Creating_programming_language_with_required_properties_sets_values() - { - var language = new ProgrammingLanguage { Name = "C#", IsArchived = false }; - - using (Assert.EnterMultipleScope()) - { - Assert.That(language.Name, Is.EqualTo("C#")); - Assert.That(language.IsArchived, Is.False); - } - } - - [Test] - public void Versions_is_initialized_empty_by_default() - { - var language = new ProgrammingLanguage { Name = "Python", IsArchived = false }; - - using (Assert.EnterMultipleScope()) - { - Assert.That(language.Versions, Is.Not.Null); - Assert.That(language.Versions, Is.Empty); - } - } - - [Test] - public void Versions_can_be_assigned() - { - var version = new LanguageVersion - { - Id = 1, - Version = "3.12", - ProgrammingLanguageId = 1, - }; - - var language = new ProgrammingLanguage - { - Name = "Python", - IsArchived = false, - Versions = [version], - }; - - using (Assert.EnterMultipleScope()) - { - Assert.That(language.Versions, Has.Exactly(1).Items); - Assert.That(language.Versions.Single(), Is.EqualTo(version)); - } - } - - [Test] - public void Auditable_fields_can_be_set() - { - var createdOn = DateTime.UtcNow; - var modifiedOn = DateTime.UtcNow.AddMinutes(1); - var deletedOn = DateTime.UtcNow.AddMinutes(2); - var userId = Guid.NewGuid(); - - var language = new ProgrammingLanguage - { - Name = "Java", - IsArchived = false, - CreatedOn = createdOn, - CreatedById = userId, - LastModifiedOn = modifiedOn, - LastModifiedById = userId, - DeletedOn = deletedOn, - }; - - using (Assert.EnterMultipleScope()) - { - Assert.That(language.CreatedOn, Is.EqualTo(createdOn)); - Assert.That(language.CreatedById, Is.EqualTo(userId)); - Assert.That(language.LastModifiedOn, Is.EqualTo(modifiedOn)); - Assert.That(language.LastModifiedById, Is.EqualTo(userId)); - Assert.That(language.DeletedOn, Is.EqualTo(deletedOn)); - } - } -} \ No newline at end of file diff --git a/tests/UnitTests/ApplicationCore/Domain/Problems/ProblemModelTests.cs b/tests/UnitTests/ApplicationCore/Domain/Problems/ProblemModelTests.cs deleted file mode 100644 index 98c945d..0000000 --- a/tests/UnitTests/ApplicationCore/Domain/Problems/ProblemModelTests.cs +++ /dev/null @@ -1,292 +0,0 @@ -using ApplicationCore.Domain.Problems; -using ApplicationCore.Domain.Problems.Languages; -using ApplicationCore.Domain.Problems.ProblemSetups; - -namespace UnitTests.ApplicationCore.Domain.Problems; - -[TestFixture] -public sealed class ProblemModelTests -{ - private static ProblemModel CreateProblem(IEnumerable? setups = null) - { - return new ProblemModel - { - Title = "Sample Problem", - Slug = "sample-problem", - Question = "Solve the problem", - Tags = [], - Difficulty = 2, - Status = ProblemStatus.Draft, - Version = 1, - ProblemSetups = setups?.ToList() ?? [], - }; - } - - [Test] - public void GetAvailableLanguages_returns_empty_when_no_problem_setups_exist() - { - var problem = CreateProblem(); - - var result = problem.GetAvailableLanguages(); - - Assert.That(result, Is.Empty); - } - - [Test] - public void GetAvailableLanguages_returns_single_language_with_multiple_versions() - { - var language = new ProgrammingLanguage - { - Id = 1, - Name = "C#", - IsArchived = false, - }; - - var v10 = new LanguageVersion - { - Id = 10, - Version = "10", - ProgrammingLanguage = language, - }; - - var v11 = new LanguageVersion - { - Id = 11, - Version = "11", - ProgrammingLanguage = language, - }; - - var setups = new[] - { - new ProblemSetupModel - { - Id = 1, - ProblemId = Guid.NewGuid(), - LanguageVersionId = v10.Id, - InitialCode = "", - LanguageVersion = v10, - }, - new ProblemSetupModel - { - Id = 2, - ProblemId = Guid.NewGuid(), - LanguageVersionId = v10.Id, - InitialCode = "", - LanguageVersion = v11, - }, - }; - - var problem = CreateProblem(setups); - - var languages = problem.GetAvailableLanguages().ToList(); - - IEnumerable expected = ["10", "11"]; - using (Assert.EnterMultipleScope()) - { - Assert.That(languages, Has.Count.EqualTo(1)); - Assert.That(languages[0].Id, Is.EqualTo(1)); - Assert.That(languages[0].Name, Is.EqualTo("C#")); - Assert.That(languages[0].IsArchived, Is.False); - Assert.That(languages[0].Versions.Select(v => v.Version), Is.EqualTo(expected)); - } - } - - [Test] - public void GetAvailableLanguages_deduplicates_language_versions_by_id() - { - var language = new ProgrammingLanguage - { - Id = 2, - Name = "Python", - IsArchived = false, - }; - - var version = new LanguageVersion - { - Id = 1, - Version = "3.11", - ProgrammingLanguage = language, - }; - - var setups = new[] - { - new ProblemSetupModel - { - Id = 1, - ProblemId = Guid.NewGuid(), - LanguageVersionId = version.Id, - InitialCode = "", - LanguageVersion = version, - }, - new ProblemSetupModel - { - Id = 2, - ProblemId = Guid.NewGuid(), - LanguageVersionId = version.Id, - InitialCode = "", - LanguageVersion = version, - }, - }; - - var problem = CreateProblem(setups); - - var languages = problem.GetAvailableLanguages().ToList(); - - Assert.That(languages.Single().Versions, Has.Exactly(1).Items); - } - - [Test] - public void GetAvailableLanguages_ignores_setups_without_language_version_or_language() - { - var version = new LanguageVersion - { - Id = 1, - Version = "1.0", - ProgrammingLanguage = null!, - }; - - var setups = new[] - { - new ProblemSetupModel - { - Id = 1, - ProblemId = Guid.NewGuid(), - LanguageVersionId = version.Id, - InitialCode = "", - LanguageVersion = null, - }, - new ProblemSetupModel - { - Id = 2, - ProblemId = Guid.NewGuid(), - InitialCode = "", - LanguageVersion = version, - LanguageVersionId = version.Id, - }, - }; - - var problem = CreateProblem(setups); - - var result = problem.GetAvailableLanguages(); - - Assert.That(result, Is.Empty); - } - - [Test] - public void GetAvailableLanguages_orders_versions_by_version_value() - { - var language = new ProgrammingLanguage - { - Id = 3, - Name = "Java", - IsArchived = false, - }; - - var v21 = new LanguageVersion - { - Id = 2, - Version = "21", - ProgrammingLanguage = language, - }; - - var v17 = new LanguageVersion - { - Id = 1, - Version = "17", - ProgrammingLanguage = language, - }; - - var setups = new[] - { - new ProblemSetupModel - { - Id = 1, - ProblemId = Guid.NewGuid(), - InitialCode = "", - LanguageVersion = v21, - LanguageVersionId = v21.Id, - }, - new ProblemSetupModel - { - Id = 2, - ProblemId = Guid.NewGuid(), - InitialCode = "", - LanguageVersion = v17, - LanguageVersionId = v21.Id, - }, - }; - - var problem = CreateProblem(setups); - - var versions = problem - .GetAvailableLanguages() - .Single() - .Versions.Select(v => v.Version) - .ToList(); - - IEnumerable expected = ["17", "21"]; - - Assert.That(versions, Is.EqualTo(expected)); - } - - [Test] - public void GetAvailableLanguages_deduplicates_languages_by_id() - { - var lang1 = new ProgrammingLanguage - { - Id = 1, - Name = "C#", - IsArchived = false, - }; - var lang2 = new ProgrammingLanguage - { - Id = 1, - Name = "C#", - IsArchived = true, - }; - - var v1 = new LanguageVersion - { - Id = 1, - Version = "10", - ProgrammingLanguage = lang1, - }; - var v2 = new LanguageVersion - { - Id = 2, - Version = "11", - ProgrammingLanguage = lang2, - }; - - var setups = new[] - { - new ProblemSetupModel - { - Id = 1, - ProblemId = Guid.NewGuid(), - InitialCode = "", - LanguageVersion = v1, - LanguageVersionId = v1.Id, - }, - new ProblemSetupModel - { - Id = 2, - ProblemId = Guid.NewGuid(), - InitialCode = "", - LanguageVersion = v2, - LanguageVersionId = v2.Id, - }, - }; - - var problem = CreateProblem(setups); - - var result = problem.GetAvailableLanguages().Single(); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result.Id, Is.EqualTo(1)); - Assert.That(result.IsArchived, Is.False); - Assert.That(result.Versions, Has.Exactly(1).Items); - } - } -} \ No newline at end of file diff --git a/tests/UnitTests/ApplicationCore/Queries/Accounts/GetAccountBySubHandlerTests.cs b/tests/UnitTests/ApplicationCore/Queries/Accounts/GetAccountBySubHandlerTests.cs deleted file mode 100644 index 6f4e856..0000000 --- a/tests/UnitTests/ApplicationCore/Queries/Accounts/GetAccountBySubHandlerTests.cs +++ /dev/null @@ -1,99 +0,0 @@ -using ApplicationCore.Domain.Accounts; -using ApplicationCore.Dtos.Accounts; -using ApplicationCore.Interfaces.Repositories; -using ApplicationCore.Queries.Accounts.GetAccountBySub; -using Ardalis.Result; -using Moq; - -namespace UnitTests.ApplicationCore.Queries.Accounts; - -[TestFixture] -public sealed class GetAccountBySubHandlerTests -{ - private Mock _repository = null!; - private GetAccountBySubHandler _handler = null!; - - [SetUp] - public void SetUp() - { - _repository = new Mock(); - _handler = new GetAccountBySubHandler(_repository.Object); - } - - [Test] - public async Task Handle_returns_invalid_when_sub_is_empty() - { - var query = new GetAccountBySubQuery(string.Empty); - - var result = await _handler.Handle(query, CancellationToken.None); - - Assert.Multiple(() => - { - Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); - Assert.That(result.ValidationErrors.Count(), Is.EqualTo(1)); - }); - } - - [Test] - public async Task Handle_returns_not_found_when_account_does_not_exist() - { - _repository - .Setup(r => r.GetBySubAsync("sub1", It.IsAny())) - .ReturnsAsync((AccountModel?)null); - - var query = new GetAccountBySubQuery("sub1"); - - var result = await _handler.Handle(query, CancellationToken.None); - - Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); - } - - [Test] - public async Task Handle_returns_account_dto_when_account_exists() - { - var account = new AccountModel() - { - Id = Guid.NewGuid(), - Username = "user1", - Sub = "sub1", - ImageUrl = "http://image.url", - CreatedOn = DateTime.UtcNow, - }; - - _repository - .Setup(r => r.GetBySubAsync("sub1", It.IsAny())) - .ReturnsAsync(account); - - var query = new GetAccountBySubQuery("sub1"); - - var result = await _handler.Handle(query, CancellationToken.None); - - Assert.Multiple(() => - { - Assert.That(result.IsSuccess, Is.True); - Assert.That(result.Value, Is.Not.Null); - Assert.That(result.Value.Id, Is.EqualTo(account.Id)); - Assert.That(result.Value.Username, Is.EqualTo(account.Username)); - Assert.That(result.Value.ImageUrl, Is.EqualTo(account.ImageUrl)); - Assert.That(result.Value.CreatedOn, Is.EqualTo(account.CreatedOn)); - }); - } - - [Test] - public async Task Handle_returns_error_when_exception_is_thrown() - { - _repository - .Setup(r => r.GetBySubAsync("sub1", It.IsAny())) - .ThrowsAsync(new Exception("db error")); - - var query = new GetAccountBySubQuery("sub1"); - - var result = await _handler.Handle(query, CancellationToken.None); - - Assert.Multiple(() => - { - Assert.That(result.Status, Is.EqualTo(ResultStatus.Error)); - Assert.That(result.Errors, Has.Some.EqualTo("db error")); - }); - } -} \ No newline at end of file diff --git a/tests/UnitTests/ApplicationCore/Queries/Accounts/GetProfileAggregateHandlerTests.cs b/tests/UnitTests/ApplicationCore/Queries/Accounts/GetProfileAggregateHandlerTests.cs deleted file mode 100644 index f95585b..0000000 --- a/tests/UnitTests/ApplicationCore/Queries/Accounts/GetProfileAggregateHandlerTests.cs +++ /dev/null @@ -1,96 +0,0 @@ -using ApplicationCore.Domain.Accounts; -using ApplicationCore.Interfaces.Repositories; -using ApplicationCore.Queries.Accounts.GetProfileAggregate; -using Ardalis.Result; -using Moq; - -namespace UnitTests.ApplicationCore.Queries.Accounts; - -[TestFixture] -public sealed class GetProfileAggregateHandlerTests -{ - private Mock _repository = null!; - private GetProfileAggregateHandler _handler = null!; - - [SetUp] - public void SetUp() - { - _repository = new Mock(); - _handler = new GetProfileAggregateHandler(_repository.Object); - } - - [Test] - public async Task Handle_returns_invalid_when_username_is_empty() - { - var query = new GetProfileAggregateQuery(""); - - var result = await _handler.Handle(query, CancellationToken.None); - - Assert.Multiple(() => - { - Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); - Assert.That(result.ValidationErrors, Has.Exactly(1).Items); - Assert.That(result.ValidationErrors.First().Identifier, Is.EqualTo("Username")); - }); - } - - [Test] - public async Task Handle_returns_not_found_when_account_does_not_exist() - { - _repository - .Setup(r => r.GetByUsernameAsync("user1", It.IsAny())) - .ReturnsAsync((AccountModel?)null); - - var query = new GetProfileAggregateQuery("user1"); - - var result = await _handler.Handle(query, CancellationToken.None); - - Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); - } - - [Test] - public async Task Handle_returns_profile_aggregate_when_account_exists() - { - var account = new AccountModel - { - Id = Guid.NewGuid(), - Username = "user1", - ImageUrl = "http://image.url", - CreatedOn = DateTime.UtcNow, - }; - - _repository - .Setup(r => r.GetByUsernameAsync("user1", It.IsAny())) - .ReturnsAsync(account); - - var query = new GetProfileAggregateQuery("user1"); - - var result = await _handler.Handle(query, CancellationToken.None); - - Assert.Multiple(() => - { - Assert.That(result.IsSuccess, Is.True); - Assert.That(result.Value.Profile.Username, Is.EqualTo("user1")); - Assert.That(result.Value.Profile.ImageUrl, Is.EqualTo("http://image.url")); - Assert.That(result.Value.Profile.Id, Is.EqualTo(account.Id)); - }); - } - - [Test] - public async Task Handle_returns_error_when_exception_is_thrown() - { - _repository - .Setup(r => r.GetByUsernameAsync("user1", It.IsAny())) - .ThrowsAsync(new Exception("db failure")); - - var query = new GetProfileAggregateQuery("user1"); - - var result = await _handler.Handle(query, CancellationToken.None); - - Assert.Multiple(() => - { - Assert.That(result.Status, Is.EqualTo(ResultStatus.Error)); - Assert.That(result.Errors, Has.Some.EqualTo("db failure")); - }); - } -} \ No newline at end of file diff --git a/tests/UnitTests/ApplicationCore/Queries/Accounts/GetProfileSettingsHandlerTests.cs b/tests/UnitTests/ApplicationCore/Queries/Accounts/GetProfileSettingsHandlerTests.cs deleted file mode 100644 index 6f54ec2..0000000 --- a/tests/UnitTests/ApplicationCore/Queries/Accounts/GetProfileSettingsHandlerTests.cs +++ /dev/null @@ -1,74 +0,0 @@ -using ApplicationCore.Domain.Accounts; -using ApplicationCore.Interfaces.Repositories; -using ApplicationCore.Queries.Accounts.GetProfileSettings; -using Ardalis.Result; -using Moq; - -namespace UnitTests.ApplicationCore.Queries.Accounts; - -[TestFixture] -public sealed class GetProfileSettingsHandlerTests -{ - private Mock _repository = null!; - private GetProfileSettingsHandler _handler = null!; - - [SetUp] - public void SetUp() - { - _repository = new Mock(); - _handler = new GetProfileSettingsHandler(_repository.Object); - } - - [Test] - public async Task Handle_returns_not_found_when_account_does_not_exist() - { - _repository - .Setup(r => r.GetBySubAsync("sub1", It.IsAny())) - .ReturnsAsync((AccountModel?)null); - - var query = new GetProfileSettingsQuery("sub1"); - - var result = await _handler.Handle(query, CancellationToken.None); - - Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); - } - - [Test] - public async Task Handle_returns_profile_settings_when_account_exists() - { - var account = new AccountModel { Username = "user1", About = "my bio" }; - - _repository - .Setup(r => r.GetBySubAsync("sub1", It.IsAny())) - .ReturnsAsync(account); - - var query = new GetProfileSettingsQuery("sub1"); - - var result = await _handler.Handle(query, CancellationToken.None); - - Assert.Multiple(() => - { - Assert.That(result.IsSuccess, Is.True); - Assert.That(result.Value.Username, Is.EqualTo("user1")); - Assert.That(result.Value.Bio, Is.EqualTo("my bio")); - }); - } - - [Test] - public async Task Handle_returns_error_when_exception_is_thrown() - { - _repository - .Setup(r => r.GetBySubAsync("sub1", It.IsAny())) - .ThrowsAsync(new Exception("db failure")); - - var query = new GetProfileSettingsQuery("sub1"); - - var result = await _handler.Handle(query, CancellationToken.None); - - Assert.Multiple(() => - { - Assert.That(result.Status, Is.EqualTo(ResultStatus.Error)); - Assert.That(result.Errors, Has.Some.EqualTo("db failure")); - }); - } -} \ No newline at end of file diff --git a/tests/UnitTests/ApplicationCore/Queries/Problems/GetProblemBySlugHandlerTests.cs b/tests/UnitTests/ApplicationCore/Queries/Problems/GetProblemBySlugHandlerTests.cs deleted file mode 100644 index ebd139d..0000000 --- a/tests/UnitTests/ApplicationCore/Queries/Problems/GetProblemBySlugHandlerTests.cs +++ /dev/null @@ -1,129 +0,0 @@ -using ApplicationCore.Domain.Problems; -using ApplicationCore.Domain.Problems.Languages; -using ApplicationCore.Domain.Problems.ProblemSetups; -using ApplicationCore.Interfaces.Repositories; -using ApplicationCore.Queries.Problems.GetProblemBySlug; -using Ardalis.Result; -using Mapster; -using Moq; - -namespace UnitTests.ApplicationCore.Queries.Problems; - -[TestFixture] -public sealed class GetProblemBySlugHandlerTests -{ - private Mock _problemRepository = null!; - private GetProblemBySlugHandler _handler = null!; - - [OneTimeSetUp] - public void OneTimeSetUp() - { - TypeAdapterConfig.GlobalSettings.Scan(typeof(ProblemModel).Assembly); - } - - [SetUp] - public void SetUp() - { - _problemRepository = new Mock(); - _handler = new GetProblemBySlugHandler(_problemRepository.Object); - } - - [Test] - public async Task Handle_returns_problem_dto_when_problem_exists() - { - var language = new ProgrammingLanguage - { - Id = 1, - Name = "C#", - IsArchived = false, - }; - - var version = new LanguageVersion - { - Id = 1, - Version = "10", - ProgrammingLanguage = language, - }; - - var problem = new ProblemModel() - { - Id = Guid.NewGuid(), - Title = "Two Sum", - Slug = "two-sum", - Question = "Find two numbers", - Tags = [], - Difficulty = 1, - Version = 1, - ProblemSetups = - [ - new ProblemSetupModel - { - Id = 1, - ProblemId = Guid.NewGuid(), - InitialCode = "", - LanguageVersion = version, - LanguageVersionId = version.Id, - }, - ], - }; - - _problemRepository - .Setup(r => r.GetProblemBySlugAsync("two-sum", It.IsAny())) - .ReturnsAsync(problem); - - var result = await _handler.Handle( - new GetProblemBySlugQuery("two-sum"), - CancellationToken.None - ); - - Assert.That(result.IsSuccess, Is.True); - - var dto = result.Value; - - Assert.Multiple(() => - { - Assert.That(dto.Title, Is.EqualTo("Two Sum")); - Assert.That(dto.Slug, Is.EqualTo("two-sum")); - Assert.That(dto.AvailableLanguages.Count(), Is.EqualTo(1)); - Assert.That(dto.AvailableLanguages.Single().Name, Is.EqualTo("C#")); - Assert.That( - dto.AvailableLanguages.Single().Versions.Single().Version, - Is.EqualTo("10") - ); - }); - } - - [Test] - public async Task Handle_returns_not_found_when_problem_missing() - { - _problemRepository - .Setup(r => r.GetProblemBySlugAsync("missing", It.IsAny())) - .ReturnsAsync((ProblemModel?)null); - - var result = await _handler.Handle( - new GetProblemBySlugQuery("missing"), - CancellationToken.None - ); - - Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); - } - - [Test] - public async Task Handle_returns_error_when_exception_thrown() - { - _problemRepository - .Setup(r => r.GetProblemBySlugAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new Exception("db error")); - - var result = await _handler.Handle( - new GetProblemBySlugQuery("two-sum"), - CancellationToken.None - ); - - Assert.Multiple(() => - { - Assert.That(result.IsSuccess, Is.False); - Assert.That(result.Errors, Has.Some.EqualTo("db error")); - }); - } -} \ No newline at end of file diff --git a/tests/UnitTests/ApplicationCore/Queries/Problems/GetProblemsPageableHandlerTests.cs b/tests/UnitTests/ApplicationCore/Queries/Problems/GetProblemsPageableHandlerTests.cs deleted file mode 100644 index 0d1b94b..0000000 --- a/tests/UnitTests/ApplicationCore/Queries/Problems/GetProblemsPageableHandlerTests.cs +++ /dev/null @@ -1,140 +0,0 @@ -using ApplicationCore.Common.Pagination; -using ApplicationCore.Domain.Problems; -using ApplicationCore.Interfaces.Repositories; -using ApplicationCore.Queries.Problems.GetProblemsPageable; -using Ardalis.Result; -using Moq; - -namespace UnitTests.ApplicationCore.Queries.Problems; - -[TestFixture] -public sealed class GetProblemsPageableHandlerTests -{ - private Mock _repo = null!; - private GetProblemsPageableHandler _handler = null!; - - [SetUp] - public void SetUp() - { - _repo = new Mock(); - _handler = new GetProblemsPageableHandler(_repo.Object); - } - - [Test] - public async Task Handle_returns_success_with_mapped_paginated_result() - { - var pagination = new PaginationRequest() { Page = 1, Size = 10 }; - - var problems = new PaginatedResult - { - Results = - [ - new ProblemModel - { - Id = Guid.NewGuid(), - Title = "Two Sum", - Slug = "two-sum", - Difficulty = 1, - Version = 1, - Tags = - [ - new TagModel() { Id = 1, Value = "arrays" }, - new TagModel() { Id = 1, Value = "hashmap" }, - ], - Question = "", - }, - new ProblemModel() - { - Id = Guid.NewGuid(), - Title = "Reverse String", - Slug = "reverse-string", - Difficulty = 1, - Version = 1, - Tags = [], - Question = "", - }, - ], - Total = 2, - Page = 1, - Size = 10, - }; - - _repo - .Setup(r => r.GetProblemsAsync(pagination, It.IsAny())) - .ReturnsAsync(problems); - - var result = await _handler.Handle( - new GetProblemsPageableQuery(pagination), - CancellationToken.None - ); - - Assert.That(result.Status, Is.EqualTo(ResultStatus.Ok)); - - Assert.Multiple(() => - { - Assert.That(result.Value.Total, Is.EqualTo(2)); - Assert.That(result.Value.Page, Is.EqualTo(1)); - Assert.That(result.Value.Size, Is.EqualTo(10)); - Assert.That(result.Value.Results.Count, Is.EqualTo(2)); - - var first = result.Value.Results[0]; - Assert.That(first.Title, Is.EqualTo("Two Sum")); - Assert.That(first.Slug, Is.EqualTo("two-sum")); - Assert.That(first.Tags, Is.EquivalentTo(["arrays", "hashmap"])); - - var second = result.Value.Results[1]; - Assert.That(second.Tags, Is.Empty); - }); - } - - [Test] - public async Task Handle_returns_success_with_empty_results_when_repository_returns_none() - { - var pagination = new PaginationRequest() { Page = 1, Size = 10 }; - - var problems = new PaginatedResult - { - Results = [], - Total = 0, - Page = 1, - Size = 10, - }; - - _repo - .Setup(r => r.GetProblemsAsync(pagination, It.IsAny())) - .ReturnsAsync(problems); - - var result = await _handler.Handle( - new GetProblemsPageableQuery(pagination), - CancellationToken.None - ); - - Assert.Multiple(() => - { - Assert.That(result.Status, Is.EqualTo(ResultStatus.Ok)); - Assert.That(result.Value.Results, Is.Empty); - Assert.That(result.Value.Total, Is.EqualTo(0)); - }); - } - - [Test] - public async Task Handle_returns_error_when_repository_throws() - { - var pagination = new PaginationRequest() { Page = 1, Size = 10 }; - - _repo - .Setup(r => r.GetProblemsAsync(pagination, It.IsAny())) - .ThrowsAsync(new Exception("db failure")); - - var result = await _handler.Handle( - new GetProblemsPageableQuery(pagination), - CancellationToken.None - ); - - Assert.Multiple(() => - { - Assert.That(result.IsSuccess, Is.False); - Assert.That(result.Errors, Has.Some.EqualTo("db failure")); - }); - } -} \ No newline at end of file diff --git a/tests/UnitTests/ApplicationCore/Services/SubmissionAppServiceTests.cs b/tests/UnitTests/ApplicationCore/Services/SubmissionAppServiceTests.cs deleted file mode 100644 index ae6a21e..0000000 --- a/tests/UnitTests/ApplicationCore/Services/SubmissionAppServiceTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using ApplicationCore.Commands.Submissions.CreateSubmission; -using ApplicationCore.Services; -using MediatR; -using Moq; - -namespace UnitTests.ApplicationCore.Services; - -[TestFixture] -public sealed class SubmissionAppServiceTests -{ - private Mock _mockMediator; - private SubmissionAppService _sut; - - [SetUp] - public void SetUp() - { - _mockMediator = new(); - _sut = new SubmissionAppService(_mockMediator.Object); - } - - [Test] - public void CreateAsync_sends_CreateSubmissionCommand_via_mediator() - { - int problemSetupId = 1; - string code = "sample code"; - var createdById = Guid.NewGuid(); - var cancellationToken = CancellationToken.None; - var expectedResult = Guid.NewGuid(); - - _mockMediator - .Setup(m => m.Send(It.IsAny(), cancellationToken)) - .ReturnsAsync(expectedResult); - - var result = _sut.CreateAsync(problemSetupId, code, createdById, cancellationToken).Result; - - Assert.That(result.Value, Is.EqualTo(expectedResult)); - - _mockMediator.Verify( - m => - m.Send( - It.Is(cmd => - cmd.ProblemSetupId == problemSetupId - && cmd.Code == code - && cmd.CreatedById == createdById - ), - cancellationToken - ), - Times.Once - ); - } -} \ No newline at end of file diff --git a/tests/UnitTests/PublicApi/Controllers/ProblemControllerTests.cs b/tests/UnitTests/PublicApi/Controllers/ProblemControllerTests.cs deleted file mode 100644 index d0d4f31..0000000 --- a/tests/UnitTests/PublicApi/Controllers/ProblemControllerTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -using ApplicationCore.Dtos.Problems; -using ApplicationCore.Interfaces.Services; -using Ardalis.Result; -using Microsoft.AspNetCore.Mvc; -using Moq; -using PublicApi.Controllers; - -namespace UnitTests.PublicApi.Controllers; - -[TestFixture] -public sealed class ProblemControllerTests -{ - private Mock _problemAppService = null!; - private Mock _accountAppService = null!; - private ProblemController _sut = null!; - - [SetUp] - public void SetUp() - { - _problemAppService = new Mock(); - _accountAppService = new Mock(); - _sut = new ProblemController(_problemAppService.Object, _accountAppService.Object); - } - - [Test] - public async Task GetBySlugAsync_returns_ok_when_problem_exists() - { - var problem = new ProblemDto - { - Id = Guid.NewGuid(), - Title = "Two Sum", - Slug = "two-sum", - Question = "Find two numbers", - Difficulty = 1, - Version = 1, - Tags = [], - AvailableLanguages = [], - }; - - _problemAppService - .Setup(service => service.GetProblemBySlugAsync("two-sum", It.IsAny())) - .ReturnsAsync(Result.Success(problem)); - - var result = await _sut.GetBySlugAsync("two-sum", CancellationToken.None); - - Assert.That(result, Is.InstanceOf()); - Assert.That(((OkObjectResult)result).Value, Is.EqualTo(problem)); - } - - [Test] - public async Task GetBySlugAsync_returns_bad_request_when_slug_is_missing() - { - var result = await _sut.GetBySlugAsync("", CancellationToken.None); - - Assert.That(result, Is.InstanceOf()); - Assert.That(((BadRequestObjectResult)result).Value, Is.EqualTo("Slug is required.")); - _problemAppService.Verify( - service => service.GetProblemBySlugAsync(It.IsAny(), It.IsAny()), - Times.Never - ); - } -} diff --git a/tests/UnitTests/UnitTests.csproj b/tests/UnitTests/UnitTests.csproj deleted file mode 100644 index 5ce73ef..0000000 --- a/tests/UnitTests/UnitTests.csproj +++ /dev/null @@ -1,48 +0,0 @@ - - - net10.0 - latest - enable - enable - false - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - - - -