diff --git a/.github/actions/setup-backend/action.yml b/.github/actions/setup-backend/action.yml new file mode 100644 index 000000000..b9ddb2845 --- /dev/null +++ b/.github/actions/setup-backend/action.yml @@ -0,0 +1,37 @@ +name: 'Setup Backend' +description: 'Setup .NET, restore dependencies, and install required tools' +inputs: + dotnet-version: + description: '.NET version to setup' + required: false + default: '10.0.x' + solution-path: + description: 'Path to the solution file' + required: false + default: 'MeAjudaAi.slnx' + +runs: + using: "composite" + steps: + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: ${{ inputs.dotnet-version }} + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/*.props') }} + restore-keys: | + nuget-${{ runner.os }}- + + - name: Restore dependencies + shell: bash + run: dotnet restore ${{ inputs.solution-path }} + + - name: Install Tools + shell: bash + run: | + dotnet tool install -g Swashbuckle.AspNetCore.Cli --version 10.1.7 + dotnet tool install -g dotnet-reportgenerator-globaltool --version 5.5.4 diff --git a/.github/actions/setup-frontend/action.yml b/.github/actions/setup-frontend/action.yml new file mode 100644 index 000000000..107cf1290 --- /dev/null +++ b/.github/actions/setup-frontend/action.yml @@ -0,0 +1,29 @@ +name: 'Setup Frontend' +description: 'Setup Node.js and install dependencies' +inputs: + node-version: + description: 'Node.js version' + required: false + default: '20' + working-directory: + description: 'Frontend working directory' + required: false + default: './src/Web' + +runs: + using: "composite" + steps: + - name: Setup Node.js environment + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version }} + cache: "npm" + cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json + + - name: Install Frontend Dependencies + shell: bash + working-directory: ${{ inputs.working-directory }} + run: npm ci + + - name: Nx Set SHAs + uses: nrwl/nx-set-shas@v5 diff --git a/.github/scripts/generate-runsettings.sh b/.github/scripts/generate-runsettings.sh index 957ccf232..d62ef5fff 100644 --- a/.github/scripts/generate-runsettings.sh +++ b/.github/scripts/generate-runsettings.sh @@ -10,6 +10,15 @@ # -o pipefail: catch errors in piped commands set -euo pipefail +# Coverage threshold (default: 90%) +COVERAGE_THRESHOLD="${COVERAGE_THRESHOLD:-90}" + +# Validate COVERAGE_THRESHOLD is within valid range (0-100) +if ! [[ "$COVERAGE_THRESHOLD" =~ ^[0-9]+$ ]] || [ "$COVERAGE_THRESHOLD" -lt 0 ] || [ "$COVERAGE_THRESHOLD" -gt 100 ]; then + echo "⚠️ WARNING: COVERAGE_THRESHOLD ($COVERAGE_THRESHOLD) is not in valid range 0-100. Using default 90." >&2 + COVERAGE_THRESHOLD=90 +fi + # Escape XML special characters to prevent malformed XML output escape_xml() { local input="$1" diff --git a/.github/workflows/ci-backend.yml b/.github/workflows/ci-backend.yml new file mode 100644 index 000000000..46817bff9 --- /dev/null +++ b/.github/workflows/ci-backend.yml @@ -0,0 +1,316 @@ +name: Backend CI + +on: + push: + branches: [master, develop] + paths: + - 'src/Modules/**' + - 'src/Bootstrapper/**' + - 'src/Shared/**' + - 'tests/**' + - '!tests/MeAjudaAi.E2E.Tests/**' + - '.github/workflows/ci-backend.yml' + - '.github/actions/setup-backend/**' + - '.github/actions/setup-postgres-connection/**' + - '.github/scripts/generate-runsettings.sh' + - 'MeAjudaAi.slnx' + pull_request: + branches: [master, develop] + paths: + - 'src/Modules/**' + - 'src/Bootstrapper/**' + - 'src/Shared/**' + - 'tests/**' + - '!tests/MeAjudaAi.E2E.Tests/**' + - '.github/workflows/ci-backend.yml' + - '.github/actions/setup-backend/**' + - '.github/actions/setup-postgres-connection/**' + - '.github/scripts/generate-runsettings.sh' + - 'MeAjudaAi.slnx' + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + checks: write + statuses: write + +env: + DOTNET_VERSION: "10.0.x" + STRICT_COVERAGE: true + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD || 'test123' }} + POSTGRES_USER: ${{ secrets.POSTGRES_USER || 'postgres' }} + POSTGRES_DB: ${{ secrets.POSTGRES_DB || 'meajudaai_test' }} + +jobs: + build-and-test: + name: Build and Test (Backend) + runs-on: ubuntu-latest + timeout-minutes: 60 + + services: + postgres: + image: postgis/postgis:16-3.4 + env: + POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }} + POSTGRES_USER: ${{ env.POSTGRES_USER }} + POSTGRES_DB: ${{ env.POSTGRES_DB }} + POSTGRES_HOST_AUTH_METHOD: md5 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + azurite: + image: mcr.microsoft.com/azure-storage/azurite:latest + ports: + - 10000:10000 + - 10001:10001 + - 10002:10002 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Backend + uses: ./.github/actions/setup-backend + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Install PostgreSQL client + run: | + sudo apt-get update + sudo apt-get install -y postgresql-client + + - name: Build solution + run: dotnet build MeAjudaAi.slnx --configuration Release --no-restore + + - name: Extract Aspire version from Directory.Packages.props + id: aspire-version + run: | + ASPIRE_VERSION=$(grep -oP 'Aspire\.Hosting\.PostgreSQL.*?Version="\K[^"]+' Directory.Packages.props || echo "13.1.3") + echo "version=$ASPIRE_VERSION" >> "$GITHUB_OUTPUT" + + - name: Prepare Aspire Integration Tests + run: | + echo "Preparing .NET Aspire for integration tests..." + ASPIRE_VERSION="${{ steps.aspire-version.outputs.version }}" + TEMP_DIR=$(mktemp -d) + cd "$TEMP_DIR" + dotnet new console -n TempDcpDownloader + cd TempDcpDownloader + dotnet add package Aspire.Hosting.Orchestration.linux-x64 --version "$ASPIRE_VERSION" + dotnet restore + cd "$GITHUB_WORKSPACE" + rm -rf "$TEMP_DIR" + DCP_PACKAGE_PATH=$(find "$HOME/.nuget/packages" -maxdepth 1 -type d \ + -iname "aspire.hosting.orchestration.linux-x64" 2>/dev/null | head -n 1) + if [ -z "$DCP_PACKAGE_PATH" ]; then + echo "Error: Aspire orchestration package not found" + exit 1 + fi + DCP_BINARY=$(find "$DCP_PACKAGE_PATH" -type f -name "dcp" 2>/dev/null | head -n 1) + if [ -z "$DCP_BINARY" ]; then + echo "Error: DCP binary not found" + exit 1 + fi + echo "DOTNET_ROOT=$HOME/.dotnet" >> $GITHUB_ENV + + - name: Wait for PostgreSQL + shell: bash + run: | + max_attempts=30 + attempt=0 + until pg_isready -h localhost -p 5432 -U "$POSTGRES_USER"; do + attempt=$((attempt + 1)) + if [ $attempt -ge $max_attempts ]; then + echo "ERROR: PostgreSQL did not become ready after $max_attempts attempts" + exit 1 + fi + echo "Waiting for PostgreSQL... (attempt $attempt/$max_attempts)" + sleep 2 + done + echo "PostgreSQL is ready" + + - name: Setup PostgreSQL connection + id: db + uses: ./.github/actions/setup-postgres-connection + with: + postgres-host: localhost + postgres-port: 5432 + postgres-db: ${{ env.POSTGRES_DB }} + postgres-user: ${{ env.POSTGRES_USER }} + postgres-password: ${{ env.POSTGRES_PASSWORD }} + + - name: Wait for PostgreSQL + shell: bash + run: | + max_attempts=60 + attempt=0 + until pg_isready -h localhost -p 5432 -U "$POSTGRES_USER"; do + attempt=$((attempt + 1)) + if [ $attempt -ge $max_attempts ]; then + echo "ERROR: PostgreSQL did not become ready after $max_attempts attempts" + exit 1 + fi + echo "Waiting for PostgreSQL... (attempt $attempt/$max_attempts)" + sleep 2 + done + echo "PostgreSQL is ready" + + - name: Run Unit Tests with Coverage + id: unit-tests + continue-on-error: true + env: + ASPNETCORE_ENVIRONMENT: Testing + MEAJUDAAI_DB_PASS: ${{ env.POSTGRES_PASSWORD }} + MEAJUDAAI_DB_USER: ${{ env.POSTGRES_USER }} + MEAJUDAAI_DB: ${{ env.POSTGRES_DB }} + ConnectionStrings__DefaultConnection: ${{ steps.db.outputs.connection-string }} + ConnectionStrings__Users: ${{ steps.db.outputs.connection-string }} + ConnectionStrings__Search: ${{ steps.db.outputs.connection-string }} + ConnectionStrings__meajudaai-db: ${{ steps.db.outputs.connection-string }} + AZURE_STORAGE_CONNECTION_STRING: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://localhost:10000/devstoreaccount1;" + run: | + echo "🧪 Running Unit & Architecture Tests..." + rm -rf ./coverage + mkdir -p ./coverage + + # Run unit tests per module for isolation + MODULES=( + "src/Modules/Users/Tests/MeAjudaAi.Modules.Users.Tests.csproj" + "src/Modules/Providers/Tests/MeAjudaAi.Modules.Providers.Tests.csproj" + "src/Modules/Documents/Tests/MeAjudaAi.Modules.Documents.Tests.csproj" + "src/Modules/ServiceCatalogs/Tests/MeAjudaAi.Modules.ServiceCatalogs.Tests.csproj" + "src/Modules/Locations/Tests/MeAjudaAi.Modules.Locations.Tests.csproj" + "src/Modules/SearchProviders/Tests/MeAjudaAi.Modules.SearchProviders.Tests.csproj" + "tests/MeAjudaAi.Shared.Tests/MeAjudaAi.Shared.Tests.csproj" + "tests/MeAjudaAi.ApiService.Tests/MeAjudaAi.ApiService.Tests.csproj" + "tests/MeAjudaAi.Architecture.Tests/MeAjudaAi.Architecture.Tests.csproj" + ) + + for module in "${MODULES[@]}"; do + if [ -f "$module" ]; then + module_name=$(basename "$module" .csproj) + dotnet test "$module" \ + --configuration Release --no-build \ + --collect:"XPlat Code Coverage" \ + --results-directory "./coverage/unit/$module_name" \ + --settings ./coverlet.runsettings + fi + done + + - name: Run Integration Tests + id: integration-tests + continue-on-error: true + env: + ASPNETCORE_ENVIRONMENT: Testing + INTEGRATION_TESTS: true + MEAJUDAAI_DB_PASS: ${{ env.POSTGRES_PASSWORD }} + MEAJUDAAI_DB_USER: ${{ env.POSTGRES_USER }} + MEAJUDAAI_DB: ${{ env.POSTGRES_DB }} + ConnectionStrings__DefaultConnection: ${{ steps.db.outputs.connection-string }} + ConnectionStrings__Users: ${{ steps.db.outputs.connection-string }} + ConnectionStrings__Search: ${{ steps.db.outputs.connection-string }} + ConnectionStrings__meajudaai-db: ${{ steps.db.outputs.connection-string }} + AZURE_STORAGE_CONNECTION_STRING: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://localhost:10000/devstoreaccount1;" + run: | + echo "🚀 Running Integration Tests (expected duration ~40m)..." + dotnet test tests/MeAjudaAi.Integration.Tests/MeAjudaAi.Integration.Tests.csproj \ + --configuration Release --no-build \ + --collect:"XPlat Code Coverage" \ + --results-directory ./coverage/integration \ + --settings ./coverlet.runsettings \ + --verbosity normal + + - name: Collect and aggregate coverage files + run: | + mkdir -p ./coverage/aggregate + # Use mapfile for robust array handling (avoids subshell issue with pipe|while) + mapfile -t coverage_files < <(find ./coverage -type f -name "coverage.cobertura.xml") + + counter=0 + for file in "${coverage_files[@]}"; do + counter=$((counter + 1)) + cp "$file" "./coverage/aggregate/coverage_${counter}.cobertura.xml" + echo "✅ Collected: $file as coverage_${counter}.cobertura.xml" + done + + if [ -n "$(ls -A ./coverage/aggregate/ 2>/dev/null)" ]; then + echo "✅ All coverage files ready for aggregation." + else + echo "❌ CRITICAL ERROR: No coverage files were generated." + exit 1 + fi + + - name: Configure DOTNET_ROOT for ReportGenerator + run: | + DOTNET_PATH="$(which dotnet)" + echo "DOTNET_ROOT=$(dirname "$(readlink -f "$DOTNET_PATH")")" >> $GITHUB_ENV + + - name: Generate aggregated coverage report + uses: danielpalme/ReportGenerator-GitHub-Action@5 + with: + reports: "coverage/aggregate/**/*.cobertura.xml" + targetdir: "coverage/final_report" + reporttypes: "Cobertura;JsonSummary" + groupby: "Namespace" + assemblyfilters: "+MeAjudaAi.*;-MeAjudaAi.AppHost;-MeAjudaAi.ServiceDefaults;-MeAjudaAi.Contracts" + classfilters: "-*.Tests;-*.Tests.*;-*Test*;-testhost;-*.Migrations.*;-*Program*;-*.Seeding.*;-*.Monitoring.*;-MeAjudaAi.Shared.Jobs.*;-MeAjudaAi.Shared.Mediator.*;-MeAjudaAi.Shared.API.*" + + - name: Upload coverage reports + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-reports + path: coverage/** + + - name: Code Coverage Summary + uses: irongut/CodeCoverageSummary@v1.3.0 + with: + filename: "coverage/final_report/Cobertura.xml" + badge: true + format: markdown + output: both + thresholds: "90 80" # line branch + fail_below_min: true + + - name: Add Coverage PR Comment + uses: marocchino/sticky-pull-request-comment@v2 + if: github.event_name == 'pull_request' + with: + recreate: true + header: coverage-report + path: code-coverage-results.md + + security-scan: + name: Security Scan + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + - name: Run Security Audit + continue-on-error: true + run: | + dotnet list package --vulnerable --include-transitive > security-audit-report.txt + cat security-audit-report.txt + - name: Verify No Critical Direct Vulnerabilities + run: | + # Parse output to find if there are critical vulnerabilities in direct dependencies + # We check the top-level references specifically. A basic scan verifies if "Critical" shows up without being in a Transitive block, or simply checks top-level explicitly. + dotnet list package --vulnerable > direct-security-audit.txt + if grep -qi "Critical" direct-security-audit.txt; then + echo "Critical direct vulnerabilities found!" + cat direct-security-audit.txt + exit 1 + fi + echo "No critical vulnerabilities found in direct dependencies." diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml new file mode 100644 index 000000000..d94623344 --- /dev/null +++ b/.github/workflows/ci-e2e.yml @@ -0,0 +1,272 @@ +name: E2E Tests + +on: + push: + branches: [master, develop] + paths: + - 'src/Web/**' + - 'src/Bootstrapper/**' + - 'src/Modules/**' + - 'tests/MeAjudaAi.E2E.Tests/**' + - '.github/workflows/ci-e2e.yml' + - '.github/actions/**' + pull_request: + branches: [master, develop] + paths: + - 'src/Web/**' + - 'src/Bootstrapper/**' + - 'src/Modules/**' + - 'tests/MeAjudaAi.E2E.Tests/**' + - '.github/workflows/ci-e2e.yml' + - '.github/actions/**' + workflow_dispatch: + +permissions: + contents: read + checks: write + statuses: write + +env: + DOTNET_VERSION: "10.0.x" + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD || 'test123' }} + POSTGRES_USER: ${{ secrets.POSTGRES_USER || 'postgres' }} + POSTGRES_DB: ${{ secrets.POSTGRES_DB || 'meajudaai_test' }} + +jobs: + backend-e2e: + name: Backend E2E + runs-on: ubuntu-latest + timeout-minutes: 30 + + services: + postgres: + image: postgis/postgis:16-3.4 + env: + POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }} + POSTGRES_USER: ${{ env.POSTGRES_USER }} + POSTGRES_DB: ${{ env.POSTGRES_DB }} + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis-server: + image: redis:alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Backend + uses: ./.github/actions/setup-backend + + - name: Build solution + run: dotnet build MeAjudaAi.slnx --configuration Release --no-restore + + - name: Run Backend E2E Tests + env: + ASPNETCORE_ENVIRONMENT: Testing + INTEGRATION_TESTS: true + MEAJUDAAI_DB_PASS: ${{ env.POSTGRES_PASSWORD }} + MEAJUDAAI_DB_USER: ${{ env.POSTGRES_USER }} + MEAJUDAAI_DB: ${{ env.POSTGRES_DB }} + run: | + dotnet test tests/MeAjudaAi.E2E.Tests/ \ + --configuration Release --no-build --verbosity normal + + frontend-e2e: + name: Frontend E2E + runs-on: ubuntu-latest + timeout-minutes: 30 + + services: + postgres: + image: postgis/postgis:16-3.4 + env: + POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }} + POSTGRES_USER: ${{ env.POSTGRES_USER }} + POSTGRES_DB: ${{ env.POSTGRES_DB }} + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis-server: + image: redis:alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Backend (for API Generation) + uses: ./.github/actions/setup-backend + + - name: Setup Frontend + uses: ./.github/actions/setup-frontend + + - name: Build Backend (for API Generation) + run: dotnet build MeAjudaAi.slnx --configuration Release --no-restore + + - name: Generate API Client + working-directory: ./src/Web + run: | + set -e + mkdir -p "$GITHUB_WORKSPACE/src/api" + export ASPNETCORE_ENVIRONMENT=Development + export ConnectionStrings__DefaultConnection="Host=localhost;Database=dummy" + export Migrations__Enabled=false + SEARCH_DIR="$GITHUB_WORKSPACE/src/Bootstrapper/MeAjudaAi.ApiService/bin/Release" + DLL_PATH=$(find "$SEARCH_DIR" -name "MeAjudaAi.ApiService.dll" | head -n 1) + if [ -z "$DLL_PATH" ]; then + echo "❌ Error: MeAjudaAi.ApiService.dll not found in $SEARCH_DIR" + exit 1 + fi + swagger tofile --output "$GITHUB_WORKSPACE/src/api/api-spec.json" "$DLL_PATH" v1 + export OPENAPI_SPEC_URL="$GITHUB_WORKSPACE/src/api/api-spec.json" + npm run generate:api --workspace=meajudaai.web.customer + npm run generate:api --workspace=meajudaai.web.admin + npm run generate:api --workspace=meajudaai.web.provider + + - name: Start Backend API + env: + ASPNETCORE_ENVIRONMENT: Testing + ASPNETCORE_URLS: "http://localhost:7002" + MEAJUDAAI_DB_PASS: ${{ env.POSTGRES_PASSWORD }} + MEAJUDAAI_DB_USER: ${{ env.POSTGRES_USER }} + MEAJUDAAI_DB: ${{ env.POSTGRES_DB }} + ConnectionStrings__DefaultConnection: "Host=localhost;Database=${{ env.POSTGRES_DB }};Username=${{ env.POSTGRES_USER }};Password=${{ env.POSTGRES_PASSWORD }}" + ConnectionStrings__redis: "localhost:6379" + run: | + dotnet run --project src/Bootstrapper/MeAjudaAi.ApiService/MeAjudaAi.ApiService.csproj --configuration Release & + + # Wait for API to be ready + echo "Waiting for API..." + API_READY=false + for i in {1..60}; do + if curl -s http://localhost:7002/health > /dev/null 2>&1; then + echo "API is up!" + API_READY=true + break + fi + echo "Waiting... ($i/60)" + sleep 2 + done + + if [ "$API_READY" = "false" ]; then + echo "ERROR: API failed to become healthy" + exit 1 + fi + + - name: Setup Applications Environment + working-directory: ./src/Web + run: | + for dir in MeAjudaAi.Web.Admin MeAjudaAi.Web.Customer MeAjudaAi.Web.Provider; do + cat > $dir/.env.local << 'EOF' + CI=true + KEYCLOAK_ADMIN_CLIENT_ID=ci-build-placeholder + KEYCLOAK_ADMIN_CLIENT_SECRET=ci-build-placeholder + KEYCLOAK_CLIENT_ID=ci-build-placeholder + KEYCLOAK_CLIENT_SECRET=ci-build-placeholder + KEYCLOAK_ISSUER=http://localhost:8080/realms/meajudaai + NEXTAUTH_SECRET=ci-build-placeholder + AUTH_SECRET=ci-build-placeholder + EOF + done + + - name: Build All Frontends + working-directory: ./src/Web + env: + NEXT_PUBLIC_API_URL: http://localhost:7002 + KEYCLOAK_ADMIN_CLIENT_ID: ci-build-placeholder + KEYCLOAK_ADMIN_CLIENT_SECRET: ci-build-placeholder + KEYCLOAK_CLIENT_ID: ci-build-placeholder + KEYCLOAK_CLIENT_SECRET: ci-build-placeholder + KEYCLOAK_ISSUER: http://localhost:8080/realms/meajudaai + NEXTAUTH_SECRET: ci-build-placeholder + AUTH_SECRET: ci-build-placeholder + run: | + echo "Building Admin..." + NEXTAUTH_URL=http://localhost:3002 npm run build --workspace=meajudaai.web.admin + echo "Building Provider..." + NEXTAUTH_URL=http://localhost:3001 npm run build --workspace=meajudaai.web.provider + echo "Building Customer..." + NEXTAUTH_URL=http://localhost:3000 npm run build --workspace=meajudaai.web.customer + + - name: Start All Frontends + working-directory: ./src/Web + env: + MOCK_AUTH: "true" + run: | + echo "Starting Admin (3002)..." + npx next start MeAjudaAi.Web.Admin --port 3002 > /tmp/admin.log 2>&1 & + echo "Starting Provider (3001)..." + npx next start MeAjudaAi.Web.Provider --port 3001 > /tmp/provider.log 2>&1 & + echo "Starting Customer (3000)..." + npx next start MeAjudaAi.Web.Customer --port 3000 > /tmp/customer.log 2>&1 & + + echo "Waiting for Frontends..." + ALL_READY=false + for i in {1..90}; do + ADMIN_UP=$(curl -s --head http://localhost:3002 > /dev/null 2>&1 && echo "up" || echo "down") + PROV_UP=$(curl -s --head http://localhost:3001 > /dev/null 2>&1 && echo "up" || echo "down") + CUST_UP=$(curl -s --head http://localhost:3000 > /dev/null 2>&1 && echo "up" || echo "down") + + if [ "$ADMIN_UP" = "up" ] && [ "$PROV_UP" = "up" ] && [ "$CUST_UP" = "up" ]; then + echo "✅ All frontends are up!" + ALL_READY=true + break + fi + echo "Waiting... ($i/90) [Admin: $ADMIN_UP, Provider: $PROV_UP, Customer: $CUST_UP]" + sleep 2 + done + + if [ "$ALL_READY" = "false" ]; then + echo "❌ Error: One or more frontends failed to start" + echo "--- Admin Log ---" + cat /tmp/admin.log + echo "--- Provider Log ---" + cat /tmp/provider.log + echo "--- Customer Log ---" + cat /tmp/customer.log + exit 1 + fi + + - name: Run Playwright E2E Tests + working-directory: ./src/Web + env: + CI: "true" + TEST_ENV: "external" + MOCK_AUTH: "true" + KEYCLOAK_ADMIN_CLIENT_ID: ci-build-placeholder + KEYCLOAK_ADMIN_CLIENT_SECRET: ci-build-placeholder + KEYCLOAK_CLIENT_ID: ci-build-placeholder + KEYCLOAK_CLIENT_SECRET: ci-build-placeholder + KEYCLOAK_ISSUER: http://localhost:8080/realms/meajudaai + ADMIN_BASE_URL: http://localhost:3002 + PROVIDER_BASE_URL: http://localhost:3001 + BASE_URL: http://localhost:3000 + run: | + npx playwright install --with-deps chromium + npx playwright test --reporter=list diff --git a/.github/workflows/ci-frontend.yml b/.github/workflows/ci-frontend.yml new file mode 100644 index 000000000..7db2e93f5 --- /dev/null +++ b/.github/workflows/ci-frontend.yml @@ -0,0 +1,134 @@ +name: Frontend CI + +on: + push: + branches: [master, develop] + paths: + - 'src/Web/**' + - 'src/Client/**' + - 'src/Bootstrapper/**' + - 'src/Modules/**' + - 'src/Shared/**' + - '.github/workflows/ci-frontend.yml' + - '.github/actions/setup-frontend/**' + - '.github/actions/setup-backend/**' + - 'MeAjudaAi.slnx' + pull_request: + branches: [master, develop] + paths: + - 'src/Web/**' + - 'src/Client/**' + - 'src/Bootstrapper/**' + - 'src/Modules/**' + - 'src/Shared/**' + - '.github/workflows/ci-frontend.yml' + - '.github/actions/setup-frontend/**' + - '.github/actions/setup-backend/**' + - 'MeAjudaAi.slnx' + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + checks: write + statuses: write + +jobs: + build-and-test: + name: Build and Test (Frontend) + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Backend (for API Generation) + uses: ./.github/actions/setup-backend + + - name: Setup Frontend + uses: ./.github/actions/setup-frontend + + - name: Build Backend (for API Generation) + run: dotnet build MeAjudaAi.slnx --configuration Release --no-restore + + - name: Generate API Client + working-directory: ./src/Web + run: | + set -e + mkdir -p "$GITHUB_WORKSPACE/src/api" + export ASPNETCORE_ENVIRONMENT=Development + export ConnectionStrings__DefaultConnection="Host=localhost;Database=dummy" + export Migrations__Enabled=false + SEARCH_DIR="$GITHUB_WORKSPACE/src/Bootstrapper/MeAjudaAi.ApiService/bin/Release" + DLL_PATH=$(find "$SEARCH_DIR" -name "MeAjudaAi.ApiService.dll" | head -n 1) + if [ -z "$DLL_PATH" ]; then + echo "Error: MeAjudaAi.ApiService.dll not found!" + exit 1 + fi + swagger tofile --output "$GITHUB_WORKSPACE/src/api/api-spec.json" "$DLL_PATH" v1 + export OPENAPI_SPEC_URL="$GITHUB_WORKSPACE/src/api/api-spec.json" + npm run generate:api --workspace=meajudaai.web.customer + npm run generate:api --workspace=meajudaai.web.admin + npm run generate:api --workspace=meajudaai.web.provider + + - name: Validate Generated API Clients + working-directory: ./src/Web + run: | + npx nx build MeAjudaAi.Web.Customer --configuration=development + npx nx build MeAjudaAi.Web.Admin --configuration=development + npx nx build MeAjudaAi.Web.Provider --configuration=development + + - name: Lint Frontend + working-directory: ./src/Web + run: npx nx affected --target=lint + + - name: Test Frontend + working-directory: ./src/Web + env: + KEYCLOAK_ADMIN_CLIENT_ID: ci-build-placeholder + KEYCLOAK_ADMIN_CLIENT_SECRET: ci-build-placeholder + KEYCLOAK_ISSUER: http://localhost:8080/realms/meajudaai + NEXTAUTH_URL: http://localhost:3000 + NEXTAUTH_SECRET: ci-build-placeholder + AUTH_SECRET: ci-build-placeholder + run: | + npx nx run-many --target=test --all --args="--coverage" + node scripts/merge-coverage.mjs + + - name: Build Frontend + working-directory: ./src/Web + env: + KEYCLOAK_ADMIN_CLIENT_ID: ci-build-placeholder + KEYCLOAK_ADMIN_CLIENT_SECRET: ci-build-placeholder + KEYCLOAK_ISSUER: http://localhost:8080/realms/meajudaai + NEXTAUTH_URL: http://localhost:3000 + NEXTAUTH_SECRET: ci-build-placeholder + AUTH_SECRET: ci-build-placeholder + run: | + set -e + npx nx run MeAjudaAi.Web.Customer:build + npx nx run MeAjudaAi.Web.Admin:build + npx nx run MeAjudaAi.Web.Provider:build + + + + - name: Upload coverage reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-frontend + path: | + src/Web/**/coverage/cobertura-coverage.xml + src/Web/coverage-global/cobertura-coverage.xml + retention-days: 30 + + - name: Add Coverage PR Comment + uses: marocchino/sticky-pull-request-comment@v2 + if: always() && github.event_name == 'pull_request' + with: + recreate: true + header: coverage-report-frontend + path: "src/Web/coverage-global/custom-coverage-results.md" diff --git a/.github/workflows/deploy-azure.yml b/.github/workflows/deploy-azure.yml new file mode 100644 index 000000000..68c32eaa6 --- /dev/null +++ b/.github/workflows/deploy-azure.yml @@ -0,0 +1,80 @@ +name: Deploy to Azure + +on: + push: + branches: [master] + workflow_dispatch: + inputs: + environment: + description: 'Environment to deploy to' + required: true + default: 'dev' + type: choice + options: + - 'dev' + - 'prod' + deploy_infrastructure: + description: 'Deploy infrastructure' + required: false + default: true + type: boolean + +permissions: + contents: read + deployments: write + statuses: write + +env: + DOTNET_VERSION: '10.0.x' + AZURE_LOCATION: 'brazilsouth' + ENVIRONMENT: ${{ github.event.inputs.environment || (github.ref == 'refs/heads/master' && 'prod' || 'dev') }} + +jobs: + # Infrastructure Validation (Read-only) + validate-infrastructure: + name: Validate Infrastructure + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/master' + permissions: + contents: read + deployments: read + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Validate Bicep syntax + run: az bicep build --file infrastructure/main.bicep + + # Deploy to Azure + deploy: + name: Deploy + runs-on: ubuntu-latest + needs: [validate-infrastructure] + if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/master' + environment: ${{ github.event.inputs.environment || (github.ref == 'refs/heads/master' && 'prod' || 'dev') }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Login to Azure + uses: azure/login@v3 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + - name: Create Resource Group + if: github.event.inputs.deploy_infrastructure != 'false' + run: | + az group create \ + --name meajudaai-${{ env.ENVIRONMENT }}-rg \ + --location ${{ env.AZURE_LOCATION }} + + - name: Deploy Infrastructure + if: github.event.inputs.deploy_infrastructure != 'false' + run: | + az deployment group create \ + --name meajudaai-${{ env.ENVIRONMENT }}-deployment \ + --resource-group meajudaai-${{ env.ENVIRONMENT }}-rg \ + --template-file infrastructure/main.bicep \ + --parameters infrastructure/${{ env.ENVIRONMENT }}.parameters.json \ + --parameters postgresAdminPassword=${{ secrets.POSTGRES_ADMIN_PASSWORD }} diff --git a/.github/workflows/master-ci-cd.yml b/.github/workflows/master-ci-cd.yml deleted file mode 100644 index a0988ca1f..000000000 --- a/.github/workflows/master-ci-cd.yml +++ /dev/null @@ -1,436 +0,0 @@ ---- -name: CI/CD Pipeline - -"on": - push: - branches: [master, develop] - workflow_dispatch: - inputs: - deploy_infrastructure: - description: 'Deploy infrastructure to dev' - required: false - default: true - type: boolean - cleanup_after_test: - description: 'Cleanup dev resources after deployment test' - required: false - default: false - type: boolean - -permissions: - contents: read - deployments: write - statuses: write - -env: - DOTNET_VERSION: '10.0.x' - AZURE_RESOURCE_GROUP_DEV: 'meajudaai-dev' - AZURE_LOCATION: 'brazilsouth' - -jobs: - # Job 1: Build and Test - build-and-test: - name: Build and Test - runs-on: ubuntu-latest - services: - postgres: - image: postgis/postgis:16-3.4 - env: - POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD || 'test123' }} - POSTGRES_USER: ${{ secrets.POSTGRES_USER || 'postgres' }} - POSTGRES_DB: ${{ secrets.POSTGRES_DB || 'meajudaai_test' }} - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Setup .NET - uses: actions/setup-dotnet@v5 - with: - dotnet-version: ${{ env.DOTNET_VERSION }} - - - name: Restore dependencies - run: dotnet restore MeAjudaAi.slnx --force-evaluate - - - name: Build solution - run: dotnet build MeAjudaAi.slnx --configuration Release --no-restore - - - name: Install Aspire workload - run: | - echo "Installing .NET Aspire workload..." - dotnet workload update - # --skip-sign-check is required to install unsigned preview Aspire workloads - # in the ephemeral GitHub Actions environment. This flag bypasses signature - # verification which is acceptable for CI/CD only. Remove this flag when - # moving to signed/stable Aspire releases. - dotnet workload install aspire --skip-sign-check - echo "Aspire workload installed" - echo "Verifying Aspire installation..." - dotnet workload list - # Verify DCP is available - if [ -d "$HOME/.dotnet/sdk-manifests" ]; then - find "$HOME/.dotnet" -name "dcp" -o -name "dcpctrl" 2>/dev/null || true - fi - - - name: Setup PostgreSQL connection - id: db - uses: ./.github/actions/setup-postgres-connection - with: - postgres-host: localhost - postgres-port: 5432 - postgres-db: ${{ secrets.POSTGRES_DB || 'meajudaai_test' }} - postgres-user: ${{ secrets.POSTGRES_USER || 'postgres' }} - postgres-password: ${{ secrets.POSTGRES_PASSWORD || 'test123' }} - - - name: Run unit tests - env: - ASPNETCORE_ENVIRONMENT: Testing - ConnectionStrings__DefaultConnection: ${{ steps.db.outputs.connection-string }} - ConnectionStrings__Users: ${{ steps.db.outputs.connection-string }} - ConnectionStrings__Search: ${{ steps.db.outputs.connection-string }} - ConnectionStrings__meajudaai-db: ${{ steps.db.outputs.connection-string }} - run: | - set -e # Exit immediately if any command fails - - # Define exclude patterns for coverage (files only - assemblies excluded via Include in coverlet) - # Excludes: Migrations, Database, Contracts, OpenApi generated, compiler services, regex generator - EXCLUDE_PATTERNS="**/Migrations/*.cs,**/Database/*.cs,**/*OpenApi*.generated.cs,**/System.Runtime.CompilerServices*.cs,**/*RegexGenerator.g.cs" - - echo "================================" - echo "BACKEND UNIT TESTS WITH COVERAGE" - echo "================================" - - echo "Running Shared and Architecture tests..." - dotnet test tests/MeAjudaAi.Shared.Tests/MeAjudaAi.Shared.Tests.csproj \ - --configuration Release --no-build --verbosity normal \ - --collect:"XPlat Code Coverage" --results-directory TestResults/Shared \ - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByFile="$EXCLUDE_PATTERNS" - - dotnet test tests/MeAjudaAi.Architecture.Tests/MeAjudaAi.Architecture.Tests.csproj \ - --configuration Release --no-build --verbosity normal \ - --collect:"XPlat Code Coverage" --results-directory TestResults/Architecture \ - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByFile="$EXCLUDE_PATTERNS" - - dotnet test tests/MeAjudaAi.ApiService.Tests/MeAjudaAi.ApiService.Tests.csproj \ - --configuration Release --no-build --verbosity normal \ - --collect:"XPlat Code Coverage" --results-directory TestResults/ApiService \ - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByFile="$EXCLUDE_PATTERNS" - - echo "Running module tests..." - dotnet test src/Modules/Users/Tests/MeAjudaAi.Modules.Users.Tests.csproj \ - --configuration Release --no-build --verbosity normal \ - --collect:"XPlat Code Coverage" --results-directory TestResults/Users \ - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByFile="$EXCLUDE_PATTERNS" - - dotnet test src/Modules/Documents/Tests/MeAjudaAi.Modules.Documents.Tests.csproj \ - --configuration Release --no-build --verbosity normal \ - --collect:"XPlat Code Coverage" --results-directory TestResults/Documents \ - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByFile="$EXCLUDE_PATTERNS" - - dotnet test src/Modules/Providers/Tests/MeAjudaAi.Modules.Providers.Tests.csproj \ - --configuration Release --no-build --verbosity normal \ - --collect:"XPlat Code Coverage" --results-directory TestResults/Providers \ - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByFile="$EXCLUDE_PATTERNS" - - dotnet test src/Modules/ServiceCatalogs/Tests/MeAjudaAi.Modules.ServiceCatalogs.Tests.csproj \ - --configuration Release --no-build --verbosity normal \ - --collect:"XPlat Code Coverage" --results-directory TestResults/ServiceCatalogs \ - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByFile="$EXCLUDE_PATTERNS" - - dotnet test src/Modules/Locations/Tests/MeAjudaAi.Modules.Locations.Tests.csproj \ - --configuration Release --no-build --verbosity normal \ - --collect:"XPlat Code Coverage" --results-directory TestResults/Locations \ - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByFile="$EXCLUDE_PATTERNS" - - dotnet test src/Modules/SearchProviders/Tests/MeAjudaAi.Modules.SearchProviders.Tests.csproj \ - --configuration Release --no-build --verbosity normal \ - --collect:"XPlat Code Coverage" --results-directory TestResults/SearchProviders \ - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByFile="$EXCLUDE_PATTERNS" - - - name: Run frontend tests (React Admin) - working-directory: src/Web/MeAjudaAi.Web.Admin - run: | - echo "====================================" - echo "REACT ADMIN TESTS" - echo "====================================" - echo "Running React Admin tests..." - npm test - echo "React Admin tests completed" - - - name: Free Disk Space for Integration Tests - run: | - echo "Freeing disk space before integration tests..." - - # Show disk usage before cleanup - echo "Disk usage before cleanup:" - df -h - - # Remove Docker images and containers that are not needed - echo "️ Removing unused Docker resources..." - docker system prune -af --volumes || true - - # Remove large packages/tools not needed for tests - echo "️ Removing unnecessary packages..." - sudo apt-get clean - sudo rm -rf /usr/local/lib/android || true - sudo rm -rf /opt/ghc || true - sudo rm -rf /usr/local/.ghcup || true - - # Show disk usage after cleanup - echo "Disk usage after cleanup:" - df -h - - echo "Disk space cleanup completed" - - - name: Run integration tests - env: - ASPNETCORE_ENVIRONMENT: Testing - INTEGRATION_TESTS: true - MEAJUDAAI_DB_PASS: ${{ secrets.POSTGRES_PASSWORD || 'test123' }} - MEAJUDAAI_DB_USER: ${{ secrets.POSTGRES_USER || 'postgres' }} - MEAJUDAAI_DB: ${{ secrets.POSTGRES_DB || 'meajudaai_test' }} - ConnectionStrings__DefaultConnection: ${{ steps.db.outputs.connection-string }} - ConnectionStrings__Users: ${{ steps.db.outputs.connection-string }} - ConnectionStrings__Search: ${{ steps.db.outputs.connection-string }} - ConnectionStrings__meajudaai-db: ${{ steps.db.outputs.connection-string }} - run: | - set -e # Exit immediately if any command fails - - echo "================================" - echo "INTEGRATION TESTS (NO COVERAGE)" - echo "================================" - dotnet test tests/MeAjudaAi.Integration.Tests/MeAjudaAi.Integration.Tests.csproj \ - --configuration Release --no-build --verbosity normal - - - name: Run E2E tests - env: - ASPNETCORE_ENVIRONMENT: Testing - ConnectionStrings__DefaultConnection: ${{ steps.db.outputs.connection-string }} - ConnectionStrings__meajudaai-db: ${{ steps.db.outputs.connection-string }} - run: | - set -e # Exit immediately if any command fails - - echo "================================" - echo "E2E TESTS (NO COVERAGE)" - echo "================================" - dotnet test tests/MeAjudaAi.E2E.Tests/MeAjudaAi.E2E.Tests.csproj \ - --configuration Release --no-build --verbosity normal - - - name: Configure DOTNET_ROOT for ReportGenerator - run: | - # ReportGenerator needs DOTNET_ROOT to find .NET runtime - if command -v dotnet >/dev/null 2>&1; then - DOTNET_PATH=$(which dotnet) - DOTNET_RESOLVED=$(readlink -f "$DOTNET_PATH") - export DOTNET_ROOT=$(dirname "$DOTNET_RESOLVED") - echo "DOTNET_ROOT=$DOTNET_ROOT" >> $GITHUB_ENV - echo "Set DOTNET_ROOT: $DOTNET_ROOT" - fi - - - name: Generate Code Coverage Report - uses: danielpalme/ReportGenerator-GitHub-Action@5 - env: - DOTNET_ROOT: ${{ env.DOTNET_ROOT }} - with: - reports: 'TestResults/Shared/**/coverage.cobertura.xml;TestResults/Architecture/**/coverage.cobertura.xml;TestResults/ApiService/**/coverage.cobertura.xml;TestResults/Users/**/coverage.cobertura.xml;TestResults/Documents/**/coverage.cobertura.xml;TestResults/Providers/**/coverage.cobertura.xml;TestResults/ServiceCatalogs/**/coverage.cobertura.xml;TestResults/Locations/**/coverage.cobertura.xml;TestResults/SearchProviders/**/coverage.cobertura.xml' - targetdir: 'TestResults/Coverage' - reporttypes: 'Html;Cobertura;JsonSummary' - assemblyfilters: '-*.Tests*;-*Test*;-*.Migrations.*;-*.Database;-*.Contracts' - classfilters: '-*.Migrations.*;-*.Database.*;-*.Contracts.*;-*.Metrics.*;-*.HealthChecks.*;-*.Jobs.Hangfire*;-*.Jobs.Configuration*;-*Program*;-*AppHost*;-*.ServiceDefaults*;-*.Keycloak.*;-*.Monitoring.*;-*NoOp*;-*RabbitMq*;-*ServiceBus*;-*Hangfire*;-*.Jobs.*;-*Options;-*BaseDesignTimeDbContextFactory*;-*SchemaPermissionsManager;-*SimpleHostEnvironment;-*CacheWarmupService;-*GeoPointConverter;-*ModuleNames;-*ModuleApiInfo;-*MessagingExtensions;-*ICacheableQuery' - verbosity: 'Info' - tag: '${{ github.run_number }}_${{ github.run_id }}' - # Note: UNIT test coverage for backend only. - # Excluded from coverage: Tests, Migrations, Database, Contracts, Metrics, Health Checks, Jobs, Program, AppHost, Keycloak, Monitoring, NoOp, RabbitMq, ServiceBus, Hangfire, Options, Design-time factories - - - name: Upload code coverage - uses: actions/upload-artifact@v4 - if: always() - with: - name: code-coverage - path: "TestResults/Coverage/**/*" - retention-days: 7 - compression-level: 6 - if-no-files-found: warn - - - name: Upload test results - uses: actions/upload-artifact@v4 - if: always() - with: - name: test-results - path: "**/TestResults/**/*" - retention-days: 7 - compression-level: 6 - if-no-files-found: warn - - # Job 2: Compatibility Gate (Npgsql 10 + Hangfire) - compatibility-gate: - name: Compatibility Gate (Npgsql 10 + Hangfire) - runs-on: ubuntu-latest - needs: build-and-test - services: - postgres: - image: postgis/postgis:16-3.4 - env: - POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD || 'test123' }} - POSTGRES_USER: ${{ secrets.POSTGRES_USER || 'postgres' }} - POSTGRES_DB: meajudaai_compat - ports: - - 5432:5432 - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Setup .NET - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '10.0.x' - - - name: ️ Setup PostgreSQL Connection - id: pg-conn - uses: ./.github/actions/setup-postgres-connection - with: - postgres-db: meajudaai_compat - postgres-user: ${{ secrets.POSTGRES_USER || 'postgres' }} - postgres-password: ${{ secrets.POSTGRES_PASSWORD || 'test123' }} - - - name: Run Hangfire Compatibility Tests - env: - ASPNETCORE_ENVIRONMENT: Testing - MEAJUDAAI_DB_HOST: localhost - MEAJUDAAI_DB_PORT: 5432 - MEAJUDAAI_DB: meajudaai_compat - MEAJUDAAI_DB_USER: ${{ secrets.POSTGRES_USER || 'postgres' }} - MEAJUDAAI_DB_PASS: ${{ secrets.POSTGRES_PASSWORD || 'test123' }} - run: | - echo "================================" - echo "VALIDATING HANGFIRE + NPGSQL 10 COMPATIBILITY" - echo "================================" - dotnet test tests/MeAjudaAi.Integration.Tests/MeAjudaAi.Integration.Tests.csproj \ - --filter "FullyQualifiedName~HangfirePostgreSqlTests" \ - --configuration Release --verbosity normal - - # Job 3: Markdown Link Validation - markdown-link-check: - name: Validate Markdown Links - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Check markdown links with lychee - uses: lycheeverse/lychee-action@v2 - with: - # Check all markdown files in the repository using config file - args: --config config/lychee.toml --verbose --no-progress "**/*.md" - # Fail the job if broken links are found - fail: true - # Generate job summary - jobSummary: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # Job 4: Infrastructure Validation (Optional) - validate-infrastructure: - name: Validate Infrastructure - runs-on: ubuntu-latest - needs: build-and-test - if: false # Disabled until Azure credentials are configured - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Login to Azure - uses: azure/login@v3 - with: - creds: ${{ secrets.AZURE_CREDENTIALS }} - - - name: Validate Bicep templates - run: | - az bicep build --file infrastructure/main.bicep - # Validate Bicep only if the resource group exists - if az group exists --name ${{ env.AZURE_RESOURCE_GROUP_DEV }} --output tsv | grep true; then - az deployment group validate \ - --resource-group ${{ env.AZURE_RESOURCE_GROUP_DEV }} \ - --template-file infrastructure/main.bicep \ - --parameters environmentName=dev location=${{ env.AZURE_LOCATION }} - else - echo "Resource group '${{ env.AZURE_RESOURCE_GROUP_DEV }}' does not exist, skipping Bicep validation" - fi - - # Job 5: Deploy to Development (Optional) - deploy-dev: - name: Deploy to Development - runs-on: ubuntu-latest - needs: [build-and-test, validate-infrastructure] - if: false # Disabled until Azure credentials and environment are configured - # environment: development - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Login to Azure - uses: azure/login@v3 - with: - creds: ${{ secrets.AZURE_CREDENTIALS }} - - - name: Create Resource Group - if: github.event.inputs.deploy_infrastructure == 'true' || github.event.inputs.deploy_infrastructure == '' - run: | - az group create \ - --name ${{ env.AZURE_RESOURCE_GROUP_DEV }} \ - --location ${{ env.AZURE_LOCATION }} - - - name: Deploy Infrastructure - if: github.event.inputs.deploy_infrastructure == 'true' || github.event.inputs.deploy_infrastructure == '' - run: | - DEPLOYMENT_NAME="meajudaai-dev-$(date +%s)" - az deployment group create \ - --name "$DEPLOYMENT_NAME" \ - --resource-group ${{ env.AZURE_RESOURCE_GROUP_DEV }} \ - --template-file infrastructure/main.bicep \ - --parameters environmentName=dev location=${{ env.AZURE_LOCATION }} - # Export infrastructure outputs for reference - az deployment group show \ - --name "$DEPLOYMENT_NAME" \ - --resource-group ${{ env.AZURE_RESOURCE_GROUP_DEV }} \ - --query "properties.outputs" > infrastructure-outputs.json - echo "Infrastructure outputs:" - cat infrastructure-outputs.json - # Get connection string for development use - SERVICE_BUS_NAMESPACE=$(jq -r '.serviceBusNamespace.value' infrastructure-outputs.json) - MANAGEMENT_POLICY_NAME=$(jq -r '.managementPolicyName.value' infrastructure-outputs.json) - CONNECTION_STRING=$(az servicebus namespace authorization-rule keys list \ - --resource-group ${{ env.AZURE_RESOURCE_GROUP_DEV }} \ - --namespace-name "$SERVICE_BUS_NAMESPACE" \ - --name "$MANAGEMENT_POLICY_NAME" \ - --query "primaryConnectionString" \ - --output tsv) - echo "Infrastructure deployed successfully!" - echo "Service Bus Namespace: $SERVICE_BUS_NAMESPACE" - echo "To use locally, set: export Messaging__ServiceBus__ConnectionString='[CONNECTION_STRING]'" - - - name: Upload infrastructure outputs - if: github.event.inputs.deploy_infrastructure == 'true' || github.event.inputs.deploy_infrastructure == '' - uses: actions/upload-artifact@v4 - with: - name: infrastructure-outputs-dev - path: infrastructure-outputs.json - retention-days: 30 - compression-level: 6 - if-no-files-found: error - - - name: Cleanup after test (if requested) - if: github.event.inputs.cleanup_after_test == 'true' - run: | - echo "Cleaning up dev resources as requested..." - az group delete --name ${{ env.AZURE_RESOURCE_GROUP_DEV }} --yes --no-wait - echo "Cleanup initiated (resources will be deleted in a few minutes)" diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml deleted file mode 100644 index e649b3237..000000000 --- a/.github/workflows/pr-validation.yml +++ /dev/null @@ -1,1335 +0,0 @@ ---- -name: Pull Request Validation - -"on": - push: - branches: [master, develop] - pull_request: - branches: ["**"] - # Manual trigger for testing workflow changes - workflow_dispatch: - -permissions: - contents: read - pull-requests: write - checks: write - statuses: write - -env: - DOTNET_VERSION: "10.0.x" - STRICT_COVERAGE: false # TODO: Re-set to true once coverage threshold (90%) is consistently met - # PostgreSQL configuration (DRY principle - single source of truth) - # Fallback credentials: Only used in fork/local dev; main repo requires secrets - POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD || 'test123' }} - POSTGRES_USER: ${{ secrets.POSTGRES_USER || 'postgres' }} - POSTGRES_DB: ${{ secrets.POSTGRES_DB || 'meajudaai_test' }} - -jobs: - # Job 1: Code Quality Checks - code-quality: - name: Code Quality Checks - runs-on: ubuntu-latest - timeout-minutes: 60 # Prevent jobs from hanging indefinitely - - services: - postgres: - image: postgis/postgis:16-3.4 - env: - # Using workflow-level environment variables (defined at top) - POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }} - POSTGRES_USER: ${{ env.POSTGRES_USER }} - POSTGRES_DB: ${{ env.POSTGRES_DB }} - POSTGRES_HOST_AUTH_METHOD: md5 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - - azurite: - image: mcr.microsoft.com/azure-storage/azurite:latest - ports: - - 10000:10000 - - 10001:10001 - - 10002:10002 - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Setup .NET - uses: actions/setup-dotnet@v5 - with: - dotnet-version: ${{ env.DOTNET_VERSION }} - - - name: Validate Secrets Configuration - run: | - echo "Using PostgreSQL credentials with fallback defaults" - echo "POSTGRES_USER: ${{ env.POSTGRES_USER }}" - echo "POSTGRES_DB: ${{ env.POSTGRES_DB }}" - echo "POSTGRES_PASSWORD: [REDACTED]" - - echo "Database configuration validated" - - - name: Check Keycloak Configuration - env: - KEYCLOAK_ADMIN_PASSWORD: ${{ secrets.KEYCLOAK_ADMIN_PASSWORD }} - run: | - echo " Checking Keycloak configuration..." - if [ -z "$KEYCLOAK_ADMIN_PASSWORD" ]; then - echo "ℹ️ KEYCLOAK_ADMIN_PASSWORD secret not configured - Keycloak is optional" - echo " To enable Keycloak authentication features, configure the secret in:" - echo " Settings → Secrets and variables → Actions → KEYCLOAK_ADMIN_PASSWORD" - echo " Tests will continue without Keycloak-dependent features" - else - echo "Keycloak secrets configured - authentication features enabled" - fi - - - name: Install PostgreSQL client - run: | - sudo apt-get update - sudo apt-get install -y postgresql-client - - - name: Restore dependencies - run: dotnet restore MeAjudaAi.slnx - - - name: Build solution - run: dotnet build MeAjudaAi.slnx --configuration Release --no-restore - - - name: Setup Node.js environment - uses: actions/setup-node@v6 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: ./src/Web/package-lock.json - - - name: Install Frontend Dependencies - working-directory: ./src/Web - run: npm ci - - - uses: nrwl/nx-set-shas@v5 - - # Generate API client BEFORE lint/test so MeAjudaAi.Web.Customer has generated types - - name: Generate API Client - working-directory: ./src/Web - run: | - set -e - # 1. Install Swashbuckle CLI to extract OpenAPI spec from DLL - dotnet tool install -g Swashbuckle.AspNetCore.Cli --version 10.1.5 - - # 2. Extract OpenAPI spec (v1) to a temporary file - # Note: Using Release build DLL from current workspace - mkdir -p ../api - - # Provide dummy env vars to allow application DI container to build successfully - # Note: ASPNETCORE_ENVIRONMENT must NOT be "Testing" here because Swashbuckle CLI - # appends the env name to "Startup" (e.g. "StartupTesting") when resolving the host, - # and no such class exists. Using "Development" avoids this quirk. - # Migrations__Enabled=false skips ApplyModuleMigrationsAsync (no real DB in CI). - export ASPNETCORE_ENVIRONMENT=Development - export ConnectionStrings__DefaultConnection="Host=localhost;Database=dummy" - export Migrations__Enabled=false - - # Use glob to find the DLL without hardcoding TFM (e.g. net10.0) - # The path is relative to the current step context (root or working-directory) - DLL_PATH=$(find ../../src/Bootstrapper/MeAjudaAi.ApiService/bin/Release -name "MeAjudaAi.ApiService.dll" | head -n 1) - if [ -z "$DLL_PATH" ]; then - echo "Error: MeAjudaAi.ApiService.dll not found!" - exit 1 - fi - echo "Found DLL at: $DLL_PATH" - - swagger tofile --output ../api/api-spec.json "$DLL_PATH" v1 - - # 3. Generate API clients for all Web Apps - # The generator reads OPENAPI_SPEC_URL from env - export OPENAPI_SPEC_URL="$GITHUB_WORKSPACE/src/api/api-spec.json" - npm run generate:api --workspace=meajudaai.web.customer - npm run generate:api --workspace=meajudaai.web.admin - npm run generate:api --workspace=meajudaai.web.provider - - - name: Lint Frontend Workspace - working-directory: ./src/Web - run: npx nx affected --target=lint - - - name: Test Frontend Workspace - working-directory: ./src/Web - run: npx nx affected --target=test - - - name: ️ Build Frontend Workspace - working-directory: ./src/Web - env: - # Dummy values required so Next.js can collect page data during build. - # auth.ts throws at module load time if these are missing, causing SSG to fail. - KEYCLOAK_ADMIN_CLIENT_ID: ci-build-placeholder - KEYCLOAK_ADMIN_CLIENT_SECRET: ci-build-placeholder - KEYCLOAK_ISSUER: http://localhost:8080/realms/meajudaai - NEXTAUTH_URL: http://localhost:3000 - NEXTAUTH_SECRET: ci-build-placeholder - AUTH_SECRET: ci-build-placeholder - run: | - set -e - # 1. Explicitly build the customer web app (uses generated API types) - npx nx run MeAjudaAi.Web.Customer:build - - # 2. Run the affected build for the rest of the workspace - npx nx affected --target=build --exclude=MeAjudaAi.Web.Customer - - - name: Prepare Aspire Integration Tests - run: | - echo " Preparing .NET Aspire for integration tests..." - echo "ℹ️ Note: Aspire workload is deprecated in .NET 10 - using NuGet packages instead" - - # In .NET 10, the Aspire orchestration package needs to be explicitly restored - # The package name varies by platform RID - echo " Downloading Aspire DCP orchestration package for linux-x64..." - - # Download the orchestration package directly - # Note: The version should match the Aspire.AppHost.Sdk version (13.0.2) - dotnet nuget locals all --list - - # Force download of the DCP package by adding it temporarily to a dummy project - TEMP_DIR=$(mktemp -d) - cd "$TEMP_DIR" - dotnet new console -n TempDcpDownloader - cd TempDcpDownloader - - # Add the Aspire orchestration package for Linux - dotnet add package Aspire.Hosting.Orchestration.linux-x64 --version 13.0.2 - dotnet restore - - # Return to workspace - cd "$GITHUB_WORKSPACE" - rm -rf "$TEMP_DIR" - - # Verify DCP binaries were downloaded (now located in NuGet package cache) - echo " Verifying DCP binaries in NuGet cache..." - - # DCP binaries come from Aspire.Hosting.Orchestration. NuGet package - # For linux-x64, the package is aspire.hosting.orchestration.linux-x64 - # NuGet packages are case-sensitive on Linux, so we need to search for the exact case - DCP_PACKAGE_PATH=$(find "$HOME/.nuget/packages" -maxdepth 1 -type d \ - -iname "aspire.hosting.orchestration.linux-x64" 2>/dev/null | head -n 1) - - if [ -z "$DCP_PACKAGE_PATH" ]; then - echo "Error: aspire.hosting.orchestration.linux-x64 package not found in NuGet cache" - echo "Searching for all Aspire orchestration packages..." - find "$HOME/.nuget/packages" -maxdepth 1 -type d \ - -iname "*aspire*orchestration*" 2>/dev/null || echo "No Aspire orchestration packages found" - echo "Listing all Aspire packages:" - find "$HOME/.nuget/packages" -maxdepth 1 -type d \ - -iname "*aspire*" 2>/dev/null || echo "No Aspire packages found" - echo "Integration tests require Aspire DCP binaries - failing fast" - exit 1 - fi - - echo "Found Aspire package at: $DCP_PACKAGE_PATH" - - # Find DCP binary in the downloaded package - DCP_BINARY=$(find "$DCP_PACKAGE_PATH" -type f -name "dcp" \ - 2>/dev/null | head -n 1) - - if [ -z "$DCP_BINARY" ]; then - echo "Error: DCP binary not found in package" - echo "Package structure:" - ls -la "$DCP_PACKAGE_PATH" || true - echo "Integration tests require Aspire DCP binaries - failing fast" - exit 1 - fi - - echo "Aspire DCP binaries verified: $DCP_BINARY" - - # Set DOTNET_ROOT for SDK discovery - # Note: This export to $GITHUB_ENV should persist to later steps, but the - # "Generate Aggregated Coverage Report" step re-detects DOTNET_ROOT dynamically - # as a fallback in case this export doesn't reliably persist across jobs. - echo "DOTNET_ROOT=$HOME/.dotnet" >> $GITHUB_ENV - - - name: Check code formatting - run: | - set -o pipefail - echo " Checking code formatting..." - # Only check whitespace and style (not SonarQube analyzer warnings) - dotnet format --verify-no-changes \ - --include whitespace style \ - --verbosity normal \ - MeAjudaAi.slnx \ - 2>&1 | tee format-output.txt - FORMAT_EXIT_CODE=${PIPESTATUS[0]} - - # Check if any files were actually formatted (not just warnings) - if grep -q "Formatted code file" format-output.txt; then - echo "Code formatting issues found." - echo "Run 'dotnet format --include whitespace style' locally to fix." - grep "Formatted code file" format-output.txt - exit 1 - elif [ $FORMAT_EXIT_CODE -ne 0 ]; then - echo "Formatting check failed with exit code $FORMAT_EXIT_CODE" - exit $FORMAT_EXIT_CODE - else - echo "No formatting changes needed" - fi - - - name: Wait for PostgreSQL to be ready - env: - PGPASSWORD: ${{ env.POSTGRES_PASSWORD }} - POSTGRES_USER: ${{ env.POSTGRES_USER }} - run: | - echo " Waiting for PostgreSQL to be ready..." - echo "Debug: POSTGRES_USER=$POSTGRES_USER" - echo "Debug: Checking PostgreSQL availability..." - - counter=1 - max_attempts=60 - - while [ $counter -le $max_attempts ]; do - if pg_isready -h localhost -p 5432 -U "$POSTGRES_USER"; then - echo "PostgreSQL is ready!" - break - fi - echo "Waiting for PostgreSQL... ($counter/$max_attempts)" - sleep 3 - counter=$((counter + 1)) - done - - # Check if we exited the loop due to timeout - if ! pg_isready -h localhost -p 5432 -U "$POSTGRES_USER"; then - echo "PostgreSQL failed to become ready within 180 seconds" - echo "Debug: Checking PostgreSQL logs..." - echo "Service container id: ${{ job.services.postgres.id }}" - docker logs "${{ job.services.postgres.id }}" || echo "Could not get PostgreSQL logs" - exit 1 - fi - - - name: Setup PostgreSQL connection - id: db - uses: ./.github/actions/setup-postgres-connection - with: - postgres-host: localhost - postgres-port: 5432 - postgres-db: ${{ env.POSTGRES_DB }} - postgres-user: ${{ env.POSTGRES_USER }} - postgres-password: ${{ env.POSTGRES_PASSWORD }} - - - name: Run Unit Tests - env: - ASPNETCORE_ENVIRONMENT: Testing - # Pre-built connection string (optional, takes precedence if available) - DB_CONNECTION_STRING: ${{ secrets.DB_CONNECTION_STRING }} - # PostgreSQL connection for CI - EXTERNAL_POSTGRES_HOST: localhost - EXTERNAL_POSTGRES_PORT: 5432 - MEAJUDAAI_DB_HOST: localhost - MEAJUDAAI_DB_PORT: 5432 - MEAJUDAAI_DB_PASS: ${{ env.POSTGRES_PASSWORD }} - MEAJUDAAI_DB_USER: ${{ env.POSTGRES_USER }} - MEAJUDAAI_DB: ${{ env.POSTGRES_DB }} - # Legacy environment variables for compatibility - DB_HOST: localhost - DB_PORT: 5432 - DB_PASSWORD: ${{ env.POSTGRES_PASSWORD }} - DB_USERNAME: ${{ env.POSTGRES_USER }} - DB_NAME: ${{ env.POSTGRES_DB }} - # Keycloak settings - KEYCLOAK_ADMIN_PASSWORD: ${{ secrets.KEYCLOAK_ADMIN_PASSWORD }} - # Azure Storage (Azurite emulator) - # TEST CREDENTIALS ONLY - These are the standard Azurite local emulator credentials - # These are intentionally public and documented at: - # https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite#well-known-storage-account-and-key - AZURE_STORAGE_CONNECTION_STRING: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://localhost:10000/devstoreaccount1;" - AzureStorage__ConnectionString: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://localhost:10000/devstoreaccount1;" - # Map connection strings to .NET configuration using double underscore - ConnectionStrings__DefaultConnection: ${{ steps.db.outputs.connection-string }} - ConnectionStrings__Users: ${{ steps.db.outputs.connection-string }} - ConnectionStrings__Search: ${{ steps.db.outputs.connection-string }} - ConnectionStrings__meajudaai-db: ${{ steps.db.outputs.connection-string }} - run: | - set -euo pipefail - echo " Executando testes com cobertura consolidada..." - - # Function to escape single quotes in PostgreSQL connection string values - escape_single_quotes() { - echo "$1" | sed "s/'/''/g" - } - - # Build .NET connection string from PostgreSQL secrets with proper quoting - # Check if a pre-built connection string secret exists first - if [ -n "${DB_CONNECTION_STRING:-}" ]; then - export ConnectionStrings__DefaultConnection="$DB_CONNECTION_STRING" - echo "Using pre-built connection string from DB_CONNECTION_STRING secret" - else - # Build connection string with proper Npgsql quoting for special characters - ESCAPED_DB=$(escape_single_quotes "$MEAJUDAAI_DB") - ESCAPED_USER=$(escape_single_quotes "$MEAJUDAAI_DB_USER") - ESCAPED_PASS=$(escape_single_quotes "$MEAJUDAAI_DB_PASS") - - DB_CONN_STR="Host=localhost;Port=5432;Database='$ESCAPED_DB'" - DB_CONN_STR="${DB_CONN_STR};Username='$ESCAPED_USER';Password='$ESCAPED_PASS'" - export ConnectionStrings__DefaultConnection="$DB_CONN_STR" - echo "Built connection string from PostgreSQL secrets with proper quoting" - fi - - # Test database connection first - echo "Testing database connection..." - PGPASSWORD="$MEAJUDAAI_DB_PASS" \ - psql -h localhost \ - -U "$MEAJUDAAI_DB_USER" \ - -d "$MEAJUDAAI_DB" \ - -c "SELECT 1;" || { - echo "Database connection failed — aborting workflow" - echo " Ensure PostgreSQL service is running and secrets are configured:" - echo " - POSTGRES_PASSWORD, POSTGRES_USER, POSTGRES_DB" - echo " Integration tests require database connectivity to validate changes" - exit 1 - } - - # Remove any existing coverage data - rm -rf ./coverage - mkdir -p ./coverage - - echo " Running unit tests with coverage for all modules..." - - # Define modules for coverage testing - # STRATEGY: Comprehensive coverage for Domain, Application, and critical Shared/ApiService layers - # - Domain/Application: Core business logic (80-100% target) - # - Shared/ApiService: Reusable infrastructure, middlewares, extensions (60-80% target) - # - Excluded: Integration tests, E2E tests, Architecture tests (tested separately below) - # - Frontend (bUnit): Separated into dedicated step below for better log analysis - # - # RATIONALE FOR INFRASTRUCTURE EXCLUSION: - # Infrastructure tests are excluded from module-based coverage runs (--filter) because: - # 1. Infrastructure tests often require real dependencies (databases, message brokers, etc.) - # 2. Mixing unit tests (mocked) with infrastructure tests distorts coverage metrics - # 3. Infrastructure/Integration tests run separately (lines 345-366) with proper test environment - # 4. This separation ensures unit test coverage accurately reflects business logic coverage - # - # FORMAT: "ModuleName:path/to/module/tests/:IncludeFilter" - MODULES=( - # Domain module tests - "Users:src/Modules/Users/Tests/:MeAjudaAi.Modules.Users.*" - "Providers:src/Modules/Providers/Tests/:MeAjudaAi.Modules.Providers.*" - "Documents:src/Modules/Documents/Tests/:MeAjudaAi.Modules.Documents.*" - "ServiceCatalogs:src/Modules/ServiceCatalogs/Tests/:MeAjudaAi.Modules.ServiceCatalogs.*" - "Locations:src/Modules/Locations/Tests/:MeAjudaAi.Modules.Locations.*" - "SearchProviders:src/Modules/SearchProviders/Tests/:MeAjudaAi.Modules.SearchProviders.*" - - # System/Shared tests (626+ tests - HIGH PRIORITY) - "Shared:tests/MeAjudaAi.Shared.Tests/:MeAjudaAi.Shared" - "ApiService:tests/MeAjudaAi.ApiService.Tests/:MeAjudaAi.ApiService" - ) - - # Source shared utility functions once before module loop - RUNSETTINGS_SCRIPT="./.github/scripts/generate-runsettings.sh" - if [ ! -f "$RUNSETTINGS_SCRIPT" ] || [ ! -r "$RUNSETTINGS_SCRIPT" ]; then - echo "ERROR: Required script not found or not readable: $RUNSETTINGS_SCRIPT" - exit 1 - fi - if ! source "$RUNSETTINGS_SCRIPT"; then - echo "ERROR: Failed to source $RUNSETTINGS_SCRIPT (exit code: $?)" - exit 1 - fi - - # Run unit tests for each module with coverage - for module_info in "${MODULES[@]}"; do - IFS=':' read -r module_name module_path include_pattern <<< "$module_info" - - if [ -d "$module_path" ]; then - echo "================================" - echo " UNIT TESTS - $module_name (WITH COVERAGE)" - echo "================================" - - # Create specific output directory for this module - MODULE_COVERAGE_DIR="./coverage/${module_name,,}" - mkdir -p "$MODULE_COVERAGE_DIR" - - # CRITICAL: Include ALL assemblies matching the pattern to get full coverage - # The Include filter determines which assemblies are instrumented for coverage - # Using broad patterns ensures we capture all source code - if [ -n "$include_pattern" ]; then - INCLUDE_FILTER="[${include_pattern}]*" - else - # Default to all MeAjudaAi assemblies if no specific pattern - INCLUDE_FILTER="[MeAjudaAi*]*" - fi - - # Validate filter is not empty or trivial - if [ "$INCLUDE_FILTER" = "[]*" ] || [ "$INCLUDE_FILTER" = "[].*" ]; then - echo " INCLUDE_FILTER is trivial, falling back to [MeAjudaAi*]*" - INCLUDE_FILTER="[MeAjudaAi*]*" - fi - - # NOTE: EXCLUDE_FILTER excludes test assemblies, migrations, and compiler-generated code - # This ensures coverage metrics reflect only hand-written production code - # Excluded patterns: - # - Test assemblies: [*.Tests*]*, [*Test*]*, [testhost]* - # - Migrations: [*]*Migrations* (any class/namespace containing "Migrations") - # - Database: [*]*.Database (database-related generated code) - # - Contracts: [*]*.Contracts (DTOs, no logic to test) - # - DbContextFactory: [*]*DbContextFactory* (EF Core design-time factories) - # - Program: [*]*.Program (application entry points) - # - OpenApi generated: [*Microsoft.AspNetCore.OpenApi.Generated*]* - # - Compiler services: [*System.Runtime.CompilerServices*]* - # - Regex generator: [*System.Text.RegularExpressions.Generated*]* - EXCLUDE_FILTER="[*.Tests*]*,[*Test*]*,[testhost]*,[*]*Migrations*,[*]*.Database,[*]*.Contracts,[*]*DbContextFactory*,[*]*.Program,[*Microsoft.AspNetCore.OpenApi.Generated*]*,[*System.Runtime.CompilerServices*]*,[*System.Text.RegularExpressions.Generated*]*" - EXCLUDE_BY_FILE="**/*OpenApi*.generated.cs,**/System.Runtime.CompilerServices*.cs,**/*RegexGenerator.g.cs,**/Migrations/*.cs,**/Migrations/**/*.cs,**/Database/*.cs,**/*DbContextFactory.cs,**/Program.cs" - - echo " Include: $INCLUDE_FILTER" - echo " Exclude: $EXCLUDE_FILTER" - echo " ExcludeByFile: $EXCLUDE_BY_FILE" - - # Create temporary runsettings file for this module with proper XML escaping - RUNSETTINGS_FILE="/tmp/${module_name,,}.runsettings" - EXCLUDE_BY_ATTRIBUTE="Obsolete,GeneratedCode,CompilerGenerated" - generate_runsettings \ - "$RUNSETTINGS_FILE" \ - "$EXCLUDE_FILTER" \ - "$EXCLUDE_BY_FILE" \ - "$EXCLUDE_BY_ATTRIBUTE" \ - "$INCLUDE_FILTER" - - - # NOTE: We run ALL tests (no --filter) because: - # 1. Tests should exercise all code paths - # 2. Include/Exclude filters in .runsettings control COVERAGE (what code is instrumented) - # 3. Test filters only control which TESTS run, not what CODE is covered - dotnet test "$module_path" \ - --configuration Release \ - --verbosity normal \ - --collect:"XPlat Code Coverage" \ - --results-directory "$MODULE_COVERAGE_DIR" \ - --logger "trx;LogFileName=${module_name,,}-test-results.trx" \ - --settings "/tmp/${module_name,,}.runsettings" - - TEST_EXIT_CODE=$? - if [ $TEST_EXIT_CODE -ne 0 ]; then - echo "Tests failed for $module_name with exit code $TEST_EXIT_CODE" - exit $TEST_EXIT_CODE - fi - - # Find and rename the coverage file to a predictable name - if [ -d "$MODULE_COVERAGE_DIR" ]; then - echo " Searching for coverage files in $MODULE_COVERAGE_DIR..." - echo " Directory contents:" - find "$MODULE_COVERAGE_DIR" -type f -name "*.xml" | head -10 - - # Look for cobertura format (search recursively in GUID subdirs) - COVERAGE_FILE=$(find "$MODULE_COVERAGE_DIR" -type f \ - -name "coverage.cobertura.xml" \ - -print -quit) - if [ -f "$COVERAGE_FILE" ]; then - echo "Found coverage file: $COVERAGE_FILE" - # Move to standardized name (removes the GUID-based original to avoid double-counting) - mv "$COVERAGE_FILE" "$MODULE_COVERAGE_DIR/${module_name,,}.cobertura.xml" - else - echo " Coverage file not found for $module_name" - echo " Available XML files:" - find "$MODULE_COVERAGE_DIR" -name "*.xml" -type f | head -5 - fi - else - echo "Coverage directory not found: $MODULE_COVERAGE_DIR" - fi - else - echo " $module_name tests not found at $module_path - skipping" - fi - done - - echo "" - echo " COVERAGE FILES SUMMARY" - echo "=========================" - echo "Total coverage XML files generated:" - find ./coverage -type f -name "*.cobertura.xml" | wc -l - echo "" - echo "Coverage files by module:" - find ./coverage -type f -name "*.cobertura.xml" -printf "%f\n" | sort | head -20 - echo "" - - echo " DEBUG: Verifying coverage files for CodeCoverageSummary..." - for module in users providers documents servicecatalogs locations searchproviders shared apiservice; do - if [ -f "coverage/${module}/${module}.cobertura.xml" ]; then - echo " coverage/${module}/${module}.cobertura.xml exists" - # Show file size and first line-rate value as sanity check - FILE_SIZE=$(stat -c%s "coverage/${module}/${module}.cobertura.xml" 2>/dev/null || echo "unknown") - COVERAGE=$(grep -o 'line-rate="[^"]*"' "coverage/${module}/${module}.cobertura.xml" 2>/dev/null | head -1 | cut -d'"' -f2 || echo "N/A") - echo " Size: ${FILE_SIZE} bytes, Coverage Rate: ${COVERAGE}" - else - echo " coverage/${module}/${module}.cobertura.xml NOT FOUND" - fi - done - echo "" - - - name: Run Architecture Tests - if: always() - run: | - echo " Running Architecture tests (no database required)..." - - # Only run Architecture tests in PR validation (fast, no database dependency) - if [ -d "tests/MeAjudaAi.Architecture.Tests/" ]; then - echo "================================" - echo " ARCHITECTURE TESTS" - echo "================================" - dotnet test "tests/MeAjudaAi.Architecture.Tests/" \ - --configuration Release \ - --no-build \ - --verbosity normal \ - --logger "trx;LogFileName=architecture-test-results.trx" - - TEST_EXIT_CODE=$? - if [ $TEST_EXIT_CODE -ne 0 ]; then - echo "Architecture tests failed with exit code $TEST_EXIT_CODE" - exit $TEST_EXIT_CODE - fi - else - echo " Architecture tests not found - skipping" - fi - - - name: Free Disk Space for Integration Tests - run: | - echo " Freeing disk space before integration tests..." - - # Show disk usage before cleanup - echo " Disk usage before cleanup:" - df -h - - # Remove Docker images and containers that are not needed - echo "️ Removing unused Docker resources..." - docker system prune -af --volumes || true - - # Remove large packages/tools not needed for tests - echo "️ Removing unnecessary packages..." - sudo apt-get clean - sudo rm -rf /usr/local/lib/android || true - sudo rm -rf /opt/ghc || true - sudo rm -rf /usr/local/.ghcup || true - - # Show disk usage after cleanup - echo " Disk usage after cleanup:" - df -h - - echo "Disk space cleanup completed" - - - name: Run Integration Tests - env: - ASPNETCORE_ENVIRONMENT: Testing - INTEGRATION_TESTS: true - MEAJUDAAI_DB_PASS: ${{ env.POSTGRES_PASSWORD }} - MEAJUDAAI_DB_USER: ${{ env.POSTGRES_USER }} - MEAJUDAAI_DB: ${{ env.POSTGRES_DB }} - ConnectionStrings__DefaultConnection: ${{ steps.db.outputs.connection-string }} - ConnectionStrings__Users: ${{ steps.db.outputs.connection-string }} - ConnectionStrings__Search: ${{ steps.db.outputs.connection-string }} - ConnectionStrings__meajudaai-db: ${{ steps.db.outputs.connection-string }} - run: | - echo " Running Integration tests..." - - # Source shared utility functions with existence check - RUNSETTINGS_SCRIPT="./.github/scripts/generate-runsettings.sh" - if [ ! -f "$RUNSETTINGS_SCRIPT" ] || [ ! -r "$RUNSETTINGS_SCRIPT" ]; then - echo "ERROR: Required script not found or not readable: $RUNSETTINGS_SCRIPT" - exit 1 - fi - if ! source "$RUNSETTINGS_SCRIPT"; then - echo "ERROR: Failed to source $RUNSETTINGS_SCRIPT (exit code: $?)" - exit 1 - fi - - if [ -d "tests/MeAjudaAi.Integration.Tests/" ]; then - echo "================================" - echo " INTEGRATION TESTS" - echo "================================" - - # Create runsettings with same exclusions as unit tests - INTEGRATION_RUNSETTINGS="/tmp/integration.runsettings" - # Exclude compiler-generated files AND test assembly files from coverage - EXCLUDE_BY_FILE="**/*OpenApi*.generated.cs,**/System.Runtime.CompilerServices*.cs,**/*RegexGenerator.g.cs" - EXCLUDE_BY_ATTRIBUTE="Obsolete,GeneratedCode,CompilerGenerated" - EXCLUDE_FILTER="[*.Tests]*,[*.Tests.*]*,[*Test*]*,[testhost]*" - INCLUDE_FILTER="[MeAjudaAi*]*" # Instrument all MeAjudaAi assemblies - - generate_runsettings \ - "$INTEGRATION_RUNSETTINGS" \ - "$EXCLUDE_FILTER" \ - "$EXCLUDE_BY_FILE" \ - "$EXCLUDE_BY_ATTRIBUTE" \ - "$INCLUDE_FILTER" - - dotnet test "tests/MeAjudaAi.Integration.Tests/" \ - --configuration Release \ - --no-build \ - --verbosity normal \ - --logger "trx;LogFileName=integration-test-results.trx" \ - --collect:"XPlat Code Coverage" \ - --results-directory ./coverage/integration \ - --settings "$INTEGRATION_RUNSETTINGS" - - TEST_EXIT_CODE=$? - if [ $TEST_EXIT_CODE -ne 0 ]; then - echo "Integration tests failed with exit code $TEST_EXIT_CODE" - exit $TEST_EXIT_CODE - fi - else - echo " Integration tests not found - skipping" - fi - - - name: Run E2E Tests - env: - ASPNETCORE_ENVIRONMENT: Testing - INTEGRATION_TESTS: true - MEAJUDAAI_DB_PASS: ${{ env.POSTGRES_PASSWORD }} - MEAJUDAAI_DB_USER: ${{ env.POSTGRES_USER }} - MEAJUDAAI_DB: ${{ env.POSTGRES_DB }} - ConnectionStrings__DefaultConnection: ${{ steps.db.outputs.connection-string }} - ConnectionStrings__Users: ${{ steps.db.outputs.connection-string }} - ConnectionStrings__Search: ${{ steps.db.outputs.connection-string }} - ConnectionStrings__meajudaai-db: ${{ steps.db.outputs.connection-string }} - run: | - echo " Running E2E tests..." - - # Source shared utility functions with existence check - RUNSETTINGS_SCRIPT="./.github/scripts/generate-runsettings.sh" - if [ ! -f "$RUNSETTINGS_SCRIPT" ] || [ ! -r "$RUNSETTINGS_SCRIPT" ]; then - echo "ERROR: Required script not found or not readable: $RUNSETTINGS_SCRIPT" - exit 1 - fi - if ! source "$RUNSETTINGS_SCRIPT"; then - echo "ERROR: Failed to source $RUNSETTINGS_SCRIPT (exit code: $?)" - exit 1 - fi - - if [ -d "tests/MeAjudaAi.E2E.Tests/" ]; then - echo "================================" - echo " E2E TESTS" - echo "================================" - - # Create runsettings with same exclusions as unit tests - E2E_RUNSETTINGS="/tmp/e2e.runsettings" - # Exclude compiler-generated files AND test assembly files from coverage - EXCLUDE_BY_FILE="**/*OpenApi*.generated.cs,**/System.Runtime.CompilerServices*.cs,**/*RegexGenerator.g.cs" - EXCLUDE_BY_ATTRIBUTE="Obsolete,GeneratedCode,CompilerGenerated" - EXCLUDE_FILTER="[*.Tests]*,[*.Tests.*]*,[*Test*]*,[testhost]*" - INCLUDE_FILTER="[MeAjudaAi*]*" # Instrument all MeAjudaAi assemblies - - generate_runsettings \ - "$E2E_RUNSETTINGS" \ - "$EXCLUDE_FILTER" \ - "$EXCLUDE_BY_FILE" \ - "$EXCLUDE_BY_ATTRIBUTE" \ - "$INCLUDE_FILTER" - - dotnet test "tests/MeAjudaAi.E2E.Tests/" \ - --configuration Release \ - --no-build \ - --verbosity normal \ - --logger "trx;LogFileName=e2e-test-results.trx" \ - --collect:"XPlat Code Coverage" \ - --results-directory ./coverage/e2e \ - --settings "$E2E_RUNSETTINGS" - - TEST_EXIT_CODE=$? - if [ $TEST_EXIT_CODE -ne 0 ]; then - echo "E2E tests failed with exit code $TEST_EXIT_CODE" - exit $TEST_EXIT_CODE - fi - else - echo " E2E tests not found - skipping" - fi - - echo "" - echo "All test categories completed" - - - name: Generate Aggregated Coverage Report - if: always() - run: | - echo " Generating aggregated coverage report from ALL tests..." - - # Verify .NET 10 installation - echo " Verifying .NET installation..." - dotnet --version - dotnet --list-sdks - dotnet --list-runtimes - - # Pre-flight check: verify all expected coverage files exist - echo " Pre-flight check: Verifying coverage files..." - MISSING_FILES=0 - for module in users providers documents servicecatalogs locations searchproviders shared apiservice; do - if ! find "coverage/${module}" -name "*.cobertura.xml" -type f | grep -q .; then - echo " Warning: No .cobertura.xml files found for ${module}" - MISSING_FILES=$((MISSING_FILES + 1)) - else - echo " Found coverage file(s) for ${module}" - fi - done - - # Check for Integration/E2E coverage - if find "coverage/integration" -name "*.xml" -type f | grep -q .; then - echo " Found Integration test coverage" - fi - if find "coverage/e2e" -name "*.xml" -type f | grep -q .; then - echo " Found E2E test coverage" - fi - - if [ $MISSING_FILES -gt 0 ]; then - echo " Warning: $MISSING_FILES module(s) missing coverage files" - echo " ReportGenerator will combine only available modules" - fi - - - name: Configure DOTNET_ROOT for ReportGenerator - run: | - # ReportGenerator needs DOTNET_ROOT to find .NET runtime - # Set to system installation path where .NET 10 runtime is installed - if command -v dotnet >/dev/null 2>&1; then - DOTNET_PATH=$(which dotnet) - DOTNET_RESOLVED=$(readlink -f "$DOTNET_PATH") - export DOTNET_ROOT=$(dirname "$DOTNET_RESOLVED") - echo "DOTNET_ROOT=$DOTNET_ROOT" >> $GITHUB_ENV - echo "Set DOTNET_ROOT: $DOTNET_ROOT" - - # Validate DOTNET_ROOT directory - if [ ! -d "$DOTNET_ROOT" ]; then - echo "ERROR: DOTNET_ROOT directory does not exist: $DOTNET_ROOT" - exit 1 - fi - - echo " Available runtimes:" - dotnet --list-runtimes - else - echo "dotnet command not found" - exit 1 - fi - - - name: Generate aggregated coverage report - uses: danielpalme/ReportGenerator-GitHub-Action@5 - env: - DOTNET_ROOT: ${{ env.DOTNET_ROOT }} - with: - reports: "coverage/**/*.cobertura.xml" - targetdir: "coverage/aggregate" - reporttypes: "Cobertura;JsonSummary" - assemblyfilters: "+MeAjudaAi.Modules.Users.*;+MeAjudaAi.Modules.Providers.*;+MeAjudaAi.Modules.Documents.*;+MeAjudaAi.Modules.ServiceCatalogs.*;+MeAjudaAi.Modules.Locations.*;+MeAjudaAi.Modules.SearchProviders.*;+MeAjudaAi.Shared*;+MeAjudaAi.ApiService*" - classfilters: "-*.Tests;-*.Tests.*;-*Test*;-testhost;-xunit*;-*.Migrations.*;-*.Contracts;-*.Database;-*DbContextFactory;-*.Program;-*.Keycloak.*;-*.Monitoring.*;-*NoOp*;-*RabbitMq*;-*ServiceBus*;-*Hangfire*;-*.Jobs.*;-*Options;-*BaseDesignTimeDbContextFactory*;-*SchemaPermissionsManager;-*SimpleHostEnvironment;-*CacheWarmupService;-*GeoPointConverter;-*ModuleNames;-*ModuleApiInfo;-*MessagingExtensions;-*ICacheableQuery" - filefilters: "-*Migrations*" - verbosity: "Info" - tag: "${{ github.run_number }}_${{ github.run_id }}" - # Note: Backend coverage only. Frontend bUnit tests run separately without coverage (unreliable for Blazor WASM). - - - name: Display coverage summary - run: | - if [ -f "coverage/aggregate/Cobertura.xml" ]; then - echo "Aggregated coverage report generated: coverage/aggregate/Cobertura.xml" - # Show summary stats from Cobertura XML - COVERAGE=$(grep -o 'line-rate="[^"]*"' coverage/aggregate/Cobertura.xml | head -1 | cut -d'"' -f2 | awk '{printf "%.2f", $1 * 100}' || echo "N/A") - echo " Combined Line Coverage (Unit + Integration + E2E): ${COVERAGE}%" - else - echo "Failed to generate aggregated coverage report" - fi - - - name: Validate namespace reorganization - run: | - echo " Validating namespace reorganization..." - if grep -R -nE '^[[:space:]]*using[[:space:]]+MeAjudaAi\.Shared\.Common;' -- src/ \ - 2>/dev/null; then - echo "Found old namespace imports" - exit 1 - else - echo "Conformidade com namespaces validada" - fi - - - name: Upload coverage reports - uses: actions/upload-artifact@v7 - if: always() - with: - name: coverage-reports - path: coverage/** - retention-days: 7 - compression-level: 6 - if-no-files-found: ignore - - - name: Upload Test Results - uses: actions/upload-artifact@v7 - if: always() - with: - name: test-results - path: "**/*.trx" - retention-days: 7 - compression-level: 6 - if-no-files-found: ignore - - - name: List Coverage Files (Debug) - run: | - echo " Listing coverage files for debugging..." - echo "Coverage directory structure:" - find ./coverage -type f 2>/dev/null | head -20 || echo "No files found in coverage directory" - echo "" - echo "OpenCover XML files (Backward Compatibility):" - find ./coverage -name "*.opencover.xml" -type f 2>/dev/null || echo "No .opencover.xml files found" - echo "" - echo "Cobertura XML files:" - find ./coverage -name "*.cobertura.xml" -type f 2>/dev/null || echo "No .cobertura.xml files found" - echo "" - echo "Any XML files:" - find ./coverage -name "*.xml" -type f 2>/dev/null || echo "No XML files found" - echo "" - echo "Coverage directory contents:" - ls -la ./coverage/ 2>/dev/null || echo "Coverage directory not found" - echo "" - echo "Checking for coverage.xml files:" - find ./coverage -name "coverage.xml" -type f 2>/dev/null || echo "No coverage.xml files found" - - - name: Fix Coverage Files (if needed) - run: | - echo " Attempting to fix coverage file locations and names..." - - # Find any coverage.xml files and rename them to appropriate format - find ./coverage -name "coverage.xml" -type f | while read -r file; do - dir=$(dirname "$file") - module=$(basename "$dir") - new_file="$dir/$module.cobertura.xml" - echo "Copying $file to $new_file" - cp "$file" "$new_file" - done - - # Find coverage files in nested directories and copy to module directories - find ./coverage -type f \ - \( -name "coverage.opencover.xml" -o -name "coverage.cobertura.xml" \) \ - | while read -r file; do - # Get the module directory (should be like ./coverage/users/) - module_dir=$(echo "$file" | sed 's|coverage/\([^/]*\)/.*|coverage/\1|') - module_name=$(basename "$module_dir") - - # Determine target file based on source format - if [[ "$file" == *"cobertura"* ]]; then - target_file="$module_dir/$module_name.cobertura.xml" - else - target_file="$module_dir/$module_name.opencover.xml" - fi - - if [ "$file" != "$target_file" ]; then - echo "Copying $file to $target_file" - cp "$file" "$target_file" 2>/dev/null || true - fi - done - - echo "Coverage files after processing:" - find ./coverage -name "*.xml" -type f 2>/dev/null || echo "No XML coverage files found" - - - name: Code Coverage Summary - id: coverage_cobertura_aggregate - continue-on-error: true - uses: irongut/CodeCoverageSummary@v1.3.0 - with: - filename: "coverage/aggregate/Cobertura.xml" - badge: true - fail_below_min: false - format: markdown - hide_branch_rate: false - hide_complexity: false - indicators: true - output: both - thresholds: "80 90" - - - name: Alternative Coverage Summary (Cobertura format) - id: coverage_cobertura - if: ${{ always() && steps.coverage_cobertura_aggregate.outcome != 'success' }} - uses: irongut/CodeCoverageSummary@v1.3.0 - with: - filename: "coverage/users/*.cobertura.xml,coverage/providers/*.cobertura.xml,coverage/documents/*.cobertura.xml,coverage/servicecatalogs/*.cobertura.xml,coverage/locations/*.cobertura.xml,coverage/searchproviders/*.cobertura.xml,coverage/shared/*.cobertura.xml,coverage/apiservice/*.cobertura.xml" - badge: true - fail_below_min: false - format: markdown - hide_branch_rate: false - hide_complexity: false - indicators: true - output: both - thresholds: "80 90" - continue-on-error: true - - - name: Fallback Coverage Summary (any XML) - id: coverage_fallback - if: >- - ${{ always() && - steps.coverage_cobertura_aggregate.outcome != 'success' && - steps.coverage_cobertura.outcome != 'success' }} - uses: irongut/CodeCoverageSummary@v1.3.0 - with: - filename: "coverage/**/*.xml" - badge: true - fail_below_min: false - format: markdown - hide_branch_rate: false - hide_complexity: false - indicators: true - output: both - thresholds: "80 90" - continue-on-error: true - - - name: Display Coverage Percentages - if: always() - run: | - echo " CODE COVERAGE SUMMARY" - echo "========================" - echo "" - - # Analyze coverage from the 8 backend modules + shared - MODULES=("users" "providers" "documents" "servicecatalogs" "locations" "searchproviders" "shared" "apiservice") - - for module in "${MODULES[@]}"; do - coverage_file="./coverage/${module}/${module}.cobertura.xml" - if [ -f "$coverage_file" ]; then - echo " Module: ${module^}" - # Extract coverage statistics from Cobertura XML - if command -v awk >/dev/null 2>&1; then - # Cobertura uses line-rate="0.85" format - line_rate=$(grep -o 'line-rate="[^"]*"' "$coverage_file" | head -1 | cut -d'"' -f2 || echo "N/A") - branch_rate=$(grep -o 'branch-rate="[^"]*"' "$coverage_file" | head -1 | cut -d'"' -f2 || echo "N/A") - - if [ "$line_rate" != "N/A" ]; then - lines_covered=$(echo "$line_rate" | awk '{printf "%.2f", $1 * 100}') - echo " Line Coverage: ${lines_covered}%" - fi - if [ "$branch_rate" != "N/A" ]; then - branch_covered=$(echo "$branch_rate" | awk '{printf "%.2f", $1 * 100}') - echo " Branch Coverage: ${branch_covered}%" - fi - fi - echo "" - fi - done - - echo " For detailed coverage report, check the 'Code Coverage Summary' step above" - echo " Minimum thresholds: 80% (warning) / 90% (good)" - - - name: Select Coverage Outputs - id: select_coverage_outputs - if: always() - run: | - echo " Detecting coverage files..." - - if [ -f "coverage/aggregate/Cobertura.xml" ]; then - COVERAGE_FILE="coverage/aggregate/Cobertura.xml" - SOURCE="Cobertura (Aggregated Direct)" - echo "Found aggregated Cobertura file: $COVERAGE_FILE" - elif find coverage -name "*.opencover.xml" -type f | head -1 >/dev/null 2>&1; then - COVERAGE_FILE=$(find coverage -name "*.opencover.xml" -type f | head -1) - SOURCE="OpenCover (Direct)" - echo "Found OpenCover file: $COVERAGE_FILE" - elif find coverage -name "*.cobertura.xml" -type f | head -1 >/dev/null 2>&1; then - COVERAGE_FILE=$(find coverage -name "*.cobertura.xml" -type f | head -1) - SOURCE="Cobertura (Direct)" - echo "Found Cobertura file: $COVERAGE_FILE" - elif find coverage -name "*.xml" -type f | head -1 >/dev/null 2>&1; then - COVERAGE_FILE=$(find coverage -name "*.xml" -type f | head -1) - SOURCE="XML (Direct)" - echo "Found XML file: $COVERAGE_FILE" - else - COVERAGE_FILE="" - SOURCE="None" - echo "No coverage files found" - fi - - if [ -n "$COVERAGE_FILE" ]; then - # Try to extract basic coverage percentage from XML - if command -v grep >/dev/null 2>&1; then - # Look for line-rate or sequenceCoverage attributes - LINE_RATE=$(grep -o 'line-rate="[^"]*"' "$COVERAGE_FILE" 2>/dev/null | head -1 | cut -d'"' -f2) - if [ -z "$LINE_RATE" ]; then - LINE_RATE=$(grep -o 'sequenceCoverage="[^"]*"' "$COVERAGE_FILE" 2>/dev/null | head -1 | cut -d'"' -f2) - fi - - if [ -n "$LINE_RATE" ]; then - # Convert decimal to percentage if needed (auto-detect format) - # If value <= 1.0, assume decimal (0.75 -> 75.00), else assume already percentage (75.5 -> 75.50) - PERCENTAGE=$(echo "$LINE_RATE" | awk '{if ($1 <= 1.0) printf "%.2f", $1 * 100; else printf "%.2f", $1}') - SUMMARY="**Coverage**: ${PERCENTAGE}% (extracted from $SOURCE)" - BADGE="![Coverage](https://img.shields.io/badge/coverage-${PERCENTAGE}%25-brightgreen)" - else - SUMMARY="**Coverage**: Available (file found, percentage not extracted)" - BADGE="![Coverage](https://img.shields.io/badge/coverage-available-blue)" - fi - else - SUMMARY="**Coverage**: Files found but could not extract percentage" - BADGE="![Coverage](https://img.shields.io/badge/coverage-found-blue)" - fi - else - SUMMARY="Coverage data not available" - BADGE="" - fi - - # Export outputs - echo "source=$SOURCE" >> $GITHUB_OUTPUT - echo "badge=$BADGE" >> $GITHUB_OUTPUT - echo "summary=$SUMMARY" >> $GITHUB_OUTPUT - - # Export multiline summary using heredoc - { - echo 'summary<> $GITHUB_OUTPUT - - echo "Coverage source: $SOURCE" - echo "Summary: $SUMMARY" - - - name: Add Coverage PR Comment - uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2 - if: github.event_name == 'pull_request' - with: - recreate: true - header: coverage-report - message: | - ## Code Coverage Report - - ${{ steps.select_coverage_outputs.outputs.summary }} - - ### Coverage Details - - **Coverage badges**: ${{ steps.select_coverage_outputs.outputs.badge }} - - **Minimum threshold**: 80% (warning) / 90% (good) - - **Report format**: Auto-detected from OpenCover/Cobertura XML files - - **Coverage source**: ${{ steps.select_coverage_outputs.outputs.source }} - - ### Coverage Analysis - - **Line Coverage**: Shows percentage of code lines executed during tests - - **Branch Coverage**: Shows percentage of code branches/conditions tested - - **Complexity**: Code complexity metrics for maintainability - - ### Quality Gates - - **Pass**: Coverage ≥ 90% - - **Warning**: Coverage 80-89% - - **Fail**: Coverage < 80% - - ### Artifacts - - **Coverage reports**: Available in workflow artifacts - - **Test results**: TRX files with detailed test execution data - - *This comment is updated automatically on each push to track coverage trends.* - - # Refactored: Extracted to reusable action for better maintainability - # See: .github/actions/validate-coverage/README.md for documentation - - name: Validate Coverage Thresholds - if: always() - uses: ./.github/actions/validate-coverage - with: - coverage-directory: "./coverage" - threshold: "90" - strict-mode: ${{ env.STRICT_COVERAGE }} - opencover-outcome: "" - cobertura-outcome: ${{ steps.coverage_cobertura.outcome }} - fallback-outcome: ${{ steps.coverage_fallback.outcome }} - - # Job 2: Security Scan (Consolidated) - security-scan: - name: Security Scan - runs-on: ubuntu-latest - timeout-minutes: 30 # Security scans shouldn't take more than 30 minutes - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Setup .NET - uses: actions/setup-dotnet@v5 - with: - dotnet-version: ${{ env.DOTNET_VERSION }} - - - name: Restore dependencies - run: dotnet restore MeAjudaAi.slnx - - - name: Run Security Audit - run: dotnet list package --vulnerable --include-transitive - - - name: Install jq - run: sudo apt-get update && sudo apt-get install -y jq - - - name: OSV-Scanner (fail on HIGH/CRITICAL) - run: | - echo " Installing OSV-Scanner..." - # Install OSV-Scanner with pinned version for reproducibility - OSV_VERSION="v1.8.3" - OSV_URL="https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}" - - # Retry logic for network transient failures - max_retries=3 - retry_count=0 - - while [ $retry_count -lt $max_retries ]; do - if curl -sSfL --connect-timeout 10 --max-time 30 \ - "${OSV_URL}/osv-scanner_linux_amd64" -o osv-scanner; then - echo "Download successful" - chmod +x osv-scanner - break - else - retry_count=$((retry_count + 1)) - if [ $retry_count -lt $max_retries ]; then - echo " Download failed (attempt $retry_count/$max_retries), retrying in 5s..." - sleep 5 - else - echo "Download failed after $max_retries attempts" - echo " Trying fallback: install via Go toolchain..." - - # Fallback: Install via Go if available - if command -v go >/dev/null 2>&1; then - echo "ℹ️ Using Go to install OSV-Scanner..." - go install github.com/google/osv-scanner/cmd/osv-scanner@${OSV_VERSION} - # Copy to local dir for consistent usage - cp "$(go env GOPATH)/bin/osv-scanner" ./osv-scanner 2>/dev/null || \ - ln -s "$(go env GOPATH)/bin/osv-scanner" ./osv-scanner - else - echo "Go toolchain not available, cannot install OSV-Scanner" - echo " CRITICAL: Vulnerability scan cannot be performed" - echo " MITIGATION: Ensure runner has Go ≥1.18 installed or provide pre-built OSV-Scanner binary" - echo " SECURITY NOTE: This workflow MUST fail when vulnerability scanning is unavailable." - echo " To fix: ensure Go is installed on the runner or configure a pre-built OSV-Scanner binary." - exit 1 # Fail the workflow - security scanning is mandatory - fi - fi - fi - done - - echo "OSV-Scanner version and help:" - ./osv-scanner --version || echo "Version command failed" - ./osv-scanner scan --help | head -20 || echo "Help command failed" - - echo " Running vulnerability scan..." - # Run OSV-Scanner and capture exit code - osv_exit_code=0 - ./osv-scanner scan --recursive --skip-git . || osv_exit_code=$? - - if [ $osv_exit_code -eq 0 ]; then - echo "No vulnerabilities found" - else - echo "OSV-Scanner found vulnerabilities (exit code: $osv_exit_code)" - echo " Running detailed scan for analysis..." - - # Run again with JSON output for detailed analysis - ./osv-scanner scan --recursive --skip-git --format json . > osv-results.json || true - - if [ -f osv-results.json ]; then - # Count HIGH/CRITICAL by CVSS (>=7.0) with safe parsing and textual severity mapping - HIGH_OR_CRIT=$( - jq -r ' - # Function to safely convert severity scores to numbers - def safe_score_to_number: - if type == "number" then . - elif type == "string" then - (tonumber? // ( - (. | ascii_upcase) as $upper - | if $upper == "CRITICAL" then 9 - elif $upper == "HIGH" then 7 - elif $upper == "MEDIUM" then 5 - elif $upper == "LOW" then 3 - else 0 - end - )) - else 0 - end; - - [.results[]?.packages[]?.vulnerabilities[]? - | ( [(.severity // [])[]?.score? | safe_score_to_number] | max // 0 ) - | select(. >= 7.0) - ] | length - ' osv-results.json 2>/dev/null - ) - - if [ "${HIGH_OR_CRIT:-0}" -gt 0 ]; then - echo "Found $HIGH_OR_CRIT HIGH/CRITICAL vulnerabilities" - echo " First 10 vulnerabilities found:" - # Use inline jq program to display vulnerability summary - jq -r '.results[]?.packages[]?.vulnerabilities[]? | - "- \(.id): \(.summary // "No summary")"' \ - osv-results.json 2>/dev/null | head -10 - echo "" - echo " Security scan failed - vulnerabilities detected" - echo " Please review and fix vulnerabilities before merging" - - # Fail the workflow - exit 1 - else - echo "No HIGH/CRITICAL vulnerabilities found" - # Count total vulnerabilities for info - TOTAL_VULNS=$(jq -r '.results[]?.packages[]?.vulnerabilities[]? | .id' \ - osv-results.json 2>/dev/null | wc -l) - if [ "$TOTAL_VULNS" -gt 0 ]; then - echo "ℹ️ Found $TOTAL_VULNS low/medium severity vulnerabilities (ignored)" - fi - fi - else - echo "OSV-Scanner failed but no results file generated" - exit 1 - fi - fi - - - name: Secret Detection with TruffleHog - if: ${{ github.event_name == 'pull_request' }} - uses: trufflesecurity/trufflehog@main - with: - path: ./ - base: ${{ github.event.pull_request.base.ref }} - head: ${{ github.event.pull_request.head.sha }} - extra_args: --debug --only-verified - # Job 3: Markdown Link Validation (Simplified) - markdown-link-check: - name: Validate Markdown Links - runs-on: ubuntu-latest - timeout-minutes: 15 # Link validation should be quick - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Cache lychee results - uses: actions/cache@v5 - with: - path: .lycheecache - key: lychee-${{ runner.os }}-${{ hashFiles('**/*.md','config/lychee.toml') }} - restore-keys: | - lychee-${{ runner.os }}- - - - name: Check markdown links with lychee - uses: lycheeverse/lychee-action@v2.7.0 - with: - # Use simplified configuration for reliability - args: >- - --config config/lychee.toml - --no-progress - --cache - --max-cache-age 1d - "docs/**/*.md" - "README.md" - # Don't fail the entire pipeline on link check failures - fail: false - jobSummary: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Report link check results - if: always() - run: | - echo " Link validation completed (non-blocking)" - echo "ℹ️ Check job summary for detailed results" - - # Job 4: Simple YAML Validation (Quiet) - yaml-validation: - name: YAML Syntax Check - runs-on: ubuntu-latest - timeout-minutes: 10 # YAML validation is quick - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Install yamllint - run: python3 -m pip install yamllint - - - name: Validate workflow files only - run: | - echo " Validating critical YAML files..." - if ! python3 -m yamllint -c config/.yamllint.yml .github/workflows/; then - echo "YAML validation failed" - echo "ℹ️ Check yamllint output above for details" - exit 1 - fi - echo "YAML validation completed" diff --git a/.gitignore b/.gitignore index de90ac397..9d50210ac 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,8 @@ coverage.cobertura.xml coverage/ coverage*/ test-coverage*/ +coverage-global/ +.nyc_output/ htmlcov/ lcov.info *.lcov @@ -156,4 +158,17 @@ legacy-analysis-report.* **/out/ **/generated/ -vite.config.*.timestamp* \ No newline at end of file +vite.config.*.timestamp* + +# Ignore Windows null device files +nul +# MkDocs output +site/ + +# Aspire +src/Aspire/MeAjudaAi.AppHost/infrastructure/ + +# Playwright +test-results/ +playwright-report/ +playwright/.cache/ diff --git a/Directory.Packages.props b/Directory.Packages.props index 1eb22144f..de8f1c8e4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -112,8 +112,8 @@ - - + + @@ -139,7 +139,7 @@ - + diff --git a/README.md b/README.md index d16c727ab..39a75618f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Uma plataforma abrangente de serviços construída com .NET Aspire, projetada para conectar prestadores de serviços com clientes usando arquitetura monólito modular. - + ## 🎯 Visão Geral @@ -20,9 +20,10 @@ O **MeAjudaAi** é uma plataforma moderna de marketplace de serviços que implem - **.NET 10.0.2** - Framework principal - **.NET Aspire 13.1** - Orquestração e observabilidade -- **Blazor WebAssembly 10.0.2** - Admin Portal SPA -- **MudBlazor 8.15.0** - Material Design UI components -- **Fluxor 6.9.0** - Redux state management +- **React 19 + Next.js 15** - Frontend Web Apps (Customer, Provider, Admin) +- **Tailwind CSS v4** - Styling +- **Zustand + TanStack Query** - State management +- **Playwright** - E2E Testing - **Entity Framework Core 10.0.2** - ORM e persistência - **Microsoft.OpenApi 2.6.1** - OpenAPI specification - **SonarAnalyzer.CSharp 10.19.0** - Code quality analysis @@ -102,9 +103,10 @@ O projeto foi organizado para facilitar navegação e manutenção: │ ├── Bootstrapper/ # API Service entry point │ ├── Modules/ # Módulos de domínio (DDD) │ ├── Shared/ # Contratos e abstrações -│ └── Web/ # Aplicações Web -│ ├── MeAjudaAi.Web.Admin/ # Admin Portal (Blazor WASM) -│ └── meajudaai-web-customer/ # Customer Web App (Next.js 15) +│ └── Web/ # Aplicações Web (NX Workspace) +│ ├── MeAjudaAi.Web.Admin/ # Admin Portal (React + Next.js 15) +│ ├── MeAjudaAi.Web.Customer/ # Customer Web App (Next.js 15) +│ └── MeAjudaAi.Web.Provider/ # Provider Web App (Next.js 15) ├── 📁 tests/ # Testes automatizados (xUnit v3) └── 📁 tools/ # Ferramentas de desenvolvimento └── api-collections/ # Gerador Bruno/Postman collections @@ -136,7 +138,7 @@ O projeto foi organizado para facilitar navegação e manutenção: | Serviço | URL | Credenciais | Descrição | |---------|-----|-------------|--------------| | **Aspire Dashboard** | https://localhost:17063/ | - | Orquestração e observabilidade | -| **Admin Portal** | https://localhost:7032/ | admin.portal/admin123 | Portal administrativo Blazor | +| **Admin Portal** | https://localhost:7032/ | admin.portal/admin123 | Portal administrativo React + Next.js | | **Customer Web App** | http://localhost:3000/ | - | Aplicação pública Next.js (clientes/prestadores) | | **API** | https://localhost:7524/swagger | - | API REST com Swagger UI | | **Keycloak** | http://localhost:8080/ | admin/[console logs] | Autenticação OAuth2/OIDC | @@ -258,7 +260,7 @@ dotnet test tests/MeAjudaAi.Modules.Users.Tests/ ## 🎨 Admin Portal -**Portal administrativo** Blazor WebAssembly para gestão completa da plataforma. +**Portal administrativo** React + Next.js para gestão completa da plataforma. **Funcionalidades:** - ✅ Autenticação via Keycloak OIDC (Authorization Code + PKCE) @@ -267,9 +269,8 @@ dotnet test tests/MeAjudaAi.Modules.Users.Tests/ - ✅ Gestão de Documentos (upload, OCR, verificação) - ✅ Gestão de Service Catalogs (categorias + serviços) - ✅ Restrições Geográficas (cidades permitidas) -- ✅ Dark Mode com Fluxor state management -- ✅ Localização completa em português -- ✅ 43 testes bUnit (componentes principais) +- ✅ Admin Portal React com Tailwind CSS +- ✅ E2E Tests com Playwright **Como Executar:** diff --git a/build_errors.txt b/build_errors.txt new file mode 100644 index 000000000..897f83895 Binary files /dev/null and b/build_errors.txt differ diff --git a/coverlet.runsettings b/coverlet.runsettings index 5ab38303c..e50ad7d09 100644 --- a/coverlet.runsettings +++ b/coverlet.runsettings @@ -6,9 +6,13 @@ cobertura [MeAjudaAi*]* - [*.Tests*]*,[*Test*]*,[testhost]*,[*]*Migrations*,[*]*.Database,[*]*DbContextFactory*,[*]*.Program,[*Microsoft.AspNetCore.OpenApi.Generated*]*,[*System.Runtime.CompilerServices*]*,[*System.Text.RegularExpressions.Generated*]* - **/Migrations/*.cs,**/Migrations/**/*.cs,**/Database/*.cs,**/*DbContextFactory.cs,**/Program.cs + [*.Tests*]*,[*Test*]*,[testhost]*,[*]*Migrations*,[*]*.DbContextFactory,[*Microsoft.AspNetCore.OpenApi.Generated*]*,[*System.Runtime.CompilerServices*]*,[*System.Text.RegularExpressions.Generated*]* + **/Migrations/*.cs,**/Migrations/**/*.cs,**/*DbContextFactory.cs Obsolete,GeneratedCode,CompilerGenerated + + 0 + line + total diff --git a/docs/admin-portal/architecture.md b/docs/admin-portal/architecture.md index 8388b9b7d..bfd6242e2 100644 --- a/docs/admin-portal/architecture.md +++ b/docs/admin-portal/architecture.md @@ -1,507 +1,93 @@ -# Admin Portal - Arquitetura +# Admin Portal - Arquitetura (React) -## 🏗️ Visão Geral Arquitetural +O Admin Portal é uma aplicação web moderna construída com **React 19** e **Next.js 15 (App Router)**, focada na gestão administrativa do ecossistema MeAjudaAi. -O Admin Portal segue uma arquitetura **Flux/Redux** implementada com **Fluxor**, garantindo state management previsível e unidirecional. +## 🏗️ Visão Geral Arquitetural -## 🔄 Padrão Flux +A arquitetura baseia-se em estados descentralizados e cache inteligente usando **TanStack Query (React Query)**, eliminando a necessidade de Redux/Fluxor para a maioria dos casos de uso. -### Fluxo de Dados Unidirecional +## 🔄 Fluxo de Dados ```mermaid -graph LR - A[User Action] --> B[Dispatch Action] - B --> C[Reducer] - C --> D[New State] - D --> E[UI Update] - E -.User Interaction.-> A - - B --> F[Effect] - F --> G[API Call] - G --> H[Dispatch Success/Failure] - H --> C +graph TD + A[Componente UI] --> B[Custom Hook] + B --> C[TanStack Query] + C --> D[API Service / Axios] + D --> E[MSW - Dev/Test] + D --> F[Backend API - Prod] + F --> G[JSON Response] + G --> D + D --> C + C --> B + B --> A ``` -### Componentes do Padrão - -| Componente | Responsabilidade | Exemplo | -|------------|------------------|---------| -| **Action** | Descreve o que aconteceu | `LoadProvidersAction` | -| **Reducer** | Atualiza o state baseado na action | `ProvidersReducer` | -| **Effect** | Side-effects (API calls, logging) | `ProvidersEffects` | -| **State** | Single source of truth | `ProvidersState` | -| **Selector** | Derivar dados do state | `GetActiveProviders` | - -## 📁 Estrutura de Features - -Cada feature segue a estrutura: +## 📁 Estrutura do Projeto ```text -Features/ -└── Modules/ - └── Providers/ - ├── ProvidersState.cs # State definition - ├── ProvidersActions.cs # All actions - ├── ProvidersReducers.cs # State mutations - ├── ProvidersEffects.cs # Side-effects - └── ProvidersSelectors.cs # (opcional) Derived state -``` - -### Exemplo Completo: Providers Feature - -#### 1. State - -```csharp -[FeatureState] -public record ProvidersState -{ - public IReadOnlyList Providers { get; init; } = []; - public bool IsLoading { get; init; } - public string? ErrorMessage { get; init; } - public int CurrentPage { get; init; } = 1; - public int PageSize { get; init; } = 20; - public int TotalCount { get; init; } - - // Computed properties - public int TotalPages => TotalCount > 0 - ? (int)Math.Ceiling(TotalCount / (double)PageSize) - : 0; - public bool HasPreviousPage => CurrentPage > 1; - public bool HasNextPage => CurrentPage < TotalPages; -} -``` - -#### 2. Actions - -```csharp -// Load -public record LoadProvidersAction(int PageNumber = 1, int PageSize = 20); -public record LoadProvidersSuccessAction( - IReadOnlyList Providers, - int TotalCount, - int PageNumber, - int PageSize); -public record LoadProvidersFailureAction(string ErrorMessage); - -// Delete -public record DeleteProviderAction(Guid ProviderId); -public record DeleteProviderSuccessAction(Guid ProviderId); -public record DeleteProviderFailureAction(Guid ProviderId, string ErrorMessage); - -// Pagination -public record NextPageAction; -public record PreviousPageAction; -public record GoToPageAction(int PageNumber); -``` - -#### 3. Reducers - -```csharp -public class ProvidersReducers -{ - [ReducerMethod] - public static ProvidersState ReduceLoadProvidersAction( - ProvidersState state, - LoadProvidersAction action) => - state with { IsLoading = true, ErrorMessage = null }; - - [ReducerMethod] - public static ProvidersState ReduceLoadProvidersSuccessAction( - ProvidersState state, - LoadProvidersSuccessAction action) => - state with - { - Providers = action.Providers, - TotalCount = action.TotalCount, - CurrentPage = action.PageNumber, - PageSize = action.PageSize, - IsLoading = false, - ErrorMessage = null - }; - - [ReducerMethod] - public static ProvidersState ReduceLoadProvidersFailureAction( - ProvidersState state, - LoadProvidersFailureAction action) => - state with - { - IsLoading = false, - ErrorMessage = action.ErrorMessage - }; -} -``` - -#### 4. Effects - -```csharp -public class ProvidersEffects -{ - private readonly IProvidersApi _providersApi; - private readonly ErrorHandlingService _errorHandler; - private readonly ISnackbar _snackbar; - - [EffectMethod] - public async Task HandleLoadProvidersAction( - LoadProvidersAction action, - IDispatcher dispatcher) - { - var result = await _errorHandler.ExecuteWithErrorHandlingAsync( - ct => _providersApi.GetProvidersAsync(action.PageNumber, action.PageSize, ct), - "Load providers"); - - if (result.IsSuccess) - { - dispatcher.Dispatch(new LoadProvidersSuccessAction( - result.Value.Items, - result.Value.TotalItems, - result.Value.PageNumber, - result.Value.PageSize)); - } - else - { - var errorMessage = _errorHandler.HandleApiError(result, "load providers"); - _snackbar.Add(errorMessage, Severity.Error); - dispatcher.Dispatch(new LoadProvidersFailureAction(errorMessage)); - } - } -} -``` - -#### 5. Uso em Componentes - -```razor -@inherits FluxorComponent -@inject IState ProvidersState -@inject IDispatcher Dispatcher - - - - - -@code { - protected override void OnInitialized() - { - base.OnInitialized(); - Dispatcher.Dispatch(new LoadProvidersAction()); - } -} -``` - -## 🎨 Componentes e Dialogs - -### Decisão Arquitetural: Pragmatic Approach - -**Dialogs NÃO usam Fluxor** - mantêm direct API calls por serem componentes efêmeros. - -**Justificativa**: -- Lifecycle curto (abrir → submit → fechar) -- Sem necessidade de compartilhar estado -- Single Responsibility: apenas submit de formulário -- Simplicidade > Consistência neste caso (YAGNI) - -### Exemplo de Dialog - -```razor -@inject IProvidersApi ProvidersApi -@inject ISnackbar Snackbar - - - - - - - - - - Cancelar - - Salvar - - - - -@code { - [CascadingParameter] MudDialogInstance MudDialog { get; set; } - - private async Task Submit() - { - try - { - var result = await ProvidersApi.UpdateProviderAsync(model); - if (result.IsSuccess) - { - Snackbar.Add("Provedor atualizado com sucesso!", Severity.Success); - MudDialog.Close(DialogResult.Ok(true)); - } - else - { - Snackbar.Add(result.Error?.Message ?? "Erro ao atualizar", Severity.Error); - } - } - catch (Exception ex) - { - Snackbar.Add($"Erro: {ex.Message}", Severity.Error); - } - } +MeAjudaAi.Web.Admin/ +├── app/ # Next.js App Router (Páginas e Layouts) +├── components/ # Componentes React (UI, Layout, Feature-based) +│ ├── ui/ # Componentes base (Shadcn/UI) +│ └── admin/ # Componentes específicos de negócio +├── hooks/ # Custom Hooks (Lógica e Data Fetching) +│ └── admin/ # Hooks de integração com API +├── lib/ # Utilitários, tipos e clientes API +│ ├── api/ # Cliente API gerado via OpenAPI +│ └── utils.ts # Utilitários gerais +└── __tests__/ # Suíte de testes (Vitest + MSW) + ├── components/ # Testes de componentes + ├── hooks/ # Testes de hooks + └── mocks/ # Handlers MSW +``` + +## 🔌 Integração com API + +A integração é feita através de um SDK TypeScript gerado automaticamente a partir da especificação OpenAPI do backend. + +### Custom Hooks Pattern + +Centralizamos a lógica de fetching em hooks para promover reuso e isolamento de efeitos: + +```typescript +// hooks/admin/use-services.ts +export function useServices(categoryId?: string) { + const queryClient = useQueryClient(); + + // Query: Get Services + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'services', categoryId], + queryFn: () => getServices(categoryId), + }); + + // Mutation: Create Service + const createMutation = useMutation({ + mutationFn: (newService: CreateServiceRequest) => postService(newService), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'services'] }), + }); + + return { services: data?.items ?? [], isLoading, createService: createMutation.mutate }; } ``` -## 🔌 API Integration +## 🧪 Estratégia de Testes -### Refit Clients +### Unitários e Integração (Vitest + Testing Library) +- **Componentes**: Validam renderização, estados de loading e interações do usuário. +- **Hooks**: Validam lógica de estado e chamadas de API usando MSW. +- **Mocks**: Handlers MSW simulam o backend com validação estrita de payloads e IDs. -Todos os módulos têm interfaces Refit tipadas: - -```csharp -public interface IProvidersApi -{ - [Get("/api/providers")] - Task>> GetProvidersAsync( - [Query] int pageNumber = 1, - [Query] int pageSize = 20, - CancellationToken cancellationToken = default); - - [Get("/api/providers/{id}")] - Task> GetProviderByIdAsync( - Guid id, - CancellationToken cancellationToken = default); - - [Put("/api/providers/{id}")] - Task UpdateProviderAsync( - Guid id, - [Body] UpdateProviderRequest request, - CancellationToken cancellationToken = default); - - [Delete("/api/providers/{id}")] - Task DeleteProviderAsync( - Guid id, - CancellationToken cancellationToken = default); -} -``` +### E2E (Playwright) +- Validam fluxos críticos como login administrativo, gestão de prestadores e categorias. +- Executados em ambiente isolado (`ci` project) com os 3 apps rodando simultaneamente. -### Registro de Serviços - -```csharp -// Program.cs -builder.Services.AddRefitClient() - .ConfigureHttpClient(c => c.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"])) - .AddStandardResilienceHandler(options => - { - options.Retry.MaxRetryAttempts = 3; - options.Retry.Delay = TimeSpan.FromSeconds(2); - options.Retry.BackoffType = DelayBackoffType.Exponential; - }); -``` - -## 🛡️ Error Handling - -### ErrorHandlingService - -Centraliza tratamento de erros com retry automático: - -```csharp -public class ErrorHandlingService -{ - public async Task> ExecuteWithErrorHandlingAsync( - Func>> operation, - string operationName, - int maxRetries = 3) - { - for (int attempt = 1; attempt <= maxRetries; attempt++) - { - try - { - var result = await operation(CancellationToken.None); - - if (result.IsSuccess || !ShouldRetry(result.Error?.StatusCode)) - return result; - - if (attempt < maxRetries) - { - await Task.Delay(GetRetryDelay(attempt)); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in {Operation}, attempt {Attempt}", - operationName, attempt); - - if (attempt == maxRetries) - return Result.Failure(new Error(500, ex.Message)); - } - } - - return Result.Failure(new Error(500, "Max retries exceeded")); - } - - private bool ShouldRetry(int? statusCode) => - statusCode >= 500 || statusCode == 408; // Server errors + Timeout - - private TimeSpan GetRetryDelay(int attempt) => - TimeSpan.FromSeconds(Math.Pow(2, attempt)); // Exponential backoff -} -``` - -## 🌐 Localização - -### LocalizationService - -Dictionary-based translations com suporte a múltiplos idiomas: - -```csharp -public class LocalizationService -{ - private readonly Dictionary> _translations = new() - { - ["pt-BR"] = new() - { - ["Common.Save"] = "Salvar", - ["Common.Cancel"] = "Cancelar", - ["Providers.Active"] = "Ativo", - // ... - }, - ["en-US"] = new() - { - ["Common.Save"] = "Save", - ["Common.Cancel"] = "Cancel", - ["Providers.Active"] = "Active", - // ... - } - }; - - public string GetString(string key, params object[] args) - { - var culture = CultureInfo.CurrentUICulture.Name; - - if (_translations.TryGetValue(culture, out var cultureDictionary) && - cultureDictionary.TryGetValue(key, out var value)) - { - return args.Length > 0 ? string.Format(value, args) : value; - } - - // Fallback to en-US - return _translations["en-US"].GetValueOrDefault(key, $"[{key}]"); - } - - public void SetCulture(string cultureName) - { - var culture = new CultureInfo(cultureName); - CultureInfo.CurrentCulture = culture; - CultureInfo.CurrentUICulture = culture; - OnCultureChanged?.Invoke(); - } - - public event Action? OnCultureChanged; -} -``` - -## ⚡ Performance Optimizations - -### 1. Virtualization - -```razor - - - -``` - -### 2. Debouncing - -```csharp -public class DebounceHelper -{ - private CancellationTokenSource? _cts; - - public async Task DebounceAsync( - Func> operation, - int millisecondsDelay = 300) - { - _cts?.Cancel(); - _cts = new CancellationTokenSource(); - - try - { - await Task.Delay(millisecondsDelay, _cts.Token); - return await operation(); - } - catch (TaskCanceledException) - { - return default!; - } - } -} -``` - -### 3. Memoization - -```csharp -public class PerformanceHelper -{ - private static readonly Dictionary _cache = new(); - - public static T Memoize(string key, Func factory, TimeSpan? ttl = null) - { - if (_cache.TryGetValue(key, out var cached) && DateTime.UtcNow < cached.Expiry) - { - return (T)cached.Value; - } - - var value = factory(); - var expiry = DateTime.UtcNow + (ttl ?? TimeSpan.FromSeconds(30)); - _cache[key] = (value!, expiry); - return value; - } -} -``` - -## 🧪 Testing - -### bUnit Tests - -```csharp -[Fact] -public void ProvidersPage_ShouldLoadProviders_OnInitialized() -{ - // Arrange - var mockState = new Mock>(); - mockState.Setup(x => x.Value).Returns(new ProvidersState - { - Providers = new List { /* test data */ }, - IsLoading = false - }); - - Services.AddSingleton(mockState.Object); - Services.AddSingleton(new MockDispatcher()); - - // Act - var cut = RenderComponent(); - - // Assert - cut.Find("table").Should().NotBeNull(); - cut.FindAll("tr").Count.Should().BeGreaterThan(1); -} -``` - -## 📊 Métricas de Arquitetura - -### Code Reduction (Flux Refactoring) - -| Página | Antes (LOC) | Depois (LOC) | Redução | -|--------|-------------|--------------|---------| -| Providers.razor | 95 | 18 | 81% | -| Documents.razor | 87 | 12 | 86% | -| Categories.razor | 103 | 18 | 83% | -| Services.razor | 98 | 18 | 82% | -| AllowedCities.razor | 92 | 14 | 85% | -| **TOTAL** | **475** | **80** | **83%** | +## 🛡️ Governança e Qualidade +- **Threshold de Cobertura**: 70% Global (obrigatório no pipeline). +- **Linting**: Regras estritas de ESLint e Prettier. +- **TypeScript**: Modo `strict` habilitado para máxima segurança de tipos. ## 🔗 Referências - -- [Fluxor Documentation](https://github.com/mrpmorris/Fluxor) -- [Flux Pattern Guide](../architecture/flux-pattern-implementation.md) -- [Refit Documentation](https://github.com/reactiveui/refit) -- [MudBlazor Components](https://mudblazor.com/) +- [Next.js Documentation](https://nextjs.org/docs) +- [TanStack Query](https://tanstack.com/query/latest) +- [Vitest](https://vitest.dev/) +- [Mock Service Worker (MSW)](https://mswjs.io/) diff --git a/docs/admin-portal/dashboard.md b/docs/admin-portal/dashboard.md index f0a738850..2abb76021 100644 --- a/docs/admin-portal/dashboard.md +++ b/docs/admin-portal/dashboard.md @@ -1,4 +1,9 @@ -# Admin Portal - Dashboard +# [LEGACY / SUPERSEDED] Admin Portal - Dashboard (Blazor) + +> [!IMPORTANT] +> Esta documentação refere-se ao Dashboard original implementado em MudBlazor e Fluxor. +> O Admin Portal atual utiliza **React/Next.js**. +> Para detalhes dos componentes atuais, consulte [features.md](./features.md). ## 📊 Visão Geral diff --git a/docs/admin-portal/features.md b/docs/admin-portal/features.md index 5e4009fa0..62b96098e 100644 --- a/docs/admin-portal/features.md +++ b/docs/admin-portal/features.md @@ -6,7 +6,7 @@ O Admin Portal oferece gerenciamento completo dos seguintes módulos: ### 1. 👥 Gestão de Prestadores (Providers) -**Página**: `Providers.razor` +**Página**: `/admin/providers` **Permissões**: `ProvidersRead`, `ProvidersUpdate`, `ProvidersApprove`, `ProvidersDelete` #### Funcionalidades @@ -33,17 +33,16 @@ stateDiagram-v2 #### Componentes -- `Providers.razor`: Página principal -- `CreateProviderDialog.razor`: Formulário de criação (removido - seed data) -- `EditProviderDialog.razor`: Formulário de edição -- `VerifyProviderDialog.razor`: Modal de verificação de status -- `ProviderSelectorDialog.razor`: Seletor de provider para associações +- `/admin/providers`: Página principal (Listagem) +- `ProviderDetailsDialog`: Modal com informações completas e histórico +- `EditProviderForm`: Formulário de edição de perfil +- `VerifyProviderStatus`: Componente de alteração de verificação --- ### 2. 📄 Gestão de Documentos (Documents) -**Página**: `Documents.razor` +**Página**: `/admin/documents` **Permissões**: `DocumentsRead`, `DocumentsUpdate`, `DocumentsApprove` #### Funcionalidades @@ -76,7 +75,7 @@ stateDiagram-v2 ### 3. 🗂️ Catálogo de Serviços -**Páginas**: `Categories.razor`, `Services.razor` +**Páginas**: `/admin/categories`, `/admin/services` **Permissões**: `ServiceCatalogsRead`, `ServiceCatalogsUpdate` #### Categories (Categorias) @@ -110,7 +109,7 @@ stateDiagram-v2 ### 4. 📍 Gestão de Localizações (Allowed Cities) -**Página**: `AllowedCities.razor` +**Página**: `/admin/allowed-cities` **Permissões**: `LocationsManage` #### Funcionalidades @@ -141,7 +140,7 @@ Ao adicionar uma cidade, o sistema: ### 5. 📊 Dashboard -**Página**: `Dashboard.razor` +**Página**: `/admin/dashboard` **Permissões**: `ViewerPolicy` (acesso básico) Ver [Dashboard Documentation](dashboard.md) para detalhes completos. @@ -158,24 +157,23 @@ Ver [Dashboard Documentation](dashboard.md) para detalhes completos. ## 🎨 Padrões de UI/UX -### MudBlazor Components +### Componentes React -Todos os módulos utilizam componentes MudBlazor para consistência: +Todos os módulos utilizam componentes React com Tailwind CSS para consistência: -- **MudDataGrid**: Tabelas paginadas com ordenação e filtros -- **MudDialog**: Modais para criação/edição -- **MudForm**: Formulários com validação -- **MudTextField**: Campos de texto com máscaras -- **MudSelect**: Dropdowns para seleção -- **MudChip**: Status badges coloridos -- **MudButton**: Botões de ação +- **DataGrid**: Tabelas paginadas com ordenação e filtros (TanStack Table) +- **Dialog**: Modais para criação/edição +- **Forms**: Formulários com validação (React Hook Form + Zod) +- **Select**: Dropdowns para seleção +- **Badge**: Status badges coloridos +- **Button**: Botões de ação -### Status Chips +### Status Badges -```razor - - @VerificationStatus.ToDisplayName(provider.VerificationStatus) - +```tsx + + {verificationStatusLabels[provider.verificationStatus]} + ``` **Cores Padrão**: @@ -187,19 +185,16 @@ Todos os módulos utilizam componentes MudBlazor para consistência: ### Confirmações de Exclusão -Todas as operações destrutivas requerem confirmação: +Todas as operações destrutivas requerem confirmação via componente `AlertDialog` ou similar do Shadcn/UI: -```csharp -var result = await DialogService.ShowMessageBox( - "Confirmar Exclusão", - "Tem certeza que deseja excluir este item?", - yesText: "Excluir", - cancelText: "Cancelar"); - -if (result == true) -{ - // Executar exclusão -} +```tsx +const handleDelete = async (id: string) => { + const confirmed = await confirmDelete("Tem certeza que deseja excluir este item?"); + if (confirmed) { + await deleteProvider(id); + toast.success("Item removido com sucesso"); + } +}; ``` --- @@ -218,15 +213,33 @@ if (result == true) | Gerenciar Catálogo | `ManagerPolicy` | | Gerenciar Localizações | `AdminPolicy` | -### Exemplo de Uso - -```razor - - - - - +### Exemplo de Uso (Policy Check) + +Utilizamos o sistema de políticas para controle de acesso: + +| can() | Política Equivalente | +|-------|---------------------| +| `ProvidersRead` | `ViewerPolicy` | +| `ProvidersUpdate` | `ManagerPolicy` | +| `ProvidersDelete`, `ProvidersApprove` | `AdminPolicy` | +| `DocumentsRead`, `DocumentsUpdate`, `DocumentsApprove` | `ManagerPolicy` | +| `ServiceCatalogsRead`, `ServiceCatalogsUpdate` | `ManagerPolicy` | +| `LocationsManage` | `AdminPolicy` | + +O mapeamento acima mostra como as permissões granulares (`can('ProvidersDelete')`) mapeiam para as políticas definidas. Em código: + +```tsx +const { can } = usePermissions(); + +return ( + <> + {can('ProvidersDelete') && ( + + )} + +); ``` --- diff --git a/docs/admin-portal/overview.md b/docs/admin-portal/overview.md index 34c7eb14c..9fc35cc29 100644 --- a/docs/admin-portal/overview.md +++ b/docs/admin-portal/overview.md @@ -2,7 +2,7 @@ ## 📋 Introdução -O **Admin Portal** é a interface administrativa da plataforma MeAjudaAi, construída com Blazor WebAssembly para fornecer uma experiência de gerenciamento moderna, responsiva e eficiente. +O **Admin Portal** é a interface administrativa da plataforma MeAjudaAi, construída com React + Next.js para fornecer uma experiência de gerenciamento moderna, responsiva e eficiente. ## 🎯 Propósito @@ -17,27 +17,28 @@ O Admin Portal permite que administradores da plataforma gerenciem: ## 🛠️ Stack Tecnológica ### Frontend -- **Blazor WebAssembly (.NET 10)**: Framework principal para SPA -- **MudBlazor 8.15.0**: Biblioteca de componentes UI Material Design -- **Fluxor**: State management (padrão Flux/Redux) +- **React 19 + Next.js 15**: Framework principal para SPA +- **Tailwind CSS v4**: Biblioteca de estilização +- **Zustand**: State management +- **TanStack Query**: Server state management ### Autenticação - **Keycloak**: Identity Provider (OIDC/OAuth 2.0) -- **PKCE Flow**: Autenticação segura para aplicações públicas +- **NextAuth.js**: Autenticação para Next.js ### Comunicação -- **Refit**: Cliente HTTP tipado para APIs -- **System.Text.Json**: Serialização JSON +- **Axios / Fetch**: Cliente HTTP +- **TanStack Query**: Data fetching e caching ## 🏗️ Arquitetura ```mermaid graph TB - subgraph "Admin Portal (Blazor WASM)" - UI[Pages/Components] - State[Fluxor State] - Effects[Fluxor Effects] - API[API Clients - Refit] + subgraph "Admin Portal (React + Next.js)" + UI[React Components] + Store[Zustand Store] + Query[TanStack Query] + API[API Calls - Fetch] end subgraph "Backend" @@ -47,15 +48,17 @@ graph TB subgraph "Auth" Keycloak[Keycloak] + NextAuth[NextAuth.js] end - UI --> State - State --> Effects - Effects --> API + UI --> Store + UI --> Query + Query --> API API --> Gateway Gateway --> Modules - UI -.Auth.-> Keycloak + UI -.Auth.-> NextAuth + NextAuth -.-> Keycloak API -.JWT.-> Gateway ``` @@ -63,53 +66,67 @@ graph TB ```text src/Web/MeAjudaAi.Web.Admin/ -├── Pages/ # Páginas principais -│ ├── Dashboard.razor -│ ├── Providers.razor -│ ├── Documents.razor -│ ├── Categories.razor -│ ├── Services.razor -│ └── AllowedCities.razor -├── Components/ # Componentes reutilizáveis -│ ├── Dialogs/ # Modais de criação/edição -│ ├── Common/ # Componentes compartilhados -│ └── Accessibility/ # Componentes de acessibilidade -├── Features/ # Fluxor Features (State/Actions/Effects/Reducers) -│ ├── Modules/ -│ │ ├── Providers/ -│ │ ├── Documents/ -│ │ └── ServiceCatalogs/ -│ ├── Dashboard/ -│ └── Theme/ -├── Services/ # Serviços auxiliares -│ ├── ErrorHandlingService.cs -│ ├── LocalizationService.cs -│ └── LiveRegionService.cs -├── Constants/ # Constantes centralizadas -│ ├── ProviderConstants.cs -│ ├── DocumentConstants.cs -│ └── CommonConstants.cs -│ # Nota: Enums e constantes compartilhadas com backend estão em MeAjudaAi.Contracts -│ # Esta pasta contém apenas constantes específicas da UI (ex: layout, cores, timeouts) -├── Helpers/ # Métodos auxiliares -│ ├── AccessibilityHelper.cs -│ ├── PerformanceHelper.cs -│ └── DebounceHelper.cs -└── Layout/ # Layouts e navegação - ├── MainLayout.razor - └── NavMenu.razor +├── app/ # Next.js App Router +│ ├── (auth)/ # Authentication routes +│ │ ├── login/ +│ │ └── layout.tsx +│ ├── (dashboard)/ # Protected routes +│ │ ├── providers/ +│ │ ├── documents/ +│ │ ├── services/ +│ │ ├── cities/ +│ │ ├── dashboard/ +│ │ └── layout.tsx +│ ├── layout.tsx +│ └── page.tsx +├── components/ # Reusable components +│ ├── ui/ # Base UI components (Button, Text, etc.) +│ ├── providers/ # Provider-specific components +│ ├── documents/ # Document-specific components +│ └── common/ # Shared components +├── hooks/ # Custom React hooks +│ ├── useProviders.ts +│ ├── useDocuments.ts +│ └── useTranslation.ts +├── stores/ # Zustand stores +│ ├── providersStore.ts +│ └── uiStore.ts +├── lib/ # Utilities +│ ├── api.ts # API client +│ └── utils.ts +└── types/ # TypeScript types ``` +### Testes E2E + +Localização: `src/Web/MeAjudaAi.Web.Admin/e2e/` + +**Estrutura:** +```text +src/Web/MeAjudaAi.Web.Admin/e2e/ +├── auth.spec.ts +├── providers.spec.ts +├── configs.spec.ts +├── dashboard.spec.ts +└── mobile-responsiveness.spec.ts +``` + +**Fixtures compartilhadas:** `src/Web/libs/e2e-support/base.ts` +- `loginAsAdmin(page)` +- `loginAsProvider(page)` +- `loginAsCustomer(page)` +- `logout(page)` + ## 🔐 Autenticação e Autorização -### Keycloak Configuration +### Keycloak Configuration (NextAuth.js v4) **Realm**: `meajudaai` **Client ID**: `admin-portal` **Flow**: Authorization Code + PKCE **Redirect URIs**: -- `https://localhost:7001/authentication/login-callback` -- `https://localhost:7001/authentication/logout-callback` +- `https://localhost:7001/api/auth/callback/keycloak` +- `https://localhost:7001/api/auth/logout` ### Políticas de Autorização @@ -121,17 +138,43 @@ src/Web/MeAjudaAi.Web.Admin/ ### Uso em Componentes -```razor -@attribute [Authorize(Policy = PolicyNames.AdminPolicy)] - - - - Editar - - - Sem permissão - - +```tsx +// Using NextAuth.js useSession for auth +'use client'; +import { useSession } from 'next-auth/react'; + +export function EditButton({ providerId }: { providerId: string }) { + const { data: session } = useSession(); + + if (session?.user?.role !== 'admin' && session?.user?.role !== 'manager') { + return Sem permissão; + } + + return ; +} + +// Protected route wrapper +'use client'; +import { useRouter } from 'next/navigation'; +import { useSession } from 'next-auth/react'; +import { useEffect } from 'react'; + +function AdminProtected({ children }: { children: React.ReactNode }) { + const { data: session, status } = useSession(); + const router = useRouter(); + + useEffect(() => { + if (status !== 'loading' && !session) { + router.push('/login'); + } + }, [status, session, router]); + + if (status === 'loading') return ; + if (!session) return null; + if (session.user.role !== 'admin') return ; + + return <>{children}; +} ``` ## 🌐 Localização (i18n) @@ -143,11 +186,21 @@ O Admin Portal suporta múltiplos idiomas: ### Uso -```razor -@inject LocalizationService L - -@L.GetString("Common.Save") -@L.GetString("Providers.ItemsFound", count) +```tsx +'use client'; +import { useTranslation } from '@/hooks/useTranslation'; + +export function SaveButton() { + const { t } = useTranslation(); + + return ; +} + +// With interpolation +function ProvidersCount({ count }: { count: number }) { + const { t } = useTranslation(); + return {t('Providers.ItemsFound', { count })}; +} ``` ## ♿ Acessibilidade @@ -164,10 +217,10 @@ O Admin Portal segue as diretrizes **WCAG 2.1 AA**: ### Otimizações Implementadas -- **Virtualization**: MudDataGrid renderiza apenas linhas visíveis -- **Debouncing**: Search com delay de 300ms -- **Memoization**: Cache de resultados filtrados (30s) -- **Lazy Loading**: Componentes carregados sob demanda +- **Virtualization**: TanStack Table com virtualização para renderizar apenas linhas visíveis +- **Debouncing**: Search com delay de 300ms via TanStack Query +- **Memoization**: Cache de resultados filtrados (30s via TanStack Query) +- **Lazy Loading**: Next.js App Router com code splitting automático ### Métricas @@ -180,16 +233,17 @@ O Admin Portal segue as diretrizes **WCAG 2.1 AA**: ## 🧪 Testes -### Cobertura de Testes bUnit +### E2E Tests com Playwright -- **43 testes** implementados -- Testes de páginas, dialogs e componentes -- Integração com Fluxor state +- Testes end-to-end para todos os fluxos principais +- Localização: `src/Web/MeAjudaAi.Web.Admin/e2e/` +- Os testes exercitam o fluxo OAuth via Keycloak (signIn('keycloak')) em vez de formulários de email/senha ### Executar Testes ```bash -dotnet test tests/MeAjudaAi.Web.Admin.Tests/ +cd src/Web +npx playwright test --grep "admin" ``` ## 🚀 Executando Localmente @@ -220,6 +274,8 @@ Acesse: `https://localhost:7001` ## 🔗 Links Úteis -- [MudBlazor Documentation](https://mudblazor.com/) -- [Fluxor Documentation](https://github.com/mrpmorris/Fluxor) -- [Blazor WebAssembly Guide](https://learn.microsoft.com/en-us/aspnet/core/blazor/) +- [Documentação React](https://react.dev/) - Biblioteca de UI +- [Documentação Next.js](https://nextjs.org/docs) - Framework React full-stack +- [Documentação Tailwind CSS](https://tailwindcss.com/docs) - Framework de estilização +- [Documentação TanStack Query](https://tanstack.com/query/latest) - Gerenciamento de estado servidor +- [Documentação Radix UI](https://www.radix-ui.com/) - Componentes UI acessíveis diff --git a/docs/architecture.md b/docs/architecture.md index 7dc4cc75b..e5067f6a8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2778,339 +2778,283 @@ public class UploadDocumentCommandHandler( ## 🎨 Frontend Architecture (Sprint 6+) -### **Blazor WebAssembly + Fluxor + MudBlazor** +### **React + Next.js + Tailwind CSS** -O Admin Portal utiliza Blazor WASM com padrão Flux/Redux para state management e Material Design UI. +O Admin Portal (assim como Customer e Provider Apps) utiliza React 19 com Next.js 15 para frontend web. ```mermaid graph TB - subgraph "🌐 Presentation - Blazor WASM" - PAGES[Pages/Razor Components] - LAYOUT[Layout Components] - AUTH[Authentication.razor] + subgraph "🌐 Presentation - React + Next.js" + PAGES[Pages/App Router] + COMPONENTS[Components] + HOOKS[Custom Hooks] end - subgraph "🔄 State Management - Fluxor" - STATE[States] + subgraph "🔄 State Management - Zustand" + STORE[Global Store] ACTIONS[Actions] - REDUCERS[Reducers] - EFFECTS[Effects] + SELECTORS[Selectors] end - subgraph "🔌 API Layer - Refit" - PROVIDERS_API[IProvidersApi] - SERVICES_API[IServiceCatalogsApi] - HTTP[HttpClient + Auth] + subgraph "🔌 Data Fetching - TanStack Query" + QUERIES[Queries] + MUTATIONS[Mutations] + CACHE[Cache] end - subgraph "🔐 Authentication - OIDC" + subgraph "🔐 Authentication - NextAuth.js" KEYCLOAK[Keycloak OIDC] - TOKEN[Token Manager] + SESSION[Session Provider] end - PAGES --> ACTIONS - ACTIONS --> REDUCERS - REDUCERS --> STATE - STATE --> PAGES - ACTIONS --> EFFECTS - EFFECTS --> PROVIDERS_API - EFFECTS --> SERVICES_API - PROVIDERS_API --> HTTP - HTTP --> TOKEN - TOKEN --> KEYCLOAK + PAGES --> COMPONENTS + COMPONENTS --> HOOKS + HOOKS --> STORE + HOOKS --> QUERIES + QUERIES --> CACHE ``` ### **Stack Tecnológica** | Componente | Tecnologia | Versão | Propósito | |-----------|-----------|--------|-----------| -| **Framework** | Blazor WebAssembly | .NET 10 | SPA client-side | -| **UI Library** | MudBlazor | 7.21.0 | Material Design components | -| **State Management** | Fluxor | 6.1.0 | Redux-pattern state | -| **HTTP Client** | Refit | 9.0.2 | Type-safe API clients | -| **Authentication** | OIDC | WASM.Authentication | Keycloak integration | -| **Testing** | bUnit + xUnit | 1.40.0 + v3.2.1 | Component tests | +| **Framework** | React 19 + Next.js 15 | 19/15 | Full-stack React | +| **UI Library** | Tailwind CSS + Base UI | v4 | Styling + headless components | +| **State Management** | Zustand | 5.x | Simple global state | +| **Data Fetching** | TanStack Query | 5.x | Server state + caching | +| **Forms** | React Hook Form + Zod | 7.x + 3.x | Form handling + validation | +| **Authentication** | NextAuth.js | 5.x | Keycloak integration | +| **Testing** | Playwright | 1.x | E2E tests | -### **Fluxor Pattern - State Management** +> **Nota**: Esta é a arquitetura atual utilizada em produção. -**Implementação do Padrão Flux/Redux**: - -```csharp -// 1. STATE (Immutable) -public record ProvidersState -{ - public List Providers { get; init; } = []; - public bool IsLoading { get; init; } - public string? ErrorMessage { get; init; } - public int PageNumber { get; init; } = 1; - public int PageSize { get; init; } = 20; - public int TotalItems { get; init; } -} - -// 2. ACTIONS (Commands) -public static class ProvidersActions -{ - public record LoadProvidersAction; - public record LoadProvidersSuccessAction(List Providers, int TotalItems); - public record LoadProvidersFailureAction(string ErrorMessage); - public record GoToPageAction(int PageNumber); -} - -// 3. REDUCERS (Pure Functions) -public static class ProvidersReducers -{ - [ReducerMethod] - public static ProvidersState OnLoadProviders(ProvidersState state, LoadProvidersAction _) => - state with { IsLoading = true, ErrorMessage = null }; - - [ReducerMethod] - public static ProvidersState OnLoadSuccess(ProvidersState state, LoadProvidersSuccessAction action) => - state with - { - Providers = action.Providers, - TotalItems = action.TotalItems, - IsLoading = false, - ErrorMessage = null - }; - - [ReducerMethod] - public static ProvidersState OnLoadFailure(ProvidersState state, LoadProvidersFailureAction action) => - state with { IsLoading = false, ErrorMessage = action.ErrorMessage }; - - [ReducerMethod] - public static ProvidersState OnGoToPage(ProvidersState state, GoToPageAction action) => - state with { PageNumber = action.PageNumber }; -} - -// 4. EFFECTS (Side Effects - API Calls) -public class ProvidersEffects -{ - private readonly IProvidersApi _providersApi; - - public ProvidersEffects(IProvidersApi providersApi) - { - _providersApi = providersApi; - } - - [EffectMethod] - public async Task HandleLoadProviders(LoadProvidersAction _, IDispatcher dispatcher) - { - try - { - var result = await _providersApi.GetProvidersAsync(pageNumber: 1, pageSize: 20); - - if (result.IsSuccess && result.Value is not null) - { - dispatcher.Dispatch(new LoadProvidersSuccessAction( - result.Value.Items, - result.Value.TotalItems)); - } - else - { - dispatcher.Dispatch(new LoadProvidersFailureAction( - result.Error?.Message ?? "Falha ao carregar fornecedores")); - } - } - catch (Exception ex) - { - dispatcher.Dispatch(new LoadProvidersFailureAction(ex.Message)); - } - } -} -``` - -**Fluxo de Dados Unidirecional**: -1. **User Interaction** → Componente dispara Action -2. **Action** → Fluxor enfileira ação -3. **Reducer** → Cria novo State (immutable) -4. **Effect** (se aplicável) → Chama API externa -5. **New State** → UI re-renderiza automaticamente - -**Benefícios do Padrão**: -- ✅ **Previsibilidade**: Estado centralizado e immutable -- ✅ **Testabilidade**: Reducers são funções puras -- ✅ **Debug**: Redux DevTools integration -- ✅ **Time-travel**: Estado histórico para debugging - -### **Refit - Type-Safe HTTP Clients (SDK)** - -**MeAjudaAi.Client.Contracts é o SDK oficial .NET** para consumir a API REST, semelhante ao AWS SDK ou Stripe SDK. +--- -**SDKs Disponíveis** (Sprint 6-7): +### **Legacy (Blazor - DEPRECATED/MIGRATED)** -| Módulo | Interface | Funcionalidades | Status | -|--------|-----------|-----------------|--------| -| **Providers** | IProvidersApi | CRUD, verificação, filtros | ✅ Completo | -| **Documents** | IDocumentsApi | Upload, verificação, status | ✅ Completo | -| **ServiceCatalogs** | IServiceCatalogsApi | Listagem, categorias | ✅ Completo | -| **Locations** | ILocationsApi | CRUD AllowedCities | ✅ Completo | -| **Users** | IUsersApi | (Planejado) | ⏳ Sprint 8+ | +> **⚠️ IMPORTANTE**: Esta seção documenta componentes **legados e migrados**. O Admin Portal foi completamente migrado para React + Next.js conforme descrito acima. +> +> Os exemplos abaixo são apenas para referência histórica e compreensão da arquitetura anterior. -**Definição de API Contracts**: +O Admin Portal foi originalmente desenvolvido em Blazor WebAssembly com MudBlazor. Abaixo estão exemplos históricos: ```csharp -public interface IProvidersApi +// Program.cs - Configuração OIDC (LEGACY - DEPRECATED) +builder.Services.AddOidcAuthentication(options => { - [Get("/api/v1/providers")] - Task>> GetProvidersAsync( - [Query] int pageNumber = 1, - [Query] int pageSize = 20, - CancellationToken cancellationToken = default); + options.ProviderOptions.Authority = "https://keycloak.local/realms/meajudaai"; + options.ProviderOptions.ClientId = "admin-portal"; + options.ProviderOptions.ResponseType = "code"; + options.ProviderOptions.Scopes.Add("openid"); + options.ProviderOptions.Scopes.Add("profile"); +}); +``` - [Get("/api/v1/providers/verification-status/{status}")] - Task>> GetProvidersByVerificationStatusAsync( - string status, - CancellationToken cancellationToken = default); -} +```razor +@* App.razor - Cascading Authentication (Legacy) *@ + + + + + + + + + + + +``` -public interface IDocumentsApi -{ - [Multipart] - [Post("/api/v1/providers/{providerId}/documents")] - Task> UploadDocumentAsync( - Guid providerId, - [AliasAs("file")] StreamPart file, - [AliasAs("documentType")] string documentType, - CancellationToken cancellationToken = default); -} +### **Zustand Pattern - State Management** + +**Implementação do Padrão Zustand**: + +```typescript +// 1. STORE (State + Actions) +import { create } from 'zustand'; + +interface ProvidersState { + providers: ModuleProviderDto[]; + isLoading: boolean; + errorMessage: string | null; + pageNumber: number; + pageSize: number; + totalItems: number; + + // Actions + loadProviders: () => Promise; + setPage: (page: number) => void; + clearError: () => void; +} + +export const useProvidersStore = create((set, get) => ({ + providers: [], + isLoading: false, + errorMessage: null, + pageNumber: 1, + pageSize: 20, + totalItems: 0, + + loadProviders: async () => { + set({ isLoading: true, errorMessage: null }); + try { + const { pageNumber, pageSize } = get(); + const result = await providersApi.getProviders({ pageNumber, pageSize }); + set({ + providers: result.data, + totalItems: result.totalItems, + isLoading: false + }); + } catch (error) { + set({ errorMessage: error.message, isLoading: false }); + } + }, + + setPage: (page: number) => set({ pageNumber: page }), + clearError: () => set({ errorMessage: null }), +})); -public interface ILocationsApi -{ - [Get("/api/v1/locations/allowed-cities")] - Task>> GetAllAllowedCitiesAsync( - [Query] bool onlyActive = true, - CancellationToken cancellationToken = default); -} +// 2. COMPONENT USAGE +import { useProvidersStore } from '@/stores/providersStore'; -public interface IServiceCatalogsApi -{ - [Get("/api/v1/service-catalogs/services")] - Task>> GetAllServicesAsync( - [Query] bool activeOnly = true, - CancellationToken cancellationToken = default); +export function ProvidersList() { + const { providers, isLoading, loadProviders } = useProvidersStore(); + + useEffect(() => { loadProviders(); }, []); + + if (isLoading) return ; + return ; } ``` -**Configuração com Autenticação**: - -```csharp -// Program.cs - Registrar todos os SDKs -builder.Services.AddRefitClient() - .ConfigureHttpClient(c => c.BaseAddress = new Uri(apiBaseUrl)) - .AddHttpMessageHandler(); - -builder.Services.AddRefitClient() - .ConfigureHttpClient(c => c.BaseAddress = new Uri(apiBaseUrl)) - .AddHttpMessageHandler(); +### **TanStack Query - Data Fetching** -builder.Services.AddRefitClient() - .ConfigureHttpClient(c => c.BaseAddress = new Uri(apiBaseUrl)) - .AddHttpMessageHandler(); +```typescript +// Hook para buscar dados com cache automático +import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'; +import { providersApi } from '@/libs/api-client'; -builder.Services.AddRefitClient() - .ConfigureHttpClient(c => c.BaseAddress = new Uri(apiBaseUrl)) - .AddHttpMessageHandler(); -``` - -**Arquitetura Interna do Refit**: +export function useProviders(page: number = 1) { + return useQuery({ + queryKey: ['providers', page], + queryFn: () => providersApi.getProviders({ pageNumber: page }), + staleTime: 30 * 1000, // 30 seconds cache + placeholderData: keepPreviousData, + }); +} -```text -Blazor Component → IProvidersApi (interface) → Refit CodeGen → HttpClient → API +export function useCreateProvider() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data) => providersApi.createProvider(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['providers'] }); + }, + }); +} ``` -**Vantagens**: -- ✅ Type-safe API calls (compile-time validation) -- ✅ Automatic serialization/deserialization -- ✅ Integration with HttpClientFactory + Polly -- ✅ Authentication header injection via message handler -- ✅ **20 linhas de código manual → 2 linhas (interface + atributo)** -- ✅ Reutilizável entre projetos (Blazor WASM, MAUI, Console) - -**Documentação Completa**: `src/Client/MeAjudaAi.Client.Contracts/README.md` - -### **MudBlazor - Material Design Components** +### **React + Tailwind CSS Components** **Componentes Principais Utilizados**: -```razor -@* Layout Principal *@ - - - - - - - - - - - - - @Body - - - -@* Data Grid com Paginação *@ - - - - - - - - @context.Item.VerificationStatus - - - - - - - - -@* KPI Cards *@ - - - - - - - Total de Fornecedores - - - - @State.Value.TotalProviders - - +```tsx +// Layout Principal + + + + + + + + {isDarkMode ? : } + + + + + + + + + {children} + + + +// Data Grid com Paginação (usando TanStack Table) + + + + Nome + Email + Status + + + + {providers.map((provider) => ( + + {provider.name} + {provider.email} + + + + + ))} + +
+ + + +// KPI Cards + + + Total de Fornecedores + + + {totalProviders} + + ``` **Configuração de Tema**: -```csharp -// Program.cs -builder.Services.AddMudServices(config => -{ - config.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.BottomRight; - config.SnackbarConfiguration.PreventDuplicates = false; - config.SnackbarConfiguration.ShowCloseIcon = true; - config.SnackbarConfiguration.VisibleStateDuration = 5000; -}); +```tsx +// tailwind.config.ts +export default { + darkMode: 'class', + theme: { + extend: { + colors: { + primary: { + 50: '#f0f9ff', + 500: '#0ea5e9', + 900: '#0c4a6e', + }, + }, + }, + }, +} -// App.razor - Dark Mode Binding - +// Componente de Tema com next-themes +import { useTheme } from 'next-themes'; -@code { - private bool _isDarkMode; - private MudTheme _theme = new MudTheme(); +function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + return ( + + ); } + +// Toast notifications com Sonner +import { toast } from 'sonner'; + +toast.success('Operação realizada com sucesso'); +toast.error('Erro ao processar requisição'); ``` ### **Authentication - Keycloak OIDC** @@ -3136,10 +3080,10 @@ builder.Services.AddOidcAuthentication(options => } ``` -**Authentication Flow**: +**Authentication Flow (LEGACY - DEPRECATED)**: ```razor -@* Authentication.razor *@ +@* Authentication.razor - DEPRECATED *@ @@ -3160,10 +3104,10 @@ builder.Services.AddOidcAuthentication(options => ``` -**Protected Routes**: +**Protected Routes (LEGACY - DEPRECATED)**: ```razor -@* App.razor *@ +@* App.razor - DEPRECATED *@ @@ -3177,156 +3121,124 @@ builder.Services.AddOidcAuthentication(options => ``` -### **Component Testing - bUnit** +### **E2E Testing - Playwright** **Setup de Testes**: -```csharp -public class ProvidersPageTests : Bunit.TestContext -{ - private readonly Mock _mockProvidersApi; - private readonly Mock _mockDispatcher; - private readonly Mock> _mockProvidersState; - - public ProvidersPageTests() - { - _mockProvidersApi = new Mock(); - _mockDispatcher = new Mock(); - _mockProvidersState = new Mock>(); - - // Mock estado inicial - _mockProvidersState.Setup(x => x.Value).Returns(new ProvidersState()); - - // Registrar serviços - Services.AddSingleton(_mockProvidersApi.Object); - Services.AddSingleton(_mockDispatcher.Object); - Services.AddSingleton(_mockProvidersState.Object); - Services.AddMudServices(); - - // Configurar JSInterop mock (CRÍTICO para MudBlazor) - JSInterop.Mode = JSRuntimeMode.Loose; - } - - [Fact] - public void Providers_Should_Dispatch_LoadAction_OnInitialized() - { - // Act - var cut = RenderComponent(); - - // Assert - _mockDispatcher.Verify( - x => x.Dispatch(It.IsAny()), - Times.Once); - } - - [Fact] - public void Providers_Should_Display_Loading_State() - { - // Arrange - _mockProvidersState.Setup(x => x.Value) - .Returns(new ProvidersState { IsLoading = true }); +```typescript +import { test, expect, loginAsAdmin } from '@meajudaai/web-e2e-support'; - // Act - var cut = RenderComponent(); +test.describe('Providers Management', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + await page.goto('/admin/providers'); + }); - // Assert - var progressElements = cut.FindAll(".mud-progress-circular"); - progressElements.Should().NotBeEmpty(); - } -} + test('should display providers list', async ({ page }) => { + await expect(page.locator('[data-testid="providers-table"]')).toBeVisible(); + }); +}); ``` -**JSInterop Mock Pattern** (CRÍTICO): +**Padrões de Teste Playwright**: +1. **AAA Pattern**: Arrange → Act → Assert +2. **Data Test IDs**: Sempre usar `data-testid` para selecionar elementos +3. **Page Objects**: Criar classes para abstração de páginas +4. **Fixtures**: Reutilizar setup com Playwright fixtures +5. **Fluent Assertions**: Usar expect para asserts expressivas -```csharp -// SEMPRE configurar JSInterop.Mode para MudBlazor -public class MyComponentTests : Bunit.TestContext -{ - public MyComponentTests() - { - Services.AddMudServices(); - JSInterop.Mode = JSRuntimeMode.Loose; // <-- OBRIGATÓRIO - } -} -``` +### **Estrutura de Arquivos (React)**: -**Padrões de Teste bUnit**: -1. **AAA Pattern**: Arrange → Act → Assert (comentários em inglês) -2. **Mock States**: Sempre mockar IState para testar renderização -3. **Mock Dispatcher**: Verificar Actions disparadas -4. **JSInterop Mock**: Obrigatório para MudBlazor components -5. **FluentAssertions**: Usar para asserts expressivas +```text +src/Web/MeAjudaAi.Web.Admin/ +├── app/ # Next.js App Router +│ ├── (auth)/ # Authentication routes (Keycloak OAuth) +│ │ ├── login/ +│ │ └── api/auth/[...nextauth]/ +│ ├── (dashboard)/ # Protected routes +│ │ ├── providers/ +│ │ ├── documents/ +│ │ ├── services/ +│ │ ├── cities/ +│ │ ├── dashboard/ +│ │ └── layout.tsx +│ ├── layout.tsx +│ └── page.tsx +├── components/ # Reusable components +│ ├── ui/ # Base UI components +│ ├── providers/ # Provider-specific components +│ └── documents/ # Document components +├── hooks/ # Custom React hooks +│ ├── useProviders.ts +│ └── useDocuments.ts +``` ### **Estrutura de Arquivos** ```text -src/Web/MeAjudaAi.Web.Admin/ -├── Pages/ # Razor pages (rotas) -│ ├── Dashboard.razor -│ ├── Providers.razor -│ └── Authentication.razor -├── Features/ # Fluxor stores por feature -│ ├── Providers/ -│ │ ├── ProvidersState.cs -│ │ ├── ProvidersActions.cs -│ │ ├── ProvidersReducers.cs -│ │ └── ProvidersEffects.cs -│ ├── Dashboard/ -│ │ └── ... -│ └── Theme/ -│ └── ... -├── Layout/ # Layout components -│ ├── MainLayout.razor -│ └── NavMenu.razor -├── wwwroot/ # Static assets -│ ├── appsettings.json -│ └── index.html -├── Program.cs # Entry point + DI -└── App.razor # Root component - -tests/MeAjudaAi.Web.Admin.Tests/ -├── Pages/ -│ ├── ProvidersPageTests.cs -│ └── DashboardPageTests.cs -└── Layout/ - └── DarkModeToggleTests.cs +src/Web/ +├── MeAjudaAi.Web.Admin/e2e/ # Admin Portal E2E tests +│ ├── auth.spec.ts +│ ├── providers.spec.ts +│ ├── configs.spec.ts +│ ├── dashboard.spec.ts +│ └── mobile-responsiveness.spec.ts +├── MeAjudaAi.Web.Customer/e2e/ # Customer Web E2E tests +│ ├── auth.spec.ts +│ ├── search.spec.ts +│ ├── onboarding.spec.ts +│ ├── profile.spec.ts +│ └── performance.spec.ts +├── MeAjudaAi.Web.Provider/e2e/ # Provider Web E2E tests +│ ├── auth.spec.ts +│ ├── onboarding.spec.ts +│ ├── profile-mgmt.spec.ts +│ ├── dashboard.spec.ts +│ └── performance.spec.ts +└── libs/e2e-support/ # Shared E2E fixtures + └── base.ts # Exports: loginAsAdmin, loginAsProvider, loginAsCustomer, logout + +# Playwright Configuration: +# - testDir: './src' (single test directory) +# - baseURL: 'http://localhost:3000' +# - grep: /e2e/ (filter tests by e2e pattern) +# - projects: chromium, firefox, webkit, mobile, ci ``` ### **Best Practices - Frontend** #### **1. State Management** -- ✅ Use Fluxor para state compartilhado entre componentes -- ✅ Mantenha States immutable (record types) -- ✅ Reducers devem ser funções puras (sem side effects) -- ✅ Effects para chamadas assíncronas (API calls) +- ✅ Use Zustand para state global compartilhado entre componentes +- ✅ Use TanStack Query para server state (API data) +- ✅ Mantenha stores pequenas e focadas em uma feature +- ✅ Separe state de UI (layout) do state de negócio - ❌ Evite state local quando precisar compartilhar entre páginas #### **2. API Integration** -- ✅ Use Refit para type-safe HTTP clients -- ✅ Defina interfaces em `Client.Contracts.Api` -- ✅ Configure authentication via `BaseAddressAuthorizationMessageHandler` -- ✅ Handle errors em Effects com try-catch -- ❌ Não chame API diretamente em components (use Effects) +- ✅ Use TanStack Query hooks para chamadas API +- ✅ Defina tipos em `types/api/` (gerados automaticamente do OpenAPI) +- ✅ Configure authentication via NextAuth.js +- ✅ Handle errors com useQuery error state +- ❌ Não chame API diretamente em components (use hooks) #### **3. Component Design** - ✅ Componentes pequenos e focados (Single Responsibility) -- ✅ Use MudBlazor components sempre que possível -- ✅ Bind state via `IState` em components -- ✅ Dispatch actions via `IDispatcher` -- ❌ Evite lógica de negócio em components (mover para Effects) +- ✅ Use Base UI ou Radix UI para headless components +- ✅ Estilize com Tailwind CSS +- ✅ Use React Hook Form + Zod para formulários +- ❌ Evite lógica de negócio em components (mover para hooks) #### **4. Testing** -- ✅ Sempre configure JSInterop.Mode = Loose -- ✅ Mock IState para testar diferentes estados -- ✅ Verifique Actions disparadas via Mock -- ✅ Use FluentAssertions para asserts -- ❌ Não teste MudBlazor internals (confiar na biblioteca) +- ✅ Use Playwright para E2E tests +- ✅ Use data-testid para seletores mais estáveis +- ✅ Separe testes por feature (e2e/admin, e2e/customer, etc.) +- ✅ Use fixtures para setup/teardown #### **5. Portuguese Localization** - ✅ Todas mensagens de erro em português - ✅ Comentários inline em português - ✅ Labels e tooltips em português -- ✅ Technical terms podem ficar em inglês (OIDC, Refit, Fluxor) +- ✅ Technical terms podem ficar em inglês (OIDC, NextAuth, TanStack) --- diff --git a/docs/roadmap-current.md b/docs/roadmap-current.md index 45e43ca1e..cfbcef439 100644 --- a/docs/roadmap-current.md +++ b/docs/roadmap-current.md @@ -3,40 +3,40 @@ **Status**: 🔄 Em andamento (Jan–Mar 2026) ### Objetivo -Desenvolver aplicações frontend usando **Blazor WebAssembly** (Admin Portal) e **React + Next.js** (Customer Web App) + **React Native** (Mobile App). +Desenvolver aplicações frontend usando **React + Next.js** (Customer Web App, Admin Portal) + **React Native** (Mobile App). -> **📅 Status Atual**: Sprint 8C concluída (21 Mar 2026) -> **📝 Decisão Técnica** (5 Fev 2026): Customer App usará **React 19 + Next.js 15 + Tailwind v4** (SEO, performance, ecosystem) -> Próximo foco: Sprint 8D - Admin Portal Migration (React) +> **📅 Status Atual**: Sprint 9 (Estabilização) em andamento (26 Mar 2026) +> **📝 Decisão Técnica**: Cobertura Global de 70% atingida e reforçada no CI/CD. +> **🎉 MIGRAÇÃO CONCLUÍDA**: Admin Portal migrado de Blazor para React + Next.js na Sprint 8D --- -### 📱 Stack Tecnológico ATUALIZADO (5 Fev 2026) +### 📱 Stack Tecnológico ATUALIZADO (21 Mar 2026) > **📝 Decisão Técnica** (5 Fevereiro 2026): > Stack de Customer App definida como **React 19 + Next.js 15 + Tailwind CSS v4**. -> **Admin Portal** permanece em **Blazor WASM** (já implementado, interno, estável). -> *Migration to React planned for Sprint 8D to unify the stack.* +> **Admin Portal**: Migrado de Blazor WASM para React + Next.js na Sprint 8D. > **Razão**: SEO crítico para Customer App, performance inicial, ecosystem maduro, hiring facilitado. -**Decisão Estratégica**: Dual Stack (Blazor para Admin, React para Customer) +**Decisão Estratégica**: Stack unificado em **React + Next.js** para todos os apps web **Justificativa**: - ✅ **SEO**: Customer App precisa aparecer no Google ("eletricista RJ") - Next.js SSR/SSG resolve - ✅ **Performance**: Initial load rápido crítico para conversão mobile - code splitting + lazy loading - ✅ **Ecosystem**: Massivo - geolocation, maps, payments, qualquer problema já resolvido -- ✅ **Hiring**: Fácil escalar time - React devs abundantes vs Blazor devs raros +- ✅ **Hiring**: Fácil escalar time - React devs abundantes - ✅ **Mobile**: React Native maduro e testado vs MAUI Hybrid ainda novo - ✅ **Modern Stack**: React 19 + Tailwind v4 é estado da arte (2026) - ⚠️∩╕Å **Trade-off**: DTOs duplicados (C# backend, TS frontend) - mitigado com OpenAPI TypeScript Generator **Stack Completa**: -**Admin Portal** (mantido): -- Blazor WebAssembly 10.0 (AOT enabled) -- MudBlazor 8.15.0 (Material Design) -- Fluxor 6.9.0 (Redux state management) -- Refit (API client) +**Admin Portal** (React - migrado na Sprint 8D): +- React 19 + TypeScript 5.7+ +- Tailwind CSS v4 +- Zustand (state management) +- React Hook Form + Zod +- NextAuth.js (Keycloak OIDC) **Customer Web App** (novo): - React 19 (Server Components + Client Components) @@ -85,14 +85,14 @@ Desenvolver aplicações frontend usando **Blazor WebAssembly** (Admin Portal) e **Generated Files Location**: ```text src/ -Γö£ΓöÇΓöÇ Contracts/ # Backend DTOs (C#) -Γö£ΓöÇΓöÇ Web/ -Γöé Γö£ΓöÇΓöÇ MeAjudaAi.Web.Admin/ # Blazor (consumes Contracts via Refit) -Γöé ΓööΓöÇΓöÇ MeAjudaAi.Web.Customer/ # Next.js -Γöé ΓööΓöÇΓöÇ types/api/generated/ # ← OpenAPI generated types -ΓööΓöÇΓöÇ Mobile/ - ΓööΓöÇΓöÇ MeAjudaAi.Mobile.Customer/ # React Native - ΓööΓöÇΓöÇ src/types/api/ # ← Same OpenAPI generated types +├── Contracts/ # Backend DTOs (C#) +└── Web/ + ├── MeAjudaAi.Web.Admin/ # React + Next.js (migrated from Blazor in Sprint 8D) + ├── MeAjudaAi.Web.Customer/ # Next.js + │ └── types/api/generated/ # ← OpenAPI generated types + └── Mobile/ + └── MeAjudaAi.Mobile.Customer/ # React Native + └── src/types/api/ # ← Same OpenAPI generated types ``` **CI/CD Pipeline** (GitHub Actions): @@ -107,7 +107,7 @@ src/ ```text src/ ├── Web/ -│ ├── MeAjudaAi.Web.Admin/ # Blazor WASM Admin Portal (existente) +│ ├── MeAjudaAi.Web.Admin/ # React + Next.js Admin Portal (Sprint 8D) │ └── MeAjudaAi.Web.Customer/ # 🚀 Next.js Customer App (Sprint 8A) ├── Mobile/ │ └── MeAjudaAi.Mobile.Customer/ # 🚀 React Native + Expo (Sprint 8B) @@ -120,13 +120,13 @@ src/ **Cross-Platform Authentication Consistency**: -| Aspect | Admin (Blazor) | Customer Web (Next.js) | Customer Mobile (RN) | -|--------|----------------|------------------------|----------------------| -| **Token Storage** | In-memory | HTTP-only cookies | Secure Storage | +| Aspect | Admin (React) | Customer Web (Next.js) | Customer Mobile (RN) | +|--------|--------------|------------------------|----------------------| +| **Token Storage** | HTTP-only cookies | HTTP-only cookies | Secure Storage | | **Token Lifetime** | 1h access + 24h refresh | 1h access + 7d refresh | 1h access + 30d refresh | -| **Refresh Strategy** | Automatic (OIDC lib) | Middleware refresh | Background refresh | +| **Refresh Strategy** | Automatic (NextAuth) | Middleware refresh | Background refresh | | **Role Claims** | `role` claim | `role` claim | `role` claim | -| **Logout** | `/bff/logout` | `/api/auth/signout` | Revoke + clear storage | +| **Logout** | `/api/auth/signout` | `/api/auth/signout` | Revoke + clear storage | **Keycloak Configuration**: - **Realm**: `MeAjudaAi` @@ -138,7 +138,7 @@ src/ **Implementation Details**: - **Protocolo**: OpenID Connect (OIDC) - **Identity Provider**: Keycloak -- **Admin Portal**: `Microsoft.AspNetCore.Components.WebAssembly.Authentication` (Blazor) +- **Admin Portal**: NextAuth.js v5 (React + Next.js) - **Customer Web**: NextAuth.js v5 (Next.js) - **Customer Mobile**: React Native OIDC Client - **Refresh**: Automático via OIDC interceptor @@ -224,7 +224,7 @@ src/ #### ✅ Fase 2: Database-Backed + Admin Portal UI (CONCLUÍDO - Sprint 7, 7 Jan 2026) -**Contexto**: Migrar lista de cidades/estados de `appsettings.json` para banco de dados, permitindo gestão dinâmica via Blazor Admin Portal sem necessidade de redeploy. +**Contexto**: Migrar lista de cidades/estados de `appsettings.json` para banco de dados, permitindo gestão dinâmica via Admin Portal (React) sem necessidade de redeploy. **Status**: ✅ IMPLEMENTADO - AllowedCities UI completa com CRUD, coordenadas geográficas, e raio de serviço. @@ -250,7 +250,7 @@ CREATE INDEX idx_allowed_regions_active ON geographic_restrictions.allowed_regio **Funcionalidades Admin Portal**: -- [ ] **Visualização de Restrições Atuais** +- [ ] **Visualização de Restrições Atuais** (Admin Portal React) - [ ] Tabela com cidades/estados permitidos - [ ] Filtros: Tipo (Cidade/Estado), Estado, Status (Ativo/Inativo) - [ ] Ordenação: Alfabética, Data de Adição @@ -341,7 +341,7 @@ public class GeographicRestrictionMiddleware 2. **Sprint 3 Semana 1**: Implementar `AllowedRegionsService` com cache 3. **Sprint 3 Semana 1**: Refactor middleware para usar serviço (mantém fallback appsettings) 4. **Sprint 3 Semana 2**: Implementar CRUD endpoints no Admin API -5. **Sprint 3 Semana 2**: Implementar UI no Blazor Admin Portal +5. **Sprint 3 Semana 2**: Implementar UI no Admin Portal (React) 6. **Sprint 3 Pós-Deploy**: Popular banco com dados iniciais (Muriaé, Itaperuna, Linhares) 7. **Sprint 4**: Remover valores de appsettings.json (obsoleto) @@ -372,15 +372,15 @@ public class GeographicRestrictionMiddleware - [ ] **Ações**: Aprovar, Remover, Banir usuário - [ ] Stub para módulo Reviews (a ser implementado na Fase 3) -**Tecnologias**: -- **Framework**: Blazor WebAssembly (.NET 10) -- **UI**: MudBlazor (Material Design) -- **State**: Fluxor (Flux/Redux pattern) -- **HTTP**: Refit + Polly (retry policies) -- **Charts**: ApexCharts.Blazor +**Tecnologias (Admin Portal React)**: +- **Framework**: React 19 + TypeScript 5.7+ +- **UI**: Tailwind CSS v4 + Base UI +- **State**: Zustand +- **HTTP**: TanStack Query + React Hook Form +- **Charts**: Recharts **Resultado Esperado**: -- ✅ Admin Portal funcional e responsivo +- ✅ Admin Portal funcional e responsivo (React) - ✅ Todas operações CRUD implementadas - ✅ Dashboard com métricas em tempo real - ✅ Deploy em Azure Container Apps @@ -433,7 +433,7 @@ public class GeographicRestrictionMiddleware - Remove Azure Service Bus, unify on RabbitMQ only. 3. 🔴 **MUST-HAVE**: **Technical Excellence Pack** (Effort: Medium) - [ ] [**TD**] **Keycloak Automation**: `setup-keycloak-clients.ps1` for local dev. - - [ ] [**TD**] **Analyzer Cleanup**: Fix MudBlazor/SonarLint warnings in Admin & Contracts. + - [ ] [**TD**] **Analyzer Cleanup**: Fix SonarLint warnings in React apps & Contracts. - [ ] [**TD**] **Refactor Extensions**: Extract `BusinessMetricsMiddlewareExtensions`. - [ ] [**TD**] **Polly Logging**: Migrate resilience logging to ILogger (Issue #113). - [ ] [**TD**] **Standardization**: Record syntax alignment in `Contracts`. @@ -601,36 +601,69 @@ Durante o processo de atualização automática de dependências pelo Dependabot - `/configuracoes` - Toggle de visibilidade + delete account com confirmação LGPD - ✅ **Slug URLs**: Perfis públicos acessíveis via slugs (ex: `/provider/joao-silva-a1b2c3d4`) -### ⏳ Sprint 8D - Admin Portal Migration (2 - 22 Abr 2026) +### ✅ Sprint 8D - Admin Portal Migration (2 - 24 Mar 2026) -**Status**: ⏳ Planned (+1 week buffer added) +**Status**: ✅ CONCLUÍDA (24 Mar 2026) **Foco**: Phased migration from Blazor WASM to React. -**Scope (Prioritized)**: -- **Admin Portal Deliverable**: Functional `apps/admin-portal` in React. -- Providers CRUD + Document Management (Critical). -- Service Catalogs + Allowed Cities. -- Dashboard with KPIs. -- Unit/Integration tests for Admin modules. - -> 1. Ship MVP with current Blazor Admin. -> 2. Reduce scope to only Providers CRUD. - -### ⌛ Sprint 9 - BUFFER & Risk Mitigation (23 Abr - 11 Mai 2026) - -**Status**: 📋 PLANEJADO PARA MAIO 2026 -**Duration**: 12 days buffer (Extended) +**Entregáveis**: +- ✅ **Admin Portal React**: Functional `src/Web/MeAjudaAi.Web.Admin/` in React. +- ✅ **Providers CRUD**: Complete provider management. +- ✅ **Document Management**: Document upload and verification. +- ✅ **Service Catalogs**: Service catalog management. +- ✅ **Allowed Cities**: Geographic restrictions management. +- ✅ **Dashboard KPIs**: Admin dashboard with metrics. + +### ✅ Sprint 8E - E2E Tests & React Test Infrastructure (23 Mar - 25 Mar 2026) + +**Status**: ✅ CONCLUÍDA (25 Mar 2026) +**Foco**: Testes E2E (Playwright) + infraestrutura de testes unitários (Vitest + RTL + MSW) + Governança de Cobertura Global. + +**Scope — E2E (Playwright)** ✅: +1. ✅ **Playwright Config**: `playwright.config.ts` com 6 projetos (Chromium, Firefox, WebKit, Mobile, CI) +2. ✅ **Customer E2E** (5 specs): auth, onboarding, performance, profile, search +3. ✅ **Provider E2E** (5 specs): auth, dashboard, onboarding, performance, profile-mgmt +4. ✅ **Admin E2E** (5 specs): auth, configs, dashboard, mobile-responsiveness, providers +5. ✅ **Shared Fixtures**: `src/Web/libs/e2e-support/base.ts` (loginAsAdmin, loginAsProvider, loginAsCustomer, logout) +6. ✅ **CI Integration**: `master-ci-cd.yml` atualizado para gerar especificação OpenAPI e rodar E2E. + +**Scope — Testes Unitários (Vitest + RTL)** ✅: +7. ✅ **Infraestrutura**: `libs/test-support/` (test-utils.tsx, customRenderHook), thresholds individuais removidos em favor de Cobertura Global. +8. ✅ **Cobertura Global**: Script `src/Web/scripts/merge-coverage.mjs` consolida relatórios de todos os projetos com threshold de 70%. +9. ✅ **Hardening Admin**: Testes unitários para `Sidebar`, `Button`, `Dashboard`, `Providers` e `Users`. Autenticação centralizada em `auth.ts`. +10. ✅ **Hardening Customer**: `DashboardClient` (DTO compliance), `DocumentUpload` (API assertions) e `SearchFilters` (API category validation). + +**Cenários de Teste E2E**: +- [x] Autenticação (login, logout, refresh token) +- [x] Fluxo de onboarding (Customer e Provider) +- [x] CRUD de providers e serviços (Admin) +- [x] Busca e filtros geolocalizados +- [x] Responsividade mobile +- [x] Performance e Core Web Vitals (INP, LCP, CLS) + +**Pendências para fechar Sprint**: +- [x] Testes unitários Admin (hooks: providers, categories, dashboard, services, allowed-cities, users; components: sidebar, ui) +- [x] Testes unitários Provider (hooks; components: dashboard cards, profile) +- [x] Configurar MSW handlers para Admin e Provider + +### ⏳ Sprint 9 - BUFFER & Risk Mitigation (25 Mar - 11 Mai 2026) + +**Status**: ⏳ EM ANDAMENTO +**Duration**: 12 days buffer - Polishing, Refactoring, and Fixing. - Move Optional tasks from 8B.2 here if needed. - Rate limiting and advanced security/monitoring. +**Follow-ups Pendentes**: +- [ ] **OpenAPI Diff Gating**: Adicionar verificação de breaking changes em CI (falhar PR se API mudar sem version bump) + ## 🎯 MVP Final Launch: 12 - 16 de Maio de 2026 🎯 ### ⚠️ Risk Assessment & Mitigation #### Risk Mitigation Strategy - **Contingency Branching**: If major tasks (Admin Migration, NX Setup) slip, we prioritize essential Player flows (Customer/Provider) and fallback to existing Admin solutions. -- **Sprint 8E (Mobile)**: De-scoped from MVP to Phase 2 to ensure web platform stability. +- **Mobile Apps**: De-scoped from MVP to Phase 2 to ensure web platform stability. - **Buffer**: Sprint 9 is strictly for stability, no new features. - Documentação final para MVP @@ -645,25 +678,20 @@ Durante o processo de atualização automática de dependências pelo Dependabot - Implementar proper token refresh handling - Adicionar fallback mechanisms -### Risk Scenario 2: MudBlazor Learning Curve - -- **Problema Potencial**: Primeira vez usando MudBlazor; componentes complexos (DataGrid, Forms) podem ter comportamentos inesperados -- **Impacto**: +3-4 dias além do planejado nos Sprints 6-7 -- **Mitigação Sprint 9**: - - Refatorar componentes para seguir best practices MudBlazor - - Implementar componentes reutilizáveis otimizados - - Documentar patterns e anti-patterns identificados - -### Risk Scenario 3: Blazor WASM Performance Issues +### Risk Scenario 3: React Performance Issues - **Problema Potencial**: App bundle size > 5MB, lazy loading não configurado corretamente - **Impacto**: UX ruim, +2-3 dias de otimização - **Mitigação Sprint 9**: - - Implementar lazy loading de assemblies - - Otimizar bundle size (tree shaking, AOT compilation) - - Adicionar loading indicators e progressive loading + - Code splitting with dynamic imports + - Tree shaking and bundle optimization + - SSR/SSG via Next.js to improve initial load + - Lazy load React components + - Optimize images using next/image and responsive formats -### Risk Scenario 4: MAUI Hybrid Platform-Specific Issues +### Risk Scenario 4: MAUI Hybrid Platform-Specific Issues (DE-SCOPED FROM MVP) + +> **⚠️ IMPORTANTE**: Este cenário de risco foi removido do escopo do MVP. Os Mobile Apps foram adiados para a Fase 2 conforme.nota acima. - **Problema Potencial**: Diferenças de comportamento iOS vs Android (permissões, geolocation, file access) - **Impacto**: +4-5 dias de debugging platform-specific @@ -1200,27 +1228,30 @@ public class ActivityHub : Hub ### 📅 Alta Prioridade (Próximos 3 meses - Q1-Q2 2026) 1. ✅ **Sprint 8B.2: NX Monorepo & Technical Excellence** (Concluída) 2. ✅ **Sprint 8C: Provider Web App (React + NX)** (Concluída - 21 Mar 2026) -3. ⏳ **Sprint 8D: Admin Portal Migration** (Abril 2026) -4. ⏳ **Sprint 9: BUFFER & RISK MITIGATION** (Abril/Maio 2026) -5. 🎯 **MVP Final Launch: 12 - 16 de Maio de 2026** -6. 📋 API Collections - Bruno .bru files para todos os módulos +3. ✅ **Sprint 8D: Admin Portal Migration** (Concluída - 24 Mar 2026) +4. ✅ **Sprint 8E: E2E Tests React Apps (Playwright)** (Concluída - 25 Mar 2026) +5. ⏳ **Sprint 9: BUFFER & RISK MITIGATION** (Abril/Maio 2026) +6. 🎯 **MVP Final Launch: 12 - 16 de Maio de 2026** +7. 📋 API Collections - Bruno .bru files para todos os módulos + +### 🎯 **Alta Prioridade - Pré-MVP** +1. 🎯 Communications - Email notifications +2. 💳 Módulo Payments & Billing (Stripe) - Preparação para monetização ### 🎯 **Média Prioridade (6-12 meses - Fase 2)** 1. 🎉 Módulo Reviews & Ratings -2. 💳 Módulo Payments & Billing (Stripe) -3. 🌍 Documents - Verificação automatizada (OCR + Background checks) -4. 🔄 Search - Indexing worker para integration events -5. 📊 Analytics - Métricas básicas -6. 🎯 Communications - Email notifications -7. 🏛️ Dispute Resolution System -8. 🔥 Alinhamento de middleware entre UseSharedServices() e UseSharedServicesAsync() +2. 🌍 Documents - Verificação automatizada (OCR + Background checks) +3. 🔄 Search - Indexing worker para integration events (extensão do módulo SearchProviders) +4. 📊 Analytics - Métricas básicas +5. 🏛️ Dispute Resolution System +6. 🔥 Alinhamento de middleware entre UseSharedServices() e UseSharedServicesAsync() ### 🔬 **Testes E2E Frontend (Pós-MVP)** -**Projeto**: `tests/MeAjudaAi.Web.Tests` +**Projeto**: `src/Web` (dividido por projeto) **Estrutura**: Uma pasta para cada projeto frontend -- `tests/MeAjudaAi.Web.Tests/Customer/` - Testes E2E para Customer Web App -- `tests/MeAjudaAi.Web.Tests/Provider/` - Testes E2E para Provider Web App -- `tests/MeAjudaAi.Web.Tests/Admin/` - Testes E2E para Admin Portal +- `src/Web/MeAjudaAi.Web.Customer/e2e/` - Testes E2E para Customer Web App +- `src/Web/MeAjudaAi.Web.Provider/e2e/` - Testes E2E para Provider Web App +- `src/Web/MeAjudaAi.Web.Admin/e2e/` - Testes E2E para Admin Portal **Framework**: Playwright **Cenários a cobrir**: diff --git a/docs/roadmap-future.md b/docs/roadmap-future.md index f19f4a881..74b7e4424 100644 --- a/docs/roadmap-future.md +++ b/docs/roadmap-future.md @@ -4,7 +4,8 @@ 3. 🧠 Recomendações com ML 4. 🎮 Gamificação avançada 5. 💬 Chat interno -6. 🌐 Internacionalização +6. 🌐 Internacionalização (i18n) +7. 🧪 Evolução da Infraestrutura de Testes --- @@ -46,6 +47,20 @@ --- -*📅 Última atualização: 9 de Março de 2026 (Sprint 8B.2 Refinement)* +## 🧪 Evolução da Infraestrutura de Testes + +O planejamento de qualidade prevê a evolução contínua da pirâmide de testes: + +### Fase 3.1: Cobertura e Contratos (Curto/Médio Prazo) +- **Meta de Cobertura**: Manter >70% de cobertura global no MVP e atingir >80% na Fase 3. +- **Testes de Contrato (Consumer-Driven)**: Implementação de Pact.io para garantir que o frontend Next.js é compatível com a API .NET sem depender de ambiente real. + +### Fase 3.2: E2E Full-Stack & BDD (Médio Prazo) +- **Orquestração com Aspire**: Migração dos testes E2E para rodarem contra instâncias locais orquestradas pelo .NET Aspire (subindo PostgreSQL + PostGIS, API e Redis em containers temporários durante a suíte). +- **BDD (Behavior Driven Development)**: Adoção de Gherkin (Cucumber ou Playwright-BDD) para os fluxos críticos de negócio (ex: ciclo de vida do serviço). + +--- + +*📅 Última atualização: 25 de Março de 2026 (Pós-Sprint 8E)* *🔄 Roadmap em constante evolução baseado em feedback, métricas e aprendizados* -*📊 Status atual: Sprint 8B.2 🔄 EM ANDAMENTO | MVP Launch em 12-16 de Maio de 2026* +*📊 Status atual: Sprint 9 🔄 EM ANDAMENTO | MVP Launch em 12-16 de Maio de 2026* diff --git a/docs/roadmap.md b/docs/roadmap.md index 4cb928a1d..34f045a2e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -7,8 +7,8 @@ Este documento consolida o planejamento estratégico e tático da plataforma MeA ## 🚀 [Roadmap Atual](./roadmap-current.md) **Status**: Fase 2 em andamento (Frontend React + Mobile). Contém o status atual das sprints, o cronograma detalhado até o MVP e o plano de mitigação de riscos. -- **Sprint Atual**: 8B.2 (Technical Excellence & NX Monorepo) -- **Próximas Sprints**: 8C (Provider App), 8D (Admin Migration) +- **Sprint Atual**: 9 (Buffer & Risk Mitigation) +- **Sprint Concluída**: 8E (E2E Tests & React Unit Infrastructure) - **Meta MVP**: Maio 2026 (12-16) --- @@ -33,10 +33,11 @@ Contém os objetivos pós-MVP e ideias para o backlog de longo prazo. **Projeto**: MeAjudaAi - Plataforma de Conexão entre Clientes e Prestadores de Serviços **Stack Principal**: .NET 10 LTS + Aspire 13 + PostgreSQL + NX Monorepo + React 19 + Next.js 15 (Customer, Provider) + Tailwind v4 > [!NOTE] -> *Admin Portal atualmente em Blazor WASM; migração para React planejada para o Sprint 8D.* +> *Admin Portal migrado de Blazor WASM para React durante o Sprint 8D (concluído em 24 de Março de 2026).* --- ## 🏗️ Decisões Arquiteturais Recentes - **NX Monorepo**: Adotado para unificar o desenvolvimento frontend e compartilhamento de código. - **Dual-Stack Transition**: Transição de Blazor WASM para React 19 (Next.js) para unificação da stack. +- **Testing Infrastructure**: Implementação de Vitest + MSW para unitários e Playwright para E2E, com agregação de cobertura global. diff --git a/docs/technical-debt.md b/docs/technical-debt.md index b9f42bf83..78dd9caee 100644 --- a/docs/technical-debt.md +++ b/docs/technical-debt.md @@ -9,62 +9,19 @@ Este documento rastreia **débitos técnicos e seu histórico de otimização**. **Sprint**: Sprint 6-7 (30 Dez 2025 - 16 Jan 2026) **Status**: Itens de baixa a média prioridade -### 🎨 Frontend - Warnings de Analyzers (BAIXA) - -**Severidade**: BAIXA (code quality) -**Status**: 🔄 EM SPRINT 8B.2 (Refactoring) - -**Descrição**: Build do Admin Portal e Contracts gera warnings de analyzers (SonarLint + MudBlazor). - -**Warnings MudBlazor (MeAjudaAi.Web.Admin)**: -1. **S2094** (records vazios em Actions) -2. **S2953** (App.razor Dispose) -3. **MUD0002** (Casing de atributos HTML em MainLayout.razor) - -**Warnings Analisador de Segurança (MeAjudaAi.Contracts)**: -4. **Hard-coded Credential False Positive**: `src/Contracts/Utilities/Constants/ValidationMessages.cs` - - **Problema**: Mensagens de erro contendo a palavra "Password" disparam o scanner. - - **Ação**: Adicionar `[SuppressMessage]` ou `.editorconfig` exclusion. - -**Impacto**: Nenhum - build continua 100% funcional. - ---- - ### 📊 Frontend - Cobertura de Testes (MÉDIA) **Severidade**: MÉDIA (quality assurance) -**Sprint**: Sprint 7.16 (aumentar cobertura) - -**Descrição**: Admin Portal tem 43 testes bUnit criados. Meta é maximizar quantidade de testes (não coverage percentual). - -**Decisão Técnica**: Coverage percentual NÃO é coletado para Blazor WASM devido a: -- Muito código gerado automaticamente (`.g.cs`, `.razor.g.cs`) -- Métricas não confiáveis para componentes compilados para WebAssembly -- **Foco**: Quantidade e qualidade de testes, não percentual de linhas - -**Testes Existentes** (43 testes): -1. **ProvidersPageTests** (4 testes) -2. **DashboardPageTests** (4 testes) -3. **DarkModeToggleTests** (2 testes) -4. **+ 33 outros testes** de Pages, Dialogs, Components - -**Gaps de Cobertura**: -- ❌ **Authentication flows**: Login/Logout/Callbacks não testados -- ❌ **Pagination**: GoToPageAction não validado em testes -- ❌ **API error scenarios**: Apenas erro genérico testado -- ❌ **MudBlazor interactions**: Clicks, inputs não validados -- ❌ **Fluxor Effects**: Chamadas API não mockadas completamente +**Status**: 🔄 EM SPRINT 8E (E2E Tests com Playwright) -**Ações Recomendadas** (Sprint 7.16): -- [ ] Criar 20+ testes adicionais (meta: 60+ testes totais) -- [ ] Testar fluxos de autenticação -- [ ] Testar paginação -- [ ] Testar interações MudBlazor -- [ ] Aumentar coverage de error scenarios +**Descrição**: Admin Portal foi migrado para React. Testes de frontend agora focam em E2E com Playwright. -**Meta**: 60-80+ testes bUnit (quantidade), não coverage percentual +**Framework de Testes**: Playwright (para todos os apps React) +- Customer Web App +- Provider Web App +- Admin Portal (React) -**BDD Futuro**: Após Customer App, implementar SpecFlow + Playwright para testes end-to-end de fluxos completos (Frontend → Backend → APIs terceiras). +**BDD**: Playwright para testes end-to-end de fluxos completos (Frontend → Backend → APIs terceiras). --- @@ -152,8 +109,8 @@ Este documento rastreia **débitos técnicos e seu histórico de otimização**. **Severidade**: BAIXA **Sprint**: Backlog -- [ ] Apply brand colors (blue, cream, orange) to entire Admin Portal -- [ ] Update MudBlazor theme +- [ ] Apply brand colors (blue, cream, orange) to entire Admin Portal (React) +- [ ] Update React component library theme - [ ] Standardize component styling **Origem**: Sprint 7.19 diff --git a/docs/testing/bunit-ci-cd-practices.md b/docs/testing/bunit-ci-cd-practices.md index 76cf399b3..8c7cd9c02 100644 --- a/docs/testing/bunit-ci-cd-practices.md +++ b/docs/testing/bunit-ci-cd-practices.md @@ -1,4 +1,9 @@ -# bUnit Tests na Pipeline CI/CD - Guia de Boas Práticas +# [LEGACY / SUPERSEDED] bUnit Tests na Pipeline CI/CD - (Blazor) + +> [!IMPORTANT] +> Esta documentação refere-se à infraestrutura de testes legada para componentes Blazor. +> O projeto migrou para **Vitest + React Testing Library**. +> Consulte o [Plano de Testes Frontend](./frontend-testing-plan.md) para a estratégia atual. ## 📋 Decisão: bUnit deve estar na pipeline diff --git a/docs/testing/coverage.md b/docs/testing/coverage.md index c8547ae32..ed6889e29 100644 --- a/docs/testing/coverage.md +++ b/docs/testing/coverage.md @@ -1293,9 +1293,56 @@ reportgenerator ` --- -## 📚 Referências +## 📚 Estratégia de Exclusão de Código da Cobertura + +### Conceito + +A partir de Abril/2026, o projeto adotou uma estratégia híbrida para gerenciar coverage: + +1. **Atributo `[ExcludeFromCodeCoverage]` no código** - Preferível para classes de configuração/DTOs +2. **Filtros no YAML do CI** - Para categorias inteiras que não são código de negócio + +### Quando Usar o Atributo + +O atributo `[ExcludeFromCodeCoverage]` deve ser usado em classes que: +- São **Options/Configuration** (data holders sem lógica) +- São **DTOs internos** de integrações (Keycloak, RabbitMQ, etc) +- São classes de **infraestrutura** sem lógica de negócio +- **NÃO** devem ser usadas em classes que contêm lógica de negócio + +### Classes com o Atributo (2026) + +| Arquivo | Classe | Justificativa | +|---------|--------|---------------| +| `Caching/CacheOptions.cs` | `CacheOptions` | Options pattern | +| `Messaging/Options/RabbitMqOptions.cs` | `RabbitMqOptions` | Options pattern | +| `Messaging/Options/MessageBusOptions.cs` | `MessageBusOptions` | Options pattern | +| `Messaging/Options/DeadLetterOptions.cs` | `DeadLetterOptions` | Options pattern | +| `Messaging/Options/DeadLetterOptions.cs` | `RabbitMqDeadLetterOptions` | Options pattern | +| `Database/PopstgresOptions.cs` | `PostgresOptions` | Options pattern | +| `Authorization/Keycloak/KeycloakPermissionResolver.cs` | `KeycloakConfiguration` | Configuração | +| `Authorization/Keycloak/KeycloakPermissionResolver.cs` | `TokenResponse` | DTO interno Keycloak | +| `Authorization/Keycloak/KeycloakPermissionResolver.cs` | `KeycloakUser` | DTO interno Keycloak | +| `Authorization/Keycloak/KeycloakPermissionResolver.cs` | `KeycloakRole` | DTO interno Keycloak | +| `Authorization/Keycloak/KeycloakPermissionOptions.cs` | `KeycloakPermissionOptions` | Options pattern | +| `Messaging/MessagingExtensions.cs` | `MessagingConfiguration` | Classe de categorização | + +### Filtros no CI (YAML) + +Categorias excluídas via YAML (não possuem valor de teste): +```yaml +classfilters: "-*.Tests;-*.Tests.*;-*Test*;-testhost;-*.Migrations.*;-*Program*;-*.Seeding.*;-*.Monitoring.*;-MeAjudaAi.Shared.Jobs.*;-MeAjudaAi.Shared.Mediator.*" +``` + +### Benefícios + +1. **Código explícito** - O atributo no arquivo .cs é autodocumentado +2. **Manutenção simplificada** - Menos filtros no YAML +3. **Visibilidade** - Facil identificar o que foi excluído e por quê + +### Referências - Relatório de Coverage Atual: `coverage-github/report/index.html` (gerado via CI/CD) -- Pipeline CI/CD: `.github/workflows/master-ci-cd.yml` +- Pipeline CI/CD: `.github/workflows/ci-backend.yml` - Configuração Coverlet: `config/coverlet.json` - Coverage local: `dotnet test --collect:"XPlat Code Coverage"` diff --git a/docs/testing/frontend-testing-plan.md b/docs/testing/frontend-testing-plan.md index a6ae36c40..dc52975fb 100644 --- a/docs/testing/frontend-testing-plan.md +++ b/docs/testing/frontend-testing-plan.md @@ -1,18 +1,17 @@ # Plano de Implementação de Testes - React 19 + TypeScript -## Projeto: MeAjudaAi.Web.Consumer (Monorepo .NET) +## Projeto: MeAjudaAi.Web.Customer (Monorepo .NET) ## Sumário 1. [Contexto do Projeto](#contexto-do-projeto) -2. [Decisao Arquitetural](#decisao-arquitetural) -3. [Bibliotecas e Dependencias](#bibliotecas-e-dependencias) -4. [Estrutura de Pastas](#estrutura-de-pastas) -5. [Configuracao](#configuracao) -6. [Estrutura dos Arquivos de Teste](#estrutura-dos-arquivos-de-teste) -7. [Integração com Pipeline CI/CD](#integração-com-pipeline-cicd) -8. [Pipeline CI/CD Robusta (Ref. Medium)](#pipeline-cicd-robusta-ref-medium) -9. [Comandos Úteis](#comandos-úteis) -10. [Boas Práticas](#boas-práticas) +2. [Bibliotecas e Dependências](#bibliotecas-e-dependencias) +3. [Estrutura de Pastas](#estrutura-de-pastas) +4. [Configuração](#configuracao) +5. [Estrutura dos Arquivos de Teste](#estrutura-dos-arquivos-de-teste) +6. [Integração com Pipeline CI/CD](#integração-com-pipeline-cicd) +7. [Pipeline CI/CD Robusta](#pipeline-cicd-robusta-ref-medium) +8. [Comandos Úteis](#comandos-úteis) +9. [Boas Práticas](#boas-práticas) --- @@ -28,33 +27,20 @@ O projeto está integrado em um **monorepo .NET** com arquitetura de **monolito - `MeAjudaAi.Shared.Tests` - `MeAjudaAi.Web.Admin.Tests` (Blazor WASM com bUnit) -- **Frontend React** localizado em: - - `src/Web/MeAjudaAi.Web.Consumer` +- **Frontend React** localizado em: `src/Web/MeAjudaAi.Web.Customer/` --- -## Decisao Arquitetural +**Abordagem Atual: Arquitetura Descentralizada (Sprint 8E)** +Cada portal (Customer, Admin, Provider) gerencia seus próprios testes dentro de sua pasta, utilizando a biblioteca compartilhada `libs/test-support` para helpers comuns. -### ✅ Recomendação: Criar Projeto Separado de Testes - -**Criar:** `tests/MeAjudaAi.Web.Consumer.Tests` - -### Justificativa - -1. **Consistência com a arquitetura existente**: Todos os projetos de teste já estão separados na pasta `tests/` -2. **Separação de responsabilidades**: Backend (.NET) e Frontend (React) mantêm seus testes isolados -3. **Pipeline CI/CD independente**: Permite executar testes frontend/backend separadamente -4. **Mesma abordagem do Web.Admin**: O portal admin Blazor já segue este padrão com `MeAjudaAi.Web.Admin.Tests` -5. **Facilita manutenção**: Dependências JavaScript não poluem projetos .NET -6. **Clareza organizacional**: Fica explícito que são testes de frontend - -### Estrutura Completa do Monorepo +### Estrutura Real do Monorepo (Nx) ```text MeAjudaAi/ ├── src/ │ ├── Web/ -│ │ ├── MeAjudaAi.Web.Consumer/ # ← Projeto React +│ │ ├── MeAjudaAi.Web.Customer/ # ← Projeto React │ │ │ ├── src/ │ │ │ ├── public/ │ │ │ ├── package.json @@ -71,7 +57,7 @@ MeAjudaAi/ │ ├── MeAjudaAi.Integration.Tests/ # .NET │ ├── MeAjudaAi.Shared.Tests/ # .NET │ ├── MeAjudaAi.Web.Admin.Tests/ # bUnit (Blazor) -│ └── MeAjudaAi.Web.Consumer.Tests/ # ← NOVO: Vitest/React Testing Library +│ └── MeAjudaAi.Web.Customer.Tests/ # ← NOVO: Vitest/React Testing Library │ ├── src/ │ │ ├── components/ │ │ ├── hooks/ @@ -133,10 +119,12 @@ npm install --save-dev @storybook/test-runner ## Estrutura de Pastas -### Estrutura do Projeto de Testes: `tests/MeAjudaAi.Web.Consumer.Tests/` +> Esta seção documenta a arquitetura original (centralizada) para fins históricos. A abordagem atual utiliza **testes descentralizados** em cada app. + +### Estrutura Original do Projeto de Testes: `tests/MeAjudaAi.Web.Customer.Tests/` ```text -MeAjudaAi.Web.Consumer.Tests/ +MeAjudaAi.Web.Customer.Tests/ ├── src/ │ ├── components/ │ │ ├── Button/ @@ -214,11 +202,10 @@ MeAjudaAi.Web.Consumer.Tests/ Os testes espelham a estrutura do projeto principal: ```text -src/Web/MeAjudaAi.Web.Consumer/src/ → tests/MeAjudaAi.Web.Consumer.Tests/src/ - components/Button/Button.tsx → components/Button/Button.test.tsx - hooks/useAuth.ts → hooks/useAuth.test.ts - pages/Home/Home.tsx → pages/Home/Home.test.tsx - utils/formatters.ts → utils/formatters.test.ts +src/Web/MeAjudaAi.Web.Customer/ → src/Web/MeAjudaAi.Web.Customer/__tests__/ + components/Button/Button.tsx → components/ui/button.test.tsx + hooks/useAuth.ts → hooks/use-auth.test.ts + lib/utils/phone.ts → lib/utils/phone.test.ts ``` ### Estrutura de Nomenclatura @@ -266,21 +253,21 @@ export default defineConfig({ 'src/main.tsx', ], thresholds: { - lines: 80, - functions: 80, - branches: 80, - statements: 80, + lines: 70, + functions: 70, + branches: 70, + statements: 70, }, }, }, resolve: { alias: { '@': path.resolve(__dirname, './src'), - '@src': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Consumer/src'), - '@components': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Consumer/src/components'), - '@hooks': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Consumer/src/hooks'), - '@utils': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Consumer/src/utils'), - '@pages': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Consumer/src/pages'), + '@src': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Customer/src'), + '@components': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Customer/src/components'), + '@hooks': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Customer/src/hooks'), + '@utils': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Customer/src/utils'), + '@pages': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Customer/src/pages'), }, }, }); @@ -917,7 +904,7 @@ O projeto está em um monorepo .NET e usa GitHub Actions para CI/CD. A integraç ### 1. Adicionar Scripts ao `package.json` -**`tests/MeAjudaAi.Web.Consumer.Tests/package.json`** +**`tests/MeAjudaAi.Web.Customer.Tests/package.json`** ```json { @@ -966,8 +953,8 @@ on: push: branches: [master, main, develop] paths: - - 'src/Web/MeAjudaAi.Web.Consumer/**' - - 'tests/MeAjudaAi.Web.Consumer.Tests/**' + - 'src/Web/MeAjudaAi.Web.Customer/**' + - 'tests/MeAjudaAi.Web.Customer.Tests/**' pull_request: branches: [master, main, develop] @@ -1007,20 +994,20 @@ jobs: with: node-version: '20' cache: 'npm' - cache-dependency-path: tests/MeAjudaAi.Web.Consumer.Tests/package-lock.json + cache-dependency-path: tests/MeAjudaAi.Web.Customer.Tests/package-lock.json - name: Install dependencies - working-directory: tests/MeAjudaAi.Web.Consumer.Tests + working-directory: tests/MeAjudaAi.Web.Customer.Tests run: npm ci - name: Run tests - working-directory: tests/MeAjudaAi.Web.Consumer.Tests + working-directory: tests/MeAjudaAi.Web.Customer.Tests run: npm run test:ci - name: Upload coverage uses: codecov/codecov-action@v4 with: - files: tests/MeAjudaAi.Web.Consumer.Tests/coverage/coverage-final.json + files: tests/MeAjudaAi.Web.Customer.Tests/coverage/coverage-final.json flags: frontend token: ${{ secrets.CODECOV_TOKEN }} @@ -1035,18 +1022,18 @@ jobs: with: node-version: '20' cache: 'npm' - cache-dependency-path: tests/MeAjudaAi.Web.Consumer.Tests/package-lock.json + cache-dependency-path: tests/MeAjudaAi.Web.Customer.Tests/package-lock.json - name: Install dependencies - working-directory: tests/MeAjudaAi.Web.Consumer.Tests + working-directory: tests/MeAjudaAi.Web.Customer.Tests run: npm ci - name: Install Playwright browsers - working-directory: tests/MeAjudaAi.Web.Consumer.Tests + working-directory: tests/MeAjudaAi.Web.Customer.Tests run: npx playwright install --with-deps - name: Run E2E tests - working-directory: tests/MeAjudaAi.Web.Consumer.Tests + working-directory: tests/MeAjudaAi.Web.Customer.Tests run: npm run test:e2e:ci - name: Upload Playwright report @@ -1054,7 +1041,7 @@ jobs: if: always() with: name: playwright-report - path: tests/MeAjudaAi.Web.Consumer.Tests/playwright-report/ + path: tests/MeAjudaAi.Web.Customer.Tests/playwright-report/ retention-days: 30 ``` @@ -1074,7 +1061,7 @@ Além do ESLint, o projeto deve integrar o **SonarScanner** no pipeline para: Diferente de um pipeline simples de build, o fluxo robusto implementado seguirá: 1. **Lint & Static Analysis**: ESLint + Prettier + SonarQube Scan. 2. **Unit & Integration Tests**: Execução com Vitest (com geração de relatório LCOV para o Sonar). -3. **Build & Package**: Geração da build de produção do Vite para MeAjudaAi.Web.Consumer. +3. **Build & Package**: Geração da build de produção do Vite para MeAjudaAi.Web.Customer. 4. **Containerization (Contexto Aspire)**: O `dotnet aspire` facilita a geração de imagens Docker que serão enviadas para o Registry (Azure Container Registry). 5. **E2E Testing**: Execução do Playwright contra o container de staging. 6. **Deployment**: Via `azd deploy` para Azure Container Apps. @@ -1093,7 +1080,7 @@ Diferente de um pipeline simples de build, o fluxo robusto implementado seguirá ### 3. Configuração de Reports para CI/CD -**`tests/MeAjudaAi.Web.Consumer.Tests/vitest.config.ts`** +**`tests/MeAjudaAi.Web.Customer.Tests/vitest.config.ts`** ```typescript import { defineConfig } from 'vitest/config'; @@ -1118,11 +1105,11 @@ export default defineConfig({ '**/*.config.*', '**/mockData', ], - thresholds: { - lines: 80, - functions: 80, - branches: 80, - statements: 80, + thresholds: { + lines: 70, + functions: 70, + branches: 70, + statements: 70, }, }, reporters: ['default', 'junit', 'json'], @@ -1134,7 +1121,7 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, './src'), - '@src': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Consumer/src'), + '@src': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Customer/src'), }, }, }); @@ -1144,7 +1131,7 @@ export default defineConfig({ ```bash # Navegar para o projeto de testes -cd tests/MeAjudaAi.Web.Consumer.Tests +cd tests/MeAjudaAi.Web.Customer.Tests # Instalar dependências (primeira vez) npm install @@ -1173,7 +1160,7 @@ Criar script para facilitar execução de testes: echo "🧪 Executando testes do Frontend (React)..." -cd tests/MeAjudaAi.Web.Consumer.Tests +cd tests/MeAjudaAi.Web.Customer.Tests # Verificar se node_modules existe if [ ! -d "node_modules" ]; then @@ -1218,7 +1205,7 @@ echo "✅ Todos os testes concluídos!" ```bash # Navegar para o projeto de testes -cd tests/MeAjudaAi.Web.Consumer.Tests +cd tests/MeAjudaAi.Web.Customer.Tests # Instalar dependências (primeira vez ou após pull) npm install @@ -1258,8 +1245,8 @@ npm run test:coverage # Ver relatório HTML no navegador open coverage/index.html -# Cobertura com threshold definido (falha se < 80%) -npm run test:coverage -- --coverage.thresholds.lines=80 +# Cobertura com threshold definido (falha se < 70%) +npm run test:coverage -- --coverage.thresholds.lines=70 ``` ### Testes E2E (Playwright) @@ -1303,7 +1290,7 @@ npm run test:e2e:report dotnet test # Executar testes Frontend específicos -cd tests/MeAjudaAi.Web.Consumer.Tests && npm test +cd tests/MeAjudaAi.Web.Customer.Tests && npm test ``` ### Debugging e Troubleshooting @@ -1351,7 +1338,7 @@ Configuração de tasks: { "label": "Run Frontend Tests", "type": "shell", - "command": "cd tests/MeAjudaAi.Web.Consumer.Tests && npm test", + "command": "cd tests/MeAjudaAi.Web.Customer.Tests && npm test", "group": "test", "presentation": { "reveal": "always", @@ -1361,13 +1348,13 @@ Configuração de tasks: { "label": "Run Frontend Tests with Coverage", "type": "shell", - "command": "cd tests/MeAjudaAi.Web.Consumer.Tests && npm run test:coverage", + "command": "cd tests/MeAjudaAi.Web.Customer.Tests && npm run test:coverage", "group": "test" }, { "label": "Run E2E Tests", "type": "shell", - "command": "cd tests/MeAjudaAi.Web.Consumer.Tests && npm run test:e2e", + "command": "cd tests/MeAjudaAi.Web.Customer.Tests && npm run test:e2e", "group": "test" } ] @@ -1496,13 +1483,15 @@ it('deve corresponder ao snapshot', () => { ## 📊 Métricas de Qualidade -### Cobertura Mínima Recomendada -- **Statements**: 80% -- **Branches**: 80% -- **Functions**: 80% -- **Lines**: 80% +### Cobertura Global (Estratégia Atual) + +Desde a Sprint 8E, adotamos uma **Cobertura Global Consolidada**: + +1. **Threshold Global**: 70% (consolidado). +2. **Mecanismo**: O script `src/Web/scripts/merge-coverage.mjs` coleta os arquivos `coverage-final.json` de cada projeto (`Customer`, `Admin`, `Provider`) e gera um relatório único. +3. **Falha de Build**: O threshold é validado apenas no relatório final. Projetos individuais não possuem thresholds no `vitest.config.ts` para evitar bloqueios em estágios iniciais de novos subprojetos. -### Pirâmide de Testes +### Pirâmide de Testes (Atualizada) ```text /\ /E2E\ 10% - Testes E2E (fluxos críticos) @@ -1547,6 +1536,67 @@ import { Component } from './Component'; --- +## 📋 Status da Implementação Atual + +### Estrutura Atual do Projeto + +O projeto de testes frontend está integrado diretamente no projeto `MeAjudaAi.Web.Customer`: + +```text +src/Web/MeAjudaAi.Web.Customer/ +├── __tests__/ # Testes unitários +│ ├── components/ +│ │ ├── auth/ +│ │ ├── home/ +│ │ ├── layout/ +│ │ ├── providers/ +│ │ ├── search/ +│ │ ├── service/ +│ │ └── ui/ +│ ├── hooks/ +│ ├── lib/ +│ └── setup.ts +├── e2e/ # Testes E2E +│ ├── auth.spec.ts +│ ├── onboarding.spec.ts +│ ├── performance.spec.ts +│ ├── profile.spec.ts +│ └── search.spec.ts +├── vitest.config.ts +└── package.json +``` + +### Comandos Atuais + +```bash +# Executar testes +npm test # ou npx vitest run + +# Executar com cobertura +npm run test:coverage # ou npx vitest run --coverage +``` + +### Cobertura Atual + +- **Lines**: ~70% +- **Functions**: ~68% +- **Branches**: ~74% +- **Statements**: ~71% + +### ⚠️ [LEGACY / SUPERSEDED] Diferenças do Plano Original + +1. **Localização**: Os testes estão dentro do projeto `MeAjudaAi.Web.Customer` (em `__tests__/`) em vez de um projeto separado. +2. **Thresholds**: Configurados em 70% ao invés de 80% inicial (ver seção Métricas de Qualidade). +3. **E2E**: Usa `@e2e` tag nos test.describe para ser executado pelo Playwright. + +### Próximos Passos + +1. Aumentar cobertura para 80% gradualmente +2. Adicionar mais testes para componentes de baixa cobertura +3. Melhorar testes de integração + +--- + ## 📚 Recursos Adicionais - [Vitest Documentation](https://vitest.dev/) @@ -1562,11 +1612,14 @@ import { Component } from './Component'; --- -## Checklist de Implementação +## ⚠️ [LEGACY / SUPERSEDED] Checklist de Implementação (Approach Centralizado) + +> [!NOTE] +> Este checklist refere-se à fase de setup inicial da infraestrutura centralizada. Para o workflow atual, utilize as ferramentas NX conforme documentado nos guias de cada aplicação. ### Fase 1: Setup Inicial ✅ -- [ ] Criar pasta `tests/MeAjudaAi.Web.Consumer.Tests/` +- [ ] Criar pasta `tests/MeAjudaAi.Web.Customer.Tests/` - [ ] Criar `package.json` com dependências - [ ] Instalar todas as bibliotecas necessárias - [ ] Configurar `vitest.config.ts` @@ -1588,7 +1641,7 @@ import { Component } from './Component'; - [ ] Escrever teste para hook useLocalStorage - [ ] Escrever teste para uma página simples - [ ] Escrever teste para utility function -- [ ] Validar cobertura mínima (80%) +- [ ] Validar cobertura mínima (70%) ### Fase 4: Testes E2E 🎭 @@ -1622,8 +1675,8 @@ import { Component } from './Component'; ```bash # Na raiz do monorepo -mkdir -p tests/MeAjudaAi.Web.Consumer.Tests -cd tests/MeAjudaAi.Web.Consumer.Tests +mkdir -p tests/MeAjudaAi.Web.Customer.Tests +cd tests/MeAjudaAi.Web.Customer.Tests # Inicializar projeto Node npm init -y @@ -1639,9 +1692,9 @@ Adicionar ao `vitest.config.ts` para referenciar o código fonte: ```typescript resolve: { alias: { - '@': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Consumer/src'), - '@components': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Consumer/src/components'), - '@hooks': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Consumer/src/hooks'), + '@': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Customer/src'), + '@components': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Customer/src/components'), + '@hooks': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Customer/src/hooks'), }, }, ``` @@ -1649,9 +1702,9 @@ resolve: { ### 3. Criar README.md no Projeto de Testes ```markdown -# MeAjudaAi.Web.Consumer.Tests +# MeAjudaAi.Web.Customer.Tests -Testes automatizados para o projeto React Consumer. +Testes automatizados para o projeto React Customer. ## Stack de Testes @@ -1681,16 +1734,16 @@ npm run test:coverage ## Cobertura Mínima -- Lines: 80% -- Functions: 80% -- Branches: 80% -- Statements: 80% +- Lines: 70% +- Functions: 70% +- Branches: 70% +- Statements: 70% ``` ### 4. Adicionar ao .gitignore ```gitignore -# tests/MeAjudaAi.Web.Consumer.Tests/.gitignore +# tests/MeAjudaAi.Web.Customer.Tests/.gitignore # Dependencies node_modules/ @@ -1740,7 +1793,7 @@ npm install --save-dev husky lint-staged # Criar hook npx husky install -npx husky add .husky/pre-commit "cd tests/MeAjudaAi.Web.Consumer.Tests && npx lint-staged" +npx husky add .husky/pre-commit "cd tests/MeAjudaAi.Web.Customer.Tests && npx lint-staged" ``` --- diff --git a/infrastructure/README.md b/infrastructure/README.md index 94af81ce5..69312e4f3 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -2,6 +2,38 @@ This directory contains the infrastructure configuration for the MeAjudaAi platform. +## 🚀 Infraestrutura Azure e CI/CD (Bicep) + +O projeto utiliza uma arquitetura de CI/CD modularizada para maior eficiência e manutenibilidade. + +### Pipelines Modularizadas +As pipelines foram divididas por responsabilidade em `.github/workflows/`: +- **`ci-backend.yml`**: Build do .NET, testes de unidade, integração e arquitetura, além de relatórios de cobertura. +- **`ci-frontend.yml`**: Lint, testes unitários (Vitest) e build dos apps React (Next.js) usando Nx Affected. +- **`ci-e2e.yml`**: Testes ponta-a-ponta pesados, incluindo API (TestContainers) e UI (Playwright). +- **`deploy-azure.yml`**: Gerenciamento e provisionamento da infraestrutura no Azure. + +### Infraestrutura como Código (IaC) +A infraestrutura é definida usando **Bicep** no diretório `infrastructure/`: +- `main.bicep`: Definição principal (PostgreSQL, Redis, Service Bus). +- `dev.parameters.json`: Parâmetros para o ambiente de desenvolvimento. +- `prod.parameters.json`: Parâmetros para o ambiente de produção. + +### Como fazer o Deployment +O workflow `deploy-azure.yml` permite provisionar a infraestrutura manualmente: +1. Vá para a aba **Actions** no GitHub. +2. Selecione o workflow **Deploy to Azure**. +3. Clique em **Run workflow** e escolha o ambiente (`dev` ou `prod`). +4. **Input `deploy_infrastructure`**: Este é um booleano (padrão `true`) que controla se a infraestrutura será provisionada durante a execução do workflow. + - Selecione desmarcado (false) para pular o provisionamento (apenas faz deploy da aplicação) + - Deixe marcado (padrão true) para executar o provisionamento completo +5. **Requisitos**: É necessário configurar as secrets `AZURE_CREDENTIALS` e `POSTGRES_ADMIN_PASSWORD` no repositório. + +### Quando atualizar o Bicep? +Você deve atualizar os arquivos Bicep sempre que adicionar novos recursos gerenciados (como uma nova base de dados ou conta de armazenamento) no `AppHost` do .NET Aspire. Os arquivos Bicep neste diretório são a "Fonte da Verdade" para o Azure. + +--- + ## 🔒 Security Requirements **Before starting any environment**, you must configure secure credentials: diff --git a/infrastructure/dev.parameters.json b/infrastructure/dev.parameters.json new file mode 100644 index 000000000..9d0e8d07b --- /dev/null +++ b/infrastructure/dev.parameters.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { + "value": "dev" + }, + "location": { + "value": "brazilsouth" + }, + "postgresAdminUsername": { + "value": "psqladmin" + } + } +} diff --git a/infrastructure/main.bicep b/infrastructure/main.bicep new file mode 100644 index 000000000..77d37d59f --- /dev/null +++ b/infrastructure/main.bicep @@ -0,0 +1,171 @@ +@description('The name of the environment (e.g. dev, prod)') +param environmentName string + +@description('The location for all resources') +param location string = resourceGroup().location + +@description('The name of the PostgreSQL server') +param postgresServerName string = 'psql-${environmentName}-${uniqueString(resourceGroup().id)}' + +@description('The name of the PostgreSQL database') +param postgresDatabaseName string = 'meajudaai' + +@description('The administrator username for the PostgreSQL server') +param postgresAdminUsername string = 'psqladmin' + +@description('The administrator password for the PostgreSQL server') +@secure() +param postgresAdminPassword string + +@description('The name of the Redis cache') +param redisCacheName string = 'redis-${environmentName}-${uniqueString(resourceGroup().id)}' + +@description('The name of the Service Bus namespace') +param serviceBusNamespaceName string = 'sb-${environmentName}-${uniqueString(resourceGroup().id)}' + +@description('The SKU of the PostgreSQL server') +param postgresSkuName string = 'Standard_B1ms' + +@description('The tier of the PostgreSQL server') +param postgresSkuTier string = 'Burstable' + +@description('The storage size of the PostgreSQL server in GB') +param postgresStorageSizeGB int = 32 + +@description('The backup retention days for the PostgreSQL server') +param postgresBackupRetentionDays int = 7 + +@description('The SKU of the Redis cache') +param redisSkuName string = 'Balanced_B1' + +@description('The VNet subnet ID for private endpoint') +@allowed(['', '/subscriptions/*/resourceGroups/*/providers/Microsoft.Network/virtualNetworks/*/subnets/*']) +param vnetSubnetId string = '' + +@description('The VNet ID for DNS zone link') +@allowed(['', '/subscriptions/*/resourceGroups/*/providers/Microsoft.Network/virtualNetworks/*']) +param vnetId string = '' + +var usePrivateNetwork = vnetSubnetId != '' && vnetId != '' + +// PostgreSQL Server +resource postgresServer 'Microsoft.DBforPostgreSQL/flexibleServers@2024-08-01' = { + name: postgresServerName + location: location + sku: { + name: postgresSkuName + tier: postgresSkuTier + } + properties: { + version: '16' + administratorLogin: postgresAdminUsername + administratorLoginPassword: postgresAdminPassword + storage: { + storageSizeGB: postgresStorageSizeGB + } + backup: { + backupRetentionDays: postgresBackupRetentionDays + geoRedundantBackup: 'Disabled' + } + publicNetworkAccess: usePrivateNetwork ? 'Disabled' : 'Enabled' + } +} + +// PostgreSQL Database +resource postgresDatabase 'Microsoft.DBforPostgreSQL/flexibleServers/databases@2024-08-01' = { + parent: postgresServer + name: postgresDatabaseName +} + +// Private Endpoint for PostgreSQL (only if VNet is provided) +resource postgresPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-01-01' = if (vnetSubnetId != '' && vnetId != '') { + name: '${postgresServerName}-pe' + location: location + properties: { + privateLinkServiceConnections: [ + { + name: '${postgresServerName}-pe-connection' + properties: { + privateLinkServiceId: postgresServer.id + groupIds: ['postgresqlServer'] + } + } + ] + subnet: { + id: vnetSubnetId + } + } +} + +// DNS Zone for PostgreSQL Private Link (only if VNet is provided) +resource privateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (vnetId != '') { + name: 'privatelink.postgres.database.azure.com' + location: 'global' + properties: {} +} + +resource privateDnsZoneLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = if (vnetId != '') { + name: '${privateDnsZone.name}-link' + parent: privateDnsZone + properties: { + registrationEnabled: false + virtualNetwork: { + id: vnetId + } + } +} + +// Link Private Endpoint to DNS Zone +resource postgresPrivateDnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-01-01' = if (vnetSubnetId != '' && vnetId != '') { + parent: postgresPrivateEndpoint + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink-postgres-database-azure-com' + properties: { + privateDnsZoneId: privateDnsZone.id + } + } + ] + } +} + +// Redis Cache (Azure Managed Redis / Redis Enterprise) +resource redisCache 'Microsoft.Cache/redisEnterprise@2024-02-01' = { + name: redisCacheName + location: location + sku: { + name: redisSkuName + } + properties: { + minimumTlsVersion: '1.2' + } +} + +resource redisDatabase 'Microsoft.Cache/redisEnterprise/databases@2024-02-01' = { + parent: redisCache + name: 'default' + properties: { + clientProtocol: 'Encrypted' + clusteringPolicy: 'OSSCluster' + } +} + +// Service Bus Namespace (as RabbitMQ alternative in Azure) +resource serviceBusNamespace 'Microsoft.ServiceBus/namespaces@2024-01-01' = { + name: serviceBusNamespaceName + location: location + sku: { + name: 'Standard' + tier: 'Standard' + } +} + +// Outputs +output postgresHost string = postgresServer.properties.fullyQualifiedDomainName +output postgresDatabase string = postgresDatabaseName +output redisHost string = redisDatabase.properties.endpoint +output serviceBusNamespace string = serviceBusNamespaceName +output postgresPrivateEndpointIp string = (vnetSubnetId != '') ? postgresPrivateEndpoint.properties.customDnsConfigs[0].ipAddresses[0] : '' +output privateDnsZoneName string = (vnetId != '') ? privateDnsZone.name : '' diff --git a/infrastructure/prod.parameters.json b/infrastructure/prod.parameters.json new file mode 100644 index 000000000..589214738 --- /dev/null +++ b/infrastructure/prod.parameters.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { + "value": "prod" + }, + "location": { + "value": "brazilsouth" + }, + "postgresAdminUsername": { + "value": "psqladmin" + } + } +} diff --git a/mkdocs.yml b/mkdocs.yml index 0a053b9bb..d4e491208 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -2,6 +2,7 @@ site_name: Documentação MeAjudaAi site_description: Plataforma conectando clientes com prestadores de serviços site_author: Equipe MeAjudaAi site_url: https://frigini.github.io/MeAjudaAi +docs_dir: docs repo_name: frigini/MeAjudaAi repo_url: https://github.com/frigini/MeAjudaAi @@ -125,7 +126,7 @@ nav: - Testes de Integração: testing/integration-tests.md - Infraestrutura de Testes: testing/test-infrastructure.md - Exemplos de Auth em Testes: testing/test-auth-examples.md - - Arquitetura E2E: testing/e2e-architecture-analysis.md + - Testes E2E: testing/e2e-tests.md - Cobertura de Código: testing/coverage.md - Admin Portal: - Visão Geral: admin-portal/overview.md diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..37070b1dc --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1554 @@ +{ + "name": "MeAjudaAi", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@vitest/coverage-v8": "^4.1.2" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", + "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.2", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.2", + "vitest": "4.1.2" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true, + "peer": true + }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..9bf367093 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "@vitest/coverage-v8": "^4.1.2" + } +} diff --git a/prompts/plano-implementacao-testes-react.md b/prompts/plano-implementacao-testes-react.md new file mode 100644 index 000000000..b25ac0209 --- /dev/null +++ b/prompts/plano-implementacao-testes-react.md @@ -0,0 +1,1120 @@ +# Plano de Implementação de Testes - React 19 + TypeScript +## Projeto: MeAjudaAi Web (Monorepo Nx) + +## 📋 Sumário + +1. [Contexto do Projeto](#contexto-do-projeto) +2. [Decisão Arquitetural](#decisao-arquitetural) +3. [Bibliotecas e Dependências](#bibliotecas-e-dependencias) +4. [Estrutura de Pastas](#estrutura-de-pastas) +5. [Configuração](#configuracao) +6. [Estrutura dos Arquivos de Teste](#estrutura-dos-arquivos-de-teste) +7. [Exemplos Práticos](#exemplos-praticos) +8. [Integração com Pipeline CI/CD](#integração-com-pipeline-cicd) +9. [Comandos Úteis](#comandos-uteis) +10. [Boas Práticas](#boas-praticas) +## 11. Checklist de Implementação + +### Fase 1: Fundação (Concluída - Sprint 8E) +- [x] Criar `libs/test-support` (setup, utils, mock-data) +- [x] Configurar `vitest.config.ts` em todos os projetos +- [x] Configurar `project.json` (NX targets) para todos os projetos +- [x] Configurar scripts no `package.json` raiz (`src/Web/`) +- [x] Implementar MSW em `MeAjudaAi.Web.Customer` +- [x] Implementar MSW em `MeAjudaAi.Web.Admin` +- [x] Implementar MSW em `MeAjudaAi.Web.Provider` +- [x] Corrigir infraestrutura de CI/CD (`ci-frontend.yml`, `ci-backend.yml`, `ci-e2e.yml`) +- [x] Implementar agregação de cobertura global (`scripts/merge-coverage.mjs`) + +### Fase 2: Cobertura Admin & Provider (Concluída - Sprint 8E) +- [x] Criar testes unitários para hooks Admin +- [x] Criar testes unitários para componentes Admin +- [x] Criar testes unitários para componentes Provider +- [x] Validar funcionamento local e em CI + +### Fase 3: Maturidade e E2E Full (Roadmap Futuro) +- [ ] Expandir cobertura unitária para >80% +- [ ] Implementar testes de contrato (Pact) +- [ ] Integrar testes E2E com .NET Aspire (containers locais) +- [ ] Implementar BDD com Gherkin para fluxos críticos + +--- + +## 🏗️ Contexto do Projeto + +O projeto está integrado em um **monorepo .NET + Nx** com arquitetura de **monolito modular**. A estrutura possui: + +- **Backend .NET** com testes organizados por camada em `tests/` +- **3 projetos React/Next.js** em `src/Web/`: + - `MeAjudaAi.Web.Customer` — Portal do cliente + - `MeAjudaAi.Web.Admin` — Portal administrativo (Next.js) + - `MeAjudaAi.Web.Provider` — Portal do prestador (Next.js) +- **Libs compartilhadas** em `src/Web/libs/`: + - `auth` — Autenticação compartilhada + - `e2e-support` — Suporte para testes E2E + - `assets` — Assets compartilhados + +--- + +## 🎯 Decisão Arquitetural + +### ✅ Testes Dentro de Cada Projeto + +Os testes ficam **dentro de cada projeto React** em uma pasta `__tests__/`, com infraestrutura compartilhada em `libs/test-support/`. + +### Justificativa + +1. **Proximidade com o código**: Testes ficam junto do projeto que testam +2. **Independência**: Cada projeto pode rodar seus testes isoladamente +3. **Consistência**: Mesma abordagem dos testes E2E que já existem em `e2e/` +4. **Simplicidade**: Evita path aliases complexos entre projetos separados +5. **Nx-friendly**: Alinhado com a estrutura do monorepo Nx + +### Estrutura Completa do Monorepo + +```text +MeAjudaAi/ +├── src/ +│ └── Web/ +│ ├── libs/ +│ │ ├── test-support/ ← NOVO: Infra compartilhada de testes +│ │ │ ├── src/ +│ │ │ │ ├── setup.ts +│ │ │ │ ├── test-utils.tsx +│ │ │ │ ├── mock-data.ts +│ │ │ │ └── index.ts +│ │ │ ├── package.json +│ │ │ └── tsconfig.json +│ │ ├── e2e-support/ ← Já existe +│ │ ├── auth/ ← Já existe +│ │ └── assets/ ← Já existe +│ │ +│ ├── MeAjudaAi.Web.Customer/ +│ │ ├── __tests__/ ← NOVO: Testes unitários/integração +│ │ │ ├── components/ +│ │ │ ├── hooks/ +│ │ │ ├── lib/ +│ │ │ └── mocks/ +│ │ ├── e2e/ ← Já existe: Testes E2E (Playwright) +│ │ ├── vitest.config.ts ← NOVO +│ │ ├── app/ +│ │ ├── components/ +│ │ ├── hooks/ +│ │ └── lib/ +│ │ +│ ├── MeAjudaAi.Web.Admin/ +│ │ ├── __tests__/ ← NOVO +│ │ ├── e2e/ ← Já existe +│ │ ├── vitest.config.ts ← NOVO +│ │ └── ... +│ │ +│ ├── MeAjudaAi.Web.Provider/ +│ │ ├── __tests__/ ← NOVO +│ │ ├── e2e/ ← Já existe +│ │ ├── vitest.config.ts ← NOVO +│ │ └── ... +│ │ +│ ├── package.json ← Adicionar scripts de teste +│ └── playwright.config.ts ← Já existe +│ +├── tests/ ← Testes .NET (não alterado) +└── MeAjudaAi.sln +``` + +--- + +## 📦 Bibliotecas e Dependências + +### Instalação no `package.json` raiz (`src/Web/`) + +```bash +# Testing framework e runners (já instalados: vitest, @vitest/ui, jsdom) +npm install --save-dev @vitest/coverage-v8 + +# React Testing Library +npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event + +# Mock Service Worker (para mock de APIs) +npm install --save-dev msw +``` + +### Pacotes Opcionais + +```bash +# Para testes de acessibilidade +npm install --save-dev jest-axe +``` + +> **Nota**: `vitest`, `@vitest/ui`, `jsdom` e `@playwright/test` já estão instalados no `package.json` raiz. +> **Nota**: Para testes de hooks, utilize o `renderHook` exportado por `test-support` que já inclui o provider do React Query configurado. + +--- + +## 📁 Estrutura de Pastas + +### Padrão por Projeto + +Cada projeto segue a mesma estrutura interna em `__tests__/`, espelhando o código-fonte: + +``` +MeAjudaAi.Web.Customer/ +├── __tests__/ +│ ├── components/ +│ │ ├── auth/ +│ │ │ ├── login-form.test.tsx +│ │ │ └── customer-register-form.test.tsx +│ │ ├── providers/ +│ │ │ ├── provider-card.test.tsx +│ │ │ └── provider-grid.test.tsx +│ │ ├── search/ +│ │ │ ├── search-filters.test.tsx +│ │ │ └── city-search.test.tsx +│ │ ├── reviews/ +│ │ │ ├── review-card.test.tsx +│ │ │ └── review-form.test.tsx +│ │ └── ui/ +│ │ ├── button.test.tsx +│ │ └── input.test.tsx +│ ├── hooks/ +│ │ ├── use-via-cep.test.ts +│ │ ├── use-services.test.ts +│ │ ├── use-register-provider.test.ts +│ │ └── use-provider-status.test.ts +│ ├── lib/ +│ │ ├── utils/ +│ │ │ ├── normalization.test.ts +│ │ │ └── phone.test.ts +│ │ ├── schemas/ +│ │ │ └── verification-status.test.ts +│ │ └── services/ +│ │ └── geocoding.test.ts +│ └── mocks/ +│ ├── handlers.ts +│ └── server.ts +├── e2e/ +│ ├── auth.spec.ts +│ ├── onboarding.spec.ts +│ ├── performance.spec.ts +│ ├── profile.spec.ts +│ └── search.spec.ts +├── vitest.config.ts +└── ... +``` + +### Mapeamento Código Fonte → Testes + +``` +components/auth/login-form.tsx → __tests__/components/auth/login-form.test.tsx +hooks/use-via-cep.ts → __tests__/hooks/use-via-cep.test.ts +lib/utils/phone.ts → __tests__/lib/utils/phone.test.ts +lib/schemas/verification-status.ts → __tests__/lib/schemas/verification-status.test.ts +``` + +### Nomenclatura + +- **Testes unitários**: `*.test.tsx` ou `*.test.ts` +- **Testes de integração**: `*.integration.test.tsx` +- **Testes de acessibilidade**: `*.accessibility.test.tsx` +- **Testes E2E**: `*.spec.ts` (dentro de `e2e/`) + +--- + +## ⚙️ Configuração + +### 1. `libs/test-support/src/setup.ts` + +Setup global compartilhado entre todos os projetos: + +```typescript +import '@testing-library/jest-dom'; +import { cleanup } from '@testing-library/react'; +import { afterEach, beforeAll, afterAll } from 'vitest'; + +// Cleanup automático após cada teste +afterEach(() => { + cleanup(); +}); + +// Mock do matchMedia (necessário para componentes responsivos) +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}); + +// Mock do IntersectionObserver (necessário para lazy loading) +global.IntersectionObserver = class IntersectionObserver { + constructor() {} + disconnect() {} + observe() {} + takeRecords() { + return []; + } + unobserve() {} +} as any; + +// Mock do ResizeObserver +global.ResizeObserver = class ResizeObserver { + constructor() {} + disconnect() {} + observe() {} + unobserve() {} +} as any; +``` + +### 2. `libs/test-support/src/test-utils.tsx` + +Custom render com providers comuns: + +```typescript +import React, { ReactElement, useMemo } from 'react'; +import { render, RenderOptions, renderHook, RenderHookOptions, RenderHookResult } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +// Cria um QueryClient limpo para cada teste +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: Infinity, + }, + mutations: { + retry: false, + }, + }, + }); +} + +interface AllTheProvidersProps { + children: React.ReactNode; + queryClient?: QueryClient; +} + +const AllTheProviders = ({ children, queryClient: client }: AllTheProvidersProps) => { + const queryClient = useMemo(() => client ?? createTestQueryClient(), [client]); + return ( + + {children} + + ); +}; + +const customRender = ( + ui: ReactElement, + options?: Omit +) => render(ui, { wrapper: AllTheProviders, ...options }); + +const AllTheProvidersWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +function customRenderHook( + callback: (props: TProps) => TValue, + options?: Omit, 'wrapper'> +): RenderHookResult { + return renderHook(callback, { wrapper: AllTheProvidersWrapper, ...options }); +} + +// Re-exporta tudo +export * from '@testing-library/react'; +export { customRender as render }; +export { customRenderHook as renderHook }; +export { createTestQueryClient }; +export { AllTheProvidersWrapper }; +``` + +### 3. `libs/test-support/src/mock-data.ts` + +Fábricas de objetos de teste compartilhados: + +```typescript +// Fábricas de dados de teste reutilizáveis + +export function createProvider(overrides = {}) { + return { + id: 'test-provider-id', + name: 'Prestador Teste', + slug: 'prestador-teste', + email: 'prestador@teste.com', + phone: '21999999999', + verificationStatus: 'Pending', + ...overrides, + }; +} + +export function createUser(overrides = {}) { + return { + id: 'test-user-id', + name: 'Usuário Teste', + email: 'usuario@teste.com', + ...overrides, + }; +} + +export function createService(overrides = {}) { + return { + id: 'test-service-id', + name: 'Serviço Teste', + categoryId: 'test-category-id', + ...overrides, + }; +} + +export function createReview(overrides = {}) { + return { + id: 'test-review-id', + rating: 5, + text: 'Excelente serviço!', + reviewerName: 'Avaliador Teste', + createdAt: new Date().toISOString(), + ...overrides, + }; +} +``` + +### 4. `libs/test-support/src/index.ts` + +```typescript +export * from './test-utils'; +export * from './mock-data'; +``` + +### 5. `vitest.config.ts` (por projeto — exemplo Customer) + +Cada projeto tem seu próprio `vitest.config.ts`: + +```typescript +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: '../libs/test-support/src/setup.ts', + css: true, + include: ['__tests__/**/*.test.{ts,tsx}'], + exclude: ['node_modules/', '.next/', 'e2e/'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + reportsDirectory: './coverage', + include: [ + 'components/**/*.{ts,tsx}', + 'hooks/**/*.{ts,tsx}', + 'lib/**/*.{ts,tsx}', + ], + exclude: [ + 'node_modules/', + '__tests__/', + 'e2e/', + '.next/', + '**/*.d.ts', + '**/*.config.*', + 'lib/api/generated/**', + 'app/**', + 'types/**', + ], + thresholds: { + // Início progressivo — aumentar conforme cobertura cresce + lines: 50, + functions: 50, + branches: 50, + statements: 50, + }, + }, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './'), + '@test-support': path.resolve(__dirname, '../libs/test-support/src'), + }, + }, +}); +``` + +### 6. MSW — Mock por projeto + +**`__tests__/mocks/handlers.ts`** (exemplo Customer): + +```typescript +import { http, HttpResponse } from 'msw'; + +export const handlers = [ + // Busca de prestadores + http.get('/api/providers', () => { + return HttpResponse.json([ + { + id: '1', + name: 'Eletricista João', + slug: 'eletricista-joao', + verificationStatus: 'Verified', + }, + ]); + }), + + // Perfil do prestador + http.get('/api/providers/:id', ({ params }) => { + return HttpResponse.json({ + id: params.id, + name: 'Prestador Teste', + services: ['Elétrica', 'Hidráulica'], + }); + }), + + // Busca de CEP via ViaCEP + http.get('https://viacep.com.br/ws/:cep/json/', ({ params }) => { + return HttpResponse.json({ + cep: params.cep, + logradouro: 'Rua Teste', + bairro: 'Bairro Teste', + localidade: 'Rio de Janeiro', + uf: 'RJ', + }); + }), +]; +``` + +**`__tests__/mocks/server.ts`**: + +```typescript +import { setupServer } from 'msw/node'; +import { handlers } from './handlers'; + +export const server = setupServer(...handlers); +``` + +### 7. Playwright — Já configurado + +O arquivo `playwright.config.ts` na raiz de `src/Web/` já está configurado e funcional. Os testes E2E em `e2e/` de cada projeto continuam como estão. + +--- + +## 📝 Estrutura dos Arquivos de Teste + +### Organização Interna dos Testes + +```typescript +describe('NomeDoComponente', () => { + // Testes de renderização + describe('Rendering', () => { + it('deve renderizar corretamente', () => {}); + }); + + // Testes de interação + describe('Interactions', () => { + it('deve chamar callback ao clicar', () => {}); + }); + + // Testes de estados + describe('States', () => { + it('deve mostrar loading', () => {}); + it('deve mostrar erro', () => {}); + }); + + // Testes de acessibilidade (quando aplicável) + describe('Accessibility', () => { + it('deve ter roles corretos', () => {}); + }); +}); +``` + +--- + +## 💡 Exemplos Práticos + +### Teste de Componente UI + +**Componente**: `components/ui/button.tsx` +**Teste**: `__tests__/components/ui/button.test.tsx` + +```typescript +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@test-support'; +import userEvent from '@testing-library/user-event'; +import { Button } from '@/components/ui/button'; + +describe('Button Component', () => { + it('deve renderizar corretamente', () => { + render(); + expect(screen.getByRole('button', { name: /clique aqui/i })).toBeInTheDocument(); + }); + + it('deve estar desabilitado quando disabled=true', () => { + render(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('deve chamar onClick quando clicado', async () => { + const handleClick = vi.fn(); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button')); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('não deve chamar onClick quando desabilitado', async () => { + const handleClick = vi.fn(); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button')); + + expect(handleClick).not.toHaveBeenCalled(); + }); +}); +``` + +### Teste de Utility Function + +**Componente**: `lib/utils/phone.ts` +**Teste**: `__tests__/lib/utils/phone.test.ts` + +```typescript +import { describe, it, expect } from 'vitest'; +import { formatPhone, validatePhone } from '@/lib/utils/phone'; + +describe('formatPhone', () => { + it('deve formatar telefone com DDD', () => { + expect(formatPhone('21999999999')).toBe('(21) 99999-9999'); + }); + + it('deve retornar vazio para entrada inválida', () => { + expect(formatPhone('')).toBe(''); + }); +}); + +describe('validatePhone', () => { + it('deve aceitar telefone válido', () => { + expect(validatePhone('21999999999')).toBe(true); + }); + + it('deve rejeitar telefone com poucos dígitos', () => { + expect(validatePhone('2199')).toBe(false); + }); +}); +``` + +### Teste de Schema Zod + +**Componente**: `lib/schemas/verification-status.ts` +**Teste**: `__tests__/lib/schemas/verification-status.test.ts` + +> Migração do teste ad-hoc existente para o formato Vitest. + +```typescript +import { describe, it, expect } from 'vitest'; +import { VerificationStatusSchema } from '@/lib/schemas/verification-status'; +import { EVerificationStatus } from '@/types/api/provider'; + +describe('VerificationStatusSchema', () => { + it.each([ + { input: 0, expected: EVerificationStatus.None }, + { input: 1, expected: EVerificationStatus.Pending }, + { input: '0', expected: EVerificationStatus.None }, + { input: '1', expected: EVerificationStatus.Pending }, + { input: 'verified', expected: EVerificationStatus.Verified }, + { input: 'REJECTED', expected: EVerificationStatus.Rejected }, + { input: 'inprogress', expected: EVerificationStatus.InProgress }, + { input: 'in_progress', expected: EVerificationStatus.InProgress }, + { input: 'suspended', expected: EVerificationStatus.Suspended }, + { input: 'none', expected: EVerificationStatus.None }, + { input: 3, expected: EVerificationStatus.Verified }, + ])('deve converter "$input" para $expected', ({ input, expected }) => { + const result = VerificationStatusSchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(expected); + } + }); + + it('deve retornar fallback para valores desconhecidos', () => { + const result = VerificationStatusSchema.safeParse('unknown'); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(EVerificationStatus.Pending); + } + }); + + it.each([null, undefined])('deve tratar %s graciosamente', (input) => { + const result = VerificationStatusSchema.safeParse(input); + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); +}); +``` + +### Teste de Hook com API (MSW) + +**Hook**: `hooks/use-via-cep.ts` +**Teste**: `__tests__/hooks/use-via-cep.test.ts` + +```typescript +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { renderHook, waitFor } from '@test-support'; +import { useViaCep } from '@/hooks/use-via-cep'; +import { server } from '../mocks/server'; + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe('useViaCep Hook', () => { + it('deve retornar dados do endereço para um CEP válido', async () => { + const { result } = renderHook(() => useViaCep('20550160')); + + await waitFor(() => { + expect(result.current.data).toBeDefined(); + }); + + expect(result.current.data?.logradouro).toBe('Rua Teste'); + expect(result.current.data?.localidade).toBe('Rio de Janeiro'); + }); + + it('deve retornar loading enquanto busca', () => { + const { result } = renderHook(() => useViaCep('20550160')); + expect(result.current.isLoading).toBe(true); + }); +}); +``` + +### Teste de Componente de Feature + +**Componente**: `components/auth/login-form.tsx` +**Teste**: `__tests__/components/auth/login-form.test.tsx` + +```typescript +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@test-support'; +import userEvent from '@testing-library/user-event'; +import { LoginForm } from '@/components/auth/login-form'; + +describe('LoginForm', () => { + it('deve renderizar campos de email e senha', () => { + render(); + + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/senha/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /entrar/i })).toBeInTheDocument(); + }); + + it('deve validar email obrigatório', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /entrar/i })); + + await waitFor(() => { + expect(screen.getByText(/campo obrigatório|email.*obrigatório/i)).toBeInTheDocument(); + }); + }); + + it('deve validar formato de email', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText(/email/i), 'email-invalido'); + await user.click(screen.getByRole('button', { name: /entrar/i })); + + await waitFor(() => { + expect(screen.getByText(/email inválido/i)).toBeInTheDocument(); + }); + }); +}); +``` + +--- + +## 🔄 Integração com Pipeline CI/CD + +### Scripts no `package.json` raiz (`src/Web/`) + +```json +{ + "scripts": { + "test:all": "npm run test:customer && npm run test:admin && npm run test:provider", + "test": "npm run test:all", + "test:customer": "cd MeAjudaAi.Web.Customer && npx vitest run --config vitest.config.ts", + "test:admin": "cd MeAjudaAi.Web.Admin && npx vitest run --config vitest.config.ts", + "test:provider": "cd MeAjudaAi.Web.Provider && npx vitest run --config vitest.config.ts", + "test:customer:watch": "cd MeAjudaAi.Web.Customer && npx vitest --config vitest.config.ts", + "test:admin:watch": "cd MeAjudaAi.Web.Admin && npx vitest --config vitest.config.ts", + "test:provider:watch": "cd MeAjudaAi.Web.Provider && npx vitest --config vitest.config.ts", + "test:customer:coverage": "cd MeAjudaAi.Web.Customer && npx vitest run --coverage --config vitest.config.ts", + "test:admin:coverage": "cd MeAjudaAi.Web.Admin && npx vitest run --coverage --config vitest.config.ts", + "test:provider:coverage": "cd MeAjudaAi.Web.Provider && npx vitest run --coverage --config vitest.config.ts", + "test:coverage:all": "npm run test:customer:coverage && npm run test:admin:coverage && npm run test:provider:coverage", + "test:coverage:merge": "node scripts/merge-coverage.mjs", + "test:coverage:global": "npm run test:coverage:all && npm run test:coverage:merge", + "test:ci": "npm run test:coverage:global", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:ci": "playwright test --project=ci --reporter=html --reporter=junit" + } +} +``` + +### GitHub Actions + +> **Nota**: O projeto utiliza workflows canônicos localizados em `.github/workflows/`: +> - `ci-frontend.yml` - Testes unitários (Vitest) com cobertura consolidada +> - `ci-e2e.yml` - Testes E2E (Playwright) com geração de OpenAPI +> +> Estes workflows implementam as melhores práticas de CI/CD para o monorepo. + +--- + +## 🎯 Comandos Úteis + +### Testes Unitários (Vitest) + +```bash +# A partir de src/Web/ + +# Executar todos os testes +npm test + +# Executar testes de um projeto específico +npm run test:customer +npm run test:admin +npm run test:provider + +# Executar em modo watch (desenvolvimento) +# Nota: Use npx vitest diretamente no diretório do projeto +cd MeAjudaAi.Web.Customer && npx vitest + +# Executar com cobertura (global - consolida todos os projetos) +npm run test:coverage:global + +# Executar apenas um arquivo específico +npx vitest run --config MeAjudaAi.Web.Customer/vitest.config.ts __tests__/lib/utils/phone.test.ts + +# Executar testes que correspondem a um padrão +npx vitest run --config MeAjudaAi.Web.Customer/vitest.config.ts -t "formatPhone" +``` + +### Testes E2E (Playwright) + +```bash +# Executar todos os testes E2E +npm run test:e2e + +# Executar em modo UI (debug visual) +npm run test:e2e:ui + +# Executar apenas um arquivo +npx playwright test MeAjudaAi.Web.Customer/e2e/auth.spec.ts + +# Executar em modo headed (ver o browser) +npx playwright test --headed + +# Ver relatório +npx playwright show-report +``` + +### Cobertura + +```bash +# Gerar relatório de cobertura +npm run test:coverage + +# Ver relatório HTML +open MeAjudaAi.Web.Customer/coverage/index.html +``` + +--- + +## ✅ Boas Práticas + +### 1. Nomenclatura +- Arquivos de teste: `nome-do-componente.test.tsx` +- Describes: `describe('NomeDoComponente', ...)` +- Tests: `it('deve fazer algo específico', ...)` + +### 2. Padrão AAA (Arrange, Act, Assert) +```typescript +it('deve incrementar contador', async () => { + // Arrange + const user = userEvent.setup(); + render(); + + // Act + await user.click(screen.getByRole('button')); + + // Assert + expect(screen.getByText('1')).toBeInTheDocument(); +}); +``` + +### 3. Queries Prioritárias (Testing Library) +1. `getByRole` (preferencial — testa acessibilidade) +2. `getByLabelText` +3. `getByPlaceholderText` +4. `getByText` +5. `getByTestId` (último recurso) + +### 4. Evitar Detalhes de Implementação +```typescript +// ❌ Ruim — testa implementação interna +expect(component.state.count).toBe(1); + +// ✅ Bom — testa comportamento visível ao usuário +expect(screen.getByText('1')).toBeInTheDocument(); +``` + +### 5. Testes Assíncronos +```typescript +// Use waitFor para operações assíncronas +await waitFor(() => { + expect(screen.getByText('Dados carregados')).toBeInTheDocument(); +}); + +// Use findBy* como atalho +const element = await screen.findByText('Dados carregados'); +``` + +### 6. Mocks Limpos +```typescript +import { vi } from 'vitest'; + +// Mock de função +const mockFn = vi.fn(); + +// Mock de módulo +vi.mock('@/lib/api/client', () => ({ + fetchProviders: vi.fn(() => Promise.resolve([])) +})); +``` + +### 7. Cobertura Mínima por Camada +- **Utils/Schemas**: 90%+ (funções puras, fácil testar) +- **Hooks**: 80%+ (lógica de negócio encapsulada) +- **Estratégia**: Arquitetura de Testes Descentralizada (cada projeto gerencia seus próprios testes unitários e E2E). +- **Cobertura**: Threshold Global de 70% consolidado via script `merge-coverage.mjs`. +- **Unitários**: Vitest + React Testing Library + MSW. +- **E2E**: Playwright (specs localizadas na pasta `e2e/` de cada projeto). +- **CI/CD**: Geração automática de `api-spec.json` seguida de `generate:api` para garantir sincronia de tipos. + +### 8. Testes de Acessibilidade (opcional) +```typescript +import { axe, toHaveNoViolations } from 'jest-axe'; + +expect.extend(toHaveNoViolations); + +it('não deve ter violações de acessibilidade', async () => { + const { container } = render(); + const results = await axe(container); + expect(results).toHaveNoViolations(); +}); +``` + +--- + +## 📊 Métricas de Qualidade + +### Pirâmide de Testes + +``` + /\ + /E2E\ 10% - Testes E2E (fluxos críticos) + /------\ + /Integration\ 20% - Testes de Integração (componentes + API) + /------------\ + / Unit \ 70% - Testes Unitários + /----------------\ +``` + +### Thresholds Progressivos + +| Fase | Lines | Functions | Branches | Statements | +|------|-------|-----------|----------|------------| +| Início | 50% | 50% | 50% | 50% | +| Meta intermediária | 65% | 65% | 65% | 65% | +| Meta final | 80% | 80% | 80% | 80% | + +--- + +## 🔧 Troubleshooting + +### Problema: Testes não encontram elementos +```typescript +render(); +screen.debug(); // Mostra HTML renderizado no console +``` + +### Problema: Testes assíncronos falhando +```typescript +// ❌ Falta await +const element = screen.findByText('Text'); + +// ✅ Correto +const element = await screen.findByText('Text'); +``` + +### Problema: Mock não funciona +```typescript +// ✅ vi.mock é hoisted — sempre fica no topo do arquivo +vi.mock('@/lib/api/client'); +import { Component } from '@/components/Component'; +``` + +### Problema: Imports com @ não resolvem +Verificar que o `vitest.config.ts` do projeto tem os aliases corretos: +```typescript +resolve: { + alias: { + '@/': path.resolve(__dirname, './'), + }, +}, +``` + +--- + +## 📋 Checklist de Implementação + +### Fase 1: Infraestrutura Base 🔨 + +- [ ] Criar `libs/test-support/` com `setup.ts`, `test-utils.tsx`, `mock-data.ts` +- [ ] Instalar dependências: `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`, `msw`, `@vitest/coverage-v8` +- [ ] Criar `vitest.config.ts` no Customer +- [ ] Criar `vitest.config.ts` no Admin +- [ ] Criar `vitest.config.ts` no Provider +- [ ] Adicionar scripts de teste ao `package.json` raiz +- [ ] Validar que `npx vitest run` funciona em cada projeto + +### Fase 2: Testes de Utils e Schemas 🧮 + +Funções puras — sem dependências de React ou API. + +- [ ] `lib/utils/normalization.test.ts` (Customer + Provider) +- [ ] `lib/utils/phone.test.ts` (Customer + Provider) +- [ ] `lib/utils/cn.test.ts` (Customer + Provider) +- [ ] `lib/schemas/verification-status.test.ts` — migrar teste ad-hoc existente +- [ ] `lib/schemas/auth.test.ts` (Customer + Provider) +- [ ] `lib/api/response-utils.test.ts` (Customer + Provider) +- [ ] `lib/api/mappers.test.ts` (Customer + Provider) + +### Fase 3: Testes de Hooks 🪝 + +Hooks com lógica de negócio — usar `renderHook` + MSW. + +**Customer:** +- [ ] `hooks/use-via-cep.test.ts` +- [ ] `hooks/use-services.test.ts` +- [ ] `hooks/use-register-provider.test.ts` +- [ ] `hooks/use-provider-status.test.ts` +- [ ] `hooks/use-my-provider-profile.test.ts` +- [ ] `hooks/use-document-upload.test.ts` +- [ ] `hooks/use-update-provider-profile.test.ts` + +**Admin:** +- [ ] `hooks/admin/use-providers.test.ts` +- [ ] `hooks/admin/use-categories.test.ts` +- [ ] `hooks/admin/use-dashboard.test.ts` +- [ ] `hooks/admin/use-services.test.ts` +- [ ] `hooks/admin/use-allowed-cities.test.ts` +- [ ] `hooks/admin/use-users.test.ts` + +### Fase 4: Testes de Componentes UI 🎨 + +Componentes de `components/ui/` — cada projeto testa os seus. + +**Customer:** +- [ ] `components/ui/button.test.tsx` +- [ ] `components/ui/input.test.tsx` +- [ ] `components/ui/card.test.tsx` +- [ ] `components/ui/select.test.tsx` +- [ ] `components/ui/dialog.test.tsx` +- [ ] `components/ui/badge.test.tsx` + +**Admin:** +- [ ] `components/ui/button.test.tsx` +- [ ] `components/ui/card.test.tsx` +- [ ] `components/ui/input.test.tsx` +- [ ] `components/ui/dialog.test.tsx` +- [ ] `components/ui/select.test.tsx` +- [ ] `components/ui/theme-toggle.test.tsx` + +**Provider:** +- [ ] `components/ui/button.test.tsx` +- [ ] `components/ui/card.test.tsx` +- [ ] `components/ui/input.test.tsx` +- [ ] `components/ui/file-upload.test.tsx` + +### Fase 5: Testes de Componentes de Feature 🏗️ + +Componentes com lógica de negócio — os mais impactantes. + +**Customer:** +- [ ] `components/auth/login-form.test.tsx` +- [ ] `components/auth/customer-register-form.test.tsx` +- [ ] `components/providers/provider-card.test.tsx` +- [ ] `components/providers/provider-grid.test.tsx` +- [ ] `components/search/search-filters.test.tsx` +- [ ] `components/search/city-search.test.tsx` +- [ ] `components/reviews/review-card.test.tsx` +- [ ] `components/reviews/review-form.test.tsx` +- [ ] `components/layout/header.test.tsx` + +**Admin:** +- [ ] `components/layout/sidebar.test.tsx` + +**Provider:** +- [ ] `components/dashboard/profile-status-card.test.tsx` +- [ ] `components/dashboard/verification-card.test.tsx` +- [ ] `components/profile/profile-header.test.tsx` +- [ ] `components/profile/profile-services.test.tsx` + +### Fase 6: MSW + Testes de Integração 🔗 + +- [ ] Configurar MSW handlers por projeto (`__tests__/mocks/`) +- [ ] Testes de integração: componentes + API (loading → data → error) +- [ ] Testes de fluxos: login → redirect, cadastro → confirmação + +### Fase 7: CI/CD e Cobertura 🚀 + +- [ ] Adicionar step de testes frontend no GitHub Actions +- [ ] Configurar reports (JUnit XML, coverage JSON) +- [ ] Estabelecer thresholds progressivos: 50% → 65% → 80% +- [ ] Adicionar badge de cobertura no README + +--- + +## 📚 Recursos Adicionais + +- [Vitest Documentation](https://vitest.dev/) +- [Testing Library — React](https://testing-library.com/docs/react-testing-library/intro/) +- [Playwright](https://playwright.dev/) +- [MSW — Mock Service Worker](https://mswjs.io/) +- [Kent C. Dodds — Testing Blog](https://kentcdodds.com/blog) + +--- + +## 🤝 Alinhamento com Backend (.NET) + +| Backend (.NET) | Frontend (React) | +|---|---| +| xUnit | Vitest | +| FluentAssertions | Jest-DOM matchers | +| Moq | MSW | +| Integration Tests | E2E Tests (Playwright) | +| Code Coverage (Coverlet) | Code Coverage (v8) | +| Testes separados em `tests/` | Testes dentro de cada projeto em `__tests__/` | + +--- + +**Última atualização**: Março 2026 +**Versão**: 3.0.0 (Testes dentro de cada projeto React) diff --git a/scripts/dev.ps1 b/scripts/dev.ps1 deleted file mode 100644 index 4fd0125ce..000000000 --- a/scripts/dev.ps1 +++ /dev/null @@ -1,135 +0,0 @@ -<# -.SYNOPSIS - Inicia o ambiente de desenvolvimento do MeAjudaAi -.DESCRIPTION - Script para iniciar a aplicação via Aspire AppHost -.EXAMPLE - .\scripts\dev.ps1 -#> - -$ErrorActionPreference = "Stop" - -# Configurar variáveis de ambiente para desenvolvimento -$env:ASPNETCORE_ENVIRONMENT = "Development" -$env:DOTNET_ENVIRONMENT = "Development" -$env:POSTGRES_PASSWORD = "postgres" -$env:DB_PASSWORD = $env:POSTGRES_PASSWORD # Program.cs reads DB_PASSWORD - -# Add social login variables from .env if present -$baseDir = $PSScriptRoot -if ([string]::IsNullOrEmpty($baseDir)) { - $baseDir = $PWD -} -$envFilePath = Join-Path $baseDir "..\infrastructure\compose\environments\.env" -$envFilePath = [System.IO.Path]::GetFullPath($envFilePath) - -Write-Host "🔍 Procurando .env em: $envFilePath" -ForegroundColor Gray - -if (Test-Path $envFilePath) { - Write-Host "🔧 Carregando variáveis de ambiente do .env..." -ForegroundColor Cyan - Get-Content $envFilePath | Where-Object { $_ -match '^\s*[\w-]+\s*=' } | ForEach-Object { - $parts = $_.Split('=', 2) - $name = $parts[0].Trim() - $value = $parts[1].Trim() - - # Strip inline comments - if ($value -match '#') { - # Find first # not inside quotes - $inSingle = $false - $inDouble = $false - for ($i = 0; $i -lt $value.Length; $i++) { - $char = $value[$i] - if ($char -eq "'" -and -not $inDouble) { $inSingle = -not $inSingle } - elseif ($char -eq '"' -and -not $inSingle) { $inDouble = -not $inDouble } - elseif ($char -eq '#' -and -not $inSingle -and -not $inDouble) { - $value = $value.Substring(0, $i).Trim() - break - } - } - } - - $cleanValue = $value - if (($cleanValue.StartsWith('"') -and $cleanValue.EndsWith('"')) -or ($cleanValue.StartsWith("'") -and $cleanValue.EndsWith("'"))) { - if ($cleanValue.Length -ge 2) { - $cleanValue = $cleanValue.Substring(1, $cleanValue.Length - 2) - } - } - Set-Item -Path "env:$name" -Value $cleanValue - } -} else { - Write-Host "⚠️ Arquivo .env não encontrado em $envFilePath. Lógicas que dependem dele podem falhar." -ForegroundColor Yellow -} - -Write-Host "🚀 Iniciando MeAjudaAi - Ambiente de Desenvolvimento" -ForegroundColor Cyan -Write-Host "=================================================" -ForegroundColor Cyan -Write-Host "" - -# Verificar Docker -Write-Host "🐳 Verificando Docker..." -ForegroundColor Yellow -try { - $dockerStatus = docker info 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Host "❌ Docker não está rodando. Inicie o Docker Desktop primeiro." -ForegroundColor Red - exit 1 - } - Write-Host "✅ Docker está rodando" -ForegroundColor Green -} catch { - Write-Host "❌ Docker não encontrado. Instale o Docker Desktop." -ForegroundColor Red - exit 1 -} - -# Verificar .NET SDK -Write-Host "" -Write-Host "📦 Verificando .NET SDK..." -ForegroundColor Yellow -try { - $dotnetVersion = dotnet --version - Write-Host "✅ .NET SDK $dotnetVersion" -ForegroundColor Green -} catch { - Write-Host "❌ .NET SDK não encontrado. Instale o .NET 10 SDK." -ForegroundColor Red - exit 1 -} - -# Restaurar dependências -Write-Host "" -Write-Host "📥 Restaurando dependências..." -ForegroundColor Yellow -dotnet restore -if ($LASTEXITCODE -ne 0) { - Write-Host "❌ Erro ao restaurar dependências" -ForegroundColor Red - exit 1 -} -Write-Host "✅ Dependências restauradas" -ForegroundColor Green - -# Iniciar aplicação -Write-Host "" -Write-Host "▶️ Iniciando Aspire AppHost..." -ForegroundColor Cyan -Write-Host "" -Write-Host "⚠️ NOTA: Se houver erro relacionado a DCP/Dashboard, execute via VS Code (F5)" -ForegroundColor Yellow -Write-Host " Mais detalhes: https://github.com/dotnet/aspire/issues/6789" -ForegroundColor Gray -Write-Host "" -Write-Host "📊 Aspire Dashboard estará disponível em:" -ForegroundColor Yellow -Write-Host " https://localhost:17063" -ForegroundColor White -Write-Host "" -Write-Host "🌐 Serviços que serão iniciados:" -ForegroundColor Yellow -Write-Host " - PostgreSQL (porta 5432)" -ForegroundColor White -Write-Host " - Redis (porta 6379)" -ForegroundColor White -Write-Host " - Keycloak (porta 8080)" -ForegroundColor White -Write-Host " - RabbitMQ (porta 5672)" -ForegroundColor White -Write-Host " - API Backend (porta 7524/5545)" -ForegroundColor White -Write-Host " - Admin Portal Blazor" -ForegroundColor White -Write-Host " - Customer Web App (porta 3000)" -ForegroundColor White -Write-Host "" -Write-Host "Pressione Ctrl+C para parar..." -ForegroundColor Gray -Write-Host "" - -try { - Push-Location "$PSScriptRoot\..\src\Aspire\MeAjudaAi.AppHost" - dotnet run - - if ($LASTEXITCODE -ne 0) { - Write-Host "" - Write-Host "❌ Aspire AppHost exited with code $LASTEXITCODE" -ForegroundColor Red - exit $LASTEXITCODE - } -} finally { - Pop-Location -} diff --git a/scripts/ef-migrate.ps1 b/scripts/ef-migrate.ps1 deleted file mode 100644 index 790d88e58..000000000 --- a/scripts/ef-migrate.ps1 +++ /dev/null @@ -1,257 +0,0 @@ -#!/usr/bin/env pwsh - -<# -.SYNOPSIS - Comando simplificado para aplicar migrações usando dotnet ef diretamente - -.DESCRIPTION - Este script aplica migrações usando comandos dotnet ef para cada módulo individualmente. - Mais simples e direto que a ferramenta customizada. - - Configuração de banco de dados via variáveis de ambiente: - - DB_HOST (padrão: localhost) - - DB_PORT (padrão: 5432) - - DB_NAME (padrão: MeAjudaAi) - - DB_USER (padrão: postgres) - - DB_PASSWORD (obrigatório - será solicitado se não definido) - -.PARAMETER Command - O comando a ser executado: - - migrate: Aplica todas as migrações (padrão) - - add: Adiciona uma nova migração - - remove: Remove a última migração - - status: Lista migrações aplicadas e pendentes - -.PARAMETER Module - Módulo específico (Users, Providers, etc.). Se não especificado, executa para todos. - -.PARAMETER MigrationName - Nome da migração (apenas para comando 'add') - -.EXAMPLE - .\ef-migrate.ps1 - Aplica migrações para todos os módulos - -.EXAMPLE - .\ef-migrate.ps1 -Module Providers - Aplica migrações apenas para o módulo Providers - -.EXAMPLE - .\ef-migrate.ps1 -Command add -Module Users -MigrationName "AddNewUserField" - Adiciona nova migração ao módulo Users -#> - -param( - [Parameter(Position = 0)] - [ValidateSet("migrate", "add", "remove", "status", "list")] - [string]$Command = "migrate", - - [Parameter()] - [ValidateSet("Users", "Providers")] - [string]$Module = $null, - - [Parameter()] - [string]$MigrationName = $null -) - -# Função para obter configuração do banco de dados -function Get-DatabaseConfig { - $dbHost = $env:DB_HOST ?? "localhost" - $dbPort = $env:DB_PORT ?? "5432" - $dbName = $env:DB_NAME ?? "MeAjudaAi" - $dbUser = $env:DB_USER ?? "postgres" - $dbPassword = $env:DB_PASSWORD - - if (-not $dbPassword) { - Write-ColoredOutput "❌ Variável de ambiente DB_PASSWORD não definida." $Red - Write-ColoredOutput "Configure as seguintes variáveis de ambiente:" $Yellow - Write-ColoredOutput " DB_HOST (padrão: localhost)" $Yellow - Write-ColoredOutput " DB_PORT (padrão: 5432)" $Yellow - Write-ColoredOutput " DB_NAME (padrão: MeAjudaAi)" $Yellow - Write-ColoredOutput " DB_USER (padrão: postgres)" $Yellow - Write-ColoredOutput " DB_PASSWORD (obrigatório)" $Yellow - Write-Host - Write-ColoredOutput "Exemplo:" $Blue - Write-ColoredOutput "`$env:DB_PASSWORD='suasenha'; .\ef-migrate.ps1" $Blue - exit 1 - } - - return "Host=$dbHost;Port=$dbPort;Database=$dbName;Username=$dbUser;Password=$dbPassword" -} - -# Obter string de conexão -$connectionString = Get-DatabaseConfig - -# Definir módulos e seus contextos -$Modules = @{ - "Users" = @{ - "Project" = "src/Modules/Users/Infrastructure/MeAjudaAi.Modules.Users.Infrastructure.csproj" - "Context" = "UsersDbContext" - "OutputDir" = "Persistence/Migrations" - "ConnectionString" = $connectionString - } - "Providers" = @{ - "Project" = "src/Modules/Providers/Infrastructure/MeAjudaAi.Modules.Providers.Infrastructure.csproj" - "Context" = "ProvidersDbContext" - "OutputDir" = "Persistence/Migrations" - "ConnectionString" = $connectionString - } -} - -# Cores -$Green = "`e[32m"; $Red = "`e[31m"; $Yellow = "`e[33m"; $Blue = "`e[34m"; $Reset = "`e[0m" - -function Write-ColoredOutput { - param([string]$Message, [string]$Color = $Reset) - Write-Host "$Color$Message$Reset" -} - -function Invoke-EFCommand { - param( - [string]$ModuleName, - [hashtable]$ModuleInfo, - [string]$EFCommand - ) - - Write-ColoredOutput "📦 $ModuleName`: $EFCommand" $Blue - - try { - # Set connection string as environment variable - $env:ConnectionStrings__DefaultConnection = $ModuleInfo.ConnectionString - $env:ASPNETCORE_ENVIRONMENT = "Development" - - Invoke-Expression "dotnet ef $EFCommand --project `"$($ModuleInfo.Project)`" --context $($ModuleInfo.Context) --verbose" - - if ($LASTEXITCODE -eq 0) { - Write-ColoredOutput " ✅ Sucesso" $Green - return $true - } else { - Write-ColoredOutput " ❌ Falhou (código: $LASTEXITCODE)" $Red - return $false - } - } catch { - Write-ColoredOutput " ❌ Erro: $_" $Red - return $false - } finally { - Remove-Item Env:ConnectionStrings__DefaultConnection -ErrorAction SilentlyContinue - Remove-Item Env:ASPNETCORE_ENVIRONMENT -ErrorAction SilentlyContinue - } -} - -# Determinar quais módulos processar -$ModulesToProcess = if ($Module) { - @($Module) -} else { - $Modules.Keys -} - -Write-ColoredOutput "🔧 Entity Framework Migration Tool" $Blue -Write-ColoredOutput "📋 Comando: $Command" $Blue -Write-ColoredOutput "🎯 Módulos: $($ModulesToProcess -join ', ')" $Blue -Write-Host - -# Verificar se dotnet ef está instalado -try { - & dotnet ef --version 2>$null | Out-Null - if ($LASTEXITCODE -ne 0) { - Write-ColoredOutput "❌ dotnet ef não encontrado. Instalando..." $Yellow - & dotnet tool install --global dotnet-ef - if ($LASTEXITCODE -ne 0) { - Write-ColoredOutput "❌ Falha ao instalar dotnet ef" $Red - exit 1 - } - } - Write-ColoredOutput "✅ dotnet ef disponível" $Green -} catch { - Write-ColoredOutput "❌ Erro ao verificar dotnet ef: $_" $Red - exit 1 -} - -$successCount = 0 -$totalCount = 0 -$failedCount = 0 - -foreach ($ModuleName in $ModulesToProcess) { - if (-not $Modules.ContainsKey($ModuleName)) { - Write-ColoredOutput "⚠️ Módulo '$ModuleName' não encontrado" $Yellow - continue - } - - $moduleInfo = $Modules[$ModuleName] - $totalCount++ - - # Verificar se o projeto existe - if (-not (Test-Path $moduleInfo.Project)) { - Write-ColoredOutput "❌ Projeto não encontrado: $($moduleInfo.Project)" $Red - $failedCount++ - continue - } - - switch ($Command) { - "migrate" { - $efCommand = "database update" - if (Invoke-EFCommand $ModuleName $moduleInfo $efCommand) { - $successCount++ - } else { - $failedCount++ - } - } - - "add" { - if (-not $MigrationName) { - Write-ColoredOutput "❌ Nome da migração é obrigatório para o comando 'add'" $Red - $failedCount++ - continue - } - $efCommand = "migrations add `"$MigrationName`" --output-dir `"$($moduleInfo.OutputDir)`"" - if (Invoke-EFCommand $ModuleName $moduleInfo $efCommand) { - $successCount++ - } else { - $failedCount++ - } - } - - "remove" { - $efCommand = "migrations remove" - if (Invoke-EFCommand $ModuleName $moduleInfo $efCommand) { - $successCount++ - } else { - $failedCount++ - } - } - - "status" { - $efCommand = "migrations list" - if (Invoke-EFCommand $ModuleName $moduleInfo $efCommand) { - $successCount++ - } else { - $failedCount++ - } - } - - "list" { - $efCommand = "migrations list" - if (Invoke-EFCommand $ModuleName $moduleInfo $efCommand) { - $successCount++ - } else { - $failedCount++ - } - } - } - - Write-Host -} - -# Resumo -Write-ColoredOutput "📊 Resumo: $successCount sucessos, $failedCount falhas de $totalCount módulos" $Blue - -if ($failedCount -eq 0 -and $totalCount -gt 0) { - Write-ColoredOutput "✅ Todos os comandos executados com sucesso!" $Green - exit 0 -} elseif ($totalCount -eq 0) { - Write-ColoredOutput "⚠️ Nenhum módulo foi processado." $Yellow - exit 1 -} else { - Write-ColoredOutput "❌ $failedCount comandos falharam. Verifique os logs acima." $Red - exit 1 -} \ No newline at end of file diff --git a/scripts/export-openapi.ps1 b/scripts/export-openapi.ps1 deleted file mode 100644 index 8aef9cd3f..000000000 --- a/scripts/export-openapi.ps1 +++ /dev/null @@ -1,59 +0,0 @@ -#requires -Version 5.1 -Set-StrictMode -Version Latest -param( - [Parameter(Mandatory = $false)] - [ValidateNotNullOrEmpty()] - [string]$OutputPath = "api/api-spec.json" -) -$ProjectRoot = Split-Path -Parent $PSScriptRoot -$OutputPath = if ([System.IO.Path]::IsPathRooted($OutputPath)) { $OutputPath } else { Join-Path $ProjectRoot $OutputPath } -try { - Write-Host "Validando especificacao OpenAPI..." -ForegroundColor Cyan - if (Test-Path -PathType Leaf $OutputPath) { - $Content = Get-Content -Raw -ErrorAction Stop $OutputPath | ConvertFrom-Json -ErrorAction Stop - if (-not $Content.paths) { - Write-Error "Secao 'paths' ausente no OpenAPI: $OutputPath" - exit 1 - } - if (-not $Content.openapi -or -not ($Content.openapi -match '^3(\.|$)')) { - Write-Error "Campo 'openapi' 3.x ausente ou invalido no OpenAPI: $OutputPath" - exit 1 - } - # Define valid HTTP operation names (case-insensitive) - $httpMethods = @('get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'trace') - - $allPaths = $Content.paths.PSObject.Properties - $PathCount = @($allPaths).Count - Write-Host "Total paths: $PathCount" -ForegroundColor Green - $totalOps = [int](($allPaths | - ForEach-Object { - ($_.Value.PSObject.Properties | Where-Object { $httpMethods -contains $_.Name.ToLowerInvariant() }).Count - } | Measure-Object -Sum - ).Sum) - Write-Host "Total operations: $totalOps" -ForegroundColor Green - $usersPaths = $Content.paths.PSObject.Properties | Where-Object { $_.Name -match '^/api/v1/users($|/)' } - - # Count only HTTP operations, not other properties like "parameters" - $usersCount = [int](($usersPaths | ForEach-Object { - $httpOps = $_.Value.PSObject.Properties | Where-Object { $httpMethods -contains $_.Name.ToLowerInvariant() } - $httpOps.Count - } | Measure-Object -Sum).Sum) - - Write-Host "Users endpoints: $usersCount" -ForegroundColor Green - $sortedUsersPaths = $usersPaths | Sort-Object Name - foreach ($path in $sortedUsersPaths) { - # Filter to only HTTP operation names - $httpOps = $path.Value.PSObject.Properties | Where-Object { $httpMethods -contains $_.Name.ToLowerInvariant() } - $methods = ($httpOps.Name | Sort-Object | ForEach-Object { $_.ToUpperInvariant() }) -join ", " - if ([string]::IsNullOrWhiteSpace($methods)) { $methods = "(no operations)" } - Write-Host " $($path.Name): $methods" -ForegroundColor White - } - Write-Host "Especificacao OK!" -ForegroundColor Green - } else { - Write-Host "Arquivo nao encontrado: $OutputPath" -ForegroundColor Red - exit 1 - } -} catch { - Write-Error ("Falha ao validar especificacao: " + $_.Exception.Message) - exit 1 -} diff --git a/scripts/migrate-all.ps1 b/scripts/migrate-all.ps1 deleted file mode 100644 index 848369fc9..000000000 --- a/scripts/migrate-all.ps1 +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env pwsh - -<# -.SYNOPSIS - Script para executar migrações de todos os módulos do MeAjudaAi - -.DESCRIPTION - Este script facilita a execução da ferramenta de migração para todos os módulos. - Ele descobre automaticamente todos os DbContexts e aplica as migrações necessárias. - -.PARAMETER Command - O comando a ser executado: - - migrate: Aplica todas as migrações pendentes (padrão) - - create: Cria os bancos de dados se não existirem - - reset: Remove e recria todos os bancos - - status: Mostra o status das migrações - -.PARAMETER ConnectionString - String de conexão customizada (opcional) - -.EXAMPLE - .\migrate-all.ps1 - Aplica todas as migrações pendentes - -.EXAMPLE - .\migrate-all.ps1 -Command status - Mostra o status das migrações - -.EXAMPLE - .\migrate-all.ps1 -Command reset - Remove e recria todos os bancos -#> - -param( - [Parameter(Position = 0)] - [ValidateSet("migrate", "create", "reset", "status")] - [string]$Command = "migrate", - - [Parameter()] - [string]$ConnectionString = $null -) - -# Cores para output -$Green = "`e[32m" -$Red = "`e[31m" -$Yellow = "`e[33m" -$Blue = "`e[34m" -$Reset = "`e[0m" - -function Write-ColoredOutput { - param([string]$Message, [string]$Color = $Reset) - Write-Host "$Color$Message$Reset" -} - -# Verificar se estamos no diretório raiz do projeto -$solutionFile = @(Get-ChildItem -Name "*.sln" -ErrorAction SilentlyContinue) -if (-not $solutionFile) { - Write-ColoredOutput "❌ Arquivo .sln não encontrado. Execute este script no diretório raiz do projeto." $Red - exit 1 -} - -Write-ColoredOutput "🔧 MeAjudaAi Migration Tool" $Blue -Write-ColoredOutput "📋 Comando: $Command" $Blue -Write-ColoredOutput "📁 Projeto: $($solutionFile[0])" $Blue -Write-Host - -# Verificar se o PostgreSQL está rodando -try { - # Verificar se existe algum container com nome "postgres" - $existingContainer = & docker ps -a --filter "name=postgres" --format "{{.Names}}" 2>$null - - if ($existingContainer -match "postgres") { - # Verificar se está rodando - $runningContainer = & docker ps --filter "name=postgres" --format "{{.Names}}" 2>$null - if ($runningContainer -match "postgres") { - Write-ColoredOutput "✅ PostgreSQL container já está rodando" $Green - } else { - Write-ColoredOutput "⚠️ PostgreSQL container existe mas não está rodando. Iniciando..." $Yellow - & docker start postgres - if ($LASTEXITCODE -ne 0) { - Write-ColoredOutput "❌ Erro ao iniciar container PostgreSQL existente" $Red - exit 1 - } - Start-Sleep -Seconds 5 - Write-ColoredOutput "✅ PostgreSQL container iniciado" $Green - } - } else { - Write-ColoredOutput "⚠️ PostgreSQL container não encontrado. Criando novo..." $Yellow - & docker run -d --name postgres -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:15 - if ($LASTEXITCODE -ne 0) { - Write-ColoredOutput "❌ Erro ao criar container PostgreSQL" $Red - exit 1 - } - Start-Sleep -Seconds 5 - Write-ColoredOutput "✅ PostgreSQL container criado e iniciado" $Green - } -} catch { - Write-ColoredOutput "❌ Erro ao verificar/iniciar PostgreSQL: $_" $Red - exit 1 -} - -# Construir a ferramenta de migração -Write-ColoredOutput "🔨 Construindo a ferramenta de migração..." $Blue -try { - $buildResult = & dotnet build tools/MigrationTool --configuration Release --verbosity quiet - if ($LASTEXITCODE -ne 0) { - Write-ColoredOutput "❌ Erro ao construir a ferramenta de migração" $Red - exit 1 - } - Write-ColoredOutput "✅ Ferramenta construída com sucesso" $Green -} catch { - Write-ColoredOutput "❌ Erro ao construir a ferramenta: $_" $Red - exit 1 -} - -# Executar a ferramenta -Write-ColoredOutput "🚀 Executando comando: $Command" $Blue -Write-Host - -try { - if ($ConnectionString) { - $env:ConnectionString = $ConnectionString - } - - & dotnet run --project tools/MigrationTool --configuration Release -- $Command - - if ($LASTEXITCODE -eq 0) { - Write-Host - Write-ColoredOutput "✅ Comando executado com sucesso!" $Green - } else { - Write-Host - Write-ColoredOutput "❌ Comando falhou com código de saída: $LASTEXITCODE" $Red - exit $LASTEXITCODE - } -} catch { - Write-ColoredOutput "❌ Erro ao executar a ferramenta: $_" $Red - exit 1 -} finally { - if ($ConnectionString) { - Remove-Item Env:ConnectionString -ErrorAction SilentlyContinue - } -} - -# Sugestões baseadas no comando executado -Write-Host -switch ($Command) { - "migrate" { - Write-ColoredOutput "💡 Dica: Use './migrate-all.ps1 status' para verificar o status das migrações" $Yellow - } - "create" { - Write-ColoredOutput "💡 Dica: Use './migrate-all.ps1 migrate' para aplicar as migrações" $Yellow - } - "reset" { - Write-ColoredOutput "💡 Dica: Use './migrate-all.ps1 status' para verificar se tudo foi resetado corretamente" $Yellow - } - "status" { - Write-ColoredOutput "💡 Dica: Use './migrate-all.ps1 migrate' se houver migrações pendentes" $Yellow - } -} \ No newline at end of file diff --git a/scripts/seed-dev-data.ps1 b/scripts/seed-dev-data.ps1 deleted file mode 100644 index 2e3d2754c..000000000 --- a/scripts/seed-dev-data.ps1 +++ /dev/null @@ -1,158 +0,0 @@ -#requires -Version 7.0 -<# -.SYNOPSIS - Seed de dados de TESTE para ambiente de desenvolvimento - -.DESCRIPTION - Popula o banco de dados com dados de TESTE via API REST: - - Cidades permitidas (10 capitais brasileiras) - - Usuários de teste (futuro) - - Providers de exemplo (futuro) - - NOTA: Dados ESSENCIAIS de domínio (ServiceCategories, Services) devem ser - inseridos via SQL script após migrations. Veja: infrastructure/database/seeds/01-seed-service-catalogs.sql - (Executados automaticamente pelo Docker Compose na inicialização) - -.PARAMETER Environment - Ambiente alvo (Development apenas). Default: Development - -.PARAMETER ApiBaseUrl - URL base da API. Default: http://localhost:5000 - Use portas Aspire quando executar via Aspire orchestration (ex: https://localhost:7524) - -.EXAMPLE - .\seed-dev-data.ps1 - -.EXAMPLE - .\seed-dev-data.ps1 -ApiBaseUrl "https://localhost:7524" -#> - -[CmdletBinding()] -param( - [Parameter()] - [ValidateSet('Development')] - [string]$Environment = 'Development', - - [Parameter()] - [string]$ApiBaseUrl = 'http://localhost:5000' -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -# Cores para output -function Write-Success { param($Message) Write-Host "✅ $Message" -ForegroundColor Green } -function Write-Info { param($Message) Write-Host "ℹ️ $Message" -ForegroundColor Cyan } -function Write-Warning { param($Message) Write-Host "⚠️ $Message" -ForegroundColor Yellow } -function Write-Error { param($Message) Write-Host "❌ $Message" -ForegroundColor Red } - -Write-Host "🌱 Seed de Dados - MeAjudaAi [$Environment]" -ForegroundColor Cyan -Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan -Write-Host "" - -# Verificar se API está rodando -Write-Info "Verificando API em $ApiBaseUrl..." -try { - $health = Invoke-RestMethod -Uri "$ApiBaseUrl/health" -Method Get -TimeoutSec 5 - Write-Success "API está rodando" -} catch { - Write-Error "API não está acessível em $ApiBaseUrl" - Write-Host "Inicie a API primeiro: cd src/Bootstrapper/MeAjudaAi.ApiService && dotnet run" -ForegroundColor Yellow - exit 1 -} - -# Obter token de autenticação -Write-Info "Obtendo token de autenticação..." -$keycloakUrl = "http://localhost:8080" -$tokenParams = @{ - Uri = "$keycloakUrl/realms/meajudaai/protocol/openid-connect/token" - Method = 'Post' - ContentType = 'application/x-www-form-urlencoded' - Body = @{ - client_id = 'meajudaai-api' - username = 'admin' - password = 'admin123' - grant_type = 'password' - } -} - -try { - $tokenResponse = Invoke-RestMethod @tokenParams - $token = $tokenResponse.access_token - Write-Success "Token obtido com sucesso" -} catch { - Write-Error "Falha ao obter token do Keycloak" - Write-Host "Verifique se Keycloak está rodando: docker-compose up keycloak" -ForegroundColor Yellow - exit 1 -} - -$headers = @{ - 'Authorization' = "Bearer $token" - 'Content-Type' = 'application/json' - 'Api-Version' = '1.0' -} - -Write-Host "" -Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan -Write-Host "ℹ️ ServiceCatalogs: Usando seed SQL automático" -ForegroundColor Yellow -Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan -Write-Info "ServiceCategories e Services são criados automaticamente via Docker Compose" -Write-Info "Localização: infrastructure/database/seeds/01-seed-service-catalogs.sql" -Write-Host "" - -Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan -Write-Host "📍 Seeding: Locations (AllowedCities)" -ForegroundColor Yellow -Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan - -$allowedCities = @( - @{ ibgeCode = "3550308"; cityName = "São Paulo"; state = "SP"; isActive = $true } - @{ ibgeCode = "3304557"; cityName = "Rio de Janeiro"; state = "RJ"; isActive = $true } - @{ ibgeCode = "3106200"; cityName = "Belo Horizonte"; state = "MG"; isActive = $true } - @{ ibgeCode = "4106902"; cityName = "Curitiba"; state = "PR"; isActive = $true } - @{ ibgeCode = "4314902"; cityName = "Porto Alegre"; state = "RS"; isActive = $true } - @{ ibgeCode = "5300108"; cityName = "Brasília"; state = "DF"; isActive = $true } - @{ ibgeCode = "2927408"; cityName = "Salvador"; state = "BA"; isActive = $true } - @{ ibgeCode = "2304400"; cityName = "Fortaleza"; state = "CE"; isActive = $true } - @{ ibgeCode = "2611606"; cityName = "Recife"; state = "PE"; isActive = $true } - @{ ibgeCode = "1302603"; cityName = "Manaus"; state = "AM"; isActive = $true } -) - -$cityCount = 0 -foreach ($city in $allowedCities) { - Write-Info "Adicionando cidade: $($city.cityName)/$($city.state)" - try { - $response = Invoke-RestMethod -Uri "$ApiBaseUrl/api/v1/locations/admin/allowed-cities" ` - -Method Post ` - -Headers $headers ` - -Body ($city | ConvertTo-Json -Depth 10) - - Write-Success "Cidade '$($city.cityName)/$($city.state)' adicionada" - $cityCount++ - } catch { - if ($_.Exception.Response.StatusCode -eq 409) { - Write-Warning "Cidade '$($city.cityName)/$($city.state)' já existe" - $cityCount++ - } else { - Write-Error "Erro ao adicionar cidade '$($city.cityName)/$($city.state)': $_" - } - } -} - -Write-Host "" -Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan -Write-Host "🎉 Seed de Dados de Teste Concluído!" -ForegroundColor Green -Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan -Write-Host "" -Write-Host "📊 Dados de TESTE inseridos:" -ForegroundColor Cyan -Write-Host " • Cidades permitidas: $cityCount" -ForegroundColor White -Write-Host "" -Write-Host "💡 Dados ESSENCIAIS (via SQL automático no Docker):" -ForegroundColor Cyan -Write-Host " • ServiceCategories: 8 categorias" -ForegroundColor White -Write-Host " • Services: 12 serviços padrão" -ForegroundColor White -Write-Host " • Local: infrastructure/database/seeds/01-seed-service-catalogs.sql" -ForegroundColor White -Write-Host "" -Write-Host "💡 Próximos passos:" -ForegroundColor Cyan -Write-Host " 1. Cadastrar providers usando Bruno collections" -ForegroundColor White -Write-Host " 2. Indexar providers para busca" -ForegroundColor White -Write-Host " 3. Testar endpoints de busca" -ForegroundColor White -Write-Host "" diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 deleted file mode 100644 index 34a55c5a5..000000000 --- a/scripts/setup.ps1 +++ /dev/null @@ -1,153 +0,0 @@ -<# -.SYNOPSIS - Setup inicial do projeto MeAjudaAi -.DESCRIPTION - Configura o ambiente de desenvolvimento do zero -.PARAMETER DevOnly - Setup apenas para desenvolvimento (sem Azure/Cloud) -.EXAMPLE - .\scripts\setup.ps1 - .\scripts\setup.ps1 -DevOnly -#> - -param( - [switch]$DevOnly -) - -$ErrorActionPreference = "Stop" - -Write-Host "⚙️ Setup MeAjudaAi - Configuração Inicial" -ForegroundColor Cyan -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "" - -# 1. Verificar pré-requisitos -Write-Host "1️⃣ Verificando pré-requisitos..." -ForegroundColor Yellow -Write-Host "" - -$missing = @() - -# .NET SDK -Write-Host " 📦 .NET SDK..." -NoNewline -try { - $dotnetVersion = dotnet --version - try { - $version = [Version]::new($dotnetVersion) - $requiredVersion = [Version]::new("10.0.0") - if ($version -ge $requiredVersion) { - Write-Host " ✅ v$dotnetVersion" -ForegroundColor Green - } else { - Write-Host " ⚠️ v$dotnetVersion (recomendado 10.0+)" -ForegroundColor Yellow - } - } catch { - Write-Host " ⚠️ v$dotnetVersion (não foi possível validar versão)" -ForegroundColor Yellow - Write-Host " Versão detectada mas formato inesperado: $_" -ForegroundColor Yellow - } -} catch { - Write-Host " ❌ Não encontrado" -ForegroundColor Red - $missing += ".NET 10 SDK (https://dotnet.microsoft.com/download/dotnet/10.0)" -} - -# Docker -Write-Host " 🐳 Docker..." -NoNewline -try { - $null = docker info 2>&1 - if ($?) { - $dockerVersion = (docker --version) -replace "Docker version ", "" -replace ",.*", "" - Write-Host " ✅ v$dockerVersion" -ForegroundColor Green - } else { - Write-Host " ⚠️ Instalado mas não está rodando" -ForegroundColor Yellow - $missing += "Docker Desktop (precisa estar rodando)" - } -} catch { - Write-Host " ❌ Não encontrado" -ForegroundColor Red - $missing += "Docker Desktop (https://www.docker.com/products/docker-desktop)" -} - -# Git -Write-Host " 🔧 Git..." -NoNewline -try { - $gitVersion = (git --version) -replace "git version ", "" - Write-Host " ✅ v$gitVersion" -ForegroundColor Green -} catch { - Write-Host " ❌ Não encontrado" -ForegroundColor Red - $missing += "Git (https://git-scm.com/)" -} - -if ($missing.Count -gt 0) { - Write-Host "" - Write-Host "❌ Pré-requisitos faltando:" -ForegroundColor Red - $missing | ForEach-Object { Write-Host " - $_" -ForegroundColor Red } - Write-Host "" - Write-Host "Instale os itens acima e execute o setup novamente." -ForegroundColor Yellow - exit 1 -} - -Write-Host "" -Write-Host "✅ Todos os pré-requisitos estão instalados!" -ForegroundColor Green -Write-Host "" - -# 2. Restaurar dependências -Write-Host "2️⃣ Restaurando dependências NuGet..." -ForegroundColor Yellow -dotnet restore -if ($LASTEXITCODE -ne 0) { - Write-Host "❌ Erro ao restaurar dependências" -ForegroundColor Red - exit 1 -} -Write-Host "✅ Dependências restauradas" -ForegroundColor Green -Write-Host "" - -# 3. Build inicial -Write-Host "3️⃣ Compilando solução..." -ForegroundColor Yellow -dotnet build --no-restore -if ($LASTEXITCODE -ne 0) { - Write-Host "❌ Erro na compilação" -ForegroundColor Red - exit 1 -} -Write-Host "✅ Compilação bem-sucedida" -ForegroundColor Green -Write-Host "" - -# 4. Configurar Keycloak (instruções) -Write-Host "4️⃣ Configuração do Keycloak" -ForegroundColor Yellow -Write-Host "" -Write-Host "⚠️ IMPORTANTE: Configuração manual necessária" -ForegroundColor Yellow -Write-Host "" -Write-Host "O Admin Portal Blazor requer um client configurado no Keycloak." -ForegroundColor White -Write-Host "" -Write-Host "📖 Siga as instruções em:" -ForegroundColor Cyan -Write-Host " docs/keycloak-admin-portal-setup.md" -ForegroundColor White -Write-Host "" -Write-Host "Resumo rápido:" -ForegroundColor Yellow -Write-Host " 1. Execute: .\scripts\dev.ps1" -ForegroundColor White -Write-Host " 2. Acesse: http://localhost:8080" -ForegroundColor White -Write-Host " 3. Login: admin/admin" -ForegroundColor White -Write-Host " 4. Realm: meajudaai" -ForegroundColor White -Write-Host " 5. Clients → Create Client" -ForegroundColor White -Write-Host " 6. Client ID: admin-portal" -ForegroundColor White -Write-Host " 7. Configure conforme documentação" -ForegroundColor White -Write-Host "" - -# 5. Próximos passos -Write-Host "✅ Setup concluído!" -ForegroundColor Green -Write-Host "" -Write-Host "📚 Próximos passos:" -ForegroundColor Cyan -Write-Host "" -Write-Host " 1. Iniciar desenvolvimento:" -ForegroundColor White -Write-Host " .\scripts\dev.ps1" -ForegroundColor Gray -Write-Host "" -Write-Host " 2. Executar testes:" -ForegroundColor White -Write-Host " dotnet test" -ForegroundColor Gray -Write-Host "" -Write-Host " 3. Ver documentação:" -ForegroundColor White -Write-Host " mkdocs serve" -ForegroundColor Gray -Write-Host " https://frigini.github.io/MeAjudaAi/" -ForegroundColor Gray -Write-Host "" -Write-Host " 4. Comandos disponíveis (via Makefile):" -ForegroundColor White -Write-Host " make help" -ForegroundColor Gray -Write-Host "" - -if (-not $DevOnly) { - Write-Host "💡 Dica: Use 'make dev' para atalhos rápidos!" -ForegroundColor Yellow -} - -Write-Host "" -Write-Host "Happy coding! 🚀" -ForegroundColor Green diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/SecurityExtensions.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/SecurityExtensions.cs index e76627521..58be3e624 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/SecurityExtensions.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/SecurityExtensions.cs @@ -514,7 +514,7 @@ clientObj is IDictionary clientDict && /// /// Configura políticas de rate limiting customizadas /// - public static IServiceCollection AddCustomRateLimiting(this IServiceCollection services) + public static IServiceCollection AddCustomRateLimiting(this IServiceCollection services, IConfiguration configuration) { services.AddRateLimiter(options => { @@ -522,7 +522,7 @@ public static IServiceCollection AddCustomRateLimiting(this IServiceCollection s options.AddFixedWindowLimiter(RateLimitPolicies.Public, opt => { opt.Window = TimeSpan.FromMinutes(1); - opt.PermitLimit = 60; + opt.PermitLimit = configuration.GetValue("RateLimit:DefaultRequestsPerMinute", 60); opt.QueueLimit = 10; opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; }); @@ -533,7 +533,7 @@ public static IServiceCollection AddCustomRateLimiting(this IServiceCollection s partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? context.Connection.Id, factory: _ => new FixedWindowRateLimiterOptions { - PermitLimit = 5, + PermitLimit = configuration.GetValue("RateLimit:AuthRequestsPerMinute", 5), Window = TimeSpan.FromMinutes(1), QueueLimit = 2, QueueProcessingOrder = QueueProcessingOrder.OldestFirst @@ -545,7 +545,7 @@ public static IServiceCollection AddCustomRateLimiting(this IServiceCollection s partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? context.Connection.Id, factory: _ => new FixedWindowRateLimiterOptions { - PermitLimit = 5, + PermitLimit = configuration.GetValue("RateLimit:ProviderRequestsPerMinute", 5), Window = TimeSpan.FromMinutes(1), QueueLimit = 2, QueueProcessingOrder = QueueProcessingOrder.OldestFirst diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Handlers/SelfOrAdminHandler.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Handlers/SelfOrAdminHandler.cs index 6cf707aff..167afd5cb 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Handlers/SelfOrAdminHandler.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Handlers/SelfOrAdminHandler.cs @@ -38,7 +38,8 @@ protected override Task HandleRequirementAsync( if (context.Resource is HttpContext httpContext) { var routeUserId = httpContext.GetRouteValue("id")?.ToString() - ?? httpContext.GetRouteValue("userId")?.ToString(); + ?? httpContext.GetRouteValue("userId")?.ToString() + ?? httpContext.GetRouteValue("providerId")?.ToString(); // Só permite acesso se ambos os IDs estão presentes e são iguais if (!string.IsNullOrWhiteSpace(userIdClaim) && diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Program.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Program.cs index d5d306e1e..58499ac2b 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Program.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Program.cs @@ -46,7 +46,7 @@ public static async Task Main(string[] args) // Shared services por último (GlobalExceptionHandler atua como fallback) builder.Services.AddSharedServices(builder.Configuration); builder.Services.AddApiServices(builder.Configuration, builder.Environment); - builder.Services.AddCustomRateLimiting(); + builder.Services.AddCustomRateLimiting(builder.Configuration); var app = builder.Build(); diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/packages.lock.json b/src/Bootstrapper/MeAjudaAi.ApiService/packages.lock.json index 2a5362fbe..7539fd70c 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/packages.lock.json +++ b/src/Bootstrapper/MeAjudaAi.ApiService/packages.lock.json @@ -71,23 +71,23 @@ }, "Swashbuckle.AspNetCore": { "type": "Direct", - "requested": "[10.1.5, )", - "resolved": "10.1.5", - "contentHash": "/eNk9z/8quXhDX14o3XLbwAX/84uIWSbiUD7cI/UrQnoBMOiyAtzKxNEJUtf/TyxjFpcXxE9FAfLvtbNpxHBSg==", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "vgef8DPT411JU5JjHiDbr0WOxsIVuAvegPGtqmm4Na4JRl/264dfBJcGkiPHsAr5P+Vda+qN1rZKRtBl1rF9aA==", "dependencies": { "Microsoft.Extensions.ApiDescription.Server": "10.0.0", - "Swashbuckle.AspNetCore.Swagger": "10.1.5", - "Swashbuckle.AspNetCore.SwaggerGen": "10.1.5", - "Swashbuckle.AspNetCore.SwaggerUI": "10.1.5" + "Swashbuckle.AspNetCore.Swagger": "10.1.7", + "Swashbuckle.AspNetCore.SwaggerGen": "10.1.7", + "Swashbuckle.AspNetCore.SwaggerUI": "10.1.7" } }, "Swashbuckle.AspNetCore.Annotations": { "type": "Direct", - "requested": "[10.1.5, )", - "resolved": "10.1.5", - "contentHash": "f+GGMzb7ugIBtHcbSF1QvSEWH3tavRnYduOxNhXcvdWJxiBsklGK9ueIFcjZpLC4dn/EMRky8YaN/AWfLibugA==", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "qHNQghKAzrjDEECIlyKEanmWxXDzrh6PyjGWVdql2qZfap0Ionuxam5OrUozibjbgQbVjRmgSXxWC8giIuCbGA==", "dependencies": { - "Swashbuckle.AspNetCore.SwaggerGen": "10.1.5" + "Swashbuckle.AspNetCore.SwaggerGen": "10.1.7" } }, "Asp.Versioning.Abstractions": { @@ -482,16 +482,16 @@ }, "Swashbuckle.AspNetCore.Swagger": { "type": "Transitive", - "resolved": "10.1.5", - "contentHash": "s4Mct6+Ob0LK9vYVaZcYi/RFFCOEJNjf6nJ5ZPoxtpdFSlzR6i9AHI7Vl44obX8cynRxJW7prA1IUabkiXolFg==", + "resolved": "10.1.7", + "contentHash": "EjLibt/d/QuRv170GoihTbcPUpgzSFm2WKHhnGJFZQ03JYzfuitsM79azaAR8NBwRunU7yScSX6HRE5JUlrEMQ==", "dependencies": { "Microsoft.OpenApi": "2.4.1" } }, "Swashbuckle.AspNetCore.SwaggerUI": { "type": "Transitive", - "resolved": "10.1.5", - "contentHash": "tQWVKNJWW7lf6S0bv22+7yfxK5IKzvsMeueF4XHSziBfREhLKt42OKzi6/1nINmyGlM4hGbR8aSMg72dLLVBLw==" + "resolved": "10.1.7", + "contentHash": "iJo3ODyUb/M8Vm8AH1r9y9iAba0w95xsCn3zFVl96ISRHbTDWxi+l7oFVCZqUEdjd97B8VMDPnMliWAdomR8uw==" }, "System.ClientModel": { "type": "Transitive", @@ -1274,11 +1274,11 @@ }, "Swashbuckle.AspNetCore.SwaggerGen": { "type": "CentralTransitive", - "requested": "[10.1.5, )", - "resolved": "10.1.5", - "contentHash": "ysQIRgqnx4Vb/9+r3xnEAiaxYmiBHO8jTg7ACaCh+R3Sn+ZKCWKD6nyu0ph3okP91wFSh/6LgccjeLUaQHV+ZA==", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "PuubO9BjvNn6U3D9kLpuWKY1JtziWw7SsGBq0age1E50uQjQ8Fzl8s0EwzrLfANqYJNgDnJi9l7N1QxcGVB2Zw==", "dependencies": { - "Swashbuckle.AspNetCore.Swagger": "10.1.5" + "Swashbuckle.AspNetCore.Swagger": "10.1.7" } }, "System.IdentityModel.Tokens.Jwt": { diff --git a/src/Contracts/MeAjudaAi.Contracts.csproj b/src/Contracts/MeAjudaAi.Contracts.csproj index c6a4c804f..198c13eec 100644 --- a/src/Contracts/MeAjudaAi.Contracts.csproj +++ b/src/Contracts/MeAjudaAi.Contracts.csproj @@ -10,4 +10,8 @@ + + + + diff --git a/src/Contracts/Models/ApiErrorResponse.cs b/src/Contracts/Models/ApiErrorResponse.cs index 1ea5fb5c3..853e48b52 100644 --- a/src/Contracts/Models/ApiErrorResponse.cs +++ b/src/Contracts/Models/ApiErrorResponse.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace MeAjudaAi.Contracts.Models; /// @@ -7,6 +9,7 @@ namespace MeAjudaAi.Contracts.Models; /// Utilizado para documentação OpenAPI e padronização de respostas de erro. /// Todos os endpoints que retornam erro devem seguir este formato. /// +[ExcludeFromCodeCoverage] public class ApiErrorResponse { /// diff --git a/src/Contracts/Models/PagedResponse.cs b/src/Contracts/Models/PagedResponse.cs index 430f04f92..e73c68c01 100644 --- a/src/Contracts/Models/PagedResponse.cs +++ b/src/Contracts/Models/PagedResponse.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + namespace MeAjudaAi.Contracts.Models; /// /// Envelope padrão para respostas paginadas da API /// +[ExcludeFromCodeCoverage] public sealed record PagedResponse { /// diff --git a/src/Contracts/Models/PagedResult.cs b/src/Contracts/Models/PagedResult.cs index 5f059fef0..3a7e18c7a 100644 --- a/src/Contracts/Models/PagedResult.cs +++ b/src/Contracts/Models/PagedResult.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace MeAjudaAi.Contracts.Models; /// @@ -8,6 +10,7 @@ namespace MeAjudaAi.Contracts.Models; /// Usado para retornar listas paginadas com metadados de navegação, /// permitindo implementar paginação no frontend de forma eficiente. /// +[ExcludeFromCodeCoverage] public sealed record PagedResult { /// diff --git a/src/Contracts/Models/Response.cs b/src/Contracts/Models/Response.cs index dd82dfeb8..c3a90ce0e 100644 --- a/src/Contracts/Models/Response.cs +++ b/src/Contracts/Models/Response.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + namespace MeAjudaAi.Contracts.Models; /// /// Envelope padrão para respostas da API /// +[ExcludeFromCodeCoverage] public sealed record Response { /// diff --git a/src/Contracts/Models/ValidationErrorResponse.cs b/src/Contracts/Models/ValidationErrorResponse.cs index 8e93f8d98..08af513e3 100644 --- a/src/Contracts/Models/ValidationErrorResponse.cs +++ b/src/Contracts/Models/ValidationErrorResponse.cs @@ -1,5 +1,7 @@ using MeAjudaAi.Contracts.Utilities.Constants; +using System.Diagnostics.CodeAnalysis; + namespace MeAjudaAi.Contracts.Models; /// @@ -9,6 +11,7 @@ namespace MeAjudaAi.Contracts.Models; /// Usado quando a validação de entrada falha, fornecendo detalhes /// específicos sobre quais campos têm problemas. /// +[ExcludeFromCodeCoverage] public class ValidationErrorResponse : ApiErrorResponse { /// diff --git a/src/Modules/Documents/Tests/packages.lock.json b/src/Modules/Documents/Tests/packages.lock.json index f2cf7d993..b36ada019 100644 --- a/src/Modules/Documents/Tests/packages.lock.json +++ b/src/Modules/Documents/Tests/packages.lock.json @@ -836,16 +836,16 @@ }, "Swashbuckle.AspNetCore.Swagger": { "type": "Transitive", - "resolved": "10.1.5", - "contentHash": "s4Mct6+Ob0LK9vYVaZcYi/RFFCOEJNjf6nJ5ZPoxtpdFSlzR6i9AHI7Vl44obX8cynRxJW7prA1IUabkiXolFg==", + "resolved": "10.1.7", + "contentHash": "EjLibt/d/QuRv170GoihTbcPUpgzSFm2WKHhnGJFZQ03JYzfuitsM79azaAR8NBwRunU7yScSX6HRE5JUlrEMQ==", "dependencies": { "Microsoft.OpenApi": "2.4.1" } }, "Swashbuckle.AspNetCore.SwaggerUI": { "type": "Transitive", - "resolved": "10.1.5", - "contentHash": "tQWVKNJWW7lf6S0bv22+7yfxK5IKzvsMeueF4XHSziBfREhLKt42OKzi6/1nINmyGlM4hGbR8aSMg72dLLVBLw==" + "resolved": "10.1.7", + "contentHash": "iJo3ODyUb/M8Vm8AH1r9y9iAba0w95xsCn3zFVl96ISRHbTDWxi+l7oFVCZqUEdjd97B8VMDPnMliWAdomR8uw==" }, "System.ClientModel": { "type": "Transitive", @@ -1021,8 +1021,8 @@ "Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.5, )", "Serilog.AspNetCore": "[10.0.0, )", "Serilog.Sinks.Seq": "[9.0.0, )", - "Swashbuckle.AspNetCore": "[10.1.5, )", - "Swashbuckle.AspNetCore.Annotations": "[10.1.5, )" + "Swashbuckle.AspNetCore": "[10.1.7, )", + "Swashbuckle.AspNetCore.Annotations": "[10.1.7, )" } }, "meajudaai.contracts": { @@ -2113,32 +2113,32 @@ }, "Swashbuckle.AspNetCore": { "type": "CentralTransitive", - "requested": "[10.1.5, )", - "resolved": "10.1.5", - "contentHash": "/eNk9z/8quXhDX14o3XLbwAX/84uIWSbiUD7cI/UrQnoBMOiyAtzKxNEJUtf/TyxjFpcXxE9FAfLvtbNpxHBSg==", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "vgef8DPT411JU5JjHiDbr0WOxsIVuAvegPGtqmm4Na4JRl/264dfBJcGkiPHsAr5P+Vda+qN1rZKRtBl1rF9aA==", "dependencies": { "Microsoft.Extensions.ApiDescription.Server": "10.0.0", - "Swashbuckle.AspNetCore.Swagger": "10.1.5", - "Swashbuckle.AspNetCore.SwaggerGen": "10.1.5", - "Swashbuckle.AspNetCore.SwaggerUI": "10.1.5" + "Swashbuckle.AspNetCore.Swagger": "10.1.7", + "Swashbuckle.AspNetCore.SwaggerGen": "10.1.7", + "Swashbuckle.AspNetCore.SwaggerUI": "10.1.7" } }, "Swashbuckle.AspNetCore.Annotations": { "type": "CentralTransitive", - "requested": "[10.1.5, )", - "resolved": "10.1.5", - "contentHash": "f+GGMzb7ugIBtHcbSF1QvSEWH3tavRnYduOxNhXcvdWJxiBsklGK9ueIFcjZpLC4dn/EMRky8YaN/AWfLibugA==", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "qHNQghKAzrjDEECIlyKEanmWxXDzrh6PyjGWVdql2qZfap0Ionuxam5OrUozibjbgQbVjRmgSXxWC8giIuCbGA==", "dependencies": { - "Swashbuckle.AspNetCore.SwaggerGen": "10.1.5" + "Swashbuckle.AspNetCore.SwaggerGen": "10.1.7" } }, "Swashbuckle.AspNetCore.SwaggerGen": { "type": "CentralTransitive", - "requested": "[10.1.5, )", - "resolved": "10.1.5", - "contentHash": "ysQIRgqnx4Vb/9+r3xnEAiaxYmiBHO8jTg7ACaCh+R3Sn+ZKCWKD6nyu0ph3okP91wFSh/6LgccjeLUaQHV+ZA==", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "PuubO9BjvNn6U3D9kLpuWKY1JtziWw7SsGBq0age1E50uQjQ8Fzl8s0EwzrLfANqYJNgDnJi9l7N1QxcGVB2Zw==", "dependencies": { - "Swashbuckle.AspNetCore.Swagger": "10.1.5" + "Swashbuckle.AspNetCore.Swagger": "10.1.7" } }, "System.IdentityModel.Tokens.Jwt": { diff --git a/src/Modules/Locations/Tests/MeAjudaAi.Modules.Locations.Tests.csproj b/src/Modules/Locations/Tests/MeAjudaAi.Modules.Locations.Tests.csproj index ee3a3f581..e83a2a32b 100644 --- a/src/Modules/Locations/Tests/MeAjudaAi.Modules.Locations.Tests.csproj +++ b/src/Modules/Locations/Tests/MeAjudaAi.Modules.Locations.Tests.csproj @@ -27,6 +27,7 @@ + diff --git a/src/Modules/Locations/Tests/Unit/API/Mappers/MapperExtensionsTests.cs b/src/Modules/Locations/Tests/Unit/API/Mappers/MapperExtensionsTests.cs new file mode 100644 index 000000000..28d49cbe8 --- /dev/null +++ b/src/Modules/Locations/Tests/Unit/API/Mappers/MapperExtensionsTests.cs @@ -0,0 +1,233 @@ +using FluentAssertions; +using MeAjudaAi.Modules.Locations.API.Mappers; +using MeAjudaAi.Modules.Locations.Application.DTOs; +using MeAjudaAi.Modules.Locations.Application.DTOs.Requests; +using Xunit; + +namespace MeAjudaAi.Modules.Locations.Tests.Unit.API.Mappers; + +[Trait("Category", "Unit")] +public class RequestMapperExtensionsTests +{ + [Fact] + public void ToCommand_CreateAllowedCityRequest_ShouldMapAllProperties() + { + // Arrange + var request = new CreateAllowedCityRequest( + CityName: "Muriaé", + StateSigla: "MG", + IbgeCode: 3143906, + Latitude: -21.13, + Longitude: -42.37, + ServiceRadiusKm: 50.0, + IsActive: true); + + // Act + var command = request.ToCommand(); + + // Assert + command.CityName.Should().Be("Muriaé"); + command.StateSigla.Should().Be("MG"); + command.IbgeCode.Should().Be(3143906); + command.Latitude.Should().Be(-21.13); + command.Longitude.Should().Be(-42.37); + command.ServiceRadiusKm.Should().Be(50.0); + command.IsActive.Should().BeTrue(); + } + + [Fact] + public void ToCommand_CreateAllowedCityRequestDto_ShouldMapContractProperties() + { + // Arrange + var dto = new MeAjudaAi.Contracts.Contracts.Modules.Locations.DTOs.CreateAllowedCityRequestDto( + City: "Linhares", + State: "ES", + Country: "Brasil", + Latitude: -19.39, + Longitude: -40.07, + ServiceRadiusKm: 80, + IsActive: true); + + // Act + var command = dto.ToCommand(); + + // Assert + command.CityName.Should().Be("Linhares"); + command.StateSigla.Should().Be("ES"); + command.IbgeCode.Should().BeNull("contract DTO does not carry IBGE code"); + command.Latitude.Should().Be(-19.39); + command.Longitude.Should().Be(-40.07); + command.ServiceRadiusKm.Should().Be(80); + command.IsActive.Should().BeTrue(); + } + + [Fact] + public void ToCommand_UpdateAllowedCityRequest_ShouldMapAllPropertiesIncludingId() + { + // Arrange + var id = Guid.NewGuid(); + var request = new UpdateAllowedCityRequest( + CityName: "Itaperuna", + StateSigla: "RJ", + IbgeCode: 3302504, + Latitude: -21.21, + Longitude: -41.89, + ServiceRadiusKm: 60.0, + IsActive: false); + + // Act + var command = request.ToCommand(id); + + // Assert + command.Id.Should().Be(id); + command.CityName.Should().Be("Itaperuna"); + command.StateSigla.Should().Be("RJ"); + command.IbgeCode.Should().Be(3302504); + command.Latitude.Should().Be(-21.21); + command.Longitude.Should().Be(-41.89); + command.ServiceRadiusKm.Should().Be(60.0); + command.IsActive.Should().BeFalse(); + } + + [Fact] + public void ToDeleteCommand_ShouldMapIdToCommand() + { + // Arrange + var id = Guid.NewGuid(); + + // Act + var command = id.ToDeleteCommand(); + + // Assert + command.Id.Should().Be(id); + } + + [Fact] + public void ToDeleteCommand_WithEmptyGuid_ShouldMapEmptyGuid() + { + // Arrange + var emptyId = Guid.Empty; + + // Act + var command = emptyId.ToDeleteCommand(); + + // Assert + command.Id.Should().Be(Guid.Empty); + } +} + +[Trait("Category", "Unit")] +public class ResponseMapperExtensionsTests +{ + private static AllowedCityDto CreateTestDto(double serviceRadiusKm = 50.0) + => new( + Id: Guid.NewGuid(), + CityName: "Muriaé", + StateSigla: "MG", + IbgeCode: 3143906, + Latitude: -21.13, + Longitude: -42.37, + ServiceRadiusKm: serviceRadiusKm, + IsActive: true, + CreatedAt: DateTime.UtcNow.AddDays(-10), + UpdatedAt: DateTime.UtcNow, + CreatedBy: "admin@test.com", + UpdatedBy: null); + + [Fact] + public void ToContract_SingleDto_ShouldMapAllProperties() + { + // Arrange + var dto = CreateTestDto(50.0); + + // Act + var contract = dto.ToContract(); + + // Assert + contract.Id.Should().Be(dto.Id); + contract.City.Should().Be("Muriaé"); + contract.State.Should().Be("MG"); + contract.Country.Should().Be("Brasil"); + contract.Latitude.Should().Be(-21.13); + contract.Longitude.Should().Be(-42.37); + contract.ServiceRadiusKm.Should().Be(50); + contract.IsActive.Should().BeTrue(); + contract.CreatedAt.Should().Be(dto.CreatedAt); + contract.UpdatedAt.Should().Be(dto.UpdatedAt); + } + + [Fact] + public void ToContract_Collection_ShouldMapAllItems() + { + // Arrange + var dtos = new List + { + CreateTestDto(30.0), + CreateTestDto(70.0), + CreateTestDto(100.0) + }; + + // Act + var contracts = dtos.ToContract(); + + // Assert + contracts.Should().HaveCount(3); + contracts[0].ServiceRadiusKm.Should().Be(30); + contracts[1].ServiceRadiusKm.Should().Be(70); + contracts[2].ServiceRadiusKm.Should().Be(100); + } + + [Fact] + public void ToContract_EmptyCollection_ShouldReturnEmptyList() + { + // Arrange + var emptyDtos = Enumerable.Empty(); + + // Act + var contracts = emptyDtos.ToContract(); + + // Assert + contracts.Should().BeEmpty(); + } + + [Fact] + public void ToContract_WithPreciseRadiusKm_ShouldRoundCorrectly() + { + // Arrange - 49.9999999 rounds to 50 (within 1e-6 tolerance) + var dto = CreateTestDto(49.9999999); + + // Act + var contract = dto.ToContract(); + + // Assert + contract.ServiceRadiusKm.Should().Be(50); + } + + [Fact] + public void ToContract_WithDecimalRadiusOutsideTolerance_ShouldThrowFormatException() + { + // Arrange - 50.5 has significant decimal part, exceeds 1e-6 tolerance + var dto = CreateTestDto(50.5); + + // Act + var act = () => dto.ToContract(); + + // Assert + act.Should().Throw() + .WithMessage("*raio de serviço*"); + } + + [Fact] + public void ToContract_CountryAlwaysBrasil() + { + // Arrange + var dto = CreateTestDto(); + + // Act + var contract = dto.ToContract(); + + // Assert + contract.Country.Should().Be("Brasil", + "the module hardcodes Brasil as country since StateSigla is always Brazilian"); + } +} diff --git a/src/Modules/Locations/Tests/Unit/API/Mappers/RequestMapperExtensionsTests.cs b/src/Modules/Locations/Tests/Unit/API/Mappers/RequestMapperExtensionsTests.cs deleted file mode 100644 index 33201638e..000000000 --- a/src/Modules/Locations/Tests/Unit/API/Mappers/RequestMapperExtensionsTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using FluentAssertions; -using MeAjudaAi.Contracts.Contracts.Modules.Locations.DTOs; -using MeAjudaAi.Modules.Locations.API.Mappers; -using MeAjudaAi.Modules.Locations.Application.DTOs.Requests; -using Xunit; - -namespace MeAjudaAi.Modules.Locations.Tests.Unit.API.Mappers; - -public class RequestMapperExtensionsTests -{ - [Fact] - public void ToCommand_FromInternalRequest_ShouldMapCorrectly() - { - // Arrange - var request = new CreateAllowedCityRequest( - CityName: "Vitoria", - StateSigla: "ES", - IbgeCode: 123456, - Latitude: -20.0, - Longitude: -40.0, - ServiceRadiusKm: 50, - IsActive: true - ); - - // Act - var command = request.ToCommand(); - - // Assert - command.CityName.Should().Be(request.CityName); - command.StateSigla.Should().Be(request.StateSigla); - command.IbgeCode.Should().Be(request.IbgeCode); - command.Latitude.Should().Be(request.Latitude); - command.Longitude.Should().Be(request.Longitude); - command.ServiceRadiusKm.Should().Be(request.ServiceRadiusKm); - command.IsActive.Should().Be(request.IsActive); - } - - [Fact] - public void ToCommand_FromContractRequestDto_ShouldMapCorrectly() - { - // Arrange - var requestDto = new CreateAllowedCityRequestDto( - City: "Serra", - State: "ES", - Country: "Brasil", - Latitude: -20.1, - Longitude: -40.2, - ServiceRadiusKm: 30, - IsActive: true - ); - - // Act - var command = requestDto.ToCommand(); - - // Assert - command.CityName.Should().Be(requestDto.City); - command.StateSigla.Should().Be(requestDto.State); - command.IbgeCode.Should().BeNull(); // DTO doesn't have IBGE code - command.Latitude.Should().Be(requestDto.Latitude); - command.Longitude.Should().Be(requestDto.Longitude); - command.ServiceRadiusKm.Should().Be(requestDto.ServiceRadiusKm); - command.IsActive.Should().Be(requestDto.IsActive); - } -} diff --git a/src/Modules/Locations/Tests/packages.lock.json b/src/Modules/Locations/Tests/packages.lock.json index 26dc46e58..776f43b68 100644 --- a/src/Modules/Locations/Tests/packages.lock.json +++ b/src/Modules/Locations/Tests/packages.lock.json @@ -814,16 +814,16 @@ }, "Swashbuckle.AspNetCore.Swagger": { "type": "Transitive", - "resolved": "10.1.5", - "contentHash": "s4Mct6+Ob0LK9vYVaZcYi/RFFCOEJNjf6nJ5ZPoxtpdFSlzR6i9AHI7Vl44obX8cynRxJW7prA1IUabkiXolFg==", + "resolved": "10.1.7", + "contentHash": "EjLibt/d/QuRv170GoihTbcPUpgzSFm2WKHhnGJFZQ03JYzfuitsM79azaAR8NBwRunU7yScSX6HRE5JUlrEMQ==", "dependencies": { "Microsoft.OpenApi": "2.4.1" } }, "Swashbuckle.AspNetCore.SwaggerUI": { "type": "Transitive", - "resolved": "10.1.5", - "contentHash": "tQWVKNJWW7lf6S0bv22+7yfxK5IKzvsMeueF4XHSziBfREhLKt42OKzi6/1nINmyGlM4hGbR8aSMg72dLLVBLw==" + "resolved": "10.1.7", + "contentHash": "iJo3ODyUb/M8Vm8AH1r9y9iAba0w95xsCn3zFVl96ISRHbTDWxi+l7oFVCZqUEdjd97B8VMDPnMliWAdomR8uw==" }, "System.ClientModel": { "type": "Transitive", @@ -999,8 +999,8 @@ "Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.5, )", "Serilog.AspNetCore": "[10.0.0, )", "Serilog.Sinks.Seq": "[9.0.0, )", - "Swashbuckle.AspNetCore": "[10.1.5, )", - "Swashbuckle.AspNetCore.Annotations": "[10.1.5, )" + "Swashbuckle.AspNetCore": "[10.1.7, )", + "Swashbuckle.AspNetCore.Annotations": "[10.1.7, )" } }, "meajudaai.contracts": { @@ -2102,32 +2102,32 @@ }, "Swashbuckle.AspNetCore": { "type": "CentralTransitive", - "requested": "[10.1.5, )", - "resolved": "10.1.5", - "contentHash": "/eNk9z/8quXhDX14o3XLbwAX/84uIWSbiUD7cI/UrQnoBMOiyAtzKxNEJUtf/TyxjFpcXxE9FAfLvtbNpxHBSg==", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "vgef8DPT411JU5JjHiDbr0WOxsIVuAvegPGtqmm4Na4JRl/264dfBJcGkiPHsAr5P+Vda+qN1rZKRtBl1rF9aA==", "dependencies": { "Microsoft.Extensions.ApiDescription.Server": "10.0.0", - "Swashbuckle.AspNetCore.Swagger": "10.1.5", - "Swashbuckle.AspNetCore.SwaggerGen": "10.1.5", - "Swashbuckle.AspNetCore.SwaggerUI": "10.1.5" + "Swashbuckle.AspNetCore.Swagger": "10.1.7", + "Swashbuckle.AspNetCore.SwaggerGen": "10.1.7", + "Swashbuckle.AspNetCore.SwaggerUI": "10.1.7" } }, "Swashbuckle.AspNetCore.Annotations": { "type": "CentralTransitive", - "requested": "[10.1.5, )", - "resolved": "10.1.5", - "contentHash": "f+GGMzb7ugIBtHcbSF1QvSEWH3tavRnYduOxNhXcvdWJxiBsklGK9ueIFcjZpLC4dn/EMRky8YaN/AWfLibugA==", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "qHNQghKAzrjDEECIlyKEanmWxXDzrh6PyjGWVdql2qZfap0Ionuxam5OrUozibjbgQbVjRmgSXxWC8giIuCbGA==", "dependencies": { - "Swashbuckle.AspNetCore.SwaggerGen": "10.1.5" + "Swashbuckle.AspNetCore.SwaggerGen": "10.1.7" } }, "Swashbuckle.AspNetCore.SwaggerGen": { "type": "CentralTransitive", - "requested": "[10.1.5, )", - "resolved": "10.1.5", - "contentHash": "ysQIRgqnx4Vb/9+r3xnEAiaxYmiBHO8jTg7ACaCh+R3Sn+ZKCWKD6nyu0ph3okP91wFSh/6LgccjeLUaQHV+ZA==", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "PuubO9BjvNn6U3D9kLpuWKY1JtziWw7SsGBq0age1E50uQjQ8Fzl8s0EwzrLfANqYJNgDnJi9l7N1QxcGVB2Zw==", "dependencies": { - "Swashbuckle.AspNetCore.Swagger": "10.1.5" + "Swashbuckle.AspNetCore.Swagger": "10.1.7" } }, "System.IdentityModel.Tokens.Jwt": { diff --git a/src/Modules/Providers/Application/Handlers/Commands/RegisterProviderCommandHandler.cs b/src/Modules/Providers/Application/Handlers/Commands/RegisterProviderCommandHandler.cs index 9e50c836b..1fb7beb49 100644 --- a/src/Modules/Providers/Application/Handlers/Commands/RegisterProviderCommandHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Commands/RegisterProviderCommandHandler.cs @@ -34,7 +34,14 @@ public async Task> HandleAsync(RegisterProviderCommand comma // Endereço e BusinessProfile inicialmente placeholders para permitir cadastro em etapas // Usando valores sentinela claros para indicar pendência - var address = new Address("Pending", "0", "Pending", "XX", "00000-000", "00000000"); + var address = new Address( + "Rua Pendente", // street + "0", // number + "Bairro Pendente", // neighborhood + "Cidade Pendente", // city + "SP", // state (valid UF) + "00000-000" // zipCode + ); var businessProfile = new BusinessProfile( command.Name, // LegalName diff --git a/src/Modules/Providers/Application/Validators/RegisterProviderCommandValidator.cs b/src/Modules/Providers/Application/Validators/RegisterProviderCommandValidator.cs index e324bbab1..8e1b0f66a 100644 --- a/src/Modules/Providers/Application/Validators/RegisterProviderCommandValidator.cs +++ b/src/Modules/Providers/Application/Validators/RegisterProviderCommandValidator.cs @@ -18,10 +18,14 @@ public RegisterProviderCommandValidator() .NotEmpty().WithMessage("O número do documento é obrigatório") .MaximumLength(20).WithMessage("O número do documento não pode exceder 20 caracteres"); + RuleFor(x => x.Email) + .NotEmpty().WithMessage("O e-mail é obrigatório") + .EmailAddress().WithMessage("E-mail inválido"); + RuleFor(x => x.PhoneNumber) + .NotEmpty().WithMessage("O número de telefone é obrigatório") .MaximumLength(20).WithMessage("O número de telefone não pode exceder 20 caracteres") - .Matches(@"^\+?[1-9]\d{1,14}$").WithMessage("Número de telefone inválido") - .When(x => !string.IsNullOrEmpty(x.PhoneNumber)); + .Matches(@"^\+?[1-9]\d{1,14}$").WithMessage("Número de telefone inválido"); RuleFor(x => x.Type) .IsInEnum().WithMessage("Tipo de prestador inválido"); diff --git a/src/Modules/Providers/Tests/Unit/API/Endpoints/ActivateMyProviderProfileEndpointTests.cs b/src/Modules/Providers/Tests/Unit/API/Endpoints/ActivateMyProviderProfileEndpointTests.cs deleted file mode 100644 index d1b3b5267..000000000 --- a/src/Modules/Providers/Tests/Unit/API/Endpoints/ActivateMyProviderProfileEndpointTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -using FluentAssertions; -using MeAjudaAi.Contracts.Functional; -using MeAjudaAi.Contracts.Models; -using MeAjudaAi.Modules.Providers.API.Endpoints.Public.Me; -using MeAjudaAi.Modules.Providers.Application.Commands; -using MeAjudaAi.Modules.Providers.Application.DTOs; -using MeAjudaAi.Modules.Providers.Application.Queries; -using MeAjudaAi.Modules.Providers.Domain.Enums; -using MeAjudaAi.Shared.Commands; -using MeAjudaAi.Shared.Queries; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Moq; -using Xunit; - -namespace MeAjudaAi.Modules.Providers.Tests.Unit.API.Endpoints; - -[Trait("Category", "Unit")] -public class ActivateMyProviderProfileEndpointTests -{ - private readonly Mock _queryDispatcherMock; - private readonly Mock _commandDispatcherMock; - - public ActivateMyProviderProfileEndpointTests() - { - _queryDispatcherMock = new Mock(); - _commandDispatcherMock = new Mock(); - } - - [Fact] - public async Task ActivateMyProfileAsync_WithValidProvider_ShouldReturnOk() - { - // Arrange - var userId = Guid.NewGuid(); - var providerId = Guid.NewGuid(); - var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); - - var providerDto = new ProviderDto( - Id: providerId, - UserId: userId, - Name: "Test", - Slug: "test", - Type: EProviderType.Individual, - BusinessProfile: null!, - Status: EProviderStatus.Active, - VerificationStatus: EVerificationStatus.Verified, - Tier: EProviderTier.Standard, - Documents: new List(), - Qualifications: new List(), - Services: new List(), - CreatedAt: DateTime.UtcNow, - UpdatedAt: null, - IsDeleted: false, - DeletedAt: null, - IsActive: true); - - _queryDispatcherMock - .Setup(x => x.QueryAsync>( - It.Is(q => q.UserId == userId), It.IsAny())) - .ReturnsAsync(Result.Success(providerDto)); - - _commandDispatcherMock - .Setup(x => x.SendAsync( - It.Is(c => c.ProviderId == providerId && c.UpdatedBy == userId.ToString()), It.IsAny())) - .ReturnsAsync(Result.Success()); - - // Act - var methodInfo = typeof(ActivateMyProviderProfileEndpoint).GetMethod("ActivateMyProfileAsync", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - - var task = (Task)methodInfo!.Invoke(null, new object[] { context, _queryDispatcherMock.Object, _commandDispatcherMock.Object, CancellationToken.None })!; - var result = await task; - - // Assert - result.Should().BeOfType>(); - _queryDispatcherMock.Verify(x => x.QueryAsync>(It.IsAny(), It.IsAny()), Times.Once); - _commandDispatcherMock.Verify(x => x.SendAsync(It.IsAny(), It.IsAny()), Times.Once); - } - - [Fact] - public async Task ActivateMyProfileAsync_WhenProviderNotFound_ShouldReturnNotFound() - { - // Arrange - var userId = Guid.NewGuid(); - var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); - - _queryDispatcherMock - .Setup(x => x.QueryAsync>( - It.Is(q => q.UserId == userId), It.IsAny())) - .ReturnsAsync(Result.Success(null)); - - // Act - var methodInfo = typeof(ActivateMyProviderProfileEndpoint).GetMethod("ActivateMyProfileAsync", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - - var task = (Task)methodInfo!.Invoke(null, new object[] { context, _queryDispatcherMock.Object, _commandDispatcherMock.Object, CancellationToken.None })!; - var result = await task; - - // Assert - result.Should().BeOfType>>(); - _commandDispatcherMock.Verify(x => x.SendAsync(It.IsAny(), It.IsAny()), Times.Never); - } -} diff --git a/src/Modules/Providers/Tests/Unit/API/Endpoints/DeactivateMyProviderProfileEndpointTests.cs b/src/Modules/Providers/Tests/Unit/API/Endpoints/DeactivateMyProviderProfileEndpointTests.cs deleted file mode 100644 index 61afbab7b..000000000 --- a/src/Modules/Providers/Tests/Unit/API/Endpoints/DeactivateMyProviderProfileEndpointTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -using FluentAssertions; -using MeAjudaAi.Contracts.Functional; -using MeAjudaAi.Contracts.Models; -using MeAjudaAi.Modules.Providers.API.Endpoints.Public.Me; -using MeAjudaAi.Modules.Providers.Application.Commands; -using MeAjudaAi.Modules.Providers.Application.DTOs; -using MeAjudaAi.Modules.Providers.Application.Queries; -using MeAjudaAi.Modules.Providers.Domain.Enums; -using MeAjudaAi.Shared.Commands; -using MeAjudaAi.Shared.Queries; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Moq; -using Xunit; - -namespace MeAjudaAi.Modules.Providers.Tests.Unit.API.Endpoints; - -[Trait("Category", "Unit")] -public class DeactivateMyProviderProfileEndpointTests -{ - private readonly Mock _queryDispatcherMock; - private readonly Mock _commandDispatcherMock; - - public DeactivateMyProviderProfileEndpointTests() - { - _queryDispatcherMock = new Mock(); - _commandDispatcherMock = new Mock(); - } - - [Fact] - public async Task DeactivateMyProfileAsync_WithValidProvider_ShouldReturnOk() - { - // Arrange - var userId = Guid.NewGuid(); - var providerId = Guid.NewGuid(); - var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); - - var providerDto = new ProviderDto( - Id: providerId, - UserId: userId, - Name: "Test", - Slug: "test", - Type: EProviderType.Individual, - BusinessProfile: null!, - Status: EProviderStatus.Active, - VerificationStatus: EVerificationStatus.Verified, - Tier: EProviderTier.Standard, - Documents: new List(), - Qualifications: new List(), - Services: new List(), - CreatedAt: DateTime.UtcNow, - UpdatedAt: null, - IsDeleted: false, - DeletedAt: null, - IsActive: true); - - _queryDispatcherMock - .Setup(x => x.QueryAsync>( - It.Is(q => q.UserId == userId), It.IsAny())) - .ReturnsAsync(Result.Success(providerDto)); - - _commandDispatcherMock - .Setup(x => x.SendAsync( - It.Is(c => c.ProviderId == providerId && c.UpdatedBy == userId.ToString()), It.IsAny())) - .ReturnsAsync(Result.Success()); - - // Act - var methodInfo = typeof(DeactivateMyProviderProfileEndpoint).GetMethod("DeactivateMyProfileAsync", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - - var task = (Task)methodInfo!.Invoke(null, new object[] { context, _queryDispatcherMock.Object, _commandDispatcherMock.Object, CancellationToken.None })!; - var result = await task; - - // Assert - result.Should().BeOfType>(); - _queryDispatcherMock.Verify(x => x.QueryAsync>(It.IsAny(), It.IsAny()), Times.Once); - _commandDispatcherMock.Verify(x => x.SendAsync(It.IsAny(), It.IsAny()), Times.Once); - } - - [Fact] - public async Task DeactivateMyProfileAsync_WhenProviderNotFound_ShouldReturnNotFound() - { - // Arrange - var userId = Guid.NewGuid(); - var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); - - _queryDispatcherMock - .Setup(x => x.QueryAsync>( - It.Is(q => q.UserId == userId), It.IsAny())) - .ReturnsAsync(Result.Success(null)); - - // Act - var methodInfo = typeof(DeactivateMyProviderProfileEndpoint).GetMethod("DeactivateMyProfileAsync", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - - var task = (Task)methodInfo!.Invoke(null, new object[] { context, _queryDispatcherMock.Object, _commandDispatcherMock.Object, CancellationToken.None })!; - var result = await task; - - // Assert - result.Should().BeOfType>>(); - _commandDispatcherMock.Verify(x => x.SendAsync(It.IsAny(), It.IsAny()), Times.Never); - } -} diff --git a/src/Modules/Providers/Tests/Unit/API/Endpoints/DeleteMyProviderProfileEndpointTests.cs b/src/Modules/Providers/Tests/Unit/API/Endpoints/DeleteMyProviderProfileEndpointTests.cs deleted file mode 100644 index 1d66a1afb..000000000 --- a/src/Modules/Providers/Tests/Unit/API/Endpoints/DeleteMyProviderProfileEndpointTests.cs +++ /dev/null @@ -1,110 +0,0 @@ -using FluentAssertions; -using MeAjudaAi.Contracts.Functional; -using MeAjudaAi.Contracts.Models; -using MeAjudaAi.Modules.Providers.API.Endpoints.Public.Me; -using MeAjudaAi.Modules.Providers.Application.Commands; -using MeAjudaAi.Modules.Providers.Application.DTOs; -using MeAjudaAi.Modules.Providers.Application.Queries; -using MeAjudaAi.Modules.Providers.Domain.Enums; -using MeAjudaAi.Shared.Commands; -using MeAjudaAi.Shared.Queries; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Moq; -using Xunit; - -namespace MeAjudaAi.Modules.Providers.Tests.Unit.API.Endpoints; - -/// -/// Testes unitários para o endpoint DeleteMyProviderProfileEndpoint. -/// -[Trait("Category", "Unit")] -public class DeleteMyProviderProfileEndpointTests -{ - private readonly Mock _queryDispatcherMock; - private readonly Mock _commandDispatcherMock; - - public DeleteMyProviderProfileEndpointTests() - { - _queryDispatcherMock = new Mock(); - _commandDispatcherMock = new Mock(); - } - - /// - /// Testa exclusão de perfil com provider válido deve retornar NoContent. - /// - [Fact] - public async Task DeleteMyProfileAsync_WithValidProvider_ShouldReturnNoContent() - { - // Arrange - var userId = Guid.NewGuid(); - var providerId = Guid.NewGuid(); - var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); - - var providerDto = new ProviderDto( - Id: providerId, - UserId: userId, - Name: "Test", - Slug: "test", - Type: EProviderType.Individual, - BusinessProfile: null!, - Status: EProviderStatus.Active, - VerificationStatus: EVerificationStatus.Verified, - Tier: EProviderTier.Standard, - Documents: new List(), - Qualifications: new List(), - Services: new List(), - CreatedAt: DateTime.UtcNow, - UpdatedAt: null, - IsDeleted: false, - DeletedAt: null, - IsActive: true); - - _queryDispatcherMock - .Setup(x => x.QueryAsync>( - It.Is(q => q.UserId == userId), It.IsAny())) - .ReturnsAsync(Result.Success(providerDto)); - - _commandDispatcherMock - .Setup(x => x.SendAsync( - It.Is(c => c.ProviderId == providerId), It.IsAny())) - .ReturnsAsync(Result.Success()); - - // Act - var methodInfo = typeof(DeleteMyProviderProfileEndpoint).GetMethod("DeleteMyProfileAsync", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - - var task = (Task)methodInfo!.Invoke(null, new object[] { context, _queryDispatcherMock.Object, _commandDispatcherMock.Object, CancellationToken.None })!; - var result = await task; - // Assert - result.Should().BeOfType>>(); - _queryDispatcherMock.Verify(x => x.QueryAsync>(It.IsAny(), It.IsAny()), Times.Once); - _commandDispatcherMock.Verify(x => x.SendAsync(It.IsAny(), It.IsAny()), Times.Once); - } - - /// - /// Testa exclusão de perfil quando provider não é encontrado deve retornar NotFound. - /// - [Fact] - public async Task DeleteMyProfileAsync_WhenProviderNotFound_ShouldReturnNotFound() - { - // Arrange - var userId = Guid.NewGuid(); - var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); - - _queryDispatcherMock - .Setup(x => x.QueryAsync>( - It.Is(q => q.UserId == userId), It.IsAny())) - .ReturnsAsync(Result.Success(null)); - - // Act - var methodInfo = typeof(DeleteMyProviderProfileEndpoint).GetMethod("DeleteMyProfileAsync", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - - var task = (Task)methodInfo!.Invoke(null, new object[] { context, _queryDispatcherMock.Object, _commandDispatcherMock.Object, CancellationToken.None })!; - var result = await task; - - result.Should().BeOfType>>(); - _commandDispatcherMock.Verify(x => x.SendAsync(It.IsAny(), It.IsAny()), Times.Never); - } -} diff --git a/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderProfileEndpointTests.cs b/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderProfileEndpointTests.cs deleted file mode 100644 index 59b33d1b5..000000000 --- a/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderProfileEndpointTests.cs +++ /dev/null @@ -1,132 +0,0 @@ -using FluentAssertions; -using MeAjudaAi.Contracts.Functional; -using MeAjudaAi.Contracts.Models; -using MeAjudaAi.Modules.Providers.API.Endpoints.Public.Me; -using MeAjudaAi.Modules.Providers.Application.DTOs; -using MeAjudaAi.Modules.Providers.Application.Queries; -using MeAjudaAi.Shared.Queries; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Moq; -using System.Security.Claims; -using Xunit; -using MeAjudaAi.Modules.Providers.Domain.Enums; -using MeAjudaAi.Shared.Utilities.Constants; - -namespace MeAjudaAi.Modules.Providers.Tests.Unit.API.Endpoints; - -[Trait("Category", "Unit")] -public class GetMyProviderProfileEndpointTests -{ - private readonly Mock _queryDispatcherMock; - - public GetMyProviderProfileEndpointTests() - { - _queryDispatcherMock = new Mock(); - } - - private static System.Reflection.MethodInfo GetMyProfileMethod() - { - var method = typeof(GetMyProviderProfileEndpoint).GetMethod( - "GetMyProfileAsync", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - - method.Should().NotBeNull("GetMyProfileAsync must exist as a private static method on GetMyProviderProfileEndpoint"); - return method!; - } - - [Fact] - public async Task GetMyProfileAsync_WithValidUserId_ShouldDispatchQuery() - { - // Arrange - var userId = Guid.NewGuid(); - var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); - var providerDto = new ProviderDto( - Id: Guid.NewGuid(), - UserId: userId, - Name: "Test", - Slug: "test", - Type: EProviderType.Individual, - BusinessProfile: null!, - Status: EProviderStatus.Active, - VerificationStatus: EVerificationStatus.Verified, - Tier: EProviderTier.Standard, - Documents: new List(), - Qualifications: new List(), - Services: new List(), - CreatedAt: DateTime.UtcNow, - UpdatedAt: null, - IsDeleted: false, - DeletedAt: null, - IsActive: true); - - var dispatchResult = Result.Success(providerDto); - - _queryDispatcherMock - .Setup(x => x.QueryAsync>( - It.Is(q => q.UserId == userId), It.IsAny())) - .ReturnsAsync(dispatchResult); - - // Act - var methodInfo = GetMyProfileMethod(); - - var task = (Task)methodInfo.Invoke(null, new object[] { context, _queryDispatcherMock.Object, CancellationToken.None })!; - var result = await task; - - // Assert - result.Should().BeOfType>>(); - _queryDispatcherMock.Verify(x => x.QueryAsync>( - It.Is(q => q.UserId == userId), It.IsAny()), Times.Once); - } - - [Fact] - public async Task GetMyProfileAsync_WithInvalidUserId_ShouldReturnBadRequest() - { - // Arrange - var context = new DefaultHttpContext(); - context.User = new ClaimsPrincipal(new ClaimsIdentity(new[] - { - new Claim(AuthConstants.Claims.Subject, "invalid-guid") - })); - - // Act - var methodInfo = GetMyProfileMethod(); - - var task = (Task)methodInfo.Invoke(null, new object[] { context, _queryDispatcherMock.Object, CancellationToken.None })!; - var result = await task; - - // Assert - result.Should().BeOfType>>(); - _queryDispatcherMock.Verify( - x => x.QueryAsync>( - It.IsAny(), It.IsAny()), - Times.Never); - } - - [Fact] - public async Task GetMyProfileAsync_WithNonExistentProvider_ShouldReturnNotFound() - { - // Arrange - var userId = Guid.NewGuid(); - var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); - var dispatchResult = Result.Success(null); - - _queryDispatcherMock - .Setup(x => x.QueryAsync>( - It.Is(q => q.UserId == userId), It.IsAny())) - .ReturnsAsync(dispatchResult); - - // Act - var methodInfo = GetMyProfileMethod(); - - var task = (Task)methodInfo.Invoke(null, new object[] { context, _queryDispatcherMock.Object, CancellationToken.None })!; - var result = await task; - - // Assert - result.Should().BeOfType>>(); - _queryDispatcherMock.Verify( - x => x.QueryAsync>( - It.Is(q => q.UserId == userId), It.IsAny()), - Times.Once); - } -} diff --git a/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderStatusEndpointTests.cs b/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderStatusEndpointTests.cs deleted file mode 100644 index 3f03fac31..000000000 --- a/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderStatusEndpointTests.cs +++ /dev/null @@ -1,109 +0,0 @@ -using FluentAssertions; -using MeAjudaAi.Contracts.Functional; -using MeAjudaAi.Contracts.Models; -using MeAjudaAi.Modules.Providers.API.Endpoints.Public.Me; -using MeAjudaAi.Modules.Providers.Application.DTOs; -using MeAjudaAi.Modules.Providers.Application.Queries; -using MeAjudaAi.Modules.Providers.Domain.Enums; -using MeAjudaAi.Shared.Queries; -using MeAjudaAi.Shared.Utilities.Constants; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Moq; -using System.Security.Claims; -using Xunit; - -namespace MeAjudaAi.Modules.Providers.Tests.Unit.API.Endpoints; - -[Trait("Category", "Unit")] -public class GetMyProviderStatusEndpointTests -{ - private readonly Mock _queryDispatcherMock; - - public GetMyProviderStatusEndpointTests() - { - _queryDispatcherMock = new Mock(); - } - - private static System.Reflection.MethodInfo GetMyStatusMethod() - { - // O método no endpoint é GetMyStatusAsync e é private static - var method = typeof(GetMyProviderStatusEndpoint).GetMethod( - "GetMyStatusAsync", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - - method.Should().NotBeNull("GetMyStatusAsync must exist as a private static method on GetMyProviderStatusEndpoint"); - return method!; - } - - [Fact] - public async Task GetMyStatusAsync_WithValidUserId_ShouldReturnStatus() - { - // Arrange - var userId = Guid.NewGuid(); - var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); - - var providerDto = new ProviderDto( - Id: Guid.NewGuid(), - UserId: userId, - Name: "Test", - Slug: "test", - Type: EProviderType.Individual, - BusinessProfile: null!, - Status: EProviderStatus.Active, - VerificationStatus: EVerificationStatus.Verified, - Tier: EProviderTier.Gold, - Documents: new List(), - Qualifications: new List(), - Services: new List(), - CreatedAt: DateTime.UtcNow, - UpdatedAt: null, - IsDeleted: false, - DeletedAt: null, - IsActive: true); - - var dispatchResult = Result.Success(providerDto); - - _queryDispatcherMock - .Setup(x => x.QueryAsync>( - It.Is(q => q.UserId == userId), It.IsAny())) - .ReturnsAsync(dispatchResult); - - // Act - var methodInfo = GetMyStatusMethod(); - var task = (Task)methodInfo.Invoke(null, new object[] { context, _queryDispatcherMock.Object, CancellationToken.None })!; - var result = await task; - - // Assert - result.Should().BeOfType>>(); - var okResult = (Ok>)result; - okResult.Value!.Data.Status.Should().Be(EProviderStatus.Active); - okResult.Value.Data.VerificationStatus.Should().Be(EVerificationStatus.Verified); - okResult.Value.Data.Tier.Should().Be(EProviderTier.Gold); - - _queryDispatcherMock.Verify(x => x.QueryAsync>( - It.Is(q => q.UserId == userId), It.IsAny()), Times.Once); - } - - [Fact] - public async Task GetMyStatusAsync_WithNonExistentProvider_ShouldReturnNotFound() - { - // Arrange - var userId = Guid.NewGuid(); - var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); - var dispatchResult = Result.Success(null); - - _queryDispatcherMock - .Setup(x => x.QueryAsync>( - It.Is(q => q.UserId == userId), It.IsAny())) - .ReturnsAsync(dispatchResult); - - // Act - var methodInfo = GetMyStatusMethod(); - var task = (Task)methodInfo.Invoke(null, new object[] { context, _queryDispatcherMock.Object, CancellationToken.None })!; - var result = await task; - - // Assert - result.Should().BeOfType>>(); - } -} diff --git a/src/Modules/Providers/Tests/Unit/API/Endpoints/UpdateMyProviderProfileEndpointTests.cs b/src/Modules/Providers/Tests/Unit/API/Endpoints/UpdateMyProviderProfileEndpointTests.cs deleted file mode 100644 index c3bed508d..000000000 --- a/src/Modules/Providers/Tests/Unit/API/Endpoints/UpdateMyProviderProfileEndpointTests.cs +++ /dev/null @@ -1,129 +0,0 @@ -using FluentAssertions; -using MeAjudaAi.Contracts.Functional; -using MeAjudaAi.Contracts.Models; -using MeAjudaAi.Modules.Providers.API.Endpoints.Public.Me; -using MeAjudaAi.Modules.Providers.Application.Commands; -using MeAjudaAi.Modules.Providers.Application.DTOs; -using MeAjudaAi.Modules.Providers.Application.DTOs.Requests; -using MeAjudaAi.Modules.Providers.Application.Queries; -using MeAjudaAi.Modules.Providers.Domain.Enums; -using MeAjudaAi.Shared.Commands; -using MeAjudaAi.Shared.Queries; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Moq; -using System.Security.Claims; -using Xunit; - -namespace MeAjudaAi.Modules.Providers.Tests.Unit.API.Endpoints; - -[Trait("Category", "Unit")] -public class UpdateMyProviderProfileEndpointTests -{ - private readonly Mock _queryDispatcherMock; - private readonly Mock _commandDispatcherMock; - - public UpdateMyProviderProfileEndpointTests() - { - _queryDispatcherMock = new Mock(); - _commandDispatcherMock = new Mock(); - } - - [Fact] - public async Task UpdateMyProfileAsync_WithValidRequest_ShouldDispatchUpdateCommand() - { - // Arrange - var userId = Guid.NewGuid(); - var providerId = Guid.NewGuid(); - var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); - - var request = new UpdateProviderProfileRequest - { - Name = "New Name", - BusinessProfile = new BusinessProfileDto( - "Legal", "Fantasy", "Desc", - new ContactInfoDto("e@e.com", "123", "site"), - new AddressDto("S", "1", "C", "N", "C", "ST", "Z", "Co")) - }; - - // Setup Query to return ProviderId - var providerDto = new ProviderDto( - Id: providerId, - UserId: userId, - Name: "Test", - Slug: "test", - Type: EProviderType.Individual, - BusinessProfile: null!, - Status: EProviderStatus.Active, - VerificationStatus: EVerificationStatus.Verified, - Tier: EProviderTier.Standard, - Documents: new List(), - Qualifications: new List(), - Services: new List(), - CreatedAt: DateTime.UtcNow, - UpdatedAt: null, - IsDeleted: false, - DeletedAt: null, - IsActive: true); - - _queryDispatcherMock - .Setup(x => x.QueryAsync>( - It.Is(q => q.UserId == userId), It.IsAny())) - .ReturnsAsync(Result.Success(providerDto)); - - // Setup Command to return Success - _commandDispatcherMock - .Setup(x => x.SendAsync>( - It.IsAny(), It.IsAny())) - .ReturnsAsync(Result.Success(providerDto)); - - // Act - var methodInfo = typeof(UpdateMyProviderProfileEndpoint).GetMethod("UpdateMyProfileAsync", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - - var task = (Task)methodInfo!.Invoke(null, new object[] { context, request, _queryDispatcherMock.Object, _commandDispatcherMock.Object, CancellationToken.None })!; - var result = await task; - - // Assert - _queryDispatcherMock.Verify(x => x.QueryAsync>( - It.Is(q => q.UserId == userId), It.IsAny()), Times.Once); - - _commandDispatcherMock.Verify(x => x.SendAsync>( - It.Is(c => c.ProviderId == providerId && c.Name == request.Name), It.IsAny()), Times.Once); - } - - [Fact] - public async Task UpdateMyProfileAsync_WhenProviderNotFound_ShouldReturnNotFound() - { - // Arrange - var userId = Guid.NewGuid(); - var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); - - var request = new UpdateProviderProfileRequest - { - Name = "New Name", - BusinessProfile = new BusinessProfileDto( - "Legal", "Fantasy", "Desc", - new ContactInfoDto("e@e.com", "123", "site"), - new AddressDto("S", "1", "C", "N", "C", "ST", "Z", "Co")) - }; - - _queryDispatcherMock - .Setup(x => x.QueryAsync>( - It.Is(q => q.UserId == userId), It.IsAny())) - .ReturnsAsync(Result.Success(null)); - - // Act - var methodInfo = typeof(UpdateMyProviderProfileEndpoint).GetMethod("UpdateMyProfileAsync", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - - var task = (Task)methodInfo!.Invoke(null, new object[] { context, request, _queryDispatcherMock.Object, _commandDispatcherMock.Object, CancellationToken.None })!; - var result = await task; // Should be NotFound - - // Assert - _commandDispatcherMock.Verify(x => x.SendAsync>( - It.IsAny(), It.IsAny()), Times.Never); - } - - -} diff --git a/src/Modules/Providers/Tests/Unit/API/Endpoints/UploadMyDocumentEndpointTests.cs b/src/Modules/Providers/Tests/Unit/API/Endpoints/UploadMyDocumentEndpointTests.cs deleted file mode 100644 index 5a94f6bd4..000000000 --- a/src/Modules/Providers/Tests/Unit/API/Endpoints/UploadMyDocumentEndpointTests.cs +++ /dev/null @@ -1,181 +0,0 @@ -using FluentAssertions; -using MeAjudaAi.Contracts.Functional; -using MeAjudaAi.Contracts.Models; -using MeAjudaAi.Modules.Providers.API.Endpoints.Public.Me; -using MeAjudaAi.Modules.Providers.Application.Commands; -using MeAjudaAi.Modules.Providers.Application.DTOs; -using MeAjudaAi.Modules.Providers.Application.DTOs.Requests; -using MeAjudaAi.Modules.Providers.Application.Queries; -using MeAjudaAi.Modules.Providers.Domain.Entities; -using MeAjudaAi.Modules.Providers.Domain.Enums; -using MeAjudaAi.Modules.Providers.Domain.ValueObjects; -using MeAjudaAi.Shared.Commands; -using MeAjudaAi.Shared.Queries; -using MeAjudaAi.Shared.Utilities.Constants; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; -using Moq; -using System.Security.Claims; -using Xunit; - -namespace MeAjudaAi.Modules.Providers.Tests.Unit.API.Endpoints; - -[Trait("Category", "Unit")] -public class UploadMyDocumentEndpointTests -{ - private readonly Mock _commandDispatcherMock; - private readonly Mock _queryDispatcherMock; - - public UploadMyDocumentEndpointTests() - { - _commandDispatcherMock = new Mock(); - _queryDispatcherMock = new Mock(); - } - - private static System.Reflection.MethodInfo UploadDocumentMethod() - { - var method = typeof(UploadMyDocumentEndpoint).GetMethod( - "UploadMyDocumentAsync", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - - method.Should().NotBeNull("UploadMyDocumentAsync must exist as a private static method on UploadMyDocumentEndpoint"); - return method!; - } - - [Fact] - public async Task UploadDocumentAsync_WithValidRequest_ShouldUploadAndReturnOk() - { - // Arrange - var userId = Guid.NewGuid(); - var providerId = Guid.NewGuid(); - var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); - - var request = new AddDocumentRequest("12345678909", EDocumentType.CPF); - - var providerDto = new ProviderDto( - Id: providerId, - UserId: userId, - Name: "Test", - Slug: "test", - Type: EProviderType.Individual, - BusinessProfile: null!, - Status: EProviderStatus.PendingBasicInfo, - VerificationStatus: EVerificationStatus.Pending, - Tier: EProviderTier.Standard, - Documents: new List(), - Qualifications: new List(), - Services: new List(), - CreatedAt: DateTime.UtcNow, - UpdatedAt: null, - IsDeleted: false, - DeletedAt: null, - IsActive: true); - - // Mock Query (Get provider by user id) - _queryDispatcherMock - .Setup(x => x.QueryAsync>( - It.Is(q => q.UserId == userId), It.IsAny())) - .ReturnsAsync(Result.Success(providerDto)); - - // Mock Command (Add document) - var commandResult = Result.Success(providerDto); - _commandDispatcherMock - .Setup(x => x.SendAsync>( - It.Is(c => c.ProviderId == providerId && c.DocumentNumber == request.Number), It.IsAny())) - .ReturnsAsync(commandResult); - - // Act - var methodInfo = UploadDocumentMethod(); - // Corrected parameter order: Context, Request, QueryDispatcher, CommandDispatcher, CancellationToken - var task = (Task)methodInfo.Invoke(null, new object[] { context, request, _queryDispatcherMock.Object, _commandDispatcherMock.Object, CancellationToken.None })!; - var result = await task; - - // Assert - result.Should().BeOfType>>(); - var okResult = (Ok>)result; - okResult.Value.Should().NotBeNull(); - okResult.Value!.IsSuccess.Should().BeTrue(); - // okResult.Value.Value.Should().BeEquivalentTo(providerDto); // Optional verification - - _queryDispatcherMock.Verify(x => x.QueryAsync>( - It.Is(q => q.UserId == userId), It.IsAny()), Times.Once); - - _commandDispatcherMock.Verify(x => x.SendAsync>( - It.Is(c => c.ProviderId == providerId), It.IsAny()), Times.Once); - } - - [Fact] - public async Task UploadDocumentAsync_WithNonExistentProvider_ShouldReturnNotFound() - { - // Arrange - var userId = Guid.NewGuid(); - var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); - var request = new AddDocumentRequest("12345678909", EDocumentType.CPF); - - _queryDispatcherMock - .Setup(x => x.QueryAsync>( - It.Is(q => q.UserId == userId), It.IsAny())) - .ReturnsAsync(Result.Success(null)); - - // Act - var methodInfo = UploadDocumentMethod(); - // Corrected parameter order - var task = (Task)methodInfo.Invoke(null, new object[] { context, request, _queryDispatcherMock.Object, _commandDispatcherMock.Object, CancellationToken.None })!; - var result = await task; - - // Assert - // Corrected expected type: NotFound> instead of NotFound - result.Should().BeOfType>>(); - - _commandDispatcherMock.Verify(x => x.SendAsync>( - It.IsAny(), It.IsAny()), Times.Never); - } - - [Fact] - public async Task UploadDocumentAsync_WhenCommandFails_ShouldReturnBadRequest() - { - // Arrange - var userId = Guid.NewGuid(); - var providerId = Guid.NewGuid(); - var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); - var request = new AddDocumentRequest("12345678909", EDocumentType.CPF); - - var providerDto = new ProviderDto( - Id: providerId, - UserId: userId, - Name: "Test", - Slug: "test", - Type: EProviderType.Individual, - BusinessProfile: null!, - Status: EProviderStatus.PendingBasicInfo, - VerificationStatus: EVerificationStatus.Pending, - Tier: EProviderTier.Standard, - Documents: new List(), - Qualifications: new List(), - Services: new List(), - CreatedAt: DateTime.UtcNow, - UpdatedAt: null, - IsDeleted: false, - DeletedAt: null, - IsActive: true); - - _queryDispatcherMock.Setup(x => x.QueryAsync>( - It.IsAny(), It.IsAny())) - .ReturnsAsync(Result.Success(providerDto)); - - _commandDispatcherMock.Setup(x => x.SendAsync>( - It.IsAny(), It.IsAny())) - .ReturnsAsync(Result.Failure("Invalid document")); - - // Act - var methodInfo = UploadDocumentMethod(); - // Corrected parameter order - var task = (Task)methodInfo.Invoke(null, new object[] { context, request, _queryDispatcherMock.Object, _commandDispatcherMock.Object, CancellationToken.None })!; - var result = await task; - - // Assert - // Corrected expected type: BadRequest> - result.Should().BeOfType>>(); - } -} diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Commands/RegisterProviderCommandHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Commands/RegisterProviderCommandHandlerTests.cs new file mode 100644 index 000000000..46b916d48 --- /dev/null +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Commands/RegisterProviderCommandHandlerTests.cs @@ -0,0 +1,147 @@ +using FluentAssertions; +using MeAjudaAi.Modules.Providers.Application.Commands; +using MeAjudaAi.Modules.Providers.Application.Handlers.Commands; +using MeAjudaAi.Modules.Providers.Domain.Entities; +using MeAjudaAi.Modules.Providers.Domain.Enums; +using MeAjudaAi.Modules.Providers.Domain.Repositories; +using MeAjudaAi.Modules.Providers.Domain.ValueObjects; +using MeAjudaAi.Shared.Exceptions; +using MeAjudaAi.Shared.Database.Exceptions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace MeAjudaAi.Modules.Providers.Tests.Unit.Application.Handlers.Commands; + +[Trait("Category", "Unit")] +public class RegisterProviderCommandHandlerTests +{ + private readonly Mock _providerRepositoryMock; + private readonly Mock> _loggerMock; + private readonly RegisterProviderCommandHandler _handler; + + public RegisterProviderCommandHandlerTests() + { + _providerRepositoryMock = new Mock(); + _loggerMock = new Mock>(); + _handler = new RegisterProviderCommandHandler(_providerRepositoryMock.Object, _loggerMock.Object); + } + + [Fact] + public async Task HandleAsync_WhenProviderAlreadyExists_ShouldReturnSuccessWithExistingProvider() + { + // Arrange + var userId = Guid.NewGuid(); + var command = new RegisterProviderCommand( + UserId: userId, + Name: "Test Provider", + Email: "test@test.com", + PhoneNumber: "11999999999", + Type: EProviderType.Individual, + DocumentNumber: "12345678901"); + + var existingProvider = new Provider(userId, "Existing Provider", EProviderType.Individual, + new BusinessProfile("Legal", new ContactInfo("test@test.com", "11999999999"), + new Address("Rua", "1", "Bairro", "Cidade", "SP", "00000-000"))); + + _providerRepositoryMock + .Setup(x => x.GetByUserIdAsync(userId, It.IsAny())) + .ReturnsAsync(existingProvider); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value.Name.Should().Be("Existing Provider"); + + _providerRepositoryMock.Verify( + x => x.AddAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task HandleAsync_WithValidCommand_ShouldCreateProviderAndReturnSuccess() + { + // Arrange + var userId = Guid.NewGuid(); + var command = new RegisterProviderCommand( + UserId: userId, + Name: "New Provider", + Email: "new@test.com", + PhoneNumber: "11988888888", + Type: EProviderType.Individual, + DocumentNumber: "12345678901"); + + _providerRepositoryMock + .Setup(x => x.GetByUserIdAsync(userId, It.IsAny())) + .ReturnsAsync((Provider?)null); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value.Name.Should().Be("New Provider"); + + _providerRepositoryMock.Verify( + x => x.AddAsync(It.Is(p => p.UserId == userId && p.Name == "New Provider"), It.IsAny()), + Times.Once); + } + + private class TestDomainException : DomainException + { + public TestDomainException(string message) : base(message) { } + } + + [Fact] + public async Task HandleAsync_WhenDomainExceptionThrows_ShouldReturnFailure() + { + // Arrange + var userId = Guid.NewGuid(); + var command = new RegisterProviderCommand(userId, "Test Provider", "test@test.com", "11999999999", EProviderType.Individual, "12345678901"); + + _providerRepositoryMock + .Setup(x => x.GetByUserIdAsync(userId, It.IsAny())) + .ReturnsAsync((Provider?)null); + + _providerRepositoryMock + .Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new TestDomainException("Invalid state")); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Error.Should().NotBeNull(); + result.Error!.StatusCode.Should().Be(400); + } + + [Fact] + public async Task HandleAsync_WhenGenericExceptionThrows_ShouldReturnFailure500() + { + // Arrange + var userId = Guid.NewGuid(); + var command = new RegisterProviderCommand(userId, "Test Provider", "test@test.com", "11999999999", EProviderType.Individual, "12345678901"); + + _providerRepositoryMock + .Setup(x => x.GetByUserIdAsync(userId, It.IsAny())) + .ReturnsAsync((Provider?)null); + + _providerRepositoryMock + .Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("Unknown failure")); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Error.Should().NotBeNull(); + result.Error!.StatusCode.Should().Be(500); + } +} diff --git a/src/Modules/Providers/Tests/Unit/Application/Validators/RegisterProviderCommandValidatorTests.cs b/src/Modules/Providers/Tests/Unit/Application/Validators/RegisterProviderCommandValidatorTests.cs new file mode 100644 index 000000000..c652b11ec --- /dev/null +++ b/src/Modules/Providers/Tests/Unit/Application/Validators/RegisterProviderCommandValidatorTests.cs @@ -0,0 +1,46 @@ +using FluentAssertions; +using MeAjudaAi.Modules.Providers.Application.Commands; +using MeAjudaAi.Modules.Providers.Application.Validators; +using MeAjudaAi.Modules.Providers.Domain.Enums; +using Xunit; + +namespace MeAjudaAi.Modules.Providers.Tests.Unit.Application.Validators; + +[Trait("Category", "Unit")] +public class RegisterProviderCommandValidatorTests +{ + private readonly RegisterProviderCommandValidator _validator = new(); + + [Fact] + public async Task Validate_WithValidCommand_ShouldPassValidation() + { + // Arrange + var command = new RegisterProviderCommand( + Guid.NewGuid(), "João Silva", "joao@test.com", + "11999999999", EProviderType.Individual, "12345678901"); + + // Act + var result = await _validator.ValidateAsync(command); + + // Assert + result.IsValid.Should().BeTrue(); + } + + [Theory] + [InlineData("", "joao@test.com", "11999999999")] // nome vazio + [InlineData("João", "email-invalido", "11999999999")] // email inválido + [InlineData("João", "joao@test.com", "")] // telefone vazio + public async Task Validate_WithInvalidData_ShouldFailValidation(string name, string email, string phone) + { + // Arrange + var command = new RegisterProviderCommand( + Guid.NewGuid(), name, email, phone, + EProviderType.Individual, "12345678901"); + + // Act + var result = await _validator.ValidateAsync(command); + + // Assert + result.IsValid.Should().BeFalse(); + } +} diff --git a/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderServiceAddedDomainEventHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderServiceAddedDomainEventHandlerTests.cs new file mode 100644 index 000000000..cadb1b305 --- /dev/null +++ b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderServiceAddedDomainEventHandlerTests.cs @@ -0,0 +1,67 @@ +using FluentAssertions; +using MeAjudaAi.Modules.Providers.Domain.Events; +using MeAjudaAi.Modules.Providers.Infrastructure.Events.Handlers; +using MeAjudaAi.Shared.Messaging; +using MeAjudaAi.Shared.Messaging.Messages.Providers; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace MeAjudaAi.Modules.Providers.Tests.Unit.Infrastructure.Events.Handlers; + +[Trait("Category", "Unit")] +public class ProviderServiceAddedDomainEventHandlerTests +{ + private readonly Mock _messageBusMock; + private readonly Mock> _loggerMock; + private readonly ProviderServiceAddedDomainEventHandler _handler; + + public ProviderServiceAddedDomainEventHandlerTests() + { + _messageBusMock = new Mock(); + _loggerMock = new Mock>(); + _handler = new ProviderServiceAddedDomainEventHandler(_messageBusMock.Object, _loggerMock.Object); + } + + [Fact] + public async Task HandleAsync_ShouldPublishIntegrationEvent() + { + // Arrange + var providerId = Guid.NewGuid(); + var serviceId = Guid.NewGuid(); + var domainEvent = new ProviderServiceAddedDomainEvent(providerId, 1, serviceId); + + _messageBusMock + .Setup(x => x.PublishAsync(It.IsAny(), null, It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + await _handler.HandleAsync(domainEvent, CancellationToken.None); + + // Assert + _messageBusMock.Verify( + x => x.PublishAsync( + It.Is(e => e.ProviderId == providerId), + null, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task HandleAsync_WhenPublishThrowsException_ShouldNotPropagateException() + { + // Arrange + var providerId = Guid.NewGuid(); + var domainEvent = new ProviderServiceAddedDomainEvent(providerId, 1, Guid.NewGuid()); + + _messageBusMock + .Setup(x => x.PublishAsync(It.IsAny(), null, It.IsAny())) + .ThrowsAsync(new Exception("Message bus error")); + + // Act + var act = async () => await _handler.HandleAsync(domainEvent, CancellationToken.None); + + // Assert + await act.Should().NotThrowAsync(); + } +} diff --git a/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderServiceRemovedDomainEventHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderServiceRemovedDomainEventHandlerTests.cs new file mode 100644 index 000000000..482f56177 --- /dev/null +++ b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderServiceRemovedDomainEventHandlerTests.cs @@ -0,0 +1,67 @@ +using FluentAssertions; +using MeAjudaAi.Modules.Providers.Domain.Events; +using MeAjudaAi.Modules.Providers.Infrastructure.Events.Handlers; +using MeAjudaAi.Shared.Messaging; +using MeAjudaAi.Shared.Messaging.Messages.Providers; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace MeAjudaAi.Modules.Providers.Tests.Unit.Infrastructure.Events.Handlers; + +[Trait("Category", "Unit")] +public class ProviderServiceRemovedDomainEventHandlerTests +{ + private readonly Mock _messageBusMock; + private readonly Mock> _loggerMock; + private readonly ProviderServiceRemovedDomainEventHandler _handler; + + public ProviderServiceRemovedDomainEventHandlerTests() + { + _messageBusMock = new Mock(); + _loggerMock = new Mock>(); + _handler = new ProviderServiceRemovedDomainEventHandler(_messageBusMock.Object, _loggerMock.Object); + } + + [Fact] + public async Task HandleAsync_ShouldPublishIntegrationEvent() + { + // Arrange + var providerId = Guid.NewGuid(); + var serviceId = Guid.NewGuid(); + var domainEvent = new ProviderServiceRemovedDomainEvent(providerId, 1, serviceId); + + _messageBusMock + .Setup(x => x.PublishAsync(It.IsAny(), null, It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + await _handler.HandleAsync(domainEvent, CancellationToken.None); + + // Assert + _messageBusMock.Verify( + x => x.PublishAsync( + It.Is(e => e.ProviderId == providerId), + null, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task HandleAsync_WhenPublishThrowsException_ShouldNotPropagateException() + { + // Arrange + var providerId = Guid.NewGuid(); + var domainEvent = new ProviderServiceRemovedDomainEvent(providerId, 1, Guid.NewGuid()); + + _messageBusMock + .Setup(x => x.PublishAsync(It.IsAny(), null, It.IsAny())) + .ThrowsAsync(new Exception("Message bus error")); + + // Act + var act = async () => await _handler.HandleAsync(domainEvent, CancellationToken.None); + + // Assert + await act.Should().NotThrowAsync(); + } +} diff --git a/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Mappers/ProviderEventMappersTests.cs b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Mappers/ProviderEventMappersTests.cs new file mode 100644 index 000000000..9031717f3 --- /dev/null +++ b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Mappers/ProviderEventMappersTests.cs @@ -0,0 +1,141 @@ +using FluentAssertions; +using MeAjudaAi.Modules.Providers.Domain.Enums; +using MeAjudaAi.Modules.Providers.Domain.Events; +using MeAjudaAi.Modules.Providers.Infrastructure.Events.Mappers; +using Xunit; + +namespace MeAjudaAi.Modules.Providers.Tests.Unit.Infrastructure.Events.Mappers; + +[Trait("Category", "Unit")] +public class ProviderEventMappersTests +{ + [Fact] + public void ToIntegrationEvent_FromProviderRegisteredDomainEvent_ShouldMapCorrectly() + { + // Arrange + var providerId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var domainEvent = new ProviderRegisteredDomainEvent( + providerId, 1, userId, "Test Provider", EProviderType.Individual, "test@test.com", "test-provider"); + + // Act + var result = domainEvent.ToIntegrationEvent(); + + // Assert + result.Source.Should().Be("Providers"); + result.ProviderId.Should().Be(providerId); + result.UserId.Should().Be(userId); + result.Name.Should().Be("Test Provider"); + result.ProviderType.Should().Be("Individual"); + result.Email.Should().Be("test@test.com"); + result.Slug.Should().Be("test-provider"); + result.RegisteredAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void ToIntegrationEvent_FromProviderDeletedDomainEvent_ShouldMapCorrectly() + { + // Arrange + var providerId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var domainEvent = new ProviderDeletedDomainEvent(providerId, 1, "Test Provider", userId.ToString()); + + // Act + var result = domainEvent.ToIntegrationEvent(userId); + + // Assert + result.Source.Should().Be("Providers"); + result.ProviderId.Should().Be(providerId); + result.UserId.Should().Be(userId); + result.Name.Should().Be("Test Provider"); + result.Reason.Should().Be("Provider deleted"); + result.DeletedBy.Should().Be(userId.ToString()); + result.DeletedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void ToIntegrationEvent_FromProviderVerificationStatusUpdatedDomainEvent_ShouldMapCorrectly() + { + // Arrange + var providerId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var domainEvent = new ProviderVerificationStatusUpdatedDomainEvent( + providerId, 1, EVerificationStatus.Pending, EVerificationStatus.Verified, userId.ToString()); + + // Act + var result = domainEvent.ToIntegrationEvent(userId, "Test Provider"); + + // Assert + result.Source.Should().Be("Providers"); + result.ProviderId.Should().Be(providerId); + result.UserId.Should().Be(userId); + result.Name.Should().Be("Test Provider"); + result.PreviousStatus.Should().Be("Pending"); + result.NewStatus.Should().Be("Verified"); + result.UpdatedBy.Should().Be(userId.ToString()); + } + + [Fact] + public void ToIntegrationEvent_FromProviderProfileUpdatedDomainEvent_ShouldMapCorrectly() + { + // Arrange + var providerId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var domainEvent = new ProviderProfileUpdatedDomainEvent( + providerId, 1, "New Name", "new@test.com", "new-name", userId.ToString(), new string[] { "Name", "Email" }); + string[] fields = { "Name", "Email" }; + + // Act + var result = domainEvent.ToIntegrationEvent(userId, fields); + + // Assert + result.Source.Should().Be("Providers"); + result.ProviderId.Should().Be(providerId); + result.UserId.Should().Be(userId); + result.Name.Should().Be("New Name"); + result.NewEmail.Should().Be("new@test.com"); + result.Slug.Should().Be("new-name"); + result.UpdatedFields.Should().BeEquivalentTo(fields); + result.UpdatedBy.Should().Be(userId.ToString()); + } + + [Fact] + public void ToIntegrationEvent_FromProviderAwaitingVerificationDomainEvent_ShouldMapCorrectly() + { + // Arrange + var providerId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var domainEvent = new ProviderAwaitingVerificationDomainEvent(providerId, 1, userId, "Test", userId.ToString()); + + // Act + var result = domainEvent.ToIntegrationEvent(); + + // Assert + result.Source.Should().Be("Providers"); + result.ProviderId.Should().Be(providerId); + result.UserId.Should().Be(userId); + result.Name.Should().Be("Test"); + result.UpdatedBy.Should().Be(userId.ToString()); + result.TransitionedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void ToIntegrationEvent_FromProviderActivatedDomainEvent_ShouldMapCorrectly() + { + // Arrange + var providerId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var domainEvent = new ProviderActivatedDomainEvent(providerId, 1, userId, "Test", userId.ToString()); + + // Act + var result = domainEvent.ToIntegrationEvent(); + + // Assert + result.Source.Should().Be("Providers"); + result.ProviderId.Should().Be(providerId); + result.UserId.Should().Be(userId); + result.Name.Should().Be("Test"); + result.ActivatedBy.Should().Be(userId.ToString()); + result.ActivatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } +} diff --git a/src/Modules/Providers/Tests/packages.lock.json b/src/Modules/Providers/Tests/packages.lock.json index beb708e21..daa660735 100644 --- a/src/Modules/Providers/Tests/packages.lock.json +++ b/src/Modules/Providers/Tests/packages.lock.json @@ -845,16 +845,16 @@ }, "Swashbuckle.AspNetCore.Swagger": { "type": "Transitive", - "resolved": "10.1.5", - "contentHash": "s4Mct6+Ob0LK9vYVaZcYi/RFFCOEJNjf6nJ5ZPoxtpdFSlzR6i9AHI7Vl44obX8cynRxJW7prA1IUabkiXolFg==", + "resolved": "10.1.7", + "contentHash": "EjLibt/d/QuRv170GoihTbcPUpgzSFm2WKHhnGJFZQ03JYzfuitsM79azaAR8NBwRunU7yScSX6HRE5JUlrEMQ==", "dependencies": { "Microsoft.OpenApi": "2.4.1" } }, "Swashbuckle.AspNetCore.SwaggerUI": { "type": "Transitive", - "resolved": "10.1.5", - "contentHash": "tQWVKNJWW7lf6S0bv22+7yfxK5IKzvsMeueF4XHSziBfREhLKt42OKzi6/1nINmyGlM4hGbR8aSMg72dLLVBLw==" + "resolved": "10.1.7", + "contentHash": "iJo3ODyUb/M8Vm8AH1r9y9iAba0w95xsCn3zFVl96ISRHbTDWxi+l7oFVCZqUEdjd97B8VMDPnMliWAdomR8uw==" }, "System.ClientModel": { "type": "Transitive", @@ -1030,8 +1030,8 @@ "Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.5, )", "Serilog.AspNetCore": "[10.0.0, )", "Serilog.Sinks.Seq": "[9.0.0, )", - "Swashbuckle.AspNetCore": "[10.1.5, )", - "Swashbuckle.AspNetCore.Annotations": "[10.1.5, )" + "Swashbuckle.AspNetCore": "[10.1.7, )", + "Swashbuckle.AspNetCore.Annotations": "[10.1.7, )" } }, "meajudaai.contracts": { @@ -2113,32 +2113,32 @@ }, "Swashbuckle.AspNetCore": { "type": "CentralTransitive", - "requested": "[10.1.5, )", - "resolved": "10.1.5", - "contentHash": "/eNk9z/8quXhDX14o3XLbwAX/84uIWSbiUD7cI/UrQnoBMOiyAtzKxNEJUtf/TyxjFpcXxE9FAfLvtbNpxHBSg==", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "vgef8DPT411JU5JjHiDbr0WOxsIVuAvegPGtqmm4Na4JRl/264dfBJcGkiPHsAr5P+Vda+qN1rZKRtBl1rF9aA==", "dependencies": { "Microsoft.Extensions.ApiDescription.Server": "10.0.0", - "Swashbuckle.AspNetCore.Swagger": "10.1.5", - "Swashbuckle.AspNetCore.SwaggerGen": "10.1.5", - "Swashbuckle.AspNetCore.SwaggerUI": "10.1.5" + "Swashbuckle.AspNetCore.Swagger": "10.1.7", + "Swashbuckle.AspNetCore.SwaggerGen": "10.1.7", + "Swashbuckle.AspNetCore.SwaggerUI": "10.1.7" } }, "Swashbuckle.AspNetCore.Annotations": { "type": "CentralTransitive", - "requested": "[10.1.5, )", - "resolved": "10.1.5", - "contentHash": "f+GGMzb7ugIBtHcbSF1QvSEWH3tavRnYduOxNhXcvdWJxiBsklGK9ueIFcjZpLC4dn/EMRky8YaN/AWfLibugA==", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "qHNQghKAzrjDEECIlyKEanmWxXDzrh6PyjGWVdql2qZfap0Ionuxam5OrUozibjbgQbVjRmgSXxWC8giIuCbGA==", "dependencies": { - "Swashbuckle.AspNetCore.SwaggerGen": "10.1.5" + "Swashbuckle.AspNetCore.SwaggerGen": "10.1.7" } }, "Swashbuckle.AspNetCore.SwaggerGen": { "type": "CentralTransitive", - "requested": "[10.1.5, )", - "resolved": "10.1.5", - "contentHash": "ysQIRgqnx4Vb/9+r3xnEAiaxYmiBHO8jTg7ACaCh+R3Sn+ZKCWKD6nyu0ph3okP91wFSh/6LgccjeLUaQHV+ZA==", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "PuubO9BjvNn6U3D9kLpuWKY1JtziWw7SsGBq0age1E50uQjQ8Fzl8s0EwzrLfANqYJNgDnJi9l7N1QxcGVB2Zw==", "dependencies": { - "Swashbuckle.AspNetCore.Swagger": "10.1.5" + "Swashbuckle.AspNetCore.Swagger": "10.1.7" } }, "System.IdentityModel.Tokens.Jwt": { diff --git a/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Events/Handlers/ProviderServicesUpdatedIntegrationEventHandlerTests.cs b/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Events/Handlers/ProviderServicesUpdatedIntegrationEventHandlerTests.cs new file mode 100644 index 000000000..d2004dac6 --- /dev/null +++ b/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Events/Handlers/ProviderServicesUpdatedIntegrationEventHandlerTests.cs @@ -0,0 +1,84 @@ +using FluentAssertions; +using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Contracts.Modules.SearchProviders; +using MeAjudaAi.Modules.SearchProviders.Infrastructure.Events.Handlers; +using MeAjudaAi.Shared.Messaging.Messages.Providers; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace MeAjudaAi.Modules.SearchProviders.Tests.Unit.Infrastructure.Events.Handlers; + +[Trait("Category", "Unit")] +public class ProviderServicesUpdatedIntegrationEventHandlerTests +{ + private readonly Mock _searchProvidersModuleApiMock; + private readonly Mock> _loggerMock; + private readonly ProviderServicesUpdatedIntegrationEventHandler _handler; + + public ProviderServicesUpdatedIntegrationEventHandlerTests() + { + _searchProvidersModuleApiMock = new Mock(); + _loggerMock = new Mock>(); + _handler = new ProviderServicesUpdatedIntegrationEventHandler(_searchProvidersModuleApiMock.Object, _loggerMock.Object); + } + + [Fact] + public async Task HandleAsync_WhenIndexSucceeds_ShouldNotThrow() + { + // Arrange + var providerId = Guid.NewGuid(); + var integrationEvent = new ProviderServicesUpdatedIntegrationEvent( + "Providers", providerId, Array.Empty()); + + _searchProvidersModuleApiMock + .Setup(x => x.IndexProviderAsync(providerId, It.IsAny())) + .ReturnsAsync(Result.Success()); + + // Act + var act = async () => await _handler.HandleAsync(integrationEvent, CancellationToken.None); + + // Assert + await act.Should().NotThrowAsync(); + _searchProvidersModuleApiMock.Verify(x => x.IndexProviderAsync(providerId, It.IsAny()), Times.Once); + } + + [Fact] + public async Task HandleAsync_WhenIndexFails_ShouldNotThrowException() + { + // Arrange + var providerId = Guid.NewGuid(); + var integrationEvent = new ProviderServicesUpdatedIntegrationEvent( + "Providers", providerId, Array.Empty()); + + _searchProvidersModuleApiMock + .Setup(x => x.IndexProviderAsync(providerId, It.IsAny())) + .ReturnsAsync(Result.Failure(new Error("Index failed", 500))); + + // Act + var act = async () => await _handler.HandleAsync(integrationEvent, CancellationToken.None); + + // Assert + await act.Should().NotThrowAsync(); + _searchProvidersModuleApiMock.Verify(x => x.IndexProviderAsync(providerId, It.IsAny()), Times.Once); + } + + [Fact] + public async Task HandleAsync_WhenApiThrowsException_ShouldNotPropagateException() + { + // Arrange + var providerId = Guid.NewGuid(); + var integrationEvent = new ProviderServicesUpdatedIntegrationEvent( + "Providers", providerId, Array.Empty()); + + _searchProvidersModuleApiMock + .Setup(x => x.IndexProviderAsync(providerId, It.IsAny())) + .ThrowsAsync(new Exception("API error")); + + // Act + var act = async () => await _handler.HandleAsync(integrationEvent, CancellationToken.None); + + // Assert + await act.Should().NotThrowAsync(); + } +} diff --git a/src/Modules/SearchProviders/Tests/packages.lock.json b/src/Modules/SearchProviders/Tests/packages.lock.json index f2cf7d993..b36ada019 100644 --- a/src/Modules/SearchProviders/Tests/packages.lock.json +++ b/src/Modules/SearchProviders/Tests/packages.lock.json @@ -836,16 +836,16 @@ }, "Swashbuckle.AspNetCore.Swagger": { "type": "Transitive", - "resolved": "10.1.5", - "contentHash": "s4Mct6+Ob0LK9vYVaZcYi/RFFCOEJNjf6nJ5ZPoxtpdFSlzR6i9AHI7Vl44obX8cynRxJW7prA1IUabkiXolFg==", + "resolved": "10.1.7", + "contentHash": "EjLibt/d/QuRv170GoihTbcPUpgzSFm2WKHhnGJFZQ03JYzfuitsM79azaAR8NBwRunU7yScSX6HRE5JUlrEMQ==", "dependencies": { "Microsoft.OpenApi": "2.4.1" } }, "Swashbuckle.AspNetCore.SwaggerUI": { "type": "Transitive", - "resolved": "10.1.5", - "contentHash": "tQWVKNJWW7lf6S0bv22+7yfxK5IKzvsMeueF4XHSziBfREhLKt42OKzi6/1nINmyGlM4hGbR8aSMg72dLLVBLw==" + "resolved": "10.1.7", + "contentHash": "iJo3ODyUb/M8Vm8AH1r9y9iAba0w95xsCn3zFVl96ISRHbTDWxi+l7oFVCZqUEdjd97B8VMDPnMliWAdomR8uw==" }, "System.ClientModel": { "type": "Transitive", @@ -1021,8 +1021,8 @@ "Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.5, )", "Serilog.AspNetCore": "[10.0.0, )", "Serilog.Sinks.Seq": "[9.0.0, )", - "Swashbuckle.AspNetCore": "[10.1.5, )", - "Swashbuckle.AspNetCore.Annotations": "[10.1.5, )" + "Swashbuckle.AspNetCore": "[10.1.7, )", + "Swashbuckle.AspNetCore.Annotations": "[10.1.7, )" } }, "meajudaai.contracts": { @@ -2113,32 +2113,32 @@ }, "Swashbuckle.AspNetCore": { "type": "CentralTransitive", - "requested": "[10.1.5, )", - "resolved": "10.1.5", - "contentHash": "/eNk9z/8quXhDX14o3XLbwAX/84uIWSbiUD7cI/UrQnoBMOiyAtzKxNEJUtf/TyxjFpcXxE9FAfLvtbNpxHBSg==", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "vgef8DPT411JU5JjHiDbr0WOxsIVuAvegPGtqmm4Na4JRl/264dfBJcGkiPHsAr5P+Vda+qN1rZKRtBl1rF9aA==", "dependencies": { "Microsoft.Extensions.ApiDescription.Server": "10.0.0", - "Swashbuckle.AspNetCore.Swagger": "10.1.5", - "Swashbuckle.AspNetCore.SwaggerGen": "10.1.5", - "Swashbuckle.AspNetCore.SwaggerUI": "10.1.5" + "Swashbuckle.AspNetCore.Swagger": "10.1.7", + "Swashbuckle.AspNetCore.SwaggerGen": "10.1.7", + "Swashbuckle.AspNetCore.SwaggerUI": "10.1.7" } }, "Swashbuckle.AspNetCore.Annotations": { "type": "CentralTransitive", - "requested": "[10.1.5, )", - "resolved": "10.1.5", - "contentHash": "f+GGMzb7ugIBtHcbSF1QvSEWH3tavRnYduOxNhXcvdWJxiBsklGK9ueIFcjZpLC4dn/EMRky8YaN/AWfLibugA==", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "qHNQghKAzrjDEECIlyKEanmWxXDzrh6PyjGWVdql2qZfap0Ionuxam5OrUozibjbgQbVjRmgSXxWC8giIuCbGA==", "dependencies": { - "Swashbuckle.AspNetCore.SwaggerGen": "10.1.5" + "Swashbuckle.AspNetCore.SwaggerGen": "10.1.7" } }, "Swashbuckle.AspNetCore.SwaggerGen": { "type": "CentralTransitive", - "requested": "[10.1.5, )", - "resolved": "10.1.5", - "contentHash": "ysQIRgqnx4Vb/9+r3xnEAiaxYmiBHO8jTg7ACaCh+R3Sn+ZKCWKD6nyu0ph3okP91wFSh/6LgccjeLUaQHV+ZA==", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "PuubO9BjvNn6U3D9kLpuWKY1JtziWw7SsGBq0age1E50uQjQ8Fzl8s0EwzrLfANqYJNgDnJi9l7N1QxcGVB2Zw==", "dependencies": { - "Swashbuckle.AspNetCore.Swagger": "10.1.5" + "Swashbuckle.AspNetCore.Swagger": "10.1.7" } }, "System.IdentityModel.Tokens.Jwt": { diff --git a/src/Modules/ServiceCatalogs/Tests/packages.lock.json b/src/Modules/ServiceCatalogs/Tests/packages.lock.json index f2cf7d993..b36ada019 100644 --- a/src/Modules/ServiceCatalogs/Tests/packages.lock.json +++ b/src/Modules/ServiceCatalogs/Tests/packages.lock.json @@ -836,16 +836,16 @@ }, "Swashbuckle.AspNetCore.Swagger": { "type": "Transitive", - "resolved": "10.1.5", - "contentHash": "s4Mct6+Ob0LK9vYVaZcYi/RFFCOEJNjf6nJ5ZPoxtpdFSlzR6i9AHI7Vl44obX8cynRxJW7prA1IUabkiXolFg==", + "resolved": "10.1.7", + "contentHash": "EjLibt/d/QuRv170GoihTbcPUpgzSFm2WKHhnGJFZQ03JYzfuitsM79azaAR8NBwRunU7yScSX6HRE5JUlrEMQ==", "dependencies": { "Microsoft.OpenApi": "2.4.1" } }, "Swashbuckle.AspNetCore.SwaggerUI": { "type": "Transitive", - "resolved": "10.1.5", - "contentHash": "tQWVKNJWW7lf6S0bv22+7yfxK5IKzvsMeueF4XHSziBfREhLKt42OKzi6/1nINmyGlM4hGbR8aSMg72dLLVBLw==" + "resolved": "10.1.7", + "contentHash": "iJo3ODyUb/M8Vm8AH1r9y9iAba0w95xsCn3zFVl96ISRHbTDWxi+l7oFVCZqUEdjd97B8VMDPnMliWAdomR8uw==" }, "System.ClientModel": { "type": "Transitive", @@ -1021,8 +1021,8 @@ "Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.5, )", "Serilog.AspNetCore": "[10.0.0, )", "Serilog.Sinks.Seq": "[9.0.0, )", - "Swashbuckle.AspNetCore": "[10.1.5, )", - "Swashbuckle.AspNetCore.Annotations": "[10.1.5, )" + "Swashbuckle.AspNetCore": "[10.1.7, )", + "Swashbuckle.AspNetCore.Annotations": "[10.1.7, )" } }, "meajudaai.contracts": { @@ -2113,32 +2113,32 @@ }, "Swashbuckle.AspNetCore": { "type": "CentralTransitive", - "requested": "[10.1.5, )", - "resolved": "10.1.5", - "contentHash": "/eNk9z/8quXhDX14o3XLbwAX/84uIWSbiUD7cI/UrQnoBMOiyAtzKxNEJUtf/TyxjFpcXxE9FAfLvtbNpxHBSg==", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "vgef8DPT411JU5JjHiDbr0WOxsIVuAvegPGtqmm4Na4JRl/264dfBJcGkiPHsAr5P+Vda+qN1rZKRtBl1rF9aA==", "dependencies": { "Microsoft.Extensions.ApiDescription.Server": "10.0.0", - "Swashbuckle.AspNetCore.Swagger": "10.1.5", - "Swashbuckle.AspNetCore.SwaggerGen": "10.1.5", - "Swashbuckle.AspNetCore.SwaggerUI": "10.1.5" + "Swashbuckle.AspNetCore.Swagger": "10.1.7", + "Swashbuckle.AspNetCore.SwaggerGen": "10.1.7", + "Swashbuckle.AspNetCore.SwaggerUI": "10.1.7" } }, "Swashbuckle.AspNetCore.Annotations": { "type": "CentralTransitive", - "requested": "[10.1.5, )", - "resolved": "10.1.5", - "contentHash": "f+GGMzb7ugIBtHcbSF1QvSEWH3tavRnYduOxNhXcvdWJxiBsklGK9ueIFcjZpLC4dn/EMRky8YaN/AWfLibugA==", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "qHNQghKAzrjDEECIlyKEanmWxXDzrh6PyjGWVdql2qZfap0Ionuxam5OrUozibjbgQbVjRmgSXxWC8giIuCbGA==", "dependencies": { - "Swashbuckle.AspNetCore.SwaggerGen": "10.1.5" + "Swashbuckle.AspNetCore.SwaggerGen": "10.1.7" } }, "Swashbuckle.AspNetCore.SwaggerGen": { "type": "CentralTransitive", - "requested": "[10.1.5, )", - "resolved": "10.1.5", - "contentHash": "ysQIRgqnx4Vb/9+r3xnEAiaxYmiBHO8jTg7ACaCh+R3Sn+ZKCWKD6nyu0ph3okP91wFSh/6LgccjeLUaQHV+ZA==", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "PuubO9BjvNn6U3D9kLpuWKY1JtziWw7SsGBq0age1E50uQjQ8Fzl8s0EwzrLfANqYJNgDnJi9l7N1QxcGVB2Zw==", "dependencies": { - "Swashbuckle.AspNetCore.Swagger": "10.1.5" + "Swashbuckle.AspNetCore.Swagger": "10.1.7" } }, "System.IdentityModel.Tokens.Jwt": { diff --git a/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs b/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs index 647fcd3ef..982ebfa56 100644 --- a/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs +++ b/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs @@ -19,19 +19,22 @@ public sealed partial class RegisterCustomerCommandHandler( ILogger logger ) : ICommandHandler> { - [GeneratedRegex(@"[^a-zA-Z0-9._\-]")] + public const string TermsNotAcceptedError = "Você deve aceitar os termos de uso para se cadastrar."; + public const string PrivacyPolicyNotAcceptedError = "Você deve aceitar a política de privacidade para se cadastrar."; + + [GeneratedRegex(@"[^a-zA-Z0-9._\-]", RegexOptions.Compiled)] private static partial Regex SanitizationRegex(); public async Task> HandleAsync(RegisterCustomerCommand command, CancellationToken cancellationToken = default) { if (!command.TermsAccepted) { - return Result.Failure(Error.BadRequest("Você deve aceitar os termos de uso para se cadastrar.")); + return Result.Failure(Error.BadRequest(TermsNotAcceptedError)); } if (!command.AcceptedPrivacyPolicy) { - return Result.Failure(Error.BadRequest("Você deve aceitar a política de privacidade para se cadastrar.")); + return Result.Failure(Error.BadRequest(PrivacyPolicyNotAcceptedError)); } Email emailAsValueObject; diff --git a/src/Modules/Users/Infrastructure/Extensions.cs b/src/Modules/Users/Infrastructure/Extensions.cs index e1786d2b6..70f87d8f5 100644 --- a/src/Modules/Users/Infrastructure/Extensions.cs +++ b/src/Modules/Users/Infrastructure/Extensions.cs @@ -26,7 +26,7 @@ public static class Extensions /// A coleção de serviços configurada para encadeamento fluente. public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) { - services.AddPersistence(configuration); + services.AddPersistence(); services.AddKeycloak(configuration); services.AddDomainServices(configuration); services.AddEventHandlers(); @@ -55,7 +55,7 @@ private static bool ShouldUseMockKeycloakServices(IConfiguration configuration) return !keycloakEnabled || !hasValidKeycloakConfig; } - private static IServiceCollection AddPersistence(this IServiceCollection services, IConfiguration configuration) + private static IServiceCollection AddPersistence(this IServiceCollection services) { services.AddDbContext((serviceProvider, options) => { diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index 41b44c61e..b1804a43f 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -88,15 +88,7 @@ public void AddUsersModule_ShouldAddApplicationAndInfrastructureServices() Assert.True(services.Count > 0); } - [Fact(Skip = "Test removed - validates internal implementation detail that requires database connection")] - public void AddUsersModule_WithEmptyConfiguration_ShouldThrowInvalidOperationException() - { - // This test was removed because: - // 1. The exception is thrown inside a DbContext configuration callback - // 2. The callback only executes when DbContext is resolved from the provider - // 3. Resolving DbContext requires a database connection, making this test unreliable - // 4. The actual behavior is covered by integration tests - } + [Fact] public void AddUsersModule_ShouldReturnSameServiceCollectionInstance() diff --git a/src/Modules/Users/Tests/Unit/Application/Commands/RegisterCustomerCommandHandlerTests.cs b/src/Modules/Users/Tests/Unit/Application/Commands/RegisterCustomerCommandHandlerTests.cs new file mode 100644 index 000000000..dba74a6ff --- /dev/null +++ b/src/Modules/Users/Tests/Unit/Application/Commands/RegisterCustomerCommandHandlerTests.cs @@ -0,0 +1,256 @@ +using FluentAssertions; +using MeAjudaAi.Modules.Users.Application.Commands; +using MeAjudaAi.Modules.Users.Application.Handlers.Commands; +using MeAjudaAi.Modules.Users.Domain.Repositories; +using MeAjudaAi.Modules.Users.Domain.Services; +using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Modules.Users.Domain.Entities; +using MeAjudaAi.Modules.Users.Domain.ValueObjects; +using MeAjudaAi.Modules.Users.Application.Mappers; +using MeAjudaAi.Modules.Users.Application.DTOs; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace MeAjudaAi.Modules.Users.Tests.Unit.Application.Commands; + +public class RegisterCustomerCommandHandlerTests +{ + private readonly Mock _userDomainServiceMock; + private readonly Mock _userRepositoryMock; + private readonly Mock> _loggerMock; + private readonly RegisterCustomerCommandHandler _handler; + + public RegisterCustomerCommandHandlerTests() + { + _userDomainServiceMock = new Mock(); + _userRepositoryMock = new Mock(); + _loggerMock = new Mock>(); + _handler = new RegisterCustomerCommandHandler( + _userDomainServiceMock.Object, + _userRepositoryMock.Object, + _loggerMock.Object); + } + + [Fact] + public async Task HandleAsync_ShouldReturnSuccess_WhenFlowSucceeds() + { + // Arrange + var command = new RegisterCustomerCommand( + Name: "Test Customer", + Email: "customer@example.com", + Password: "Password123!", + PhoneNumber: "11988887777", + TermsAccepted: true, + AcceptedPrivacyPolicy: true + ); + + _userRepositoryMock.Setup(x => x.GetByEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((User?)null); + + var user = User.Create(new Username("test_user_slug"), new Email(command.Email), "Test", "Customer", Guid.NewGuid().ToString(), command.PhoneNumber).Value!; + _userDomainServiceMock.Setup(x => x.CreateUserAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(Result.Success(user)); + + _userRepositoryMock.Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + _userDomainServiceMock.Verify(x => x.CreateUserAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny()), Times.Once); + _userRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task HandleAsync_ShouldReturnFailure_WhenTermsNotAccepted() + { + // Arrange + var command = new RegisterCustomerCommand("John Doe", "email@test.com", "Password123!", "11999999999", false, true); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Message.Should().Be(RegisterCustomerCommandHandler.TermsNotAcceptedError); + } + + [Fact] + public async Task HandleAsync_ShouldReturnFailure_WhenPrivacyPolicyNotAccepted() + { + // Arrange + var command = new RegisterCustomerCommand("John Doe", "email@test.com", "Password123!", "11999999999", true, false); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Message.Should().Be(RegisterCustomerCommandHandler.PrivacyPolicyNotAcceptedError); + _userDomainServiceMock.Verify(x => x.CreateUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); + _userRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleAsync_ShouldReturnFailure_WhenEmailIsInvalid() + { + // Arrange + var command = new RegisterCustomerCommand("John Doe", "invalid-email", "Password123!", "11999999999", true, true); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.StatusCode.Should().Be(400); + } + + [Fact] + public async Task HandleAsync_ShouldReturnFailure_WhenEmailAlreadyInUse() + { + // Arrange + var command = new RegisterCustomerCommand("John Doe", "existing@test.com", "Password123!", "11999999999", true, true); + var existingUser = User.Create(new Username("existing"), new Email(command.Email), "Existing", "User", Guid.NewGuid().ToString(), null).Value!; + + _userRepositoryMock.Setup(x => x.GetByEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(existingUser); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.StatusCode.Should().Be(409); + result.Error.Message.Should().Contain("já está em uso"); + } + + [Fact] + public async Task HandleAsync_ShouldReturnFailure_WhenNameHasOnlyOnePart() + { + // Arrange + var command = new RegisterCustomerCommand("SoloName", "email@test.com", "Password123!", "11999999999", true, true); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Message.Should().Contain("sobrenome é obrigatório"); + } + + [Fact] + public async Task HandleAsync_ShouldReturnFailure_WhenFirstNameTooShort() + { + // Arrange + var command = new RegisterCustomerCommand("J Doe", "email@test.com", "Password123!", "11999999999", true, true); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Message.Should().Contain("primeiro nome deve ter pelo menos"); + } + + [Fact] + public async Task HandleAsync_ShouldReturnFailure_WhenLastNameTooShort() + { + // Arrange + var command = new RegisterCustomerCommand("John D", "email@test.com", "Password123!", "11999999999", true, true); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Message.Should().Contain("sobrenome deve ter pelo menos"); + } + + [Fact] + public async Task HandleAsync_ShouldReturnFailure_WhenCreateUserAsyncFails() + { + // Arrange + var command = new RegisterCustomerCommand("John Doe", "email@test.com", "Password123!", "11999999999", true, true); + var error = Error.Internal("Service failure"); + + _userDomainServiceMock.Setup(x => x.CreateUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Failure(error)); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(error); + } + + [Fact] + public async Task HandleAsync_ShouldReturnFailure_AndTriggerCompensation_WhenAddAsyncThrowsExceptionAndUserNotFound() + { + // Arrange + var command = new RegisterCustomerCommand("John Doe", "email@test.com", "Password123!", "11999999999", true, true); + var user = User.Create(new Username("test_user"), new Email(command.Email), "John", "Doe", Guid.NewGuid().ToString(), null).Value!; + + _userDomainServiceMock.Setup(x => x.CreateUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(user)); + + _userRepositoryMock.Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("DB Error")); + + _userRepositoryMock.Setup(x => x.GetByIdNoTrackingAsync(user.Id, It.IsAny())) + .ReturnsAsync((User?)null); + + _userDomainServiceMock.Setup(x => x.DeactivateUserInKeycloakAsync(user.Id, It.IsAny())) + .ReturnsAsync(Result.Success()); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + _userDomainServiceMock.Verify(x => x.DeactivateUserInKeycloakAsync(user.Id, It.IsAny()), Times.Once); + } + + [Fact] + public async Task HandleAsync_ShouldReturnFailure_AndNotTriggerCompensation_WhenAddAsyncThrowsExceptionAndUserFound() + { + // Arrange + var command = new RegisterCustomerCommand("John Doe", "email@test.com", "Password123!", "11999999999", true, true); + var user = User.Create(new Username("test_user"), new Email(command.Email), "John", "Doe", Guid.NewGuid().ToString(), null).Value!; + + _userDomainServiceMock.Setup(x => x.CreateUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(user)); + + _userRepositoryMock.Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("DB Error")); + + _userRepositoryMock.Setup(x => x.GetByIdNoTrackingAsync(user.Id, It.IsAny())) + .ReturnsAsync(user); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + _userDomainServiceMock.Verify(x => x.DeactivateUserInKeycloakAsync(user.Id, It.IsAny()), Times.Never); + } +} diff --git a/src/Modules/Users/Tests/Unit/Infrastructure/Mappers/DomainEventMapperExtensionsTests.cs b/src/Modules/Users/Tests/Unit/Infrastructure/Mappers/DomainEventMapperExtensionsTests.cs new file mode 100644 index 000000000..49d8f4c90 --- /dev/null +++ b/src/Modules/Users/Tests/Unit/Infrastructure/Mappers/DomainEventMapperExtensionsTests.cs @@ -0,0 +1,110 @@ +using FluentAssertions; +using MeAjudaAi.Modules.Users.Domain.Events; +using MeAjudaAi.Modules.Users.Infrastructure.Mappers; +using MeAjudaAi.Modules.Users.Domain.ValueObjects; +using Xunit; + +namespace MeAjudaAi.Modules.Users.Tests.Unit.Infrastructure.Mappers; + +[Trait("Category", "Unit")] +public class DomainEventMapperExtensionsTests +{ + [Fact] + public void ToIntegrationEvent_FromUserRegisteredDomainEvent_ShouldMapCorrectly() + { + // Arrange + var userId = Guid.NewGuid(); + var domainEvent = new UserRegisteredDomainEvent( + userId, 1, "john@example.com", new Username("johndoe"), "John", "Doe"); + + // Act + var result = domainEvent.ToIntegrationEvent(); + + // Assert + result.Source.Should().Be("Users"); + result.UserId.Should().Be(userId); + result.Username.Should().Be("johndoe"); + result.Email.Should().Be("john@example.com"); + result.FirstName.Should().Be("John"); + result.LastName.Should().Be("Doe"); + result.KeycloakId.Should().BeEmpty(); + result.Roles.Should().BeEmpty(); + result.RegisteredAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void ToIntegrationEvent_FromUserProfileUpdatedDomainEvent_ShouldMapCorrectly() + { + // Arrange + var userId = Guid.NewGuid(); + var domainEvent = new UserProfileUpdatedDomainEvent( + userId, 1, "John", "Smith"); + var email = "john@example.com"; + + // Act + var result = domainEvent.ToIntegrationEvent(email); + + // Assert + result.Source.Should().Be("Users"); + result.UserId.Should().Be(userId); + result.FirstName.Should().Be("John"); + result.LastName.Should().Be("Smith"); + result.Email.Should().Be(email); + result.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void ToIntegrationEvent_FromUserDeletedDomainEvent_ShouldMapCorrectly() + { + // Arrange + var userId = Guid.NewGuid(); + var domainEvent = new UserDeletedDomainEvent(userId, 1); + + // Act + var result = domainEvent.ToIntegrationEvent(); + + // Assert + result.Source.Should().Be("Users"); + result.UserId.Should().Be(userId); + result.DeletedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void ToIntegrationEvent_WhenUserRegisteredDomainEventIsNull_ShouldThrowArgumentNullException() + { + // Arrange + UserRegisteredDomainEvent domainEvent = null!; + + // Act + var act = () => domainEvent.ToIntegrationEvent(); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void ToIntegrationEvent_WhenUserProfileUpdatedDomainEventIsNull_ShouldThrowArgumentNullException() + { + // Arrange + UserProfileUpdatedDomainEvent domainEvent = null!; + + // Act + var act = () => domainEvent.ToIntegrationEvent("email@test.com"); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void ToIntegrationEvent_WhenEmailIsNullOrWhiteSpace_ShouldThrowArgumentException() + { + // Arrange + var domainEvent = new UserProfileUpdatedDomainEvent(Guid.NewGuid(), 1, "A", "B"); + + // Act + var act = () => domainEvent.ToIntegrationEvent(""); + + // Assert + act.Should().Throw(); + } +} diff --git a/src/Modules/Users/Tests/Unit/Infrastructure/Services/LocalDevelopment/LocalDevelopmentAuthenticationDomainServiceTests.cs b/src/Modules/Users/Tests/Unit/Infrastructure/Services/LocalDevelopment/LocalDevelopmentAuthenticationDomainServiceTests.cs new file mode 100644 index 000000000..d426bdfe4 --- /dev/null +++ b/src/Modules/Users/Tests/Unit/Infrastructure/Services/LocalDevelopment/LocalDevelopmentAuthenticationDomainServiceTests.cs @@ -0,0 +1,85 @@ +using FluentAssertions; +using MeAjudaAi.Modules.Users.Infrastructure.Services.LocalDevelopment; +using Xunit; + +namespace MeAjudaAi.Modules.Users.Tests.Unit.Infrastructure.Services.LocalDevelopment; + +[Trait("Category", "Unit")] +public class LocalDevelopmentAuthenticationDomainServiceTests +{ + private readonly LocalDevelopmentAuthenticationDomainService _service = new(); + + [Theory] + [InlineData("testuser", "testpassword")] + [InlineData("test@example.com", "testpassword")] + public async Task AuthenticateAsync_WithValidCredentials_ShouldReturnSuccess(string username, string password) + { + // Act + var result = await _service.AuthenticateAsync(username, password); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value.Roles.Should().Contain("customer"); + result.Value.AccessToken.Should().StartWith($"mock_token_{result.Value.UserId}"); + } + + [Theory] + [InlineData("testuser", "wrong")] + [InlineData("wrong", "testpassword")] + [InlineData("invalid", "invalid")] + public async Task AuthenticateAsync_WithInvalidCredentials_ShouldReturnFailure(string username, string password) + { + // Act + var result = await _service.AuthenticateAsync(username, password); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Error.Should().NotBeNull(); + result.Error!.Message.Should().Be("Invalid credentials"); + } + + [Fact] + public async Task ValidateTokenAsync_WithValidTokenFormat_ShouldExtractUserId() + { + // Arrange + var expectedId = Guid.NewGuid(); + var token = $"mock_token_{expectedId}_timestamp"; + + // Act + var result = await _service.ValidateTokenAsync(token); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.UserId.Should().Be(expectedId); + result.Value.Roles.Should().Contain("customer"); + result.Value.Claims["sub"].Should().Be(expectedId.ToString()); + } + + [Fact] + public async Task ValidateTokenAsync_WithValidPrefixButNoGuid_ShouldReturnFallbackUserId() + { + // Arrange + var token = "mock_token_invalid_format"; + + // Act + var result = await _service.ValidateTokenAsync(token); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.UserId.Should().NotBe(Guid.Empty); + } + + [Fact] + public async Task ValidateTokenAsync_WithInvalidPrefix_ShouldReturnFailure() + { + // Arrange + var token = "invalid_token_format"; + + // Act + var result = await _service.ValidateTokenAsync(token); + + // Assert + result.IsSuccess.Should().BeFalse(); + } +} diff --git a/src/Modules/Users/Tests/Unit/Infrastructure/Services/LocalDevelopment/LocalDevelopmentUserDomainServiceTests.cs b/src/Modules/Users/Tests/Unit/Infrastructure/Services/LocalDevelopment/LocalDevelopmentUserDomainServiceTests.cs new file mode 100644 index 000000000..2ca24c7a6 --- /dev/null +++ b/src/Modules/Users/Tests/Unit/Infrastructure/Services/LocalDevelopment/LocalDevelopmentUserDomainServiceTests.cs @@ -0,0 +1,60 @@ +using FluentAssertions; +using MeAjudaAi.Modules.Users.Domain.ValueObjects; +using MeAjudaAi.Modules.Users.Infrastructure.Services.LocalDevelopment; +using Xunit; + +namespace MeAjudaAi.Modules.Users.Tests.Unit.Infrastructure.Services.LocalDevelopment; + +[Trait("Category", "Unit")] +public class LocalDevelopmentUserDomainServiceTests +{ + private readonly LocalDevelopmentUserDomainService _service = new(); + + [Fact] + public async Task CreateUserAsync_ShouldReturnSuccessWithGeneratedKeycloakId() + { + // Arrange + var username = new Username("testuser"); + var email = new Email("test@example.com"); + var roles = new[] { "customer" }; + + // Act + var result = await _service.CreateUserAsync( + username, email, "Test", "User", "password", roles, "11999999999"); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value.Username.Should().Be(username); + result.Value.Email.Should().Be(email); + result.Value.FirstName.Should().Be("Test"); + result.Value.LastName.Should().Be("User"); + result.Value.KeycloakId.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task SyncUserWithKeycloakAsync_ShouldAlwaysReturnSuccess() + { + // Arrange + var userId = new UserId(Guid.NewGuid()); + + // Act + var result = await _service.SyncUserWithKeycloakAsync(userId); + + // Assert + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task DeactivateUserInKeycloakAsync_ShouldAlwaysReturnSuccess() + { + // Arrange + var userId = new UserId(Guid.NewGuid()); + + // Act + var result = await _service.DeactivateUserInKeycloakAsync(userId); + + // Assert + result.IsSuccess.Should().BeTrue(); + } +} diff --git a/src/Modules/Users/Tests/packages.lock.json b/src/Modules/Users/Tests/packages.lock.json index f2cf7d993..b36ada019 100644 --- a/src/Modules/Users/Tests/packages.lock.json +++ b/src/Modules/Users/Tests/packages.lock.json @@ -836,16 +836,16 @@ }, "Swashbuckle.AspNetCore.Swagger": { "type": "Transitive", - "resolved": "10.1.5", - "contentHash": "s4Mct6+Ob0LK9vYVaZcYi/RFFCOEJNjf6nJ5ZPoxtpdFSlzR6i9AHI7Vl44obX8cynRxJW7prA1IUabkiXolFg==", + "resolved": "10.1.7", + "contentHash": "EjLibt/d/QuRv170GoihTbcPUpgzSFm2WKHhnGJFZQ03JYzfuitsM79azaAR8NBwRunU7yScSX6HRE5JUlrEMQ==", "dependencies": { "Microsoft.OpenApi": "2.4.1" } }, "Swashbuckle.AspNetCore.SwaggerUI": { "type": "Transitive", - "resolved": "10.1.5", - "contentHash": "tQWVKNJWW7lf6S0bv22+7yfxK5IKzvsMeueF4XHSziBfREhLKt42OKzi6/1nINmyGlM4hGbR8aSMg72dLLVBLw==" + "resolved": "10.1.7", + "contentHash": "iJo3ODyUb/M8Vm8AH1r9y9iAba0w95xsCn3zFVl96ISRHbTDWxi+l7oFVCZqUEdjd97B8VMDPnMliWAdomR8uw==" }, "System.ClientModel": { "type": "Transitive", @@ -1021,8 +1021,8 @@ "Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.5, )", "Serilog.AspNetCore": "[10.0.0, )", "Serilog.Sinks.Seq": "[9.0.0, )", - "Swashbuckle.AspNetCore": "[10.1.5, )", - "Swashbuckle.AspNetCore.Annotations": "[10.1.5, )" + "Swashbuckle.AspNetCore": "[10.1.7, )", + "Swashbuckle.AspNetCore.Annotations": "[10.1.7, )" } }, "meajudaai.contracts": { @@ -2113,32 +2113,32 @@ }, "Swashbuckle.AspNetCore": { "type": "CentralTransitive", - "requested": "[10.1.5, )", - "resolved": "10.1.5", - "contentHash": "/eNk9z/8quXhDX14o3XLbwAX/84uIWSbiUD7cI/UrQnoBMOiyAtzKxNEJUtf/TyxjFpcXxE9FAfLvtbNpxHBSg==", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "vgef8DPT411JU5JjHiDbr0WOxsIVuAvegPGtqmm4Na4JRl/264dfBJcGkiPHsAr5P+Vda+qN1rZKRtBl1rF9aA==", "dependencies": { "Microsoft.Extensions.ApiDescription.Server": "10.0.0", - "Swashbuckle.AspNetCore.Swagger": "10.1.5", - "Swashbuckle.AspNetCore.SwaggerGen": "10.1.5", - "Swashbuckle.AspNetCore.SwaggerUI": "10.1.5" + "Swashbuckle.AspNetCore.Swagger": "10.1.7", + "Swashbuckle.AspNetCore.SwaggerGen": "10.1.7", + "Swashbuckle.AspNetCore.SwaggerUI": "10.1.7" } }, "Swashbuckle.AspNetCore.Annotations": { "type": "CentralTransitive", - "requested": "[10.1.5, )", - "resolved": "10.1.5", - "contentHash": "f+GGMzb7ugIBtHcbSF1QvSEWH3tavRnYduOxNhXcvdWJxiBsklGK9ueIFcjZpLC4dn/EMRky8YaN/AWfLibugA==", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "qHNQghKAzrjDEECIlyKEanmWxXDzrh6PyjGWVdql2qZfap0Ionuxam5OrUozibjbgQbVjRmgSXxWC8giIuCbGA==", "dependencies": { - "Swashbuckle.AspNetCore.SwaggerGen": "10.1.5" + "Swashbuckle.AspNetCore.SwaggerGen": "10.1.7" } }, "Swashbuckle.AspNetCore.SwaggerGen": { "type": "CentralTransitive", - "requested": "[10.1.5, )", - "resolved": "10.1.5", - "contentHash": "ysQIRgqnx4Vb/9+r3xnEAiaxYmiBHO8jTg7ACaCh+R3Sn+ZKCWKD6nyu0ph3okP91wFSh/6LgccjeLUaQHV+ZA==", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "PuubO9BjvNn6U3D9kLpuWKY1JtziWw7SsGBq0age1E50uQjQ8Fzl8s0EwzrLfANqYJNgDnJi9l7N1QxcGVB2Zw==", "dependencies": { - "Swashbuckle.AspNetCore.Swagger": "10.1.5" + "Swashbuckle.AspNetCore.Swagger": "10.1.7" } }, "System.IdentityModel.Tokens.Jwt": { diff --git a/src/Shared/Authorization/Keycloak/KeycloakPermissionOptions.cs b/src/Shared/Authorization/Keycloak/KeycloakPermissionOptions.cs index 65b88c06a..448a865e7 100644 --- a/src/Shared/Authorization/Keycloak/KeycloakPermissionOptions.cs +++ b/src/Shared/Authorization/Keycloak/KeycloakPermissionOptions.cs @@ -1,10 +1,12 @@ using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; namespace MeAjudaAi.Shared.Authorization.Keycloak; /// /// Opções de configuração para integração com Keycloak. /// +[ExcludeFromCodeCoverage] public sealed class KeycloakPermissionOptions { /// diff --git a/src/Shared/Authorization/Keycloak/KeycloakPermissionResolver.cs b/src/Shared/Authorization/Keycloak/KeycloakPermissionResolver.cs index 03915d01d..8a5a45867 100644 --- a/src/Shared/Authorization/Keycloak/KeycloakPermissionResolver.cs +++ b/src/Shared/Authorization/Keycloak/KeycloakPermissionResolver.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Net; using System.Net.Http.Headers; using System.Security.Cryptography; @@ -8,6 +9,7 @@ using MeAjudaAi.Shared.Authorization.ValueObjects; using MeAjudaAi.Shared.Utilities; using MeAjudaAi.Shared.Utilities.Constants; +using MeAjudaAi.Shared.Caching; using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -22,7 +24,7 @@ public sealed class KeycloakPermissionResolver : IKeycloakPermissionResolver { private readonly HttpClient _httpClient; private readonly KeycloakConfiguration _config; - private readonly HybridCache _cache; + private readonly ICacheService _cache; private readonly ILogger _logger; public string ModuleName => ModuleNames.Users; // Keycloak resolver é usado principalmente pelo módulo Users @@ -30,7 +32,7 @@ public sealed class KeycloakPermissionResolver : IKeycloakPermissionResolver public KeycloakPermissionResolver( HttpClient httpClient, IConfiguration configuration, - HybridCache cache, + ICacheService cache, ILogger logger) { ArgumentNullException.ThrowIfNull(configuration); @@ -54,15 +56,22 @@ public KeycloakPermissionResolver( } /// - /// Masks a user ID for logging purposes to avoid exposing PII. + /// Mascara um ID de usuário para fins de log, a fim de evitar a exposição de PII (Informações Pessoalmente Identificáveis). /// private static string MaskUserId(string userId) => PiiMaskingHelper.MaskUserId(userId); + /// + /// Opções de cache estáticas para o armazenamento de roles. + /// + private static readonly HybridCacheEntryOptions RoleCacheOptions = new() + { + Expiration = TimeSpan.FromMinutes(15), + LocalCacheExpiration = TimeSpan.FromMinutes(5) + }; + public async Task> ResolvePermissionsAsync(UserId userId, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(userId); - - // Converte UserId para string para compatibilidade com a implementação atual return await ResolvePermissionsAsync(userId.Value.ToString(), cancellationToken); } @@ -73,22 +82,13 @@ public async Task> ResolvePermissionsAsync(string use try { - // Cache key para roles do usuário (hashed to prevent PII in cache infrastructure) var cacheKey = $"keycloak_user_roles_{HashForCacheKey(userId)}"; - var cacheOptions = new HybridCacheEntryOptions - { - Expiration = TimeSpan.FromMinutes(15), // Cache roles por 15 minutos - LocalCacheExpiration = TimeSpan.FromMinutes(5) - }; - - // Busca roles do cache ou Keycloak var userRoles = await _cache.GetOrCreateAsync( cacheKey, async ValueTask> (ct) => await GetUserRolesFromKeycloakAsync(userId, ct), - cacheOptions, + options: RoleCacheOptions, cancellationToken: cancellationToken); - // Mapeia roles para permissões var permissions = new HashSet(); foreach (var role in userRoles) { @@ -113,11 +113,7 @@ async ValueTask> (ct) => await GetUserRolesFromKeycloakAsy } } - public bool CanResolve(EPermission permission) - { - // Este resolver pode processar qualquer permissão pois consulta diretamente o Keycloak - return true; - } + public bool CanResolve(EPermission permission) => true; /// /// Busca roles do usuário no Keycloak via Admin API. @@ -165,19 +161,22 @@ private async Task GetAdminTokenAsync(CancellationToken cancellationToke { var cacheKey = "keycloak_admin_token"; - return await _cache.GetOrCreateAsync( - cacheKey, - async ValueTask (ct) => - { - var tokenResponse = await RequestAdminTokenAsync(ct); - return tokenResponse.AccessToken; - }, - new HybridCacheEntryOptions - { - Expiration = TimeSpan.FromMinutes(4), // Conservador: token de 5min - margem de 1min - LocalCacheExpiration = TimeSpan.FromSeconds(120) - }, - cancellationToken: cancellationToken); + var (cachedToken, isCached) = await _cache.GetAsync(cacheKey, cancellationToken); + if (isCached && cachedToken != null) + { + return cachedToken.AccessToken; + } + + var tokenResponse = await RequestAdminTokenAsync(cancellationToken); + + var margin = TimeSpan.FromSeconds(60); + var expiration = tokenResponse.ExpiresIn > 60 + ? TimeSpan.FromSeconds(tokenResponse.ExpiresIn) - margin + : TimeSpan.FromSeconds(Math.Max(30, tokenResponse.ExpiresIn / 2)); + + await _cache.SetAsync(cacheKey, tokenResponse, expiration, cancellationToken: cancellationToken); + + return tokenResponse.AccessToken; } private async Task RequestAdminTokenAsync(CancellationToken cancellationToken) @@ -382,7 +381,7 @@ public IEnumerable MapKeycloakRoleToPermissions(string keycloakRole } /// - /// Hashes a string value for use in cache keys to prevent PII exposure. + /// Gera o hash de uma string para uso em chaves de cache, prevenindo a exposição de PII. /// private static string HashForCacheKey(string input) { @@ -395,6 +394,7 @@ private static string HashForCacheKey(string input) /// /// Configuração para integração com Keycloak. /// +[ExcludeFromCodeCoverage] public sealed class KeycloakConfiguration { public string BaseUrl { get; set; } = string.Empty; @@ -406,6 +406,7 @@ public sealed class KeycloakConfiguration /// /// Resposta do token do Keycloak. /// +[ExcludeFromCodeCoverage] internal sealed class TokenResponse { [JsonPropertyName("access_token")] @@ -421,6 +422,7 @@ internal sealed class TokenResponse /// /// Representação do usuário no Keycloak. /// +[ExcludeFromCodeCoverage] internal sealed class KeycloakUser { [JsonPropertyName("id")] @@ -439,6 +441,7 @@ internal sealed class KeycloakUser /// /// Representação do role no Keycloak. /// +[ExcludeFromCodeCoverage] internal sealed class KeycloakRole { [JsonPropertyName("id")] diff --git a/src/Shared/Authorization/Keycloak/packages.lock.json b/src/Shared/Authorization/Keycloak/packages.lock.json new file mode 100644 index 000000000..384ba4798 --- /dev/null +++ b/src/Shared/Authorization/Keycloak/packages.lock.json @@ -0,0 +1,25 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.DotNet.ILCompiler": { + "type": "Direct", + "requested": "[10.0.4, )", + "resolved": "10.0.4", + "contentHash": "tjoEJBbnn4duaUJ+akWmIe4jTsIN4zZt92wynr5UAWj+HgoQLQI0kV5twx0j1k5cL19WMQw/wT7T1h8pOLZYkg==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.4, )", + "resolved": "10.0.4", + "contentHash": "CCx8ojW3mOL150/LnP0DK7qpMrJEt6xxNCmJFKoX89v1h0FwpsEHqennowGPYDxp6zIkIO4f9PxynjOeLF+1zw==" + }, + "SonarAnalyzer.CSharp": { + "type": "Direct", + "requested": "[10.21.0.135717, )", + "resolved": "10.21.0.135717", + "contentHash": "i5awvO2aapfOLXq5v7uvCxLvx+p5H79RhGi2+gPMTDKpw4IBOX3Nw+1fuIcH4ZIzQ7+g5PYJi3fBYHFh0Iz+Fw==" + } + } + } +} \ No newline at end of file diff --git a/src/Shared/Caching/CacheMetrics.cs b/src/Shared/Caching/CacheMetrics.cs index 53db8b598..eaac0b19a 100644 --- a/src/Shared/Caching/CacheMetrics.cs +++ b/src/Shared/Caching/CacheMetrics.cs @@ -1,18 +1,62 @@ +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Metrics; +using System.Security.Cryptography; +using System.Text; namespace MeAjudaAi.Shared.Caching; /// -/// Métricas específicas para operações de cache. -/// Fornece instrumentação para monitoramento de performance de cache. +/// Interface para métricas específicas de operações de cache. /// -public sealed class CacheMetrics +public interface ICacheMetrics +{ + void RecordCacheHit(string key, string operation = "get"); + void RecordCacheMiss(string key, string operation = "get"); + void RecordOperationDuration(double durationSeconds, string operation, string result); + void RecordOperation(string key, string operation, bool isHit, double durationSeconds); +} + +/// +/// Implementação concreta das métricas de cache utilizando System.Diagnostics.Metrics. +/// +[ExcludeFromCodeCoverage] +public sealed class CacheMetrics : ICacheMetrics { private readonly Counter _cacheHits; private readonly Counter _cacheMisses; private readonly Counter _cacheOperations; private readonly Histogram _cacheOperationDuration; + private static string NormalizeCacheKey(string key) + { + if (string.IsNullOrEmpty(key)) + return "empty"; + + var parts = key.Split(':'); + if (parts.Length >= 2) + { + var type = parts[0]; + if (type.Equals("user", StringComparison.OrdinalIgnoreCase)) + return "user:{id}"; + if (type.Equals("provider", StringComparison.OrdinalIgnoreCase)) + return "provider:{id}"; + if (type.Equals("permission", StringComparison.OrdinalIgnoreCase)) + return "permission:{id}"; + if (type.Equals("role", StringComparison.OrdinalIgnoreCase)) + return "role:{id}"; + return $"{type}:{{id}}"; + } + + if (key.Length > 20) + { + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(key)); + var hash = Convert.ToHexString(hashBytes)[..8]; + return $"hash:{hash}"; + } + + return key; + } + public CacheMetrics(IMeterFactory meterFactory) { var meter = meterFactory.Create("MeAjudaAi.Cache"); @@ -35,31 +79,24 @@ public CacheMetrics(IMeterFactory meterFactory) description: "Duration of cache operations in seconds"); } - /// - /// Registra um cache hit - /// public void RecordCacheHit(string key, string operation = "get") { - _cacheHits.Add(1, new KeyValuePair("key", key), + var normalizedKey = NormalizeCacheKey(key); + _cacheHits.Add(1, new KeyValuePair("key", normalizedKey), new KeyValuePair("operation", operation)); _cacheOperations.Add(1, new KeyValuePair("result", "hit"), new KeyValuePair("operation", operation)); } - /// - /// Registra um cache miss - /// public void RecordCacheMiss(string key, string operation = "get") { - _cacheMisses.Add(1, new KeyValuePair("key", key), + var normalizedKey = NormalizeCacheKey(key); + _cacheMisses.Add(1, new KeyValuePair("key", normalizedKey), new KeyValuePair("operation", operation)); _cacheOperations.Add(1, new KeyValuePair("result", "miss"), new KeyValuePair("operation", operation)); } - /// - /// Registra a duração de uma operação de cache - /// public void RecordOperationDuration(double durationSeconds, string operation, string result) { _cacheOperationDuration.Record(durationSeconds, @@ -67,9 +104,6 @@ public void RecordOperationDuration(double durationSeconds, string operation, st new KeyValuePair("result", result)); } - /// - /// Registra uma operação de cache com todas as métricas - /// public void RecordOperation(string key, string operation, bool isHit, double durationSeconds) { if (isHit) diff --git a/src/Shared/Caching/CacheOptions.cs b/src/Shared/Caching/CacheOptions.cs new file mode 100644 index 000000000..7e2f10942 --- /dev/null +++ b/src/Shared/Caching/CacheOptions.cs @@ -0,0 +1,41 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Caching.Hybrid; + +namespace MeAjudaAi.Shared.Caching; + +[ExcludeFromCodeCoverage] +public sealed class CacheOptions +{ + public TimeSpan? Expiration { get; init; } + public TimeSpan? LocalCacheExpiration { get; init; } + + public HybridCacheEntryOptions ToHybridCacheEntryOptions() + { + if (Expiration == null && LocalCacheExpiration == null) + return new HybridCacheEntryOptions(); + + return new HybridCacheEntryOptions + { + Expiration = Expiration, + LocalCacheExpiration = LocalCacheExpiration + }; + } + + public static CacheOptions Default => new() + { + Expiration = TimeSpan.FromHours(1), + LocalCacheExpiration = TimeSpan.FromMinutes(5) + }; + + public static CacheOptions ShortTerm => new() + { + Expiration = TimeSpan.FromMinutes(5), + LocalCacheExpiration = TimeSpan.FromMinutes(1) + }; + + public static CacheOptions LongTerm => new() + { + Expiration = TimeSpan.FromHours(1), + LocalCacheExpiration = TimeSpan.FromMinutes(15) + }; +} diff --git a/src/Shared/Caching/CacheTags.cs b/src/Shared/Caching/CacheTags.cs index 676ef97c6..b7e7ed3a8 100644 --- a/src/Shared/Caching/CacheTags.cs +++ b/src/Shared/Caching/CacheTags.cs @@ -1,9 +1,12 @@ +using System.Diagnostics.CodeAnalysis; + namespace MeAjudaAi.Shared.Caching; /// /// Constantes para tags de cache utilizadas no sistema. /// Permite invalidação em grupo de entradas relacionadas. /// +[ExcludeFromCodeCoverage] public static class CacheTags { // Tags para o módulo Users diff --git a/src/Shared/Caching/CachingExtensions.cs b/src/Shared/Caching/CachingExtensions.cs index ef1490ccf..061150e09 100644 --- a/src/Shared/Caching/CachingExtensions.cs +++ b/src/Shared/Caching/CachingExtensions.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -7,11 +8,15 @@ namespace MeAjudaAi.Shared.Caching; /// /// Extension methods para configuração de Caching /// +[ExcludeFromCodeCoverage] public static class CachingExtensions { public static IServiceCollection AddCaching(this IServiceCollection services, IConfiguration configuration) { + // Registrar métricas (necessário para CacheMetrics) + services.AddMetrics(); + services.AddHybridCache(options => { options.MaximumPayloadBytes = 1024 * 1024; @@ -35,7 +40,7 @@ public static IServiceCollection AddCaching(this IServiceCollection services, }); // Registra métricas de cache - services.AddSingleton(); + services.AddSingleton(); // Registra serviços de cache services.AddSingleton(); diff --git a/src/Shared/Caching/HybridCacheService.cs b/src/Shared/Caching/HybridCacheService.cs index f9ecc2622..b1a99fb95 100644 --- a/src/Shared/Caching/HybridCacheService.cs +++ b/src/Shared/Caching/HybridCacheService.cs @@ -7,10 +7,11 @@ namespace MeAjudaAi.Shared.Caching; public class HybridCacheService( HybridCache hybridCache, ILogger logger, - CacheMetrics metrics, + ICacheMetrics? metrics, IConfiguration configuration) : ICacheService { private readonly bool _isCacheEnabled = configuration.GetValue("Cache:Enabled", true); + private readonly ICacheMetrics? _metrics = metrics; public async Task<(T? value, bool isCached)> GetAsync(string key, CancellationToken cancellationToken = default) { @@ -39,15 +40,21 @@ public class HybridCacheService( HybridCache hybridCache, var isCached = !factoryCalled; stopwatch.Stop(); - metrics.RecordOperation(key, "get", isCached, stopwatch.Elapsed.TotalSeconds); + _metrics?.RecordOperation(key, "get", isCached, stopwatch.Elapsed.TotalSeconds); // Retornar tupla: (valor, estava_em_cache) return isCached ? (result, true) : (default, false); } + catch (InvalidOperationException) + { + stopwatch.Stop(); + logger.LogDebug("Item not found in cache for key {Key} and valueFactory returned null", key); + return (default, false); + } catch (Exception ex) { stopwatch.Stop(); - metrics.RecordOperationDuration(stopwatch.Elapsed.TotalSeconds, "get", "error"); + _metrics?.RecordOperationDuration(stopwatch.Elapsed.TotalSeconds, "get", "error"); logger.LogWarning(ex, "Failed to get value from cache for key {Key}", key); return (default, false); } @@ -77,12 +84,12 @@ public async Task SetAsync( await hybridCache.SetAsync(key, value, options, tags, cancellationToken); stopwatch.Stop(); - metrics.RecordOperationDuration(stopwatch.Elapsed.TotalSeconds, "set", "success"); + _metrics?.RecordOperationDuration(stopwatch.Elapsed.TotalSeconds, "set", "success"); } catch (Exception ex) { stopwatch.Stop(); - metrics.RecordOperationDuration(stopwatch.Elapsed.TotalSeconds, "set", "error"); + _metrics?.RecordOperationDuration(stopwatch.Elapsed.TotalSeconds, "set", "error"); logger.LogWarning(ex, "Failed to set value in cache for key {Key}", key); } } @@ -184,16 +191,16 @@ public async Task GetOrCreateAsync( cancellationToken); stopwatch.Stop(); - metrics.RecordOperation(key, "get-or-create", !factoryCalled, stopwatch.Elapsed.TotalSeconds); + _metrics?.RecordOperation(key, "get-or-create", !factoryCalled, stopwatch.Elapsed.TotalSeconds); return result; } catch (Exception ex) { stopwatch.Stop(); - metrics.RecordOperationDuration(stopwatch.Elapsed.TotalSeconds, "get-or-create", "error"); + _metrics?.RecordOperationDuration(stopwatch.Elapsed.TotalSeconds, "get-or-create", "error"); logger.LogError(ex, "Failed to get or create cache value for key {Key}", key); - return await factory(cancellationToken); + return default!; } } diff --git a/src/Shared/Database/BaseDesignTimeDbContextFactory.cs b/src/Shared/Database/BaseDesignTimeDbContextFactory.cs index f981e95ff..5fcffbbb9 100644 --- a/src/Shared/Database/BaseDesignTimeDbContextFactory.cs +++ b/src/Shared/Database/BaseDesignTimeDbContextFactory.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Configuration; @@ -9,6 +10,7 @@ namespace MeAjudaAi.Shared.Database; /// Detecta automaticamente o nome do módulo a partir do namespace /// /// O tipo do DbContext +[ExcludeFromCodeCoverage] public abstract class BaseDesignTimeDbContextFactory : IDesignTimeDbContextFactory where TContext : DbContext { diff --git a/src/Shared/Database/DatabaseConstants.cs b/src/Shared/Database/DatabaseConstants.cs index a2328375d..42c7f0ad4 100644 --- a/src/Shared/Database/DatabaseConstants.cs +++ b/src/Shared/Database/DatabaseConstants.cs @@ -1,5 +1,8 @@ +using System.Diagnostics.CodeAnalysis; + namespace MeAjudaAi.Shared.Database; +[ExcludeFromCodeCoverage] public static class DatabaseConstants { /// diff --git a/src/Shared/Database/DatabaseExtensions.cs b/src/Shared/Database/DatabaseExtensions.cs index e76edcbf0..d98d8c720 100644 --- a/src/Shared/Database/DatabaseExtensions.cs +++ b/src/Shared/Database/DatabaseExtensions.cs @@ -51,6 +51,9 @@ public static IServiceCollection AddPostgres( }); } + // Registra PostgresOptions como singleton para injeção direta (ex: DapperConnection) + services.AddSingleton(sp => sp.GetRequiredService>().Value); + // Monitoramento essencial de banco de dados services.AddDatabaseMonitoring(); diff --git a/src/Shared/Database/PopstgresOptions.cs b/src/Shared/Database/PopstgresOptions.cs index 9b9c31705..47d647acb 100644 --- a/src/Shared/Database/PopstgresOptions.cs +++ b/src/Shared/Database/PopstgresOptions.cs @@ -1,5 +1,8 @@ +using System.Diagnostics.CodeAnalysis; + namespace MeAjudaAi.Shared.Database; +[ExcludeFromCodeCoverage] public sealed class PostgresOptions { public const string SectionName = "Postgres"; diff --git a/src/Shared/Domain/ValueObject.cs b/src/Shared/Domain/ValueObject.cs index 2fca4bc88..f29fa6a1f 100644 --- a/src/Shared/Domain/ValueObject.cs +++ b/src/Shared/Domain/ValueObject.cs @@ -2,7 +2,7 @@ namespace MeAjudaAi.Shared.Domain; public abstract class ValueObject { - protected abstract IEnumerable GetEqualityComponents(); + protected abstract IEnumerable GetEqualityComponents(); public override bool Equals(object? obj) { @@ -17,7 +17,7 @@ public override int GetHashCode() { return GetEqualityComponents() .Select(x => x?.GetHashCode() ?? 0) - .Aggregate((x, y) => x ^ y); + .Aggregate(0, (x, y) => x ^ y); } public static bool operator ==(ValueObject? left, ValueObject? right) diff --git a/src/Shared/Extensions/EnumExtensions.cs b/src/Shared/Extensions/EnumExtensions.cs index b858cfbb7..f88945fbe 100644 --- a/src/Shared/Extensions/EnumExtensions.cs +++ b/src/Shared/Extensions/EnumExtensions.cs @@ -3,61 +3,91 @@ namespace MeAjudaAi.Shared.Extensions; /// -/// Extensões para operações com Enum usando C# 14 Extension Members +/// Extensões para operações com Enum /// public static class EnumExtensions { - extension(string value) where TEnum : struct, Enum + /// + /// Converte string para enum com validação e retorna Result + /// + /// Tipo do enum + /// Valor em string + /// Se deve ignorar case (padrão: true) + /// Result com enum convertido ou erro + public static Result ToEnum(this string? value, bool ignoreCase = true) where TEnum : struct, Enum { - /// - /// Converte string para enum com validação e retorna Result - /// - /// Se deve ignorar case (padrão: true) - /// Result com enum convertido ou erro - public Result ToEnum(bool ignoreCase = true) + if (string.IsNullOrWhiteSpace(value)) { - if (string.IsNullOrWhiteSpace(value)) - { - return Result.Failure( - Error.BadRequest($"Value cannot be null or empty for enum {typeof(TEnum).Name}")); - } - - if (Enum.TryParse(value, ignoreCase, out var result) && Enum.IsDefined(typeof(TEnum), result)) - { - return Result.Success(result); - } - - var validValues = string.Join(", ", Enum.GetNames()); return Result.Failure( - Error.BadRequest($"Invalid {typeof(TEnum).Name}: '{value}'. Valid values are: {validValues}")); + Error.BadRequest($"O valor não pode ser nulo ou vazio para o enum {typeof(TEnum).Name}")); } - /// - /// Converte string para enum com valor padrão se conversão falhar - /// - /// Valor padrão se conversão falhar - /// Se deve ignorar case (padrão: true) - /// Enum convertido ou valor padrão - public TEnum ToEnumOrDefault(TEnum defaultValue, bool ignoreCase = true) + if (TryParseAndIsDefined(value, ignoreCase, out var result)) { - if (string.IsNullOrWhiteSpace(value)) - return defaultValue; - - return Enum.TryParse(value, ignoreCase, out var result) && Enum.IsDefined(typeof(TEnum), result) ? result : defaultValue; + return Result.Success(result); } - /// - /// Verifica se uma string é um valor válido para o enum - /// - /// Se deve ignorar case (padrão: true) - /// True se é um valor válido - public bool IsValidEnum(bool ignoreCase = true) + var validValues = string.Join(", ", Enum.GetNames()); + return Result.Failure( + Error.BadRequest($"Enum {typeof(TEnum).Name} inválido: '{value}'. Valores válidos: {validValues}")); + } + + /// + /// Converte string para enum com valor padrão se conversão falhar + /// + /// Tipo do enum + /// Valor em string + /// Valor padrão se conversão falhar + /// Se deve ignorar case (padrão: true) + /// Enum convertido ou valor padrão + public static TEnum ToEnumOrDefault(this string? value, TEnum defaultValue, bool ignoreCase = true) where TEnum : struct, Enum + { + if (string.IsNullOrWhiteSpace(value)) + return defaultValue; + + return TryParseAndIsDefined(value, ignoreCase, out var result) ? result : defaultValue; + } + + /// + /// Verifica se uma string é um valor válido para o enum + /// + /// Tipo do enum + /// Valor em string + /// Se deve ignorar case (padrão: true) + /// Verdadeiro se é um valor válido + public static bool IsValidEnum(this string? value, bool ignoreCase = true) where TEnum : struct, Enum + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + return TryParseAndIsDefined(value, ignoreCase, out _); + } + + private static bool TryParseAndIsDefined(string value, bool ignoreCase, out TEnum result) where TEnum : struct, Enum + { + var underlyingType = Enum.GetUnderlyingType(typeof(TEnum)); + var typeCode = Type.GetTypeCode(underlyingType); + + bool isNumeric = typeCode switch { - if (string.IsNullOrWhiteSpace(value)) - return false; + TypeCode.Byte => byte.TryParse(value, out _), + TypeCode.SByte => sbyte.TryParse(value, out _), + TypeCode.Int16 => short.TryParse(value, out _), + TypeCode.UInt16 => ushort.TryParse(value, out _), + TypeCode.Int32 => int.TryParse(value, out _), + TypeCode.UInt32 => uint.TryParse(value, out _), + TypeCode.Int64 => long.TryParse(value, out _), + TypeCode.UInt64 => ulong.TryParse(value, out _), + _ => false + }; - return Enum.TryParse(value, ignoreCase, out var result) && Enum.IsDefined(typeof(TEnum), result); + if (isNumeric) + { + result = default; + return false; } + + return Enum.TryParse(value, ignoreCase, out result) && Enum.IsDefined(typeof(TEnum), result); } /// @@ -78,6 +108,6 @@ public static string[] GetValidValues() where TEnum : struct, Enum public static string GetValidValuesDescription() where TEnum : struct, Enum { var values = GetValidValues(); - return $"Valid {typeof(TEnum).Name} values: {string.Join(", ", values)}"; + return $"Valores válidos para {typeof(TEnum).Name}: {string.Join(", ", values)}"; } } diff --git a/src/Shared/Extensions/SimpleHostEnvironment.cs b/src/Shared/Extensions/SimpleHostEnvironment.cs index bef288598..d1bd20c81 100644 --- a/src/Shared/Extensions/SimpleHostEnvironment.cs +++ b/src/Shared/Extensions/SimpleHostEnvironment.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; @@ -7,6 +8,7 @@ namespace MeAjudaAi.Shared.Extensions; /// Implementação simples interna de IHostEnvironment para registro de messaging. /// Para testes, use MockHostEnvironment de MeAjudaAi.Shared.Tests.TestInfrastructure.Mocks. /// +[ExcludeFromCodeCoverage] internal sealed class SimpleHostEnvironment : IHostEnvironment { public SimpleHostEnvironment(string environmentName) diff --git a/src/Shared/Logging/CorrelationIdEnricher.cs b/src/Shared/Logging/CorrelationIdEnricher.cs index 00201160d..32416564b 100644 --- a/src/Shared/Logging/CorrelationIdEnricher.cs +++ b/src/Shared/Logging/CorrelationIdEnricher.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using MeAjudaAi.Shared.Utilities.Constants; using MeAjudaAi.Shared.Utilities; using Microsoft.Extensions.DependencyInjection; @@ -11,6 +12,7 @@ namespace MeAjudaAi.Shared.Logging; /// /// Enricher do Serilog para adicionar Correlation ID aos logs /// +[ExcludeFromCodeCoverage] public class CorrelationIdEnricher : ILogEventEnricher { private const string CorrelationIdPropertyName = "CorrelationId"; diff --git a/src/Shared/Logging/LoggingContextMiddleware.cs b/src/Shared/Logging/LoggingContextMiddleware.cs index c40c75e79..31ea8850a 100644 --- a/src/Shared/Logging/LoggingContextMiddleware.cs +++ b/src/Shared/Logging/LoggingContextMiddleware.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using MeAjudaAi.Shared.Utilities.Constants; using MeAjudaAi.Shared.Utilities; using Microsoft.AspNetCore.Builder; @@ -11,6 +12,7 @@ namespace MeAjudaAi.Shared.Logging; /// /// Middleware para adicionar correlation ID e contexto enriquecido aos logs /// +[ExcludeFromCodeCoverage] internal class LoggingContextMiddleware(RequestDelegate next, ILogger logger) { public async Task InvokeAsync(HttpContext context) diff --git a/src/Shared/Logging/LoggingExtensions.cs b/src/Shared/Logging/LoggingExtensions.cs index b8d446cae..0b0550530 100644 --- a/src/Shared/Logging/LoggingExtensions.cs +++ b/src/Shared/Logging/LoggingExtensions.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; @@ -15,6 +16,7 @@ namespace MeAjudaAi.Shared.Logging; /// /// Extension methods consolidados para configuração de Logging /// +[ExcludeFromCodeCoverage] public static class LoggingExtensions { /// diff --git a/src/Shared/Logging/SerilogConfigurator.cs b/src/Shared/Logging/SerilogConfigurator.cs index 7c0632760..34cb97d93 100644 --- a/src/Shared/Logging/SerilogConfigurator.cs +++ b/src/Shared/Logging/SerilogConfigurator.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using MeAjudaAi.Shared.Authorization.Keycloak; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -12,6 +13,7 @@ namespace MeAjudaAi.Shared.Logging; /// /// Configurador híbrido do Serilog - combina appsettings.json com lógica C# /// +[ExcludeFromCodeCoverage] public static class SerilogConfigurator { /// diff --git a/src/Shared/MeAjudaAi.Shared.csproj b/src/Shared/MeAjudaAi.Shared.csproj index b1e51b4c9..b850576f9 100644 --- a/src/Shared/MeAjudaAi.Shared.csproj +++ b/src/Shared/MeAjudaAi.Shared.csproj @@ -5,6 +5,8 @@ enable enable false + All + true @@ -59,7 +61,6 @@ - @@ -72,6 +73,7 @@ + diff --git a/src/Shared/Messaging/EventTypeRegistry.cs b/src/Shared/Messaging/EventTypeRegistry.cs index a07d7a0bd..1aab9e2a0 100644 --- a/src/Shared/Messaging/EventTypeRegistry.cs +++ b/src/Shared/Messaging/EventTypeRegistry.cs @@ -10,29 +10,36 @@ public class EventTypeRegistry(ICacheService cache, ILogger l public async Task> GetAllEventTypesAsync(CancellationToken cancellationToken = default) { - var eventTypes = await cache.GetOrCreateAsync( + var typeNames = await cache.GetOrCreateAsync( CacheKey, async _ => await DiscoverEventTypesAsync(), expiration: TimeSpan.FromHours(1), tags: ["event-registry"], cancellationToken: cancellationToken); - return eventTypes.Values; + if (typeNames == null) + return Enumerable.Empty(); + + return typeNames.Select(n => Type.GetType(n.Value)).Where(t => t != null).Cast(); } public async Task GetEventTypeAsync(string eventName, CancellationToken cancellationToken = default) { - var eventTypes = await cache.GetOrCreateAsync( + var typeNames = await cache.GetOrCreateAsync( CacheKey, async _ => await DiscoverEventTypesAsync(), expiration: TimeSpan.FromHours(1), tags: ["event-registry"], cancellationToken: cancellationToken); - return eventTypes.GetValueOrDefault(eventName); + if (typeNames == null) + return null; + + var typeName = typeNames.GetValueOrDefault(eventName); + return typeName != null ? Type.GetType(typeName) : null; } - private ValueTask> DiscoverEventTypesAsync() + private ValueTask> DiscoverEventTypesAsync() { logger.LogInformation("Discovering event types..."); @@ -41,7 +48,7 @@ private ValueTask> DiscoverEventTypesAsync() .SelectMany(a => a.GetTypes()) .Where(t => typeof(IntegrationEvent).IsAssignableFrom(t) && !t.IsAbstract && t.IsPublic) - .ToDictionary(t => t.Name, t => t); + .ToDictionary(t => t.Name, t => t.AssemblyQualifiedName); logger.LogInformation("Discovered {Count} event types", eventTypes.Count); return ValueTask.FromResult(eventTypes); diff --git a/src/Shared/Messaging/Messages/Documents/DocumentVerifiedIntegrationEvent.cs b/src/Shared/Messaging/Messages/Documents/DocumentVerifiedIntegrationEvent.cs index e88b5de2a..df1024b83 100644 --- a/src/Shared/Messaging/Messages/Documents/DocumentVerifiedIntegrationEvent.cs +++ b/src/Shared/Messaging/Messages/Documents/DocumentVerifiedIntegrationEvent.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using MeAjudaAi.Shared.Events; namespace MeAjudaAi.Shared.Messaging.Messages.Documents; @@ -14,6 +15,7 @@ namespace MeAjudaAi.Shared.Messaging.Messages.Documents; /// - Enviar notificações de aprovação /// - Atualizar dashboards e métricas /// +[ExcludeFromCodeCoverage] public sealed record DocumentVerifiedIntegrationEvent( string Source, Guid DocumentId, diff --git a/src/Shared/Messaging/Messages/Providers/ProviderActivatedIntegrationEvent.cs b/src/Shared/Messaging/Messages/Providers/ProviderActivatedIntegrationEvent.cs index ca11e4504..f0f8ea18b 100644 --- a/src/Shared/Messaging/Messages/Providers/ProviderActivatedIntegrationEvent.cs +++ b/src/Shared/Messaging/Messages/Providers/ProviderActivatedIntegrationEvent.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using MeAjudaAi.Shared.Events; namespace MeAjudaAi.Shared.Messaging.Messages.Providers; @@ -14,6 +15,7 @@ namespace MeAjudaAi.Shared.Messaging.Messages.Providers; /// - Habilitar recursos específicos para prestadores ativos /// - Atualizar dashboards e métricas /// +[ExcludeFromCodeCoverage] public sealed record ProviderActivatedIntegrationEvent( string Source, Guid ProviderId, diff --git a/src/Shared/Messaging/Messages/Providers/ProviderAwaitingVerificationIntegrationEvent.cs b/src/Shared/Messaging/Messages/Providers/ProviderAwaitingVerificationIntegrationEvent.cs index 9e5680bdc..6c46ec889 100644 --- a/src/Shared/Messaging/Messages/Providers/ProviderAwaitingVerificationIntegrationEvent.cs +++ b/src/Shared/Messaging/Messages/Providers/ProviderAwaitingVerificationIntegrationEvent.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using MeAjudaAi.Shared.Events; namespace MeAjudaAi.Shared.Messaging.Messages.Providers; @@ -14,6 +15,7 @@ namespace MeAjudaAi.Shared.Messaging.Messages.Providers; /// - Preparar processos de verificação /// - Atualizar dashboards e métricas /// +[ExcludeFromCodeCoverage] public sealed record ProviderAwaitingVerificationIntegrationEvent( string Source, Guid ProviderId, diff --git a/src/Shared/Messaging/Messages/Providers/ProviderDeletedIntegrationEvent.cs b/src/Shared/Messaging/Messages/Providers/ProviderDeletedIntegrationEvent.cs index 8da830978..c3864160b 100644 --- a/src/Shared/Messaging/Messages/Providers/ProviderDeletedIntegrationEvent.cs +++ b/src/Shared/Messaging/Messages/Providers/ProviderDeletedIntegrationEvent.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using MeAjudaAi.Shared.Events; namespace MeAjudaAi.Shared.Messaging.Messages.Providers; @@ -13,6 +14,7 @@ namespace MeAjudaAi.Shared.Messaging.Messages.Providers; /// - Arquivar dados relacionados /// - Atualizar estatísticas /// +[ExcludeFromCodeCoverage] public sealed record ProviderDeletedIntegrationEvent( string Source, Guid ProviderId, diff --git a/src/Shared/Messaging/Messages/Providers/ProviderProfileUpdatedIntegrationEvent.cs b/src/Shared/Messaging/Messages/Providers/ProviderProfileUpdatedIntegrationEvent.cs index e869e2357..da690ec55 100644 --- a/src/Shared/Messaging/Messages/Providers/ProviderProfileUpdatedIntegrationEvent.cs +++ b/src/Shared/Messaging/Messages/Providers/ProviderProfileUpdatedIntegrationEvent.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using MeAjudaAi.Shared.Events; namespace MeAjudaAi.Shared.Messaging.Messages.Providers; @@ -13,6 +14,7 @@ namespace MeAjudaAi.Shared.Messaging.Messages.Providers; /// - Notificar sistemas externos /// - Manter auditoria de mudanças /// +[ExcludeFromCodeCoverage] public sealed record ProviderProfileUpdatedIntegrationEvent( string Source, Guid ProviderId, diff --git a/src/Shared/Messaging/Messages/Providers/ProviderRegisteredIntegrationEvent.cs b/src/Shared/Messaging/Messages/Providers/ProviderRegisteredIntegrationEvent.cs index 5af7e8c0e..fb7f1866e 100644 --- a/src/Shared/Messaging/Messages/Providers/ProviderRegisteredIntegrationEvent.cs +++ b/src/Shared/Messaging/Messages/Providers/ProviderRegisteredIntegrationEvent.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using MeAjudaAi.Shared.Events; namespace MeAjudaAi.Shared.Messaging.Messages.Providers; @@ -13,6 +14,7 @@ namespace MeAjudaAi.Shared.Messaging.Messages.Providers; /// - Sincronizar dados com sistemas externos /// - Atualizar estatísticas e métricas /// +[ExcludeFromCodeCoverage] public sealed record ProviderRegisteredIntegrationEvent( string Source, Guid ProviderId, diff --git a/src/Shared/Messaging/Messages/Providers/ProviderServicesUpdatedIntegrationEvent.cs b/src/Shared/Messaging/Messages/Providers/ProviderServicesUpdatedIntegrationEvent.cs index 93138595b..ee4470264 100644 --- a/src/Shared/Messaging/Messages/Providers/ProviderServicesUpdatedIntegrationEvent.cs +++ b/src/Shared/Messaging/Messages/Providers/ProviderServicesUpdatedIntegrationEvent.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using MeAjudaAi.Shared.Events; namespace MeAjudaAi.Shared.Messaging.Messages.Providers; @@ -8,6 +9,7 @@ namespace MeAjudaAi.Shared.Messaging.Messages.Providers; /// Módulo que originou o evento /// ID do prestador /// Lista de IDs de serviços do prestador (pode ser vazio se precisar buscar tudo) +[ExcludeFromCodeCoverage] public sealed record ProviderServicesUpdatedIntegrationEvent( string Source, Guid ProviderId, diff --git a/src/Shared/Messaging/Messages/Providers/ProviderVerificationStatusUpdatedIntegrationEvent.cs b/src/Shared/Messaging/Messages/Providers/ProviderVerificationStatusUpdatedIntegrationEvent.cs index 16d283649..2b6d90ac3 100644 --- a/src/Shared/Messaging/Messages/Providers/ProviderVerificationStatusUpdatedIntegrationEvent.cs +++ b/src/Shared/Messaging/Messages/Providers/ProviderVerificationStatusUpdatedIntegrationEvent.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using MeAjudaAi.Shared.Events; namespace MeAjudaAi.Shared.Messaging.Messages.Providers; @@ -13,6 +14,7 @@ namespace MeAjudaAi.Shared.Messaging.Messages.Providers; /// - Sincronizar com sistemas externos /// - Gerar relatórios de conformidade /// +[ExcludeFromCodeCoverage] public sealed record ProviderVerificationStatusUpdatedIntegrationEvent( string Source, Guid ProviderId, diff --git a/src/Shared/Messaging/Messages/Users/UserDeletedIntegrationEvent.cs b/src/Shared/Messaging/Messages/Users/UserDeletedIntegrationEvent.cs index a3f868b30..32af910b8 100644 --- a/src/Shared/Messaging/Messages/Users/UserDeletedIntegrationEvent.cs +++ b/src/Shared/Messaging/Messages/Users/UserDeletedIntegrationEvent.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using MeAjudaAi.Shared.Events; namespace MeAjudaAi.Shared.Messaging.Messages.Users; @@ -5,6 +6,7 @@ namespace MeAjudaAi.Shared.Messaging.Messages.Users; /// /// Publicado quando um usuário é excluído (soft delete) /// +[ExcludeFromCodeCoverage] public sealed record UserDeletedIntegrationEvent ( string Source, diff --git a/src/Shared/Messaging/Messages/Users/UserProfileUpdatedIntegrationEvent.cs b/src/Shared/Messaging/Messages/Users/UserProfileUpdatedIntegrationEvent.cs index 2917e235f..d97a711dc 100644 --- a/src/Shared/Messaging/Messages/Users/UserProfileUpdatedIntegrationEvent.cs +++ b/src/Shared/Messaging/Messages/Users/UserProfileUpdatedIntegrationEvent.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using MeAjudaAi.Shared.Events; namespace MeAjudaAi.Shared.Messaging.Messages.Users; @@ -5,6 +6,7 @@ namespace MeAjudaAi.Shared.Messaging.Messages.Users; /// /// Publicado quando um usuário atualiza suas informações de perfil /// +[ExcludeFromCodeCoverage] public sealed record UserProfileUpdatedIntegrationEvent( string Source, Guid UserId, diff --git a/src/Shared/Messaging/Messages/Users/UserRegisteredIntegrationEvent.cs b/src/Shared/Messaging/Messages/Users/UserRegisteredIntegrationEvent.cs index 82c8b9b30..da7964fc8 100644 --- a/src/Shared/Messaging/Messages/Users/UserRegisteredIntegrationEvent.cs +++ b/src/Shared/Messaging/Messages/Users/UserRegisteredIntegrationEvent.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using MeAjudaAi.Shared.Events; namespace MeAjudaAi.Shared.Messaging.Messages.Users; @@ -5,6 +6,7 @@ namespace MeAjudaAi.Shared.Messaging.Messages.Users; /// /// Publicado quando um novo usuário se registra no sistema /// +[ExcludeFromCodeCoverage] public sealed record UserRegisteredIntegrationEvent( string Source, Guid UserId, diff --git a/src/Shared/Messaging/MessagingExtensions.cs b/src/Shared/Messaging/MessagingExtensions.cs index 9bcb42927..7e61f2f87 100644 --- a/src/Shared/Messaging/MessagingExtensions.cs +++ b/src/Shared/Messaging/MessagingExtensions.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using MeAjudaAi.Shared.Utilities.Constants; using MeAjudaAi.Shared.Messaging.DeadLetter; using MeAjudaAi.Shared.Messaging.Factories; @@ -18,6 +19,7 @@ namespace MeAjudaAi.Shared.Messaging; /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S2094:Classes should not be empty", Justification = "Classe de categorização de logs - mantida para uso futuro")] +[ExcludeFromCodeCoverage] internal sealed class MessagingConfiguration { } diff --git a/src/Shared/Messaging/Options/DeadLetterOptions.cs b/src/Shared/Messaging/Options/DeadLetterOptions.cs index e73a82c5d..fa6ad0a1d 100644 --- a/src/Shared/Messaging/Options/DeadLetterOptions.cs +++ b/src/Shared/Messaging/Options/DeadLetterOptions.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + namespace MeAjudaAi.Shared.Messaging.Options; /// /// Opções de configuração para Dead Letter Queue (DLQ) /// +[ExcludeFromCodeCoverage] public sealed class DeadLetterOptions { public const string SectionName = "Messaging:DeadLetter"; @@ -84,6 +87,7 @@ public sealed class DeadLetterOptions /// /// Configurações específicas de DLQ para RabbitMQ /// +[ExcludeFromCodeCoverage] public sealed class RabbitMqDeadLetterOptions { /// diff --git a/src/Shared/Messaging/Options/MessageBusOptions.cs b/src/Shared/Messaging/Options/MessageBusOptions.cs index 6ac2abd0a..50602bdbb 100644 --- a/src/Shared/Messaging/Options/MessageBusOptions.cs +++ b/src/Shared/Messaging/Options/MessageBusOptions.cs @@ -1,7 +1,9 @@ +using System.Diagnostics.CodeAnalysis; using MeAjudaAi.Shared.Messaging.Strategy; namespace MeAjudaAi.Shared.Messaging.Options; +[ExcludeFromCodeCoverage] public sealed class MessageBusOptions { public TimeSpan DefaultTimeToLive { get; set; } = TimeSpan.FromDays(1); diff --git a/src/Shared/Messaging/Options/RabbitMqOptions.cs b/src/Shared/Messaging/Options/RabbitMqOptions.cs index 9989d768a..fa9645749 100644 --- a/src/Shared/Messaging/Options/RabbitMqOptions.cs +++ b/src/Shared/Messaging/Options/RabbitMqOptions.cs @@ -1,7 +1,9 @@ +using System.Diagnostics.CodeAnalysis; using MeAjudaAi.Shared.Messaging.Strategy; namespace MeAjudaAi.Shared.Messaging.Options; +[ExcludeFromCodeCoverage] public sealed class RabbitMqOptions { public const string SectionName = "Messaging:RabbitMQ"; diff --git a/src/Shared/Monitoring/BusinessMetrics.cs b/src/Shared/Monitoring/BusinessMetrics.cs index af23d4e38..1504eab7e 100644 --- a/src/Shared/Monitoring/BusinessMetrics.cs +++ b/src/Shared/Monitoring/BusinessMetrics.cs @@ -6,7 +6,7 @@ namespace MeAjudaAi.Shared.Monitoring; /// /// Métricas customizadas de negócio para MeAjudaAi /// -internal class BusinessMetrics : IDisposable +public class BusinessMetrics : IDisposable { private readonly Meter _meter; private readonly Counter _userRegistrations; diff --git a/src/Shared/Monitoring/BusinessMetricsMiddleware.cs b/src/Shared/Monitoring/BusinessMetricsMiddleware.cs index da55669a6..ef1f34d5f 100644 --- a/src/Shared/Monitoring/BusinessMetricsMiddleware.cs +++ b/src/Shared/Monitoring/BusinessMetricsMiddleware.cs @@ -10,7 +10,7 @@ namespace MeAjudaAi.Shared.Monitoring; /// /// Middleware para capturar métricas customizadas de negócio /// -internal class BusinessMetricsMiddleware( +public class BusinessMetricsMiddleware( RequestDelegate next, BusinessMetrics businessMetrics, ILogger logger) diff --git a/src/Shared/Monitoring/MetricsCollectorService.cs b/src/Shared/Monitoring/MetricsCollectorService.cs index bafac19e3..ed23f6ea4 100644 --- a/src/Shared/Monitoring/MetricsCollectorService.cs +++ b/src/Shared/Monitoring/MetricsCollectorService.cs @@ -7,13 +7,14 @@ namespace MeAjudaAi.Shared.Monitoring; /// /// Serviço em background para coletar métricas periódicas /// -internal class MetricsCollectorService( +public class MetricsCollectorService( BusinessMetrics businessMetrics, IServiceScopeFactory serviceScopeFactory, TimeProvider timeProvider, - ILogger logger) : BackgroundService + ILogger logger, + TimeSpan? interval = null) : BackgroundService { - private readonly TimeSpan _interval = TimeSpan.FromMinutes(1); // Coleta a cada minuto + private readonly TimeSpan _interval = interval ?? TimeSpan.FromMinutes(1); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { diff --git a/src/Shared/Utilities/Constants/ApiEndpoints.cs b/src/Shared/Utilities/Constants/ApiEndpoints.cs index 1d4b9b92f..796a95919 100644 --- a/src/Shared/Utilities/Constants/ApiEndpoints.cs +++ b/src/Shared/Utilities/Constants/ApiEndpoints.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace MeAjudaAi.Shared.Utilities.Constants; /// @@ -7,6 +9,7 @@ namespace MeAjudaAi.Shared.Utilities.Constants; /// Baseado nos endpoints realmente existentes no projeto. /// Mantém apenas o que está implementado para evitar confusão. /// +[ExcludeFromCodeCoverage] public static class ApiEndpoints { /// @@ -43,7 +46,7 @@ public static class Providers public const string GetByCity = "/by-city/{city}"; // GET GetProvidersByCityEndpoint public const string GetByState = "/by-state/{state}"; // GET GetProvidersByStateEndpoint public const string GetByType = "/by-type/{type}"; // GET GetProvidersByTypeEndpoint - public const string GetByVerificationStatus = "/by-verification-status/{status}"; // GET GetProvidersByVerificationStatusEndpoint + public const string GetByVerificationStatus = "/verification-status/{status}"; // GET GetProvidersByVerificationStatusEndpoint public const string UpdateProfile = "/{id:guid}/profile"; // PUT UpdateProviderProfileEndpoint public const string UpdateVerificationStatus = "/{id:guid}/verification-status"; // PUT UpdateVerificationStatusEndpoint public const string AddDocument = "/{id:guid}/documents"; // POST AddDocumentEndpoint diff --git a/src/Shared/Utilities/Constants/AuthConstants.cs b/src/Shared/Utilities/Constants/AuthConstants.cs index 57237e8e8..ad43f71d8 100644 --- a/src/Shared/Utilities/Constants/AuthConstants.cs +++ b/src/Shared/Utilities/Constants/AuthConstants.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace MeAjudaAi.Shared.Utilities.Constants; /// @@ -7,6 +9,7 @@ namespace MeAjudaAi.Shared.Utilities.Constants; /// Baseado nos valores realmente utilizados no projeto. /// Evita duplicação com UserRoles.cs existente. /// +[ExcludeFromCodeCoverage] public static class AuthConstants { /// diff --git a/src/Shared/Utilities/Constants/EnvironmentNames.cs b/src/Shared/Utilities/Constants/EnvironmentNames.cs index de20548e8..7693addbb 100644 --- a/src/Shared/Utilities/Constants/EnvironmentNames.cs +++ b/src/Shared/Utilities/Constants/EnvironmentNames.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + namespace MeAjudaAi.Shared.Utilities.Constants; /// /// Constantes para nomes de ambientes de execução para evitar strings hardcoded e typos /// +[ExcludeFromCodeCoverage] public static class EnvironmentNames { /// diff --git a/src/Shared/Utilities/Constants/FeatureFlags.cs b/src/Shared/Utilities/Constants/FeatureFlags.cs index 92a8fd434..9c5c00078 100644 --- a/src/Shared/Utilities/Constants/FeatureFlags.cs +++ b/src/Shared/Utilities/Constants/FeatureFlags.cs @@ -1,9 +1,12 @@ +using System.Diagnostics.CodeAnalysis; + namespace MeAjudaAi.Shared.Utilities.Constants; /// /// Constantes para nomes de feature flags. /// Usado com Microsoft.FeatureManagement para controle dinâmico de funcionalidades. /// +[ExcludeFromCodeCoverage] public static class FeatureFlags { /// diff --git a/src/Shared/Utilities/Constants/RateLimitPolicies.cs b/src/Shared/Utilities/Constants/RateLimitPolicies.cs index 453293bcb..24d83a40c 100644 --- a/src/Shared/Utilities/Constants/RateLimitPolicies.cs +++ b/src/Shared/Utilities/Constants/RateLimitPolicies.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + namespace MeAjudaAi.Shared.Utilities.Constants; /// /// Constantes para nomes de políticas de rate limiting /// +[ExcludeFromCodeCoverage] public static class RateLimitPolicies { /// diff --git a/src/Shared/Utilities/Constants/RoleConstants.cs b/src/Shared/Utilities/Constants/RoleConstants.cs index 5135ddc77..e8e6cc092 100644 --- a/src/Shared/Utilities/Constants/RoleConstants.cs +++ b/src/Shared/Utilities/Constants/RoleConstants.cs @@ -1,9 +1,12 @@ +using System.Diagnostics.CodeAnalysis; + namespace MeAjudaAi.Shared.Utilities.Constants; /// /// Constantes centralizadas para roles do Keycloak. /// Evita duplicação e garante consistência entre código e configuração do Keycloak. /// +[ExcludeFromCodeCoverage] public static class RoleConstants { // Roles de sistema/admin diff --git a/src/Shared/Utilities/Constants/ValidationConstants.cs b/src/Shared/Utilities/Constants/ValidationConstants.cs index ab146f3e3..26d5b37c2 100644 --- a/src/Shared/Utilities/Constants/ValidationConstants.cs +++ b/src/Shared/Utilities/Constants/ValidationConstants.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + #pragma warning disable S2068 // "password" detected here, make sure this is not a hard-coded credential namespace MeAjudaAi.Shared.Utilities.Constants; @@ -7,6 +9,7 @@ namespace MeAjudaAi.Shared.Utilities.Constants; /// /// Valores extraídos das migrations existentes para garantir consistência. /// +[ExcludeFromCodeCoverage] public static class ValidationConstants { /// diff --git a/src/Shared/Utilities/PhoneNumberValidator.cs b/src/Shared/Utilities/PhoneNumberValidator.cs index b34e8e075..2167241b6 100644 --- a/src/Shared/Utilities/PhoneNumberValidator.cs +++ b/src/Shared/Utilities/PhoneNumberValidator.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + namespace MeAjudaAi.Shared.Utilities; /// /// Utilitário para validação de números de telefone /// +[ExcludeFromCodeCoverage] public static class PhoneNumberValidator { /// @@ -20,7 +23,7 @@ public static bool IsValidInternationalFormat(string? phoneNumber) if (!phoneNumber.StartsWith('+')) return false; - var digitsOnly = phoneNumber[1..].Replace(" ", "").Replace("-", ""); + var digitsOnly = phoneNumber[1..].Replace(" ", "").Replace("-", "").Replace(".", ""); return digitsOnly.Length >= 8 && digitsOnly.Length <= 15 && digitsOnly.All(char.IsDigit); } } diff --git a/src/Shared/Utilities/PiiMaskingHelper.cs b/src/Shared/Utilities/PiiMaskingHelper.cs index d140fb81e..847a3d936 100644 --- a/src/Shared/Utilities/PiiMaskingHelper.cs +++ b/src/Shared/Utilities/PiiMaskingHelper.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + namespace MeAjudaAi.Shared.Utilities; /// /// Utilitário para mascarar informações sensíveis (PII) em logs. /// +[ExcludeFromCodeCoverage] public static class PiiMaskingHelper { /// diff --git a/src/Shared/Utilities/SlugHelper.cs b/src/Shared/Utilities/SlugHelper.cs index c4122bdc5..7ef71ee1f 100644 --- a/src/Shared/Utilities/SlugHelper.cs +++ b/src/Shared/Utilities/SlugHelper.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Text; using System.Text.RegularExpressions; @@ -7,6 +8,7 @@ namespace MeAjudaAi.Shared.Utilities; /// /// Helper para geração de slugs amigáveis para URL /// +[ExcludeFromCodeCoverage] public static partial class SlugHelper { [GeneratedRegex(@"[^a-z0-9\s-]")] diff --git a/src/Shared/Utilities/UuidGenerator.cs b/src/Shared/Utilities/UuidGenerator.cs index eb416eb05..63436f396 100644 --- a/src/Shared/Utilities/UuidGenerator.cs +++ b/src/Shared/Utilities/UuidGenerator.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; namespace MeAjudaAi.Shared.Utilities; @@ -5,6 +6,7 @@ namespace MeAjudaAi.Shared.Utilities; /// /// Gerador centralizado de identificadores únicos /// +[ExcludeFromCodeCoverage] public static class UuidGenerator { /// diff --git a/src/Web/MeAjudaAi.Web.Admin/__tests__/components/layout/sidebar.test.tsx b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/layout/sidebar.test.tsx new file mode 100644 index 000000000..55fe37e75 --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/layout/sidebar.test.tsx @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from 'test-support'; +import userEvent from '@testing-library/user-event'; +import type { ReactNode } from 'react'; + +const { mockSignOut } = vi.hoisted(() => ({ + mockSignOut: vi.fn(), +})); + +vi.mock('next-auth/react', () => ({ + useSession: () => ({ data: { user: { name: 'Carlos Admin', roles: ['admin'] } } }), + signOut: mockSignOut, +})); +vi.mock('next/navigation', () => ({ + usePathname: () => '/dashboard', +})); +vi.mock('next/link', () => ({ + default: ({ children, href }: { children: ReactNode; href: string }) => ( + {children} + ), +})); +vi.mock('@/components/ui/theme-toggle', () => ({ + ThemeToggle: () => , +})); +vi.mock('@/lib/types', () => ({ + APP_ROUTES: { + DASHBOARD: '/dashboard', + PROVIDERS: '/providers', + DOCUMENTS: '/documents', + CATEGORIES: '/categories', + SERVICES: '/services', + CITIES: '/allowed-cities', + SETTINGS: '/settings', + }, + APP_ROUTE_LABELS: { + DASHBOARD: 'Dashboard', + PROVIDERS: 'Prestadores', + DOCUMENTS: 'Documentos', + CATEGORIES: 'Categorias', + SERVICES: 'Serviços', + CITIES: 'Cidades', + SETTINGS: 'Configurações', + }, + ROLES: { ADMIN: 'admin', USER: 'user' }, +})); + +import { Sidebar } from '@/components/layout/sidebar'; + +describe('Sidebar', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('deve renderizar o logo MeAjudaAí', () => { + render(); + expect(screen.getByText('MeAjudaAí')).toBeInTheDocument(); + }); + + it('deve exibir itens de navegação', () => { + render(); + expect(screen.getByText('Dashboard')).toBeInTheDocument(); + expect(screen.getByText('Prestadores')).toBeInTheDocument(); + expect(screen.getByText('Categorias')).toBeInTheDocument(); + }); + + it('deve exibir nome do usuário da sessão', () => { + render(); + expect(screen.getByText('Carlos Admin')).toBeInTheDocument(); + }); + + it('deve exibir label de Administrador para role admin', () => { + render(); + expect(screen.getByText('Administrador')).toBeInTheDocument(); + }); + + it('deve ter botão de abrir menu mobile', () => { + render(); + expect(screen.getByLabelText('Open sidebar')).toBeInTheDocument(); + }); + + it('deve ter botão de sair', () => { + render(); + expect(screen.getByText('Sair')).toBeInTheDocument(); + }); + + it('deve chamar signOut ao clicar no botão de sair', async () => { + const user = userEvent.setup(); + render(); + const logoutButton = screen.getByText('Sair'); + await user.click(logoutButton); + expect(mockSignOut).toHaveBeenCalledWith({ callbackUrl: "/login" }); + }); +}); diff --git a/src/Web/MeAjudaAi.Web.Admin/__tests__/components/providers/app-providers.test.tsx b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/providers/app-providers.test.tsx new file mode 100644 index 000000000..7d5539360 --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/providers/app-providers.test.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from 'test-support'; +import { AppProviders } from '@/components/providers/app-providers'; + +vi.mock('next-auth/react', async (importOriginal) => { + const actual = await importOriginal(); + let capturedSession: unknown = null; + return { + ...actual, + SessionProvider: ({ session, children }: { session?: unknown; children: React.ReactNode }) => { + capturedSession = session; + return <>{children}; + }, + useSession: () => [capturedSession, false] as const, + }; +}); + +describe('AppProviders (Admin)', () => { + it('deve renderizar children corretamente', () => { + render( + +
Conteúdo de Teste
+
+ ); + + expect(screen.getByTestId('test-child')).toBeInTheDocument(); + expect(screen.getByText('Conteúdo de Teste')).toBeInTheDocument(); + }); + + it('deve renderizar com session inicial', async () => { + const mockSession = { + user: { name: 'Admin Test' }, + expires: new Date(Date.now() + 3600 * 1000).toISOString(), + }; + + render( + +
Authenticated Content
+
+ ); + + expect(screen.getByText('Authenticated Content')).toBeInTheDocument(); + const { useSession } = await import('next-auth/react'); + expect(useSession()[0]).toEqual(mockSession); + }); +}); diff --git a/src/Web/MeAjudaAi.Web.Admin/__tests__/components/providers/theme-provider.test.tsx b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/providers/theme-provider.test.tsx new file mode 100644 index 000000000..46db5a11d --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/providers/theme-provider.test.tsx @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, act } from 'test-support'; +import { ThemeProvider, useTheme } from '@/components/providers/theme-provider'; +import { useEffect } from 'react'; + +// Helper component to test useTheme hook +const ThemeTestComponent = () => { + const { theme, setTheme, toggleTheme } = useTheme(); + return ( +
+ {theme} + + + +
+ ); +}; + +describe('ThemeProvider (Admin)', () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.classList.remove('dark'); + vi.clearAllMocks(); + }); + + it('deve usar light como default se nada estiver no localStorage', () => { + render( + + + + ); + + // Initial render before useEffect might be light + // But once mounted, it should still be light if no preference + expect(screen.getByTestId('current-theme')).toHaveTextContent('light'); + }); + + it('deve carregar o tema do localStorage', async () => { + localStorage.setItem('theme', 'dark'); + + render( + + + + ); + + expect(screen.getByTestId('current-theme')).toHaveTextContent('dark'); + expect(document.documentElement.classList.contains('dark')).toBe(true); + }); + + it('deve alternar o tema quando toggleTheme é chamado', () => { + render( + + + + ); + + const toggleBtn = screen.getByTestId('toggle-theme'); + + act(() => { + toggleBtn.click(); + }); + + expect(screen.getByTestId('current-theme')).toHaveTextContent('dark'); + expect(localStorage.getItem('theme')).toBe('dark'); + expect(document.documentElement.classList.contains('dark')).toBe(true); + + act(() => { + toggleBtn.click(); + }); + + expect(screen.getByTestId('current-theme')).toHaveTextContent('light'); + expect(localStorage.getItem('theme')).toBe('light'); + expect(document.documentElement.classList.contains('dark')).toBe(false); + }); + + it('deve lançar erro se useTheme for usado fora do provider', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => render()).toThrow('useTheme must be used within a ThemeProvider'); + + consoleSpy.mockRestore(); + }); +}); diff --git a/src/Web/MeAjudaAi.Web.Admin/__tests__/components/providers/toast-provider.test.tsx b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/providers/toast-provider.test.tsx new file mode 100644 index 000000000..916f4164d --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/providers/toast-provider.test.tsx @@ -0,0 +1,16 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render } from 'test-support'; +import { Toaster } from '@/components/providers/toast-provider'; + +vi.mock('sonner', () => ({ + Toaster: (props: Record) =>
+})); + +describe('Toaster (Admin)', () => { + it('deve renderizar com a posição correta', () => { + const { getByTestId } = render(); + const toaster = getByTestId('sonner-toaster'); + expect(toaster).toBeInTheDocument(); + expect(toaster).toHaveAttribute('data-position', 'top-right'); + }); +}); diff --git a/src/Web/MeAjudaAi.Web.Admin/__tests__/components/ui/badge.test.tsx b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/ui/badge.test.tsx new file mode 100644 index 000000000..1e28456ae --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/ui/badge.test.tsx @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from 'test-support'; +import { Badge } from '@/components/ui/badge'; + +const badgeVariants = [ + ['default', 'bg-primary'], + ['secondary', 'bg-secondary'], + ['destructive', 'bg-destructive'], + ['success', 'bg-green-100'], + ['warning', 'bg-yellow-100'], +] as const; + +describe('Badge (Admin)', () => { + it('should render Badge component', () => { + render(Test Badge); + expect(screen.getByText('Test Badge')).toBeInTheDocument(); + }); + + test.each(badgeVariants)('should render %s variant with correct classes', (variant, expectedClass) => { + render(Test); + expect(screen.getByText('Test')).toHaveClass(expectedClass); + }); + + it('should apply custom className', () => { + render(Test); + expect(screen.getByText('Test')).toHaveClass('custom-class'); + }); +}); diff --git a/src/Web/MeAjudaAi.Web.Admin/__tests__/components/ui/button.test.tsx b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/ui/button.test.tsx new file mode 100644 index 000000000..c946ed182 --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/ui/button.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from 'test-support'; +import userEvent from '@testing-library/user-event'; +import { Button } from '@/components/ui/button'; + +describe('Button (Admin)', () => { + it('deve renderizar com texto', () => { + render(); + expect(screen.getByRole('button', { name: /clique/i })).toBeInTheDocument(); + }); + + it('deve estar desabilitado quando disabled=true', () => { + render(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('deve chamar onClick quando clicado', async () => { + const handleClick = vi.fn(); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('não deve chamar onClick quando desabilitado', async () => { + const handleClick = vi.fn(); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button')); + expect(handleClick).not.toHaveBeenCalled(); + }); + + it.each` + name | props | expectedClasses + ${'destructive'} | ${{ variant: 'destructive' }} | ${'bg-destructive'} + ${'ghost'} | ${{ variant: 'ghost' }} | ${'bg-transparent'} + ${'sm size'} | ${{ size: 'sm' }} | ${'h-9'} + `('deve renderizar $name com estilo correto', ({ props, expectedClasses }) => { + render(); + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('data-slot', 'button'); + expect(button).toHaveClass(expectedClasses); + }); + + it('deve aceitar props adicionais', () => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('id', 'test-button'); + }); +}); diff --git a/src/Web/MeAjudaAi.Web.Admin/__tests__/components/ui/card.test.tsx b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/ui/card.test.tsx new file mode 100644 index 000000000..1adf9779e --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/ui/card.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from 'test-support'; +import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/components/ui/card'; + +describe('Card (Admin)', () => { + it('should render Card component', () => { + render(Card Content); + expect(screen.getByText('Card Content')).toBeInTheDocument(); + }); + + it('should render all card sub-components', () => { + render( + + + Test Title + + Test Content + + ); + + expect(screen.getByText('Test Title')).toBeInTheDocument(); + expect(screen.getByText('Test Content')).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 3, name: 'Test Title' })).toBeInTheDocument(); + }); + + it('should apply custom className', () => { + render(Test); + expect(screen.getByText('Test')).toHaveClass('custom-class'); + }); + + it('should apply custom className to subcomponents', () => { + render( + + + Title + + Content + + ); + + expect(screen.getByTestId('card-header')).toHaveClass('header-class'); + expect(screen.getByText('Title')).toHaveClass('title-class'); + expect(screen.getByText('Content')).toHaveClass('content-class'); + }); + + it('should render CardDescription with correct styling', () => { + render( + + + Title + Description text + + + ); + + expect(screen.getByText('Description text')).toBeInTheDocument(); + expect(screen.getByText('Description text')).toHaveClass('text-sm', 'text-muted-foreground'); + }); +}); diff --git a/src/Web/MeAjudaAi.Web.Admin/__tests__/components/ui/dialog.test.tsx b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/ui/dialog.test.tsx new file mode 100644 index 000000000..a8a92c584 --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/ui/dialog.test.tsx @@ -0,0 +1,94 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from 'test-support'; +import userEvent from '@testing-library/user-event'; +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, DialogClose } from '@/components/ui/dialog'; + +describe('Dialog (Admin)', () => { + it('deve renderizar children quando open', () => { + render( + + Open + + + Título + Descrição + +
Conteúdo
+ + Cancelar + +
+
+ ); + expect(screen.getByText('Título')).toBeInTheDocument(); + expect(screen.getByText('Descrição')).toBeInTheDocument(); + expect(screen.getByText('Conteúdo')).toBeInTheDocument(); + }); + + it('deve renderizar trigger', () => { + render( + + Open Dialog + Content + + ); + expect(screen.getByRole('button', { name: /open dialog/i })).toBeInTheDocument(); + }); + + it('deve renderizar sem children quando closed', () => { + render( + + Open + Content + + ); + expect(screen.queryByText('Content')).not.toBeInTheDocument(); + }); + + it('DialogTrigger deve aceitar className customizado', () => { + render( + + Open + Content + + ); + const trigger = screen.getByRole('button'); + expect(trigger).toHaveClass('custom-trigger'); + }); + + it('DialogTrigger deve suportar asChild', () => { + render( + + + Content + + ); + expect(screen.getByRole('button', { name: /custom trigger/i })).toBeInTheDocument(); + }); + + it('deve chamar onOpenChange quando o trigger é clicado', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + render( + + Open + Content + + ); + + await user.click(screen.getByRole('button', { name: /open/i })); + expect(handleChange).toHaveBeenCalledWith(true); + }); + + it('deve renderizar botão default no DialogClose se children não for provido', async () => { + render( + + + + + + ); + + expect(screen.getByRole('button', { name: /cancelar/i })).toBeInTheDocument(); + }); +}); diff --git a/src/Web/MeAjudaAi.Web.Admin/__tests__/components/ui/input.test.tsx b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/ui/input.test.tsx new file mode 100644 index 000000000..e083700f2 --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/ui/input.test.tsx @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from 'test-support'; +import userEvent from '@testing-library/user-event'; +import { Input } from '@/components/ui/input'; + +describe('Input (Admin)', () => { + it('deve renderizar campo de input', () => { + render(); + expect(screen.getByPlaceholderText('Digite aqui')).toBeInTheDocument(); + }); + + it('deve aceitar digitação', async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByPlaceholderText('Digite'); + await user.type(input, 'teste'); + expect(input).toHaveValue('teste'); + }); + + it('deve estar desabilitado quando disabled=true', () => { + render(); + expect(screen.getByPlaceholderText('Desabilitado')).toBeDisabled(); + }); +}); diff --git a/src/Web/MeAjudaAi.Web.Admin/__tests__/components/ui/select.test.tsx b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/ui/select.test.tsx new file mode 100644 index 000000000..dfc152cdd --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/ui/select.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from 'test-support'; +import userEvent from '@testing-library/user-event'; +import { Select, SelectItem } from '@/components/ui/select'; + +describe('Select (Admin)', () => { + it('deve renderizar com placeholder', () => { + render( + + ); + expect(screen.getByText('Selecione')).toBeInTheDocument(); + }); + + it('deve aceitar value controlado', () => { + render( + + ); + expect(screen.getByText('Opção 1')).toBeInTheDocument(); + }); + + it('deve aceitar defaultValue não controlado', () => { + render( + + ); + expect(screen.getByText('Opção 1')).toBeInTheDocument(); + }); + + it('deve estar desabilitado quando disabled=true', () => { + render( + + ); + const trigger = screen.getByText('Selecione').closest('button'); + expect(trigger).toBeDisabled(); + }); + + it('deve aceitar className customizado no Select', () => { + render( + + ); + const trigger = screen.getByText('Selecione').closest('button'); + expect(trigger).toHaveClass('custom-select'); + }); + + it('deve chamar onValueChange ao escolher uma opção', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + const onValueChange = vi.fn(); + render( + + ); + + await user.click(screen.getByText('Selecione')); + const option2 = await screen.findByText('Opção 2'); + await user.click(option2); + + expect(onValueChange).toHaveBeenCalledWith('2'); + }); + + it('SelectItem deve aceitar className customizado', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + render( + + ); + + await user.click(screen.getByText('Selecione')); + const item = await screen.findByTestId('select-item'); + expect(item).toHaveClass('custom-item'); + }); +}); diff --git a/src/Web/MeAjudaAi.Web.Admin/__tests__/components/ui/theme-toggle.test.tsx b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/ui/theme-toggle.test.tsx new file mode 100644 index 000000000..3099aa6e5 --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Admin/__tests__/components/ui/theme-toggle.test.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen } from 'test-support'; +import userEvent from '@testing-library/user-event'; +import { ThemeToggle } from '@/components/ui/theme-toggle'; + +const { toggleTheme } = vi.hoisted(() => ({ + toggleTheme: vi.fn(), +})); + +vi.mock('@/components/providers/theme-provider', () => ({ + useTheme: () => ({ theme: 'light', toggleTheme }), +})); + +describe('ThemeToggle', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + it('deve renderizar botão de toggle de tema', () => { + render(); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('deve ter aria-label descritivo no modo light', () => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Switch to dark mode'); + }); + + it('deve ter aria-pressed=false no modo light', () => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'false'); + }); + + it('deve chamar toggleTheme ao clicar', async () => { + const user = userEvent.setup(); + render(); + const button = screen.getByRole('button'); + await user.click(button); + expect(toggleTheme).toHaveBeenCalled(); + }); +}); + diff --git a/src/Web/MeAjudaAi.Web.Admin/__tests__/hooks/admin/use-allowed-cities.test.ts b/src/Web/MeAjudaAi.Web.Admin/__tests__/hooks/admin/use-allowed-cities.test.ts new file mode 100644 index 000000000..4b0092918 --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Admin/__tests__/hooks/admin/use-allowed-cities.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from 'test-support'; +import { + useAllowedCities, + useAllowedCityById, + useCreateAllowedCity, + useUpdateAllowedCity, + usePatchAllowedCity, + useDeleteAllowedCity +} from '@/hooks/admin/use-allowed-cities'; +import * as api from '@/lib/api/generated'; + +vi.mock('@/lib/api/generated', () => ({ + apiAllowedCitiesGet: vi.fn(), + apiAllowedCitiesGet2: vi.fn(), + apiAllowedCitiesPost: vi.fn(), + apiAllowedCitiesPut: vi.fn(), + apiAllowedCitiesPatch: vi.fn(), + apiAllowedCitiesDelete: vi.fn(), +})); + +describe('useAllowedCities Hook (Admin)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('deve buscar todas as cidades permitidas', async () => { + vi.mocked(api.apiAllowedCitiesGet).mockResolvedValue({ data: [{ id: '1', cityName: 'City 1' }] }); + const { result } = renderHook(() => useAllowedCities()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBeDefined(); + expect(result.current.data).toHaveLength(1); + expect(api.apiAllowedCitiesGet).toHaveBeenCalled(); + }); + + it('deve buscar cidade por ID', async () => { + vi.mocked(api.apiAllowedCitiesGet2).mockResolvedValue({ data: { id: 'r-1', cityName: 'City Name' } }); + const { result } = renderHook(() => useAllowedCityById('r-1')); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBeDefined(); + expect(result.current.data?.cityName).toBe('City Name'); + expect(api.apiAllowedCitiesGet2).toHaveBeenCalledWith({ path: { id: 'r-1' } }); + }); + + it('deve criar uma cidade permitida', async () => { + vi.mocked(api.apiAllowedCitiesPost).mockResolvedValue({ data: { id: 'new' } }); + const { result } = renderHook(() => useCreateAllowedCity()); + + await result.current.mutateAsync({ cityName: 'New City', stateCode: 'RJ' }); + expect(api.apiAllowedCitiesPost).toHaveBeenCalledWith({ + body: { cityName: 'New City', stateCode: 'RJ' } + }); + }); + + it('deve atualizar uma cidade permitida (formato simplificado)', async () => { + vi.mocked(api.apiAllowedCitiesPut).mockResolvedValue({ data: { id: '1' } }); + const { result } = renderHook(() => useUpdateAllowedCity()); + + await result.current.mutateAsync({ id: '1', body: { cityName: 'Updated' } }); + expect(api.apiAllowedCitiesPut).toHaveBeenCalledWith({ + path: { id: '1' }, + body: { cityName: 'Updated' } + }); + }); + + it('deve atualizar uma cidade permitida (formato completo)', async () => { + vi.mocked(api.apiAllowedCitiesPut).mockResolvedValue({ data: { id: '1' } }); + const { result } = renderHook(() => useUpdateAllowedCity()); + + await result.current.mutateAsync({ path: { id: '1' }, body: { cityName: 'Updated Full' } }); + expect(api.apiAllowedCitiesPut).toHaveBeenCalledWith({ + path: { id: '1' }, + body: { cityName: 'Updated Full' } + }); + }); + + it('deve atualizar parcialmente uma cidade permitida', async () => { + vi.mocked(api.apiAllowedCitiesPatch).mockResolvedValue({ data: { id: '1' } }); + const { result } = renderHook(() => usePatchAllowedCity()); + + await result.current.mutateAsync({ id: '1', body: { isActive: false } }); + expect(api.apiAllowedCitiesPatch).toHaveBeenCalledWith({ + path: { id: '1' }, + body: { isActive: false } + }); + }); + + it('deve deletar uma cidade permitida', async () => { + vi.mocked(api.apiAllowedCitiesDelete).mockResolvedValue({ data: { success: true } }); + const { result } = renderHook(() => useDeleteAllowedCity()); + + await result.current.mutateAsync('r-1'); + expect(api.apiAllowedCitiesDelete).toHaveBeenCalledWith({ path: { id: 'r-1' } }); + }); + + it('deve mapear erros (error matrices) caso a API falhe', async () => { + vi.mocked(api.apiAllowedCitiesGet).mockRejectedValue(new Error('Matrix Failure')); + const { result } = renderHook(() => useAllowedCities()); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toBeDefined(); + expect(result.current.error?.message).toBe('Matrix Failure'); + }); +}); diff --git a/src/Web/MeAjudaAi.Web.Admin/__tests__/hooks/admin/use-categories.test.ts b/src/Web/MeAjudaAi.Web.Admin/__tests__/hooks/admin/use-categories.test.ts new file mode 100644 index 000000000..dadea49fe --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Admin/__tests__/hooks/admin/use-categories.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from 'test-support'; +import { + useCategories, + useCategoryById, + useCreateCategory, + useUpdateCategory, + useDeleteCategory, + useActivateCategory, + useDeactivateCategory +} from '@/hooks/admin/use-categories'; +import * as api from '@/lib/api/generated'; + +vi.mock('@/lib/api/generated', () => ({ + apiCategoriesGet: vi.fn(), + apiCategoriesGet2: vi.fn(), + apiCategoriesPost: vi.fn(), + apiCategoriesPut: vi.fn(), + apiCategoriesDelete: vi.fn(), + apiActivatePost2: vi.fn(), + apiDeactivatePost2: vi.fn(), +})); + +describe('useCategories Hook (Admin)', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('deve buscar todas as categorias', async () => { + vi.mocked(api.apiCategoriesGet).mockResolvedValue({ data: [{ id: '1', name: 'Cat 1' }] } as any); + const { result } = renderHook(() => useCategories()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(1); + expect(api.apiCategoriesGet).toHaveBeenCalled(); + }); + + it('deve lidar com resposta de categorias em formato de objeto (data payload)', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(api.apiCategoriesGet).mockResolvedValue({ data: { data: [{ id: '1', name: 'Cat 1' }] } } as any); + const { result } = renderHook(() => useCategories()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(1); + }); + + it('deve retornar array vazio para resposta inválida de categorias', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(api.apiCategoriesGet).mockResolvedValue(null as any); + const { result } = renderHook(() => useCategories()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([]); + }); + + it('deve buscar categoria por ID', async () => { + vi.mocked(api.apiCategoriesGet2).mockResolvedValue({ data: { id: 'c-1', name: 'Cat Name' } } as any); + const { result } = renderHook(() => useCategoryById('c-1')); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.name).toBe('Cat Name'); + expect(api.apiCategoriesGet2).toHaveBeenCalledWith({ path: { id: 'c-1' } }); + }); + + it('deve lidar com resposta de categoria por ID em formato de objeto (data payload)', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(api.apiCategoriesGet2).mockResolvedValue({ data: { data: { id: 'c-1', name: 'Payload Cat' } } } as any); + const { result } = renderHook(() => useCategoryById('c-1')); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.name).toBe('Payload Cat'); + }); + + it('deve criar uma categoria', async () => { + vi.mocked(api.apiCategoriesPost).mockResolvedValue({ data: { id: 'new' } } as any); + const { result } = renderHook(() => useCreateCategory()); + + await result.current.mutateAsync({ name: 'New Cat', description: 'Desc', displayOrder: 1 }); + expect(api.apiCategoriesPost).toHaveBeenCalledWith({ + body: { name: 'New Cat', description: 'Desc', displayOrder: 1 } + }); + }); + + it('deve atualizar uma categoria', async () => { + vi.mocked(api.apiCategoriesPut).mockResolvedValue({ data: { id: '1' } } as any); + const { result } = renderHook(() => useUpdateCategory()); + + await result.current.mutateAsync({ id: '1', name: 'Updated', description: 'New Desc' }); + expect(api.apiCategoriesPut).toHaveBeenCalledWith({ + path: { id: '1' }, + body: { name: 'Updated', description: 'New Desc' } + }); + }); + + it('deve atualizar uma categoria com displayOrder', async () => { + vi.mocked(api.apiCategoriesPut).mockResolvedValue({ data: { id: '1' } } as any); + const { result } = renderHook(() => useUpdateCategory()); + + await result.current.mutateAsync({ id: '1', name: 'Updated', displayOrder: 10 }); + expect(api.apiCategoriesPut).toHaveBeenCalledWith(expect.objectContaining({ + body: expect.objectContaining({ displayOrder: 10 }) + })); + }); + + it('deve deletar uma categoria', async () => { + vi.mocked(api.apiCategoriesDelete).mockResolvedValue({ data: { success: true } } as any); + const { result } = renderHook(() => useDeleteCategory()); + + await result.current.mutateAsync('c-1'); + expect(api.apiCategoriesDelete).toHaveBeenCalledWith({ path: { id: 'c-1' } }); + }); + + it('deve ativar uma categoria', async () => { + vi.mocked(api.apiActivatePost2).mockResolvedValue({ data: { success: true } } as any); + const { result } = renderHook(() => useActivateCategory()); + + await result.current.mutateAsync('c-1'); + expect(api.apiActivatePost2).toHaveBeenCalledWith({ path: { id: 'c-1' } }); + }); + + it('deve desativar uma categoria', async () => { + vi.mocked(api.apiDeactivatePost2).mockResolvedValue({ data: { success: true } } as any); + const { result } = renderHook(() => useDeactivateCategory()); + + await result.current.mutateAsync('c-1'); + expect(api.apiDeactivatePost2).toHaveBeenCalledWith({ path: { id: 'c-1' } }); + }); +}); diff --git a/src/Web/MeAjudaAi.Web.Admin/__tests__/hooks/admin/use-dashboard.test.ts b/src/Web/MeAjudaAi.Web.Admin/__tests__/hooks/admin/use-dashboard.test.ts new file mode 100644 index 000000000..6c7931a0b --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Admin/__tests__/hooks/admin/use-dashboard.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from 'test-support'; +import { useDashboardStats } from '@/hooks/admin/use-dashboard'; +import * as api from '@/lib/api/generated'; +import { EVerificationStatus, EProviderType } from '@/lib/types'; + +vi.mock('@/lib/api/generated', () => ({ + apiProvidersGet2: vi.fn(), +})); + +describe('useDashboardStats Hook (Admin)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('deve calcular estatísticas do dashboard corretamente', async () => { + const mockProviders = [ + { id: '1', verificationStatus: EVerificationStatus.Verified, type: EProviderType.Individual }, + { id: '2', verificationStatus: EVerificationStatus.Pending, type: EProviderType.Company }, + { id: '3', verificationStatus: EVerificationStatus.Rejected, type: EProviderType.Freelancer }, + { id: '4', verificationStatus: EVerificationStatus.InProgress, type: EProviderType.Cooperative }, + { id: '5', verificationStatus: EVerificationStatus.Suspended, type: EProviderType.Company }, + ]; + + vi.mocked(api.apiProvidersGet2).mockResolvedValue({ + data: { + value: { + items: mockProviders, + totalPages: 1 + } + } + } as any); + + const { result } = renderHook(() => useDashboardStats()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.total).toBe(5); + expect(result.current.data?.approved).toBe(1); + expect(result.current.data?.pending).toBe(1); + expect(result.current.data?.rejected).toBe(1); + expect(result.current.data?.underReview).toBe(1); + expect(result.current.data?.suspended).toBe(1); + + expect(result.current.data?.individual).toBe(1); + expect(result.current.data?.company).toBe(2); + expect(result.current.data?.freelancer).toBe(1); + expect(result.current.data?.cooperative).toBe(1); + + expect(api.apiProvidersGet2).toHaveBeenCalled(); + }); + + it('deve lidar com múltiplas páginas de provedores', async () => { + vi.mocked(api.apiProvidersGet2) + .mockResolvedValueOnce({ data: { items: [{ id: '1', verificationStatus: EVerificationStatus.Verified, type: EProviderType.Individual }], totalPages: 2 } } as any) + .mockResolvedValueOnce({ data: { items: [{ id: '2', verificationStatus: EVerificationStatus.Verified, type: EProviderType.Company }], totalPages: 2 } } as any); + + const { result } = renderHook(() => useDashboardStats()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.total).toBe(2); + expect(api.apiProvidersGet2).toHaveBeenCalledTimes(2); + expect(api.apiProvidersGet2).toHaveBeenNthCalledWith(1, expect.objectContaining({ query: expect.objectContaining({ pageNumber: 1, pageSize: 100 }) })); + expect(api.apiProvidersGet2).toHaveBeenNthCalledWith(2, expect.objectContaining({ query: expect.objectContaining({ pageNumber: 2, pageSize: 100 }) })); + }); + + it('deve lidar com falha na resposta da API', async () => { + vi.mocked(api.apiProvidersGet2).mockResolvedValue({ data: null } as any); + + const { result } = renderHook(() => useDashboardStats()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.total).toBe(0); + }); +}); diff --git a/src/Web/MeAjudaAi.Web.Admin/__tests__/hooks/admin/use-providers.test.ts b/src/Web/MeAjudaAi.Web.Admin/__tests__/hooks/admin/use-providers.test.ts new file mode 100644 index 000000000..ae26bfb65 --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Admin/__tests__/hooks/admin/use-providers.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from 'test-support'; +import { + useProviders, + useProviderById, + useProvidersByStatus, + useProvidersByType, + useCreateProvider, + useUpdateProvider, + useDeleteProvider, + useActivateProvider, + useDeactivateProvider +} from '@/hooks/admin/use-providers'; +import * as api from '@/lib/api/generated'; +import { EVerificationStatus, EProviderType } from '@/lib/types'; + +vi.mock('@/lib/api/generated', () => ({ + apiProvidersGet2: vi.fn(), + apiProvidersGet3: vi.fn(), + apiProvidersPost: vi.fn(), + apiProvidersPut: vi.fn(), + apiProvidersDelete: vi.fn(), + apiActivatePost: vi.fn(), + apiDeactivatePost: vi.fn(), +})); + +describe('useProviders Hook (Admin)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('deve buscar todos os provedores', async () => { + vi.mocked(api.apiProvidersGet2).mockResolvedValue({ data: [{ id: '1', name: 'Provider 1' }] }); + const { result } = renderHook(() => useProviders()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBeDefined(); + expect(result.current.data?.length).toBeGreaterThan(0); + expect(api.apiProvidersGet2).toHaveBeenCalled(); + }); + + it('deve buscar provedor por ID', async () => { + vi.mocked(api.apiProvidersGet3).mockResolvedValue({ data: { id: 'p-1', name: 'Provider Name' } }); + const { result } = renderHook(() => useProviderById('p-1')); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBeDefined(); + expect(result.current.data?.name).toBe('Provider Name'); + expect(api.apiProvidersGet3).toHaveBeenCalledWith({ path: { id: 'p-1' } }); + }); + + it('deve criar um provedor', async () => { + vi.mocked(api.apiProvidersPost).mockResolvedValue({ data: { id: 'new' } }); + const { result } = renderHook(() => useCreateProvider()); + + await result.current.mutateAsync({ name: 'New Provider' }); + expect(api.apiProvidersPost).toHaveBeenCalledWith({ body: { name: 'New Provider' } }); + }); + + it('deve atualizar um provedor', async () => { + vi.mocked(api.apiProvidersPut).mockResolvedValue({ data: { id: '1' } }); + const { result } = renderHook(() => useUpdateProvider()); + + await result.current.mutateAsync({ id: '1', data: { name: 'Updated' } }); + expect(api.apiProvidersPut).toHaveBeenCalledWith({ path: { id: '1' }, body: { name: 'Updated' } }); + }); + + it('deve deletar um provedor', async () => { + vi.mocked(api.apiProvidersDelete).mockResolvedValue({ data: { success: true } }); + const { result } = renderHook(() => useDeleteProvider()); + + await result.current.mutateAsync('p-1'); + expect(api.apiProvidersDelete).toHaveBeenCalledWith({ path: { id: 'p-1' } }); + }); + + it('deve ativar um provedor', async () => { + vi.mocked(api.apiActivatePost).mockResolvedValue({ data: { success: true } }); + const { result } = renderHook(() => useActivateProvider()); + + await result.current.mutateAsync('p-1'); + expect(api.apiActivatePost).toHaveBeenCalledWith({ path: { id: 'p-1' } }); + }); + + it('deve desativar um provedor', async () => { + vi.mocked(api.apiDeactivatePost).mockResolvedValue({ data: { success: true } }); + const { result } = renderHook(() => useDeactivateProvider()); + + await result.current.mutateAsync('p-1'); + expect(api.apiDeactivatePost).toHaveBeenCalledWith({ path: { id: 'p-1' } }); + }); + + it('deve retornar dados brutos se a propriedade .data estiver ausente', async () => { + const rawProvider = { id: 'raw-1' } as unknown as import('@/lib/types').ProviderDto; + vi.mocked(api.apiProvidersGet2).mockResolvedValue([rawProvider]); + const { result } = renderHook(() => useProviders()); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([{ id: 'raw-1' }]); + }); + + it('deve desabilitar useProviderById se ID estiver vazio', async () => { + renderHook(() => useProviderById('')); + await waitFor(() => expect(api.apiProvidersGet3).not.toHaveBeenCalled()); + }); + + it('deve desabilitar useProvidersByStatus se status estiver vazio', async () => { + renderHook(() => useProvidersByStatus('')); + await waitFor(() => expect(api.apiProvidersGet2).not.toHaveBeenCalled()); + }); + + it('deve desabilitar useProvidersByType se type estiver vazio', async () => { + renderHook(() => useProvidersByType('')); + await waitFor(() => expect(api.apiProvidersGet2).not.toHaveBeenCalled()); + }); + + it('deve buscar provedores por status quando status é fornecido', async () => { + vi.mocked(api.apiProvidersGet2).mockResolvedValue({ data: [{ id: '1' }] }); + const { result } = renderHook(() => useProvidersByStatus(EVerificationStatus.Verified)); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(api.apiProvidersGet2).toHaveBeenCalledWith(expect.objectContaining({ + query: { verificationStatus: EVerificationStatus.Verified } + })); + }); + + it('deve buscar provedores por tipo quando tipo é fornecido', async () => { + vi.mocked(api.apiProvidersGet2).mockResolvedValue({ data: [{ id: '1' }] }); + const { result } = renderHook(() => useProvidersByType(EProviderType.Individual)); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(api.apiProvidersGet2).toHaveBeenCalledWith(expect.objectContaining({ + query: { type: EProviderType.Individual } + })); + }); + + it('deve retornar dados brutos se a propriedade .data estiver ausente no useProviderById', async () => { + const rawProvider = { id: 'raw-1' } as unknown as import('@/lib/types').ProviderDto; + vi.mocked(api.apiProvidersGet3).mockResolvedValue(rawProvider); + const { result } = renderHook(() => useProviderById('raw-1')); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual({ id: 'raw-1' }); + }); + + it('deve retornar dados brutos se a propriedade .data estiver ausente no useProvidersByStatus', async () => { + vi.mocked(api.apiProvidersGet2).mockResolvedValue([{ id: 'raw-1' }] as any); + const { result } = renderHook(() => useProvidersByStatus(EVerificationStatus.Verified)); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([{ id: 'raw-1' }]); + }); + + it('deve retornar dados brutos se a propriedade .data estiver ausente no useProvidersByType', async () => { + vi.mocked(api.apiProvidersGet2).mockResolvedValue([{ id: 'raw-1' }] as any); + const { result } = renderHook(() => useProvidersByType(EProviderType.Individual)); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([{ id: 'raw-1' }]); + }); +}); diff --git a/src/Web/MeAjudaAi.Web.Admin/__tests__/hooks/admin/use-services.test.ts b/src/Web/MeAjudaAi.Web.Admin/__tests__/hooks/admin/use-services.test.ts new file mode 100644 index 000000000..36e1909aa --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Admin/__tests__/hooks/admin/use-services.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from 'test-support'; +import { useServices, useServiceById, useCreateService, useUpdateService, useDeleteService } from '@/hooks/admin/use-services'; +import * as api from '@/lib/api/generated'; + +vi.mock('@/lib/api/generated', () => ({ + apiServicesGet: vi.fn(), + apiServicesGet2: vi.fn(), + apiServicesPost: vi.fn(), + apiServicesPut: vi.fn(), + apiServicesDelete: vi.fn(), + apiCategoryGet: vi.fn(), +})); + +describe('useServices Hook (Admin)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('deve buscar todos os serviços quando categoryId não for fornecido', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(api.apiServicesGet).mockResolvedValue({ data: [{ id: '1', name: 'Service 1' }] } as any); + const { result } = renderHook(() => useServices()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(1); + expect(api.apiServicesGet).toHaveBeenCalled(); + }); + + it('deve buscar serviços por categoria quando categoryId for fornecido', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(api.apiCategoryGet).mockResolvedValue({ data: [{ id: '1', name: 'Cat Service' }] } as any); + const { result } = renderHook(() => useServices('cat-123')); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(1); + expect(api.apiCategoryGet).toHaveBeenCalledWith({ path: { categoryId: 'cat-123' } }); + }); + + it('deve buscar serviço por ID', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(api.apiServicesGet2).mockResolvedValue({ data: { id: 'svc-1', name: 'Service Name' } } as any); + const { result } = renderHook(() => useServiceById('svc-1')); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.name).toBe('Service Name'); + expect(api.apiServicesGet2).toHaveBeenCalledWith({ path: { id: 'svc-1' } }); + }); + + it('deve criar um serviço', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(api.apiServicesPost).mockResolvedValue({ data: { id: 'new' } } as any); + const { result } = renderHook(() => useCreateService()); + + await result.current.mutateAsync({ name: 'New Service' }); + expect(api.apiServicesPost).toHaveBeenCalledWith({ body: { name: 'New Service' } }); + }); + + it('deve atualizar um serviço', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(api.apiServicesPut).mockResolvedValue({ data: { id: '1' } } as any); + const { result } = renderHook(() => useUpdateService()); + + await result.current.mutateAsync({ id: '1', body: { name: 'Updated' } }); + expect(api.apiServicesPut).toHaveBeenCalledWith({ path: { id: '1' }, body: { name: 'Updated' } }); + }); + + it('deve deletar um serviço', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(api.apiServicesDelete).mockResolvedValue({ data: { success: true } } as any); + const { result } = renderHook(() => useDeleteService()); + + await result.current.mutateAsync('svc-1'); + expect(api.apiServicesDelete).toHaveBeenCalledWith({ path: { id: 'svc-1' } }); + }); + + it('deve retornar dados brutos se a propriedade .data estiver ausente no useServices', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(api.apiServicesGet).mockResolvedValue([{ id: 'raw-1' }] as any); + const { result } = renderHook(() => useServices()); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([{ id: 'raw-1' }]); + }); + + it('deve usar o formato alternativo no useCreateService', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(api.apiServicesPost).mockResolvedValue({ data: { id: 'new' } } as any); + const { result } = renderHook(() => useCreateService()); + await result.current.mutateAsync({ body: { name: 'Wrapped' } }); + expect(api.apiServicesPost).toHaveBeenCalledWith({ body: { name: 'Wrapped' } }); + }); + + it('deve usar o formato alternativo no useUpdateService', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(api.apiServicesPut).mockResolvedValue({ data: { id: '1' } } as any); + const { result } = renderHook(() => useUpdateService()); + await result.current.mutateAsync({ path: { id: '1' }, body: { name: 'Wrapped' } }); + expect(api.apiServicesPut).toHaveBeenCalledWith({ path: { id: '1' }, body: { name: 'Wrapped' } }); + }); + + it('deve suportar formato alternativo (objeto com path) no useDeleteService', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(api.apiServicesDelete).mockResolvedValue({ data: { success: true } } as any); + const { result } = renderHook(() => useDeleteService()); + + await result.current.mutateAsync({ path: { id: 'svc-2' } }); + expect(api.apiServicesDelete).toHaveBeenCalledWith({ path: { id: 'svc-2' } }); + }); +}); diff --git a/src/Web/MeAjudaAi.Web.Admin/__tests__/hooks/admin/use-users.test.ts b/src/Web/MeAjudaAi.Web.Admin/__tests__/hooks/admin/use-users.test.ts new file mode 100644 index 000000000..d06928489 --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Admin/__tests__/hooks/admin/use-users.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from 'test-support'; +import { useUsers, useUserById, useDeleteUser } from '@/hooks/admin/use-users'; +import * as api from '@/lib/api/generated'; + +vi.mock('@/lib/api/generated', () => ({ + apiUsersGet: vi.fn(), + apiUsersGet2: vi.fn(), + apiUsersDelete: vi.fn(), +})); + +describe('useUsers Hook (Admin)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('deve buscar todos os usuários', async () => { + vi.mocked(api.apiUsersGet).mockResolvedValue({ data: [{ id: '1', name: 'User 1' }] } as any); + const { result } = renderHook(() => useUsers()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(1); + expect(api.apiUsersGet).toHaveBeenCalled(); + }); + + it('deve buscar usuário por ID', async () => { + vi.mocked(api.apiUsersGet2).mockResolvedValue({ data: { id: 'u-1', name: 'User Name' } } as any); + const { result } = renderHook(() => useUserById('u-1')); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.name).toBe('User Name'); + expect(api.apiUsersGet2).toHaveBeenCalledWith({ path: { id: 'u-1' } }); + }); + + it('deve deletar um usuário', async () => { + vi.mocked(api.apiUsersDelete).mockResolvedValue({ success: true } as any); + const { result } = renderHook(() => useDeleteUser()); + + await result.current.mutateAsync('u-1'); + expect(api.apiUsersDelete).toHaveBeenCalledWith({ path: { id: 'u-1' } }); + }); +}); diff --git a/src/Web/MeAjudaAi.Web.Admin/__tests__/lib/utils.test.ts b/src/Web/MeAjudaAi.Web.Admin/__tests__/lib/utils.test.ts new file mode 100644 index 000000000..29cc0ef10 --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Admin/__tests__/lib/utils.test.ts @@ -0,0 +1,23 @@ +import { expect, test, describe } from "vitest"; +import { getVerificationBadgeVariant } from "../../lib/utils"; +import { EVerificationStatus } from "../../lib/types"; + +const statusVariantMap: [EVerificationStatus | undefined, string][] = [ + [EVerificationStatus.Verified, "success"], + [EVerificationStatus.InProgress, "warning"], + [EVerificationStatus.Suspended, "warning"], + [EVerificationStatus.Rejected, "destructive"], + [EVerificationStatus.None, "secondary"], + [EVerificationStatus.Pending, "secondary"], + [undefined, "secondary"], + [null, "secondary"], + [-1, "secondary"], + [999, "secondary"], + ["invalid" as any, "secondary"], +]; + +describe("getVerificationBadgeVariant", () => { + test.each(statusVariantMap)("returns %s for %p status", (status, expected) => { + expect(getVerificationBadgeVariant(status as any)).toBe(expected); + }); +}); diff --git a/src/Web/MeAjudaAi.Web.Admin/__tests__/mocks/handlers.ts b/src/Web/MeAjudaAi.Web.Admin/__tests__/mocks/handlers.ts new file mode 100644 index 000000000..833a5cf26 --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Admin/__tests__/mocks/handlers.ts @@ -0,0 +1,182 @@ +import { http, HttpResponse } from 'msw'; +import { EProviderType, EVerificationStatus, type VerificationStatus, type ProviderType } from '@/lib/types'; + +const mockProvider = { + id: 'provider-1', + name: 'Prestador Teste', + email: 'prestador@teste.com', + verificationStatus: EVerificationStatus.Pending as VerificationStatus, + type: EProviderType.Individual as ProviderType, +}; + +const mockCategory = { + id: 'category-1', + name: 'Elétrica', + description: 'Serviços elétricos', + displayOrder: 1, + isActive: true, +}; + +const mockCity = { + id: 'city-1', + cityName: 'Muriaé', + stateCode: 'MG', + isActive: true, +}; + +const mockUser = { + id: 'user-1', + name: 'Usuário Teste', + email: 'usuario@teste.com', +}; + +const providersMap = new Map(); +providersMap.set(mockProvider.id, { ...mockProvider }); + +const categoriesMap = new Map(); +categoriesMap.set(mockCategory.id, { ...mockCategory }); + +const citiesMap = new Map(); +citiesMap.set(mockCity.id, { ...mockCity }); + +const usersMap = new Map(); +usersMap.set(mockUser.id, { ...mockUser }); + +export function resetMockData() { + providersMap.clear(); + providersMap.set(mockProvider.id, { ...mockProvider }); + + categoriesMap.clear(); + categoriesMap.set(mockCategory.id, { ...mockCategory }); + + citiesMap.clear(); + citiesMap.set(mockCity.id, { ...mockCity }); + + usersMap.clear(); + usersMap.set(mockUser.id, { ...mockUser }); +} + +// Admin handlers should match the SDK paths (usually /api/v1/...) +export const handlers = [ + // Providers + http.get('/api/v1/providers', () => + HttpResponse.json({ data: { items: Array.from(providersMap.values()), totalPages: 1, totalItems: providersMap.size } }) + ), + http.get('/api/v1/providers/:id', ({ params }) => { + const provider = providersMap.get(params.id as string); + if (!provider) return HttpResponse.json({ error: 'Not Found' }, { status: 404 }); + return HttpResponse.json({ data: provider }); + }), + http.post('/api/v1/providers', async ({ request }) => { + try { + const body = await request.json() as Record; + const newProvider = { ...mockProvider, ...body, id: `provider-${providersMap.size + 1}` }; + providersMap.set(newProvider.id, newProvider); + return HttpResponse.json({ data: newProvider }, { status: 201 }); + } catch { + return HttpResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + }), + http.put('/api/v1/providers/:id', async ({ params, request }) => { + try { + const body = await request.json() as Record; + const existing = providersMap.get(params.id as string); + if (!existing) return HttpResponse.json({ error: 'Not Found' }, { status: 404 }); + const updated = { ...existing, ...body, id: params.id as string }; + providersMap.set(params.id as string, updated); + return HttpResponse.json({ data: updated }); + } catch { + return HttpResponse.json({ error: 'Bad Request', message: 'Invalid JSON' }, { status: 400 }); + } + }), + http.delete('/api/v1/providers/:id', ({ params }) => { + if (!providersMap.has(params.id as string)) return HttpResponse.json({ error: 'Not Found' }, { status: 404 }); + providersMap.delete(params.id as string); + return new HttpResponse(null, { status: 204 }); + }), + http.post('/api/v1/providers/:id/activate', ({ params }) => { + const provider = providersMap.get(params.id as string); + if (!provider) return HttpResponse.json({ error: 'Not Found' }, { status: 404 }); + const updated = { ...provider, verificationStatus: EVerificationStatus.Verified }; + providersMap.set(params.id as string, updated); + return HttpResponse.json({ data: updated }); + }), + http.post('/api/v1/providers/:id/deactivate', ({ params }) => { + const provider = providersMap.get(params.id as string); + if (!provider) return HttpResponse.json({ error: 'Not Found' }, { status: 404 }); + const updated = { ...provider, verificationStatus: EVerificationStatus.Suspended }; + providersMap.set(params.id as string, updated); + return HttpResponse.json({ data: updated }); + }), + + // Categories + http.get('/api/v1/service-catalogs/categories', () => HttpResponse.json({ data: Array.from(categoriesMap.values()) })), + http.get('/api/v1/service-catalogs/categories/:id', ({ params }) => { + const category = categoriesMap.get(params.id as string); + if (!category) return HttpResponse.json({ error: 'Not Found' }, { status: 404 }); + return HttpResponse.json({ data: category }); + }), + http.post('/api/v1/service-catalogs/categories', async ({ request }) => { + try { + const body = await request.json() as Record; + const newCategory = { ...mockCategory, ...body, id: `category-${categoriesMap.size + 1}` }; + categoriesMap.set(newCategory.id, newCategory); + return HttpResponse.json({ data: newCategory }, { status: 201 }); + } catch { + return HttpResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + }), + http.put('/api/v1/service-catalogs/categories/:id', async ({ params, request }) => { + try { + const category = categoriesMap.get(params.id as string); + if (!category) return HttpResponse.json({ error: 'Not Found' }, { status: 404 }); + const body = await request.json() as Record; + const updated = { ...category, ...body, id: params.id as string }; + categoriesMap.set(params.id as string, updated); + return HttpResponse.json({ data: updated }); + } catch { + return HttpResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + }), + http.delete('/api/v1/service-catalogs/categories/:id', ({ params }) => { + if (!categoriesMap.has(params.id as string)) return HttpResponse.json({ error: 'Not Found' }, { status: 404 }); + categoriesMap.delete(params.id as string); + return new HttpResponse(null, { status: 204 }); + }), + + // Allowed Cities + http.get('/api/v1/admin/allowed-cities', () => HttpResponse.json({ data: Array.from(citiesMap.values()) })), + http.get('/api/v1/admin/allowed-cities/:id', ({ params }) => { + const city = citiesMap.get(params.id as string); + if (!city) return HttpResponse.json({ error: 'Not Found' }, { status: 404 }); + return HttpResponse.json({ data: city }); + }), + http.post('/api/v1/admin/allowed-cities', async ({ request }) => { + try { + const body = await request.json() as Record; + const newCity = { ...mockCity, ...body, id: `city-${citiesMap.size + 1}` }; + citiesMap.set(newCity.id, newCity); + return HttpResponse.json({ data: newCity }, { status: 201 }); + } catch { + return HttpResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + }), + http.delete('/api/v1/admin/allowed-cities/:id', ({ params }) => { + if (!citiesMap.has(params.id as string)) return HttpResponse.json({ error: 'Not Found' }, { status: 404 }); + citiesMap.delete(params.id as string); + return new HttpResponse(null, { status: 204 }); + }), + + // Users + http.get('/api/v1/users', () => HttpResponse.json({ data: Array.from(usersMap.values()) })), + http.get('/api/v1/users/:id', ({ params }) => { + const user = usersMap.get(params.id as string); + if (!user) return HttpResponse.json({ error: 'Not Found' }, { status: 404 }); + return HttpResponse.json({ data: user }); + }), + http.delete('/api/v1/users/:id', ({ params }) => { + if (!usersMap.has(params.id as string)) return HttpResponse.json({ error: 'Not Found' }, { status: 404 }); + usersMap.delete(params.id as string); + return new HttpResponse(null, { status: 204 }); + }), +]; diff --git a/src/Web/MeAjudaAi.Web.Admin/__tests__/mocks/server.ts b/src/Web/MeAjudaAi.Web.Admin/__tests__/mocks/server.ts new file mode 100644 index 000000000..e52fee0a6 --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Admin/__tests__/mocks/server.ts @@ -0,0 +1,4 @@ +import { setupServer } from 'msw/node'; +import { handlers } from './handlers'; + +export const server = setupServer(...handlers); diff --git a/src/Web/MeAjudaAi.Web.Admin/__tests__/setup.ts b/src/Web/MeAjudaAi.Web.Admin/__tests__/setup.ts new file mode 100644 index 000000000..e5247c5a8 --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Admin/__tests__/setup.ts @@ -0,0 +1,50 @@ +import '@testing-library/jest-dom'; +import { cleanup } from '@testing-library/react'; +import { afterEach, vi } from 'vitest'; +import { resetMockData } from './mocks/handlers'; + +afterEach(() => { + cleanup(); + resetMockData(); +}); + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}); + +global.IntersectionObserver = class IntersectionObserver { + constructor() {} + disconnect() {} + observe() {} + takeRecords() { + return []; + } + unobserve() {} + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any; + +global.ResizeObserver = class ResizeObserver { + constructor() {} + disconnect() {} + observe() {} + unobserve() {} + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any; + +// Mock Pointer Events for Radix UI components (Select, Dialog, etc.) +if (typeof window !== 'undefined') { + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + window.HTMLElement.prototype.hasPointerCapture = vi.fn(() => false); + window.HTMLElement.prototype.releasePointerCapture = vi.fn(); + window.HTMLElement.prototype.setPointerCapture = vi.fn(); +} diff --git a/src/Web/MeAjudaAi.Web.Admin/app/(admin)/dashboard/page.tsx b/src/Web/MeAjudaAi.Web.Admin/app/(admin)/dashboard/page.tsx index a57d2cdf8..c6c06ded1 100644 --- a/src/Web/MeAjudaAi.Web.Admin/app/(admin)/dashboard/page.tsx +++ b/src/Web/MeAjudaAi.Web.Admin/app/(admin)/dashboard/page.tsx @@ -1,9 +1,16 @@ "use client"; -import { Users, Clock, CheckCircle, AlertCircle, TrendingUp, Loader2 } from "lucide-react"; +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { Users, Clock, CheckCircle, AlertCircle, TrendingUp, Loader2, RefreshCw } from "lucide-react"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; -import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts"; +import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip, LineChart, Line, XAxis, YAxis, CartesianGrid } from "recharts"; import { useDashboardStats } from "@/hooks/admin"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +const TypedTooltip = Tooltip as any; +const TypedLegend = Legend as any; const verificationColors = { approved: "#22c55e", @@ -21,7 +28,7 @@ const typeColors = { }; export default function DashboardPage() { - const { data: stats, isLoading, error } = useDashboardStats(); + const { data: stats, isLoading, error, refetch } = useDashboardStats(); const verificationData = [ { name: "Aprovados", value: stats?.approved ?? 0, color: verificationColors.approved }, @@ -38,12 +45,9 @@ export default function DashboardPage() { { name: "Cooperativa", value: stats?.cooperative ?? 0, color: typeColors.cooperative }, ].filter((d) => d.value > 0); - const approvedPercentage = stats?.total ? ((stats.approved / stats.total) * 100).toFixed(0) : 0; - const rejectedPercentage = stats?.total ? ((stats.rejected / stats.total) * 100).toFixed(0) : 0; - - if (isLoading) { + if (isLoading && !stats) { return ( -
+
); @@ -51,37 +55,55 @@ export default function DashboardPage() { if (error) { return ( -
- Erro ao carregar dados do dashboard. Tente novamente. +
+

Erro ao carregar dados do dashboard. Tente novamente.

+
); } return (
-
-

Dashboard

-

Visão geral dos prestadores e métricas

+
+
+

Dashboard

+

Visão geral dos prestadores e métricas

+
+ + {stats?.updatedAt && ( +
+ + Atualizado em {stats.updatedAt.toLocaleTimeString('pt-BR')} + + +
+ )}
-
- - - - Total de Prestadores - - - - -
{stats?.total ?? 0}
-

- - Atualizado agora -

-
-
- - +
+ + + + + Total de Prestadores + + + + +
{stats?.total ?? 0}
+

+ + Total de Prestadores +

+
+
+ + + Aguardando Verificação @@ -89,12 +111,12 @@ export default function DashboardPage() { -
{(stats?.pending ?? 0) + (stats?.underReview ?? 0)}
-

Revisão pendente

+
{(stats?.pending ?? 0) + (stats?.underReview ?? 0)}
+

Aguardando Verificação

- + Aprovados @@ -102,12 +124,12 @@ export default function DashboardPage() { -
{stats?.approved ?? 0}
-

{approvedPercentage}% do total

+
{stats?.approved ?? 0}
+

Aprovados

- + Rejeitados @@ -115,8 +137,32 @@ export default function DashboardPage() { -
{stats?.rejected ?? 0}
-

{rejectedPercentage}% do total

+
{stats?.rejected ?? 0}
+

Rejeitados

+
+
+
+ +
+ + + Prestadores ao longo do tempo + + +
+ + + + + + + + + +
@@ -124,10 +170,10 @@ export default function DashboardPage() {
- Status de Verificação + Status de Verificação -
+
`${name} ${((percent ?? 0) * 100).toFixed(0)}%`} + labelLine={false} > {verificationData.map((entry, index) => ( ))} - - + + {value}} + />
@@ -154,10 +203,10 @@ export default function DashboardPage() { - Prestadores por Tipo + Prestadores por Tipo -
+
`${name} ${((percent ?? 0) * 100).toFixed(0)}%`} + labelLine={false} > {typeData.map((entry, index) => ( ))} - + {value}} />
diff --git a/src/Web/MeAjudaAi.Web.Admin/app/layout.tsx b/src/Web/MeAjudaAi.Web.Admin/app/layout.tsx index e07c1a87e..b64437560 100644 --- a/src/Web/MeAjudaAi.Web.Admin/app/layout.tsx +++ b/src/Web/MeAjudaAi.Web.Admin/app/layout.tsx @@ -2,8 +2,7 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./global.css"; import { AppProviders } from "@/components/providers/app-providers"; -import { getServerSession } from "next-auth/next"; -import { authOptions } from "@/auth"; +import { auth } from "@/auth"; const inter = Inter({ subsets: ["latin"] }); @@ -18,7 +17,7 @@ export default async function RootLayout({ }: { children: React.ReactNode; }) { - const session = await getServerSession(authOptions); + const session = await auth(); return ( diff --git a/src/Web/MeAjudaAi.Web.Admin/auth.ts b/src/Web/MeAjudaAi.Web.Admin/auth.ts index 4a205e723..7b9a03f67 100644 --- a/src/Web/MeAjudaAi.Web.Admin/auth.ts +++ b/src/Web/MeAjudaAi.Web.Admin/auth.ts @@ -1,4 +1,4 @@ -import NextAuth, { type NextAuthOptions, type DefaultSession } from "next-auth"; +import NextAuth, { getServerSession, type NextAuthOptions, type DefaultSession } from "next-auth"; import Keycloak from "next-auth/providers/keycloak"; declare module "next-auth" { @@ -10,14 +10,74 @@ declare module "next-auth" { } } -const keycloakClientId = process.env.KEYCLOAK_ADMIN_CLIENT_ID; -const keycloakClientSecret = process.env.KEYCLOAK_ADMIN_CLIENT_SECRET; -const keycloakIssuer = process.env.KEYCLOAK_ISSUER; +const isCi = process.env.CI === "true" || process.env.NEXT_PUBLIC_CI === "true" || process.env.MOCK_AUTH === "true"; -if (!keycloakClientId || !keycloakClientSecret || !keycloakIssuer) { - throw new Error("Missing Keycloak environment variables: KEYCLOAK_ADMIN_CLIENT_ID, KEYCLOAK_ADMIN_CLIENT_SECRET, or KEYCLOAK_ISSUER"); +function getRequiredEnv(name: string): string { + const value = process.env[name]; + if (!value) { + if (isCi) { + console.warn(`[auth] Environment variable ${name} is missing - using placeholder for CI.`); + return "ci-placeholder"; + } + if (process.env.NODE_ENV === "development") { + throw new Error(`[auth] Environment variable ${name} is required but not set.`); + } + throw new Error(`[auth] Environment variable ${name} is required.`); + } + return value; +} + +const hasAdminVars = process.env.KEYCLOAK_ADMIN_CLIENT_ID && process.env.KEYCLOAK_ADMIN_CLIENT_SECRET; +const hasClientVars = process.env.KEYCLOAK_CLIENT_ID && process.env.KEYCLOAK_CLIENT_SECRET; + +const hasPartialAdminVars = (!!process.env.KEYCLOAK_ADMIN_CLIENT_ID) !== (!!process.env.KEYCLOAK_ADMIN_CLIENT_SECRET); +const hasPartialClientVars = (!!process.env.KEYCLOAK_CLIENT_ID) !== (!!process.env.KEYCLOAK_CLIENT_SECRET); + +if (hasPartialAdminVars && !isCi) { + throw new Error("Both KEYCLOAK_ADMIN_CLIENT_ID and KEYCLOAK_ADMIN_CLIENT_SECRET must be set or neither"); +} + +if (hasPartialClientVars && !isCi) { + throw new Error("Both KEYCLOAK_CLIENT_ID and KEYCLOAK_CLIENT_SECRET must be set or neither"); } +let keycloakClientId: string; +let keycloakClientSecret: string; +let keycloakIssuer: string; +let authMode: string; + +if (isCi) { + authMode = "CI_MOCK"; + keycloakClientId = process.env.KEYCLOAK_CLIENT_ID || "placeholder"; + keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET || "placeholder"; + keycloakIssuer = process.env.KEYCLOAK_ISSUER || "http://localhost:8080/realms/meajudaai"; +} else if (hasAdminVars) { + authMode = "ADMIN"; + keycloakClientId = process.env.KEYCLOAK_ADMIN_CLIENT_ID!; + keycloakClientSecret = process.env.KEYCLOAK_ADMIN_CLIENT_SECRET!; + keycloakIssuer = process.env.KEYCLOAK_ISSUER || getRequiredEnv("KEYCLOAK_ISSUER"); +} else if (hasClientVars) { + authMode = "CLIENT"; + keycloakClientId = process.env.KEYCLOAK_CLIENT_ID!; + keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET!; + keycloakIssuer = process.env.KEYCLOAK_ISSUER || getRequiredEnv("KEYCLOAK_ISSUER"); +} else { + authMode = "REQUIRED"; + keycloakClientId = getRequiredEnv("KEYCLOAK_CLIENT_ID"); + keycloakClientSecret = getRequiredEnv("KEYCLOAK_CLIENT_SECRET"); + keycloakIssuer = getRequiredEnv("KEYCLOAK_ISSUER"); +} + +let hostname = 'unknown'; +if (keycloakIssuer) { + try { + hostname = new URL(keycloakIssuer).hostname; + } catch { + throw new Error(`[auth] Invalid KEYCLOAK_ISSUER URL: ${keycloakIssuer}. Please provide a valid URL.`); + } +} +console.log(`[auth] Using KEYCLOAK credentials: ${authMode} (issuer: ${hostname})`); + export const authOptions: NextAuthOptions = { providers: [ Keycloak({ @@ -35,7 +95,7 @@ export const authOptions: NextAuthOptions = { maxAge: 30 * 60, }, callbacks: { - async jwt({ token, user, account, profile }) { + async jwt({ token, account, profile }) { if (account && profile) { const keycloakProfile = profile as { sub?: string; @@ -61,3 +121,10 @@ export const authOptions: NextAuthOptions = { const handler = NextAuth(authOptions); export { handler as GET, handler as POST }; + +/** + * Helper to get the server session in Server Components. + */ +export async function auth() { + return getServerSession(authOptions); +} diff --git a/src/Web/MeAjudaAi.Web.Admin/components/layout/sidebar.tsx b/src/Web/MeAjudaAi.Web.Admin/components/layout/sidebar.tsx index ee70bfa6b..5efe5ef3d 100644 --- a/src/Web/MeAjudaAi.Web.Admin/components/layout/sidebar.tsx +++ b/src/Web/MeAjudaAi.Web.Admin/components/layout/sidebar.tsx @@ -47,6 +47,7 @@ export function Sidebar() { className="fixed top-4 left-4 z-40 md:hidden flex items-center justify-center p-2 rounded-md bg-surface border border-border shadow-sm text-foreground" onClick={() => setIsOpen(true)} aria-label="Open sidebar" + data-testid="mobile-menu-toggle" > @@ -57,11 +58,15 @@ export function Sidebar() { className="fixed inset-0 z-40 bg-black/50 md:hidden" onClick={() => setIsOpen(false)} aria-hidden="true" + data-testid="mobile-menu-backdrop" /> )} -