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