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 @@ +