Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
fa0ffe1
fix(admin): Svelte 5 hydration + reactivity guards on 8 pages
jeremie0342 Jul 27, 2026
a1e4155
test(e2e): Playwright admin flows — Phase 1 nav-smoke + Phase 2 criti…
jeremie0342 Jul 27, 2026
3bf6586
chore(qa): bug/todo tracking + Trello sync pipeline
jeremie0342 Jul 27, 2026
60bf110
ci: add e2e-admin job pulling backend image from GHCR
jeremie0342 Jul 27, 2026
6a3181f
chore(security): bump transitive `cookie` 0.6 → 0.7.2 via override
jeremie0342 Jul 27, 2026
8b891e9
refactor(i18n): extract intlLocale helper, remove 15+ duplicate impls
jeremie0342 Jul 27, 2026
c1e8e55
test(ui): unit specs for Input, Modal, Select (20 tests)
jeremie0342 Jul 27, 2026
f5b477d
refactor(i18n): migrate all remaining inline i18n.locale strings to i…
jeremie0342 Jul 28, 2026
2cd812b
feat(defensive): global "backend unreachable" banner with auto-retry
jeremie0342 Jul 28, 2026
ce342de
refactor(e2e): route 9 admin specs through the shared e2e/setup/db he…
jeremie0342 Jul 28, 2026
57b038c
refactor(sponsored): extract decide modal into a dedicated component
jeremie0342 Jul 28, 2026
f4106a5
refactor(admin): extract 3 remaining form modals (skills, challenges,…
jeremie0342 Jul 28, 2026
12a1164
feat(admin): UI for Challenge AI variant + Fraud deep-scan endpoints
jeremie0342 Jul 28, 2026
966fb4c
fix(auth-client): align 4 auth methods to backend BE-P0-01..04 contra…
jeremie0342 Jul 28, 2026
d61ca18
chore(env): default vite proxy to production backend at api.skill-uv.com
jeremie0342 Jul 29, 2026
feceba2
chore(qa): restore original card titles so Trello sync updates in place
jeremie0342 Jul 29, 2026
b0514d8
feat(admin): consume new user-detail 2FA/passkey fields (backend comm…
jeremie0342 Jul 29, 2026
3444e1c
test(admin): flip regression guards + add community approve 400 spec
jeremie0342 Jul 29, 2026
ad830c8
chore(qa): mark 8 backend bugs as fixed with commit refs, sync Trello
jeremie0342 Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 20 additions & 13 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
# 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.
# Prod (default recommendation):
API_URL=https://api.skill-uv.com/api
# Local Rust backend:
# 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
# ─── 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 only safe to set when it points at a local staging
# database — never wire prod credentials from a dev machine.
# DATABASE_URL=postgres://skilluv:CHANGE_ME@localhost:5433/skilluv
159 changes: 155 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
159 changes: 159 additions & 0 deletions e2e/admin/catalog-crud.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
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(
'SELECT id, display_name, description FROM orientations WHERE slug = $1',
[slug]
);
return rows[0] as { id: string; display_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}`;

await page.goto('/catalog');
// Orientations tab — most catalog pages have a segmented control.
await page.getByRole('button', { name: /orientations?/i }).first().click().catch(() => {});

// Open create form (a button labelled "Nouvelle orientation" per fr.ts).
await page.getByRole('button', { name: /nouvelle orientation|new orientation|créer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5_000 });

await dialog.locator('input[placeholder*="slug"], input[name="slug"], #slug').first().fill(slug);
await dialog.getByRole('textbox', { name: /nom|display name/i }).first().fill(displayName);

const req = page.waitForResponse(
(r) => r.url().includes('/admin/orientations') && r.request().method() === 'POST'
);
await dialog.locator('form').evaluate((f: HTMLFormElement) => f.requestSubmit());
expect((await req).status(), 'orientation POST').toBeLessThan(300);

const created = await readOrientation(slug);
expect(created?.display_name).toBe(displayName);

await cleanupOrientation(slug);
});

// ─── Badge rules ────────────────────────────────────────────────────────

async function readBadgeRule(slug: string) {
return withDb(async (client) => {
const { rows } = await client.query(
'SELECT id, display_name, deprecated_at FROM badge_rules WHERE slug = $1',
[slug]
);
return rows[0] as
| { id: string; display_name: string; deprecated_at: Date | null }
| undefined;
});
}

async function seedBadgeRule() {
const id = uniq();
const slug = `e2e-badge-${id}`;
return withDb(async (client) => {
const { rows } = await client.query(
`INSERT INTO badge_rules (slug, display_name, description, kind, rule_expr, reward_fragments)
VALUES ($1, $2, 'E2E test rule', 'proof', '{}'::jsonb, 0)
RETURNING id`,
[slug, `E2E Badge ${id}`]
);
return { id: rows[0].id as string, slug };
});
}

async function cleanupBadgeRule(slug: string) {
await withDb(async (client) => {
await client.query('DELETE FROM badge_rules WHERE slug = $1', [slug]);
});
}

test('admin deprecates a badge rule from /catalog', async ({ page }) => {
const rule = await seedBadgeRule();

await page.goto('/catalog');
await page.getByRole('button', { name: /badge/i }).first().click().catch(() => {});

// Locate our seeded rule's row + trigger the deprecate action.
const row = page.locator(`text=${rule.slug}`).first();
await expect(row).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: /déprécier|deprecate/i }).first().click();

// Deprecate is destructive → reason required.
await page.getByTestId('confirm-dangerous-reason').fill('E2E — rule superseded by newer criteria');
const req = page.waitForResponse(
(r) => r.url().includes(`/admin/badge-rules/${rule.slug}/deprecate`) && r.request().method() === 'POST'
);
await page.getByTestId('confirm-dangerous-action').click();
expect((await req).status(), 'deprecate POST').toBeLessThan(300);

const state = await readBadgeRule(rule.slug);
expect(state?.deprecated_at, 'deprecated_at set').not.toBeNull();

await cleanupBadgeRule(rule.slug);
});

// ─── Tenants ────────────────────────────────────────────────────────────

async function readTenant(slug: string) {
return withDb(async (client) => {
const { rows } = await client.query(
'SELECT id, name, plan FROM tenants WHERE slug = $1',
[slug]
);
return rows[0] as { id: string; name: string; plan: string } | undefined;
});
}

async function cleanupTenant(slug: string) {
await withDb(async (client) => {
await client.query('DELETE FROM tenants WHERE slug = $1', [slug]);
});
}

test('admin creates a tenant from /tenants', async ({ page }) => {
const id = uniq();
const slug = `e2e-tenant-${id}`.slice(0, 40);
const name = `E2E Tenant ${id}`;

await page.goto('/tenants');
await page.waitForResponse((r) => r.url().includes('/api/admin/tenants') && r.request().method() === 'GET');

await page.getByRole('button', { name: /nouveau tenant|new tenant|créer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();

await dialog.locator('input[placeholder*="slug"], input[name="slug"], #slug').first().fill(slug);
await dialog.getByRole('textbox', { name: /nom|company|name/i }).first().fill(name);
// Contact email is required by the create endpoint.
await dialog.locator('input[type="email"]').first().fill(`${slug}@e2e.test`);

const req = page.waitForResponse(
(r) => r.url().includes('/admin/tenants') && r.request().method() === 'POST'
);
await dialog.locator('form').evaluate((f: HTMLFormElement) => f.requestSubmit());
expect((await req).status(), 'tenant POST').toBeLessThan(300);

const created = await readTenant(slug);
expect(created?.name).toBe(name);

await cleanupTenant(slug);
});
Loading
Loading