diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c1e3b39 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,96 @@ +name: CI Tests + +on: + pull_request: + branches: [ master, main ] + push: + branches: [ master ] + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + ports: + - 5432:5432 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: app + options: >- + --health-cmd "pg_isready -U postgres -d app" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend deps + working-directory: frontend + run: npm ci + + - name: Install Playwright browsers + working-directory: frontend + run: npx playwright install --with-deps + + - name: Set up Python + uses: astral-sh/setup-uv@v5 + with: + python-version: '3.12' + + - name: Install backend deps + working-directory: backend + run: uv sync --dev + + - name: Lint (ruff) + working-directory: backend + run: uv run ruff check --fix . + + - name: Type check (mypy) + working-directory: backend + run: uv run mypy app + + - name: Run backend tests + working-directory: backend + env: + ENVIRONMENT: local + DOMAIN: localhost + FRONTEND_HOST: http://localhost:5173 + BACKEND_CORS_ORIGINS: http://localhost:5173 + SECRET_KEY: testsecret + FIRST_SUPERUSER: admin@example.com + FIRST_SUPERUSER_PASSWORD: admin + POSTGRES_SERVER: 127.0.0.1 + POSTGRES_PORT: 5432 + POSTGRES_DB: app + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + run: uv run pytest -q + + - name: Run frontend e2e tests + working-directory: frontend + env: + PLAYWRIGHT_BASE_URL: http://localhost:5173 + VITE_API_URL: http://localhost:8000 + run: | + # Start backend against CI Postgres + uv run --directory ../backend fastapi run app/main.py & + BACKEND_PID=$! + echo "Started backend pid=$BACKEND_PID" + # Wait backend up + npx wait-on -t 60000 http://localhost:8000/api/v1/utils/health-check/ + # Run playwright + npx playwright test --reporter=list + # Stop backend + kill $BACKEND_PID + diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5f6f7f6..b280016 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -29,6 +29,9 @@ dev-dependencies = [ "ruff<1.0.0,>=0.2.2", "pre-commit<4.0.0,>=3.6.2", "types-passlib<2.0.0.0,>=1.7.7.20240106", + "pytest<9.0.0,>=8.2.0", + "httpx<1.0.0,>=0.27.0", + "pytest-anyio<1.0.0,>=0.0.0", ] [build-system] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..b1962d4 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,47 @@ +import os + +# Ensure test env vars are set BEFORE importing application modules that read them +os.environ.setdefault("ENVIRONMENT", "local") +os.environ.setdefault("SECRET_KEY", "testsecret") +os.environ.setdefault("FIRST_SUPERUSER", "admin@example.com") +os.environ.setdefault("FIRST_SUPERUSER_PASSWORD", "admin") +os.environ.setdefault("POSTGRES_SERVER", os.getenv("POSTGRES_SERVER", "127.0.0.1")) +os.environ.setdefault("POSTGRES_PORT", os.getenv("POSTGRES_PORT", "5432")) +os.environ.setdefault("POSTGRES_DB", os.getenv("POSTGRES_DB", "app")) +os.environ.setdefault("POSTGRES_USER", os.getenv("POSTGRES_USER", "postgres")) +os.environ.setdefault("POSTGRES_PASSWORD", os.getenv("POSTGRES_PASSWORD", "postgres")) + +import pytest +from sqlmodel import SQLModel, create_engine + +import app.core.db as core_db +import app.api.deps as deps + + +@pytest.fixture(scope="session", autouse=True) +def _create_database_schema() -> None: + # Prefer SQLite for tests unless TEST_DB=postgres is set + engine_to_use = None + if os.getenv("TEST_DB", "sqlite").lower() == "sqlite": + sqlite_url = "sqlite:///./test.db" + sqlite_engine = create_engine( + sqlite_url, connect_args={"check_same_thread": False} + ) + # Override engines used by the app + core_db.engine = sqlite_engine + deps.engine = sqlite_engine + engine_to_use = sqlite_engine + else: + from app.core.db import engine as pg_engine + from app.backend_pre_start import init as wait_for_db + + wait_for_db(pg_engine) + engine_to_use = pg_engine + + # Create all tables for tests; drop them after session ends + SQLModel.metadata.create_all(engine_to_use) + try: + yield + finally: + SQLModel.metadata.drop_all(engine_to_use) + diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..6d60f97 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,56 @@ +import os +import uuid + +import pytest +from httpx import AsyncClient + +from app.core.config import settings +from app.main import app + + +@pytest.mark.anyio +async def test_signup_login_me(monkeypatch: pytest.MonkeyPatch) -> None: + # Ensure env resembles local for tests + monkeypatch.setenv("ENVIRONMENT", "local") + # Provide default secrets if not present in env to satisfy validators + monkeypatch.setenv("SECRET_KEY", os.getenv("SECRET_KEY", uuid.uuid4().hex)) + monkeypatch.setenv( + "FIRST_SUPERUSER", + os.getenv("FIRST_SUPERUSER", "admin@example.com"), + ) + monkeypatch.setenv( + "FIRST_SUPERUSER_PASSWORD", + os.getenv("FIRST_SUPERUSER_PASSWORD", "admin"), + ) + + async with AsyncClient(app=app, base_url="http://test") as client: + # Signup + email = f"user-{uuid.uuid4().hex[:8]}@example.com" + password = "changeme123!" + resp = await client.post( + f"{settings.API_V1_STR}/users/signup", + json={"email": email, "password": password, "full_name": "Test"}, + ) + assert resp.status_code == 200, resp.text + user = resp.json() + assert user["email"] == email + + # Login + resp = await client.post( + f"{settings.API_V1_STR}/login/access-token", + data={"username": email, "password": password}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert resp.status_code == 200, resp.text + token = resp.json()["access_token"] + assert token + + # Me + resp = await client.get( + f"{settings.API_V1_STR}/users/me", + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200, resp.text + me = resp.json() + assert me["email"] == email + diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..fd00269 --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,13 @@ +import pytest +from httpx import AsyncClient + +from app.main import app + + +@pytest.mark.anyio +async def test_health_check() -> None: + async with AsyncClient(app=app, base_url="http://test") as client: + resp = await client.get("/api/v1/utils/health-check/") + assert resp.status_code == 200 + assert resp.json() is True + diff --git a/frontend/README.md b/frontend/README.md index 6f11f36..36b2cce 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -137,6 +137,12 @@ Then, you can run the tests with the following command: npx playwright test ``` +Install browsers (first time only): + +```bash +npm run test:e2e:install +``` + You can also run your tests in UI mode to see the browser and interact with it running: ```bash diff --git a/frontend/biome.json b/frontend/biome.json index a06315d..bdd6ca9 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -8,7 +8,8 @@ "node_modules", "src/routeTree.gen.ts", "playwright.config.ts", - "playwright-report" + "playwright-report", + "test-results" ] }, "linter": { diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts new file mode 100644 index 0000000..9cd2dc8 --- /dev/null +++ b/frontend/e2e/auth.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from "@playwright/test" + +test("signup -> login -> lands on dashboard", async ({ page }) => { + const email = `e2e-${Date.now()}@example.com` + const password = "Playwright1!" + + await page.goto("/") + + // Navigate to signup + await page.getByRole("link", { name: /sign ?up/i }).click() + + await page.getByLabel(/email/i).fill(email) + await page.getByLabel(/password/i).fill(password) + const fullName = page.getByLabel(/full\s*name/i) + if (await fullName.isVisible()) { + await fullName.fill("E2E User") + } + await page.getByRole("button", { name: /sign ?up/i }).click() + + // After signup, navigate to login if not redirected + if (!(await page.getByRole("link", { name: /logout/i }).isVisible({ timeout: 1000 }).catch(() => false))) { + await page.getByRole("link", { name: /log ?in/i }).click() + await page.getByLabel(/email/i).fill(email) + await page.getByLabel(/password/i).fill(password) + await page.getByRole("button", { name: /log ?in/i }).click() + } + + // Expect some authenticated UI element + await expect(page.getByText(/settings|profile|dashboard/i)).toBeVisible() +}) + diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts new file mode 100644 index 0000000..a959be4 --- /dev/null +++ b/frontend/e2e/smoke.spec.ts @@ -0,0 +1,8 @@ +import { test, expect } from "@playwright/test" + +test("app loads and shows navbar", async ({ page }) => { + await page.goto("/") + // Title may vary; check for a stable element in the navbar instead + await expect(page.getByRole("navigation")).toBeVisible() +}) + diff --git a/frontend/package.json b/frontend/package.json index 7e0e766..37b198e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,10 @@ "build": "tsc -p tsconfig.build.json && vite build", "lint": "biome check --apply-unsafe --no-errors-on-unmatched --files-ignore-unknown=true ./", "preview": "vite preview", - "generate-client": "openapi-ts" + "generate-client": "openapi-ts", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:install": "playwright install" }, "dependencies": { "@chakra-ui/react": "^3.8.0", @@ -27,6 +30,7 @@ }, "devDependencies": { "@biomejs/biome": "1.9.4", + "@playwright/test": "^1.52.0", "@hey-api/openapi-ts": "^0.57.0", "@tanstack/router-devtools": "1.19.1", "@tanstack/router-vite-plugin": "1.19.0", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..94111a8 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from "@playwright/test" + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 2 : undefined, + reporter: [["html", { outputFolder: "playwright-report", open: "never" }], ["list"]], + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173", + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: "npm run dev", + url: "http://localhost:5173", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}) + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 43ca817..d6ccd89 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -21,7 +21,7 @@ "@/*": ["./src/*"] } }, - "include": ["src/**/*.ts", "tests/**/*.ts", "playwright.config.ts"], + "include": ["src/**/*.ts", "tests/**/*.ts", "e2e/**/*.ts", "playwright.config.ts"], "references": [ { "path": "./tsconfig.node.json" diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c49ec52..3c899b6 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -11,4 +11,8 @@ export default defineConfig({ }, }, plugins: [react(), TanStackRouterVite()], + server: { + port: 5173, + host: true, + }, }) diff --git a/package.json b/package.json new file mode 100644 index 0000000..0b18b5a --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "root", + "private": true, + "scripts": { + "test": "npm run -w frontend test:e2e && uv run -q --directory backend pytest", + "test:ci": "uv run -q --directory backend pytest && npm run -w frontend test:e2e" + } +} +