diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..03fc0eb
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,12 @@
+**/.dockerignore
+**/.env
+**/.git
+**/.gitignore
+**/.vs
+**/.vscode
+**/bin
+**/obj
+**/Dockerfile
+**/node_modules
+**/dist
+**/.angular
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..efdba87
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+* text=auto
+*.sh text eol=lf
diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml
new file mode 100644
index 0000000..da47953
--- /dev/null
+++ b/.github/workflows/pr-validation.yml
@@ -0,0 +1,79 @@
+name: PR Validation
+
+on:
+ pull_request:
+ branches:
+ - develop
+ - main
+
+jobs:
+ build-and-test-api:
+ name: API Validation
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Restore dependencies
+ run: dotnet restore -p:Configuration=Release
+
+ - name: Build project
+ run: dotnet build --no-restore --configuration Release
+
+ - name: Run Unit & Integration Tests
+ run: dotnet test --no-build --configuration Release --verbosity normal
+
+ - name: Verify API Dockerfile builds
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: src/api/SmartEventBooking.Web/Dockerfile
+ push: false
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+
+ build-and-test-client:
+ name: Client UI Validation
+ runs-on: ubuntu-latest
+
+ defaults:
+ run:
+ working-directory: ./src/client
+
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version-file: src/client/.nvmrc
+ cache: 'npm'
+ cache-dependency-path: ./src/client/package-lock.json
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run client tests
+ run: npm test -- --watch=false
+
+ - name: Verify Client Dockerfile builds
+ uses: docker/build-push-action@v5
+ with:
+ context: ./src/client
+ push: false
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.github/workflows/release-to-prod.yml b/.github/workflows/release-to-prod.yml
new file mode 100644
index 0000000..680c3d7
--- /dev/null
+++ b/.github/workflows/release-to-prod.yml
@@ -0,0 +1,96 @@
+name: Release to Production
+
+on:
+ push:
+ branches: [ main ]
+
+env:
+ API_IMAGE: smart-event-booking-api-app
+ CLIENT_IMAGE: smart-event-booking-client-app
+ AZURE_WEBAPP_API_NAME: app-smarteventbooking-api
+ AZURE_WEBAPP_CLIENT_NAME: app-smarteventbooking-client
+
+jobs:
+ run-tests:
+ name: Run Tests
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Run API tests
+ run: dotnet test --configuration Release --verbosity normal
+
+ build-and-push:
+ needs: run-tests
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_ACCESSTOKEN }}
+
+ - name: Build and push API
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: src/api/SmartEventBooking.Web/Dockerfile
+ push: true
+ tags: |
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.API_IMAGE }}:latest
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.API_IMAGE }}:${{ github.sha }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: Build and push Client
+ uses: docker/build-push-action@v5
+ with:
+ context: ./src/client
+ push: true
+ tags: |
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.CLIENT_IMAGE }}:latest
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.CLIENT_IMAGE }}:${{ github.sha }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ deploy-api:
+ needs: build-and-push
+ runs-on: ubuntu-latest
+ steps:
+ - name: Deploy API to Azure Web App
+ uses: azure/webapps-deploy@v2
+ with:
+ app-name: ${{ env.AZURE_WEBAPP_API_NAME }}
+ publish-profile: ${{ secrets.AZURE_API_PUBLISH_PROFILE }}
+ images: '${{ secrets.DOCKERHUB_USERNAME }}/${{ env.API_IMAGE }}:${{ github.sha }}'
+
+ - name: Smoke test API
+ run: |
+ curl --retry 10 --retry-delay 10 --retry-connrefused --fail \
+ ${{ vars.AZURE_WEBAPP_API_URL }}/health/db
+
+ deploy-client:
+ needs: build-and-push
+ runs-on: ubuntu-latest
+ steps:
+ - name: Deploy Client to Azure Web App
+ uses: azure/webapps-deploy@v2
+ with:
+ app-name: ${{ env.AZURE_WEBAPP_CLIENT_NAME }}
+ publish-profile: ${{ secrets.AZURE_CLIENT_PUBLISH_PROFILE }}
+ images: '${{ secrets.DOCKERHUB_USERNAME }}/${{ env.CLIENT_IMAGE }}:${{ github.sha }}'
+
+ - name: Smoke test Client
+ run: |
+ curl --retry 10 --retry-delay 10 --retry-connrefused --fail \
+ ${{ vars.AZURE_WEBAPP_CLIENT_URL }}
diff --git a/.gitignore b/.gitignore
index 3101c45..2daa04b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -417,5 +417,5 @@ FodyWeavers.xsd
*.msm
*.msp
-docker-compose.yml
-Dockerfile
\ No newline at end of file
+#docker-compose.yml
+#Dockerfile
\ No newline at end of file
diff --git a/README.md b/README.md
index 9d5f670..33dc558 100644
--- a/README.md
+++ b/README.md
@@ -6,14 +6,53 @@ For product vision, scope, and contribution workflow, see `PROJECT_SCOPE.md`.
## Prerequisites
+**To run with Docker Compose:**
+- [Docker Desktop](https://www.docker.com/products/docker-desktop/)
+
+**To run locally:**
- .NET SDK 10.0
+- Node.js 24 LTS (see [Node version setup](#node-version-setup))
- SQL Server (local instance or Docker)
- Node.js (v18 or later) and npm
- Angular CLI (`npm install -g @angular/cli`)
+## Running with Docker Compose
+
+The quickest way to run the full stack (API + client + database) without any local tooling beyond Docker.
+
+1. Copy the env file and fill in your credentials:
+ ```bash
+ cp .env.example .env
+ ```
+
+2. Start everything:
+ ```bash
+ docker compose up -d --build
+ ```
+
+ | Service | URL |
+ |---|---|
+ | Client (Angular) | http://localhost:4200 |
+ | API (ASP.NET Core) | http://localhost:5270 |
+ | SQL Server | localhost:1433 |
+
+ Migrations run automatically on API startup — no manual `dotnet ef` step needed.
+
+3. Stop everything:
+ ```bash
+ docker compose down
+ ```
+ Add `-v` to also wipe the database volume.
+
+---
+
+## Running locally
+
+Follow the numbered steps below to run the API and client separately.
+
## 1) Configure the database settings
-This app reads database values from environment variables (`Database__Server`, `Database__Name`, `Database__User`, `Database__Password`, `Database__TrustServerCertificate`) and falls back to `src/SmartEventBooking.Web/appsettings.json`.
+This app reads database values from environment variables (`Database__Server`, `Database__Name`, `Database__User`, `Database__Password`, `Database__TrustServerCertificate`) and falls back to `src/api/SmartEventBooking.Web/appsettings.json`.
Admin seeding credentials are also read from environment variables:
@@ -45,10 +84,10 @@ From the repository root:
```bash
dotnet restore
-dotnet ef database update --project src/SmartEventBooking.Infrastructure/SmartEventBooking.Infrastructure.csproj --startup-project src/SmartEventBooking.Web/SmartEventBooking.Web.csproj --context ApplicationDbContext
+dotnet ef database update --project src/api/SmartEventBooking.Infrastructure/SmartEventBooking.Infrastructure.csproj --startup-project src/api/SmartEventBooking.Web/SmartEventBooking.Web.csproj --context ApplicationDbContext
```
-## 4) Run the app
+## 4) Run the API
```bash
dotnet run --project src/api/SmartEventBooking.Web/SmartEventBooking.Web.csproj --launch-profile https
@@ -58,6 +97,7 @@ Default URLs (development):
- `https://localhost:7134`
- `http://localhost:5270`
+- Swagger UI: `https://localhost:7134/swagger`
If you run without the `https` launch profile, you may see:
@@ -65,16 +105,16 @@ If you run without the `https` launch profile, you may see:
Use `--launch-profile https` (shown above), or set `ASPNETCORE_URLS` to include an HTTPS URL.
-## Run the Frontend (Development server)
-
-To start a local development server for the frontend, navigate to the client directory and run:
+## 5) Run the client
```bash
cd src/client
-npm install
+npm ci
ng serve
```
+Client runs at `http://localhost:4200`. The API URL it connects to is configured in `src/client/public/assets/config.json`.
+
## 6) Verify connectivity
Health endpoint:
@@ -93,3 +133,41 @@ Expected result:
dotnet build
dotnet test
```
+
+---
+
+## Node version setup
+
+The client requires **Node.js 24 LTS**, pinned in `src/client/.nvmrc`.
+
+### Option A — Install Node 24 directly (simplest)
+
+Download and install Node 24 LTS from [nodejs.org](https://nodejs.org/en/download). No version manager needed if you only work on this project.
+
+### Option B — Use fnm (if you work on multiple projects with different Node versions)
+
+**Windows:**
+```powershell
+winget install Schniz.fnm
+```
+Add to your PowerShell profile (run once):
+```powershell
+Add-Content $PROFILE "fnm env --use-on-cd --shell power-shell | Out-String | Invoke-Expression"
+```
+Restart PowerShell, then inside `src/client/`:
+```powershell
+fnm install
+fnm use
+```
+
+**macOS / Linux:**
+```bash
+curl -fsSL https://fnm.vercel.app/install | bash
+```
+Then inside `src/client/`:
+```bash
+fnm install
+fnm use
+```
+
+> **Switching versions and seeing native module errors?** Delete `node_modules` and run `npm ci` again — native binaries are version-specific.
diff --git a/SmartEventBooking.slnx b/SmartEventBooking.slnx
index e25f507..1e00b82 100644
--- a/SmartEventBooking.slnx
+++ b/SmartEventBooking.slnx
@@ -8,8 +8,12 @@
-
-
+
+
+
+
+
+
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..514e7dd
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,50 @@
+
+services:
+ db:
+ image: mcr.microsoft.com/mssql/server:2022-latest
+ container_name: smart-event-booking-sql-db
+ environment:
+ - ACCEPT_EULA=Y
+ - MSSQL_SA_PASSWORD=${Database__Password}
+ ports:
+ - "1433:1433"
+ volumes:
+ - sql_data:/var/opt/mssql
+ healthcheck:
+ test: ["CMD", "/opt/mssql-tools18/bin/sqlcmd", "-S", "localhost", "-U", "sa", "-P", "${Database__Password}", "-Q", "SELECT 1", "-No"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ start_period: 10s
+
+ api:
+ image: smart-event-booking-api:latest
+ build:
+ context: .
+ dockerfile: src/api/SmartEventBooking.Web/Dockerfile
+ container_name: smart-event-booking-api-app
+ ports:
+ - "5270:8080"
+ env_file:
+ - .env
+ environment:
+ - Database__Server=db
+ depends_on:
+ db:
+ condition: service_healthy
+
+ client:
+ image: smart-event-booking-client:latest
+ build:
+ context: ./src/client
+ dockerfile: Dockerfile
+ container_name: smart-event-booking-client-app
+ ports:
+ - "4200:80"
+ environment:
+ - API_BASE_URL=http://localhost:5270
+ depends_on:
+ - api
+
+volumes:
+ sql_data:
\ No newline at end of file
diff --git a/src/api/SmartEventBooking.Infrastructure/DependencyInjection.cs b/src/api/SmartEventBooking.Infrastructure/DependencyInjection.cs
index bb40337..3515b6d 100644
--- a/src/api/SmartEventBooking.Infrastructure/DependencyInjection.cs
+++ b/src/api/SmartEventBooking.Infrastructure/DependencyInjection.cs
@@ -40,8 +40,9 @@ public static IServiceCollection AddInfrastructure(
options.UseSqlServer(connectionString, sqlOptions =>
{
- sqlOptions.EnableRetryOnFailure(maxRetryCount: 3,
- maxRetryDelay: TimeSpan.FromSeconds(5),
+ sqlOptions.EnableRetryOnFailure(
+ maxRetryCount: 3,
+ maxRetryDelay: TimeSpan.FromSeconds(10),
errorNumbersToAdd: null);
});
});
diff --git a/src/api/SmartEventBooking.Web/Dockerfile b/src/api/SmartEventBooking.Web/Dockerfile
new file mode 100644
index 0000000..93cb69a
--- /dev/null
+++ b/src/api/SmartEventBooking.Web/Dockerfile
@@ -0,0 +1,27 @@
+FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
+WORKDIR /app
+EXPOSE 8080
+
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+WORKDIR /src
+
+COPY ["src/api/SmartEventBooking.Web/SmartEventBooking.Web.csproj", "src/api/SmartEventBooking.Web/"]
+COPY ["src/api/SmartEventBooking.Application/SmartEventBooking.Application.csproj", "src/api/SmartEventBooking.Application/"]
+COPY ["src/api/SmartEventBooking.Domain/SmartEventBooking.Domain.csproj", "src/api/SmartEventBooking.Domain/"]
+COPY ["src/api/SmartEventBooking.Infrastructure/SmartEventBooking.Infrastructure.csproj", "src/api/SmartEventBooking.Infrastructure/"]
+COPY ["src/api/SmartEventBooking.Shared/SmartEventBooking.Shared.csproj", "src/api/SmartEventBooking.Shared/"]
+
+RUN dotnet restore "src/api/SmartEventBooking.Web/SmartEventBooking.Web.csproj"
+
+COPY . .
+WORKDIR "/src/src/api/SmartEventBooking.Web"
+
+RUN dotnet build "SmartEventBooking.Web.csproj" -c Release -o /app/build
+
+FROM build AS publish
+RUN dotnet publish "SmartEventBooking.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false
+
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+ENTRYPOINT ["dotnet", "SmartEventBooking.Web.dll"]
\ No newline at end of file
diff --git a/src/api/SmartEventBooking.Web/Program.cs b/src/api/SmartEventBooking.Web/Program.cs
index 9c61c87..019cd55 100644
--- a/src/api/SmartEventBooking.Web/Program.cs
+++ b/src/api/SmartEventBooking.Web/Program.cs
@@ -13,6 +13,8 @@
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped();
builder.Services.AddValidatorsFromAssemblyContaining();
@@ -32,11 +34,14 @@
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});
+var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get()
+ ?? new[] { "http://localhost:4200", "https://localhost:4200" };
+
builder.Services.AddCors(options =>
{
- options.AddDefaultPolicy(builder =>
+ options.AddDefaultPolicy(corsBuilder =>
{
- builder.WithOrigins("http://localhost:4200", "https://localhost:4200")
+ corsBuilder.WithOrigins(allowedOrigins)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
@@ -47,8 +52,12 @@
await app.InitialiseDatabaseAsync();
-// Configure the HTTP request pipeline.
-if (!app.Environment.IsDevelopment())
+if (app.Environment.IsDevelopment())
+{
+ app.UseSwagger();
+ app.UseSwaggerUI();
+}
+else
{
app.UseExceptionHandler("/Errors/500");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
diff --git a/src/api/SmartEventBooking.Web/Properties/launchSettings.json b/src/api/SmartEventBooking.Web/Properties/launchSettings.json
index 695f794..b84c5ba 100644
--- a/src/api/SmartEventBooking.Web/Properties/launchSettings.json
+++ b/src/api/SmartEventBooking.Web/Properties/launchSettings.json
@@ -5,6 +5,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
+ "launchUrl": "swagger",
"applicationUrl": "http://localhost:5270",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
@@ -14,6 +15,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
+ "launchUrl": "swagger",
"applicationUrl": "https://localhost:7134;http://localhost:5270",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
diff --git a/src/api/SmartEventBooking.Web/SmartEventBooking.Web.csproj b/src/api/SmartEventBooking.Web/SmartEventBooking.Web.csproj
index bde2366..dbaf6ee 100644
--- a/src/api/SmartEventBooking.Web/SmartEventBooking.Web.csproj
+++ b/src/api/SmartEventBooking.Web/SmartEventBooking.Web.csproj
@@ -13,6 +13,7 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
all
+
diff --git a/src/api/SmartEventBooking.Web/appsettings.Development.json b/src/api/SmartEventBooking.Web/appsettings.Development.json
index 7568bef..a23a2ec 100644
--- a/src/api/SmartEventBooking.Web/appsettings.Development.json
+++ b/src/api/SmartEventBooking.Web/appsettings.Development.json
@@ -1,4 +1,8 @@
{
+ "AllowedOrigins": [
+ "http://localhost:4200",
+ "https://localhost:4200"
+ ],
"ConnectionStrings": {
"DefaultConnection": ""
},
diff --git a/src/api/SmartEventBooking.Web/appsettings.json b/src/api/SmartEventBooking.Web/appsettings.json
index a796e57..6ad1def 100644
--- a/src/api/SmartEventBooking.Web/appsettings.json
+++ b/src/api/SmartEventBooking.Web/appsettings.json
@@ -1,11 +1,12 @@
{
+ "AllowedOrigins": [],
"ConnectionStrings": {
"DefaultConnection": ""
},
"Database": {
- "Server": "localhost,1433",
+ "Server": "",
"Name": "SmartEventBookingDb",
- "User": "sa",
+ "User": "",
"Password": "",
"TrustServerCertificate": true
},
diff --git a/src/api/SmartEventBooking.Web/temp.txt b/src/api/SmartEventBooking.Web/temp.txt
deleted file mode 100644
index e69de29..0000000
diff --git a/src/client/.nvmrc b/src/client/.nvmrc
new file mode 100644
index 0000000..a45fd52
--- /dev/null
+++ b/src/client/.nvmrc
@@ -0,0 +1 @@
+24
diff --git a/src/client/Dockerfile b/src/client/Dockerfile
new file mode 100644
index 0000000..f858451
--- /dev/null
+++ b/src/client/Dockerfile
@@ -0,0 +1,25 @@
+FROM node:24-alpine AS build
+WORKDIR /app
+
+COPY package.json package-lock.json ./
+RUN npm ci
+
+COPY . .
+RUN npm run build -- --configuration production
+
+FROM nginx:stable-alpine
+
+RUN apk add --no-cache gettext
+
+COPY --from=build /app/dist/client/browser /usr/share/nginx/html
+COPY nginx.custom.conf /etc/nginx/conf.d/default.conf
+
+COPY config.json.template /usr/share/nginx/html/assets/config.json.template
+
+COPY docker-entrypoint.sh /docker-entrypoint.sh
+
+RUN sed -i 's/\r//' /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh
+
+EXPOSE 80
+
+ENTRYPOINT ["/docker-entrypoint.sh"]
\ No newline at end of file
diff --git a/src/client/config.json.template b/src/client/config.json.template
new file mode 100644
index 0000000..7553824
--- /dev/null
+++ b/src/client/config.json.template
@@ -0,0 +1,3 @@
+{
+ "apiBaseUrl": "${API_BASE_URL}"
+}
diff --git a/src/client/docker-entrypoint.sh b/src/client/docker-entrypoint.sh
new file mode 100644
index 0000000..beaf0cb
--- /dev/null
+++ b/src/client/docker-entrypoint.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+envsubst '${API_BASE_URL}' \
+ < /usr/share/nginx/html/assets/config.json.template \
+ > /usr/share/nginx/html/assets/config.json
+exec nginx -g 'daemon off;'
diff --git a/src/client/nginx.custom.conf b/src/client/nginx.custom.conf
new file mode 100644
index 0000000..681e1a6
--- /dev/null
+++ b/src/client/nginx.custom.conf
@@ -0,0 +1,21 @@
+server {
+ listen 80;
+ server_name localhost;
+
+ location / {
+ root /usr/share/nginx/html;
+ index index.html index.htm;
+ try_files $uri $uri/ /index.html;
+ }
+
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?|eot|ttf|otf)$ {
+ root /usr/share/nginx/html;
+ expires 1y;
+ add_header Cache-Control "public, no-transform";
+ }
+
+ error_page 500 502 503 504 /50x.html;
+ location = /50x.html {
+ root /usr/share/nginx/html;
+ }
+}
\ No newline at end of file
diff --git a/src/client/package-lock.json b/src/client/package-lock.json
index bc1d9ce..ee0d0c8 100644
--- a/src/client/package-lock.json
+++ b/src/client/package-lock.json
@@ -463,7 +463,6 @@
"resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.9.tgz",
"integrity": "sha512-7spQcF3hPN/fjTx6Pwa32KRRdO0NcixnRuPV4lo50ejtXesjiLVR+fkaX38sawAyGoq89IuuYvUDrbLwCMypmQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -480,7 +479,6 @@
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.9.tgz",
"integrity": "sha512-clsK1EsSPtAuqlRl4CciA/gsvsW7xe0eWcvHxtrMW6DYaUJ6X4AAuDxEEJ5cf/3Mpw4s8KssjIUPPtbrUIGLSQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -494,7 +492,6 @@
"integrity": "sha512-hTTW/OiqTXrwTneS18CMp47OX0XSbLYl2rIomLS3nXVJniSETH6S/k+LqQtGWWgLbzsd3PzUOOckHnvzpTBTsA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/core": "7.29.0",
"@jridgewell/sourcemap-codec": "^1.4.14",
@@ -527,7 +524,6 @@
"resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.9.tgz",
"integrity": "sha512-uZLq2aedJ+0uEZxyf6a1Nc7y1aZ7akAW7K1Kon8JUDZOvI2IDbk0i00MzkELt8q9uSmSSqg9zNKuhjspFf0Pyw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -572,7 +568,6 @@
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.9.tgz",
"integrity": "sha512-MjEtFvoFtsjsAeu2yzauqGgwwEHV4ml25c9vGFmw4OmSoNme4yp41f2DegwOkn1TTHL3OF3GE65ng2U2feJU4Q==",
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -697,7 +692,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1061,7 +1055,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -1110,11 +1103,35 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=20.19.0"
}
},
+ "node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -1122,6 +1139,7 @@
"dev": true,
"license": "MIT",
"optional": true,
+ "peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -1853,7 +1871,6 @@
"integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@inquirer/checkbox": "^4.3.2",
"@inquirer/confirm": "^5.1.21",
@@ -4306,7 +4323,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -4458,7 +4474,6 @@
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"readdirp": "^5.0.0"
},
@@ -5147,7 +5162,6 @@
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -5476,7 +5490,6 @@
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -5818,7 +5831,6 @@
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.31",
"@asamuzakjp/dom-selector": "^6.8.1",
@@ -5927,7 +5939,6 @@
"integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"cli-truncate": "^5.0.0",
"colorette": "^2.0.20",
@@ -7025,7 +7036,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -7339,7 +7349,6 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@@ -7964,8 +7973,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "license": "0BSD",
- "peer": true
+ "license": "0BSD"
},
"node_modules/tuf-js": {
"version": "4.1.0",
@@ -8003,7 +8011,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -8089,7 +8096,6 @@
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -8165,7 +8171,6 @@
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.5",
@@ -8558,7 +8563,6 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/src/client/public/assets/config.json b/src/client/public/assets/config.json
new file mode 100644
index 0000000..31c6818
--- /dev/null
+++ b/src/client/public/assets/config.json
@@ -0,0 +1,3 @@
+{
+ "apiBaseUrl": "https://localhost:7134"
+}
diff --git a/src/client/src/app/app.config.ts b/src/client/src/app/app.config.ts
index ea41744..86c2d06 100644
--- a/src/client/src/app/app.config.ts
+++ b/src/client/src/app/app.config.ts
@@ -1,8 +1,9 @@
-import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
+import { ApplicationConfig, inject, provideBrowserGlobalErrorListeners, provideAppInitializer } from '@angular/core';
import { provideRouter } from '@angular/router';
import { HttpInterceptorFn, provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
+import { ConfigService } from './core/services/config.service';
import { errorInterceptor } from './core/interceptors/error.interceptor';
const withCredentialsInterceptor: HttpInterceptorFn = (req, next) =>
@@ -12,6 +13,12 @@ export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
- provideHttpClient(withInterceptors([withCredentialsInterceptor, errorInterceptor]))
+
+ provideHttpClient(withInterceptors([withCredentialsInterceptor, errorInterceptor])),
+
+ provideAppInitializer(() => {
+ const configService = inject(ConfigService);
+ return configService.load();
+ })
]
};
diff --git a/src/client/src/app/core/services/auth.service.ts b/src/client/src/app/core/services/auth.service.ts
index 79f2e8b..a26aa0a 100644
--- a/src/client/src/app/core/services/auth.service.ts
+++ b/src/client/src/app/core/services/auth.service.ts
@@ -1,7 +1,7 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap } from 'rxjs';
-import { environment } from '../../../environments/environment';
+import { ConfigService } from './config.service';
import { LoginRequest, RegisterRequest, AuthResponse, AuthSession } from '../models/auth.model';
@Injectable({
@@ -11,6 +11,7 @@ export class AuthService {
private static readonly AUTH_SESSION_STORAGE_KEY = 'auth.session';
private readonly http = inject(HttpClient);
+ private readonly config = inject(ConfigService);
private readonly authSessionSignal = signal(this.readStoredAuthSession());
readonly authSession = computed(() => this.authSessionSignal());
@@ -22,9 +23,9 @@ export class AuthService {
return this.roles().includes(role);
}
- private readonly authApiUrl = environment.apiUrl.endsWith('/events')
- ? environment.apiUrl.replace('/events', '/auth')
- : `${environment.apiUrl}/auth`;
+ private get authApiUrl(): string {
+ return `${this.config.apiBaseUrl}/api/auth`;
+ }
register(request: RegisterRequest): Observable {
return this.http.post(`${this.authApiUrl}/register`, request)
diff --git a/src/client/src/app/core/services/category.service.ts b/src/client/src/app/core/services/category.service.ts
index 5b1e51f..2cf49a2 100644
--- a/src/client/src/app/core/services/category.service.ts
+++ b/src/client/src/app/core/services/category.service.ts
@@ -2,14 +2,18 @@ import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Category } from '../models/category.model';
-import { environment } from '../../../environments/environment';
+import { ConfigService } from './config.service';
@Injectable({
providedIn: 'root'
})
export class CategoryService {
private http = inject(HttpClient);
- private apiUrl = `${environment.apiUrl}/categories`;
+ private config = inject(ConfigService);
+
+ private get apiUrl(): string {
+ return `${this.config.apiBaseUrl}/api/categories`;
+ }
getCategories(): Observable {
return this.http.get(this.apiUrl);
diff --git a/src/client/src/app/core/services/config.service.ts b/src/client/src/app/core/services/config.service.ts
new file mode 100644
index 0000000..de9ba89
--- /dev/null
+++ b/src/client/src/app/core/services/config.service.ts
@@ -0,0 +1,23 @@
+import { Injectable, inject } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { firstValueFrom } from 'rxjs';
+
+interface AppConfig {
+ apiBaseUrl: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class ConfigService {
+ private readonly http = inject(HttpClient);
+ private config: AppConfig = { apiBaseUrl: '' };
+
+ async load(): Promise {
+ this.config = await firstValueFrom(
+ this.http.get('/assets/config.json')
+ );
+ }
+
+ get apiBaseUrl(): string {
+ return this.config.apiBaseUrl;
+ }
+}
diff --git a/src/client/src/app/core/services/event.service.ts b/src/client/src/app/core/services/event.service.ts
index 7a539a2..5b53807 100644
--- a/src/client/src/app/core/services/event.service.ts
+++ b/src/client/src/app/core/services/event.service.ts
@@ -4,14 +4,18 @@ import { Observable } from 'rxjs';
import { Event } from '../models/event.model';
import { PaginatedList } from '../models/paginated-list.model';
import { EventSearchDto } from '../models/event-search.model';
-import { environment } from '../../../environments/environment';
+import { ConfigService } from './config.service';
@Injectable({
providedIn: 'root'
})
export class EventService {
private http = inject(HttpClient);
- private apiUrl = `${environment.apiUrl}/events`;
+ private config = inject(ConfigService);
+
+ private get apiUrl(): string {
+ return `${this.config.apiBaseUrl}/api/events`;
+ }
getUpcomingEvents(page: number = 1, pageSize: number = 5, search?: EventSearchDto): Observable> {
let params = new HttpParams()
diff --git a/src/client/tsconfig.app.json b/src/client/tsconfig.app.json
index 264f459..e4dd97c 100644
--- a/src/client/tsconfig.app.json
+++ b/src/client/tsconfig.app.json
@@ -4,6 +4,7 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
+ "rootDir": "./src",
"types": []
},
"include": [
diff --git a/SmartEventBooking.Infrastructure.IntegrationTests/Persistence/IdentitySeederTests.cs b/tests/SmartEventBooking.Infrastructure.IntegrationTests/Persistence/IdentitySeederTests.cs
similarity index 100%
rename from SmartEventBooking.Infrastructure.IntegrationTests/Persistence/IdentitySeederTests.cs
rename to tests/SmartEventBooking.Infrastructure.IntegrationTests/Persistence/IdentitySeederTests.cs
diff --git a/SmartEventBooking.Infrastructure.IntegrationTests/SmartEventBooking.Infrastructure.IntegrationTests.csproj b/tests/SmartEventBooking.Infrastructure.IntegrationTests/SmartEventBooking.Infrastructure.IntegrationTests.csproj
similarity index 65%
rename from SmartEventBooking.Infrastructure.IntegrationTests/SmartEventBooking.Infrastructure.IntegrationTests.csproj
rename to tests/SmartEventBooking.Infrastructure.IntegrationTests/SmartEventBooking.Infrastructure.IntegrationTests.csproj
index a7d6e10..dfadfa3 100644
--- a/SmartEventBooking.Infrastructure.IntegrationTests/SmartEventBooking.Infrastructure.IntegrationTests.csproj
+++ b/tests/SmartEventBooking.Infrastructure.IntegrationTests/SmartEventBooking.Infrastructure.IntegrationTests.csproj
@@ -22,10 +22,10 @@
-
-
-
-
+
+
+
+
\ No newline at end of file
diff --git a/SmartEventBooking.Infrastructure.UnitTests/Helpers/MockHelpers.cs b/tests/SmartEventBooking.Infrastructure.UnitTests/Helpers/MockHelpers.cs
similarity index 100%
rename from SmartEventBooking.Infrastructure.UnitTests/Helpers/MockHelpers.cs
rename to tests/SmartEventBooking.Infrastructure.UnitTests/Helpers/MockHelpers.cs
diff --git a/SmartEventBooking.Infrastructure.UnitTests/Identity/AuthServiceTests.cs b/tests/SmartEventBooking.Infrastructure.UnitTests/Identity/AuthServiceTests.cs
similarity index 99%
rename from SmartEventBooking.Infrastructure.UnitTests/Identity/AuthServiceTests.cs
rename to tests/SmartEventBooking.Infrastructure.UnitTests/Identity/AuthServiceTests.cs
index d7a1d74..1dec78b 100644
--- a/SmartEventBooking.Infrastructure.UnitTests/Identity/AuthServiceTests.cs
+++ b/tests/SmartEventBooking.Infrastructure.UnitTests/Identity/AuthServiceTests.cs
@@ -240,7 +240,7 @@ public async Task LoginAsync_ShouldReturnErrors_WhenUserIsLockedOut()
var result = await _authService.LoginAsync(dto);
result.Succeeded.Should().BeFalse();
- result.Errors.Should().ContainSingle().Which.Should().Be("Login failed.");
+ result.Errors.Should().ContainSingle().Which.Should().Be("Login failed: Rate limited.");
}
[Fact]
@@ -269,7 +269,7 @@ public async Task LoginAsync_ShouldReturnErrors_WhenUserIsNotAllowed()
var result = await _authService.LoginAsync(dto);
result.Succeeded.Should().BeFalse();
- result.Errors.Should().ContainSingle().Which.Should().Be("Login failed.");
+ result.Errors.Should().ContainSingle().Which.Should().Be("Login failed: Please verify your email (should never happen for now).");
}
[Fact]
diff --git a/SmartEventBooking.Infrastructure.UnitTests/SmartEventBooking.Infrastructure.UnitTests.csproj b/tests/SmartEventBooking.Infrastructure.UnitTests/SmartEventBooking.Infrastructure.UnitTests.csproj
similarity index 71%
rename from SmartEventBooking.Infrastructure.UnitTests/SmartEventBooking.Infrastructure.UnitTests.csproj
rename to tests/SmartEventBooking.Infrastructure.UnitTests/SmartEventBooking.Infrastructure.UnitTests.csproj
index e3824bb..8bc9eae 100644
--- a/SmartEventBooking.Infrastructure.UnitTests/SmartEventBooking.Infrastructure.UnitTests.csproj
+++ b/tests/SmartEventBooking.Infrastructure.UnitTests/SmartEventBooking.Infrastructure.UnitTests.csproj
@@ -18,9 +18,9 @@
-
-
-
+
+
+
diff --git a/tests/SmartEventBooking.Web.Tests/Controllers/AuthControllerTests.cs b/tests/SmartEventBooking.Web.Tests/Controllers/AuthControllerTests.cs
index 8f37d73..66e0367 100644
--- a/tests/SmartEventBooking.Web.Tests/Controllers/AuthControllerTests.cs
+++ b/tests/SmartEventBooking.Web.Tests/Controllers/AuthControllerTests.cs
@@ -1,164 +1,134 @@
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
-using FluentAssertions;
-using SmartEventBooking.Web.Controllers;
using SmartEventBooking.Application.Abstractions.Identity;
using SmartEventBooking.Application.DTOs.Auth;
-using Microsoft.AspNetCore.Http;
+using SmartEventBooking.Web.Controllers;
using System.Security.Claims;
-namespace SmartEventBooking.Web.Tests.Controllers
+namespace SmartEventBooking.Web.Tests.Controllers;
+
+public class AuthControllerTests
{
- public class AuthControllerTests
+ private readonly Mock _authServiceMock;
+ private readonly AuthController _controller;
+
+ public AuthControllerTests()
{
- private readonly Mock _authServiceMock;
- private readonly AuthController _controller;
+ _authServiceMock = new Mock();
+ _controller = new AuthController(_authServiceMock.Object);
- public AuthControllerTests()
+ var user = new ClaimsPrincipal(new ClaimsIdentity());
+ _controller.ControllerContext = new ControllerContext
{
- _authServiceMock = new Mock();
- _controller = new AuthController(_authServiceMock.Object);
-
- var user = new ClaimsPrincipal(new ClaimsIdentity());
- _controller.ControllerContext = new ControllerContext
- {
- HttpContext = new DefaultHttpContext { User = user }
- };
- }
-
- [Fact]
- public async Task Register_ReturnsOk_WhenRegistrationSucceeds()
+ HttpContext = new DefaultHttpContext { User = user }
+ };
+ }
+
+ [Fact]
+ public async Task Register_ReturnsOk_WhenRegistrationSucceeds()
+ {
+ var dto = new RegisterDto
{
- var dto = new RegisterDto
- {
- Email = "test@test.com",
- Password = "Password123!",
- ConfirmPassword = "Password123!",
- FirstName = "Ivan"
- };
+ Email = "test@example.com",
+ Password = "Password123!",
+ ConfirmPassword = "Password123!",
+ FirstName = "Ivan"
+ };
- _authServiceMock.Setup(x => x.RegisterAsync(dto))
- .ReturnsAsync(new AuthResultDto { Succeeded = true });
+ _authServiceMock.Setup(x => x.RegisterAsync(dto))
+ .ReturnsAsync(new AuthResultDto { Succeeded = true });
- var result = await _controller.Register(dto);
+ var result = await _controller.Register(dto);
- result.Should().BeOfType();
- }
+ result.Should().BeOfType();
+ }
- [Fact]
- public async Task Register_ReturnsBadRequest_WhenRegistrationFails()
- {
- var dto = new RegisterDto
- {
- Email = "test@test.com",
- Password = "Password123!",
- ConfirmPassword = "Password123!",
- FirstName = "Ivan"
- };
-
- _authServiceMock.Setup(x => x.RegisterAsync(dto))
- .ReturnsAsync(new AuthResultDto { Succeeded = false, Errors = new[] { "Error 1" } });
-
- var result = await _controller.Register(dto);
-
- result.Should().BeOfType();
- var badRequestResult = result as BadRequestObjectResult;
- (badRequestResult?.Value as IEnumerable).Should().Contain("Error 1");
- }
-
- [Fact]
- public async Task Login_ReturnsViewResult_WhenNotAuthenticated()
+ [Fact]
+ public async Task Register_ReturnsBadRequest_WhenRegistrationFails()
+ {
+ var dto = new RegisterDto
{
- var result = _controller.Login();
+ Email = "test@example.com",
+ Password = "Password123!",
+ ConfirmPassword = "Password123!",
+ FirstName = "Ivan"
+ };
- result.Should().BeOfType();
- }
+ _authServiceMock.Setup(x => x.RegisterAsync(dto))
+ .ReturnsAsync(new AuthResultDto { Succeeded = false, Errors = ["Email already taken"] });
- [Fact]
- public async Task Login_Post_ReturnsViewResult_WhenModelStateIsInvalid()
- {
- _controller.ModelState.AddModelError("Email", "Required");
- var dto = new LoginDto
- {
- Email = "",
- Password = "123",
- RememberMe = true
- };
-
- var result = await _controller.Login(dto);
-
- result.Should().BeOfType();
- var viewResult = result as ViewResult;
- viewResult?.Model.Should().Be(dto);
- }
-
- [Fact]
- public async Task Login_Post_RedirectsToHome_WhenRegistrationSucceeds()
+ var result = await _controller.Register(dto);
+
+ result.Should().BeOfType();
+
+ var badRequestResult = result as BadRequestObjectResult;
+ (badRequestResult?.Value as IEnumerable).Should().Contain("Email already taken");
+ }
+
+ [Fact]
+ public async Task Login_ReturnsOk_WhenLoginSucceeds()
+ {
+ var dto = new LoginDto
{
- var dto = new LoginDto
- {
- Email = "test@test.com",
- Password = "Password123!",
- RememberMe = true
- };
-
- _authServiceMock.Setup(x => x.LoginAsync(dto))
- .ReturnsAsync(new AuthResultDto { Succeeded = true });
-
- var result = await _controller.Login(dto);
-
- result.Should().BeOfType();
- var redirectResult = result as RedirectToActionResult;
- redirectResult?.ActionName.Should().Be("Index");
- redirectResult?.ControllerName.Should().Be("Home");
- }
-
- [Fact]
- public async Task Login_Post_ReturnsViewWithErrors_WhenLoginFails()
+ Email = "test@example.com",
+ Password = "Password123!",
+ RememberMe = true
+ };
+
+ _authServiceMock.Setup(x => x.LoginAsync(dto))
+ .ReturnsAsync(new AuthResultDto { Succeeded = true });
+
+ var result = await _controller.Login(dto);
+
+ result.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task Login_ReturnsBadRequest_WhenLoginFails()
+ {
+ var dto = new LoginDto
{
- var dto = new LoginDto
- {
- Email = "test@test.com",
- Password = "Password123!",
- RememberMe = true
- };
-
- _authServiceMock.Setup(x => x.LoginAsync(dto))
- .ReturnsAsync(new AuthResultDto { Succeeded = false, Errors = new[] { "Error 1" } });
-
- var result = await _controller.Login(dto);
-
- result.Should().BeOfType();
- _controller.ModelState.IsValid.Should().BeFalse();
- _controller.ModelState.Values.SelectMany(v => v.Errors)
- .Should().ContainSingle()
- .Which.ErrorMessage.Should().Be("Error 1");
- }
-
- [Theory]
- [InlineData(true)]
- [InlineData(false)]
- public async Task Login_Post_ForwardsRememberMeToAuthService(bool rememberMe)
+ Email = "test@example.com",
+ Password = "Password123!",
+ RememberMe = true
+ };
+
+ _authServiceMock.Setup(x => x.LoginAsync(dto))
+ .ReturnsAsync(new AuthResultDto { Succeeded = false, Errors = ["Invalid credentials"] });
+
+ var result = await _controller.Login(dto);
+
+ result.Should().BeOfType();
+
+ var badRequestResult = result as BadRequestObjectResult;
+ (badRequestResult?.Value as IEnumerable).Should().Contain("Invalid credentials");
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task Login_ForwardsRememberMeToAuthService(bool rememberMe)
+ {
+ var dto = new LoginDto
{
- var dto = new LoginDto
- {
- Email = "test@test.com",
- Password = "Password123!",
- RememberMe = rememberMe
- };
-
- _authServiceMock
- .Setup(x => x.LoginAsync(It.IsAny()))
- .ReturnsAsync(new AuthResultDto { Succeeded = true });
-
- await _controller.Login(dto);
-
- _authServiceMock.Verify(
- x => x.LoginAsync(It.Is(d =>
- d.Email == dto.Email &&
- d.Password == dto.Password &&
- d.RememberMe == rememberMe)),
- Times.Once);
- }
+ Email = "test@example.com",
+ Password = "Password123!",
+ RememberMe = rememberMe
+ };
+
+ _authServiceMock
+ .Setup(x => x.LoginAsync(It.IsAny()))
+ .ReturnsAsync(new AuthResultDto { Succeeded = true });
+
+ await _controller.Login(dto);
+
+ _authServiceMock.Verify(
+ x => x.LoginAsync(It.Is(d =>
+ d.Email == dto.Email &&
+ d.Password == dto.Password &&
+ d.RememberMe == rememberMe)),
+ Times.Once);
}
}
diff --git a/tests/SmartEventBooking.Web.Tests/Controllers/HomeControllerTests.cs b/tests/SmartEventBooking.Web.Tests/Controllers/HomeControllerTests.cs
index 2939a3a..8abd23f 100644
--- a/tests/SmartEventBooking.Web.Tests/Controllers/HomeControllerTests.cs
+++ b/tests/SmartEventBooking.Web.Tests/Controllers/HomeControllerTests.cs
@@ -1,4 +1,5 @@
using FluentAssertions;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging.Abstractions;
using SmartEventBooking.Web.Controllers;
@@ -7,12 +8,19 @@ namespace SmartEventBooking.Web.Tests.Controllers;
public class HomeControllerTests
{
+ private static HomeController CreateController() =>
+ new(NullLogger.Instance)
+ {
+ ControllerContext = new ControllerContext
+ {
+ HttpContext = new DefaultHttpContext()
+ }
+ };
+
[Fact]
public void Index_ReturnsOkResult()
{
- var controller = new HomeController(NullLogger.Instance);
-
- var result = controller.Index();
+ var result = CreateController().Index();
result.Should().BeOfType();
}
@@ -20,10 +28,8 @@ public void Index_ReturnsOkResult()
[Fact]
public void Privacy_ReturnsOkResult()
{
- var controller = new HomeController(NullLogger.Instance);
-
- var result = controller.Privacy();
+ var result = CreateController().Privacy();
result.Should().BeOfType();
}
-}
+}
\ No newline at end of file
diff --git a/tests/SmartEventBooking.Web.Tests/SmartEventBooking.Web.Tests.csproj b/tests/SmartEventBooking.Web.Tests/SmartEventBooking.Web.Tests.csproj
index 70b04bc..a5a993d 100644
--- a/tests/SmartEventBooking.Web.Tests/SmartEventBooking.Web.Tests.csproj
+++ b/tests/SmartEventBooking.Web.Tests/SmartEventBooking.Web.Tests.csproj
@@ -22,6 +22,7 @@
+