From 07a6781c5e0120adb51aa952fe587f696a2bf649 Mon Sep 17 00:00:00 2001 From: GlennIgen Date: Sat, 11 Jul 2026 02:10:27 +0200 Subject: [PATCH] CI/CD: Gitea Actions workflow med tests --- .gitea/workflows/ci.yml | 73 ++++++++++++++++++++++++++ backend/requirements.txt | 6 +++ backend/tests/__init__.py | 1 + backend/tests/conftest.py | 101 ++++++++++++++++++++++++++++++++++++ backend/tests/test_admin.py | 39 ++++++++++++++ backend/tests/test_auth.py | 52 +++++++++++++++++++ backend/tests/test_cards.py | 74 ++++++++++++++++++++++++++ 7 files changed, 346 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_admin.py create mode 100644 backend/tests/test_auth.py create mode 100644 backend/tests/test_cards.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..01fc0dc --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI/CD +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: pokedexter + POSTGRES_USER: pokedexter + POSTGRES_PASSWORD: pokedexter_secret + options: >- + --health-cmd "pg_isready -U pokedexter" + --health-interval 5s + --health-timeout 3s + --health-retries 5 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install -r backend/requirements.txt + + - name: Run tests + env: + DATABASE_URL: "postgresql+asyncpg://pokedexter:pokedexter_secret@postgres/pokedexter" + run: | + cd backend + pytest -v --cov=app --cov-report=term --cov-report=xml + + - name: Upload coverage + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage + path: backend/coverage.xml + + build: + needs: test + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build backend image + run: | + docker build -t pokedexter-backend:${{ github.sha }} -t pokedexter-backend:latest -f Dockerfile_backend . + + - name: Build nginx image + run: | + docker build -t pokedexter-nginx:${{ github.sha }} -t pokedexter-nginx:latest -f Dockerfile_nginx . + + - name: Log build info + run: | + echo "Backend image: pokedexter-backend:${{ github.sha }}" + echo "Nginx image: pokedexter-nginx:${{ github.sha }}" \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 4dff6b3..f2d6b57 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,3 +7,9 @@ python-multipart==0.0.12 bcrypt==5.0.0 python-jose[cryptography]==3.3.0 httpx==0.27.0 + +# Testing +pytest>=8.0 +pytest-asyncio>=0.24 +pytest-cov>=5.0 +pytest-httpx>=0.30 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..af937ef --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for pokedexter API.""" \ No newline at end of file diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..8b168fe --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,101 @@ +"""Test fixtures — async database and HTTP client.""" + +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from httpx import ASGITransport, AsyncClient + +from app.database import Base, get_db +from app.main import app +from app.auth import hash_password +from app.models import User + +import os + +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql+asyncpg://pokedexter:pokedexter_secret@localhost/pokedexter", +) + + +@pytest_asyncio.fixture(scope="session") +async def engine(): + """Create a fresh database for testing.""" + e = create_async_engine(DATABASE_URL, echo=False) + async with e.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + yield e + async with e.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await e.dispose() + + +@pytest_asyncio.fixture +async def session(engine): + """Per-test database session.""" + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as s: + yield s + + +@pytest_asyncio.fixture +async def client(session): + """HTTP client with injected test DB session.""" + + async def override_get_db(): + yield session + + app.dependency_overrides[get_db] = override_get_db + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + app.dependency_overrides.clear() + + +@pytest_asyncio.fixture +async def admin_user(session): + """Insert admin user into test DB.""" + u = User( + username="admin", + password_hash=hash_password("admin123"), + role="admin", + ) + session.add(u) + await session.commit() + return u + + +@pytest_asyncio.fixture +async def normal_user(session): + """Insert normal user into test DB.""" + u = User( + username="testuser", + password_hash=hash_password("test123"), + role="user", + ) + session.add(u) + await session.commit() + return u + + +@pytest_asyncio.fixture +async def admin_token(client, admin_user): + """Log in as admin and return the JWT token.""" + resp = await client.post("/api/login", json={ + "username": "admin", + "password": "admin123", + }) + assert resp.status_code == 200 + return resp.cookies.get("token") + + +@pytest_asyncio.fixture +async def user_token(client, normal_user): + """Log in as normal user and return the JWT token.""" + resp = await client.post("/api/login", json={ + "username": "testuser", + "password": "test123", + }) + assert resp.status_code == 200 + return resp.cookies.get("token") \ No newline at end of file diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py new file mode 100644 index 0000000..ecd94c7 --- /dev/null +++ b/backend/tests/test_admin.py @@ -0,0 +1,39 @@ +"""Tests for admin endpoints.""" + +import pytest + + +@pytest.mark.asyncio +class TestAdmin: + """Admin routes require admin role.""" + + async def test_dashboard_requires_admin(self, client, user_token): + client.cookies.set("token", user_token) + resp = await client.get("/admin/", follow_redirects=False) + assert resp.status_code == 401 + + async def test_dashboard_as_admin(self, client, admin_token): + client.cookies.set("token", admin_token) + resp = await client.get("/admin/") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + + async def test_series_admin_requires_admin(self, client, user_token): + client.cookies.set("token", user_token) + resp = await client.get("/admin/series", follow_redirects=False) + assert resp.status_code == 401 + + async def test_series_admin_as_admin(self, client, admin_token): + client.cookies.set("token", admin_token) + resp = await client.get("/admin/series") + assert resp.status_code == 200 + + async def test_sets_admin_as_admin(self, client, admin_token): + client.cookies.set("token", admin_token) + resp = await client.get("/admin/sets") + assert resp.status_code == 200 + + async def test_cards_admin_as_admin(self, client, admin_token): + client.cookies.set("token", admin_token) + resp = await client.get("/admin/cards") + assert resp.status_code == 200 \ No newline at end of file diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..69871bf --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,52 @@ +"""Tests for authentication endpoints.""" + +import pytest + + +@pytest.mark.asyncio +class TestAuth: + async def test_login_page(self, client): + resp = await client.get("/login") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + + async def test_login_success(self, client, admin_user): + resp = await client.post("/api/login", json={ + "username": "admin", + "password": "admin123", + }) + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert "token" in resp.cookies + + async def test_login_wrong_password(self, client, admin_user): + resp = await client.post("/api/login", json={ + "username": "admin", + "password": "forkert", + }) + assert resp.status_code == 401 + + async def test_login_unknown_user(self, client): + resp = await client.post("/api/login", json={ + "username": "findes_ikke", + "password": "test123", + }) + assert resp.status_code == 401 + + async def test_logout(self, client, admin_token): + client.cookies.set("token", admin_token) + resp = await client.post("/api/logout") + assert resp.status_code == 200 + # Cookie should be deleted + set_cookie = resp.headers.get("set-cookie", "") + assert "token=;" in set_cookie or "Max-Age=0" in set_cookie + + async def test_index_redirect_when_not_logged_in(self, client): + resp = await client.get("/", follow_redirects=False) + assert resp.status_code == 303 # Redirect to /login + + async def test_index_when_logged_in(self, client, admin_token): + client.cookies.set("token", admin_token) + resp = await client.get("/") + assert resp.status_code == 200 \ No newline at end of file diff --git a/backend/tests/test_cards.py b/backend/tests/test_cards.py new file mode 100644 index 0000000..4ccddbf --- /dev/null +++ b/backend/tests/test_cards.py @@ -0,0 +1,74 @@ +"""Tests for card/series/set endpoints.""" + +import pytest + + +@pytest.mark.asyncio +class TestCardsAPI: + """Tests for card browsning (no auth required for browse endpoints).""" + + async def test_series_list(self, client, admin_token): + client.cookies.set("token", admin_token) + resp = await client.get("/series") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + + async def test_sets_list(self, client, admin_token): + client.cookies.set("token", admin_token) + resp = await client.get("/sets") + assert resp.status_code == 200 + + async def test_search_page(self, client, admin_token): + client.cookies.set("token", admin_token) + resp = await client.get("/search") + assert resp.status_code == 200 + + # ── JSON API ────────────────────────────────────────────── + + async def test_api_series(self, client): + resp = await client.get("/api/series") + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + async def test_api_sets(self, client): + resp = await client.get("/api/sets") + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + async def test_api_series_sets_not_found(self, client): + resp = await client.get("/api/series/FinnesIkke/sets") + assert resp.status_code == 200 + data = resp.json() + assert "error" in data + + async def test_api_sets_cards_not_found(self, client): + resp = await client.get("/api/sets/XXX/cards") + assert resp.status_code == 200 + assert resp.json() == [] + + async def test_search_empty(self, client): + resp = await client.get("/api/search") + assert resp.status_code == 200 + assert resp.json() == [] + + async def test_search_no_match(self, client): + resp = await client.get("/api/search?q=zzz_nonexistent_xyz") + assert resp.status_code == 200 + assert resp.json() == [] + + # ── 404 tests ───────────────────────────────────────────── + + async def test_serie_detail_404(self, client, admin_token): + client.cookies.set("token", admin_token) + resp = await client.get("/series/FinnesIkke") + assert resp.status_code == 404 + + async def test_set_detail_404(self, client, admin_token): + client.cookies.set("token", admin_token) + resp = await client.get("/sets/ZZZ") + assert resp.status_code == 404 + + async def test_card_detail_404(self, client, admin_token): + client.cookies.set("token", admin_token) + resp = await client.get("/card/ZZZ/999") + assert resp.status_code == 404 \ No newline at end of file