Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
96 changes: 96 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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

3 changes: 3 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
47 changes: 47 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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)

56 changes: 56 additions & 0 deletions backend/tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -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

13 changes: 13 additions & 0 deletions backend/tests/test_health.py
Original file line number Diff line number Diff line change
@@ -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

6 changes: 6 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion frontend/biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"node_modules",
"src/routeTree.gen.ts",
"playwright.config.ts",
"playwright-report"
"playwright-report",
"test-results"
]
},
"linter": {
Expand Down
31 changes: 31 additions & 0 deletions frontend/e2e/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})

8 changes: 8 additions & 0 deletions frontend/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})

6 changes: 5 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions frontend/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
})

2 changes: 1 addition & 1 deletion frontend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions frontend/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@ export default defineConfig({
},
},
plugins: [react(), TanStackRouterVite()],
server: {
port: 5173,
host: true,
},
})
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}

Loading