diff --git a/.github/actions/create-env-file/action.yml b/.github/actions/create-env-file/action.yml new file mode 100644 index 00000000..6b56358f --- /dev/null +++ b/.github/actions/create-env-file/action.yml @@ -0,0 +1,32 @@ +name: Create Environment File +description: Create .env file with all necessary variables +runs: + using: composite + steps: + - name: Create .env file + shell: bash + env: + DB_USER: ${{ env.DB_USER }} + DB_PASSWORD: ${{ env.DB_PASSWORD }} + DB_NAME: ${{ env.DB_NAME }} + BACKEND_PORT: ${{ env.BACKEND_PORT }} + JWT_SECRET: ${{ env.JWT_SECRET }} + JWT_EXPIRATION: ${{ env.JWT_EXPIRATION }} + ALLOWED_ORIGINS: ${{ env.ALLOWED_ORIGINS }} + FRONTEND_PORT: ${{ env.FRONTEND_PORT }} + run: | + cat > .env << EOF + DB_USER=${DB_USER} + DB_PASSWORD=${DB_PASSWORD} + DB_NAME=${DB_NAME} + DB_PORT=5432 + DB_HOST=localhost + BACKEND_PORT=${BACKEND_PORT} + NODE_ENV=test + JWT_SECRET=${JWT_SECRET} + JWT_EXPIRATION=${JWT_EXPIRATION} + ALLOWED_ORIGINS=${ALLOWED_ORIGINS} + FRONTEND_PORT=${FRONTEND_PORT} + NEXT_PUBLIC_API_URL=http://localhost:${BACKEND_PORT} + DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@localhost:5432/${DB_NAME} + EOF diff --git a/.github/actions/setup-node-pnpm/action.yml b/.github/actions/setup-node-pnpm/action.yml new file mode 100644 index 00000000..e5bdbb7f --- /dev/null +++ b/.github/actions/setup-node-pnpm/action.yml @@ -0,0 +1,23 @@ +name: Setup Node.js and pnpm +description: Configure Node.js with pnpm and cache +inputs: + node-version: + description: Node.js version + required: false + default: 22.15.0 +runs: + using: composite + steps: + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js with cache + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + shell: bash + run: pnpm ci:install diff --git a/.github/actions/setup-services/action.yml b/.github/actions/setup-services/action.yml new file mode 100644 index 00000000..37049149 --- /dev/null +++ b/.github/actions/setup-services/action.yml @@ -0,0 +1,21 @@ +name: Setup Database and Environment +description: Configure database and create .env file +runs: + using: composite + steps: + - name: Wait for PostgreSQL + shell: bash + run: | + echo "Waiting for PostgreSQL to be ready..." + for i in {1..30}; do + if pg_isready -h localhost -p 5432 -U ${{ env.DB_USER }}; then + echo "✅ PostgreSQL is ready!" + break + fi + echo "Waiting for PostgreSQL... ($i/30)" + sleep 2 + done + + - name: Setup database + shell: bash + run: pnpm db:setup diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da5bd4ef..3f04fda5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,20 @@ on: - dev - branche-de-france +# 🎯 VARIABLES GLOBALES : disponibles dans tous les jobs +env: + # Versions des outils + NODE_VERSION: 22.15.0 + + # Configuration de l'environnement + NODE_ENV: test + DB_PORT: 5432 + DB_HOST: localhost + + # Timeouts (en minutes) + SERVICE_TIMEOUT: 60 + PLAYWRIGHT_TIMEOUT: 60 + jobs: lint: runs-on: ubuntu-latest @@ -21,199 +35,460 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm with: - node-version: 22.15.0 - - - name: Setup pnpm - run: npm install -g pnpm@10.11.0 - - - name: Install dependencies - run: pnpm install + node-version: ${{ env.NODE_VERSION }} + # pnpm-version omis volontairement pour auto-détection - name: Run ESLint run: pnpm lint typecheck: - needs: lint runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm with: - node-version: 22.15.0 - - - name: Setup pnpm - run: npm install -g pnpm@10.11.0 + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} - - name: Install dependencies - run: pnpm install + - name: Build packages for typecheck + run: pnpm ci:prebuild && pnpm ci:build - name: Run TypeScript typecheck - run: pnpm -r run typecheck + run: pnpm typecheck build: - needs: typecheck + needs: [lint, typecheck] runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm with: - node-version: 22.15.0 + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} - - name: Setup pnpm - run: npm install -g pnpm@10.11.0 - - - name: Install dependencies - run: pnpm install - - - name: Build packages + - name: Build all packages env: NODE_ENV: production CI: true - run: pnpm -r build + run: pnpm ci:build - lighthouse: - name: Lighthouse Accessibility Audit + tests: needs: build runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + + - name: Run unit tests + working-directory: packages/shared + run: pnpm test + + playwright: + needs: tests + timeout-minutes: 60 + runs-on: ubuntu-latest + + env: + DB_USER: ${{ secrets.DB_USER }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + DB_NAME: ${{ secrets.DB_NAME }} + BACKEND_PORT: ${{ secrets.BACKEND_PORT }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} + JWT_EXPIRATION: ${{ secrets.JWT_EXPIRATION }} + ALLOWED_ORIGINS: ${{ secrets.ALLOWED_ORIGINS }} + FRONTEND_PORT: ${{ secrets.FRONTEND_PORT }} + # Variables calculées + DATABASE_URL: postgresql://${{ secrets.DB_USER }}:${{ secrets.DB_PASSWORD }}@localhost:5432/${{ secrets.DB_NAME }} + NEXT_PUBLIC_API_URL: http://localhost:${{ secrets.BACKEND_PORT }} + + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: ${{ secrets.DB_PASSWORD }} + POSTGRES_USER: ${{ secrets.DB_USER }} + POSTGRES_DB: ${{ secrets.DB_NAME }} + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm with: - node-version: 22.15.0 + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y lsof + + - name: Install Playwright Browsers + run: pnpm --filter gazette_backend exec playwright install --with-deps + + - name: Create environment file + uses: ./.github/actions/create-env-file + + - name: Setup services + uses: ./.github/actions/setup-services - - name: Setup pnpm - run: npm install -g pnpm@10.11.0 + - name: Build applications + run: pnpm ci:build - - name: Install dependencies + - name: Start Backend run: | - echo "Installing dependencies..." - pnpm install - echo "Building shared packages..." - pnpm --filter @gazette/shared build + echo "Starting backend..." + cd apps/backend + + # Copy .env file to backend directory + cp ../../.env ./.env + + # Verify environment variables (sans exposer les secrets) + echo "Verifying environment variables..." + echo "DB_NAME: $DB_NAME" + echo "DB_HOST: ${{ env.DB_HOST }}" + echo "DB_PORT: ${{ env.DB_PORT }}" + + # Start the backend with explicit environment variables + NODE_ENV=${{ env.NODE_ENV }} \ + DB_NAME=$DB_NAME \ + DB_USER=$DB_USER \ + DB_PASSWORD=$DB_PASSWORD \ + DB_HOST=${{ env.DB_HOST }} \ + DB_PORT=${{ env.DB_PORT }} \ + BACKEND_PORT=$BACKEND_PORT \ + JWT_SECRET=$JWT_SECRET \ + JWT_EXPIRATION=$JWT_EXPIRATION \ + ALLOWED_ORIGINS=$ALLOWED_ORIGINS \ + DATABASE_URL=$DATABASE_URL \ + nohup pnpm start > ../../backend.log 2>&1 & + echo $! > ../../backend.pid + + # Give it a moment to start + sleep 2 + + # Check if process is still running + if ! ps -p $(cat ../../backend.pid) > /dev/null; then + echo "❌ Backend process failed to start" + echo "Backend logs:" + cat ../../backend.log + exit 1 + fi + + echo "Waiting for backend to be ready..." + for i in {1..60}; do + if curl -f http://localhost:$BACKEND_PORT/health 2>/dev/null; then + echo "✅ Backend is ready!" + break + elif [ $i -eq 60 ]; then + echo "❌ Backend failed to start within 60 attempts" + echo "Backend logs:" + cat ../../backend.log + # Also show running processes for debugging + echo "Process status:" + ps aux | grep node + exit 1 + fi + echo "Waiting for backend... ($i/60)" + + # Check if process died while waiting + if ! ps -p $(cat ../../backend.pid) > /dev/null; then + echo "❌ Backend process died while waiting" + echo "Backend logs:" + cat ../../backend.log + exit 1 + fi - - name: Create .env file for CI + sleep 2 + done + + - name: Start Frontend run: | - cat > .env << EOF - DB_USER=${{ secrets.DB_USER }} - DB_PASSWORD=${{ secrets.DB_PASSWORD }} - DB_NAME=${{ secrets.DB_NAME }} - DB_PORT=${{ secrets.DB_PORT }} - BACKEND_PORT=${{ secrets.BACKEND_PORT }} - NODE_ENV=test - JWT_SECRET=${{ secrets.JWT_SECRET }} - JWT_EXPIRATION=${{ secrets.JWT_EXPIRATION }} - ALLOWED_ORIGINS=${{ secrets.ALLOWED_ORIGINS }} - FRONTEND_PORT=${{ secrets.FRONTEND_PORT }} - NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }} - EOF - echo "Created .env file (values hidden for security)" - echo "Checking if all required secrets are set..." - if [ -z "${{ secrets.DB_USER }}" ] || [ -z "${{ secrets.BACKEND_PORT }}" ] || [ -z "${{ secrets.FRONTEND_PORT }}" ]; then - echo "❌ Missing required secrets!" + echo "Starting frontend..." + + # Check if port is in use and kill the process if it exists + echo "Checking if port $FRONTEND_PORT is in use..." + if lsof -i :$FRONTEND_PORT; then + echo "Port $FRONTEND_PORT is in use. Attempting to free it..." + sudo fuser -k $FRONTEND_PORT/tcp || true + sleep 2 + fi + + cd apps/frontend + + # Copy .env file to frontend directory + cp ../../.env ./.env + + # Verify environment variables (sans exposer les secrets) + echo "Verifying environment variables..." + echo "FRONTEND_PORT: $FRONTEND_PORT" + echo "NEXT_PUBLIC_API_URL: $NEXT_PUBLIC_API_URL" + + # Start the frontend with explicit environment variables and port + FRONTEND_PORT=$FRONTEND_PORT \ + NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ + NODE_ENV=${{ env.NODE_ENV }} \ + nohup pnpm start -p $FRONTEND_PORT > ../../frontend.log 2>&1 & + echo $! > ../../frontend.pid + + # Give it a moment to start + sleep 2 + + # Check if process is still running + if ! ps -p $(cat ../../frontend.pid) > /dev/null; then + echo "❌ Frontend process failed to start" + echo "Frontend logs:" + cat ../../frontend.log exit 1 fi - echo "✅ All required secrets are set" - - name: Start Docker services + echo "Waiting for frontend to be ready..." + for i in {1..60}; do + if curl -f http://localhost:$FRONTEND_PORT 2>/dev/null; then + echo "✅ Frontend is ready!" + break + elif [ $i -eq 60 ]; then + echo "❌ Frontend failed to start within 60 attempts" + echo "Frontend logs:" + cat ../../frontend.log + # Also show running processes for debugging + echo "Process status:" + ps aux | grep node + exit 1 + fi + echo "Waiting for frontend... ($i/60)" + + # Check if process died while waiting + if ! ps -p $(cat ../../frontend.pid) > /dev/null; then + echo "❌ Frontend process died while waiting" + echo "Frontend logs:" + cat ../../frontend.log + exit 1 + fi + + sleep 3 + done + + - name: Verify services are accessible + run: | + echo "=== Final health checks ===" + echo "Backend health check:" + curl -f http://localhost:$BACKEND_PORT/health || exit 1 + + echo "Frontend accessibility:" + curl -f http://localhost:$FRONTEND_PORT || exit 1 + + echo "✅ All services are ready for testing!" + + - name: Run Playwright tests + env: + PLAYWRIGHT_BASE_URL: http://localhost:${{ env.FRONTEND_PORT }} + API_URL: http://localhost:${{ env.BACKEND_PORT }} + run: | + echo "Running Playwright tests..." + pnpm test:e2e --reporter=html + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: ./apps/backend/playwright-report/ + retention-days: 30 + + - name: Show logs on failure + if: failure() + run: | + echo "=== Backend logs ===" + cat backend.log 2>/dev/null || echo "No backend logs found" + echo "=== Frontend logs ===" + cat frontend.log 2>/dev/null || echo "No frontend logs found" + + - name: Cleanup + if: always() + run: | + echo "Cleaning up processes..." + [ -f backend.pid ] && kill $(cat backend.pid) 2>/dev/null || echo "No backend process to kill" + [ -f frontend.pid ] && kill $(cat frontend.pid) 2>/dev/null || echo "No frontend process to kill" + + lighthouse: + name: Lighthouse Accessibility Audit + needs: playwright + runs-on: ubuntu-latest + + env: + DB_USER: ${{ secrets.DB_USER }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + DB_NAME: ${{ secrets.DB_NAME }} + BACKEND_PORT: ${{ secrets.BACKEND_PORT }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} + JWT_EXPIRATION: ${{ secrets.JWT_EXPIRATION }} + ALLOWED_ORIGINS: ${{ secrets.ALLOWED_ORIGINS }} + FRONTEND_PORT: ${{ secrets.FRONTEND_PORT }} + DATABASE_URL: postgresql://${{ secrets.DB_USER }}:${{ secrets.DB_PASSWORD }}@localhost:5432/${{ secrets.DB_NAME }} + NEXT_PUBLIC_API_URL: http://localhost:${{ secrets.BACKEND_PORT }} + + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: ${{ secrets.DB_PASSWORD }} + POSTGRES_USER: ${{ secrets.DB_USER }} + POSTGRES_DB: ${{ secrets.DB_NAME }} + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Create environment file + uses: ./.github/actions/create-env-file + + - name: Setup services + uses: ./.github/actions/setup-services + + - name: Build and start applications run: | - echo "=== Starting Docker services ===" - echo "Stopping any existing containers..." - docker compose down --volumes --remove-orphans - - echo "Removing any existing images..." - docker compose rm -f - docker rmi gazette-frontend gazette-backend || echo "No images to remove" - - echo "Building and starting all services..." - docker compose up --build -d - - echo "=== Waiting for services to start ===" - echo "Waiting 60 seconds for services to fully start..." - sleep 60 - - echo "=== Container Status Check ===" - echo "All containers status:" - docker compose ps -a - - echo "=== Container Health Check ===" - echo "Checking if containers are running..." - if ! docker compose ps --status running | grep -q "frontend\|backend"; then - echo "❌ Some containers are not running!" - echo "Container logs:" - docker compose logs --tail=50 - echo "❌ Exiting due to container startup failure" + # Build all packages + pnpm ci:build + + # Start backend with proper environment + cd apps/backend + echo "Starting backend from $(pwd)" + NODE_ENV=${{ env.NODE_ENV }} \ + DB_NAME=$DB_NAME \ + DB_USER=$DB_USER \ + DB_PASSWORD=$DB_PASSWORD \ + DB_HOST=${{ env.DB_HOST }} \ + DB_PORT=${{ env.DB_PORT }} \ + BACKEND_PORT=$BACKEND_PORT \ + JWT_SECRET=$JWT_SECRET \ + JWT_EXPIRATION=$JWT_EXPIRATION \ + ALLOWED_ORIGINS=$ALLOWED_ORIGINS \ + DATABASE_URL=$DATABASE_URL \ + nohup pnpm start > ../../backend.log 2>&1 & + BACKEND_PID=$! + echo $BACKEND_PID > ../../backend.pid + + # Give backend a moment to start + sleep 5 + + # Check if backend process is running + if ! ps -p $BACKEND_PID > /dev/null; then + echo "❌ Backend process failed to start" + echo "Backend logs:" + cat ../../backend.log exit 1 fi - echo "✅ All containers are running!" - - echo "=== Network Configuration ===" - echo "Docker networks:" - docker network ls - echo "Gazette network details:" - docker network inspect gazette_gazette-network || echo "Network not found" - - echo "=== Port Mappings ===" - echo "Frontend port mapping:" - docker compose port frontend ${{ secrets.FRONTEND_PORT }} || echo "❌ Frontend port mapping failed" - echo "Backend port mapping:" - docker compose port backend ${{ secrets.BACKEND_PORT }} || echo "❌ Backend port mapping failed" - - echo "=== Service Accessibility ===" - echo "Testing backend accessibility..." - for i in {1..15}; do - echo "Attempt $i/15 to connect to backend on port ${{ secrets.BACKEND_PORT }}..." - if curl -s http://localhost:${{ secrets.BACKEND_PORT }}/contents > /dev/null 2>&1; then - echo "✅ Backend is accessible!" + # Start frontend with proper environment + cd ../frontend + echo "Starting frontend from $(pwd)" + FRONTEND_PORT=$FRONTEND_PORT \ + NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ + NODE_ENV=${{ env.NODE_ENV }} \ + nohup pnpm start -p $FRONTEND_PORT > ../../frontend.log 2>&1 & + FRONTEND_PID=$! + echo $FRONTEND_PID > ../../frontend.pid + + cd ../.. + echo "Current directory: $(pwd)" + + # Wait for services with better error handling + echo "Waiting for services to start..." + + echo "Checking backend..." + for i in {1..30}; do + if curl -f http://localhost:$BACKEND_PORT/health 2>/dev/null; then + echo "✅ Backend is ready!" break + elif [ $i -eq 30 ]; then + echo "❌ Backend failed to start within 30 attempts" + echo "Backend logs:" + cat backend.log || echo "No backend log file found" + echo "Process status:" + ps aux | grep node + exit 1 fi - if [ $i -eq 15 ]; then - echo "❌ Backend is not accessible after 15 attempts" + echo "Waiting for backend... ($i/30)" + + # Check if process died + if ! ps -p $(cat backend.pid) > /dev/null; then + echo "❌ Backend process died" echo "Backend logs:" - docker compose logs backend --tail=30 + cat backend.log || echo "No backend log file found" exit 1 fi - sleep 5 + + sleep 2 done - echo "Testing frontend accessibility..." - for i in {1..15}; do - echo "Attempt $i/15 to connect to frontend on port ${{ secrets.FRONTEND_PORT }}..." - if curl -s http://localhost:${{ secrets.FRONTEND_PORT }}/ > /dev/null 2>&1; then - echo "✅ Frontend is accessible!" + echo "Checking frontend..." + for i in {1..30}; do + if curl -f http://localhost:$FRONTEND_PORT 2>/dev/null; then + echo "✅ Frontend is ready!" break + elif [ $i -eq 30 ]; then + echo "❌ Frontend failed to start within 30 attempts" + echo "Frontend logs:" + cat frontend.log || echo "No frontend log file found" + echo "Process status:" + ps aux | grep node + exit 1 fi - if [ $i -eq 15 ]; then - echo "❌ Frontend is not accessible after 15 attempts" + echo "Waiting for frontend... ($i/30)" + + # Check if process died + if ! ps -p $(cat frontend.pid) > /dev/null; then + echo "❌ Frontend process died" echo "Frontend logs:" - docker compose logs frontend --tail=30 + cat frontend.log || echo "No frontend log file found" exit 1 fi - sleep 5 + + sleep 2 done - echo "✅ All services are accessible!" + echo "✅ All services ready for Lighthouse!" + + # Show running processes for verification + echo "Running processes:" + ps aux | grep node - name: Run Lighthouse CI - env: - FRONTEND_PORT: ${{ secrets.FRONTEND_PORT }} run: | - echo "Installing Lighthouse CLI..." npm install -g @lhci/cli@0.15.x - - echo "Running Lighthouse with localhost..." - lhci collect --url=http://localhost:${{ secrets.FRONTEND_PORT }}/ + lhci collect --url=http://localhost:$FRONTEND_PORT/ lhci upload --target=temporary-public-storage diff --git a/.gitignore b/.gitignore index 532fd889..5dcf0ae0 100644 --- a/.gitignore +++ b/.gitignore @@ -114,7 +114,6 @@ apps/*/pnpm-lock.yaml packages/*/pnpm-lock.yaml # Test files -**/*.spec.ts **/*.e2e-spec.ts **/jest-e2e.json diff --git a/.vscode/settings.json b/.vscode/settings.json index 59231cba..148ed133 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -43,5 +43,9 @@ "scss", "pcss", "postcss" - ] + ], + "remote.extensionKind": { + "adpyke.codesnap": ["ui"], + "dbaeumer.vscode-eslint": ["workspace"] + } } diff --git a/apps/backend/.gitignore b/apps/backend/.gitignore new file mode 100644 index 00000000..58786aac --- /dev/null +++ b/apps/backend/.gitignore @@ -0,0 +1,7 @@ + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/apps/backend/jest.config.js b/apps/backend/jest.config.js new file mode 100644 index 00000000..5d7395a2 --- /dev/null +++ b/apps/backend/jest.config.js @@ -0,0 +1,26 @@ +export default { + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: '.', + moduleFileExtensions: ['js', 'json', 'ts'], + transform: { + '^.+\\.(t|j)s$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.jest.json' }], + }, + collectCoverageFrom: ['**/*.(t|j)s'], + coverageDirectory: '../coverage', + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + '^@/(.*)': '', + '^~/(.*)$': '/../$1', + }, + testMatch: [ + '**/?(*.)+(spec|test).[tj]s?(x)', + 'src/**/?(*.)+(spec|test).[tj]s?(x)', + ], + transformIgnorePatterns: [ + 'node_modules/(?!(.*\\.mjs$))', + ], + extensionsToTreatAsEsm: ['.ts'], + verbose: true, + detectOpenHandles: true, +} diff --git a/apps/backend/package.json b/apps/backend/package.json index ede6a223..cd8cb65a 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -7,12 +7,21 @@ "license": "UNLICENSED", "scripts": { "build": "nest build", - "start": "nest start", + "start": "node dist/src/main.js", + "start:dev": "nest start", "dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/src/main.js", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "test": "jest --config jest.config.ts", + "test": "jest --config jest.config.js", + "test:e2e": "playwright test", + "typecheck": "tsc --noEmit", + "migrate": "mikro-orm migration:up", + "migrate:create": "mikro-orm migration:create", + "migrate:down": "mikro-orm migration:down", + "migrate:list": "mikro-orm migration:list", + "migrate:pending": "mikro-orm migration:pending", + "migrate:fresh": "pnpm schema:drop && pnpm migrate", "schema:drop": "mikro-orm schema:drop --run", "schema:update": "mikro-orm schema:update --run", "schema:fresh": "pnpm schema:drop && pnpm schema:update", @@ -22,7 +31,9 @@ "migration:list": "mikro-orm migration:list", "migration:pending": "mikro-orm migration:pending", "seeder:run": "mikro-orm seeder:run", - "seeder:create": "mikro-orm seeder:create" + "seeder:create": "mikro-orm seeder:create", + "db:setup": "pnpm migrate", + "clean": "rimraf dist coverage *.log" }, "dependencies": { "@gazette/shared": "workspace:*", @@ -48,22 +59,27 @@ "pino-pretty": "^13.0.0", "reflect-metadata": "^0.2.2", "rss-parser": "^3.13.0", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "zod": "^4.1.4" }, "devDependencies": { "@eslint/js": "^9.18.0", + "@faker-js/faker": "^9.9.0", "@mikro-orm/cli": "^6.4.15", "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", + "@playwright/test": "^1.55.0", "@swc/cli": "^0.6.0", "@swc/core": "^1.10.7", "@types/cookie-parser": "^1.4.9", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", + "@types/node": "^20.19.1", "@types/supertest": "^6.0.2", "globals": "^16.0.0", "jest": "^29.7.0", + "nock": "^14.0.10", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.4.0", @@ -73,23 +89,6 @@ "typescript-eslint": "^8.20.0", "vitest": "^3.1.4" }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" - }, "mikro-orm": { "useTsNode": true, "configPaths": [ diff --git a/apps/backend/playwright.config.ts b/apps/backend/playwright.config.ts new file mode 100644 index 00000000..b0db36b2 --- /dev/null +++ b/apps/backend/playwright.config.ts @@ -0,0 +1,80 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + timeout: 30 * 1000, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3002', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://localhost:3000', + // reuseExistingServer: !process.env.CI, + // }, +}) diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 23e51256..dee4d845 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -8,6 +8,7 @@ import { ScheduleModule } from '@nestjs/schedule' import { LoggerModule } from 'nestjs-pino' import { AppController } from './app.controller' import { AppService } from './app.service' +import { validateEnv } from './config/env.validation' import { AuthModule } from './modules/auth/auth.module' import { ContentModule } from './modules/content/content.module' import { JobService } from './modules/job/job.service' @@ -22,9 +23,7 @@ import { UsersModule } from './modules/user/user.module' imports: [ ConfigModule.forRoot({ isGlobal: true, - validate: (config: Record) => { - return config - }, + validate: validateEnv, }), LoggerModule.forRoot({ pinoHttp: { diff --git a/apps/backend/src/config/env.validation.ts b/apps/backend/src/config/env.validation.ts new file mode 100644 index 00000000..426c1ac9 --- /dev/null +++ b/apps/backend/src/config/env.validation.ts @@ -0,0 +1,25 @@ +import { z } from 'zod' + +export const envSchema = z.object({ + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + DB_HOST: z.string().min(1), + DB_PORT: z.string().transform(Number).default(5432), + DB_USER: z.string().min(1), + DB_PASSWORD: z.string().min(1), + DB_NAME: z.string().min(1), + BACKEND_PORT: z.string().transform(Number).default(3000), + JWT_SECRET: z.string().min(1), + JWT_EXPIRATION: z.string().min(1), + ALLOWED_ORIGINS: z.string().min(1), +}) + +export type EnvConfig = z.infer + +export function validateEnv(config: Record) { + const result = envSchema.safeParse(config) + if (!result.success) { + console.error('❌ Invalid environment variables:', result.error.format()) + throw new Error('Invalid environment configuration') + } + return result.data +} diff --git a/apps/backend/src/config/rss-sources.ts b/apps/backend/src/config/rss-sources.ts index 55f878a3..599966ef 100644 --- a/apps/backend/src/config/rss-sources.ts +++ b/apps/backend/src/config/rss-sources.ts @@ -17,6 +17,18 @@ export const RSS_SOURCES = { description: 'Média d\'investigation', picture: 'https://www.blast-info.fr/assets/images/logo-blast.png', }, + invisibleoranges: { + name: 'Invisible Oranges', + url: 'https://www.invisibleoranges.com/feed/', + description: 'Blog de critique musicale spécialisé dans le metal', + picture: 'https://media.invisibleoranges.com/qcerjudxrp/uploads/2023/06/21/io-logo-header.png', + }, + metalorgie: { + name: 'Metalorgie', + url: 'https://www.metalorgie.com/feed/news', + description: 'Webzine de critique musicale spécialisé dans le metal', + picture: 'https://www.metalorgie.com/images/v5/logo-red-background.png', + }, } as const export type RssSourceKey = keyof typeof RSS_SOURCES diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 0366cb46..ac76a389 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -1,6 +1,7 @@ import { NestFactory } from '@nestjs/core' import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' import * as cookieParser from 'cookie-parser' +import { Request, Response } from 'express' import { Logger } from 'nestjs-pino' import { AppModule } from './app.module' @@ -15,6 +16,8 @@ async function bootstrap() { exposedHeaders: 'Set-Cookie', }, }) + + // Configuration Swagger const config = new DocumentBuilder() .setTitle('Gazette API') .setDescription('API pour l\'application Gazette') @@ -27,6 +30,24 @@ async function bootstrap() { app.useLogger(app.get(Logger)) app.use(cookieParser()) - await app.listen(process.env.PORT ?? 3000) + // Endpoint de health check simple + const expressApp = app.getHttpAdapter().getInstance() + expressApp.get('/health', (req: Request, res: Response) => { + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + environment: process.env.NODE_ENV || 'development', + port: process.env.PORT ?? 3000, + }) + }) + + await app.listen(process.env.PORT ?? 3000, '0.0.0.0') + + const logger = app.get(Logger) + logger.log(`🚀 Application is running on: http://localhost:${process.env.PORT ?? 3000}`) + logger.log(`📚 Swagger documentation: http://localhost:${process.env.PORT ?? 3000}/api-docs`) + logger.log(`💓 Health check: http://localhost:${process.env.PORT ?? 3000}/health`) } + bootstrap() diff --git a/apps/backend/src/modules/job/job.service.ts b/apps/backend/src/modules/job/job.service.ts index 76d91a09..ddaab96a 100644 --- a/apps/backend/src/modules/job/job.service.ts +++ b/apps/backend/src/modules/job/job.service.ts @@ -8,7 +8,7 @@ export class JobService { private readonly contentService: ContentService, ) {} - @Cron('0 0 * * *') // toutes les 24 heures (minuit) + @Cron('0 0 * * *') async handleCron() { console.warn('[CRON] Lancement de la synchronisation des flux RSS...') diff --git a/apps/backend/src/modules/media/media.controller.spec.ts b/apps/backend/src/modules/media/media.controller.spec.ts new file mode 100644 index 00000000..13359116 --- /dev/null +++ b/apps/backend/src/modules/media/media.controller.spec.ts @@ -0,0 +1,55 @@ +import { faker } from '@faker-js/faker/.' +import { Collection } from '@mikro-orm/core' +import { Test } from '@nestjs/testing' +import { Subscription } from '@/entities/subscription.entity' +import { Media } from '../../entities/media.entity' +import { MediaController } from './media.controlller' +import { MediaService } from './media.service' + +describe('mediaController', () => { + let mediaController: MediaController + let mediaService: MediaService + + const createMockMedia = (): Media => ({ + id: faker.string.uuid(), + name: faker.company.name(), + description: faker.company.catchPhrase(), + picture: faker.image.urlLoremFlickr({ category: 'business' }), + urlRss: faker.internet.url(), + createdAt: new Date(), + subscribers: new Collection(this), + }) + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [MediaController], + providers: [ + { + provide: MediaService, + useValue: { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + }, + }, + ], + }).compile() + + mediaService = moduleRef.get(MediaService) + mediaController = moduleRef.get(MediaController) + }) + + describe('findAll', () => { + it('should return an array of medias', async () => { + const mockMedias: Media[] = Array.from({ length: 5 }, () => createMockMedia()) + + jest.spyOn(mediaService, 'findAll').mockResolvedValue(mockMedias) + + const result = await mediaController.findAll() + + expect(result).toEqual(mockMedias) + expect(result).toHaveLength(5) + expect(mediaService.findAll).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/apps/backend/src/modules/media/media.service.ts b/apps/backend/src/modules/media/media.service.ts index cc107e82..53e7b6b5 100644 --- a/apps/backend/src/modules/media/media.service.ts +++ b/apps/backend/src/modules/media/media.service.ts @@ -1,7 +1,7 @@ import { EntityManager } from '@mikro-orm/core' import { Injectable } from '@nestjs/common' -import { Media } from 'src/entities/media.entity' import { RSS_SOURCES, RssSourceKey } from '../../config/rss-sources' +import { Media } from '../../entities/media.entity' @Injectable() export class MediaService { diff --git a/apps/backend/src/modules/rss/feeds/generic-rss.feed.ts b/apps/backend/src/modules/rss/feeds/generic-rss.feed.ts index 3ec1679c..54f3e877 100644 --- a/apps/backend/src/modules/rss/feeds/generic-rss.feed.ts +++ b/apps/backend/src/modules/rss/feeds/generic-rss.feed.ts @@ -21,9 +21,9 @@ export function createGenericRssFeed(config: GenericRssConfig): FeedSource { const feed = await parser.parseURL(this.url) return feed.items.map(item => ({ - title: config.titleCleaner ? config.titleCleaner(item.title) : (item.title), - link: item.link, - pubDate: item.pubDate, + title: config.titleCleaner ? config.titleCleaner(item.title || '') : (item.title || ''), + link: item.link || '', + pubDate: item.pubDate || '', description: item.contentSnippet || item.content, source: config.sourceKey, logo: feed.image?.url || sourceConfig.picture, diff --git a/apps/backend/src/modules/rss/feeds/index.ts b/apps/backend/src/modules/rss/feeds/index.ts index 278257a3..e582a1ac 100644 --- a/apps/backend/src/modules/rss/feeds/index.ts +++ b/apps/backend/src/modules/rss/feeds/index.ts @@ -16,8 +16,18 @@ export const BlastFeed: FeedSource = createGenericRssFeed({ sourceKey: 'blast', }) +export const InvisibleOrangesFeed: FeedSource = createGenericRssFeed({ + sourceKey: 'invisibleoranges', +}) + +export const MetalorgieFeed: FeedSource = createGenericRssFeed({ + sourceKey: 'metalorgie', +}) + export const RSS_FEEDS = { bondyblog: BondyBlogFeed, arretsurimage: ArretSurImageFeed, blast: BlastFeed, + invisibleoranges: InvisibleOrangesFeed, + metalorgie: MetalorgieFeed, } diff --git a/apps/backend/src/modules/rss/rss.controller.ts b/apps/backend/src/modules/rss/rss.controller.ts index 53930463..4fabecbe 100644 --- a/apps/backend/src/modules/rss/rss.controller.ts +++ b/apps/backend/src/modules/rss/rss.controller.ts @@ -25,4 +25,14 @@ export class RssController { async getBlastFeed(): Promise { return this.rssService.fetchBlastFeed() } + + @Get('invisibleoranges') + async getInvisibleOrangesFeed(): Promise { + return this.rssService.fetchInvisibleOrangesFeed() + } + + @Get('metalorgie') + async getMetalorgieFeed(): Promise { + return this.rssService.fetchMetalorgieFeed() + } } diff --git a/apps/backend/src/modules/rss/rss.service.ts b/apps/backend/src/modules/rss/rss.service.ts index 6e5f7d0b..3130a7bc 100644 --- a/apps/backend/src/modules/rss/rss.service.ts +++ b/apps/backend/src/modules/rss/rss.service.ts @@ -22,4 +22,12 @@ export class RssService { async fetchBlastFeed(): Promise { return RSS_FEEDS.blast.fetch() } + + async fetchInvisibleOrangesFeed(): Promise { + return RSS_FEEDS.invisibleoranges.fetch() + } + + async fetchMetalorgieFeed(): Promise { + return RSS_FEEDS.metalorgie.fetch() + } } diff --git a/apps/backend/tests/e2e/auth.e2e.spec.ts b/apps/backend/tests/e2e/auth.e2e.spec.ts new file mode 100644 index 00000000..44813bcd --- /dev/null +++ b/apps/backend/tests/e2e/auth.e2e.spec.ts @@ -0,0 +1,93 @@ +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' + +test.describe('Authentication E2E', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:3002/') + }) + + test('should register a new user', async ({ page }) => { + const testUser = { + pseudo: faker.person.firstName(), + email: faker.internet.email(), + password: 'Password(123)', + } + + await page.click('a[href="/signin"]') + await expect(page).toHaveURL('http://localhost:3002/signin') + + await page.fill('[data-testid="pseudo-input"]', testUser.pseudo) + await page.fill('[data-testid="email-input"]', testUser.email) + await page.fill('[data-testid="password-input"]', testUser.password) + await page.fill('[data-testid="confirm-password-input"]', testUser.password) + + const navigationPromise = page.waitForURL('http://localhost:3002/explore') + await page.click('[data-testid="submit-button"]') + await navigationPromise + + await expect(page).toHaveURL('http://localhost:3002/explore') + }) + + test('should login with existing user', async ({ page }) => { + // Create the test user first + const existingUser = { + pseudo: 'testuser', + email: 'test@example.com', + password: 'Password(123)', + } + + // Register the test user + await page.goto('http://localhost:3002/signin') + await page.fill('[data-testid="pseudo-input"]', existingUser.pseudo) + await page.fill('[data-testid="email-input"]', existingUser.email) + await page.fill('[data-testid="password-input"]', existingUser.password) + await page.fill('[data-testid="confirm-password-input"]', existingUser.password) + await page.click('[data-testid="submit-button"]') + + // Wait for registration to complete and redirect + await page.waitForURL('http://localhost:3002/explore') + + // Now try to log out + await page.click('a[href="/settings"]') + await page.click('[data-testid="logout-link"]') + await page.waitForURL('http://localhost:3002/') + + // Now proceed with the login test + await page.click('a[href="/login"]') + await expect(page).toHaveURL('http://localhost:3002/login') + + await page.fill('[data-testid="email-input"]', existingUser.email) + await page.fill('[data-testid="password-input"]', existingUser.password) + + const navigationPromise = page.waitForURL('http://localhost:3002/explore') + await page.click('[data-testid="submit-button"]') + await navigationPromise + + await expect(page).toHaveURL('http://localhost:3002/explore') + }) + + test('should logout user', async ({ page }) => { + // Create the test user first + const existingUser = { + pseudo: 'testuser2', // Using a different user to avoid conflicts + email: 'test2@example.com', + password: 'Password(123)', + } + + // Register the test user + await page.goto('http://localhost:3002/signin') + await page.fill('[data-testid="pseudo-input"]', existingUser.pseudo) + await page.fill('[data-testid="email-input"]', existingUser.email) + await page.fill('[data-testid="password-input"]', existingUser.password) + await page.fill('[data-testid="confirm-password-input"]', existingUser.password) + await page.click('[data-testid="submit-button"]') + await page.waitForURL('http://localhost:3002/explore') + + await page.click('a[href="/settings"]') + const navigationPromise = page.waitForURL('http://localhost:3002/') + await page.click('[data-testid="logout-link"]') + await navigationPromise + + await expect(page).toHaveURL('http://localhost:3002/') + }) +}) diff --git a/apps/backend/tsconfig.jest.json b/apps/backend/tsconfig.jest.json new file mode 100644 index 00000000..52499654 --- /dev/null +++ b/apps/backend/tsconfig.jest.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "incremental": false, + "target": "ES2021", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "baseUrl": "../", + "module": "commonjs", + "paths": { + "@/*": ["src/*"], + "@shared/*": ["../../packages/shared/*"] + }, + "types": ["jest", "node"], + "esModuleInterop": true, + "isolatedModules": true + }, + "include": ["src"] +} diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index c0e7a85a..9fbbd829 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -10,6 +10,7 @@ "@/*": ["src/*"] }, "resolveJsonModule": true, + "strict": true, "strictBindCallApply": false, "strictNullChecks": false, "noFallthroughCasesInSwitch": false, diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 1b62f697..b9114122 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -6,7 +6,8 @@ "dev": "next dev -p 3002", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "clean": "rimraf .next out dist *.log" }, "dependencies": { "@chakra-ui/next-js": "^2.2.0", @@ -27,7 +28,7 @@ "react-hook-form": "^7.50.0", "react-i18next": "^14.0.0", "react-icons": "^5.0.1", - "zod": "^3.25.28" + "zod": "^4.1.4" }, "devDependencies": { "@types/node": "^20.11.16", diff --git a/apps/frontend/src/components/custom/FormLogin.tsx b/apps/frontend/src/components/custom/FormLogin.tsx index 75c595e9..3f2ba6f8 100644 --- a/apps/frontend/src/components/custom/FormLogin.tsx +++ b/apps/frontend/src/components/custom/FormLogin.tsx @@ -19,14 +19,12 @@ function FormLogin() { const toast = useToast() const { login, loading: authLoading } = useAuth() - const LoginSchema = LogUserSchema - const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ - resolver: zodResolver(LoginSchema), + resolver: zodResolver(LogUserSchema), defaultValues: { email: '', password: '', @@ -36,7 +34,7 @@ function FormLogin() { const onSubmit = async (data: LoginUserDto) => { try { await login(data.email, data.password) - router.push('/explore') + router.replace('/explore') } catch (error) { console.error('login error:', error) @@ -72,6 +70,7 @@ function FormLogin() { rounded="md" shadow="md" variant="flushed" + data-testid="email-input" {...register('email', { required: t('requiredField') })} /> {errors.email?.message} @@ -84,6 +83,7 @@ function FormLogin() { rounded="md" shadow="md" variant="flushed" + data-testid="password-input" {...register('password', { required: t('requiredField') })} /> {errors.password?.message} @@ -98,6 +98,7 @@ function FormLogin() { fontColor="color.white" backgroundColor="color.chaletGreen" text={t('login')} + data-testid="submit-button" isLoading={isSubmitting || isLoading} disabled={isLoading} /> diff --git a/apps/frontend/src/components/custom/FormSignUp.tsx b/apps/frontend/src/components/custom/FormSignUp.tsx index 407abdc8..444a9c2e 100644 --- a/apps/frontend/src/components/custom/FormSignUp.tsx +++ b/apps/frontend/src/components/custom/FormSignUp.tsx @@ -49,7 +49,6 @@ function FormSignUp() { }) await login(data.email, data.password) - toast({ title: t('success'), description: t('confirmCreation'), @@ -57,8 +56,8 @@ function FormSignUp() { duration: 3000, isClosable: true, }) - - setIsWelcomeModalOpen(true) + // setIsWelcomeModalOpen(true) + router.replace('/explore') } catch (error) { console.error(error) @@ -94,6 +93,7 @@ function FormSignUp() { rounded="md" shadow="md" variant="flushed" + data-testid="pseudo-input" {...register('pseudo', { required: t('requiredField') })} /> {errors.pseudo?.message} @@ -105,6 +105,7 @@ function FormSignUp() { rounded="md" shadow="md" variant="flushed" + data-testid="email-input" {...register('email', { required: t('requiredField') })} /> {errors.email?.message} @@ -117,6 +118,7 @@ function FormSignUp() { rounded="md" shadow="md" variant="flushed" + data-testid="password-input" {...register('password', { required: t('requiredField') })} /> {errors.password?.message} @@ -129,6 +131,7 @@ function FormSignUp() { rounded="md" shadow="md" variant="flushed" + data-testid="confirm-password-input" {...register('confirmPassword', { required: t('requiredField'), })} @@ -144,7 +147,9 @@ function FormSignUp() { textStyle="button" fontColor="color.white" backgroundColor="color.chaletGreen" + data-testid="submit-button" text={t('signIn')} + isLoading={isSubmitting || isLoading} disabled={isLoading} /> @@ -159,7 +164,7 @@ function FormSignUp() { isOpen={isWelcomeModalOpen} onClose={() => { setIsWelcomeModalOpen(false) - router.push('/explore') + router.replace('/explore') }} /> diff --git a/apps/frontend/src/components/custom/LibraryCard.tsx b/apps/frontend/src/components/custom/LibraryCard.tsx index a8861a5b..99a696d8 100644 --- a/apps/frontend/src/components/custom/LibraryCard.tsx +++ b/apps/frontend/src/components/custom/LibraryCard.tsx @@ -1,9 +1,9 @@ import { Card, CardFooter, Flex, Heading, Image, Link, Text } from '@chakra-ui/react' -import { ContentWithMediaDto } from '@gazette/shared' +import { ContentDto } from '@gazette/shared' import { Heart } from 'lucide-react' interface LibraryCardProps { - content: ContentWithMediaDto + content: ContentDto like: (contentId: string) => void dislike: (contentId: string) => void isLiked: (contentId: string) => boolean @@ -48,8 +48,8 @@ function LibraryCard({ content, like, dislike, isLiked }: LibraryCardProps) { > {content.media.name} - {content.media.name} + {content.media?.name} diff --git a/apps/frontend/src/components/custom/RssCard.tsx b/apps/frontend/src/components/custom/RssCard.tsx index 08268440..d8e177d0 100644 --- a/apps/frontend/src/components/custom/RssCard.tsx +++ b/apps/frontend/src/components/custom/RssCard.tsx @@ -1,9 +1,9 @@ import { Card, CardBody, CardFooter, CardHeader, Heading, Link, Text, VStack } from '@chakra-ui/react' -import { ContentWithMediaDto } from '@gazette/shared' +import { ContentDto } from '@gazette/shared' import { Heart } from 'lucide-react' interface RssCardProps { - content: ContentWithMediaDto + content: ContentDto like: (contentId: string) => void dislike: (contentId: string) => void isLiked: (contentId: string) => boolean diff --git a/apps/frontend/src/components/custom/SettingsMenu.tsx b/apps/frontend/src/components/custom/SettingsMenu.tsx index 5e9b2b00..59c5422d 100644 --- a/apps/frontend/src/components/custom/SettingsMenu.tsx +++ b/apps/frontend/src/components/custom/SettingsMenu.tsx @@ -20,7 +20,6 @@ function SettingsMenu() { const handleLogout = async () => { try { await logout() - router.push('/') } catch (error) { console.error('Erreur lors de la déconnexion:', error) @@ -44,7 +43,7 @@ function SettingsMenu() { duration: 5000, }) setDeleteModalOpen(false) - router.push('/') + router.replace('/') } catch (error) { console.error('Erreur lors de la suppression du compte:', error) @@ -66,6 +65,7 @@ function SettingsMenu() { onClick={handleLogout} cursor="pointer" textStyle="nav" + data-testid="logout-link" > {t('logout')} diff --git a/apps/frontend/src/components/custom/WelcomeDisplay.tsx b/apps/frontend/src/components/custom/WelcomeDisplay.tsx index fc5439d2..3a10620d 100644 --- a/apps/frontend/src/components/custom/WelcomeDisplay.tsx +++ b/apps/frontend/src/components/custom/WelcomeDisplay.tsx @@ -57,6 +57,7 @@ function WelcomeDisplay() { href="/login" textStyle="welcomeLink" color="color.white" + data-testid="login-link" > {t('login')} diff --git a/apps/frontend/src/components/guards/AuthGuard.tsx b/apps/frontend/src/components/guards/AuthGuard.tsx index 66110257..b291b9c1 100644 --- a/apps/frontend/src/components/guards/AuthGuard.tsx +++ b/apps/frontend/src/components/guards/AuthGuard.tsx @@ -14,7 +14,7 @@ export function AuthGuard({ children }: AuthGuardProps) { useEffect(() => { if (!loading && !user) { - router.push('/login') + router.replace('/login') } }, [user, loading, router]) diff --git a/apps/frontend/src/contexts/AuthContext.tsx b/apps/frontend/src/contexts/AuthContext.tsx index 25918ab8..4d2dec0e 100644 --- a/apps/frontend/src/contexts/AuthContext.tsx +++ b/apps/frontend/src/contexts/AuthContext.tsx @@ -1,6 +1,7 @@ 'use client' import { UserDto } from '@gazette/shared' +import { useRouter } from 'next/navigation' import { createContext, useEffect, useMemo, useState } from 'react' import { deleteUserAccount, getUserProfile, loginUser, logoutUser } from '@/services/api/user' @@ -31,6 +32,7 @@ async function loadUserProfile(setUser: (user: UserDto | null) => void) { export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null) const [loading, setLoading] = useState(true) + const router = useRouter() useEffect(() => { loadUserProfile(setUser).finally(() => setLoading(false)) @@ -39,11 +41,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const login = async (email: string, password: string) => { await loginUser(email, password) await loadUserProfile(setUser) + router.replace('/explore') } const logout = async () => { await logoutUser() setUser(null) + router.replace('/') } const deleteAccount = async () => { diff --git a/apps/frontend/src/hooks/useContents.ts b/apps/frontend/src/hooks/useContents.ts index 6d66422f..a876eb6c 100644 --- a/apps/frontend/src/hooks/useContents.ts +++ b/apps/frontend/src/hooks/useContents.ts @@ -1,11 +1,11 @@ -import { ContentWithMediaDto } from '@gazette/shared' +import { ContentDto } from '@gazette/shared' import { useQuery } from '@tanstack/react-query' import { getUserContent } from '@/services/api/content' import { useAuth } from './useAuth' export function useContents() { const { user } = useAuth() - const { data: contents = [], isLoading, isError } = useQuery({ + const { data: contents = [], isLoading, isError } = useQuery({ queryKey: ['contents'], queryFn: () => getUserContent(), enabled: !!user, diff --git a/apps/frontend/src/services/api/content.ts b/apps/frontend/src/services/api/content.ts index 260403db..72cafce8 100644 --- a/apps/frontend/src/services/api/content.ts +++ b/apps/frontend/src/services/api/content.ts @@ -1,6 +1,6 @@ -import { ContentWithMediaDto } from '@gazette/shared' +import { ContentDto } from '@gazette/shared' import { api } from '@/config' -export async function getUserContent(): Promise { +export async function getUserContent(): Promise { return api.get('contents/user/subscriptions').json() } diff --git a/docker-compose.yml b/docker-compose.yml index 67a053e1..83b0110e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,7 @@ services: context: . dockerfile: apps/backend/Dockerfile env_file: - - .env # Charge le .env racine + - .env environment: DB_HOST: postgres DB_PORT: 5432 diff --git a/package.json b/package.json index 59911575..c3d890f5 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,35 @@ "docker:down": "docker-compose down", "docker:build": "docker-compose build", "docker:logs": "docker-compose logs -f", - "schema:fresh": "docker-compose exec backend pnpx mikro-orm schema:fresh --run" + "schema:fresh": "docker-compose exec backend pnpx mikro-orm schema:fresh --run", + "build": "pnpm clean && pnpm ci:build", + "build:shared": "pnpm --filter @gazette/shared build", + "build:backend": "pnpm --filter gazette_backend build", + "build:frontend": "pnpm --filter gazette_frontend build", + "start": "pnpm -r --parallel start", + "start:backend": "pnpm --filter gazette_backend start", + "start:frontend": "pnpm --filter gazette_frontend start", + "clean": "pnpm clean:all", + "clean:all": "pnpm -r clean && pnpm clean:cache && pnpm clean:builds", + "clean:cache": "rimraf node_modules/.cache .pnpm-store", + "clean:builds": "rimraf apps/frontend/.next apps/frontend/dist apps/backend/dist packages/shared/dist", + "typecheck": "pnpm -r run typecheck", + "test": "pnpm -r test", + "test:unit": "pnpm -r run test", + "test:e2e": "pnpm --filter gazette_backend test:e2e", + "db:migrate": "pnpm --filter gazette_backend migrate", + "db:seed": "pnpm --filter gazette_backend seeder:run", + "db:setup": "pnpm --filter gazette_backend db:setup", + "db:fresh": "pnpm --filter gazette_backend schema:fresh", + "docker:restart": "pnpm docker:down && pnpm docker:up", + "ci:install": "pnpm install --frozen-lockfile", + "ci:prebuild": "pnpm build:shared && pnpm -r exec pnpm link @gazette/shared", + "ci:build": "pnpm ci:prebuild && pnpm build:backend && pnpm build:frontend", + "ci:test": "pnpm typecheck && pnpm lint && pnpm test:unit", + "fresh": "pnpm clean:all && pnpm install && pnpm ci:build" + }, + "dependencies": { + "rimraf": "^5.0.10" }, "devDependencies": { "@antfu/eslint-config": "^4.14.1", diff --git a/apps/backend/jest.config.ts b/packages/shared/jest.config.js similarity index 56% rename from apps/backend/jest.config.ts rename to packages/shared/jest.config.js index 8e26849d..b525c10b 100644 --- a/apps/backend/jest.config.ts +++ b/packages/shared/jest.config.js @@ -1,12 +1,12 @@ -import { pathsToModuleNameMapper } from 'ts-jest' -import { compilerOptions } from './tsconfig.json' +const { pathsToModuleNameMapper } = require('ts-jest') +const { compilerOptions } = require('./tsconfig.json') -export default { +module.exports = { preset: 'ts-jest', testEnvironment: 'node', - rootDir: '.', + rootDir: 'src', moduleFileExtensions: ['js', 'json', 'ts'], - testRegex: '.*\\.spec\\.ts$', + testMatch: ['**/?(*.)+(spec|test).[tj]s?(x)'], transform: { '^.+\\.(t|j)s$': 'ts-jest', }, diff --git a/packages/shared/package.json b/packages/shared/package.json index c6f3f945..ccd98804 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -2,6 +2,13 @@ "name": "@gazette/shared", "version": "1.0.0", "private": true, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", @@ -10,13 +17,17 @@ "dev": "tsup src/index.ts --format cjs,esm --dts --watch", "clean": "rimraf dist", "lint": "eslint src/**/*.ts", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test": "jest --config jest.config.js" }, "dependencies": { "@mikro-orm/core": "^5.0.0", + "@types/jest": "^29.5.14", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", - "zod": "^3.25.28" + "jest": "^29.7.0", + "ts-jest": "^29.4.0", + "zod": "^4.1.4" }, "devDependencies": { "rimraf": "^5.0.0", diff --git a/packages/shared/src/dtos/content.dto.ts b/packages/shared/src/dtos/content.dto.ts index dfa74924..b9302f8a 100644 --- a/packages/shared/src/dtos/content.dto.ts +++ b/packages/shared/src/dtos/content.dto.ts @@ -1,32 +1,4 @@ -export interface ContentDto { - id: string - title: string - link: string - pubDate: string - description?: string - source: string - logo?: string - mediaId?: string -} - -export interface ContentWithMediaDto extends ContentDto { - media: { - id: string - name: string - description?: string - picture?: string - urlRss?: string - } -} - -export interface RssItemDto { - title: string - link: string - pubDate: string - description?: string - source: string - logo?: string -} +import type { RssItemDto } from '@/schemas/ContentSchema' export interface FeedSource { name: string diff --git a/packages/shared/src/dtos/media.dto.ts b/packages/shared/src/dtos/media.dto.ts deleted file mode 100644 index d4c022b0..00000000 --- a/packages/shared/src/dtos/media.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface MediaDto { - id: string - name: string - description: string - picture: string - urlRss: string -} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1b9e32f5..62c4c1fd 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,8 +1,9 @@ export * from './dtos/content.dto' export * from './dtos/like.dto' -export * from './dtos/media.dto' export * from './dtos/subscription.dto' export * from './dtos/user.dto' export * from './enums' export * from './interfaces' -export * from './schemas' +export * from './schemas/ContentSchema' +export * from './schemas/MediaSchema' +export * from './schemas/UserSchema' diff --git a/packages/shared/src/schemas/ContentSchema.spec.ts b/packages/shared/src/schemas/ContentSchema.spec.ts new file mode 100644 index 00000000..9af087c0 --- /dev/null +++ b/packages/shared/src/schemas/ContentSchema.spec.ts @@ -0,0 +1,260 @@ +import type { ContentDto } from './ContentSchema' +import { ContentSchema } from './ContentSchema' +import { MediaSchema } from './MediaSchema' + +describe('contentSchema', () => { + const validContent: ContentDto = { + id: '550e8400-e29b-41d4-a716-446655440001', + title: 'Article de test', + link: 'https://example.com/article', + pubDate: '2024-01-15T10:30:00+01:00', + description: 'Description de l\'article', + source: 'bondyblog', + logo: 'https://example.com/logo.png', + } + + it('should accept valid content data with all fields', () => { + const result = ContentSchema.safeParse(validContent) + expect(result.success).toBe(true) + + if (result.success) { + expect(result.data.title).toBe('Article de test') + expect(result.data.source).toBe('bondyblog') + } + }) + + it('should accept content without optional fields', () => { + const minimalContent = { + id: validContent.id, + title: validContent.title, + link: validContent.link, + pubDate: validContent.pubDate, + source: validContent.source, + // description, logo, media sont optionnels + } + + const result = ContentSchema.safeParse(minimalContent) + expect(result.success).toBe(true) + + if (result.success) { + expect(result.data.description).toBeUndefined() + expect(result.data.logo).toBeUndefined() + expect(result.data.media).toBeUndefined() + } + }) + + describe('title validation', () => { + it('should reject empty title', () => { + const invalidContent = { ...validContent, title: '' } + const result = ContentSchema.safeParse(invalidContent) + expect(result.success).toBe(false) + + if (!result.success) { + const titleError = result.error.issues.find(issue => issue.path[0] === 'title') + expect(titleError?.message).toBe('Title is required') + } + }) + + it('should accept long titles', () => { + const longTitle = 'Very Long Title That Might Exceed Normal Length But Should Still Be Valid' + const validContentWithLongTitle = { ...validContent, title: longTitle } + expect(ContentSchema.safeParse(validContentWithLongTitle).success).toBe(true) + }) + }) + + describe('link validation', () => { + it('should reject invalid URLs', () => { + const invalidUrls = [ + 'not-a-url', + '', + ] + + invalidUrls.forEach((link) => { + const invalidContent = { ...validContent, link } + const result = ContentSchema.safeParse(invalidContent) + expect(result.success).toBe(false) + + if (!result.success) { + const linkError = result.error.issues.find(issue => issue.path[0] === 'link') + expect(linkError?.message).toBe('Link must be a valid URL') + } + }) + }) + }) + + describe('pubDate validation', () => { + it('should accept valid ISO datetime formats', () => { + const validDates = [ + '2024-01-15T10:30:00Z', + '2024-01-15T10:30:00+01:00', + '2024-01-15T10:30:00-05:00', + '2024-12-31T23:59:59.999Z', + ] + + validDates.forEach((pubDate) => { + const validContentWithDate = { ...validContent, pubDate } + expect(ContentSchema.safeParse(validContentWithDate).success).toBe(true) + }) + }) + + it('should reject invalid date formats', () => { + const invalidDates = [ + '2024-01-15', + '10:30:00', + '2024/01/15 10:30:00', + 'Jan 15, 2024', + 'invalid-date', + ] + + invalidDates.forEach((pubDate) => { + const invalidContent = { ...validContent, pubDate } + const result = ContentSchema.safeParse(invalidContent) + expect(result.success).toBe(false) + }) + }) + }) + + describe('source validation', () => { + it('should reject empty source', () => { + const invalidContent = { ...validContent, source: '' } + const result = ContentSchema.safeParse(invalidContent) + expect(result.success).toBe(false) + + if (!result.success) { + const sourceError = result.error.issues.find(issue => issue.path[0] === 'source') + expect(sourceError?.message).toBe('Source is required') + } + }) + }) + + describe('optional logo validation', () => { + it('should accept undefined logo', () => { + const contentWithoutLogo = { ...validContent } + delete contentWithoutLogo.logo + + expect(ContentSchema.safeParse(contentWithoutLogo).success).toBe(true) + }) + + it('should reject invalid logo URL', () => { + const invalidContent = { ...validContent, logo: 'not-a-url' } + const result = ContentSchema.safeParse(invalidContent) + expect(result.success).toBe(false) + + if (!result.success) { + const logoError = result.error.issues.find(issue => issue.path[0] === 'logo') + expect(logoError?.message).toBe('Logo must be a valid URL') + } + }) + }) +}) + +describe('contentWithMediaSchema', () => { + const validContentWithMedia: ContentDto = { + id: '550e8400-e29b-41d4-a716-446655440001', + title: 'Article avec média', + link: 'https://example.com/article', + pubDate: '2024-01-15T10:30:00+01:00', + source: 'bondyblog', + media: { + id: '550e8400-e29b-41d4-a716-446655440002', + name: 'Bondy Blog', + description: 'Blog d\'actualités', + picture: 'https://www.bondyblog.fr/wp-content/uploads/2019/01/logo-bondy-blog.png', + urlRss: 'https://www.bondyblog.fr/feed/', + }, + } + + it('should accept valid content with media', () => { + const result = ContentSchema.safeParse(validContentWithMedia) + expect(result.success).toBe(true) + + if (result.success) { + expect(result.data.media?.name).toBe('Bondy Blog') + expect(result.data.media?.id).toBe('550e8400-e29b-41d4-a716-446655440002') + } + }) + + it('should accept content with media having optional fields', () => { + const contentWithFullMedia = { + ...validContentWithMedia, + media: { + ...validContentWithMedia.media, + picture: 'https://example.com/picture.png', + urlRss: 'https://example.com/rss.xml', + }, + } + + expect(ContentSchema.safeParse(contentWithFullMedia).success).toBe(true) + }) + + it('should reject content with invalid media structure', () => { + const invalidContent = { + ...validContentWithMedia, + media: { + id: 'invalid-uuid', + name: '', // Nom vide + }, + } + + const result = ContentSchema.safeParse(invalidContent) + expect(result.success).toBe(false) + }) + + it('should inherit all ContentSchema validations', () => { + const invalidContent = { + ...validContentWithMedia, + title: '', // Titre vide + link: 'invalid-url', + media: validContentWithMedia.media, + } + + const result = ContentSchema.safeParse(invalidContent) + expect(result.success).toBe(false) + + if (!result.success) { + expect(result.error.issues.length).toBeGreaterThanOrEqual(2) // Titre + link + } + }) +}) + +describe('type inference', () => { + it('should infer correct MediaDto type', () => { + const validMedia = { + id: '550e8400-e29b-41d4-a716-446655440000', + name: 'Test Media', + description: 'Test Description', + picture: 'https://example.com/pic.jpg', + urlRss: 'https://example.com/rss.xml', + } + + const result = MediaSchema.safeParse(validMedia) + + if (result.success) { + const data = result.data + expect(typeof data.id).toBe('string') + expect(typeof data.name).toBe('string') + expect(typeof data.description).toBe('string') + expect(typeof data.picture).toBe('string') + expect(typeof data.urlRss).toBe('string') + } + }) + + it('should infer correct ContentDto type with optionals', () => { + const validContent = { + id: '550e8400-e29b-41d4-a716-446655440001', + title: 'Test', + link: 'https://example.com', + pubDate: '2024-01-15T10:30:00Z', + source: 'test-source', + } + + const result = ContentSchema.safeParse(validContent) + + if (result.success) { + const data = result.data + expect(typeof data.description).toBe('undefined') + expect(typeof data.logo).toBe('undefined') + expect(typeof data.media).toBe('undefined') + } + }) +}) diff --git a/packages/shared/src/schemas/ContentSchema.ts b/packages/shared/src/schemas/ContentSchema.ts new file mode 100644 index 00000000..b8fd0ded --- /dev/null +++ b/packages/shared/src/schemas/ContentSchema.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' +import { MediaSchema } from './MediaSchema' + +export const ContentSchema = z.object({ + id: z.uuid(), + title: z.string().min(1, { message: 'Title is required' }), + link: z.url({ message: 'Link must be a valid URL' }), + pubDate: z.iso.datetime({ offset: true, local: true }), + description: z.string().optional(), + source: z.string().min(1, { message: 'Source is required' }), + logo: z.url({ message: 'Logo must be a valid URL' }).optional(), + media: MediaSchema.optional(), +}) + +export type ContentDto = z.infer + +export const RssItemSchema = ContentSchema.omit({ id: true }) + +export type RssItemDto = z.infer diff --git a/packages/shared/src/schemas/MediaSchema.spec.ts b/packages/shared/src/schemas/MediaSchema.spec.ts new file mode 100644 index 00000000..1d3b77df --- /dev/null +++ b/packages/shared/src/schemas/MediaSchema.spec.ts @@ -0,0 +1,160 @@ +import type { MediaDto } from './MediaSchema' +import { MediaSchema } from './MediaSchema' + +describe('media and Content Schema Validation', () => { + describe('mediaSchema', () => { + const validMedia: MediaDto = { + id: '550e8400-e29b-41d4-a716-446655440000', + name: 'Bondy Blog', + description: 'Blog d\'actualités et d\'investigation', + picture: 'https://www.bondyblog.fr/wp-content/uploads/2019/01/logo-bondy-blog.png', + urlRss: 'https://www.bondyblog.fr/feed/', + } + + it('should accept valid media data', () => { + const result = MediaSchema.safeParse(validMedia) + expect(result.success).toBe(true) + + if (result.success) { + expect(result.data.id).toBe(validMedia.id) + expect(result.data.name).toBe('Bondy Blog') + expect(result.data.urlRss).toBe('https://www.bondyblog.fr/feed/') + } + }) + + describe('id validation', () => { + it('should reject invalid UUID format', () => { + const invalidUuids = [ + 'not-a-uuid', + '123', + '550e8400-e29b-41d4-a716', // Trop court + '550e8400-e29b-41d4-a716-446655440000-extra', // Trop long + '', + ] + + invalidUuids.forEach((id) => { + const invalidMedia = { ...validMedia, id } + const result = MediaSchema.safeParse(invalidMedia) + expect(result.success).toBe(false) + }) + }) + + it('should accept valid UUID formats', () => { + const validUuids = [ + '550e8400-e29b-41d4-a716-446655440000', + '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + '00000000-0000-0000-0000-000000000000', + ] + + validUuids.forEach((id) => { + const validMediaWithId = { ...validMedia, id } + expect(MediaSchema.safeParse(validMediaWithId).success).toBe(true) + }) + }) + }) + + describe('name validation', () => { + it('should reject empty name', () => { + const invalidMedia = { ...validMedia, name: '' } + const result = MediaSchema.safeParse(invalidMedia) + expect(result.success).toBe(false) + + if (!result.success) { + const nameError = result.error.issues.find(issue => issue.path[0] === 'name') + expect(nameError?.message).toBe('Name is required') + } + }) + + it('should accept single character name', () => { + const validMediaWithShortName = { ...validMedia, name: 'A' } + expect(MediaSchema.safeParse(validMediaWithShortName).success).toBe(true) + }) + + it('should accept long names', () => { + const longName = 'Very Long Media Name That Should Still Be Valid' + const validMediaWithLongName = { ...validMedia, name: longName } + expect(MediaSchema.safeParse(validMediaWithLongName).success).toBe(true) + }) + }) + + describe('description validation', () => { + it('should reject empty description', () => { + const invalidMedia = { ...validMedia, description: '' } + const result = MediaSchema.safeParse(invalidMedia) + expect(result.success).toBe(false) + + if (!result.success) { + const descError = result.error.issues.find(issue => issue.path[0] === 'description') + expect(descError?.message).toBe('Description is required') + } + }) + + it('should accept long descriptions', () => { + const longDescription = 'A'.repeat(1000) + const validMediaWithLongDesc = { ...validMedia, description: longDescription } + expect(MediaSchema.safeParse(validMediaWithLongDesc).success).toBe(true) + }) + }) + + describe('uRL validations', () => { + it('should reject invalid picture URLs', () => { + const invalidUrls = [ + 'not-a-url', + '', + ] + + invalidUrls.forEach((picture) => { + const invalidMedia = { ...validMedia, picture } + const result = MediaSchema.safeParse(invalidMedia) + expect(result.success).toBe(false) + + if (!result.success) { + const pictureError = result.error.issues.find(issue => issue.path[0] === 'picture') + expect(pictureError?.message).toBe('Picture must be a valid URL') + } + }) + }) + + it('should reject invalid RSS URLs', () => { + const invalidMedia = { ...validMedia, urlRss: 'not-a-valid-url' } + const result = MediaSchema.safeParse(invalidMedia) + expect(result.success).toBe(false) + + if (!result.success) { + const urlRssError = result.error.issues.find(issue => issue.path[0] === 'urlRss') + expect(urlRssError?.message).toBe('RSS URL must be a valid URL') + } + }) + + it('should accept various valid URL formats', () => { + const validUrls = [ + 'https://example.com', + 'http://example.com/path/to/resource', + 'https://subdomain.example.com/feed.xml', + 'https://example.com:8080/api/feed', + ] + + validUrls.forEach((url) => { + const validMediaWithUrl = { + ...validMedia, + picture: url, + urlRss: url, + } + expect(MediaSchema.safeParse(validMediaWithUrl).success).toBe(true) + }) + }) + }) + + it('should reject missing required fields', () => { + const requiredFields = ['id', 'name', 'description', 'picture', 'urlRss'] + + requiredFields.forEach((field) => { + const incompleteMedia = { ...validMedia } + delete incompleteMedia[field as keyof MediaDto] + + const result = MediaSchema.safeParse(incompleteMedia) + expect(result.success).toBe(false) + }) + }) + }) +}) diff --git a/packages/shared/src/schemas/MediaSchema.ts b/packages/shared/src/schemas/MediaSchema.ts new file mode 100644 index 00000000..f8435e22 --- /dev/null +++ b/packages/shared/src/schemas/MediaSchema.ts @@ -0,0 +1,13 @@ +import { z } from 'zod' + +export const MediaSchema = z.object({ + id: z.uuid(), + name: z.string().min(1, { message: 'Name is required' }), + description: z.string().min(1, { message: 'Description is required' }), + picture: z.url({ message: 'Picture must be a valid URL' }), + urlRss: z.url({ message: 'RSS URL must be a valid URL' }), + createdAt: z.date().optional(), + subscribers: z.object().optional(), +}) + +export type MediaDto = z.infer diff --git a/packages/shared/src/schemas/UserSchema.spec.ts b/packages/shared/src/schemas/UserSchema.spec.ts new file mode 100644 index 00000000..9ccd6b65 --- /dev/null +++ b/packages/shared/src/schemas/UserSchema.spec.ts @@ -0,0 +1,307 @@ +import { CreateUserSchema, LogUserSchema, SignUpFormSchema } from './UserSchema' + +describe('user Schema Validation', () => { + describe('createUserSchema', () => { + it('should accept valid user data', () => { + const validUser = { + pseudo: 'testuser', + email: 'test@example.com', + password: 'ValidPass123?', + } + + const result = CreateUserSchema.safeParse(validUser) + expect(result.success).toBe(true) + + if (result.success) { + expect(result.data.pseudo).toBe('testuser') + expect(result.data.email).toBe('test@example.com') + } + }) + + describe('pseudo validation', () => { + it('should reject pseudo with less than 2 characters', () => { + const invalidUser = { + pseudo: 'a', + email: 'test@example.com', + password: 'ValidPass123?', + } + + const result = CreateUserSchema.safeParse(invalidUser) + expect(result.success).toBe(false) + + if (!result.success) { + const pseudoError = result.error.issues.find(issue => issue.path[0] === 'pseudo') + expect(pseudoError?.message).toBe('Must be at least 2 characters') + } + }) + + it('should accept pseudo with exactly 2 characters', () => { + const validUser = { + pseudo: 'ab', + email: 'test@example.com', + password: 'ValidPass123?', + } + + expect(CreateUserSchema.safeParse(validUser).success).toBe(true) + }) + + it('should accept long pseudo', () => { + const validUser = { + pseudo: 'verylongpseudothatshouldbefine', + email: 'test@example.com', + password: 'ValidPass123?', + } + + expect(CreateUserSchema.safeParse(validUser).success).toBe(true) + }) + }) + + describe('email validation', () => { + it('should reject invalid email formats', () => { + const invalidEmails = [ + 'not-an-email', + '@example.com', + 'test@', + 'test.example.com', + '', + ] + + invalidEmails.forEach((email) => { + const invalidUser = { + pseudo: 'testuser', + email, + password: 'ValidPass123?', + } + + const result = CreateUserSchema.safeParse(invalidUser) + expect(result.success).toBe(false) + }) + }) + + it('should accept valid email formats', () => { + const validEmails = [ + 'test@example.com', + 'user.name@domain.co.uk', + 'test123@gmail.com', + 'a@b.com', + ] + + validEmails.forEach((email) => { + const validUser = { + pseudo: 'testuser', + email, + password: 'ValidPass123?', + } + + expect(CreateUserSchema.safeParse(validUser).success).toBe(true) + }) + }) + }) + + describe('password validation', () => { + it('should reject passwords with less than 8 characters', () => { + const invalidUser = { + pseudo: 'testuser', + email: 'test@example.com', + password: 'Abc1?', + } + + const result = CreateUserSchema.safeParse(invalidUser) + expect(result.success).toBe(false) + + if (!result.success) { + const passwordError = result.error.issues.find(issue => issue.path[0] === 'password') + expect(passwordError?.message).toBe('Must contain at least 8 characters') + } + }) + + it('should reject password without uppercase letter', () => { + const invalidUser = { + pseudo: 'testuser', + email: 'test@example.com', + password: 'lowercase123?', + } + + const result = CreateUserSchema.safeParse(invalidUser) + expect(result.success).toBe(false) + + if (!result.success) { + const passwordError = result.error.issues.find(issue => issue.path[0] === 'password') + expect(passwordError?.message).toContain('uppercase') + } + }) + + it('should reject password without lowercase letter', () => { + const invalidUser = { + pseudo: 'testuser', + email: 'test@example.com', + password: 'UPPERCASE123?', + } + + const result = CreateUserSchema.safeParse(invalidUser) + expect(result.success).toBe(false) + }) + + it('should reject password without number', () => { + const invalidUser = { + pseudo: 'testuser', + email: 'test@example.com', + password: 'ValidPass?', + } + + const result = CreateUserSchema.safeParse(invalidUser) + expect(result.success).toBe(false) + }) + + it('should reject password without special character', () => { + const invalidUser = { + pseudo: 'testuser', + email: 'test@example.com', + password: 'ValidPass123', + } + + const result = CreateUserSchema.safeParse(invalidUser) + expect(result.success).toBe(false) + }) + + it('should accept password with all valid special characters', () => { + const validSpecialChars = ['-', '[', ']', '(', ')', '*', '~', '_', '#', ':', '?'] + + validSpecialChars.forEach((char) => { + const validUser = { + pseudo: 'testuser', + email: 'test@example.com', + password: `ValidPass123${char}`, + } + + expect(CreateUserSchema.safeParse(validUser).success).toBe(true) + }) + }) + + it('should reject password with invalid special characters', () => { + const invalidSpecialChars = ['!', '@', '$', '%', '^', '&', '+', '='] + + invalidSpecialChars.forEach((char) => { + const invalidUser = { + pseudo: 'testuser', + email: 'test@example.com', + password: `ValidPass123${char}`, + } + + expect(CreateUserSchema.safeParse(invalidUser).success).toBe(false) + }) + }) + }) + }) + + describe('signUpFormSchema', () => { + it('should accept valid signup form with matching passwords', () => { + const validSignUp = { + pseudo: 'testuser', + email: 'test@example.com', + password: 'ValidPass123?', + confirmPassword: 'ValidPass123?', + } + + const result = SignUpFormSchema.safeParse(validSignUp) + expect(result.success).toBe(true) + }) + + it('should reject signup form with non-matching passwords', () => { + const invalidSignUp = { + pseudo: 'testuser', + email: 'test@example.com', + password: 'ValidPass123?', + confirmPassword: 'DifferentPass123?', + } + + const result = SignUpFormSchema.safeParse(invalidSignUp) + expect(result.success).toBe(false) + + if (!result.success) { + const confirmPasswordError = result.error.issues.find( + issue => issue.path.includes('confirmPassword'), + ) + expect(confirmPasswordError?.message).toBe('Les mots de passe doivent être identiques') + } + }) + + it('should inherit all validations from CreateUserSchema', () => { + const invalidSignUp = { + pseudo: 'a', // Trop court + email: 'invalid-email', + password: 'weak', + confirmPassword: 'weak', + } + + const result = SignUpFormSchema.safeParse(invalidSignUp) + expect(result.success).toBe(false) + + if (!result.success) { + expect(result.error.issues.length).toBeGreaterThan(1) // Plusieurs erreurs + } + }) + }) + + describe('logUserSchema', () => { + it('should accept valid login data', () => { + const validLogin = { + email: 'test@example.com', + password: 'ValidPass123?', + } + + const result = LogUserSchema.safeParse(validLogin) + expect(result.success).toBe(true) + }) + + it('should reject invalid email in login', () => { + const invalidLogin = { + email: 'not-an-email', + password: 'ValidPass123?', + } + + const result = LogUserSchema.safeParse(invalidLogin) + expect(result.success).toBe(false) + }) + + it('should reject invalid password in login', () => { + const invalidLogin = { + email: 'test@example.com', + password: 'weak', + } + + const result = LogUserSchema.safeParse(invalidLogin) + expect(result.success).toBe(false) + }) + + it('should reject missing fields', () => { + const incompleteLogin = { + email: 'test@example.com', + // password manquant + } + + const result = LogUserSchema.safeParse(incompleteLogin) + expect(result.success).toBe(false) + }) + }) + + describe('type inference', () => { + it('should infer correct types', () => { + const validUser = { + pseudo: 'testuser', + email: 'test@example.com', + password: 'ValidPass123?', + } + + const result = CreateUserSchema.safeParse(validUser) + + if (result.success) { + // TypeScript doit inférer les bons types + const data = result.data + expect(typeof data.pseudo).toBe('string') + expect(typeof data.email).toBe('string') + expect(typeof data.password).toBe('string') + } + }) + }) +}) diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/UserSchema.ts similarity index 91% rename from packages/shared/src/schemas/index.ts rename to packages/shared/src/schemas/UserSchema.ts index 48a4065c..961caecd 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/UserSchema.ts @@ -5,7 +5,7 @@ const passwordValidation = /^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?\d)(?=.*?[-[\]()*~_#: export const CreateUserSchema = z .object({ pseudo: z.string().min(2, { message: 'Must be at least 2 characters' }), - email: z.string().email(), + email: z.email(), password: z .string() .min(8, { message: 'Must contain at least 8 characters' }) @@ -18,7 +18,7 @@ export type CreateUserDto = z.infer export const SignUpFormSchema = z .object({ pseudo: z.string().min(2, { message: 'Le pseudo doit être composé au minimun de 2 caractères' }), - email: z.string().email('Le format de l\'email est invalide'), + email: z.email('Le format de l\'email est invalide'), password: z .string() .min(8, { message: 'Le mot de passe doit être composé au minimum de 8 caractères' }) @@ -34,7 +34,7 @@ export type SignUpFormDto = z.infer export const LogUserSchema = z .object({ - email: z.string().email('Le format de l\'email est invalide'), + email: z.email('Le format de l\'email est invalide'), password: z .string() .min(8, 'Le mot de passe doit être composé au minimum de 8 caractères') diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 9d051fe8..3c918020 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -4,9 +4,14 @@ "lib": ["ES2020"], "emitDecoratorMetadata": true, "experimentalDecorators": true, + "baseUrl": "./", "rootDir": "src", "module": "ESNext", "moduleResolution": "node", + "paths": { + "@/*": ["src/*"] + }, + "resolveJsonModule": true, "strict": true, "declaration": true, "outDir": "dist", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53619eab..30789cd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + rimraf: + specifier: ^5.0.10 + version: 5.0.10 devDependencies: '@antfu/eslint-config': specifier: ^4.14.1 @@ -101,10 +105,16 @@ importers: rxjs: specifier: ^7.8.1 version: 7.8.2 + zod: + specifier: ^4.1.4 + version: 4.1.4 devDependencies: '@eslint/js': specifier: ^9.18.0 version: 9.29.0 + '@faker-js/faker': + specifier: ^9.9.0 + version: 9.9.0 '@mikro-orm/cli': specifier: ^6.4.15 version: 6.4.16(pg@8.16.0) @@ -117,6 +127,9 @@ importers: '@nestjs/testing': specifier: ^11.0.1 version: 11.1.3(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3)(@nestjs/platform-express@11.1.3) + '@playwright/test': + specifier: ^1.55.0 + version: 1.55.0 '@swc/cli': specifier: ^0.6.0 version: 0.6.0(@swc/core@1.12.7)(chokidar@4.0.3) @@ -132,6 +145,9 @@ importers: '@types/jest': specifier: ^29.5.14 version: 29.5.14 + '@types/node': + specifier: ^20.19.1 + version: 20.19.1 '@types/supertest': specifier: ^6.0.2 version: 6.0.3 @@ -141,6 +157,9 @@ importers: jest: specifier: ^29.7.0 version: 29.7.0(@types/node@20.19.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.12.7)(@types/node@20.19.1)(typescript@5.8.3)) + nock: + specifier: ^14.0.10 + version: 14.0.10 source-map-support: specifier: ^0.5.21 version: 0.5.21 @@ -149,7 +168,7 @@ importers: version: 7.1.1 ts-jest: specifier: ^29.4.0 - version: 29.4.0(@babel/core@7.27.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.12.7)(@types/node@20.19.1)(typescript@5.8.3)))(typescript@5.8.3) + version: 29.4.0(@babel/core@7.27.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.7))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.1)(babel-plugin-macros@3.1.0))(typescript@5.8.3) ts-loader: specifier: ^9.5.2 version: 9.5.2(typescript@5.8.3)(webpack@5.99.6(@swc/core@1.12.7)) @@ -223,8 +242,8 @@ importers: specifier: ^5.0.1 version: 5.5.0(react@18.3.1) zod: - specifier: ^3.25.28 - version: 3.25.67 + specifier: ^4.1.4 + version: 4.1.4 devDependencies: '@types/node': specifier: ^20.11.16 @@ -244,15 +263,24 @@ importers: '@mikro-orm/core': specifier: ^5.0.0 version: 5.9.8 + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 class-transformer: specifier: ^0.5.1 version: 0.5.1 class-validator: specifier: ^0.14.0 version: 0.14.2 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.12.7)(@types/node@20.19.1)(typescript@5.8.3)) + ts-jest: + specifier: ^29.4.0 + version: 29.4.0(@babel/core@7.27.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.7))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.1)(babel-plugin-macros@3.1.0))(typescript@5.8.3) zod: - specifier: ^3.25.28 - version: 3.25.67 + specifier: ^4.1.4 + version: 4.1.4 devDependencies: rimraf: specifier: ^5.0.0 @@ -890,6 +918,10 @@ packages: resolution: {integrity: sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@faker-js/faker@9.9.0': + resolution: {integrity: sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==} + engines: {node: '>=18.0.0', npm: '>=9.0.0'} + '@hookform/resolvers@5.1.1': resolution: {integrity: sha512-J/NVING3LMAEvexJkyTLjruSm7aOFx7QX21pzkiJfMoNG0wl5aFEjLTl7ay7IQb9EWY6AkrBy7tHL2Alijpdcg==} peerDependencies: @@ -1258,6 +1290,10 @@ packages: peerDependencies: '@mikro-orm/core': ^6.0.0 + '@mswjs/interceptors@0.39.6': + resolution: {integrity: sha512-bndDP83naYYkfayr/qhBHMhk0YGwS1iv6vaEGcr0SQbO0IZtbOPqjKjds/WcG+bJA+1T5vCx6kprKOzn5Bg+Vw==} + engines: {node: '>=18'} + '@napi-rs/nice-android-arm-eabi@1.0.1': resolution: {integrity: sha512-5qpvOu5IGwDo7MEKVqqyAxF90I6aLj4n07OzpARdgDRfz8UbBztTByBp0RC59r3J1Ij8uzYi6jI7r5Lws7nn6w==} engines: {node: '>= 10'} @@ -1551,6 +1587,15 @@ packages: engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} hasBin: true + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@paralleldrive/cuid2@2.2.2': resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==} @@ -1562,6 +1607,11 @@ packages: resolution: {integrity: sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.55.0': + resolution: {integrity: sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==} + engines: {node: '>=18'} + hasBin: true + '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -3495,6 +3545,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3747,6 +3802,9 @@ packages: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -3996,6 +4054,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -4503,6 +4564,10 @@ packages: sass: optional: true + nock@14.0.10: + resolution: {integrity: sha512-Q7HjkpyPeLa0ZVZC5qpxBt5EyLczFJ91MEewQiIi9taWuA0KB/MDJlUWtON+7dGouVdADTQsf9RA7TZk6D8VMw==} + engines: {node: '>=18.20.0 <20 || >=20.12.1'} + node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} @@ -4565,6 +4630,9 @@ packages: resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} engines: {node: '>=0.10.0'} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + p-cancelable@3.0.0: resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==} engines: {node: '>=12.20'} @@ -4751,6 +4819,16 @@ packages: pkg-types@2.1.1: resolution: {integrity: sha512-eY0QFb6eSwc9+0d/5D2lFFUq+A3n3QNGSy/X2Nvp+6MfzGw2u6EbA7S80actgjY1lkvvI0pqB+a4hioMh443Ew==} + playwright-core@1.55.0: + resolution: {integrity: sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.55.0: + resolution: {integrity: sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==} + engines: {node: '>=18'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -4838,6 +4916,10 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + propagate@2.0.1: + resolution: {integrity: sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==} + engines: {node: '>= 8'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -5228,6 +5310,7 @@ packages: source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions spdx-exceptions@2.5.0: resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} @@ -5274,6 +5357,9 @@ packages: streamx@2.22.1: resolution: {integrity: sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==} + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -5953,6 +6039,9 @@ packages: zod@3.25.67: resolution: {integrity: sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==} + zod@4.1.4: + resolution: {integrity: sha512-2YqJuWkU6IIK9qcE4k1lLLhyZ6zFw7XVRdQGpV97jEIZwTrscUw+DY31Xczd8nwaoksyJUIxCojZXwckJovWxA==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -6694,6 +6783,8 @@ snapshots: '@eslint/core': 0.15.1 levn: 0.4.1 + '@faker-js/faker@9.9.0': {} + '@hookform/resolvers@5.1.1(react-hook-form@7.58.1(react@18.3.1))': dependencies: '@standard-schema/utils': 0.3.0 @@ -7178,6 +7269,15 @@ snapshots: fs-extra: 11.3.0 globby: 11.1.0 + '@mswjs/interceptors@0.39.6': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + '@napi-rs/nice-android-arm-eabi@1.0.1': optional: true @@ -7426,6 +7526,15 @@ snapshots: dependencies: consola: 3.4.2 + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + '@paralleldrive/cuid2@2.2.2': dependencies: '@noble/hashes': 1.8.0 @@ -7435,6 +7544,10 @@ snapshots: '@pkgr/core@0.2.7': {} + '@playwright/test@1.55.0': + dependencies: + playwright: 1.55.0 + '@popperjs/core@2.11.8': {} '@rollup/rollup-android-arm-eabi@4.44.1': @@ -9591,6 +9704,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -9835,6 +9951,8 @@ snapshots: is-interactive@1.0.0: {} + is-node-process@1.2.0: {} + is-number@7.0.0: {} is-plain-obj@1.1.0: {} @@ -10257,6 +10375,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: {} + json5@2.2.3: {} jsonc-eslint-parser@2.4.0: @@ -10901,6 +11021,12 @@ snapshots: - '@babel/core' - babel-plugin-macros + nock@14.0.10: + dependencies: + '@mswjs/interceptors': 0.39.6 + json-stringify-safe: 5.0.1 + propagate: 2.0.1 + node-abort-controller@3.1.1: {} node-emoji@1.11.0: @@ -10964,6 +11090,8 @@ snapshots: os-tmpdir@1.0.2: {} + outvariant@1.4.3: {} + p-cancelable@3.0.0: {} p-limit@2.3.0: @@ -11151,6 +11279,14 @@ snapshots: exsolve: 1.0.7 pathe: 2.0.3 + playwright-core@1.55.0: {} + + playwright@1.55.0: + dependencies: + playwright-core: 1.55.0 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} pnpm-workspace-yaml@0.3.1: @@ -11220,6 +11356,8 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + propagate@2.0.1: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -11659,6 +11797,8 @@ snapshots: optionalDependencies: bare-events: 2.5.4 + strict-event-emitter@0.5.1: {} + string-argv@0.3.2: {} string-length@4.0.2: @@ -11893,7 +12033,7 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.4.0(@babel/core@7.27.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.12.7)(@types/node@20.19.1)(typescript@5.8.3)))(typescript@5.8.3): + ts-jest@29.4.0(@babel/core@7.27.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.7))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.1)(babel-plugin-macros@3.1.0))(typescript@5.8.3): dependencies: bs-logger: 0.2.6 ejs: 3.1.10 @@ -11911,6 +12051,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.27.7) + esbuild: 0.25.5 jest-util: 29.7.0 ts-loader@9.5.2(typescript@5.8.3)(webpack@5.99.6(@swc/core@1.12.7)): @@ -12352,4 +12493,6 @@ snapshots: zod@3.25.67: {} + zod@4.1.4: {} + zwitch@2.0.4: {}