Merge pull request #141 from aaronpo97/refactor/add-infrastructure-project-dir

Refactor/add infrastructure project dir
This commit is contained in:
Aaron Po
2026-02-12 18:26:17 -05:00
committed by GitHub
55 changed files with 1664 additions and 1410 deletions

3
.gitignore vendored
View File

@@ -483,9 +483,6 @@ FodyWeavers.xsd
*.feature.cs
database
.env
.env.dev
.env.test

View File

@@ -2,46 +2,6 @@
A social platform for craft beer enthusiasts to discover breweries, share reviews, and connect with fellow beer lovers.
## Table of Contents
- [Project Status](#project-status)
- [Repository Structure](#repository-structure)
- [Technology Stack](#technology-stack)
- [Getting Started](#getting-started)
- [Prerequisites](#prerequisites)
- [Quick Start (Development Environment)](#quick-start-development-environment)
- [Manual Setup (Without Docker)](#manual-setup-without-docker)
- [Environment Variables](#environment-variables)
- [Overview](#overview)
- [Backend Variables (.NET API)](#backend-variables-net-api)
- [Frontend Variables (Next.js)](#frontend-variables-nextjs)
- [Docker Variables](#docker-variables)
- [External Services](#external-services)
- [Generating Secrets](#generating-secrets)
- [Environment File Structure](#environment-file-structure)
- [Variable Reference Table](#variable-reference-table)
- [Testing](#testing)
- [Database Schema](#database-schema)
- [Authentication & Security](#authentication--security)
- [Architecture Patterns](#architecture-patterns)
- [Docker & Containerization](#docker--containerization)
- [Container Architecture](#container-architecture)
- [Docker Compose Environments](#docker-compose-environments)
- [Service Dependencies](#service-dependencies)
- [Health Checks](#health-checks)
- [Volumes](#volumes)
- [Networks](#networks)
- [Environment Variables](#environment-variables)
- [Container Lifecycle](#container-lifecycle)
- [Docker Tips & Troubleshooting](#docker-tips--troubleshooting)
- [Roadmap](#roadmap)
- [License](#license)
- [Contact & Support](#contact--support)
---
## Project Status
This project is in active development, transitioning from a full-stack Next.js application to a **multi-project monorepo** with:
@@ -49,10 +9,10 @@ This project is in active development, transitioning from a full-stack Next.js a
- **Frontend**: Next.js with TypeScript
- **Architecture**: SQL-first approach using stored procedures
**Current State** (February 2025):
**Current State** (February 2026):
- Core authentication and user management APIs functional
- Database schema and migrations established
- Repository and service layers implemented
- Domain, Infrastructure, Repository, and Service layers implemented
- Frontend integration with .NET API in progress
- Migrating remaining features from Next.js serverless functions
@@ -70,13 +30,21 @@ src/Core/
├── Database/
│ ├── Database.Migrations/ # DbUp migrations (embedded SQL scripts)
│ └── Database.Seed/ # Database seeding for development/testing
├── Repository/
── Repository.Core/ # Data access layer (stored procedure-based)
└── Repository.Tests/ # Unit tests for repositories
├── Domain/
── Domain.csproj # Domain entities and models
└── Entities/ # Core domain entities (UserAccount, UserCredential, etc.)
├── Infrastructure/
│ ├── Infrastructure.Jwt/ # JWT token generation and validation
│ ├── Infrastructure.PasswordHashing/ # Argon2id password hashing
│ └── Infrastructure.Repository/
│ ├── Infrastructure.Repository/ # Data access layer (stored procedure-based)
│ └── Infrastructure.Repository.Tests/ # Unit tests for repositories
└── Service/
└── Service.Core/ # Business logic layer
Website/ # Next.js frontend application
misc/
└── raw-data/ # Sample data files (breweries, beers)
```
### Key Components
@@ -86,6 +54,7 @@ Website/ # Next.js frontend application
- Controllers: `AuthController`, `UserController`
- Configured with Swagger UI for API exploration
- Health checks and structured logging
- Middleware for error handling and request processing
**Database Layer**
- SQL Server with stored procedures for all data operations
@@ -93,7 +62,19 @@ Website/ # Next.js frontend application
- Comprehensive schema including users, breweries, beers, locations, and social features
- Seeders for development data (users, locations across US/Canada/Mexico)
**Repository Layer** (`Repository.Core`)
**Domain Layer** (`Domain`)
- Core business entities and models
- Entities: `UserAccount`, `UserCredential`, `UserVerification`
- Shared domain logic and value objects
- No external dependencies - pure domain model
**Infrastructure Layer**
- **Infrastructure.Jwt**: JWT token generation, validation, and configuration
- **Infrastructure.PasswordHashing**: Argon2id password hashing with configurable parameters
- **Infrastructure.Password**: Password utilities and validation
- **Infrastructure.Repository**: Repository pattern infrastructure and base classes
**Repository Layer** (`Infrastructure.Repository`)
- Abstraction over SQL Server using ADO.NET
- `ISqlConnectionFactory` for connection management
- Repositories: `AuthRepository`, `UserAccountRepository`
@@ -101,9 +82,9 @@ Website/ # Next.js frontend application
**Service Layer** (`Service.Core`)
- Business logic and orchestration
- Services: `AuthService`, `UserService`, `JwtService`
- Password hashing with Argon2id
- JWT token generation
- Services: `AuthService`, `UserService`
- Integration with infrastructure components
- Transaction management and business rule enforcement
**Frontend** (`Website`)
- Next.js 14+ with TypeScript
@@ -334,7 +315,7 @@ Provide a complete SQL Server connection string:
DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
```
The connection factory checks for `DB_CONNECTION_STRING` first, then falls back to building from components. See [DefaultSqlConnectionFactory.cs](src/Core/Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs).
The connection factory checks for `DB_CONNECTION_STRING` first, then falls back to building from components. See [DefaultSqlConnectionFactory.cs](src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs).
#### JWT Authentication
@@ -578,7 +559,7 @@ docker compose -f docker-compose.test.yaml up --abort-on-container-exit
This runs:
- **API.Specs** - BDD integration tests
- **Repository.Tests** - Unit tests for data access
- **Infrastructure.Repository.Tests** - Unit tests for data access
Test results are output to `./test-results/`.
@@ -590,10 +571,10 @@ cd src/Core
dotnet test API/API.Specs/API.Specs.csproj
```
**Unit Tests (Repository.Tests)**
**Unit Tests (Infrastructure.Repository.Tests)**
```bash
cd src/Core
dotnet test Repository/Repository.Tests/Repository.Tests.csproj
dotnet test Infrastructure/Infrastructure.Repository/Infrastructure.Repository.Tests/Repository.Tests.csproj
```
### Test Features

View File

@@ -101,7 +101,7 @@ services:
condition: service_completed_successfully
build:
context: ./src/Core
dockerfile: Repository/Repository.Tests/Dockerfile
dockerfile: Infrastructure/Infrastructure.Repository.Tests/Dockerfile
args:
BUILD_CONFIGURATION: Release
environment:

View File

@@ -19,7 +19,8 @@
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.csproj" />
<ProjectReference Include="..\..\Repository\Repository.Core\Repository.Core.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Service\Service.Core\Service.Core.csproj" />
</ItemGroup>

View File

@@ -1,9 +1,9 @@
using API.Core.Contracts.Auth;
using API.Core.Contracts.Common;
using Domain.Core.Entities;
using Domain.Entities;
using Infrastructure.Jwt;
using Microsoft.AspNetCore.Mvc;
using Service.Core.Auth;
using Service.Core.Jwt;
namespace API.Core.Controllers
{

View File

@@ -1,4 +1,4 @@
using Domain.Core.Entities;
using Domain.Entities;
using Microsoft.AspNetCore.Mvc;
using Service.Core.User;

View File

@@ -9,7 +9,10 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["Repository/Repository.Core/Repository.Core.csproj", "Repository/Repository.Core/"]
COPY ["Domain/Domain.csproj", "Domain/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Service/Service.Core/Service.Core.csproj", "Service/Service.Core/"]
RUN dotnet restore "API/API.Core/API.Core.csproj"
COPY . .

View File

@@ -0,0 +1,47 @@
using System.Net;
using System.Text.Json;
using API.Core.Contracts.Common;
using FluentValidation;
namespace API.Core.Middleware;
public class ValidationExceptionHandlingMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch (ValidationException ex)
{
await HandleValidationExceptionAsync(context, ex);
}
}
private static Task HandleValidationExceptionAsync(HttpContext context, ValidationException exception)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
var errors = exception.Errors
.Select(e => e.ErrorMessage)
.ToList();
var message = errors.Count == 1
? errors[0]
: "Validation failed. " + string.Join(" ", errors);
var response = new ResponseBody
{
Message = message
};
var jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
return context.Response.WriteAsync(JsonSerializer.Serialize(response, jsonOptions));
}
}

View File

@@ -1,21 +1,45 @@
using FluentValidation;
using Repository.Core.Repositories.Auth;
using Repository.Core.Repositories.UserAccount;
using Repository.Core.Sql;
using FluentValidation.AspNetCore;
using Infrastructure.Jwt;
using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth;
using Infrastructure.Repository.Sql;
using Infrastructure.Repository.UserAccount;
using Microsoft.AspNetCore.Mvc;
using Service.Core.Auth;
using Service.Core.Jwt;
using Service.Core.Password;
using Service.Core.User;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var errors = context.ModelState.Values
.SelectMany(v => v.Errors)
.Select(e => e.ErrorMessage)
.ToList();
var message = errors.Count == 1
? errors[0]
: string.Join(" ", errors);
var response = new
{
message
};
return new BadRequestObjectResult(response);
};
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddOpenApi();
// Add FluentValidation
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddFluentValidationAutoValidation();
// Add health checks
builder.Services.AddHealthChecks();
@@ -35,7 +59,7 @@ builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IAuthRepository, AuthRepository>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IJwtService, JwtService>();
builder.Services.AddScoped<IPasswordService, PasswordService>();
builder.Services.AddScoped<IPasswordInfra, Argon2Infrastructure>();
var app = builder.Build();
@@ -59,3 +83,6 @@ lifetime.ApplicationStopping.Register(() =>
});
app.Run();
// Make Program class accessible to test projects
public partial class Program { }

View File

@@ -3,7 +3,10 @@ ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
COPY ["Repository/Repository.Core/Repository.Core.csproj", "Repository/Repository.Core/"]
COPY ["Domain/Domain.csproj", "Domain/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Service/Service.Core/Service.Core.csproj", "Service/Service.Core/"]
RUN dotnet restore "API/API.Specs/API.Specs.csproj"
COPY . .

View File

@@ -52,7 +52,6 @@ Feature: User Registration
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | newuser@example.com | 1990-01-01 | weakpass |
Then the response has HTTP status 400
And the response JSON should have "message" equal "Password does not meet complexity requirements."
Scenario: Cannot register a user younger than 19 years of age (regulatory requirement)
Given the API is running

View File

@@ -10,9 +10,11 @@
<Folder Name="/Domain/">
<Project Path="Domain/Domain.csproj" />
</Folder>
<Folder Name="/Repository/">
<Project Path="Repository/Repository.Core/Repository.Core.csproj" />
<Project Path="Repository/Repository.Tests/Repository.Tests.csproj" />
<Folder Name="/Infrastructure/">
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
<Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
<Project Path="Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj" />
<Project Path="Infrastructure\Infrastructure.Repository.Tests\Infrastructure.Repository.Tests.csproj" />
</Folder>
<Folder Name="/Service/">
<Project Path="Service/Service.Core/Service.Core.csproj" />

View File

@@ -19,6 +19,6 @@
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.csproj" />
<ProjectReference Include="..\..\Repository\Repository.Core\Repository.Core.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,9 +1,8 @@
using Microsoft.Data.SqlClient;
namespace DBSeed
{
namespace Database.Seed;
internal interface ISeeder
{
Task SeedAsync(SqlConnection connection);
}
}

View File

@@ -1,8 +1,7 @@
using System.Data;
using Microsoft.Data.SqlClient;
namespace DBSeed
{
namespace Database.Seed;
internal class LocationSeeder : ISeeder
{
@@ -325,4 +324,3 @@ namespace DBSeed
await command.ExecuteNonQueryAsync();
}
}
}

View File

@@ -1,7 +1,7 @@
using DBSeed;
using Microsoft.Data.SqlClient;
using Microsoft.Data.SqlClient;
using DbUp;
using System.Reflection;
using Database.Seed;
string BuildConnectionString()
{
@@ -33,6 +33,7 @@ string BuildConnectionString()
return builder.ConnectionString;
}
try
{
var connectionString = BuildConnectionString();
@@ -72,7 +73,6 @@ try
using (connection)
{
ISeeder[] seeders =
[
new LocationSeeder(),

View File

@@ -1,14 +1,12 @@
using System.Data;
using System.Security.Cryptography;
using System.Text;
using Domain.Core.Entities;
using Repository.Core.Repositories;
using idunno.Password;
using Konscious.Security.Cryptography;
using Microsoft.Data.SqlClient;
namespace DBSeed
{
namespace Database.Seed;
internal class UserSeeder : ISeeder
{
private static readonly IReadOnlyList<(
@@ -173,8 +171,6 @@ namespace DBSeed
createdCredentials++;
// add user verification
if (await HasUserVerificationAsync(connection, id)) continue;
@@ -212,7 +208,6 @@ namespace DBSeed
return (Guid)result!;
}
private static string GeneratePasswordHash(string pwd)
@@ -270,4 +265,3 @@ namespace DBSeed
return baseDate.AddDays(-offsetDays);
}
}
}

View File

@@ -1,4 +1,4 @@
namespace Domain.Core.Entities;
namespace Domain.Entities;
public class UserAccount
{

View File

@@ -1,4 +1,4 @@
namespace Domain.Core.Entities;
namespace Domain.Entities;
public class UserCredential
{

View File

@@ -1,4 +1,4 @@
namespace Domain.Core.Entities;
namespace Domain.Entities;
public class UserVerification
{

View File

@@ -1,4 +1,4 @@
namespace Service.Core.Jwt;
namespace Infrastructure.Jwt;
public interface IJwtService
{

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Infrastructure.Jwt</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference
Include="Microsoft.IdentityModel.JsonWebTokens"
Version="8.2.1"
/>
<PackageReference
Include="System.IdentityModel.Tokens.Jwt"
Version="8.2.1"
/>
</ItemGroup>
</Project>

View File

@@ -4,22 +4,28 @@ using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
namespace Service.Core.Jwt;
namespace Infrastructure.Jwt;
public class JwtService : IJwtService
{
private readonly string? _secret = Environment.GetEnvironmentVariable("JWT_SECRET");
private readonly string? _secret = Environment.GetEnvironmentVariable(
"JWT_SECRET"
);
public string GenerateJwt(Guid userId, string username, DateTime expiry)
{
var handler = new JsonWebTokenHandler();
var key = Encoding.UTF8.GetBytes(_secret ?? throw new InvalidOperationException("secret not set"));
var key = Encoding.UTF8.GetBytes(
_secret ?? throw new InvalidOperationException("secret not set")
);
// Base claims (always present)
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
var tokenDescriptor = new SecurityTokenDescriptor
@@ -28,7 +34,8 @@ public class JwtService : IJwtService
Expires = expiry,
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(key),
SecurityAlgorithms.HmacSha256)
SecurityAlgorithms.HmacSha256
),
};
return handler.CreateToken(tokenDescriptor);

View File

@@ -2,9 +2,9 @@ using System.Security.Cryptography;
using System.Text;
using Konscious.Security.Cryptography;
namespace Service.Core.Password;
namespace Infrastructure.PasswordHashing;
public class PasswordService : IPasswordService
public class Argon2Infrastructure : IPasswordInfra
{
private const int SaltSize = 16; // 128-bit
private const int HashSize = 32; // 256-bit
@@ -19,7 +19,7 @@ public class PasswordService : IPasswordService
Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
MemorySize = ArgonMemoryKb,
Iterations = ArgonIterations
Iterations = ArgonIterations,
};
var hash = argon2.GetBytes(HashSize);
@@ -30,8 +30,12 @@ public class PasswordService : IPasswordService
{
try
{
var parts = stored.Split(':', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2) return false;
var parts = stored.Split(
':',
StringSplitOptions.RemoveEmptyEntries
);
if (parts.Length != 2)
return false;
var salt = Convert.FromBase64String(parts[0]);
var expected = Convert.FromBase64String(parts[1]);
@@ -41,7 +45,7 @@ public class PasswordService : IPasswordService
Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
MemorySize = ArgonMemoryKb,
Iterations = ArgonIterations
Iterations = ArgonIterations,
};
var actual = argon2.GetBytes(expected.Length);

View File

@@ -1,6 +1,6 @@
namespace Service.Core.Password;
namespace Infrastructure.PasswordHashing;
public interface IPasswordService
public interface IPasswordInfra
{
public string Hash(string password);
public bool Verify(string password, string stored);

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Infrastructure.PasswordHashing</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference
Include="Konscious.Security.Cryptography.Argon2"
Version="1.3.1"
/>
</ItemGroup>
</Project>

View File

@@ -1,15 +1,15 @@
using Apps72.Dev.Data.DbMocker;
using Repository.Core.Repositories.Auth;
using FluentAssertions;
using Repository.Tests.Database;
using System.Data;
using Apps72.Dev.Data.DbMocker;
using FluentAssertions;
using Infrastructure.Repository.Auth;
using Repository.Tests.Database;
namespace Repository.Tests.Auth;
public class AuthRepositoryTest
{
private static AuthRepository CreateRepo(MockDbConnection conn)
=> new(new TestConnectionFactory(conn));
private static AuthRepository CreateRepo(MockDbConnection conn) =>
new(new TestConnectionFactory(conn));
[Fact]
public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount()
@@ -17,10 +17,12 @@ public class AuthRepositoryTest
var expectedUserId = Guid.NewGuid();
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "USP_RegisterUser")
.ReturnsTable(MockTable.WithColumns(("UserAccountId", typeof(Guid)))
.AddRow(expectedUserId));
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser")
.ReturnsTable(
MockTable
.WithColumns(("UserAccountId", typeof(Guid)))
.AddRow(expectedUserId)
);
var repo = CreateRepo(conn);
var result = await repo.RegisterUserAsync(
@@ -47,9 +49,10 @@ public class AuthRepositoryTest
var userId = Guid.NewGuid();
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(MockTable.WithColumns(
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(
MockTable
.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
@@ -59,7 +62,8 @@ public class AuthRepositoryTest
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
).AddRow(
)
.AddRow(
userId,
"emailuser",
"Email",
@@ -69,7 +73,8 @@ public class AuthRepositoryTest
null,
new DateTime(1990, 5, 15),
null
));
)
);
var repo = CreateRepo(conn);
var result = await repo.GetUserByEmailAsync("emailuser@example.com");
@@ -87,8 +92,7 @@ public class AuthRepositoryTest
{
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn);
@@ -103,9 +107,12 @@ public class AuthRepositoryTest
var userId = Guid.NewGuid();
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
.ReturnsTable(MockTable.WithColumns(
conn.Mocks.When(cmd =>
cmd.CommandText == "usp_GetUserAccountByUsername"
)
.ReturnsTable(
MockTable
.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
@@ -115,7 +122,8 @@ public class AuthRepositoryTest
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
).AddRow(
)
.AddRow(
userId,
"usernameuser",
"Username",
@@ -125,7 +133,8 @@ public class AuthRepositoryTest
null,
new DateTime(1985, 8, 20),
null
));
)
);
var repo = CreateRepo(conn);
var result = await repo.GetUserByUsernameAsync("usernameuser");
@@ -141,8 +150,9 @@ public class AuthRepositoryTest
{
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
conn.Mocks.When(cmd =>
cmd.CommandText == "usp_GetUserAccountByUsername"
)
.ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn);
@@ -158,21 +168,26 @@ public class AuthRepositoryTest
var credentialId = Guid.NewGuid();
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
.ReturnsTable(MockTable.WithColumns(
conn.Mocks.When(cmd =>
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
)
.ReturnsTable(
MockTable
.WithColumns(
("UserCredentialId", typeof(Guid)),
("UserAccountId", typeof(Guid)),
("Hash", typeof(string)),
("CreatedAt", typeof(DateTime)),
("Timer", typeof(byte[]))
).AddRow(
)
.AddRow(
credentialId,
userId,
"hashed_password_value",
DateTime.UtcNow,
null
));
)
);
var repo = CreateRepo(conn);
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
@@ -189,8 +204,9 @@ public class AuthRepositoryTest
var userId = Guid.NewGuid();
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
conn.Mocks.When(cmd =>
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
)
.ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn);
@@ -206,14 +222,14 @@ public class AuthRepositoryTest
var newPasswordHash = "new_hashed_password";
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "USP_RotateUserCredential")
conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential")
.ReturnsScalar(1);
var repo = CreateRepo(conn);
// Should not throw
var act = async () => await repo.RotateCredentialAsync(userId, newPasswordHash);
var act = async () =>
await repo.RotateCredentialAsync(userId, newPasswordHash);
await act.Should().NotThrowAsync();
}
}

View File

@@ -1,10 +1,11 @@
using System.Data.Common;
using Repository.Core.Sql;
using Infrastructure.Repository.Sql;
namespace Repository.Tests.Database;
internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
{
private readonly DbConnection _conn = conn;
public DbConnection CreateConnection() => _conn;
}

View File

@@ -0,0 +1,15 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Domain/Domain.csproj", "Domain/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["Infrastructure/Infrastructure.Repository.Tests/Repository.Tests.csproj", "Infrastructure/Infrastructure.Repository.Tests/"]
RUN dotnet restore "Infrastructure/Infrastructure.Repository.Tests/Repository.Tests.csproj"
COPY . .
WORKDIR "/src/Infrastructure/Infrastructure.Repository.Tests"
RUN dotnet build "./Repository.Tests.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS final
RUN mkdir -p /app/test-results
WORKDIR /src/Infrastructure/Infrastructure.Repository.Tests
ENTRYPOINT ["dotnet", "test", "./Repository.Tests.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/repository-tests.trx"]

View File

@@ -4,7 +4,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Repository.Tests</RootNamespace>
<RootNamespace>Infrastructure.Repository.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
@@ -15,9 +15,18 @@
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="FluentAssertions" Version="6.9.0" />
<PackageReference Include="DbMocker" Version="1.26.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" />
<PackageReference
Include="Microsoft.Extensions.Configuration"
Version="9.0.0"
/>
<PackageReference
Include="Microsoft.Extensions.Configuration.Abstractions"
Version="9.0.0"
/>
<PackageReference
Include="Microsoft.Extensions.Configuration.Binder"
Version="9.0.0"
/>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
</ItemGroup>
@@ -26,6 +35,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Repository.Core\Repository.Core.csproj" />
<ProjectReference Include="..\Infrastructure.Repository\Infrastructure.Repository.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,180 @@
using Apps72.Dev.Data.DbMocker;
using FluentAssertions;
using Infrastructure.Repository.UserAccount;
using Repository.Tests.Database;
namespace Repository.Tests.UserAccount;
public class UserAccountRepositoryTest
{
private static UserAccountRepository CreateRepo(MockDbConnection conn) =>
new(new TestConnectionFactory(conn));
[Fact]
public async Task GetByIdAsync_ReturnsRow_Mapped()
{
var conn = new MockDbConnection();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById")
.ReturnsTable(
MockTable
.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
)
.AddRow(
Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
"yerb",
"Aaron",
"Po",
"aaronpo@example.com",
new DateTime(2020, 1, 1),
null,
new DateTime(1990, 1, 1),
null
)
);
var repo = CreateRepo(conn);
var result = await repo.GetByIdAsync(
Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
);
result.Should().NotBeNull();
result!.Username.Should().Be("yerb");
result.Email.Should().Be("aaronpo@example.com");
}
[Fact]
public async Task GetAllAsync_ReturnsMultipleRows()
{
var conn = new MockDbConnection();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetAllUserAccounts")
.ReturnsTable(
MockTable
.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
)
.AddRow(
Guid.NewGuid(),
"a",
"A",
"A",
"a@example.com",
DateTime.UtcNow,
null,
DateTime.UtcNow.Date,
null
)
.AddRow(
Guid.NewGuid(),
"b",
"B",
"B",
"b@example.com",
DateTime.UtcNow,
null,
DateTime.UtcNow.Date,
null
)
);
var repo = CreateRepo(conn);
var results = (await repo.GetAllAsync(null, null)).ToList();
results.Should().HaveCount(2);
results
.Select(r => r.Username)
.Should()
.BeEquivalentTo(new[] { "a", "b" });
}
[Fact]
public async Task GetByUsername_ReturnsRow()
{
var conn = new MockDbConnection();
conn.Mocks.When(cmd =>
cmd.CommandText == "usp_GetUserAccountByUsername"
)
.ReturnsTable(
MockTable
.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
)
.AddRow(
Guid.NewGuid(),
"lookupuser",
"L",
"U",
"lookup@example.com",
DateTime.UtcNow,
null,
DateTime.UtcNow.Date,
null
)
);
var repo = CreateRepo(conn);
var result = await repo.GetByUsernameAsync("lookupuser");
result.Should().NotBeNull();
result!.Email.Should().Be("lookup@example.com");
}
[Fact]
public async Task GetByEmail_ReturnsRow()
{
var conn = new MockDbConnection();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(
MockTable
.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
)
.AddRow(
Guid.NewGuid(),
"byemail",
"B",
"E",
"byemail@example.com",
DateTime.UtcNow,
null,
DateTime.UtcNow.Date,
null
)
);
var repo = CreateRepo(conn);
var result = await repo.GetByEmailAsync("byemail@example.com");
result.Should().NotBeNull();
result!.Username.Should().Be("byemail");
}
}

View File

@@ -0,0 +1,195 @@
using System.Data;
using System.Data.Common;
using Domain.Entities;
using Infrastructure.Repository.Sql;
namespace Infrastructure.Repository.Auth;
public class AuthRepository(ISqlConnectionFactory connectionFactory)
: Repository<Domain.Entities.UserAccount>(connectionFactory),
IAuthRepository
{
public async Task<Domain.Entities.UserAccount> RegisterUserAsync(
string username,
string firstName,
string lastName,
string email,
DateTime dateOfBirth,
string passwordHash
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_RegisterUser";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
AddParameter(command, "@FirstName", firstName);
AddParameter(command, "@LastName", lastName);
AddParameter(command, "@Email", email);
AddParameter(command, "@DateOfBirth", dateOfBirth);
AddParameter(command, "@Hash", passwordHash);
var result = await command.ExecuteScalarAsync();
var userAccountId = result != null ? (Guid)result : Guid.Empty;
return new Domain.Entities.UserAccount
{
UserAccountId = userAccountId,
Username = username,
FirstName = firstName,
LastName = lastName,
Email = email,
DateOfBirth = dateOfBirth,
CreatedAt = DateTime.UtcNow,
};
}
public async Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(
string email
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByEmail";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Email", email);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<Domain.Entities.UserAccount?> GetUserByUsernameAsync(
string username
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByUsername";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(
Guid userAccountId
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_GetActiveUserCredentialByUserAccountId";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync()
? MapToCredentialEntity(reader)
: null;
}
public async Task RotateCredentialAsync(
Guid userAccountId,
string newPasswordHash
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_RotateUserCredential";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId_", userAccountId);
AddParameter(command, "@Hash", newPasswordHash);
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Maps a data reader row to a UserAccount entity.
/// </summary>
protected override Domain.Entities.UserAccount MapToEntity(
DbDataReader reader
)
{
return new Domain.Entities.UserAccount
{
UserAccountId = reader.GetGuid(
reader.GetOrdinal("UserAccountId")
),
Username = reader.GetString(reader.GetOrdinal("Username")),
FirstName = reader.GetString(reader.GetOrdinal("FirstName")),
LastName = reader.GetString(reader.GetOrdinal("LastName")),
Email = reader.GetString(reader.GetOrdinal("Email")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
UpdatedAt = reader.IsDBNull(reader.GetOrdinal("UpdatedAt"))
? null
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt")),
DateOfBirth = reader.GetDateTime(
reader.GetOrdinal("DateOfBirth")
),
Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
? null
: (byte[])reader["Timer"],
};
}
/// <summary>
/// Maps a data reader row to a UserCredential entity.
/// </summary>
private static UserCredential MapToCredentialEntity(DbDataReader reader)
{
var entity = new UserCredential
{
UserCredentialId = reader.GetGuid(
reader.GetOrdinal("UserCredentialId")
),
UserAccountId = reader.GetGuid(
reader.GetOrdinal("UserAccountId")
),
Hash = reader.GetString(reader.GetOrdinal("Hash")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
};
// Optional columns
var hasTimer =
reader
.GetSchemaTable()
?.Rows.Cast<System.Data.DataRow>()
.Any(r =>
string.Equals(
r["ColumnName"]?.ToString(),
"Timer",
StringComparison.OrdinalIgnoreCase
)
) ?? false;
if (hasTimer)
{
entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
? null
: (byte[])reader["Timer"];
}
return entity;
}
/// <summary>
/// Helper method to add a parameter to a database command.
/// </summary>
private static void AddParameter(
DbCommand command,
string name,
object? value
)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
}

View File

@@ -0,0 +1,67 @@
using Domain.Entities;
namespace Infrastructure.Repository.Auth;
/// <summary>
/// Repository for authentication-related database operations including user registration and credential management.
/// </summary>
public interface IAuthRepository
{
/// <summary>
/// Registers a new user with account details and initial credential.
/// Uses stored procedure: USP_RegisterUser
/// </summary>
/// <param name="username">Unique username for the user</param>
/// <param name="firstName">User's first name</param>
/// <param name="lastName">User's last name</param>
/// <param name="email">User's email address</param>
/// <param name="dateOfBirth">User's date of birth</param>
/// <param name="passwordHash">Hashed password</param>
/// <returns>The newly created UserAccount with generated ID</returns>
Task<Domain.Entities.UserAccount> RegisterUserAsync(
string username,
string firstName,
string lastName,
string email,
DateTime dateOfBirth,
string passwordHash
);
/// <summary>
/// Retrieves a user account by email address (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByEmail
/// </summary>
/// <param name="email">Email address to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(
string email
);
/// <summary>
/// Retrieves a user account by username (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByUsername
/// </summary>
/// <param name="username">Username to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Entities.UserAccount?> GetUserByUsernameAsync(
string username
);
/// <summary>
/// Retrieves the active (non-revoked) credential for a user account.
/// Uses stored procedure: USP_GetActiveUserCredentialByUserAccountId
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>Active UserCredential if found, null otherwise</returns>
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(
Guid userAccountId
);
/// <summary>
/// Rotates a user's credential by invalidating all existing credentials and creating a new one.
/// Uses stored procedure: USP_RotateUserCredential
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <param name="newPasswordHash">New hashed password</param>
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
}

View File

@@ -3,7 +3,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Repository.Core</RootNamespace>
<RootNamespace>Infrastructure.Repository</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
@@ -12,7 +12,10 @@
Version="160.1000.6"
/>
<PackageReference Include="System.Data.SqlClient" Version="4.9.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference
Include="Microsoft.Extensions.Configuration.Abstractions"
Version="8.0.0"
/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.csproj" />

View File

@@ -0,0 +1,17 @@
using System.Data.Common;
using Infrastructure.Repository.Sql;
namespace Infrastructure.Repository;
public abstract class Repository<T>(ISqlConnectionFactory connectionFactory)
where T : class
{
protected async Task<DbConnection> CreateConnection()
{
var connection = connectionFactory.CreateConnection();
await connection.OpenAsync();
return connection;
}
protected abstract T MapToEntity(DbDataReader reader);
}

View File

@@ -0,0 +1,49 @@
using System.Data.Common;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
namespace Infrastructure.Repository.Sql;
public class DefaultSqlConnectionFactory(IConfiguration configuration)
: ISqlConnectionFactory
{
private readonly string _connectionString = GetConnectionString(
configuration
);
private static string GetConnectionString(IConfiguration configuration)
{
// Check for full connection string first
var fullConnectionString = Environment.GetEnvironmentVariable(
"DB_CONNECTION_STRING"
);
if (!string.IsNullOrEmpty(fullConnectionString))
{
return fullConnectionString;
}
// Try to build from individual environment variables (preferred method for Docker)
try
{
return SqlConnectionStringHelper.BuildConnectionString();
}
catch (InvalidOperationException)
{
// Fall back to configuration-based connection string if env vars are not set
var connString = configuration.GetConnectionString("Default");
if (!string.IsNullOrEmpty(connString))
{
return connString;
}
throw new InvalidOperationException(
"Database connection string not configured. Set DB_CONNECTION_STRING or DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD env vars or ConnectionStrings:Default."
);
}
}
public DbConnection CreateConnection()
{
return new SqlConnection(_connectionString);
}
}

View File

@@ -0,0 +1,8 @@
using System.Data.Common;
namespace Infrastructure.Repository.Sql;
public interface ISqlConnectionFactory
{
DbConnection CreateConnection();
}

View File

@@ -0,0 +1,61 @@
using Microsoft.Data.SqlClient;
namespace Infrastructure.Repository.Sql;
public static class SqlConnectionStringHelper
{
/// <summary>
/// Builds a SQL Server connection string from environment variables.
/// Expects DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD, and DB_TRUST_SERVER_CERTIFICATE.
/// </summary>
/// <param name="databaseName">Optional override for the database name. If null, uses DB_NAME env var.</param>
/// <returns>A properly formatted SQL Server connection string.</returns>
public static string BuildConnectionString(string? databaseName = null)
{
var server =
Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException(
"DB_SERVER environment variable is not set"
);
var dbName =
databaseName
?? Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException(
"DB_NAME environment variable is not set"
);
var user =
Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException(
"DB_USER environment variable is not set"
);
var password =
Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException(
"DB_PASSWORD environment variable is not set"
);
var builder = new SqlConnectionStringBuilder
{
DataSource = server,
InitialCatalog = dbName,
UserID = user,
Password = password,
TrustServerCertificate = true,
Encrypt = true,
};
return builder.ConnectionString;
}
/// <summary>
/// Builds a connection string to the master database using environment variables.
/// </summary>
/// <returns>A connection string for the master database.</returns>
public static string BuildMasterConnectionString()
{
return BuildConnectionString("master");
}
}

View File

@@ -0,0 +1,16 @@
namespace Infrastructure.Repository.UserAccount;
public interface IUserAccountRepository
{
Task<Domain.Entities.UserAccount?> GetByIdAsync(Guid id);
Task<IEnumerable<Domain.Entities.UserAccount>> GetAllAsync(
int? limit,
int? offset
);
Task UpdateAsync(Domain.Entities.UserAccount userAccount);
Task DeleteAsync(Guid id);
Task<Domain.Entities.UserAccount?> GetByUsernameAsync(
string username
);
Task<Domain.Entities.UserAccount?> GetByEmailAsync(string email);
}

View File

@@ -0,0 +1,149 @@
using System.Data;
using System.Data.Common;
using Infrastructure.Repository.Sql;
namespace Infrastructure.Repository.UserAccount;
public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
: Repository<Domain.Entities.UserAccount>(connectionFactory),
IUserAccountRepository
{
public async Task<Domain.Entities.UserAccount?> GetByIdAsync(
Guid id
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountById";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", id);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<
IEnumerable<Domain.Entities.UserAccount>
> GetAllAsync(int? limit, int? offset)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetAllUserAccounts";
command.CommandType = CommandType.StoredProcedure;
if (limit.HasValue)
AddParameter(command, "@Limit", limit.Value);
if (offset.HasValue)
AddParameter(command, "@Offset", offset.Value);
await using var reader = await command.ExecuteReaderAsync();
var users = new List<Domain.Entities.UserAccount>();
while (await reader.ReadAsync())
{
users.Add(MapToEntity(reader));
}
return users;
}
public async Task UpdateAsync(
Domain.Entities.UserAccount userAccount
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_UpdateUserAccount";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccount.UserAccountId);
AddParameter(command, "@Username", userAccount.Username);
AddParameter(command, "@FirstName", userAccount.FirstName);
AddParameter(command, "@LastName", userAccount.LastName);
AddParameter(command, "@Email", userAccount.Email);
AddParameter(command, "@DateOfBirth", userAccount.DateOfBirth);
await command.ExecuteNonQueryAsync();
}
public async Task DeleteAsync(Guid id)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_DeleteUserAccount";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", id);
await command.ExecuteNonQueryAsync();
}
public async Task<Domain.Entities.UserAccount?> GetByUsernameAsync(
string username
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByUsername";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<Domain.Entities.UserAccount?> GetByEmailAsync(
string email
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByEmail";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Email", email);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
protected override Domain.Entities.UserAccount MapToEntity(
DbDataReader reader
)
{
return new Domain.Entities.UserAccount
{
UserAccountId = reader.GetGuid(
reader.GetOrdinal("UserAccountId")
),
Username = reader.GetString(reader.GetOrdinal("Username")),
FirstName = reader.GetString(reader.GetOrdinal("FirstName")),
LastName = reader.GetString(reader.GetOrdinal("LastName")),
Email = reader.GetString(reader.GetOrdinal("Email")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
UpdatedAt = reader.IsDBNull(reader.GetOrdinal("UpdatedAt"))
? null
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt")),
DateOfBirth = reader.GetDateTime(
reader.GetOrdinal("DateOfBirth")
),
Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
? null
: (byte[])reader["Timer"],
};
}
private static void AddParameter(
DbCommand command,
string name,
object? value
)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
}

View File

@@ -1,168 +0,0 @@
using System.Data;
using System.Data.Common;
using Domain.Core.Entities;
using Repository.Core.Sql;
namespace Repository.Core.Repositories.Auth
{
public class AuthRepository : Repository<Domain.Core.Entities.UserAccount>, IAuthRepository
{
public AuthRepository(ISqlConnectionFactory connectionFactory)
: base(connectionFactory)
{
}
public async Task<Domain.Core.Entities.UserAccount> RegisterUserAsync(
string username,
string firstName,
string lastName,
string email,
DateTime dateOfBirth,
string passwordHash)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_RegisterUser";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
AddParameter(command, "@FirstName", firstName);
AddParameter(command, "@LastName", lastName);
AddParameter(command, "@Email", email);
AddParameter(command, "@DateOfBirth", dateOfBirth);
AddParameter(command, "@Hash", passwordHash);
var result = await command.ExecuteScalarAsync();
var userAccountId = result != null ? (Guid)result : Guid.Empty;
return new Domain.Core.Entities.UserAccount
{
UserAccountId = userAccountId,
Username = username,
FirstName = firstName,
LastName = lastName,
Email = email,
DateOfBirth = dateOfBirth,
CreatedAt = DateTime.UtcNow
};
}
public async Task<Domain.Core.Entities.UserAccount?> GetUserByEmailAsync(string email)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByEmail";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Email", email);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<Domain.Core.Entities.UserAccount?> GetUserByUsernameAsync(string username)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByUsername";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_GetActiveUserCredentialByUserAccountId";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null;
}
public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_RotateUserCredential";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId_", userAccountId);
AddParameter(command, "@Hash", newPasswordHash);
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Maps a data reader row to a UserAccount entity.
/// </summary>
protected override Domain.Core.Entities.UserAccount MapToEntity(DbDataReader reader)
{
return new Domain.Core.Entities.UserAccount
{
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
Username = reader.GetString(reader.GetOrdinal("Username")),
FirstName = reader.GetString(reader.GetOrdinal("FirstName")),
LastName = reader.GetString(reader.GetOrdinal("LastName")),
Email = reader.GetString(reader.GetOrdinal("Email")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
UpdatedAt = reader.IsDBNull(reader.GetOrdinal("UpdatedAt"))
? null
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt")),
DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")),
Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
? null
: (byte[])reader["Timer"]
};
}
/// <summary>
/// Maps a data reader row to a UserCredential entity.
/// </summary>
private static UserCredential MapToCredentialEntity(DbDataReader reader)
{
var entity = new UserCredential
{
UserCredentialId = reader.GetGuid(reader.GetOrdinal("UserCredentialId")),
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
Hash = reader.GetString(reader.GetOrdinal("Hash")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt"))
};
// Optional columns
var hasTimer = reader.GetSchemaTable()?.Rows
.Cast<System.Data.DataRow>()
.Any(r => string.Equals(r["ColumnName"]?.ToString(), "Timer",
StringComparison.OrdinalIgnoreCase)) ??
false;
if (hasTimer)
{
entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) ? null : (byte[])reader["Timer"];
}
return entity;
}
/// <summary>
/// Helper method to add a parameter to a database command.
/// </summary>
private static void AddParameter(DbCommand command, string name, object? value)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
}
}

View File

@@ -1,61 +0,0 @@
using Domain.Core.Entities;
namespace Repository.Core.Repositories.Auth
{
/// <summary>
/// Repository for authentication-related database operations including user registration and credential management.
/// </summary>
public interface IAuthRepository
{
/// <summary>
/// Registers a new user with account details and initial credential.
/// Uses stored procedure: USP_RegisterUser
/// </summary>
/// <param name="username">Unique username for the user</param>
/// <param name="firstName">User's first name</param>
/// <param name="lastName">User's last name</param>
/// <param name="email">User's email address</param>
/// <param name="dateOfBirth">User's date of birth</param>
/// <param name="passwordHash">Hashed password</param>
/// <returns>The newly created UserAccount with generated ID</returns>
Task<Domain.Core.Entities.UserAccount> RegisterUserAsync(
string username,
string firstName,
string lastName,
string email,
DateTime dateOfBirth,
string passwordHash);
/// <summary>
/// Retrieves a user account by email address (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByEmail
/// </summary>
/// <param name="email">Email address to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Core.Entities.UserAccount?> GetUserByEmailAsync(string email);
/// <summary>
/// Retrieves a user account by username (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByUsername
/// </summary>
/// <param name="username">Username to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Core.Entities.UserAccount?> GetUserByUsernameAsync(string username);
/// <summary>
/// Retrieves the active (non-revoked) credential for a user account.
/// Uses stored procedure: USP_GetActiveUserCredentialByUserAccountId
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>Active UserCredential if found, null otherwise</returns>
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
/// <summary>
/// Rotates a user's credential by invalidating all existing credentials and creating a new one.
/// Uses stored procedure: USP_RotateUserCredential
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <param name="newPasswordHash">New hashed password</param>
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
}
}

View File

@@ -1,18 +0,0 @@
using System.Data.Common;
using Repository.Core.Sql;
namespace Repository.Core.Repositories
{
public abstract class Repository<T>(ISqlConnectionFactory connectionFactory)
where T : class
{
protected async Task<DbConnection> CreateConnection()
{
var connection = connectionFactory.CreateConnection();
await connection.OpenAsync();
return connection;
}
protected abstract T MapToEntity(DbDataReader reader);
}
}

View File

@@ -1,15 +0,0 @@
using Domain.Core.Entities;
namespace Repository.Core.Repositories.UserAccount
{
public interface IUserAccountRepository
{
Task<Domain.Core.Entities.UserAccount?> GetByIdAsync(Guid id);
Task<IEnumerable<Domain.Core.Entities.UserAccount>> GetAllAsync(int? limit, int? offset);
Task UpdateAsync(Domain.Core.Entities.UserAccount userAccount);
Task DeleteAsync(Guid id);
Task<Domain.Core.Entities.UserAccount?> GetByUsernameAsync(string username);
Task<Domain.Core.Entities.UserAccount?> GetByEmailAsync(string email);
}
}

View File

@@ -1,130 +0,0 @@
using System.Data;
using System.Data.Common;
using Domain.Core.Entities;
using Repository.Core.Sql;
namespace Repository.Core.Repositories.UserAccount
{
public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
: Repository<Domain.Core.Entities.UserAccount>(connectionFactory), IUserAccountRepository
{
public async Task<Domain.Core.Entities.UserAccount?> GetByIdAsync(Guid id)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountById";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", id);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<IEnumerable<Domain.Core.Entities.UserAccount>> GetAllAsync(int? limit, int? offset)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetAllUserAccounts";
command.CommandType = CommandType.StoredProcedure;
if (limit.HasValue)
AddParameter(command, "@Limit", limit.Value);
if (offset.HasValue)
AddParameter(command, "@Offset", offset.Value);
await using var reader = await command.ExecuteReaderAsync();
var users = new List<Domain.Core.Entities.UserAccount>();
while (await reader.ReadAsync())
{
users.Add(MapToEntity(reader));
}
return users;
}
public async Task UpdateAsync(Domain.Core.Entities.UserAccount userAccount)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_UpdateUserAccount";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccount.UserAccountId);
AddParameter(command, "@Username", userAccount.Username);
AddParameter(command, "@FirstName", userAccount.FirstName);
AddParameter(command, "@LastName", userAccount.LastName);
AddParameter(command, "@Email", userAccount.Email);
AddParameter(command, "@DateOfBirth", userAccount.DateOfBirth);
await command.ExecuteNonQueryAsync();
}
public async Task DeleteAsync(Guid id)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_DeleteUserAccount";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", id);
await command.ExecuteNonQueryAsync();
}
public async Task<Domain.Core.Entities.UserAccount?> GetByUsernameAsync(string username)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByUsername";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<Domain.Core.Entities.UserAccount?> GetByEmailAsync(string email)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByEmail";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Email", email);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
protected override Domain.Core.Entities.UserAccount MapToEntity(DbDataReader reader)
{
return new Domain.Core.Entities.UserAccount
{
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
Username = reader.GetString(reader.GetOrdinal("Username")),
FirstName = reader.GetString(reader.GetOrdinal("FirstName")),
LastName = reader.GetString(reader.GetOrdinal("LastName")),
Email = reader.GetString(reader.GetOrdinal("Email")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
UpdatedAt = reader.IsDBNull(reader.GetOrdinal("UpdatedAt"))
? null
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt")),
DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")),
Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
? null
: (byte[])reader["Timer"]
};
}
private static void AddParameter(DbCommand command, string name, object? value)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
}
}

View File

@@ -1,46 +0,0 @@
using System.Data.Common;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
namespace Repository.Core.Sql
{
public class DefaultSqlConnectionFactory(IConfiguration configuration) : ISqlConnectionFactory
{
private readonly string _connectionString = GetConnectionString(configuration);
private static string GetConnectionString(IConfiguration configuration)
{
// Check for full connection string first
var fullConnectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING");
if (!string.IsNullOrEmpty(fullConnectionString))
{
return fullConnectionString;
}
// Try to build from individual environment variables (preferred method for Docker)
try
{
return SqlConnectionStringHelper.BuildConnectionString();
}
catch (InvalidOperationException)
{
// Fall back to configuration-based connection string if env vars are not set
var connString = configuration.GetConnectionString("Default");
if (!string.IsNullOrEmpty(connString))
{
return connString;
}
throw new InvalidOperationException(
"Database connection string not configured. Set DB_CONNECTION_STRING or DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD env vars or ConnectionStrings:Default."
);
}
}
public DbConnection CreateConnection()
{
return new SqlConnection(_connectionString);
}
}
}

View File

@@ -1,9 +0,0 @@
using System.Data.Common;
namespace Repository.Core.Sql
{
public interface ISqlConnectionFactory
{
DbConnection CreateConnection();
}
}

View File

@@ -1,50 +0,0 @@
using Microsoft.Data.SqlClient;
namespace Repository.Core.Sql
{
public static class SqlConnectionStringHelper
{
/// <summary>
/// Builds a SQL Server connection string from environment variables.
/// Expects DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD, and DB_TRUST_SERVER_CERTIFICATE.
/// </summary>
/// <param name="databaseName">Optional override for the database name. If null, uses DB_NAME env var.</param>
/// <returns>A properly formatted SQL Server connection string.</returns>
public static string BuildConnectionString(string? databaseName = null)
{
var server = Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
var dbName = databaseName
?? Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
var user = Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException("DB_USER environment variable is not set");
var password = Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
var builder = new SqlConnectionStringBuilder
{
DataSource = server,
InitialCatalog = dbName,
UserID = user,
Password = password,
TrustServerCertificate = true,
Encrypt = true
};
return builder.ConnectionString;
}
/// <summary>
/// Builds a connection string to the master database using environment variables.
/// </summary>
/// <returns>A connection string for the master database.</returns>
public static string BuildMasterConnectionString()
{
return BuildConnectionString("master");
}
}
}

View File

@@ -1,14 +0,0 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Repository/Repository.Core/Repository.Core.csproj", "Repository/Repository.Core/"]
COPY ["Repository/Repository.Tests/Repository.Tests.csproj", "Repository/Repository.Tests/"]
RUN dotnet restore "Repository/Repository.Tests/Repository.Tests.csproj"
COPY . .
WORKDIR "/src/Repository/Repository.Tests"
RUN dotnet build "./Repository.Tests.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS final
RUN mkdir -p /app/test-results
WORKDIR /src/Repository/Repository.Tests
ENTRYPOINT ["dotnet", "test", "./Repository.Tests.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/repository-tests.trx"]

View File

@@ -1,119 +0,0 @@
using Apps72.Dev.Data.DbMocker;
using Repository.Core.Repositories.UserAccount;
using FluentAssertions;
using Repository.Tests.Database;
namespace Repository.Tests.UserAccount;
public class UserAccountRepositoryTest
{
private static UserAccountRepository CreateRepo(MockDbConnection conn)
=> new(new TestConnectionFactory(conn));
[Fact]
public async Task GetByIdAsync_ReturnsRow_Mapped()
{
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "usp_GetUserAccountById")
.ReturnsTable(MockTable.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
).AddRow(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
"yerb", "Aaron", "Po", "aaronpo@example.com",
new DateTime(2020, 1, 1), null,
new DateTime(1990, 1, 1), null));
var repo = CreateRepo(conn);
var result = await repo.GetByIdAsync(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"));
result.Should().NotBeNull();
result!.Username.Should().Be("yerb");
result.Email.Should().Be("aaronpo@example.com");
}
[Fact]
public async Task GetAllAsync_ReturnsMultipleRows()
{
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "usp_GetAllUserAccounts")
.ReturnsTable(MockTable.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
).AddRow(Guid.NewGuid(), "a", "A", "A", "a@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date,
null)
.AddRow(Guid.NewGuid(), "b", "B", "B", "b@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date,
null));
var repo = CreateRepo(conn);
var results = (await repo.GetAllAsync(null, null)).ToList();
results.Should().HaveCount(2);
results.Select(r => r.Username).Should().BeEquivalentTo(new[] { "a", "b" });
}
[Fact]
public async Task GetByUsername_ReturnsRow()
{
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
.ReturnsTable(MockTable.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
).AddRow(Guid.NewGuid(), "lookupuser", "L", "U", "lookup@example.com", DateTime.UtcNow, null,
DateTime.UtcNow.Date, null));
var repo = CreateRepo(conn);
var result = await repo.GetByUsernameAsync("lookupuser");
result.Should().NotBeNull();
result!.Email.Should().Be("lookup@example.com");
}
[Fact]
public async Task GetByEmail_ReturnsRow()
{
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(MockTable.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
).AddRow(Guid.NewGuid(), "byemail", "B", "E", "byemail@example.com", DateTime.UtcNow, null,
DateTime.UtcNow.Date, null));
var repo = CreateRepo(conn);
var result = await repo.GetByEmailAsync("byemail@example.com");
result.Should().NotBeNull();
result!.Username.Should().Be("byemail");
}
}

View File

@@ -1,12 +1,12 @@
using Domain.Core.Entities;
using Repository.Core.Repositories.Auth;
using Service.Core.Password;
using Domain.Entities;
using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth;
namespace Service.Core.Auth;
public class AuthService(
IAuthRepository authRepo,
IPasswordService passwordService
IPasswordInfra passwordInfra
) : IAuthService
{
public async Task<UserAccount> RegisterAsync(UserAccount userAccount, string password)
@@ -19,7 +19,7 @@ public class AuthService(
}
// password hashing
var hashed = passwordService.Hash(password);
var hashed = passwordInfra.Hash(password);
// Register user with hashed password
return await authRepo.RegisterUserAsync(
@@ -43,6 +43,6 @@ public class AuthService(
var activeCred = await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId);
if (activeCred is null) return null;
return !passwordService.Verify(password, activeCred.Hash) ? null : user;
return !passwordInfra.Verify(password, activeCred.Hash) ? null : user;
}
}

View File

@@ -1,4 +1,4 @@
using Domain.Core.Entities;
using Domain.Entities;
namespace Service.Core.Auth;

View File

@@ -12,6 +12,8 @@
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.csproj" />
<ProjectReference Include="..\..\Repository\Repository.Core\Repository.Core.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,4 @@
using Domain.Core.Entities;
using Domain.Entities;
namespace Service.Core.User;

View File

@@ -1,5 +1,5 @@
using Domain.Core.Entities;
using Repository.Core.Repositories.UserAccount;
using Domain.Entities;
using Infrastructure.Repository.UserAccount;
namespace Service.Core.User;