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
|
bcrypt==5.0.0
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
httpx==0.27.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