feat: add CI/CD pipelines, test suite, and ci/staging branch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
58
.gitea/workflows/publish.yml
Normal file
58
.gitea/workflows/publish.yml
Normal 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
87
.gitea/workflows/test.yml
Normal 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
|
||||
19
CLAUDE.md
19
CLAUDE.md
@@ -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
3
backend/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
4
backend/requirements-test.txt
Normal file
4
backend/requirements-test.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
pytest==8.2.0
|
||||
pytest-asyncio==0.23.6
|
||||
pytest-cov==5.0.0
|
||||
httpx==0.27.0
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
117
backend/tests/conftest.py
Normal file
117
backend/tests/conftest.py
Normal 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),
|
||||
}
|
||||
53
backend/tests/test_auth.py
Normal file
53
backend/tests/test_auth.py
Normal 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()
|
||||
95
backend/tests/test_beds.py
Normal file
95
backend/tests/test_beds.py
Normal 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
|
||||
6
backend/tests/test_health.py
Normal file
6
backend/tests/test_health.py
Normal 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
|
||||
114
backend/tests/test_plants.py
Normal file
114
backend/tests/test_plants.py
Normal 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
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
78
frontend/src/stores/auth.test.js
Normal file
78
frontend/src/stores/auth.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user