diff --git a/.env.example b/.env.example index 9259888..b25863b 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,33 @@ # Skilluv Admin — environment variables -# Copy to `.env` and adjust for local dev. Do NOT commit `.env`. +# Copy to `.env` (gitignored) and adjust for your setup. -# Internal URL of the Skilluv backend (server-side only, used by -# hooks.server.ts for SSR calls). The browser side goes through the -# `/api` proxy declared in vite.config.ts, so this value only matters -# when running under `node build` (production adapter-node output). -API_URL=http://localhost:3001/api +# ─── SSR + dev proxy ───────────────────────────────────────────────── +# Internal URL of the Skilluv backend used server-side by hooks.server.ts +# for the /auth/me call. Always ends in /api. +# Deployed backend (default recommendation): +API_URL=https://api.skill-uv.com/api +# Local Rust backend (docker stack + cargo run): +# API_URL=http://localhost:3001/api -# --- Playwright e2e (optional) --- -# Postgres URL used by `e2e/admin-back-e2e.spec.ts` to seed a test -# admin directly in DB. Leave empty to skip the e2e suite. -# SKILLUV_PG_URL=postgres://skilluv:skilluv_secret@localhost:5433/skilluv +# Where the vite dev server proxies /api/*. Must be the same host as +# API_URL minus the /api suffix. +VITE_API_PROXY_TARGET=https://api.skill-uv.com +# VITE_API_PROXY_TARGET=http://localhost:3001 -# Backend URL used by the same e2e suite for the pre-flight health -# check. Skipped if unreachable. -# SKILLUV_BACKEND=http://localhost:3001 +# ─── Analytics validation (P26 v2, SKI-100 section 5) ──────────────── +# Dashboard Grafana qui trace les compteurs Prometheus du workflow +# challenge (ingestion, refresh externe, webhook CI, bonus merge). La page +# /validation-analytics affiche un lien vers cette URL quand elle est +# renseignée, et un rappel de configuration sinon. +# PUBLIC_GRAFANA_URL=https://grafana.skill-uv.com/d/skilluv-challenges + +# ─── Playwright E2E ────────────────────────────────────────────────── +# When set, e2e/setup/bootstrap-admin.mjs and the admin-project specs +# talk to this backend + Postgres directly. Leave empty to run only the +# `public` Playwright project (no backend needed). +BACKEND_URL=https://api.skill-uv.com +# DATABASE_URL is required by the `admin` project: bootstrap-admin.mjs +# grants the admin role in SQL, and 14 of 17 admin specs seed fixtures via +# e2e/setup/db.ts. Point it at the Postgres backing whichever BACKEND_URL +# you chose — the local docker stack exposes it on 5433. +# DATABASE_URL=postgres://skilluv:CHANGE_ME@localhost:5433/skilluv diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3373711..ef2926a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: run: npm run build e2e: - name: Playwright smoke tests + name: Playwright public smoke runs-on: ubuntu-latest needs: check timeout-minutes: 15 @@ -54,13 +54,164 @@ jobs: - name: Build run: npm run build - - name: Run smoke tests - run: npm run test:e2e + - name: Run public smoke tests + run: npx playwright test --project=public - name: Upload Playwright report if: failure() uses: actions/upload-artifact@v7 with: - name: playwright-report + name: playwright-public-report + path: playwright-report/ + retention-days: 7 + + e2e-admin: + # Runs the authenticated `admin` Playwright project against a real backend + # pulled from GHCR. Requires the backend team's publish workflow to be + # merged first (see skilluv-backend PR #33). This job will stay red on + # PRs until that image is published — that's intentional; we don't skip + # tests when a dependency isn't ready, we surface the gap. + name: Playwright admin flows (needs backend image) + runs-on: ubuntu-latest + needs: check + timeout-minutes: 20 + + services: + postgres: + image: postgres:18.4-alpine + env: + POSTGRES_USER: skilluv + POSTGRES_PASSWORD: skilluv_secret + POSTGRES_DB: skilluv + # postgres 18+ warns when data lives at /var/lib/postgresql/data + # (the legacy mount path). Force a subdir to silence the check. + PGDATA: /var/lib/postgresql/data/pgdata + ports: + - 5433:5432 + options: >- + --health-cmd "pg_isready -U skilluv" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + redis: + image: redis:8.8-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 5 + + mailpit: + image: axllent/mailpit:latest + ports: + - 1025:1025 + - 8025:8025 + options: >- + --health-cmd "wget -q --spider http://localhost:8025 || exit 1" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + steps: + - uses: actions/checkout@v7 + + # MinIO can't be a GHA service (the image needs a `server /data` arg; + # services don't support commands). Docker-run it on host network so + # the backend (also host-net) reaches it via localhost:9000. + - name: Start MinIO + run: | + docker run -d --name minio --network host \ + -e MINIO_ROOT_USER=skilluv \ + -e MINIO_ROOT_PASSWORD=skilluv_secret \ + minio/minio:RELEASE.2025-09-07T16-13-09Z \ + server /data + for i in $(seq 1 30); do + curl -fsS http://localhost:9000/minio/health/live > /dev/null 2>&1 && \ + echo "minio ready after ${i}s" && exit 0 + sleep 1 + done + docker logs minio + exit 1 + + - name: Start backend from GHCR image + env: + # `:master` is republished on every green master merge — see + # skilluv-backend/.github/workflows/ci.yml `publish` job. + BACKEND_IMAGE: ghcr.io/skilluv/skilluv-backend:master + run: | + docker pull "$BACKEND_IMAGE" + # Host network — backend reaches postgres/redis/mailpit/minio via + # the ports GHA services (and MinIO above) already bound to the + # runner. Admin origin allowlist added so the API accepts + # requests from the test's Origin: http://localhost:5174. + docker run -d --name backend --network host \ + -e HOST=0.0.0.0 \ + -e PORT=3001 \ + -e ENVIRONMENT=dev \ + -e DATABASE_URL=postgres://skilluv:skilluv_secret@localhost:5433/skilluv \ + -e REDIS_URL=redis://localhost:6379 \ + -e JWT_SECRET=ci-test-secret-please-rotate \ + -e BASE_URL=http://localhost:3001 \ + -e MINIO_ENDPOINT=http://localhost:9000 \ + -e MINIO_ACCESS_KEY=skilluv \ + -e MINIO_SECRET_KEY=skilluv_secret \ + -e MINIO_BUCKET=avatars \ + -e SMTP_HOST=localhost \ + -e SMTP_PORT=1025 \ + -e SMTP_TLS=none \ + -e EMAIL_FROM=noreply@skilluv.test \ + -e ADMIN_ORIGINS=http://localhost:5174 \ + -e RUST_LOG=skilluv_backend=info,tower_http=info \ + "$BACKEND_IMAGE" + + - name: Wait for backend to be healthy + run: | + for i in $(seq 1 60); do + if curl -fsS http://localhost:3001/api/health > /dev/null 2>&1; then + echo "backend ready after ${i}s" + exit 0 + fi + sleep 2 + done + echo "backend never became healthy — dumping logs:" + docker logs backend + exit 1 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '24' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Bootstrap admin user (register + elevate + enable 2FA) + env: + BACKEND_URL: http://localhost:3001 + DATABASE_URL: postgres://skilluv:skilluv_secret@localhost:5433/skilluv + run: node e2e/setup/bootstrap-admin.mjs + + - name: Run admin Playwright project + env: + BACKEND_URL: http://localhost:3001 + DATABASE_URL: postgres://skilluv:skilluv_secret@localhost:5433/skilluv + run: npx playwright test --project=admin + + - name: Dump backend logs on failure + if: failure() + run: docker logs backend + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v7 + with: + name: playwright-admin-report path: playwright-report/ retention-days: 7 diff --git a/.gitignore b/.gitignore index 1215999..e32a20e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,14 @@ playwright-report test-results .playwright +# E2E admin bootstrap artifacts (contain test creds + session state) +e2e/setup/admin-credentials.json +e2e/setup/admin-storage-state.json +e2e/setup/*.png + +# Trello sync creds (see qa/.trello.env.example) +qa/.trello.env + # AI coding assistant caches .claude/ .claude.* diff --git a/README.md b/README.md index 85574b3..0cf06eb 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,16 @@ ## What is Skilluv? -Skilluv is a community platform training the African OSS generation through real contributions to real open source projects. Every completed challenge produces a verifiable artifact — a merged pull request, a delivered Figma component, a submitted CVE report, a playable game build — exportable to recruiters. +Skilluv is a platform where talents in code, design, security, game +development, product, and other tech crafts grow their skills by +contributing to **real open source projects**. Every completed challenge +produces a verifiable artifact — a merged pull request, a delivered +Figma component, a submitted CVE report, a playable game build — +exportable to recruiters. -Full product vision in the [backend repository](https://github.com/skilluv/skilluv-backend). +**African-born, globally open.** Contributors and hiring companies come +from anywhere. Full product vision in the +[org profile](https://github.com/skilluv). ## What this repo contains @@ -133,4 +140,4 @@ Distributed under the [GNU Affero General Public License v3.0](LICENSE) (AGPL-3. ## Origin -Skilluv is built solo by [Jeremie Zitti](https://github.com/skilluv), a Beninese engineer. Public launch: **January 2027**. +Skilluv is built solo by [Jeremie Zitti](https://github.com/skilluv), an engineer based in Benin. Public launch: **January 2027**. diff --git a/e2e/admin/catalog-crud.spec.ts b/e2e/admin/catalog-crud.spec.ts new file mode 100644 index 0000000..fd6816a --- /dev/null +++ b/e2e/admin/catalog-crud.spec.ts @@ -0,0 +1,182 @@ +import { test, expect } from '@playwright/test'; +import { withDb, uniq } from '../setup/db'; + +// Phase 3 — catalog admin CRUD: orientations + badge rules + tenants. +// Grouped here because each individually is short but shares the /catalog +// tab surface + similar seed patterns. + +// ─── Orientations ──────────────────────────────────────────────────────── + +async function readOrientation(slug: string) { + return withDb(async (client) => { + const { rows } = await client.query( + // La colonne s'appelle `name` : `display_name` n'existe pas sur + // `orientations` (elle existe sur `badge_rules`, d'où la confusion). + 'SELECT id, name, description FROM orientations WHERE slug = $1', + [slug] + ); + return rows[0] as { id: string; name: string; description: string | null } | undefined; + }); +} + +async function cleanupOrientation(slug: string) { + await withDb(async (client) => { + await client.query('DELETE FROM orientations WHERE slug = $1', [slug]); + }); +} + +test('admin creates an orientation from /catalog', async ({ page }) => { + const id = uniq(); + const slug = `e2e-orient-${id}`; + const displayName = `E2E Orientation ${id}`; + + // Attendre le chargement de l'onglet : cliquer avant l'hydratation ne + // déclenche rien et la modale ne s'ouvre jamais. + // L'onglet lit le catalogue public `/orientations` ; seules les mutations + // passent par `/admin/orientations`. + const listed = page.waitForResponse( + (r) => r.url().includes('/orientations') && r.request().method() === 'GET' + ); + await page.goto('/catalog'); + await listed; + + // Libellé réel : « Créer une orientation » (i18n `orientations.createBtn`). + await page.getByRole('button', { name: /créer une orientation/i }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + // Les champs sont des , donc adressables par leur libellé + // plutôt que par un id ou un placeholder qui n'existent pas. + await dialog.getByLabel(/slug/i).first().fill(slug); + await dialog.getByLabel(/nom affiché/i).first().fill(displayName); + + // La modale n'est pas un