feat: add CI/CD pipelines, test suite, and ci/staging branch
Some checks failed
Tests / Backend Tests (push) Failing after 5m42s
Tests / Frontend Tests (push) Failing after 1m11s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Faultier314
2026-04-06 10:13:12 +02:00
parent 5b00036951
commit 4305d104e5
14 changed files with 645 additions and 1 deletions

View File

@@ -0,0 +1,58 @@
name: Build & Publish
on:
push:
branches:
- develop
- main
env:
REGISTRY: tea.jr-family.de
BACKEND_IMAGE: tea.jr-family.de/admin/gartenmanager-backend
FRONTEND_IMAGE: tea.jr-family.de/admin/gartenmanager-frontend
jobs:
publish:
name: Build & Push Images
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Read version
id: version
run: echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT
- name: Compute image tags
id: tags
run: |
VERSION="${{ steps.version.outputs.version }}"
BRANCH="${GITHUB_REF_NAME}"
if [ "$BRANCH" = "main" ]; then
echo "backend_tags=${{ env.BACKEND_IMAGE }}:${VERSION} ${{ env.BACKEND_IMAGE }}:latest" >> $GITHUB_OUTPUT
echo "frontend_tags=${{ env.FRONTEND_IMAGE }}:${VERSION} ${{ env.FRONTEND_IMAGE }}:latest" >> $GITHUB_OUTPUT
else
echo "backend_tags=${{ env.BACKEND_IMAGE }}:${VERSION}-dev ${{ env.BACKEND_IMAGE }}:dev" >> $GITHUB_OUTPUT
echo "frontend_tags=${{ env.FRONTEND_IMAGE }}:${VERSION}-dev ${{ env.FRONTEND_IMAGE }}:dev" >> $GITHUB_OUTPUT
fi
- name: Login to Gitea registry
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ${{ env.REGISTRY }} \
-u ${{ gitea.actor }} --password-stdin
- name: Build & push backend
run: |
docker build -t placeholder ./backend
for tag in ${{ steps.tags.outputs.backend_tags }}; do
docker tag placeholder $tag
docker push $tag
done
- name: Build & push frontend
run: |
docker build -t placeholder ./frontend
for tag in ${{ steps.tags.outputs.frontend_tags }}; do
docker tag placeholder $tag
docker push $tag
done

87
.gitea/workflows/test.yml Normal file
View File

@@ -0,0 +1,87 @@
name: Tests
on:
push:
branches:
- 'ci/**'
- develop
pull_request:
branches:
- develop
- main
jobs:
backend:
name: Backend Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: gartenmanager_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: pip
cache-dependency-path: |
backend/requirements.txt
backend/requirements-test.txt
- name: Install dependencies
run: pip install -r backend/requirements.txt -r backend/requirements-test.txt
- name: Run tests
working-directory: backend
env:
DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/gartenmanager_test
SECRET_KEY: ci-test-secret-key-min-32-characters
run: pytest tests/ -v --cov=app --cov-report=xml --cov-report=term-missing
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: backend-coverage
path: backend/coverage.xml
frontend:
name: Frontend Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Lint
working-directory: frontend
run: npm run lint
- name: Unit tests
working-directory: frontend
run: npm run test
- name: Build check
working-directory: frontend
run: npm run build

View File

