CI/CD: Gitea Actions workflow med tests
This commit is contained in:
@@ -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 }}"
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for pokedexter API."""
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user