@@ -26,6 +26,25 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**Sessionstart:** [.claude/session-context.md](.claude/session-context.md) lesen.
**Modulreferenz:** [docs/project-structure.md](docs/project-structure.md) vor Quellcode-Reads.
## CI/CD Workflow
**Pflicht vor jedem Merge nach `develop`:**
```bash
# 1. Auf ci/staging mergen und pushen
git checkout ci/staging
git merge feature/<name>
git push
# 2. Warten bis alle Actions grün sind (.gitea/workflows/test.yml)
# 3. Erst dann nach develop mergen
```
**Gitea Actions:**
- `test.yml` läuft auf `ci/**`, `develop`, PRs nach `develop`/`main`
- `publish.yml` baut + pusht Container bei Push nach `develop` und `main`
- Registry: `tea.jr-family.de` | Images: `admin/gartenmanager-backend`, `admin/gartenmanager-frontend`
- develop → Tag `:dev` | main → Tag `:latest` + Versionsnummer
## Scripts (immer diese verwenden)
```bash

3
backend/pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
testpaths = tests

View File

@@ -0,0 +1,4 @@
pytest==8.2.0
pytest-asyncio==0.23.6
pytest-cov==5.0.0
httpx==0.27.0

View File

117
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,117 @@
import asyncio
import uuid
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import settings
from app.core.security import get_password_hash
from app.db.base import Base
from app.db.session import get_session
from app.main import app
from app.models.plant import NutrientDemand, PlantFamily, WaterDemand
from app.models.tenant import Tenant
from app.models.user import TenantRole, User, UserTenant
_engine = create_async_engine(settings.DATABASE_URL, echo=False)
_SessionLocal = async_sessionmaker(
bind=_engine,
class_=AsyncSession,
expire_on_commit=False,
)
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope="session")
async def setup_database():
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await _engine.dispose()
async def _override_get_session():
async with _SessionLocal() as session:
yield session
@pytest_asyncio.fixture
async def client(setup_database):
app.dependency_overrides[get_session] = _override_get_session
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def db(setup_database):
async with _SessionLocal() as session:
yield session
@pytest_asyncio.fixture
async def test_tenant(db: AsyncSession):
tenant = Tenant(
id=uuid.uuid4(),
name="Test Tenant",
slug=f"test-{uuid.uuid4().hex[:8]}",
)
db.add(tenant)
await db.commit()
yield tenant
@pytest_asyncio.fixture
async def test_family(db: AsyncSession):
family = PlantFamily(
id=uuid.uuid4(),
name=f"Testfamilie-{uuid.uuid4().hex[:6]}",
latin_name=f"Familia testus {uuid.uuid4().hex[:6]}",
)
db.add(family)
await db.commit()
yield family
@pytest_asyncio.fixture
async def test_user(db: AsyncSession, test_tenant: Tenant):
email = f"user-{uuid.uuid4().hex[:6]}@test.local"
password = "testpass123"
user = User(
id=uuid.uuid4(),
email=email,
hashed_password=get_password_hash(password),
full_name="Test User",
is_superadmin=False,
)
db.add(user)
await db.flush()
db.add(UserTenant(user_id=user.id, tenant_id=test_tenant.id, role=TenantRole.READ_WRITE))
await db.commit()
# Attach plaintext password for use in auth fixtures
user._plain_password = password # type: ignore[attr-defined]
yield user
@pytest_asyncio.fixture
async def auth_headers(client: AsyncClient, test_user: User, test_tenant: Tenant):
resp = await client.post(
"/api/v1/auth/login",
json={"email": test_user.email, "password": test_user._plain_password}, # type: ignore[attr-defined]
)
assert resp.status_code == 200
token = resp.json()["access_token"]
return {
"Authorization": f"Bearer {token}",
"X-Tenant-ID": str(test_tenant.id),
}

View File

@@ -0,0 +1,53 @@
async def test_login_success(client, test_user):
resp = await client.post(
"/api/v1/auth/login",
json={"email": test_user.email, "password": test_user._plain_password},
)
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["token_type"] == "bearer"
async def test_login_wrong_password(client, test_user):
resp = await client.post(
"/api/v1/auth/login",
json={"email": test_user.email, "password": "wrongpassword"},
)
assert resp.status_code == 401
async def test_login_unknown_email(client):
resp = await client.post(
"/api/v1/auth/login",
json={"email": "nobody@example.com", "password": "irrelevant"},
)
assert resp.status_code == 401
async def test_me(client, auth_headers):
resp = await client.get("/api/v1/auth/me", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert "email" in data
async def test_me_unauthenticated(client):
resp = await client.get("/api/v1/auth/me")
assert resp.status_code == 401
async def test_token_refresh(client, test_user):
login = await client.post(
"/api/v1/auth/login",
json={"email": test_user.email, "password": test_user._plain_password},
)
refresh_token = login.json()["refresh_token"]
resp = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": refresh_token},
)
assert resp.status_code == 200
assert "access_token" in resp.json()

View File

@@ -0,0 +1,95 @@
import uuid
async def test_get_beds(client, auth_headers):
resp = await client.get("/api/v1/beds", headers=auth_headers)
assert resp.status_code == 200
assert isinstance(resp.json(), list)
async def test_get_beds_unauthenticated(client):
resp = await client.get("/api/v1/beds")
assert resp.status_code == 401
async def test_create_bed(client, auth_headers):
resp = await client.post(
"/api/v1/beds",
headers=auth_headers,
json={
"name": "Testbeet",
"width_m": "1.5",
"length_m": "3.0",
"location": "sonnig",
"soil_type": "normal",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "Testbeet"
assert data["tenant_id"] is not None
async def test_get_bed_by_id(client, auth_headers):
create = await client.post(
"/api/v1/beds",
headers=auth_headers,
json={
"name": "Einzelbeet",
"width_m": "2.0",
"length_m": "4.0",
"location": "halbschatten",
},
)
bed_id = create.json()["id"]
resp = await client.get(f"/api/v1/beds/{bed_id}", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert data["id"] == bed_id
assert "plantings" in data
async def test_get_bed_not_found(client, auth_headers):
resp = await client.get(f"/api/v1/beds/{uuid.uuid4()}", headers=auth_headers)
assert resp.status_code == 404
async def test_update_bed(client, auth_headers):
create = await client.post(
"/api/v1/beds",
headers=auth_headers,
json={
"name": "Altbeet",
"width_m": "1.0",
"length_m": "2.0",
"location": "sonnig",
},
)
bed_id = create.json()["id"]
resp = await client.put(
f"/api/v1/beds/{bed_id}",
headers=auth_headers,
json={"name": "Neubeet"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "Neubeet"
async def test_delete_bed_requires_tenant_admin(client, auth_headers):
# test_user has READ_WRITE, not TENANT_ADMIN → delete should fail
create = await client.post(
"/api/v1/beds",
headers=auth_headers,
json={
"name": "ZuLoeschenBeet",
"width_m": "1.0",
"length_m": "1.0",
"location": "schatten",
},
)
bed_id = create.json()["id"]
resp = await client.delete(f"/api/v1/beds/{bed_id}", headers=auth_headers)
assert resp.status_code == 403

View File

@@ -0,0 +1,6 @@
async def test_health(client):
resp = await client.get("/health")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert "version" in data

View File

@@ -0,0 +1,114 @@
import uuid
async def test_get_plants(client, auth_headers):
resp = await client.get("/api/v1/plants", headers=auth_headers)
assert resp.status_code == 200
assert isinstance(resp.json(), list)
async def test_get_plants_unauthenticated(client):
resp = await client.get("/api/v1/plants")
assert resp.status_code == 401
async def test_get_plant_families(client, auth_headers):
resp = await client.get("/api/v1/plant-families", headers=auth_headers)
assert resp.status_code == 200
assert isinstance(resp.json(), list)
async def test_create_plant(client, auth_headers, test_family):
resp = await client.post(
"/api/v1/plants",
headers=auth_headers,
json={
"name": "Testtomate",
"family_id": str(test_family.id),
"nutrient_demand": "stark",
"water_demand": "viel",
"spacing_cm": 50,
"sowing_start_month": 3,
"sowing_end_month": 5,
"rest_years": 3,
},
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "Testtomate"
assert data["tenant_id"] is not None
return data["id"]
async def test_get_plant_by_id(client, auth_headers, test_family):
create = await client.post(
"/api/v1/plants",
headers=auth_headers,
json={
"name": "Testsalat",
"family_id": str(test_family.id),
"nutrient_demand": "mittel",
"water_demand": "mittel",
"spacing_cm": 30,
"sowing_start_month": 3,
"sowing_end_month": 6,
"rest_years": 0,
},
)
plant_id = create.json()["id"]
resp = await client.get(f"/api/v1/plants/{plant_id}", headers=auth_headers)
assert resp.status_code == 200
assert resp.json()["id"] == plant_id
async def test_get_plant_not_found(client, auth_headers):
resp = await client.get(f"/api/v1/plants/{uuid.uuid4()}", headers=auth_headers)
assert resp.status_code == 404
async def test_update_plant(client, auth_headers, test_family):
create = await client.post(
"/api/v1/plants",
headers=auth_headers,
json={
"name": "Altname",
"family_id": str(test_family.id),
"nutrient_demand": "schwach",
"water_demand": "wenig",
"spacing_cm": 20,
"sowing_start_month": 4,
"sowing_end_month": 6,
"rest_years": 1,
},
)
plant_id = create.json()["id"]
resp = await client.put(
f"/api/v1/plants/{plant_id}",
headers=auth_headers,
json={"name": "Neuname"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "Neuname"
async def test_delete_plant(client, auth_headers, test_family):
create = await client.post(
"/api/v1/plants",
headers=auth_headers,
json={
"name": "ZuLoeschenPflanze",
"family_id": str(test_family.id),
"nutrient_demand": "mittel",
"water_demand": "mittel",
"spacing_cm": 25,
"sowing_start_month": 4,
"sowing_end_month": 7,
"rest_years": 0,
},
)
plant_id = create.json()["id"]
resp = await client.delete(f"/api/v1/plants/{plant_id}", headers=auth_headers)
assert resp.status_code == 204

View File

@@ -6,7 +6,9 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src --ext .vue,.js,.ts"
"lint": "eslint src --ext .vue,.js,.ts",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"axios": "^1.6.8",
@@ -18,7 +20,10 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"@vue/test-utils": "^2.4.6",
"vite": "^5.2.0",
"vitest": "^1.6.0",
"jsdom": "^24.1.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.24.0"
}

View File

@@ -0,0 +1,78 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from './auth'
// localStorage is not available in jsdom by default without setup,
// so we mock it minimally
const localStorageMock = (() => {
let store = {}
return {
getItem: (key) => store[key] ?? null,
setItem: (key, value) => { store[key] = String(value) },
removeItem: (key) => { delete store[key] },
clear: () => { store = {} },
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock })
// Mock the API module so tests don't make real HTTP calls
vi.mock('@/api', () => ({
authApi: {
login: vi.fn(),
},
}))
describe('useAuthStore', () => {
beforeEach(() => {
localStorageMock.clear()
setActivePinia(createPinia())
})
it('initializes with null user when localStorage is empty', () => {
const store = useAuthStore()
expect(store.user).toBeNull()
expect(store.tenants).toEqual([])
expect(store.activeTenantId).toBeNull()
expect(store.isLoggedIn).toBe(false)
})
it('isLoggedIn is true when user is set', () => {
localStorageMock.setItem('user', JSON.stringify({ id: '1', email: 'a@b.de' }))
const store = useAuthStore()
expect(store.isLoggedIn).toBe(true)
})
it('logout clears state and localStorage', () => {
localStorageMock.setItem('user', JSON.stringify({ id: '1' }))
localStorageMock.setItem('tenants', JSON.stringify([{ id: 'tid' }]))
localStorageMock.setItem('tenant_id', 'tid')
const store = useAuthStore()
store.logout()
expect(store.user).toBeNull()
expect(store.tenants).toEqual([])
expect(store.activeTenantId).toBeNull()
expect(localStorageMock.getItem('user')).toBeNull()
})
it('setActiveTenant updates state and localStorage', () => {
const store = useAuthStore()
store.setActiveTenant('new-tenant-id')
expect(store.activeTenantId).toBe('new-tenant-id')
expect(localStorageMock.getItem('tenant_id')).toBe('new-tenant-id')
})
it('activeTenant returns matching tenant from list', () => {
const tenants = [
{ id: 'tid-1', name: 'Erster' },
{ id: 'tid-2', name: 'Zweiter' },
]
localStorageMock.setItem('tenants', JSON.stringify(tenants))
localStorageMock.setItem('tenant_id', 'tid-2')
const store = useAuthStore()
expect(store.activeTenant?.name).toBe('Zweiter')
})
})

View File

@@ -3,6 +3,11 @@ import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
include: ['src/**/*.test.{js,ts}'],
},
plugins: [vue()],
resolve: {
alias: {