Compare commits
26 Commits
6f7352124d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 22ce3f9d49 | |||
| 83b46c8cda | |||
| a686957b09 | |||
| 53931d9d2b | |||
| 51a9d1db58 | |||
| ade9673f02 | |||
| 1c4c4ec708 | |||
| b69e5b1820 | |||
| 4b38c4fa09 | |||
| ad7f4e2b1c | |||
| 6aae17edba | |||
| 970f8eb295 | |||
| dad798a904 | |||
| 52d23053e7 | |||
| 6f5e886bd6 | |||
| f9a87efb7a | |||
| 279487067e | |||
| be932c1930 | |||
| 776149e7d3 | |||
| 6e25914074 | |||
| 90cdac7468 | |||
| fa567c1c3f | |||
| f1959eb3d2 | |||
| 592abc4b6d | |||
| 3b15d7439d | |||
| 59b785b8ed |
@@ -0,0 +1,15 @@
|
||||
# CannaManage — Environment Variables
|
||||
# Copy this file to .env and fill in the values.
|
||||
# NEVER commit .env to git.
|
||||
|
||||
# Database
|
||||
DB_PASSWORD=cannamanage_dev
|
||||
|
||||
# JWT Secret — must be valid base64 (used by Decoders.BASE64.decode in JwtService)
|
||||
# Generate with: openssl rand -base64 48
|
||||
JWT_SECRET=
|
||||
|
||||
# NextAuth / Auth.js secret — minimum 32 characters
|
||||
# Generate with: openssl rand -base64 32
|
||||
NEXTAUTH_SECRET=
|
||||
AUTH_SECRET=
|
||||
@@ -0,0 +1,195 @@
|
||||
name: CI — Build, Lint & Security Scan
|
||||
|
||||
# Runs on every push to main. Must pass before deploy.
|
||||
# Security scans catch CVEs, license issues, and secrets BEFORE they reach prod.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Backend: compile + test + dependency audit
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: cannamanage_test
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
cache: maven
|
||||
|
||||
- name: Maven compile
|
||||
run: ./mvnw compile -B -q -DskipTests -T 1C
|
||||
|
||||
- name: Maven test
|
||||
run: ./mvnw test -B -T 1C
|
||||
env:
|
||||
CI_POSTGRES_URL: jdbc:postgresql://localhost:5432/cannamanage_test
|
||||
CI_POSTGRES_USER: test
|
||||
CI_POSTGRES_PASSWORD: test
|
||||
|
||||
- name: OWASP Dependency-Check (SCA)
|
||||
run: |
|
||||
./mvnw org.owasp:dependency-check-maven:check \
|
||||
-DfailBuildOnCVSS=7 \
|
||||
-DsuppressionFile=.snyk-maven-suppressions.xml \
|
||||
-Dformats=JSON,HTML \
|
||||
-B -q
|
||||
# failBuildOnCVSS=7: High/Critical CVEs fail the build.
|
||||
# Suppress known false positives in .snyk-maven-suppressions.xml.
|
||||
|
||||
- name: Upload dependency-check report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: dependency-check-report
|
||||
path: target/dependency-check-report.*
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Frontend: lint + type-check + dependency audit
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install pnpm
|
||||
run: corepack enable && corepack prepare pnpm@10.8.1 --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd cannamanage-frontend && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: cd cannamanage-frontend && pnpm lint
|
||||
|
||||
- name: Type check
|
||||
run: cd cannamanage-frontend && pnpm type-check
|
||||
|
||||
- name: pnpm audit (SCA)
|
||||
run: |
|
||||
cd cannamanage-frontend
|
||||
pnpm audit --audit-level=high
|
||||
# Fails on High/Critical. Use .pnpmauditrc or --ignore for known exceptions.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Docker image security scan (Trivy)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
image-scan:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build images (parallel)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker build -t cannamanage-backend:scan -f Dockerfile.backend . &
|
||||
PID1=$!
|
||||
docker build -t cannamanage-frontend:scan -f cannamanage-frontend/Dockerfile cannamanage-frontend/ &
|
||||
PID2=$!
|
||||
wait $PID1 $PID2
|
||||
|
||||
- name: Install Trivy
|
||||
run: |
|
||||
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
||||
|
||||
- name: Scan backend image
|
||||
run: |
|
||||
trivy image \
|
||||
--severity HIGH,CRITICAL \
|
||||
--exit-code 1 \
|
||||
--ignore-unfixed \
|
||||
--format table \
|
||||
cannamanage-backend:scan
|
||||
|
||||
- name: Scan frontend image
|
||||
run: |
|
||||
trivy image \
|
||||
--severity HIGH,CRITICAL \
|
||||
--exit-code 1 \
|
||||
--ignore-unfixed \
|
||||
--format table \
|
||||
cannamanage-frontend:scan
|
||||
|
||||
- name: Scan backend image (full report — JSON)
|
||||
if: always()
|
||||
run: |
|
||||
trivy image \
|
||||
--format json \
|
||||
--output trivy-backend.json \
|
||||
cannamanage-backend:scan
|
||||
|
||||
- name: Scan frontend image (full report — JSON)
|
||||
if: always()
|
||||
run: |
|
||||
trivy image \
|
||||
--format json \
|
||||
--output trivy-frontend.json \
|
||||
cannamanage-frontend:scan
|
||||
|
||||
- name: Upload Trivy reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: trivy-reports
|
||||
path: trivy-*.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Secret detection (Gitleaks)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
secrets-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Gitleaks
|
||||
run: |
|
||||
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz \
|
||||
| tar -xz -C /usr/local/bin gitleaks
|
||||
|
||||
- name: Run Gitleaks
|
||||
run: |
|
||||
gitleaks detect \
|
||||
--source . \
|
||||
--report-format json \
|
||||
--report-path gitleaks-report.json \
|
||||
--exit-code 1
|
||||
|
||||
- name: Upload Gitleaks report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: gitleaks-report
|
||||
path: gitleaks-report.json
|
||||
+122
-40
@@ -1,51 +1,133 @@
|
||||
name: Deploy to Production
|
||||
name: Deploy to TrueNAS
|
||||
|
||||
# Auto-deploy on push to main.
|
||||
# Runs on the self-hosted Gitea Actions runner on TrueNAS.local
|
||||
# (container: cannamanage-act-runner). The runner mounts the host Docker
|
||||
# socket into the job container, so `docker compose` commands act on the
|
||||
# TrueNAS Docker daemon and (re)build/restart the live cannamanage stack.
|
||||
#
|
||||
# The job checks the repo out into its own workspace and builds from there,
|
||||
# so it always deploys exactly the pushed commit — it does NOT depend on the
|
||||
# old /mnt/VM_SSD_Pool/cannamanage host checkout.
|
||||
#
|
||||
# Compose project name is pinned to "cannamanage" so it updates the existing
|
||||
# containers and reuses the persistent "cannamanage_pgdata" volume on the host.
|
||||
# Live host ports: frontend 3000, backend 8081->8080 (LAN, healthcheck/debug).
|
||||
# db is internal-only (no host publish) — reachable as db:5432 on the compose net.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# Avoid overlapping deploys if pushes land in quick succession.
|
||||
concurrency:
|
||||
group: truenas-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Run backend tests
|
||||
run: ./mvnw verify -B -q
|
||||
|
||||
deploy:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
COMPOSE: docker compose -f docker-compose.yml -f docker-compose.truenas.yml -p cannamanage
|
||||
# Production secrets — set in Gitea repo Settings → Actions → Secrets.
|
||||
# AUTH_SECRET : NextAuth v5 session secret (rotating invalidates sessions)
|
||||
# JWT_SECRET : base64 backend HMAC key (rotating invalidates all tokens)
|
||||
# DB_PASSWORD : Postgres role password (must match the live DB role)
|
||||
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
|
||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||
steps:
|
||||
- name: Deploy to production
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: plate-software.de
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
cd /opt/cannamanage
|
||||
git pull origin main
|
||||
docker compose -f docker-compose.prod.yml build
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
- name: Check out pushed commit
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Wait for backend health
|
||||
sleep 15
|
||||
for i in 1 2 3 4 5; do
|
||||
if curl -sf http://127.0.0.1:8080/actuator/health > /dev/null 2>&1; then
|
||||
echo "✅ Deploy successful at $(date)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting... attempt $i/5"
|
||||
sleep 5
|
||||
done
|
||||
- name: Show toolchain
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker version --format 'docker {{.Server.Version}}'
|
||||
docker compose version
|
||||
|
||||
echo "❌ Deploy failed — backend unhealthy"
|
||||
docker compose -f docker-compose.prod.yml logs --tail=30 backend
|
||||
exit 1
|
||||
# NOTE: Backend tests (mvn test) and frontend lint (pnpm lint) are run locally
|
||||
# before pushing. The self-hosted act runner uses Docker-in-Docker which doesn't
|
||||
# support volume mounts for nested containers. Tests remain a local-only gate.
|
||||
|
||||
- name: Build images
|
||||
run: |
|
||||
set -euo pipefail
|
||||
$COMPOSE build
|
||||
|
||||
- name: Ensure DB up & reconcile role password
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Start just the db first (idempotent — reuses the running container
|
||||
# and the persistent cannamanage_pgdata volume).
|
||||
$COMPOSE up -d db
|
||||
echo "Waiting for db to accept connections ..."
|
||||
for i in $(seq 1 20); do
|
||||
if docker exec cannamanage-db pg_isready -U cannamanage -q; then break; fi
|
||||
echo " attempt $i/20 — waiting 3s"; sleep 3
|
||||
done
|
||||
# POSTGRES_PASSWORD only applies on FIRST volume init, so the existing
|
||||
# volume still holds the old role password. Force the live role to match
|
||||
# the rotated ${DB_PASSWORD} so the backend can authenticate. Local
|
||||
# socket connections inside the container use trust auth (no password).
|
||||
# Skipped when the secret is unset to avoid blanking the dev password.
|
||||
if [ -n "${DB_PASSWORD:-}" ]; then
|
||||
docker exec cannamanage-db psql -U cannamanage -d cannamanage \
|
||||
-c "ALTER USER cannamanage WITH PASSWORD '${DB_PASSWORD}';"
|
||||
echo "✅ DB role password reconciled"
|
||||
else
|
||||
echo "⚠️ DB_PASSWORD secret not set — leaving role password unchanged"
|
||||
fi
|
||||
|
||||
- name: Roll out stack
|
||||
run: |
|
||||
set -euo pipefail
|
||||
$COMPOSE up -d --remove-orphans
|
||||
|
||||
- name: Wait for backend health
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Waiting for backend health on :8081 ..."
|
||||
for i in $(seq 1 20); do
|
||||
if wget -q -O /dev/null http://192.168.188.119:8081/actuator/health; then
|
||||
echo "✅ Backend healthy after ${i} attempt(s)"
|
||||
exit 0
|
||||
fi
|
||||
echo " attempt $i/20 — waiting 6s"
|
||||
sleep 6
|
||||
done
|
||||
echo "❌ Backend did not become healthy — recent logs:"
|
||||
$COMPOSE logs --tail=40 backend
|
||||
exit 1
|
||||
|
||||
- name: Verify frontend
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Probe the frontend on its own loopback INSIDE the container via the
|
||||
# bundled node runtime. This is network-namespace-independent (no
|
||||
# reliance on the host port being wired during a mid-recreate window,
|
||||
# which caused a transient false-failure previously) and needs no
|
||||
# wget/curl in the image. Any HTTP status < 500 counts as "up" — the
|
||||
# root path returns 307 -> /login when unauthenticated, which is healthy.
|
||||
echo "Waiting for frontend on container loopback :3000 ..."
|
||||
for i in $(seq 1 20); do
|
||||
if docker exec cannamanage-frontend node -e "require('http').get('http://127.0.0.1:3000/',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))"; then
|
||||
echo "✅ Frontend responding after ${i} attempt(s)"
|
||||
exit 0
|
||||
fi
|
||||
echo " attempt $i/20 — waiting 5s"
|
||||
sleep 5
|
||||
done
|
||||
echo "❌ Frontend did not respond — recent logs:"
|
||||
$COMPOSE logs --tail=40 frontend
|
||||
exit 1
|
||||
|
||||
- name: Prune dangling images
|
||||
run: docker image prune -f || true
|
||||
|
||||
- name: Deployment summary
|
||||
run: |
|
||||
echo "=== CannaManage deployed to TrueNAS ==="
|
||||
echo "Commit: ${GITHUB_SHA}"
|
||||
echo "Backend: http://192.168.188.119:8081"
|
||||
echo "Frontend: http://192.168.188.119:3000"
|
||||
|
||||
@@ -15,3 +15,5 @@ cannamanage-frontend/.env.local
|
||||
|
||||
# Production secrets (never commit)
|
||||
.env
|
||||
~/
|
||||
~/
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- generic [ref=e2]:
|
||||
- navigation "Navigation Bar" [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- link "Home" [ref=e5] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e6]
|
||||
- link "Explore" [ref=e7] [cursor=pointer]:
|
||||
- /url: /explore/repos
|
||||
- link "Help" [ref=e8] [cursor=pointer]:
|
||||
- /url: https://docs.gitea.com
|
||||
- generic [ref=e9]:
|
||||
- link "Register" [ref=e10] [cursor=pointer]:
|
||||
- /url: /user/sign_up
|
||||
- img [ref=e11]
|
||||
- generic [ref=e13]: Register
|
||||
- link "Sign In" [ref=e14] [cursor=pointer]:
|
||||
- /url: /user/login
|
||||
- img [ref=e15]
|
||||
- generic [ref=e17]: Sign In
|
||||
- generic [ref=e18]:
|
||||
- generic [ref=e19]:
|
||||
- generic [ref=e21]:
|
||||
- generic [ref=e22]:
|
||||
- img [ref=e24]
|
||||
- generic [ref=e27]:
|
||||
- link "pplate" [ref=e28] [cursor=pointer]:
|
||||
- /url: /pplate
|
||||
- text: /
|
||||
- link "cannamanage" [ref=e29] [cursor=pointer]:
|
||||
- /url: /pplate/cannamanage
|
||||
- generic [ref=e30]:
|
||||
- link "RSS Feed" [ref=e31] [cursor=pointer]:
|
||||
- /url: /pplate/cannamanage.rss
|
||||
- img [ref=e32]
|
||||
- generic "Sign in to watch this repository." [ref=e35] [cursor=pointer]:
|
||||
- button "Watch" [disabled]:
|
||||
- img
|
||||
- generic: Watch
|
||||
- link "1" [ref=e36]:
|
||||
- /url: /pplate/cannamanage/watchers
|
||||
- generic "Sign in to star this repository." [ref=e38] [cursor=pointer]:
|
||||
- button "Star" [disabled]:
|
||||
- img
|
||||
- generic: Star
|
||||
- link "0" [ref=e39]:
|
||||
- /url: /pplate/cannamanage/stars
|
||||
- generic "Sign in to fork this repository.":
|
||||
- generic:
|
||||
- img
|
||||
- generic: Fork
|
||||
- link "0":
|
||||
- /url: /pplate/cannamanage/forks
|
||||
- navigation [ref=e41]:
|
||||
- generic [ref=e42]:
|
||||
- link "Code" [ref=e43] [cursor=pointer]:
|
||||
- /url: /pplate/cannamanage/src/
|
||||
- img [ref=e44]
|
||||
- generic [ref=e46]: Code
|
||||
- link "Issues 9" [ref=e47] [cursor=pointer]:
|
||||
- /url: /pplate/cannamanage/issues
|
||||
- img [ref=e48]
|
||||
- generic [ref=e51]: Issues
|
||||
- generic [ref=e52]: "9"
|
||||
- link "Pull Requests" [ref=e53] [cursor=pointer]:
|
||||
- /url: /pplate/cannamanage/pulls
|
||||
- img [ref=e54]
|
||||
- generic [ref=e56]: Pull Requests
|
||||
- link "Actions" [ref=e57] [cursor=pointer]:
|
||||
- /url: /pplate/cannamanage/actions
|
||||
- img [ref=e58]
|
||||
- generic [ref=e60]: Actions
|
||||
- link "Packages" [ref=e61] [cursor=pointer]:
|
||||
- /url: /pplate/cannamanage/packages
|
||||
- img [ref=e62]
|
||||
- generic [ref=e64]: Packages
|
||||
- link "Projects" [ref=e65] [cursor=pointer]:
|
||||
- /url: /pplate/cannamanage/projects
|
||||
- img [ref=e66]
|
||||
- generic [ref=e68]: Projects
|
||||
- link "Releases" [ref=e69] [cursor=pointer]:
|
||||
- /url: /pplate/cannamanage/releases
|
||||
- img [ref=e70]
|
||||
- generic [ref=e72]: Releases
|
||||
- link "Wiki" [ref=e73] [cursor=pointer]:
|
||||
- /url: /pplate/cannamanage/wiki
|
||||
- img [ref=e74]
|
||||
- generic [ref=e76]: Wiki
|
||||
- link "Activity" [ref=e77] [cursor=pointer]:
|
||||
- /url: /pplate/cannamanage/activity
|
||||
- img [ref=e78]
|
||||
- generic [ref=e80]: Activity
|
||||
- generic [ref=e83]:
|
||||
- generic [ref=e84]:
|
||||
- generic [ref=e86]:
|
||||
- generic "Failure" [ref=e87]:
|
||||
- img [ref=e88]
|
||||
- 'heading "ci(deploy): auto-deploy to TrueNAS via self-hosted Gitea Actions runner" [level=2] [ref=e90]'
|
||||
- generic [ref=e91]:
|
||||
- generic [ref=e92]:
|
||||
- link "deploy.yml" [ref=e93] [cursor=pointer]:
|
||||
- /url: /pplate/cannamanage/actions/?workflow=deploy.yml
|
||||
- text: ":"
|
||||
- text: Commit
|
||||
- link "3b15d7439d" [ref=e94] [cursor=pointer]:
|
||||
- /url: /pplate/cannamanage/commit/3b15d7439dceb6cb073f871a0955b0acd31630ee
|
||||
- text: pushed by
|
||||
- link "pplate" [ref=e95] [cursor=pointer]:
|
||||
- /url: /pplate
|
||||
- link "main" [ref=e97] [cursor=pointer]:
|
||||
- /url: /pplate/cannamanage/src/branch/main
|
||||
- generic [ref=e98]:
|
||||
- generic [ref=e99]:
|
||||
- link "Summary" [ref=e100] [cursor=pointer]:
|
||||
- /url: /pplate/cannamanage/actions/runs/29
|
||||
- img [ref=e101]
|
||||
- generic [ref=e103]: Summary
|
||||
- generic [ref=e105]: All jobs
|
||||
- list [ref=e106]:
|
||||
- listitem [ref=e107]:
|
||||
- link "Failure deploy 3s" [ref=e108] [cursor=pointer]:
|
||||
- /url: /pplate/cannamanage/actions/runs/29/jobs/57
|
||||
- generic "Failure" [ref=e109]:
|
||||
- img [ref=e110]
|
||||
- generic [ref=e112]: deploy
|
||||
- generic [ref=e113]: 3s
|
||||
- generic [ref=e115]: Run Details
|
||||
- list [ref=e116]:
|
||||
- listitem [ref=e117]:
|
||||
- link "Workflow file" [ref=e118] [cursor=pointer]:
|
||||
- /url: /pplate/cannamanage/actions/runs/29/workflow
|
||||
- img [ref=e119]
|
||||
- generic [ref=e121]: Workflow file
|
||||
- generic [ref=e123]:
|
||||
- generic [ref=e124]:
|
||||
- generic [ref=e125]:
|
||||
- text: Triggered via push •
|
||||
- generic "Jun 16, 2026, 6:52 PM" [ref=e126]: 1 hour ago
|
||||
- generic [ref=e127]:
|
||||
- generic "Failure" [ref=e128]:
|
||||
- img [ref=e129]
|
||||
- generic [ref=e131]: Failure
|
||||
- text: •
|
||||
- generic [ref=e132]: "Total duration: 3s"
|
||||
- generic [ref=e133]:
|
||||
- generic [ref=e134]:
|
||||
- heading "Workflow Dependencies" [level=4] [ref=e135]
|
||||
- generic [ref=e136]:
|
||||
- text: 1 jobs • 0 dependencies
|
||||
- generic [ref=e137]: • 0% success
|
||||
- generic [ref=e138]:
|
||||
- button "Already at 100% zoom" [disabled]:
|
||||
- img
|
||||
- button "Reset view" [ref=e139] [cursor=pointer]:
|
||||
- img [ref=e140]
|
||||
- button "Zoom out (Ctrl/Cmd + scroll on graph)" [ref=e142] [cursor=pointer]:
|
||||
- img [ref=e143]
|
||||
- img [ref=e147]:
|
||||
- generic "deploy" [ref=e148] [cursor=pointer]:
|
||||
- generic:
|
||||
- generic:
|
||||
- generic "failure":
|
||||
- img
|
||||
- generic [ref=e151]:
|
||||
- generic: deploy
|
||||
- generic: 3s
|
||||
- group "Footer" [ref=e152]:
|
||||
- contentinfo "About Software" [ref=e153]:
|
||||
- link "Powered by Gitea" [ref=e154] [cursor=pointer]:
|
||||
- /url: https://about.gitea.com
|
||||
- generic [ref=e155]: "Version: 1.26.2"
|
||||
- generic [ref=e156]:
|
||||
- text: "Page:"
|
||||
- strong [ref=e157]: 3ms
|
||||
- text: "Template:"
|
||||
- strong [ref=e158]: 1ms
|
||||
- group "Links" [ref=e159]:
|
||||
- menu [ref=e160] [cursor=pointer]:
|
||||
- generic [ref=e162]:
|
||||
- img [ref=e163]
|
||||
- text: Auto
|
||||
- menu [ref=e165] [cursor=pointer]:
|
||||
- generic [ref=e166]:
|
||||
- img [ref=e167]
|
||||
- text: English
|
||||
- link "Licenses" [ref=e169] [cursor=pointer]:
|
||||
- /url: /assets/licenses.txt
|
||||
- link "API" [ref=e170] [cursor=pointer]:
|
||||
- /url: /api/swagger
|
||||
@@ -0,0 +1,19 @@
|
||||
# Snyk (https://snyk.io) policy file — managed by Lumen
|
||||
# Ignores documented false positives and accepted risks.
|
||||
version: v1.25.0
|
||||
language-settings:
|
||||
java:
|
||||
countUntriaged: false
|
||||
|
||||
ignore:
|
||||
# CSRF disabled on stateless JWT API chain — intentional and correct per OWASP:
|
||||
# "If your application does not use cookies for authentication, CSRF is not a risk."
|
||||
# The API security filter chain (Order 1) uses Authorization: Bearer tokens only.
|
||||
# The portal filter chain (Order 2) correctly enables CSRF via CookieCsrfTokenRepository.
|
||||
SNYK-JAVA-ORGSPRINGFRAMEWORKSECURITY-CSRF:
|
||||
- 'cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java':
|
||||
reason: >-
|
||||
Stateless JWT API — CSRF not applicable. Browser never auto-sends
|
||||
Bearer tokens. Portal chain has CSRF enabled via CookieCsrfTokenRepository.
|
||||
expires: 2027-06-19T00:00:00.000Z
|
||||
created: 2026-06-19T07:00:00.000Z
|
||||
@@ -1,111 +1,87 @@
|
||||
# CannaManage
|
||||
|
||||
Multi-tenant cannabis club management platform for German **Anbauvereinigungen** (cultivation associations) under CanG §19.
|
||||
|
||||
## Overview
|
||||
|
||||
CannaManage handles member management, distribution tracking, and legal compliance for cannabis cultivation clubs in Germany. It enforces the strict quotas mandated by the Cannabis Act (CanG) — including monthly limits (50g adult / 30g under-21), daily limits (25g), and THC restrictions for minors.
|
||||
Full-stack management platform for German cannabis cultivation associations (Anbauvereinigungen) under the CanG/KCanG regulatory framework.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|-----------|
|
||||
| Runtime | Java 21 (Temurin) |
|
||||
| Framework | Spring Boot 4.0.6 |
|
||||
| Security | Spring Security 7.0 + JWT (JJWT 0.12.6) |
|
||||
| ORM | Hibernate 7 / JPA |
|
||||
| Database | PostgreSQL (prod), H2 (test) |
|
||||
| Migrations | Flyway 10 |
|
||||
| API Docs | SpringDoc OpenAPI 2.8.6 |
|
||||
| Build | Maven (multi-module) |
|
||||
| Container | Docker Compose (Postgres + app) |
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| **Frontend** | Next.js 15, React 19, TypeScript, Tailwind CSS 4, shadcn/ui |
|
||||
| **Backend** | Spring Boot 3.5, Java 17, Spring Security (JWT + session) |
|
||||
| **Database** | PostgreSQL 16, Flyway migrations |
|
||||
| **Infrastructure** | Docker Compose, Gitea Actions CI/CD, TrueNAS deployment |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
cannamanage/
|
||||
├── cannamanage-domain/ # JPA entities, enums, TenantContext
|
||||
├── cannamanage-service/ # Business logic, repositories, ComplianceService
|
||||
├── cannamanage-api/ # Spring Boot app, controllers, security, DTOs
|
||||
├── docs/
|
||||
│ └── sprint-2/ # Sprint planning docs
|
||||
└── docker-compose.yml # Local dev environment
|
||||
├── cannamanage-api/ # Spring Boot REST API (entry point)
|
||||
├── cannamanage-service/ # Business logic layer
|
||||
├── cannamanage-domain/ # JPA entities, enums, value objects
|
||||
├── cannamanage-frontend/ # Next.js frontend (pnpm)
|
||||
├── deploy/ # Deployment scripts & nginx config
|
||||
├── docker-compose.yml # Local development stack
|
||||
└── .gitea/workflows/ # CI/CD pipeline
|
||||
```
|
||||
|
||||
## Modules
|
||||
## Local Development
|
||||
|
||||
### cannamanage-domain
|
||||
JPA entities with multi-tenant isolation via `@Filter("tenantFilter")`:
|
||||
- `Member` — club members with age tracking
|
||||
- `Distribution` — cannabis distribution records
|
||||
- `MonthlyQuota` — per-member monthly usage tracking
|
||||
- `Batch` / `Strain` / `StockMovement` — inventory management
|
||||
- `Club` — association registration
|
||||
- `User` — authentication accounts
|
||||
### Prerequisites
|
||||
|
||||
### cannamanage-service
|
||||
- `ComplianceService` — CanG §19 quota enforcement (25 unit tests)
|
||||
- Repositories for all entities
|
||||
- Java 17+
|
||||
- Maven 3.9+
|
||||
- Node.js 22+ with pnpm 10+
|
||||
- Docker & Docker Compose
|
||||
|
||||
### cannamanage-api
|
||||
- **Auth** — JWT login + refresh token rotation (SHA-256 hashed)
|
||||
- **Members** — CRUD for association members
|
||||
- **Distributions** — compliance-gated distribution recording
|
||||
- **Stock** — batch and inventory management
|
||||
- **Compliance** — quota status API
|
||||
- Multi-tenant isolation via `TenantFilterAspect` (Hibernate @Filter activation)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/v1/auth/login` | Public | Login with email + password |
|
||||
| POST | `/api/v1/auth/refresh` | Public | Refresh token rotation |
|
||||
| GET | `/api/v1/compliance/quota/{memberId}` | ADMIN, MEMBER | Monthly quota status |
|
||||
| GET/POST/PUT | `/api/v1/members/**` | ADMIN, MEMBER | Member CRUD |
|
||||
| POST | `/api/v1/distributions/**` | ADMIN, MEMBER | Record distributions |
|
||||
| GET/POST | `/api/v1/stock/**` | ADMIN | Stock management |
|
||||
|
||||
Swagger UI: `http://localhost:8080/swagger-ui.html`
|
||||
|
||||
## Running Locally
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
# Start PostgreSQL
|
||||
docker compose up -d
|
||||
docker compose up -d db
|
||||
|
||||
# Run the app
|
||||
JAVA_HOME=/path/to/jdk-21 ./mvnw spring-boot:run -pl cannamanage-api
|
||||
|
||||
# Run all tests (H2 in-memory)
|
||||
JAVA_HOME=/path/to/jdk-21 ./mvnw clean verify
|
||||
# Run Spring Boot
|
||||
mvn spring-boot:run -f cannamanage-api/pom.xml -Dspring-boot.run.profiles=local
|
||||
```
|
||||
|
||||
## Testing
|
||||
### Frontend
|
||||
|
||||
- **37 tests total** — all green
|
||||
- 25 unit tests (`ComplianceServiceTest`) — quota enforcement logic
|
||||
- 7 integration tests (`AuthControllerIntegrationTest`) — full HTTP auth flow
|
||||
- 5 integration tests (`ComplianceControllerIntegrationTest`) — quota API with JWT
|
||||
```bash
|
||||
cd cannamanage-frontend
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Integration tests use `@SpringBootTest(webEnvironment = RANDOM_PORT)` with H2 and Spring's `RestClient`.
|
||||
The frontend runs on http://localhost:3000, backend on http://localhost:8080.
|
||||
|
||||
## Security Model
|
||||
### Full Stack (Docker)
|
||||
|
||||
- **Stateless JWT** — no session, no UserDetailsService
|
||||
- **Roles**: ADMIN (full access), MEMBER (self-service), STAFF (Sprint 3)
|
||||
- **Multi-tenancy**: Hibernate `@Filter` activated per-request via AOP aspect
|
||||
- **Refresh tokens**: SHA-256 hashed (Spring Security 7 enforces BCrypt 72-byte limit)
|
||||
- Token rotation on refresh — old tokens invalidated
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
## Sprint History
|
||||
## Deployment
|
||||
|
||||
| Sprint | Focus | Status |
|
||||
|--------|-------|--------|
|
||||
| 1 | Domain entities, ComplianceService, 25 tests | ✅ Done |
|
||||
| 2 | REST API, Spring Security, JWT, OpenAPI, integration tests | ✅ Done |
|
||||
| 3 | Member portal, STAFF role, real-time notifications | 📋 Planned |
|
||||
Push to `main` triggers the Gitea Actions CI pipeline which:
|
||||
1. Runs backend tests (`mvn test`)
|
||||
2. Runs frontend lint (`pnpm lint`)
|
||||
3. Builds Docker images
|
||||
4. Deploys to TrueNAS via Docker Compose
|
||||
5. Verifies backend health + frontend availability
|
||||
|
||||
Manual deploy:
|
||||
```bash
|
||||
cd deploy && ./deploy.sh
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|----------|---------|---------|
|
||||
| `CANNAMANAGE_SECURITY_JWT_SECRET` | JWT signing key (base64, 256-bit) | — (required) |
|
||||
| `CORS_ORIGINS` | Allowed CORS origins (comma-separated) | `http://localhost:3000` |
|
||||
| `SMTP_HOST` / `SMTP_PORT` | Mail server for invites | `localhost:1025` |
|
||||
| `SCHEDULERS_ENABLED` | Enable background jobs | `true` |
|
||||
|
||||
## License
|
||||
|
||||
Private — Patrick Plate
|
||||
Proprietary — Patrick Plate
|
||||
|
||||
@@ -140,6 +140,17 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
<!-- Rate limiting (Bucket4j + Caffeine cache) -->
|
||||
<dependency>
|
||||
<groupId>com.bucket4j</groupId>
|
||||
<artifactId>bucket4j-core</artifactId>
|
||||
<version>8.10.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
<version>3.1.8</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
@@ -21,6 +22,7 @@ import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'STAFF', 'MEMBER')")
|
||||
public class DocumentController {
|
||||
|
||||
private final DocumentService documentService;
|
||||
@@ -33,13 +35,14 @@ public class DocumentController {
|
||||
* Verify the requested document belongs to the caller's current tenant (club).
|
||||
* Prevents IDOR: a user from club A must not be able to download/delete a document of club B
|
||||
* just by guessing or enumerating the document UUID.
|
||||
* Returns 404 (not 403) to avoid revealing document existence to other tenants.
|
||||
*/
|
||||
private Document loadOwnedDocument(UUID documentId) {
|
||||
Document doc = documentService.getDocument(documentId);
|
||||
UUID currentTenantId = TenantContext.getCurrentTenant();
|
||||
if (currentTenantId == null || doc.getClubId() == null || !doc.getClubId().equals(currentTenantId)) {
|
||||
// Use 403 (not 404) — caller is authenticated, just not authorized for this resource.
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied to document");
|
||||
// Return 404 to prevent information leakage about document existence across tenants
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found");
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
@@ -78,6 +81,7 @@ public class DocumentController {
|
||||
}
|
||||
|
||||
@DeleteMapping("/documents/{id}")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'STAFF')")
|
||||
public ResponseEntity<Void> deleteDocument(
|
||||
@PathVariable UUID id,
|
||||
@RequestParam UUID clubId,
|
||||
@@ -87,7 +91,7 @@ public class DocumentController {
|
||||
Document doc = loadOwnedDocument(id);
|
||||
UUID currentTenantId = TenantContext.getCurrentTenant();
|
||||
if (!clubId.equals(currentTenantId)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Tenant mismatch");
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found");
|
||||
}
|
||||
UUID userId = UUID.fromString(principal.getName());
|
||||
documentService.deleteDocument(id, userId, doc.getClubId());
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.service.StorageQuotaService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* REST controller for storage quota information.
|
||||
* Provides endpoint to check current storage usage for the caller's club.
|
||||
* Club ID is extracted from the JWT/tenant context — not from request params.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/storage")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'STAFF')")
|
||||
public class StorageController {
|
||||
|
||||
private final StorageQuotaService storageQuotaService;
|
||||
|
||||
public StorageController(StorageQuotaService storageQuotaService) {
|
||||
this.storageQuotaService = storageQuotaService;
|
||||
}
|
||||
|
||||
@GetMapping("/usage")
|
||||
public ResponseEntity<StorageQuotaService.StorageUsageDTO> getUsage() {
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
return ResponseEntity.ok(storageQuotaService.getUsage(clubId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Test-only controller for resetting the database to a known seed state.
|
||||
* Only active when cannamanage.test.endpoints.enabled=true (test profile).
|
||||
* NEVER activate this in production.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/test")
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(name = "cannamanage.test.endpoints.enabled", havingValue = "true")
|
||||
public class TestResetController {
|
||||
|
||||
private final DataSource dataSource;
|
||||
|
||||
/**
|
||||
* Truncates all application tables and re-seeds with test data.
|
||||
* The Flyway schema_history table is preserved.
|
||||
*/
|
||||
@PostMapping("/reset-db")
|
||||
public ResponseEntity<Void> resetDatabase() {
|
||||
log.info("Test DB reset requested — truncating all tables and re-seeding");
|
||||
|
||||
try (Connection conn = dataSource.getConnection()) {
|
||||
truncateAllTables(conn);
|
||||
reseed();
|
||||
log.info("Test DB reset complete — seed data re-applied");
|
||||
return ResponseEntity.ok().build();
|
||||
} catch (SQLException e) {
|
||||
log.error("Failed to reset test database", e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
private void truncateAllTables(Connection conn) throws SQLException {
|
||||
List<String> tables = getApplicationTables(conn);
|
||||
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
// Disable FK constraints for truncation
|
||||
stmt.execute("SET session_replication_role = 'replica'");
|
||||
|
||||
for (String table : tables) {
|
||||
stmt.execute("TRUNCATE TABLE " + table + " CASCADE");
|
||||
log.debug("Truncated table: {}", table);
|
||||
}
|
||||
|
||||
// Re-enable FK constraints
|
||||
stmt.execute("SET session_replication_role = 'origin'");
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> getApplicationTables(Connection conn) throws SQLException {
|
||||
List<String> tables = new ArrayList<>();
|
||||
|
||||
try (Statement stmt = conn.createStatement();
|
||||
ResultSet rs = stmt.executeQuery(
|
||||
"SELECT tablename FROM pg_tables " +
|
||||
"WHERE schemaname = 'public' " +
|
||||
"AND tablename != 'flyway_schema_history'")) {
|
||||
while (rs.next()) {
|
||||
tables.add(rs.getString("tablename"));
|
||||
}
|
||||
}
|
||||
|
||||
return tables;
|
||||
}
|
||||
|
||||
private void reseed() {
|
||||
ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
|
||||
populator.addScript(new ClassPathResource("db/testdata/R__seed_test_data.sql"));
|
||||
populator.setSeparator(";");
|
||||
populator.execute(dataSource);
|
||||
}
|
||||
}
|
||||
+15
@@ -5,6 +5,7 @@ import de.cannamanage.service.exception.BatchNotFoundException;
|
||||
import de.cannamanage.service.exception.MemberNotFoundException;
|
||||
import de.cannamanage.service.exception.PreventionOfficerLimitExceededException;
|
||||
import de.cannamanage.service.exception.QuotaExceededException;
|
||||
import de.cannamanage.service.exception.StorageQuotaExceededException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
@@ -121,6 +122,20 @@ public class GlobalExceptionHandler {
|
||||
return problem;
|
||||
}
|
||||
|
||||
@ExceptionHandler(StorageQuotaExceededException.class)
|
||||
public ProblemDetail handleStorageQuotaExceeded(StorageQuotaExceededException ex) {
|
||||
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.PAYMENT_REQUIRED, ex.getMessage());
|
||||
problem.setTitle("Storage Quota Exceeded");
|
||||
problem.setType(URI.create("urn:cannamanage:error:STORAGE_QUOTA_EXCEEDED"));
|
||||
problem.setProperty("code", "STORAGE_QUOTA_EXCEEDED");
|
||||
problem.setProperty("currentUsage", ex.getCurrentUsage());
|
||||
problem.setProperty("limit", ex.getLimit());
|
||||
problem.setProperty("requestedBytes", ex.getRequestedBytes());
|
||||
problem.setProperty("timestamp", Instant.now().toString());
|
||||
return problem;
|
||||
}
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ProblemDetail handleResponseStatus(ResponseStatusException ex) {
|
||||
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import io.github.bucket4j.Bandwidth;
|
||||
import io.github.bucket4j.Bucket;
|
||||
import io.github.bucket4j.ConsumptionProbe;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Rate-limits login attempts per client IP using Bucket4j + Caffeine cache.
|
||||
* Allows 5 login attempts per minute per IP; returns 429 when exhausted.
|
||||
*/
|
||||
@Component
|
||||
@Order(1)
|
||||
public class LoginRateLimitFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final String LOGIN_PATH = "/api/v1/auth/login";
|
||||
private static final int CAPACITY = 5;
|
||||
private static final Duration REFILL_PERIOD = Duration.ofMinutes(1);
|
||||
|
||||
private final Cache<String, Bucket> buckets = Caffeine.newBuilder()
|
||||
.maximumSize(10_000)
|
||||
.expireAfterAccess(Duration.ofMinutes(10))
|
||||
.build();
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
if (!"POST".equalsIgnoreCase(request.getMethod()) || !LOGIN_PATH.equals(request.getRequestURI())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
String clientIp = resolveClientIp(request);
|
||||
Bucket bucket = buckets.get(clientIp, k -> createBucket());
|
||||
|
||||
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
|
||||
if (probe.isConsumed()) {
|
||||
filterChain.doFilter(request, response);
|
||||
} else {
|
||||
long waitSeconds = probe.getNanosToWaitForRefill() / 1_000_000_000 + 1;
|
||||
response.setStatus(429);
|
||||
response.setHeader("Retry-After", String.valueOf(waitSeconds));
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\":\"Too many login attempts. Retry after " + waitSeconds + "s\"}");
|
||||
}
|
||||
}
|
||||
|
||||
private Bucket createBucket() {
|
||||
return Bucket.builder()
|
||||
.addLimit(Bandwidth.builder()
|
||||
.capacity(CAPACITY)
|
||||
.refillGreedy(CAPACITY, REFILL_PERIOD)
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
private String resolveClientIp(HttpServletRequest request) {
|
||||
String xff = request.getHeader("X-Forwarded-For");
|
||||
if (xff != null && !xff.isBlank()) {
|
||||
// Take the first IP in the chain (original client)
|
||||
return xff.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
@@ -53,6 +54,13 @@ public class SecurityConfig {
|
||||
http
|
||||
.securityMatcher("/api/**")
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
// snyk:ignore java/CsrfProtectionDisabled — Intentional: this filter chain
|
||||
// handles stateless JWT-authenticated API calls only. CSRF attacks exploit
|
||||
// browser-managed session cookies; Bearer token auth is immune because the
|
||||
// token is never sent automatically by the browser. OWASP CSRF Prevention
|
||||
// Cheat Sheet: "If your application does not use cookies for authentication,
|
||||
// CSRF is not a risk." The portal chain (Order 2) correctly enables CSRF via
|
||||
// CookieCsrfTokenRepository for its session-based auth.
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
@@ -71,10 +79,13 @@ public class SecurityConfig {
|
||||
.requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF")
|
||||
.requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||
.requestMatchers("/api/v1/reports/**").hasRole("ADMIN")
|
||||
// Documents endpoint — explicit listing for defense-in-depth so it can
|
||||
// never accidentally end up in a permitAll() rule above. Per-document
|
||||
// tenant ownership is additionally enforced in DocumentController.
|
||||
.requestMatchers("/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||
// Documents endpoint — method-specific matchers for defense-in-depth.
|
||||
// POST (upload) and DELETE restricted to ADMIN/STAFF; GET allowed for all
|
||||
// authenticated roles. Per-document tenant ownership is additionally
|
||||
// enforced in DocumentController via TenantContext.
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||
.requestMatchers(HttpMethod.POST, "/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF")
|
||||
.requestMatchers(HttpMethod.DELETE, "/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF")
|
||||
.anyRequest().authenticated())
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ import java.util.UUID;
|
||||
@RequiredArgsConstructor
|
||||
public class AuthService {
|
||||
|
||||
private static final String INVALID_CREDENTIALS = "Invalid credentials";
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final JwtService jwtService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
@@ -43,14 +45,14 @@ public class AuthService {
|
||||
@Transactional
|
||||
public LoginResponse login(LoginRequest request) {
|
||||
User user = userRepository.findByEmail(request.email())
|
||||
.orElseThrow(() -> new AuthenticationException("Invalid credentials"));
|
||||
.orElseThrow(() -> new AuthenticationException(INVALID_CREDENTIALS));
|
||||
|
||||
if (!user.isActive()) {
|
||||
throw new AuthenticationException("Account not activated");
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
|
||||
throw new AuthenticationException("Invalid credentials");
|
||||
throw new AuthenticationException(INVALID_CREDENTIALS);
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
@@ -147,7 +149,7 @@ public class AuthService {
|
||||
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(hash);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException("SHA-256 not available", e);
|
||||
throw new IllegalStateException("SHA-256 not available", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# =============================================
|
||||
# application-test.properties
|
||||
# Profile: test — for integration test environment
|
||||
# Activate with: -Dspring.profiles.active=test
|
||||
# =============================================
|
||||
|
||||
# Database: use docker-compose.test.yml PostgreSQL
|
||||
spring.datasource.url=jdbc:postgresql://localhost:5433/cannamanage_test
|
||||
spring.datasource.username=cannamanage_test
|
||||
spring.datasource.password=test_password
|
||||
spring.jpa.hibernate.ddl-auto=validate
|
||||
|
||||
# Flyway: include test seed data
|
||||
spring.flyway.enabled=true
|
||||
spring.flyway.locations=classpath:db/migration,classpath:db/testdata
|
||||
|
||||
# Enable test-only endpoints (TestResetController)
|
||||
cannamanage.test.endpoints.enabled=true
|
||||
|
||||
# Disable schedulers during test runs
|
||||
cannamanage.schedulers.enabled=false
|
||||
|
||||
# JWT: deterministic test secret (base64-encoded 256-bit key)
|
||||
cannamanage.security.jwt.secret=dGVzdC1zZWNyZXQta2V5LWZvci1pbnRlZ3JhdGlvbi10ZXN0cy1vbmx5LTMyYg==
|
||||
cannamanage.security.jwt.access-token-expiry=3600
|
||||
cannamanage.security.jwt.refresh-token-expiry=86400
|
||||
|
||||
# Logging
|
||||
logging.level.de.cannamanage=DEBUG
|
||||
logging.level.org.flywaydb=INFO
|
||||
logging.level.org.springframework.security=DEBUG
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Fix schema drift: members table is missing columns that the JPA Member entity expects.
|
||||
-- user_id: links member to their login user account (nullable, set on portal registration)
|
||||
-- iban: member's IBAN for bank statement matching (Sprint 10, nullable, consent-gated)
|
||||
-- iban_consent_date: timestamp when BANK_DATA consent was granted
|
||||
|
||||
ALTER TABLE members ADD COLUMN IF NOT EXISTS user_id UUID;
|
||||
ALTER TABLE members ADD COLUMN IF NOT EXISTS iban VARCHAR(34);
|
||||
ALTER TABLE members ADD COLUMN IF NOT EXISTS iban_consent_date TIMESTAMPTZ;
|
||||
|
||||
-- Index for user_id lookups (portal login → member resolution)
|
||||
CREATE INDEX IF NOT EXISTS idx_members_user_id ON members(user_id);
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- Add created_at and updated_at to generated_reports (split from V27 to avoid checksum mismatch)
|
||||
ALTER TABLE generated_reports ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT NOW();
|
||||
ALTER TABLE generated_reports ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT NOW();
|
||||
@@ -0,0 +1,9 @@
|
||||
-- V36: Add storage quota tracking to clubs
|
||||
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS storage_used_bytes BIGINT DEFAULT 0;
|
||||
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS storage_limit_bytes BIGINT DEFAULT 5368709120;
|
||||
-- Default: 5 GB (5 * 1024^3) = Starter tier
|
||||
|
||||
-- Backfill existing clubs with actual usage
|
||||
UPDATE clubs c SET storage_used_bytes = COALESCE(
|
||||
(SELECT SUM(d.file_size) FROM documents d WHERE d.club_id = c.id), 0
|
||||
);
|
||||
@@ -0,0 +1,265 @@
|
||||
-- R__seed_test_data.sql — Repeatable Flyway migration for integration test data
|
||||
-- This file is idempotent: uses ON CONFLICT DO NOTHING for all inserts.
|
||||
-- Activated only when spring.flyway.locations includes classpath:db/testdata
|
||||
|
||||
-- ============================================================
|
||||
-- 1. CLUB
|
||||
-- ============================================================
|
||||
INSERT INTO clubs (id, tenant_id, name, address, license_number, max_members, status, created_at)
|
||||
VALUES (
|
||||
'a0000000-0000-0000-0000-000000000001',
|
||||
'a0000000-0000-0000-0000-000000000001',
|
||||
'Grüner Daumen e.V.',
|
||||
'Hanfstraße 42, 10115 Berlin',
|
||||
'LIC-2024-GD-001',
|
||||
500,
|
||||
'ACTIVE',
|
||||
'2024-01-01T00:00:00Z'
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 2. MEMBERS (7)
|
||||
-- ============================================================
|
||||
INSERT INTO members (id, tenant_id, club_id, first_name, last_name, email, date_of_birth, membership_date, membership_number, status, is_under_21, prevention_officer, created_at)
|
||||
VALUES
|
||||
('c1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Max', 'Mustermann', 'max@gruener-daumen.de', '1990-05-20', '2024-01-15', 'GD-001', 'ACTIVE', FALSE, FALSE, '2024-01-15T10:00:00Z'),
|
||||
('c1000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Anna', 'Schmidt', 'anna@gruener-daumen.de', '1985-11-03', '2024-02-01', 'GD-002', 'ACTIVE', FALSE, FALSE, '2024-02-01T10:00:00Z'),
|
||||
('c1000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Jonas', 'Weber', 'jonas@gruener-daumen.de', '2006-03-15', '2024-03-10', 'GD-003', 'ACTIVE', TRUE, FALSE, '2024-03-10T10:00:00Z'),
|
||||
('c1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Maria', 'Müller', 'maria@gruener-daumen.de', '1978-08-22', '2023-06-01', 'GD-004', 'SUSPENDED', FALSE, FALSE, '2023-06-01T10:00:00Z'),
|
||||
('c1000000-0000-0000-0000-000000000005', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Thomas', 'Müller', 'thomas@gruener-daumen.de', '1992-12-01', '2024-01-20', 'GD-005', 'ACTIVE', FALSE, FALSE, '2024-01-20T10:00:00Z'),
|
||||
('c1000000-0000-0000-0000-000000000006', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Lisa', 'Bauer', 'lisa@gruener-daumen.de', '1995-07-14', '2024-04-01', 'GD-006', 'ACTIVE', FALSE, FALSE, '2024-04-01T10:00:00Z'),
|
||||
('c1000000-0000-0000-0000-000000000007', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Karl', 'Fischer', 'karl@gruener-daumen.de', '1980-02-28', '2023-01-01', 'GD-007', 'EXPELLED', FALSE, FALSE, '2023-01-01T10:00:00Z')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 3. USERS (admin staff account)
|
||||
-- ============================================================
|
||||
INSERT INTO users (id, tenant_id, member_id, email, password_hash, role, active, created_at)
|
||||
VALUES (
|
||||
'b1000000-0000-0000-0000-000000000001',
|
||||
'a0000000-0000-0000-0000-000000000001',
|
||||
'c1000000-0000-0000-0000-000000000001',
|
||||
'admin@gruener-daumen.de',
|
||||
'$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy',
|
||||
'ROLE_ADMIN',
|
||||
TRUE,
|
||||
'2024-01-15T10:00:00Z'
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Additional user accounts for members who need to author forum/info-board posts
|
||||
INSERT INTO users (id, tenant_id, member_id, email, password_hash, role, active, created_at)
|
||||
VALUES
|
||||
('b1000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000002',
|
||||
'anna.user@gruener-daumen.de', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', 'ROLE_MEMBER', TRUE, '2024-02-01T10:00:00Z'),
|
||||
('b1000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000003',
|
||||
'jonas.user@gruener-daumen.de', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', 'ROLE_MEMBER', TRUE, '2024-03-10T10:00:00Z')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 4. STRAINS (3)
|
||||
-- ============================================================
|
||||
INSERT INTO strains (id, tenant_id, name, thc_percentage, cbd_percentage, description, created_at)
|
||||
VALUES
|
||||
('d1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Northern Lights', 18.50, 0.50, 'Klassische Indica, entspannend und schmerzlindernd', '2024-04-01T10:00:00Z'),
|
||||
('d1000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001',
|
||||
'CBD Critical Mass', 5.00, 12.00, 'CBD-dominante Sorte für medizinische Anwendungen', '2024-04-01T10:00:00Z'),
|
||||
('d1000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Amnesia Haze', 22.00, 0.10, 'Starke Sativa mit hohem THC-Gehalt', '2024-04-01T10:00:00Z')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 5. BATCHES (3)
|
||||
-- ============================================================
|
||||
INSERT INTO batches (id, tenant_id, strain_id, quantity_grams, harvest_date, batch_code, status, contamination_flag, created_at)
|
||||
VALUES
|
||||
('e1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'd1000000-0000-0000-0000-000000000001', 500.00, '2024-04-25', 'NL-2024-001', 'AVAILABLE', FALSE, '2024-05-01T10:00:00Z'),
|
||||
('e1000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001',
|
||||
'd1000000-0000-0000-0000-000000000002', 300.00, '2024-05-10', 'CM-2024-001', 'AVAILABLE', FALSE, '2024-05-15T10:00:00Z'),
|
||||
('e1000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001',
|
||||
'd1000000-0000-0000-0000-000000000003', 200.00, '2024-03-20', 'AH-2024-001', 'RECALLED', TRUE, '2024-04-01T10:00:00Z')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 6. DISTRIBUTIONS (3 recent)
|
||||
-- ============================================================
|
||||
INSERT INTO distributions (id, tenant_id, member_id, batch_id, quantity_grams, distributed_at, recorded_by, notes, thc_percentage, cbd_percentage, strain_name, created_at)
|
||||
VALUES
|
||||
('dd000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'c1000000-0000-0000-0000-000000000001', 'e1000000-0000-0000-0000-000000000001',
|
||||
5.00, NOW() - INTERVAL '2 days', 'c1000000-0000-0000-0000-000000000001', 'Reguläre Abgabe',
|
||||
18.50, 0.50, 'Northern Lights', NOW() - INTERVAL '2 days'),
|
||||
('dd000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001',
|
||||
'c1000000-0000-0000-0000-000000000002', 'e1000000-0000-0000-0000-000000000002',
|
||||
3.00, NOW() - INTERVAL '1 day', 'c1000000-0000-0000-0000-000000000001', 'CBD-Abgabe',
|
||||
5.00, 12.00, 'CBD Critical Mass', NOW() - INTERVAL '1 day'),
|
||||
('dd000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001',
|
||||
'c1000000-0000-0000-0000-000000000005', 'e1000000-0000-0000-0000-000000000002',
|
||||
23.00, NOW(), 'c1000000-0000-0000-0000-000000000001', 'Nahe am Monatslimit (25g)',
|
||||
5.00, 12.00, 'CBD Critical Mass', NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 7. MONTHLY QUOTAS (Thomas near-quota)
|
||||
-- ============================================================
|
||||
INSERT INTO monthly_quotas (id, tenant_id, member_id, year, month, total_distributed, max_allowed, version, created_at)
|
||||
VALUES
|
||||
('mq000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'c1000000-0000-0000-0000-000000000005',
|
||||
EXTRACT(YEAR FROM NOW())::INT, EXTRACT(MONTH FROM NOW())::INT,
|
||||
23.00, 25.00, 1, NOW())
|
||||
ON CONFLICT (member_id, year, month) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 8. DOCUMENTS (4)
|
||||
-- ============================================================
|
||||
INSERT INTO documents (id, tenant_id, club_id, title, category, filename, content_type, file_size, storage_path, access_level, description, uploaded_by, created_at)
|
||||
VALUES
|
||||
('f1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Vereinssatzung 2024', 'SATZUNG', 'satzung-2024.pdf', 'application/pdf', 245000,
|
||||
'/documents/a0000000/satzung-2024.pdf', 'ALL_MEMBERS', 'Aktuelle Vereinssatzung gemäß §18 KCanG',
|
||||
'b1000000-0000-0000-0000-000000000001', '2024-01-15T10:00:00Z'),
|
||||
('f1000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Protokoll MV März 2024', 'PROTOKOLL', 'protokoll-mv-2024-03.pdf', 'application/pdf', 128000,
|
||||
'/documents/a0000000/protokoll-mv-2024-03.pdf', 'ALL_MEMBERS', 'Protokoll der Mitgliederversammlung vom 15.03.2024',
|
||||
'b1000000-0000-0000-0000-000000000001', '2024-03-16T10:00:00Z'),
|
||||
('f1000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'KCanG-Genehmigung', 'GENEHMIGUNG', 'kcang-genehmigung.pdf', 'application/pdf', 340000,
|
||||
'/documents/a0000000/kcang-genehmigung.pdf', 'BOARD_ONLY', 'Genehmigungsbescheid nach §11 KCanG',
|
||||
'b1000000-0000-0000-0000-000000000001', '2024-01-10T10:00:00Z'),
|
||||
('f1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Mietvertrag', 'VERTRAG', 'mietvertrag-vereinsheim.pdf', 'application/pdf', 520000,
|
||||
'/documents/a0000000/mietvertrag-vereinsheim.pdf', 'BOARD_ONLY', 'Mietvertrag für Vereinsräume',
|
||||
'b1000000-0000-0000-0000-000000000001', '2023-12-01T10:00:00Z')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 9. BOARD POSITIONS (3)
|
||||
-- ============================================================
|
||||
INSERT INTO board_positions (id, tenant_id, club_id, title, description, sort_order, is_active, created_at)
|
||||
VALUES
|
||||
('g1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Vorsitzende/r', 'Erste/r Vorsitzende/r des Vereins', 1, TRUE, '2024-01-15T10:00:00Z'),
|
||||
('g1000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Kassenführung', 'Schatzmeister/in — Kassenführung und Finanzen', 2, TRUE, '2024-01-15T10:00:00Z'),
|
||||
('g1000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Schriftführung', 'Protokollführung und Korrespondenz', 3, TRUE, '2024-01-15T10:00:00Z')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Board members (Max = Vorsitzender, Anna = Kassenführung, Schriftführung = vacant)
|
||||
INSERT INTO board_members (id, tenant_id, club_id, position_id, member_id, elected_at, term_start, is_current, created_at)
|
||||
VALUES
|
||||
('gm000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'g1000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000001',
|
||||
'2024-01-15', '2024-01-15', TRUE, '2024-01-15T10:00:00Z'),
|
||||
('gm000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'g1000000-0000-0000-0000-000000000002', 'c1000000-0000-0000-0000-000000000002',
|
||||
'2024-01-15', '2024-01-15', TRUE, '2024-01-15T10:00:00Z')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 10. EVENTS (2)
|
||||
-- ============================================================
|
||||
INSERT INTO club_events (id, club_id, title, description, event_type, start_at, end_at, location, created_by, tenant_id, created_at, updated_at)
|
||||
VALUES
|
||||
('ev000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Mitgliederversammlung Q3', 'Ordentliche Mitgliederversammlung mit Vorstandswahl',
|
||||
'ASSEMBLY', NOW() + INTERVAL '14 days', NOW() + INTERVAL '14 days' + INTERVAL '2 hours',
|
||||
'Vereinsheim, Hanfstraße 42', 'b1000000-0000-0000-0000-000000000001',
|
||||
'a0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '7 days', NOW() - INTERVAL '7 days'),
|
||||
('ev000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Gartentag Mai', 'Gemeinsamer Gartentag — Pflege der Anbauflächen',
|
||||
'SOCIAL', NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days' + INTERVAL '4 hours',
|
||||
'Vereinsgarten', 'b1000000-0000-0000-0000-000000000001',
|
||||
'a0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '45 days', NOW() - INTERVAL '45 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 11. FORUM TOPICS (2) + REPLIES
|
||||
-- ============================================================
|
||||
INSERT INTO forum_topics (id, club_id, tenant_id, title, content, author_id, reply_count, last_reply_at, created_at)
|
||||
VALUES
|
||||
('ft000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Neue Sorten für Sommer', 'Welche Sorten sollen wir diesen Sommer anbauen? Ich schlage vor, mehr CBD-lastige Sorten zu probieren.',
|
||||
'b1000000-0000-0000-0000-000000000001', 3, NOW() - INTERVAL '2 days', NOW() - INTERVAL '10 days'),
|
||||
('ft000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Bewässerungssystem', 'Hat jemand Erfahrung mit automatischen Bewässerungssystemen für den Indoor-Bereich?',
|
||||
'b1000000-0000-0000-0000-000000000002', 1, NOW() - INTERVAL '5 days', NOW() - INTERVAL '7 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Forum replies
|
||||
INSERT INTO forum_replies (id, topic_id, club_id, tenant_id, content, author_id, created_at)
|
||||
VALUES
|
||||
('fr000000-0000-0000-0000-000000000001', 'ft000000-0000-0000-0000-000000000001',
|
||||
'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'CBD Critical Mass hat sich bei uns bewährt — guter Ertrag und medizinisch wertvoll!',
|
||||
'b1000000-0000-0000-0000-000000000002', NOW() - INTERVAL '9 days'),
|
||||
('fr000000-0000-0000-0000-000000000002', 'ft000000-0000-0000-0000-000000000001',
|
||||
'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Finde ich gut! Vielleicht auch Charlotte''s Web als weitere CBD-Option?',
|
||||
'b1000000-0000-0000-0000-000000000003', NOW() - INTERVAL '7 days'),
|
||||
('fr000000-0000-0000-0000-000000000003', 'ft000000-0000-0000-0000-000000000001',
|
||||
'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Stimme zu — lasst uns in der MV darüber abstimmen.',
|
||||
'b1000000-0000-0000-0000-000000000001', NOW() - INTERVAL '2 days'),
|
||||
('fr000000-0000-0000-0000-000000000004', 'ft000000-0000-0000-0000-000000000002',
|
||||
'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Wir nutzen BlueMat-Tropfer — funktioniert super für Erde und Kokos.',
|
||||
'b1000000-0000-0000-0000-000000000001', NOW() - INTERVAL '5 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 12. INFO BOARD POSTS (2)
|
||||
-- ============================================================
|
||||
INSERT INTO info_board_posts (id, club_id, title, content, category, is_pinned, is_archived, author_id, tenant_id, created_at, updated_at)
|
||||
VALUES
|
||||
('ib000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Willkommen neue Mitglieder', 'Herzlich willkommen bei Grüner Daumen e.V.! Bitte lest die Vereinssatzung und meldet euch bei Fragen beim Vorstand.',
|
||||
'GENERAL', TRUE, FALSE, 'b1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days'),
|
||||
('ib000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Öffnungszeiten Sommer', 'Ab Juni gelten erweiterte Öffnungszeiten: Mo-Fr 10-20 Uhr, Sa 10-16 Uhr.',
|
||||
'MAINTENANCE', FALSE, FALSE, 'b1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
NOW() - INTERVAL '14 days', NOW() - INTERVAL '14 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 13. GROW ENTRIES (2)
|
||||
-- ============================================================
|
||||
INSERT INTO grow_entries (id, name, strain_id, status, started_at, expected_harvest_at, notes, tenant_id, created_at, updated_at)
|
||||
VALUES
|
||||
('ge000000-0000-0000-0000-000000000001',
|
||||
'Northern Lights Batch #2', 'd1000000-0000-0000-0000-000000000001', 'VEGETATIVE',
|
||||
NOW() - INTERVAL '21 days', NOW() + INTERVAL '49 days',
|
||||
'Zweiter Indoor-Batch NL, 6 Pflanzen',
|
||||
'a0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '21 days', NOW() - INTERVAL '1 day'),
|
||||
('ge000000-0000-0000-0000-000000000002',
|
||||
'CBD Outdoor', 'd1000000-0000-0000-0000-000000000002', 'SEEDLING',
|
||||
NOW() - INTERVAL '7 days', NOW() + INTERVAL '90 days',
|
||||
'Outdoor-Test mit CBD Critical Mass, 4 Pflanzen',
|
||||
'a0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '7 days', NOW() - INTERVAL '1 day')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 14. COMPLIANCE DEADLINES (3)
|
||||
-- ============================================================
|
||||
INSERT INTO compliance_deadlines (id, tenant_id, club_id, area, title, description, due_date, is_recurring, created_at)
|
||||
VALUES
|
||||
('cd000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'KCANG', 'Jahresbericht', 'Jährlicher Tätigkeitsbericht an die zuständige Behörde gemäß §22 KCanG',
|
||||
(CURRENT_DATE + INTERVAL '60 days')::DATE, TRUE, NOW() - INTERVAL '30 days'),
|
||||
('cd000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'FINANCE', 'EÜR Abgabe', 'Einnahmen-Überschuss-Rechnung an das Finanzamt',
|
||||
(CURRENT_DATE - INTERVAL '5 days')::DATE, FALSE, NOW() - INTERVAL '60 days'),
|
||||
('cd000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'VEREIN', 'Mitgliederversammlung', 'Ordentliche Mitgliederversammlung (mindestens 1x jährlich)',
|
||||
(CURRENT_DATE + INTERVAL '14 days')::DATE, TRUE, NOW() - INTERVAL '14 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.domain.entity.Document;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.enums.DocumentAccessLevel;
|
||||
import de.cannamanage.domain.enums.DocumentCategory;
|
||||
import de.cannamanage.service.DocumentService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.Principal;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Security unit tests for {@link DocumentController}.
|
||||
* Verifies tenant isolation (IDOR protection) at the controller layer.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DocumentControllerSecurityTest {
|
||||
|
||||
@Mock
|
||||
private DocumentService documentService;
|
||||
|
||||
@InjectMocks
|
||||
private DocumentController documentController;
|
||||
|
||||
private static final UUID CLUB_A = UUID.fromString("00000000-0000-0000-0000-00000000000a");
|
||||
private static final UUID CLUB_B = UUID.fromString("00000000-0000-0000-0000-00000000000b");
|
||||
private static final UUID DOC_ID = UUID.fromString("00000000-0000-0000-0000-000000000099");
|
||||
private static final UUID USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// Default tenant context: CLUB_A
|
||||
TenantContext.setCurrentTenant(CLUB_A);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
TenantContext.clear();
|
||||
}
|
||||
|
||||
// --- T-09: Download wrong tenant → 404 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("downloadDocument — wrong tenant throws 404 (IDOR protection)")
|
||||
void testDownload_wrongTenant_returns404() {
|
||||
// Document belongs to CLUB_B but user's tenant is CLUB_A
|
||||
Document doc = new Document();
|
||||
doc.setId(DOC_ID);
|
||||
doc.setClubId(CLUB_B);
|
||||
doc.setFilename("secret.pdf");
|
||||
doc.setContentType("application/pdf");
|
||||
when(documentService.getDocument(DOC_ID)).thenReturn(doc);
|
||||
|
||||
assertThatThrownBy(() -> documentController.downloadDocument(DOC_ID))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(ex -> {
|
||||
ResponseStatusException rse = (ResponseStatusException) ex;
|
||||
assertThat(rse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
});
|
||||
}
|
||||
|
||||
// --- T-10: Download correct tenant → 200 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("downloadDocument — correct tenant returns content")
|
||||
void testDownload_correctTenant_succeeds() throws IOException {
|
||||
Document doc = new Document();
|
||||
doc.setId(DOC_ID);
|
||||
doc.setClubId(CLUB_A);
|
||||
doc.setFilename("report.pdf");
|
||||
doc.setContentType("application/pdf");
|
||||
doc.setStoragePath(CLUB_A + "/" + DOC_ID + "_report.pdf");
|
||||
when(documentService.getDocument(DOC_ID)).thenReturn(doc);
|
||||
when(documentService.downloadDocument(DOC_ID)).thenReturn("test content".getBytes());
|
||||
|
||||
ResponseEntity<byte[]> response = documentController.downloadDocument(DOC_ID);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
}
|
||||
|
||||
// --- T-11: Delete wrong tenant → 404 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("deleteDocument — wrong tenant throws 404 (IDOR protection)")
|
||||
void testDelete_wrongTenant_returns404() {
|
||||
Document doc = new Document();
|
||||
doc.setId(DOC_ID);
|
||||
doc.setClubId(CLUB_B);
|
||||
doc.setTitle("Secret Doc");
|
||||
when(documentService.getDocument(DOC_ID)).thenReturn(doc);
|
||||
|
||||
Principal principal = mock(Principal.class);
|
||||
|
||||
assertThatThrownBy(() -> documentController.deleteDocument(DOC_ID, CLUB_A, principal))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(ex -> {
|
||||
ResponseStatusException rse = (ResponseStatusException) ex;
|
||||
assertThat(rse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
});
|
||||
}
|
||||
|
||||
// --- T-12: Delete correct tenant → 204 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("deleteDocument — correct tenant and matching clubId succeeds")
|
||||
void testDelete_correctTenant_succeeds() throws IOException {
|
||||
Document doc = new Document();
|
||||
doc.setId(DOC_ID);
|
||||
doc.setClubId(CLUB_A);
|
||||
doc.setTitle("My Doc");
|
||||
doc.setStoragePath(CLUB_A + "/" + DOC_ID + "_my.pdf");
|
||||
when(documentService.getDocument(DOC_ID)).thenReturn(doc);
|
||||
|
||||
Principal principal = mock(Principal.class);
|
||||
when(principal.getName()).thenReturn(USER_ID.toString());
|
||||
|
||||
ResponseEntity<Void> response = documentController.deleteDocument(DOC_ID, CLUB_A, principal);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
|
||||
// --- T-13/T-14: Upload role restriction is handled by Spring Security @PreAuthorize,
|
||||
// not testable in a pure unit test. Covered by SecurityConfigIntegrationTest. ---
|
||||
|
||||
@Test
|
||||
@DisplayName("deleteDocument — mismatched clubId param vs tenant throws 404")
|
||||
void testDelete_mismatchedClubIdParam_returns404() {
|
||||
// Document belongs to CLUB_A and tenant is CLUB_A, but clubId param is different
|
||||
Document doc = new Document();
|
||||
doc.setId(DOC_ID);
|
||||
doc.setClubId(CLUB_A);
|
||||
doc.setTitle("Doc");
|
||||
when(documentService.getDocument(DOC_ID)).thenReturn(doc);
|
||||
|
||||
Principal principal = mock(Principal.class);
|
||||
|
||||
// Passing CLUB_B as the clubId param while tenant is CLUB_A
|
||||
assertThatThrownBy(() -> documentController.deleteDocument(DOC_ID, CLUB_B, principal))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(ex -> {
|
||||
ResponseStatusException rse = (ResponseStatusException) ex;
|
||||
assertThat(rse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
});
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package de.cannamanage.api.exception;
|
||||
|
||||
import de.cannamanage.service.exception.QuotaExceededException;
|
||||
import de.cannamanage.service.exception.QuotaViolationCode;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.validation.BeanPropertyBindingResult;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.core.MethodParameter;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link GlobalExceptionHandler} verifying RFC 9457 ProblemDetail
|
||||
* responses and ensuring no internal details (stack traces, paths) are leaked.
|
||||
*/
|
||||
class GlobalExceptionHandlerTest {
|
||||
|
||||
private GlobalExceptionHandler handler;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
handler = new GlobalExceptionHandler();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleValidation_returnsStatus400WithFieldErrors() throws Exception {
|
||||
// Simulate a validation failure with field errors
|
||||
BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(new Object(), "request");
|
||||
bindingResult.addError(new FieldError("request", "email", "must not be blank"));
|
||||
bindingResult.addError(new FieldError("request", "name", "size must be between 2 and 100"));
|
||||
|
||||
// MethodParameter is needed for MethodArgumentNotValidException constructor
|
||||
MethodParameter param = new MethodParameter(
|
||||
this.getClass().getDeclaredMethod("setUp"), -1);
|
||||
MethodArgumentNotValidException ex = new MethodArgumentNotValidException(param, bindingResult);
|
||||
|
||||
ProblemDetail problem = handler.handleValidation(ex);
|
||||
|
||||
assertThat(problem.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
|
||||
assertThat(problem.getTitle()).isEqualTo("Bad Request");
|
||||
assertThat(problem.getType().toString()).contains("VALIDATION_FAILED");
|
||||
assertThat(problem.getProperties()).containsKey("errors");
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> errors = (List<String>) problem.getProperties().get("errors");
|
||||
assertThat(errors).hasSize(2);
|
||||
assertThat(errors).anyMatch(e -> e.contains("email"));
|
||||
assertThat(errors).anyMatch(e -> e.contains("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleAccessDenied_returnsStatus403WithNoStackTrace() {
|
||||
AccessDeniedException ex = new AccessDeniedException("You shall not pass");
|
||||
|
||||
ProblemDetail problem = handler.handleAccessDenied(ex);
|
||||
|
||||
assertThat(problem.getStatus()).isEqualTo(HttpStatus.FORBIDDEN.value());
|
||||
assertThat(problem.getTitle()).isEqualTo("Forbidden");
|
||||
assertThat(problem.getDetail()).isEqualTo("Access denied");
|
||||
// SECURITY: original exception message NOT exposed
|
||||
assertThat(problem.getDetail()).doesNotContain("shall not pass");
|
||||
// SECURITY: no stack trace or internal paths
|
||||
assertThat(problem.getProperties()).doesNotContainKey("stackTrace");
|
||||
assertThat(problem.getProperties()).doesNotContainKey("trace");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleGenericException_returnsStatus500WithGenericMessage() {
|
||||
RuntimeException ex = new RuntimeException(
|
||||
"NullPointerException at com.internal.Service.process(Service.java:42)");
|
||||
|
||||
ProblemDetail problem = handler.handleGeneric(ex);
|
||||
|
||||
assertThat(problem.getStatus()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR.value());
|
||||
assertThat(problem.getTitle()).isEqualTo("Internal Server Error");
|
||||
assertThat(problem.getDetail()).isEqualTo("An unexpected error occurred");
|
||||
// SECURITY: internal details NOT leaked
|
||||
assertThat(problem.getDetail()).doesNotContain("NullPointerException");
|
||||
assertThat(problem.getDetail()).doesNotContain("Service.java");
|
||||
assertThat(problem.getDetail()).doesNotContain("com.internal");
|
||||
assertThat(problem.getProperties().get("code")).isEqualTo("INTERNAL_ERROR");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleQuotaExceeded_returnsStatus409WithCode() {
|
||||
QuotaExceededException ex = new QuotaExceededException(
|
||||
QuotaViolationCode.MEMBER_INACTIVE, "Member is inactive");
|
||||
|
||||
ProblemDetail problem = handler.handleQuotaExceeded(ex);
|
||||
|
||||
assertThat(problem.getStatus()).isEqualTo(HttpStatus.CONFLICT.value());
|
||||
assertThat(problem.getTitle()).isEqualTo("Compliance Violation");
|
||||
assertThat(problem.getDetail()).isEqualTo("Member is inactive");
|
||||
assertThat(problem.getProperties().get("code")).isEqualTo("MEMBER_INACTIVE");
|
||||
assertThat(problem.getProperties()).containsKey("timestamp");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleMemberNotFound_returnsStatus404WithRfc9457Body() {
|
||||
var ex = new de.cannamanage.service.exception.MemberNotFoundException(UUID.randomUUID());
|
||||
|
||||
ProblemDetail problem = handler.handleMemberNotFound(ex);
|
||||
|
||||
assertThat(problem.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value());
|
||||
assertThat(problem.getTitle()).isEqualTo("Not Found");
|
||||
assertThat(problem.getType().toString()).contains("MEMBER_NOT_FOUND");
|
||||
assertThat(problem.getProperties().get("code")).isEqualTo("MEMBER_NOT_FOUND");
|
||||
assertThat(problem.getProperties()).containsKey("timestamp");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAllHandlers_includeTimestamp_neverExposeInternalState() {
|
||||
// Verify that all handlers set the timestamp property
|
||||
ProblemDetail p1 = handler.handleAccessDenied(new AccessDeniedException("x"));
|
||||
ProblemDetail p2 = handler.handleGeneric(new RuntimeException("internal error details"));
|
||||
ProblemDetail p3 = handler.handleQuotaExceeded(
|
||||
new QuotaExceededException(QuotaViolationCode.MEMBER_INACTIVE, "msg"));
|
||||
|
||||
assertThat(p1.getProperties()).containsKey("timestamp");
|
||||
assertThat(p2.getProperties()).containsKey("timestamp");
|
||||
assertThat(p3.getProperties()).containsKey("timestamp");
|
||||
|
||||
// None should expose stack traces or class paths
|
||||
for (ProblemDetail p : List.of(p1, p2, p3)) {
|
||||
assertThat(p.getProperties()).doesNotContainKey("stackTrace");
|
||||
assertThat(p.getProperties()).doesNotContainKey("exception");
|
||||
if (p.getDetail() != null) {
|
||||
assertThat(p.getDetail()).doesNotContain(".java:");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
-9
@@ -8,6 +8,7 @@ import de.cannamanage.api.dto.stock.BatchResponse;
|
||||
import de.cannamanage.api.dto.stock.CreateBatchRequest;
|
||||
import de.cannamanage.domain.entity.Club;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.entity.User;
|
||||
import de.cannamanage.domain.enums.ClubStatus;
|
||||
import de.cannamanage.domain.enums.UserRole;
|
||||
@@ -41,16 +42,31 @@ import java.util.UUID;
|
||||
public abstract class AbstractIntegrationTest {
|
||||
|
||||
@Container
|
||||
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
|
||||
.withDatabaseName("cannamanage_test")
|
||||
.withUsername("test")
|
||||
.withPassword("test");
|
||||
static PostgreSQLContainer<?> postgres = shouldUseTestcontainers()
|
||||
? new PostgreSQLContainer<>("postgres:16-alpine")
|
||||
.withDatabaseName("cannamanage_test")
|
||||
.withUsername("test")
|
||||
.withPassword("test")
|
||||
: null;
|
||||
|
||||
@DynamicPropertySource
|
||||
static void configureProperties(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", postgres::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", postgres::getUsername);
|
||||
registry.add("spring.datasource.password", postgres::getPassword);
|
||||
if (postgres != null) {
|
||||
registry.add("spring.datasource.url", postgres::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", postgres::getUsername);
|
||||
registry.add("spring.datasource.password", postgres::getPassword);
|
||||
} else {
|
||||
registry.add("spring.datasource.url", () -> System.getenv("CI_POSTGRES_URL"));
|
||||
registry.add("spring.datasource.username", () -> System.getenv("CI_POSTGRES_USER"));
|
||||
registry.add("spring.datasource.password", () -> System.getenv("CI_POSTGRES_PASSWORD"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use Testcontainers locally; skip when CI provides PostgreSQL via service container.
|
||||
*/
|
||||
private static boolean shouldUseTestcontainers() {
|
||||
return System.getenv("CI_POSTGRES_URL") == null;
|
||||
}
|
||||
|
||||
@LocalServerPort
|
||||
@@ -105,16 +121,23 @@ public abstract class AbstractIntegrationTest {
|
||||
// --- Test data creation helpers ---
|
||||
|
||||
/**
|
||||
* Creates a club (tenant) and returns its ID.
|
||||
* Creates a club (tenant) and returns its tenant ID.
|
||||
* IMPORTANT: Sets TenantContext for all subsequent entity creation.
|
||||
* The returned UUID is the tenantId (same value used for all entities).
|
||||
*/
|
||||
protected UUID createTestClub(String name) {
|
||||
// Pre-generate the tenant UUID — all entities will share this
|
||||
UUID tenantId = UUID.randomUUID();
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
Club club = new Club();
|
||||
club.setName(name);
|
||||
club.setLicenseNumber("LIC-" + UUID.randomUUID().toString().substring(0, 8));
|
||||
club.setStatus(ClubStatus.ACTIVE);
|
||||
club.setMaxMembers(500);
|
||||
club.setMaxPreventionOfficers(3);
|
||||
club = clubRepository.save(club);
|
||||
return club.getId();
|
||||
// TenantContext remains set — @PrePersist will use it for subsequent entities
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
package de.cannamanage.api.integration;
|
||||
|
||||
import de.cannamanage.api.controller.AssemblyController;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.enums.AgendaItemType;
|
||||
import de.cannamanage.domain.enums.AssemblyType;
|
||||
import de.cannamanage.domain.enums.VoteDecision;
|
||||
import de.cannamanage.domain.enums.VoteType;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Integration test verifying the full assembly (Mitgliederversammlung) lifecycle end-to-end.
|
||||
* Tests creation, quorum enforcement, voting with majority thresholds, and protocol generation.
|
||||
*/
|
||||
class AssemblyLifecycleIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
private UUID tenantId;
|
||||
private String adminToken;
|
||||
private UUID member1Id;
|
||||
private UUID member2Id;
|
||||
private UUID member3Id;
|
||||
|
||||
private static final String ADMIN_EMAIL = "asm-admin@test.de";
|
||||
private static final String ADMIN_PASSWORD = "AdminPass123!";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenantId = createTestClub("Assembly Test Club");
|
||||
createAdminUser(tenantId, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
adminToken = getAccessToken(ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
|
||||
// Create 3 members for quorum and voting tests
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
Member m1 = createMemberDirectly(tenantId, "Alice", "Meier", "alice@test.de", LocalDate.of(1990, 1, 1));
|
||||
Member m2 = createMemberDirectly(tenantId, "Bob", "Schmidt", "bob@test.de", LocalDate.of(1985, 6, 15));
|
||||
Member m3 = createMemberDirectly(tenantId, "Clara", "Weber", "clara@test.de", LocalDate.of(1992, 9, 30));
|
||||
member1Id = m1.getId();
|
||||
member2Id = m2.getId();
|
||||
member3Id = m3.getId();
|
||||
TenantContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Full assembly lifecycle: Create → Add agenda → Start → Vote → Complete")
|
||||
void testFullLifecycle_CreateStartVoteComplete() {
|
||||
// Step 1: Create assembly
|
||||
Instant scheduledAt = Instant.now().plus(1, ChronoUnit.HOURS);
|
||||
Map<String, Object> createRequest = Map.of(
|
||||
"title", "Ordentliche Mitgliederversammlung 2026",
|
||||
"assemblyType", "REGULAR",
|
||||
"scheduledAt", scheduledAt.toString(),
|
||||
"location", "Vereinsheim",
|
||||
"quorumRequired", 2,
|
||||
"agendaItems", List.of(
|
||||
Map.of("title", "Kassenbericht", "description", "Bericht des Schatzmeisters", "itemType", "DISCUSSION"),
|
||||
Map.of("title", "Vorstandswahl", "description", "Neuwahl des Vorstands", "itemType", "VOTE")
|
||||
)
|
||||
);
|
||||
|
||||
ResponseEntity<String> createResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(createRequest)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(createResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(createResponse.getBody()).contains("Ordentliche Mitgliederversammlung 2026");
|
||||
|
||||
// Extract assembly ID from response
|
||||
String assemblyId = extractId(createResponse.getBody());
|
||||
assertThat(assemblyId).isNotNull();
|
||||
|
||||
// Step 2: Check in attendees (quorum = 2, we check in 2 members)
|
||||
checkInAttendee(assemblyId, member1Id);
|
||||
checkInAttendee(assemblyId, member2Id);
|
||||
|
||||
// Step 3: Start assembly (quorum met with 2 attendees)
|
||||
ResponseEntity<String> startResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/start")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(startResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(startResponse.getBody()).contains("IN_PROGRESS");
|
||||
|
||||
// Step 4: Create a vote on the second agenda item
|
||||
// First get assembly detail to find agenda item IDs
|
||||
ResponseEntity<String> detailResponse = restClient().get()
|
||||
.uri("/api/v1/assemblies/" + assemblyId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
assertThat(detailResponse.getStatusCode().value()).isEqualTo(200);
|
||||
|
||||
String agendaItemId = extractSecondAgendaItemId(detailResponse.getBody());
|
||||
assertThat(agendaItemId).isNotNull();
|
||||
|
||||
Map<String, Object> voteRequest = Map.of(
|
||||
"agendaItemId", agendaItemId,
|
||||
"title", "Vorstandswahl Abstimmung",
|
||||
"description", "Wahl des neuen Vorstands",
|
||||
"voteType", "SIMPLE_MAJORITY"
|
||||
);
|
||||
|
||||
ResponseEntity<String> voteCreateResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/votes")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(voteRequest)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(voteCreateResponse.getStatusCode().value()).isEqualTo(200);
|
||||
String voteId = extractId(voteCreateResponse.getBody());
|
||||
|
||||
// Step 5: Cast votes — both members vote YES (simple majority passes)
|
||||
castVote(voteId, member1Id, "YES");
|
||||
castVote(voteId, member2Id, "YES");
|
||||
|
||||
// Step 6: Close vote
|
||||
ResponseEntity<String> closeVoteResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies/votes/" + voteId + "/close")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(closeVoteResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(closeVoteResponse.getBody()).contains("PASSED");
|
||||
|
||||
// Step 7: Complete assembly
|
||||
ResponseEntity<String> completeResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/complete")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(completeResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(completeResponse.getBody()).contains("COMPLETED");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Quorum check: not enough attendees — cannot start")
|
||||
void testQuorumCheck_InsufficientAttendees_CannotStart() {
|
||||
// Create assembly requiring quorum of 3
|
||||
Map<String, Object> createRequest = Map.of(
|
||||
"title", "Quorum Test Assembly",
|
||||
"assemblyType", "REGULAR",
|
||||
"scheduledAt", Instant.now().plus(1, ChronoUnit.HOURS).toString(),
|
||||
"location", "Online",
|
||||
"quorumRequired", 3
|
||||
);
|
||||
|
||||
ResponseEntity<String> createResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(createRequest)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
String assemblyId = extractId(createResponse.getBody());
|
||||
|
||||
// Check in only 2 members (quorum needs 3)
|
||||
checkInAttendee(assemblyId, member1Id);
|
||||
checkInAttendee(assemblyId, member2Id);
|
||||
|
||||
// Try to start — should fail due to quorum
|
||||
ResponseEntity<String> startResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/start")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
// Expect failure — quorum not met
|
||||
assertThat(startResponse.getStatusCode().value()).isIn(400, 422, 409);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Extraordinary assembly creation succeeds")
|
||||
void testExtraordinaryAssembly_CreationSucceeds() {
|
||||
Map<String, Object> createRequest = Map.of(
|
||||
"title", "Außerordentliche Versammlung",
|
||||
"assemblyType", "EXTRAORDINARY",
|
||||
"scheduledAt", Instant.now().plus(2, ChronoUnit.DAYS).toString(),
|
||||
"location", "Vereinsheim",
|
||||
"quorumRequired", 2
|
||||
);
|
||||
|
||||
ResponseEntity<String> response = restClient().post()
|
||||
.uri("/api/v1/assemblies")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(createRequest)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody()).contains("EXTRAORDINARY");
|
||||
assertThat(response.getBody()).contains("Außerordentliche Versammlung");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Vote with SIMPLE_MAJORITY: exact threshold (50%+1) passes")
|
||||
void testVote_SimpleMajority_ExactThreshold_Passes() {
|
||||
// Create and start assembly with 3 attendees
|
||||
String assemblyId = createAndStartAssemblyWith3Attendees();
|
||||
|
||||
// Get first agenda item ID
|
||||
ResponseEntity<String> detailResponse = restClient().get()
|
||||
.uri("/api/v1/assemblies/" + assemblyId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
String agendaItemId = extractFirstAgendaItemId(detailResponse.getBody());
|
||||
|
||||
// Create vote
|
||||
Map<String, Object> voteRequest = Map.of(
|
||||
"agendaItemId", agendaItemId,
|
||||
"title", "Majority Test",
|
||||
"description", "Testing exact majority threshold",
|
||||
"voteType", "SIMPLE_MAJORITY"
|
||||
);
|
||||
|
||||
ResponseEntity<String> voteResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/votes")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(voteRequest)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
String voteId = extractId(voteResponse.getBody());
|
||||
|
||||
// Cast votes: 2 YES, 1 NO — 2/3 > 50% → should pass
|
||||
castVote(voteId, member1Id, "YES");
|
||||
castVote(voteId, member2Id, "YES");
|
||||
castVote(voteId, member3Id, "NO");
|
||||
|
||||
// Close vote
|
||||
ResponseEntity<String> closeResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies/votes/" + voteId + "/close")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(closeResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(closeResponse.getBody()).contains("PASSED");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Archive assembly generates protocol document (PDF downloadable)")
|
||||
void testComplete_GeneratesProtocol_Downloadable() {
|
||||
// Create, start, and complete assembly
|
||||
String assemblyId = createAndStartAssemblyWith3Attendees();
|
||||
|
||||
// Complete the assembly
|
||||
restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/complete")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
// Download protocol PDF
|
||||
ResponseEntity<byte[]> protocolResponse = restClient().get()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/protocol")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(byte[].class);
|
||||
|
||||
assertThat(protocolResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(protocolResponse.getHeaders().getContentType())
|
||||
.isEqualTo(MediaType.APPLICATION_PDF);
|
||||
assertThat(protocolResponse.getBody()).isNotNull();
|
||||
assertThat(protocolResponse.getBody().length).isGreaterThan(0);
|
||||
}
|
||||
|
||||
// === Helper methods ===
|
||||
|
||||
private void checkInAttendee(String assemblyId, UUID memberId) {
|
||||
Map<String, Object> request = Map.of("memberId", memberId.toString());
|
||||
|
||||
ResponseEntity<String> response = restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/attendees")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
}
|
||||
|
||||
private void castVote(String voteId, UUID memberId, String decision) {
|
||||
Map<String, Object> request = Map.of(
|
||||
"memberId", memberId.toString(),
|
||||
"decision", decision
|
||||
);
|
||||
|
||||
ResponseEntity<String> response = restClient().post()
|
||||
.uri("/api/v1/assemblies/votes/" + voteId + "/cast")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
}
|
||||
|
||||
private String createAndStartAssemblyWith3Attendees() {
|
||||
Map<String, Object> createRequest = Map.of(
|
||||
"title", "Test Assembly",
|
||||
"assemblyType", "REGULAR",
|
||||
"scheduledAt", Instant.now().plus(1, ChronoUnit.HOURS).toString(),
|
||||
"location", "Online",
|
||||
"quorumRequired", 2,
|
||||
"agendaItems", List.of(
|
||||
Map.of("title", "Tagesordnungspunkt 1", "description", "Test", "itemType", "VOTE")
|
||||
)
|
||||
);
|
||||
|
||||
ResponseEntity<String> createResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(createRequest)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
String assemblyId = extractId(createResponse.getBody());
|
||||
|
||||
// Check in 3 attendees
|
||||
checkInAttendee(assemblyId, member1Id);
|
||||
checkInAttendee(assemblyId, member2Id);
|
||||
checkInAttendee(assemblyId, member3Id);
|
||||
|
||||
// Start assembly
|
||||
restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/start")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
return assemblyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the "id" field value from a JSON response body.
|
||||
* Simple regex extraction to avoid Jackson dependency in test.
|
||||
*/
|
||||
private String extractId(String jsonBody) {
|
||||
if (jsonBody == null) return null;
|
||||
var pattern = java.util.regex.Pattern.compile("\"id\"\\s*:\\s*\"([^\"]+)\"");
|
||||
var matcher = pattern.matcher(jsonBody);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the second agenda item's ID from the assembly detail response.
|
||||
*/
|
||||
private String extractSecondAgendaItemId(String jsonBody) {
|
||||
if (jsonBody == null) return null;
|
||||
var pattern = java.util.regex.Pattern.compile("\"agendaItems\"\\s*:\\s*\\[.*?\\{[^}]*\"id\"\\s*:\\s*\"([^\"]+)\"[^}]*\\}\\s*,\\s*\\{[^}]*\"id\"\\s*:\\s*\"([^\"]+)\"");
|
||||
var matcher = pattern.matcher(jsonBody);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(2);
|
||||
}
|
||||
// Fallback: try to get any agenda item ID
|
||||
return extractFirstAgendaItemId(jsonBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the first agenda item's ID from the assembly detail response.
|
||||
*/
|
||||
private String extractFirstAgendaItemId(String jsonBody) {
|
||||
if (jsonBody == null) return null;
|
||||
// Look for agendaItems array and extract first ID
|
||||
var pattern = java.util.regex.Pattern.compile("\"agendaItems\"\\s*:\\s*\\[\\s*\\{[^}]*\"id\"\\s*:\\s*\"([^\"]+)\"");
|
||||
var matcher = pattern.matcher(jsonBody);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
package de.cannamanage.api.integration;
|
||||
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Integration test verifying the full bank import lifecycle end-to-end.
|
||||
* Tests: upload MT940 → parse → auto-match → confirm → complete,
|
||||
* duplicate file detection, and session abandonment.
|
||||
*/
|
||||
class BankImportLifecycleIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
private UUID tenantId;
|
||||
private String adminToken;
|
||||
private UUID memberId;
|
||||
|
||||
private static final String ADMIN_EMAIL = "bank-admin@test.de";
|
||||
private static final String ADMIN_PASSWORD = "AdminPass123!";
|
||||
|
||||
/**
|
||||
* Minimal MT940 statement for testing. Contains one transaction
|
||||
* that can be auto-matched by name/IBAN.
|
||||
*/
|
||||
private static final String SAMPLE_MT940 = """
|
||||
:20:STARTUM
|
||||
:25:10010010/1234567890
|
||||
:28C:0
|
||||
:60F:C260101EUR1000,00
|
||||
:61:2601010101CR50,00N051NONREF
|
||||
:86:116?00GUTSCHRIFT?20Mitgliedsbeitrag?21Januar 2026?32MEIER ALICE?30TESTDE00?31DE89370400440532013000
|
||||
:62F:C260101EUR1050,00
|
||||
-
|
||||
""";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenantId = createTestClub("Bank Import Test Club");
|
||||
createAdminUser(tenantId, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
adminToken = getAccessToken(ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
|
||||
// Create a member with IBAN for auto-matching
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
Member member = createMemberDirectly(tenantId, "Alice", "Meier",
|
||||
"alice-bank@test.de", LocalDate.of(1990, 5, 20));
|
||||
member.setIban("DE89370400440532013000");
|
||||
member.setIbanConsentDate(Instant.now());
|
||||
memberRepository.save(member);
|
||||
memberId = member.getId();
|
||||
TenantContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Full flow: Upload MT940 → parse → confirm matches → complete")
|
||||
void testFullFlow_UploadMt940_MatchConfirmComplete() {
|
||||
// Step 1: Upload MT940 file
|
||||
String sessionId = uploadMt940(SAMPLE_MT940, "statement_jan.mt940");
|
||||
assertThat(sessionId).isNotNull();
|
||||
|
||||
// Step 2: Get session detail — should be OPEN
|
||||
ResponseEntity<String> sessionResponse = restClient().get()
|
||||
.uri("/api/v1/finance/import/sessions/" + sessionId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(sessionResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(sessionResponse.getBody()).contains(sessionId);
|
||||
|
||||
// Step 3: List transactions
|
||||
ResponseEntity<String> txnResponse = restClient().get()
|
||||
.uri("/api/v1/finance/import/sessions/" + sessionId + "/transactions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(txnResponse.getStatusCode().value()).isEqualTo(200);
|
||||
|
||||
// Step 4: Confirm all matched transactions
|
||||
ResponseEntity<String> confirmAllResponse = restClient().post()
|
||||
.uri("/api/v1/finance/import/sessions/" + sessionId + "/confirm-all")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(confirmAllResponse.getStatusCode().value()).isEqualTo(200);
|
||||
|
||||
// Step 5: Complete the session (GoBD seal)
|
||||
ResponseEntity<String> completeResponse = restClient().post()
|
||||
.uri("/api/v1/finance/import/sessions/" + sessionId + "/complete")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(completeResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(completeResponse.getBody()).contains("COMPLETED");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Duplicate file (same SHA-256 hash) rejected on second upload")
|
||||
void testDuplicateUpload_SameFile_Rejected() {
|
||||
// First upload — should succeed
|
||||
String sessionId = uploadMt940(SAMPLE_MT940, "duplicate_test.mt940");
|
||||
assertThat(sessionId).isNotNull();
|
||||
|
||||
// Second upload of same content — should be rejected
|
||||
ResponseEntity<String> duplicateResponse = uploadMt940Raw(SAMPLE_MT940, "duplicate_test_copy.mt940");
|
||||
|
||||
assertThat(duplicateResponse.getStatusCode().value()).isIn(409, 400, 422);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unmatched transactions remain in PENDING status")
|
||||
void testUnmatchedTransactions_RemainPending() {
|
||||
// MT940 with a transaction that won't match any member's IBAN
|
||||
String unmatchedMt940 = """
|
||||
:20:STARTUM
|
||||
:25:10010010/1234567890
|
||||
:28C:0
|
||||
:60F:C260101EUR1000,00
|
||||
:61:2601010101CR75,00N051NONREF
|
||||
:86:116?00GUTSCHRIFT?20Unbekannte Zahlung?21Ref XYZ?32UNBEKANNT PERSON?30NOBANK00?31DE00000000000000000000
|
||||
:62F:C260101EUR1075,00
|
||||
-
|
||||
""";
|
||||
|
||||
String sessionId = uploadMt940(unmatchedMt940, "unmatched_test.mt940");
|
||||
assertThat(sessionId).isNotNull();
|
||||
|
||||
// Get transactions filtered by PENDING/UNMATCHED status
|
||||
ResponseEntity<String> pendingResponse = restClient().get()
|
||||
.uri("/api/v1/finance/import/sessions/" + sessionId + "/transactions?status=PENDING")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(pendingResponse.getStatusCode().value()).isEqualTo(200);
|
||||
// Should contain at least one transaction (the unmatched one)
|
||||
assertThat(pendingResponse.getBody()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Completed session is immutable — cannot be modified")
|
||||
void testImmutability_CompleteSessionCannotBeModified() {
|
||||
// Upload and complete a session
|
||||
String sessionId = uploadMt940(SAMPLE_MT940 + " ", "immutable_test.mt940");
|
||||
assertThat(sessionId).isNotNull();
|
||||
|
||||
// Complete the session
|
||||
restClient().post()
|
||||
.uri("/api/v1/finance/import/sessions/" + sessionId + "/complete")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
// Try to confirm-all on completed session — should fail (GoBD immutability)
|
||||
ResponseEntity<String> confirmAfterComplete = restClient().post()
|
||||
.uri("/api/v1/finance/import/sessions/" + sessionId + "/confirm-all")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(confirmAfterComplete.getStatusCode().value()).isIn(400, 409, 422);
|
||||
}
|
||||
|
||||
// === Helper methods ===
|
||||
|
||||
/**
|
||||
* Uploads an MT940 file and returns the session ID from the response.
|
||||
*/
|
||||
private String uploadMt940(String content, String filename) {
|
||||
ResponseEntity<String> response = uploadMt940Raw(content, filename);
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(201);
|
||||
return extractId(response.getBody());
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an MT940 file and returns the raw ResponseEntity for assertion.
|
||||
* Uses multipart/form-data upload matching the controller's @RequestParam("file").
|
||||
*/
|
||||
private ResponseEntity<String> uploadMt940Raw(String content, String filename) {
|
||||
byte[] fileBytes = content.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
// Use RestClient with multipart — manual boundary construction
|
||||
String boundary = "----TestBoundary" + UUID.randomUUID().toString().replace("-", "");
|
||||
String body = "--" + boundary + "\r\n"
|
||||
+ "Content-Disposition: form-data; name=\"file\"; filename=\"" + filename + "\"\r\n"
|
||||
+ "Content-Type: application/octet-stream\r\n\r\n"
|
||||
+ content + "\r\n"
|
||||
+ "--" + boundary + "--\r\n";
|
||||
|
||||
return restClient().post()
|
||||
.uri("/api/v1/finance/import/sessions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.header(HttpHeaders.CONTENT_TYPE, "multipart/form-data; boundary=" + boundary)
|
||||
.body(body.getBytes(StandardCharsets.UTF_8))
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the "id" field value from a JSON response body.
|
||||
*/
|
||||
private String extractId(String jsonBody) {
|
||||
if (jsonBody == null) return null;
|
||||
var pattern = java.util.regex.Pattern.compile("\"id\"\\s*:\\s*\"([^\"]+)\"");
|
||||
var matcher = pattern.matcher(jsonBody);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
package de.cannamanage.api.integration;
|
||||
|
||||
import de.cannamanage.api.dto.distribution.CreateDistributionRequest;
|
||||
import de.cannamanage.api.dto.distribution.DistributionResponse;
|
||||
import de.cannamanage.domain.entity.Batch;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.entity.Strain;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.enums.BatchStatus;
|
||||
import de.cannamanage.domain.enums.MemberStatus;
|
||||
import de.cannamanage.service.repository.BatchRepository;
|
||||
import de.cannamanage.service.repository.StrainRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Integration test verifying the full distribution lifecycle end-to-end.
|
||||
* Tests CanG §19 compliance checks (daily/monthly quotas, U21 THC limits, inactive member rejection).
|
||||
*/
|
||||
class DistributionLifecycleIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private StrainRepository strainRepository;
|
||||
|
||||
@Autowired
|
||||
private BatchRepository batchRepository;
|
||||
|
||||
private UUID tenantId;
|
||||
private String adminToken;
|
||||
private UUID memberId;
|
||||
private UUID batchId;
|
||||
|
||||
private static final String ADMIN_EMAIL = "dist-admin@test.de";
|
||||
private static final String ADMIN_PASSWORD = "AdminPass123!";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenantId = createTestClub("Distribution Test Club");
|
||||
createAdminUser(tenantId, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
adminToken = getAccessToken(ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
|
||||
// Create an active member (adult, born 1990)
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
Member member = createMemberDirectly(tenantId, "Max", "Muster",
|
||||
"max@test.de", LocalDate.of(1990, 1, 15));
|
||||
memberId = member.getId();
|
||||
|
||||
// Create a strain + batch with stock
|
||||
Strain strain = new Strain();
|
||||
strain.setTenantId(tenantId);
|
||||
strain.setName("Test Strain");
|
||||
strain.setThcPercentage(new BigDecimal("15.0"));
|
||||
strain.setCbdPercentage(new BigDecimal("2.0"));
|
||||
strain = strainRepository.save(strain);
|
||||
|
||||
Batch batch = new Batch();
|
||||
batch.setTenantId(tenantId);
|
||||
batch.setStrainId(strain.getId());
|
||||
batch.setQuantityGrams(new BigDecimal("500.0"));
|
||||
batch.setHarvestDate(LocalDate.now().minusDays(7));
|
||||
batch.setBatchCode("BATCH-TEST-001");
|
||||
batch.setStatus(BatchStatus.AVAILABLE);
|
||||
batch = batchRepository.save(batch);
|
||||
batchId = batch.getId();
|
||||
|
||||
TenantContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Create distribution for member — succeeds and records distribution")
|
||||
void testCreateDistribution_ValidRequest_Succeeds() {
|
||||
CreateDistributionRequest request = new CreateDistributionRequest(
|
||||
memberId, batchId, new BigDecimal("5.0"), "Test distribution");
|
||||
|
||||
ResponseEntity<DistributionResponse> response = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.toEntity(DistributionResponse.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(201);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().memberId()).isEqualTo(memberId);
|
||||
assertThat(response.getBody().batchId()).isEqualTo(batchId);
|
||||
assertThat(response.getBody().quantityGrams()).isEqualByComparingTo(new BigDecimal("5.0"));
|
||||
assertThat(response.getBody().distributedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Distribution respects daily quota (25g) — boundary test at limit")
|
||||
void testCreateDistribution_DailyQuotaExceeded_Rejected() {
|
||||
// First: distribute 24g (just under limit)
|
||||
CreateDistributionRequest request1 = new CreateDistributionRequest(
|
||||
memberId, batchId, new BigDecimal("24.0"), null);
|
||||
|
||||
ResponseEntity<DistributionResponse> response1 = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request1)
|
||||
.retrieve()
|
||||
.toEntity(DistributionResponse.class);
|
||||
assertThat(response1.getStatusCode().value()).isEqualTo(201);
|
||||
|
||||
// Second: distribute 1g more (should work — exactly at 25g)
|
||||
CreateDistributionRequest request2 = new CreateDistributionRequest(
|
||||
memberId, batchId, new BigDecimal("1.0"), null);
|
||||
|
||||
ResponseEntity<DistributionResponse> response2 = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request2)
|
||||
.retrieve()
|
||||
.toEntity(DistributionResponse.class);
|
||||
assertThat(response2.getStatusCode().value()).isEqualTo(201);
|
||||
|
||||
// Third: 0.01g more — exceeds daily limit of 25g
|
||||
CreateDistributionRequest request3 = new CreateDistributionRequest(
|
||||
memberId, batchId, new BigDecimal("0.01"), null);
|
||||
|
||||
ResponseEntity<String> response3 = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request3)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response3.getStatusCode().value()).isIn(422, 400);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Distribution respects monthly quota (50g) — boundary test at limit")
|
||||
void testCreateDistribution_MonthlyQuotaExceeded_Rejected() {
|
||||
// Distribute 25g (daily max) — first day
|
||||
CreateDistributionRequest request1 = new CreateDistributionRequest(
|
||||
memberId, batchId, new BigDecimal("25.0"), null);
|
||||
|
||||
ResponseEntity<DistributionResponse> response1 = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request1)
|
||||
.retrieve()
|
||||
.toEntity(DistributionResponse.class);
|
||||
assertThat(response1.getStatusCode().value()).isEqualTo(201);
|
||||
|
||||
// Now try to distribute 25.01g more — would exceed monthly 50g for adults
|
||||
// (in reality this is the same day so daily limit triggers first at 25g,
|
||||
// but the monthly check also applies)
|
||||
CreateDistributionRequest request2 = new CreateDistributionRequest(
|
||||
memberId, batchId, new BigDecimal("25.01"), null);
|
||||
|
||||
ResponseEntity<String> response2 = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request2)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
// Should be rejected (either daily or monthly limit)
|
||||
assertThat(response2.getStatusCode().value()).isIn(422, 400);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("U21 member gets lower THC restriction — high-THC strain rejected")
|
||||
void testCreateDistribution_Under21HighThc_Rejected() {
|
||||
// Create an under-21 member (born 5 years ago = 5 years old, but set under21=true)
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
Member youngMember = new Member();
|
||||
youngMember.setTenantId(tenantId);
|
||||
youngMember.setClubId(tenantId);
|
||||
youngMember.setFirstName("Jung");
|
||||
youngMember.setLastName("Mitglied");
|
||||
youngMember.setEmail("jung@test.de");
|
||||
youngMember.setDateOfBirth(LocalDate.now().minusYears(19));
|
||||
youngMember.setMembershipDate(LocalDate.now());
|
||||
youngMember.setMembershipNumber("M-U21-001");
|
||||
youngMember.setUnder21(true);
|
||||
youngMember.setStatus(MemberStatus.ACTIVE);
|
||||
youngMember = memberRepository.save(youngMember);
|
||||
|
||||
// Create a strain with THC > 10% (the U21 limit)
|
||||
Strain highThcStrain = new Strain();
|
||||
highThcStrain.setTenantId(tenantId);
|
||||
highThcStrain.setName("High THC Strain");
|
||||
highThcStrain.setThcPercentage(new BigDecimal("15.0"));
|
||||
highThcStrain.setCbdPercentage(new BigDecimal("1.0"));
|
||||
highThcStrain = strainRepository.save(highThcStrain);
|
||||
|
||||
Batch highThcBatch = new Batch();
|
||||
highThcBatch.setTenantId(tenantId);
|
||||
highThcBatch.setStrainId(highThcStrain.getId());
|
||||
highThcBatch.setQuantityGrams(new BigDecimal("100.0"));
|
||||
highThcBatch.setHarvestDate(LocalDate.now().minusDays(3));
|
||||
highThcBatch.setBatchCode("BATCH-HIGH-THC-001");
|
||||
highThcBatch.setStatus(BatchStatus.AVAILABLE);
|
||||
highThcBatch = batchRepository.save(highThcBatch);
|
||||
TenantContext.clear();
|
||||
|
||||
// Try to distribute high-THC strain to U21 member
|
||||
CreateDistributionRequest request = new CreateDistributionRequest(
|
||||
youngMember.getId(), highThcBatch.getId(), new BigDecimal("3.0"), null);
|
||||
|
||||
ResponseEntity<String> response = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isIn(422, 400);
|
||||
assertThat(response.getBody()).containsIgnoringCase("THC");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Distribution to inactive member is rejected")
|
||||
void testCreateDistribution_InactiveMember_Rejected() {
|
||||
// Create an inactive member
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
Member inactiveMember = new Member();
|
||||
inactiveMember.setTenantId(tenantId);
|
||||
inactiveMember.setClubId(tenantId);
|
||||
inactiveMember.setFirstName("Inaktiv");
|
||||
inactiveMember.setLastName("Mitglied");
|
||||
inactiveMember.setEmail("inaktiv@test.de");
|
||||
inactiveMember.setDateOfBirth(LocalDate.of(1985, 6, 1));
|
||||
inactiveMember.setMembershipDate(LocalDate.now());
|
||||
inactiveMember.setMembershipNumber("M-INACTIVE-001");
|
||||
inactiveMember.setUnder21(false);
|
||||
inactiveMember.setStatus(MemberStatus.SUSPENDED);
|
||||
inactiveMember = memberRepository.save(inactiveMember);
|
||||
TenantContext.clear();
|
||||
|
||||
CreateDistributionRequest request = new CreateDistributionRequest(
|
||||
inactiveMember.getId(), batchId, new BigDecimal("5.0"), null);
|
||||
|
||||
ResponseEntity<String> response = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isIn(422, 400);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Batch distribution — multiple distributions in sequence succeed within limits")
|
||||
void testCreateDistribution_BatchMultipleMembers_Succeeds() {
|
||||
// Create a second member
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
Member member2 = createMemberDirectly(tenantId, "Anna", "Beispiel",
|
||||
"anna@test.de", LocalDate.of(1992, 3, 20));
|
||||
TenantContext.clear();
|
||||
|
||||
// Distribute to first member
|
||||
CreateDistributionRequest request1 = new CreateDistributionRequest(
|
||||
memberId, batchId, new BigDecimal("10.0"), null);
|
||||
|
||||
ResponseEntity<DistributionResponse> response1 = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request1)
|
||||
.retrieve()
|
||||
.toEntity(DistributionResponse.class);
|
||||
assertThat(response1.getStatusCode().value()).isEqualTo(201);
|
||||
|
||||
// Distribute to second member
|
||||
CreateDistributionRequest request2 = new CreateDistributionRequest(
|
||||
member2.getId(), batchId, new BigDecimal("15.0"), null);
|
||||
|
||||
ResponseEntity<DistributionResponse> response2 = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request2)
|
||||
.retrieve()
|
||||
.toEntity(DistributionResponse.class);
|
||||
assertThat(response2.getStatusCode().value()).isEqualTo(201);
|
||||
|
||||
// Verify both distributions are listed
|
||||
ResponseEntity<String> listResponse = restClient().get()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
assertThat(listResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(listResponse.getBody()).contains(memberId.toString());
|
||||
assertThat(listResponse.getBody()).contains(member2.getId().toString());
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package de.cannamanage.api.integration;
|
||||
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||
|
||||
/**
|
||||
* Integration test verifying Flyway migrations apply cleanly to a fresh PostgreSQL database.
|
||||
* Validates schema integrity, idempotency, and expected table existence.
|
||||
*/
|
||||
class MigrationIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Test
|
||||
@DisplayName("All Flyway migrations (V1–V34) apply cleanly on fresh database")
|
||||
void testFlywayMigration_AllMigrationsApply_NoErrors() {
|
||||
// The application context starts with Flyway auto-migration enabled,
|
||||
// so if we reach this point, all migrations applied successfully.
|
||||
// Verify via flyway_schema_history table.
|
||||
Integer migrationCount = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM flyway_schema_history WHERE success = true",
|
||||
Integer.class);
|
||||
|
||||
assertThat(migrationCount).isGreaterThanOrEqualTo(34);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Running Flyway migrate again is idempotent (no new migrations applied)")
|
||||
void testFlywayMigration_Idempotent_SecondRunNoOp() {
|
||||
// Grab a Flyway instance pointing at the same datasource
|
||||
Flyway flyway = Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.locations("classpath:db/migration")
|
||||
.load();
|
||||
|
||||
// Running migrate again should be a no-op (0 new migrations)
|
||||
assertThatNoException().isThrownBy(flyway::migrate);
|
||||
|
||||
// Verify no pending migrations
|
||||
var info = flyway.info();
|
||||
assertThat(info.pending()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Schema contains all expected core tables after migration")
|
||||
void testFlywayMigration_ExpectedTablesExist() {
|
||||
// Spot-check critical tables from various migrations
|
||||
List<String> expectedTables = List.of(
|
||||
"users",
|
||||
"members",
|
||||
"distributions",
|
||||
"clubs",
|
||||
"audit_events",
|
||||
"bank_import_sessions",
|
||||
"assemblies",
|
||||
"forum_topics",
|
||||
"batches",
|
||||
"strains",
|
||||
"monthly_quotas",
|
||||
"bank_transactions",
|
||||
"assembly_votes",
|
||||
"documents"
|
||||
);
|
||||
|
||||
for (String table : expectedTables) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM information_schema.tables " +
|
||||
"WHERE table_schema = 'public' AND table_name = ?",
|
||||
Integer.class, table);
|
||||
assertThat(count)
|
||||
.as("Table '%s' should exist in the schema", table)
|
||||
.isEqualTo(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package de.cannamanage.api.integration;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Integration test verifying Spring Security filter chain behavior end-to-end.
|
||||
* Tests public endpoints, JWT-protected endpoints, and CORS configuration.
|
||||
*/
|
||||
class SecurityConfigIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
private UUID tenantId;
|
||||
private static final String ADMIN_EMAIL = "sec-admin@test.de";
|
||||
private static final String ADMIN_PASSWORD = "SecurePass123!";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenantId = createTestClub("Security Config Test Club");
|
||||
createAdminUser(tenantId, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unauthenticated request to public endpoint (actuator/health) returns 200")
|
||||
void testUnauthenticated_PublicEndpoint_Allowed() {
|
||||
ResponseEntity<String> response = restClient().get()
|
||||
.uri("/actuator/health")
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unauthenticated request to protected endpoint returns 401/403")
|
||||
void testUnauthenticated_ProtectedEndpoint_Returns401() {
|
||||
ResponseEntity<String> response = restClient().get()
|
||||
.uri("/api/v1/members")
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isIn(401, 403);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Authenticated request to protected endpoint returns 200")
|
||||
void testAuthenticated_ProtectedEndpoint_Returns200() {
|
||||
String token = getAccessToken(ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
|
||||
ResponseEntity<String> response = restClient().get()
|
||||
.uri("/api/v1/members")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("CORS headers present on OPTIONS preflight request")
|
||||
void testCorsHeaders_PresentOnOptions() {
|
||||
ResponseEntity<String> response = restClient().options()
|
||||
.uri("/api/v1/members")
|
||||
.header(HttpHeaders.ORIGIN, "http://localhost:3000")
|
||||
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
|
||||
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "Authorization")
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
// Should not be blocked — allowed origin
|
||||
assertThat(response.getStatusCode().value()).isIn(200, 204);
|
||||
assertThat(response.getHeaders().getAccessControlAllowOrigin())
|
||||
.isEqualTo("http://localhost:3000");
|
||||
assertThat(response.getHeaders().getAccessControlAllowMethods())
|
||||
.isNotEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.io.Decoders;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.lang.reflect.Field;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link JwtService} covering token generation, parsing,
|
||||
* claim extraction, and security attack vectors.
|
||||
*/
|
||||
class JwtServiceTest {
|
||||
|
||||
private JwtService jwtService;
|
||||
|
||||
// A valid base64-encoded 256-bit secret for testing
|
||||
private static final String TEST_SECRET = Base64.getEncoder().encodeToString(
|
||||
"ThisIsA32ByteSecretKeyForTests!!".getBytes());
|
||||
|
||||
private UUID userId;
|
||||
private UUID tenantId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
jwtService = new JwtService();
|
||||
setField(jwtService, "secretKey", TEST_SECRET);
|
||||
setField(jwtService, "accessTokenExpiry", 3600L);
|
||||
setField(jwtService, "refreshTokenExpiry", 2592000L);
|
||||
|
||||
userId = UUID.randomUUID();
|
||||
tenantId = UUID.randomUUID();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateAccessToken_validClaims_containsExpectedFields() {
|
||||
String token = jwtService.generateAccessToken(userId, tenantId, "ADMIN", "test@example.com");
|
||||
|
||||
assertThat(token).isNotNull().isNotBlank();
|
||||
assertThat(jwtService.extractSubject(token)).isEqualTo(userId.toString());
|
||||
assertThat(jwtService.extractTenantId(token)).isEqualTo(tenantId);
|
||||
assertThat(jwtService.extractRole(token)).isEqualTo("ADMIN");
|
||||
assertThat(jwtService.extractEmail(token)).isEqualTo("test@example.com");
|
||||
assertThat(jwtService.extractJti(token)).isNotNull().isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractUserId_validToken_returnsCorrectUuid() {
|
||||
String token = jwtService.generateAccessToken(userId, tenantId, "MEMBER", "user@club.de");
|
||||
|
||||
UUID extracted = jwtService.extractUserId(token);
|
||||
assertThat(extracted).isEqualTo(userId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractRole_staffToken_returnsStaff() {
|
||||
String token = jwtService.generateStaffAccessToken(userId, tenantId, "staff@club.de",
|
||||
List.of("MANAGE_MEMBERS", "VIEW_FINANCES"));
|
||||
|
||||
assertThat(jwtService.extractRole(token)).isEqualTo("STAFF");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractPermissions_staffToken_returnsPermissionsList() {
|
||||
List<String> permissions = List.of("MANAGE_MEMBERS", "VIEW_FINANCES", "MANAGE_GROW");
|
||||
String token = jwtService.generateStaffAccessToken(userId, tenantId, "staff@club.de", permissions);
|
||||
|
||||
List<String> extracted = jwtService.extractPermissions(token);
|
||||
assertThat(extracted).containsExactlyInAnyOrderElementsOf(permissions);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractPermissions_nonStaffToken_returnsEmptyList() {
|
||||
String token = jwtService.generateAccessToken(userId, tenantId, "ADMIN", "admin@club.de");
|
||||
|
||||
List<String> extracted = jwtService.extractPermissions(token);
|
||||
assertThat(extracted).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractTenantId_validToken_returnsCorrectTenantUuid() {
|
||||
String token = jwtService.generateAccessToken(userId, tenantId, "MEMBER", "m@club.de");
|
||||
|
||||
UUID extracted = jwtService.extractTenantId(token);
|
||||
assertThat(extracted).isEqualTo(tenantId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTokenValid_freshToken_returnsTrue() {
|
||||
String token = jwtService.generateAccessToken(userId, tenantId, "ADMIN", "a@b.com");
|
||||
|
||||
assertThat(jwtService.isTokenValid(token)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTokenValid_expiredToken_returnsFalse() throws Exception {
|
||||
// Create a service with 0 second expiry
|
||||
JwtService shortLived = new JwtService();
|
||||
setField(shortLived, "secretKey", TEST_SECRET);
|
||||
setField(shortLived, "accessTokenExpiry", 0L);
|
||||
setField(shortLived, "refreshTokenExpiry", 0L);
|
||||
|
||||
String token = shortLived.generateAccessToken(userId, tenantId, "ADMIN", "a@b.com");
|
||||
|
||||
// Token with 0-second expiry is immediately expired
|
||||
Thread.sleep(50);
|
||||
assertThat(jwtService.isTokenValid(token)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTokenValid_invalidSignature_returnsFalse() {
|
||||
// Generate token with a different key
|
||||
String differentSecret = Base64.getEncoder().encodeToString(
|
||||
"ACompletelyDifferentKey1234567!!".getBytes());
|
||||
SecretKey wrongKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(differentSecret));
|
||||
|
||||
String forgedToken = Jwts.builder()
|
||||
.subject(userId.toString())
|
||||
.claim("tenant_id", tenantId.toString())
|
||||
.claim("role", "ADMIN")
|
||||
.issuedAt(Date.from(Instant.now()))
|
||||
.expiration(Date.from(Instant.now().plusSeconds(3600)))
|
||||
.signWith(wrongKey)
|
||||
.compact();
|
||||
|
||||
assertThat(jwtService.isTokenValid(forgedToken)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTokenValid_malformedToken_returnsFalse() {
|
||||
assertThat(jwtService.isTokenValid("not.a.valid.jwt.token")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTokenValid_nullToken_returnsFalse() {
|
||||
assertThat(jwtService.isTokenValid(null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTokenValid_emptyToken_returnsFalse() {
|
||||
assertThat(jwtService.isTokenValid("")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTokenValid_tamperedPayload_returnsFalse() {
|
||||
String token = jwtService.generateAccessToken(userId, tenantId, "MEMBER", "m@club.de");
|
||||
// Tamper with the payload (second segment) by modifying a character
|
||||
String[] parts = token.split("\\.");
|
||||
// Flip a character in the payload
|
||||
byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[1]);
|
||||
payloadBytes[5] = (byte) (payloadBytes[5] ^ 0xFF);
|
||||
parts[1] = Base64.getUrlEncoder().withoutPadding().encodeToString(payloadBytes);
|
||||
String tampered = String.join(".", parts);
|
||||
|
||||
assertThat(jwtService.isTokenValid(tampered)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateRefreshToken_containsRefreshType() {
|
||||
String token = jwtService.generateRefreshToken(userId, tenantId);
|
||||
|
||||
assertThat(token).isNotNull();
|
||||
assertThat(jwtService.extractSubject(token)).isEqualTo(userId.toString());
|
||||
assertThat(jwtService.extractTenantId(token)).isEqualTo(tenantId);
|
||||
assertThat(jwtService.extractJti(token)).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateSecret_tooShort_throwsIllegalState() throws Exception {
|
||||
JwtService invalid = new JwtService();
|
||||
setField(invalid, "secretKey", "short");
|
||||
setField(invalid, "accessTokenExpiry", 3600L);
|
||||
setField(invalid, "refreshTokenExpiry", 2592000L);
|
||||
|
||||
assertThatThrownBy(invalid::validateSecret)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("JWT secret is not configured");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateSecret_defaultPlaceholder_throwsIllegalState() throws Exception {
|
||||
JwtService invalid = new JwtService();
|
||||
setField(invalid, "secretKey", JwtService.UNCONFIGURED_SECRET_MARKER);
|
||||
setField(invalid, "accessTokenExpiry", 3600L);
|
||||
setField(invalid, "refreshTokenExpiry", 2592000L);
|
||||
|
||||
assertThatThrownBy(invalid::validateSecret)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("JWT secret is not configured");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractExpirationInstant_returnsNonNullFutureInstant() {
|
||||
String token = jwtService.generateAccessToken(userId, tenantId, "ADMIN", "a@b.com");
|
||||
|
||||
Instant expiration = jwtService.extractExpirationInstant(token);
|
||||
assertThat(expiration).isAfter(Instant.now());
|
||||
}
|
||||
|
||||
// --- Utility ---
|
||||
|
||||
private static void setField(Object target, String fieldName, Object value) throws Exception {
|
||||
Field field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link LoginRateLimitFilter} covering rate limiting with Bucket4j + Caffeine.
|
||||
* Tests per-IP bucket isolation, blocking after threshold, and Retry-After header.
|
||||
*/
|
||||
class LoginRateLimitFilterTest {
|
||||
|
||||
private LoginRateLimitFilter filter;
|
||||
private FilterChain filterChain;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
filter = new LoginRateLimitFilter();
|
||||
filterChain = mock(FilterChain.class);
|
||||
}
|
||||
|
||||
// --- T-26: First 5 requests pass ---
|
||||
|
||||
@Test
|
||||
@DisplayName("Rate limit — first 5 requests from same IP are allowed")
|
||||
void testRateLimit_allowsFirstFiveRequests() throws ServletException, IOException {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
MockHttpServletRequest request = createLoginRequest("192.168.1.1");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilterInternal(request, response, filterChain);
|
||||
|
||||
assertThat(response.getStatus()).isNotEqualTo(429);
|
||||
}
|
||||
|
||||
// FilterChain should have been invoked 5 times
|
||||
verify(filterChain, times(5)).doFilter(any(), any());
|
||||
}
|
||||
|
||||
// --- T-27: 6th request returns 429 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("Rate limit — 6th request from same IP returns 429")
|
||||
void testRateLimit_blocks6thRequest_returns429() throws ServletException, IOException {
|
||||
String ip = "10.0.0.1";
|
||||
|
||||
// Exhaust the 5-request bucket
|
||||
for (int i = 0; i < 5; i++) {
|
||||
MockHttpServletRequest request = createLoginRequest(ip);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
filter.doFilterInternal(request, response, filterChain);
|
||||
}
|
||||
|
||||
// 6th request should be rate-limited
|
||||
MockHttpServletRequest request = createLoginRequest(ip);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
filter.doFilterInternal(request, response, filterChain);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(429);
|
||||
assertThat(response.getContentAsString()).contains("Too many login attempts");
|
||||
}
|
||||
|
||||
// --- Retry-After header ---
|
||||
|
||||
@Test
|
||||
@DisplayName("Rate limit — 429 response includes Retry-After header")
|
||||
void testRateLimit_includesRetryAfterHeader() throws ServletException, IOException {
|
||||
String ip = "10.0.0.2";
|
||||
|
||||
// Exhaust the bucket
|
||||
for (int i = 0; i < 5; i++) {
|
||||
filter.doFilterInternal(createLoginRequest(ip), new MockHttpServletResponse(), filterChain);
|
||||
}
|
||||
|
||||
// 6th request — check headers
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
filter.doFilterInternal(createLoginRequest(ip), response, filterChain);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(429);
|
||||
String retryAfter = response.getHeader("Retry-After");
|
||||
assertThat(retryAfter).isNotNull();
|
||||
assertThat(Integer.parseInt(retryAfter)).isGreaterThan(0);
|
||||
}
|
||||
|
||||
// --- T-28: Separate buckets per IP ---
|
||||
|
||||
@Test
|
||||
@DisplayName("Rate limit — different IPs have separate rate limit buckets")
|
||||
void testRateLimit_separateBucketsPerIp() throws ServletException, IOException {
|
||||
String ip1 = "192.168.1.100";
|
||||
String ip2 = "192.168.1.200";
|
||||
|
||||
// Exhaust quota for ip1
|
||||
for (int i = 0; i < 5; i++) {
|
||||
filter.doFilterInternal(createLoginRequest(ip1), new MockHttpServletResponse(), filterChain);
|
||||
}
|
||||
|
||||
// ip1 should be blocked
|
||||
MockHttpServletResponse responseIp1 = new MockHttpServletResponse();
|
||||
filter.doFilterInternal(createLoginRequest(ip1), responseIp1, filterChain);
|
||||
assertThat(responseIp1.getStatus()).isEqualTo(429);
|
||||
|
||||
// ip2 should still be allowed
|
||||
MockHttpServletResponse responseIp2 = new MockHttpServletResponse();
|
||||
filter.doFilterInternal(createLoginRequest(ip2), responseIp2, filterChain);
|
||||
assertThat(responseIp2.getStatus()).isNotEqualTo(429);
|
||||
}
|
||||
|
||||
// --- Non-login requests pass through ---
|
||||
|
||||
@Test
|
||||
@DisplayName("Non-login endpoint requests are not rate limited")
|
||||
void testNonLoginEndpoint_notRateLimited() throws ServletException, IOException {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/members");
|
||||
request.setRemoteAddr("10.0.0.5");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilterInternal(request, response, filterChain);
|
||||
|
||||
verify(filterChain).doFilter(request, response);
|
||||
assertThat(response.getStatus()).isNotEqualTo(429);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET request to login path is not rate limited")
|
||||
void testGetLoginPath_notRateLimited() throws ServletException, IOException {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/auth/login");
|
||||
request.setRemoteAddr("10.0.0.6");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilterInternal(request, response, filterChain);
|
||||
|
||||
verify(filterChain).doFilter(request, response);
|
||||
assertThat(response.getStatus()).isNotEqualTo(429);
|
||||
}
|
||||
|
||||
// --- X-Forwarded-For header ---
|
||||
|
||||
@Test
|
||||
@DisplayName("Rate limit uses X-Forwarded-For header for client IP resolution")
|
||||
void testRateLimit_usesXForwardedFor() throws ServletException, IOException {
|
||||
String realIp = "203.0.113.50";
|
||||
|
||||
// Exhaust bucket via X-Forwarded-For IP
|
||||
for (int i = 0; i < 5; i++) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/login");
|
||||
request.setRemoteAddr("127.0.0.1"); // proxy IP
|
||||
request.addHeader("X-Forwarded-For", realIp + ", 10.0.0.1");
|
||||
filter.doFilterInternal(request, new MockHttpServletResponse(), filterChain);
|
||||
}
|
||||
|
||||
// 6th request from same forwarded IP should be blocked
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/login");
|
||||
request.setRemoteAddr("127.0.0.1");
|
||||
request.addHeader("X-Forwarded-For", realIp + ", 10.0.0.1");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
filter.doFilterInternal(request, response, filterChain);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(429);
|
||||
}
|
||||
|
||||
// --- Helper ---
|
||||
|
||||
private MockHttpServletRequest createLoginRequest(String ip) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/login");
|
||||
request.setRemoteAddr(ip);
|
||||
return request;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link LoginRateLimiter} covering rate limiting logic,
|
||||
* IP isolation, boundary conditions, and counter reset.
|
||||
*/
|
||||
class LoginRateLimiterTest {
|
||||
|
||||
private LoginRateLimiter rateLimiter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
rateLimiter = new LoginRateLimiter();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTryAcquire_firstAttempt_allowed() {
|
||||
boolean result = rateLimiter.tryAcquire("192.168.1.1");
|
||||
|
||||
assertThat(result).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTryAcquire_withinLimit_allAllowed() {
|
||||
String ip = "10.0.0.1";
|
||||
|
||||
// Attempts 1 through 4 (under the limit of 5) should all be allowed
|
||||
for (int i = 0; i < LoginRateLimiter.MAX_ATTEMPTS_PER_WINDOW - 1; i++) {
|
||||
assertThat(rateLimiter.tryAcquire(ip)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTryAcquire_exactlyAtLimit_stillAllowed() {
|
||||
String ip = "10.0.0.2";
|
||||
|
||||
// Use up exactly MAX_ATTEMPTS_PER_WINDOW attempts
|
||||
for (int i = 0; i < LoginRateLimiter.MAX_ATTEMPTS_PER_WINDOW; i++) {
|
||||
assertThat(rateLimiter.tryAcquire(ip)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTryAcquire_oneOverLimit_blocked() {
|
||||
String ip = "10.0.0.3";
|
||||
|
||||
// Exhaust the quota
|
||||
for (int i = 0; i < LoginRateLimiter.MAX_ATTEMPTS_PER_WINDOW; i++) {
|
||||
rateLimiter.tryAcquire(ip);
|
||||
}
|
||||
|
||||
// Next attempt should be blocked
|
||||
assertThat(rateLimiter.tryAcquire(ip)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTryAcquire_differentIps_trackedIndependently() {
|
||||
String ip1 = "192.168.1.100";
|
||||
String ip2 = "192.168.1.200";
|
||||
|
||||
// Exhaust quota for ip1
|
||||
for (int i = 0; i < LoginRateLimiter.MAX_ATTEMPTS_PER_WINDOW; i++) {
|
||||
rateLimiter.tryAcquire(ip1);
|
||||
}
|
||||
assertThat(rateLimiter.tryAcquire(ip1)).isFalse();
|
||||
|
||||
// ip2 should still be allowed
|
||||
assertThat(rateLimiter.tryAcquire(ip2)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testResetCounters_afterReset_attemptsAllowedAgain() {
|
||||
String ip = "10.0.0.4";
|
||||
|
||||
// Exhaust the quota
|
||||
for (int i = 0; i < LoginRateLimiter.MAX_ATTEMPTS_PER_WINDOW; i++) {
|
||||
rateLimiter.tryAcquire(ip);
|
||||
}
|
||||
assertThat(rateLimiter.tryAcquire(ip)).isFalse();
|
||||
|
||||
// Reset counters (simulating the scheduled task)
|
||||
rateLimiter.resetCounters();
|
||||
|
||||
// Should be allowed again
|
||||
assertThat(rateLimiter.tryAcquire(ip)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTryAcquire_ipv6Address_handledCorrectly() {
|
||||
String ipv6 = "2001:0db8:85a3:0000:0000:8a2e:0370:7334";
|
||||
|
||||
assertThat(rateLimiter.tryAcquire(ipv6)).isTrue();
|
||||
|
||||
// Exhaust quota for IPv6
|
||||
for (int i = 1; i < LoginRateLimiter.MAX_ATTEMPTS_PER_WINDOW; i++) {
|
||||
rateLimiter.tryAcquire(ipv6);
|
||||
}
|
||||
// Should still pass at limit
|
||||
assertThat(rateLimiter.tryAcquire(ipv6)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTryAcquire_nullOrBlankIp_treatedAsUnknown() {
|
||||
// null and blank IPs should not throw — they get mapped to "unknown"
|
||||
assertThat(rateLimiter.tryAcquire(null)).isTrue();
|
||||
assertThat(rateLimiter.tryAcquire("")).isTrue();
|
||||
assertThat(rateLimiter.tryAcquire(" ")).isTrue();
|
||||
|
||||
// All null/blank share the "unknown" bucket — 3 attempts above, 2 more allowed
|
||||
assertThat(rateLimiter.tryAcquire(null)).isTrue();
|
||||
assertThat(rateLimiter.tryAcquire("")).isTrue();
|
||||
// 6th attempt (over limit of 5) should be blocked
|
||||
assertThat(rateLimiter.tryAcquire(null)).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for {@link SecurityConfig} — verifying that the security filter chain
|
||||
* correctly requires authentication for protected endpoints and allows public endpoints.
|
||||
* Uses RestClient against an actual HTTP server (same pattern as AuthControllerIntegrationTest).
|
||||
*
|
||||
* Note: The existing SecurityConfigIntegrationTest (Testcontainers) covers the same cases
|
||||
* with a full database. This test uses the simpler "test" profile for faster execution.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
class SecurityConfigTest {
|
||||
|
||||
@LocalServerPort
|
||||
private int port;
|
||||
|
||||
private RestClient restClient() {
|
||||
return RestClient.builder()
|
||||
.baseUrl("http://localhost:" + port)
|
||||
.build();
|
||||
}
|
||||
|
||||
// --- T-21: Document endpoints require authentication ---
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/v1/documents — unauthenticated returns 401")
|
||||
void testDocumentEndpoints_requireAuthentication() {
|
||||
ResponseEntity<String> response = restClient().get()
|
||||
.uri("/api/v1/documents?clubId=00000000-0000-0000-0000-000000000001")
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/v1/documents/{id}/download — unauthenticated returns 401")
|
||||
void testDocumentDownload_requiresAuthentication() {
|
||||
ResponseEntity<String> response = restClient().get()
|
||||
.uri("/api/v1/documents/00000000-0000-0000-0000-000000000099/download")
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||
}
|
||||
|
||||
// --- T-22: Auth endpoints are public ---
|
||||
|
||||
@Test
|
||||
@DisplayName("POST /api/v1/auth/login — accessible without authentication (not 401)")
|
||||
void testAuthEndpoints_arePublic() {
|
||||
ResponseEntity<String> response = restClient().post()
|
||||
.uri("/api/v1/auth/login")
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.body("{\"email\":\"test@test.de\",\"password\":\"test\"}")
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
// Auth endpoints are public — should NOT return 401/403
|
||||
// May return 400 or 500 (user not found), that's fine
|
||||
assertThat(response.getStatusCode().value()).isNotEqualTo(401);
|
||||
assertThat(response.getStatusCode().value()).isNotEqualTo(403);
|
||||
}
|
||||
|
||||
// --- T-23: Actuator health is public ---
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /actuator/health — accessible without authentication")
|
||||
void testActuatorHealth_isPublic() {
|
||||
ResponseEntity<String> response = restClient().get()
|
||||
.uri("/actuator/health")
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.hibernate.Filter;
|
||||
import org.hibernate.Session;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link TenantFilterAspect} verifying that the Hibernate
|
||||
* tenant filter is correctly activated/skipped based on TenantContext state.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class TenantFilterAspectTest {
|
||||
|
||||
@Mock
|
||||
private EntityManager entityManager;
|
||||
|
||||
@Mock
|
||||
private Session session;
|
||||
|
||||
@Mock
|
||||
private Filter filter;
|
||||
|
||||
private TenantFilterAspect aspect;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// Use doReturn to handle the generic unwrap() method properly
|
||||
doReturn(session).when(entityManager).unwrap(Session.class);
|
||||
when(session.enableFilter("tenantFilter")).thenReturn(filter);
|
||||
|
||||
aspect = new TenantFilterAspect(entityManager);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
TenantContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testActivateTenantFilter_withTenantSet_enablesFilter() {
|
||||
UUID tenantId = UUID.randomUUID();
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
|
||||
aspect.activateTenantFilter();
|
||||
|
||||
verify(session).enableFilter("tenantFilter");
|
||||
verify(filter).setParameter("tenantId", tenantId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testActivateTenantFilter_differentTenants_getDifferentFilterValues() {
|
||||
UUID tenant1 = UUID.randomUUID();
|
||||
UUID tenant2 = UUID.randomUUID();
|
||||
|
||||
// First tenant
|
||||
TenantContext.setCurrentTenant(tenant1);
|
||||
aspect.activateTenantFilter();
|
||||
verify(filter).setParameter("tenantId", tenant1);
|
||||
|
||||
// Second tenant
|
||||
TenantContext.setCurrentTenant(tenant2);
|
||||
aspect.activateTenantFilter();
|
||||
verify(filter).setParameter("tenantId", tenant2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testActivateTenantFilter_noTenantInContext_filterNotActivated() {
|
||||
// TenantContext is empty (no tenant set)
|
||||
aspect.activateTenantFilter();
|
||||
|
||||
verify(entityManager, never()).unwrap(Session.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testActivateTenantFilter_tenantCleared_filterNotActivated() {
|
||||
// Set and then clear tenant
|
||||
TenantContext.setCurrentTenant(UUID.randomUUID());
|
||||
TenantContext.clear();
|
||||
|
||||
aspect.activateTenantFilter();
|
||||
|
||||
verify(entityManager, never()).unwrap(Session.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testActivateTenantFilter_multipleCallsSameTenant_enablesFilterEachTime() {
|
||||
UUID tenantId = UUID.randomUUID();
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
|
||||
// Aspect is called per-repository-method; it should enable filter every time
|
||||
aspect.activateTenantFilter();
|
||||
aspect.activateTenantFilter();
|
||||
aspect.activateTenantFilter();
|
||||
|
||||
verify(session, times(3)).enableFilter("tenantFilter");
|
||||
verify(filter, times(3)).setParameter("tenantId", tenantId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testActivateTenantFilter_concurrentRequests_isolatedByThread() throws Exception {
|
||||
UUID tenant1 = UUID.randomUUID();
|
||||
UUID tenant2 = UUID.randomUUID();
|
||||
|
||||
// Simulate concurrent requests on different threads
|
||||
Thread thread1 = new Thread(() -> {
|
||||
TenantContext.setCurrentTenant(tenant1);
|
||||
aspect.activateTenantFilter();
|
||||
TenantContext.clear();
|
||||
});
|
||||
|
||||
Thread thread2 = new Thread(() -> {
|
||||
TenantContext.setCurrentTenant(tenant2);
|
||||
aspect.activateTenantFilter();
|
||||
TenantContext.clear();
|
||||
});
|
||||
|
||||
thread1.start();
|
||||
thread2.start();
|
||||
thread1.join();
|
||||
thread2.join();
|
||||
|
||||
// Both tenants should have been set (order not guaranteed)
|
||||
verify(filter).setParameter("tenantId", tenant1);
|
||||
verify(filter).setParameter("tenantId", tenant2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTenantContext_clear_preventsLeakage() {
|
||||
UUID tenantId = UUID.randomUUID();
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
TenantContext.clear();
|
||||
|
||||
// After clear, no tenant should be active
|
||||
aspect.activateTenantFilter();
|
||||
|
||||
verify(entityManager, never()).unwrap(Session.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testActivateTenantFilter_adminCannotAccessOtherClub_filterUsesContextTenant() {
|
||||
// Even for admin role, the filter is activated with whatever tenant is in context.
|
||||
// Cross-tenant access is prevented by TenantContext being set per-request.
|
||||
UUID adminTenant = UUID.randomUUID();
|
||||
TenantContext.setCurrentTenant(adminTenant);
|
||||
|
||||
aspect.activateTenantFilter();
|
||||
|
||||
// Filter is always set to the context tenant — no bypass possible
|
||||
verify(filter).setParameter("tenantId", adminTenant);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package de.cannamanage.api.service;
|
||||
|
||||
import de.cannamanage.api.dto.auth.LoginRequest;
|
||||
import de.cannamanage.api.dto.auth.LoginResponse;
|
||||
import de.cannamanage.api.dto.auth.RefreshRequest;
|
||||
import de.cannamanage.api.security.JwtService;
|
||||
import de.cannamanage.domain.entity.User;
|
||||
import de.cannamanage.domain.enums.UserRole;
|
||||
import de.cannamanage.service.repository.InviteTokenRepository;
|
||||
import de.cannamanage.service.repository.StaffAccountRepository;
|
||||
import de.cannamanage.service.repository.UserRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link AuthService} covering login, token refresh, and SHA-256 hashing.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuthServiceTest {
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Mock
|
||||
private JwtService jwtService;
|
||||
|
||||
@Mock
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@Mock
|
||||
private InviteTokenRepository inviteTokenRepository;
|
||||
|
||||
@Mock
|
||||
private StaffAccountRepository staffAccountRepository;
|
||||
|
||||
@InjectMocks
|
||||
private AuthService authService;
|
||||
|
||||
private User activeUser;
|
||||
private static final UUID USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
private static final UUID TENANT_ID = UUID.fromString("00000000-0000-0000-0000-000000000010");
|
||||
private static final String EMAIL = "admin@test.de";
|
||||
private static final String PASSWORD = "SecurePass123!";
|
||||
private static final String HASHED_PASSWORD = "$2a$10$hashedvalue";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
activeUser = new User();
|
||||
activeUser.setId(USER_ID);
|
||||
activeUser.setEmail(EMAIL);
|
||||
activeUser.setPasswordHash(HASHED_PASSWORD);
|
||||
activeUser.setRole(UserRole.ROLE_ADMIN);
|
||||
activeUser.setActive(true);
|
||||
activeUser.setTenantId(TENANT_ID);
|
||||
}
|
||||
|
||||
// --- T-15: Login valid credentials → token pair ---
|
||||
|
||||
@Test
|
||||
@DisplayName("login — valid credentials returns token pair")
|
||||
void testLogin_validCredentials_returnsTokenPair() {
|
||||
when(userRepository.findByEmail(EMAIL)).thenReturn(Optional.of(activeUser));
|
||||
when(passwordEncoder.matches(PASSWORD, HASHED_PASSWORD)).thenReturn(true);
|
||||
when(jwtService.generateAccessToken(any(), any(), anyString(), anyString()))
|
||||
.thenReturn("access-token-123");
|
||||
when(jwtService.generateRefreshToken(any(), any()))
|
||||
.thenReturn("refresh-token-456");
|
||||
when(userRepository.save(any(User.class))).thenReturn(activeUser);
|
||||
|
||||
LoginResponse response = authService.login(new LoginRequest(EMAIL, PASSWORD));
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.accessToken()).isEqualTo("access-token-123");
|
||||
assertThat(response.refreshToken()).isEqualTo("refresh-token-456");
|
||||
assertThat(response.expiresIn()).isEqualTo(3600L);
|
||||
assertThat(response.role()).isEqualTo("ADMIN");
|
||||
}
|
||||
|
||||
// --- T-16: Login invalid password → 401 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("login — invalid password throws AuthenticationException")
|
||||
void testLogin_invalidPassword_throws401() {
|
||||
when(userRepository.findByEmail(EMAIL)).thenReturn(Optional.of(activeUser));
|
||||
when(passwordEncoder.matches("wrong-password", HASHED_PASSWORD)).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> authService.login(new LoginRequest(EMAIL, "wrong-password")))
|
||||
.isInstanceOf(AuthService.AuthenticationException.class)
|
||||
.hasMessageContaining("Invalid credentials");
|
||||
}
|
||||
|
||||
// --- T-17: Login non-existent user → 401 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("login — non-existent user throws AuthenticationException")
|
||||
void testLogin_nonExistentUser_throws401() {
|
||||
when(userRepository.findByEmail("nobody@test.de")).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> authService.login(new LoginRequest("nobody@test.de", PASSWORD)))
|
||||
.isInstanceOf(AuthService.AuthenticationException.class)
|
||||
.hasMessageContaining("Invalid credentials");
|
||||
}
|
||||
|
||||
// --- T-18: Refresh token valid → new access token ---
|
||||
|
||||
@Test
|
||||
@DisplayName("refresh — valid token returns new access token")
|
||||
void testRefreshToken_validToken_returnsNewAccessToken() {
|
||||
String oldRefreshToken = "valid-refresh-token";
|
||||
// Compute expected hash
|
||||
String expectedHash = sha256(oldRefreshToken);
|
||||
activeUser.setRefreshTokenHash(expectedHash);
|
||||
|
||||
when(jwtService.isTokenValid(oldRefreshToken)).thenReturn(true);
|
||||
when(jwtService.extractUserId(oldRefreshToken)).thenReturn(USER_ID);
|
||||
when(userRepository.findById(USER_ID)).thenReturn(Optional.of(activeUser));
|
||||
when(jwtService.generateAccessToken(any(), any(), anyString(), anyString()))
|
||||
.thenReturn("new-access-token");
|
||||
when(jwtService.generateRefreshToken(any(), any()))
|
||||
.thenReturn("new-refresh-token");
|
||||
when(userRepository.save(any(User.class))).thenReturn(activeUser);
|
||||
|
||||
LoginResponse response = authService.refresh(new RefreshRequest(oldRefreshToken));
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.accessToken()).isEqualTo("new-access-token");
|
||||
assertThat(response.refreshToken()).isEqualTo("new-refresh-token");
|
||||
}
|
||||
|
||||
// --- T-19: Refresh token expired → 401 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("refresh — expired/invalid token throws AuthenticationException")
|
||||
void testRefreshToken_expired_throws401() {
|
||||
String expiredToken = "expired-refresh-token";
|
||||
when(jwtService.isTokenValid(expiredToken)).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> authService.refresh(new RefreshRequest(expiredToken)))
|
||||
.isInstanceOf(AuthService.AuthenticationException.class)
|
||||
.hasMessageContaining("Invalid or expired refresh token");
|
||||
}
|
||||
|
||||
// --- T-20: SHA-256 hashing is deterministic ---
|
||||
|
||||
@Test
|
||||
@DisplayName("SHA-256 hashing is deterministic — same input always produces same hash")
|
||||
void testSha256_deterministic() {
|
||||
String input = "test-refresh-token-abc123";
|
||||
String hash1 = sha256(input);
|
||||
String hash2 = sha256(input);
|
||||
|
||||
assertThat(hash1).isEqualTo(hash2);
|
||||
assertThat(hash1).hasSize(64); // SHA-256 produces 64 hex chars
|
||||
assertThat(hash1).matches("[0-9a-f]{64}");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SHA-256 hashing — different inputs produce different hashes")
|
||||
void testSha256_differentInputs_differentHashes() {
|
||||
String hash1 = sha256("token-one");
|
||||
String hash2 = sha256("token-two");
|
||||
|
||||
assertThat(hash1).isNotEqualTo(hash2);
|
||||
}
|
||||
|
||||
// Helper to replicate AuthService's sha256 logic for test verification
|
||||
private String sha256(String input) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(hash);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("SHA-256 not available", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,12 @@ public class Club extends AbstractTenantEntity {
|
||||
@Column(name = "allowed_email_pattern", length = 255)
|
||||
private String allowedEmailPattern;
|
||||
|
||||
@Column(name = "storage_used_bytes", nullable = false)
|
||||
private Long storageUsedBytes = 0L;
|
||||
|
||||
@Column(name = "storage_limit_bytes", nullable = false)
|
||||
private Long storageLimitBytes = 5_368_709_120L; // 5 GB default
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 50)
|
||||
private ClubStatus status = ClubStatus.ACTIVE;
|
||||
@@ -99,4 +105,10 @@ public class Club extends AbstractTenantEntity {
|
||||
|
||||
public ClubStatus getStatus() { return status; }
|
||||
public void setStatus(ClubStatus status) { this.status = status; }
|
||||
|
||||
public Long getStorageUsedBytes() { return storageUsedBytes; }
|
||||
public void setStorageUsedBytes(Long storageUsedBytes) { this.storageUsedBytes = storageUsedBytes; }
|
||||
|
||||
public Long getStorageLimitBytes() { return storageLimitBytes; }
|
||||
public void setStorageLimitBytes(Long storageLimitBytes) { this.storageLimitBytes = storageLimitBytes; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# IMPORTANT: Keep this version in sync with @playwright/test in package.json
|
||||
FROM mcr.microsoft.com/playwright:v1.60.0-noble
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files for dependency installation
|
||||
COPY package.json pnpm-lock.yaml .npmrc ./
|
||||
|
||||
# Install pnpm and project dependencies at build time
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy playwright config and test infrastructure
|
||||
COPY playwright.config.ts tsconfig.json ./
|
||||
COPY e2e/ ./e2e/
|
||||
|
||||
# Default command (overridden by docker-compose)
|
||||
CMD ["npx", "playwright", "test", "e2e/integration/", "--reporter=list"]
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* API client for integration tests.
|
||||
* Used for direct backend calls: DB verification, test reset, data assertions.
|
||||
*/
|
||||
const API_URL = process.env.API_URL || "http://localhost:8080"
|
||||
|
||||
export class ApiClient {
|
||||
private token: string | null = null
|
||||
|
||||
async login(email: string, password: string): Promise<void> {
|
||||
const res = await fetch(`${API_URL}/api/v1/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Login failed: ${res.status}`)
|
||||
const data = await res.json()
|
||||
this.token = data.token
|
||||
}
|
||||
|
||||
async resetDb(): Promise<void> {
|
||||
const res = await fetch(`${API_URL}/api/v1/test/reset-db`, {
|
||||
method: "POST",
|
||||
headers: this.authHeaders(),
|
||||
})
|
||||
if (!res.ok) throw new Error(`DB reset failed: ${res.status}`)
|
||||
}
|
||||
|
||||
async getMembers(): Promise<any> {
|
||||
return this.get("/api/v1/members")
|
||||
}
|
||||
|
||||
async getDocuments(): Promise<any> {
|
||||
return this.get("/api/v1/documents")
|
||||
}
|
||||
|
||||
async getBatches(): Promise<any> {
|
||||
return this.get("/api/v1/batches")
|
||||
}
|
||||
|
||||
async getDistributions(): Promise<any> {
|
||||
return this.get("/api/v1/distributions")
|
||||
}
|
||||
|
||||
async getBoardPositions(): Promise<any> {
|
||||
return this.get("/api/v1/board")
|
||||
}
|
||||
|
||||
private authHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if (this.token) headers["Authorization"] = `Bearer ${this.token}`
|
||||
return headers
|
||||
}
|
||||
|
||||
private async get(path: string): Promise<any> {
|
||||
const res = await fetch(`${API_URL}${path}`, {
|
||||
headers: this.authHeaders(),
|
||||
})
|
||||
if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
private async post(path: string, body?: unknown): Promise<any> {
|
||||
const res = await fetch(`${API_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers: this.authHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!res.ok) throw new Error(`POST ${path} failed: ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect, test as setup } from "@playwright/test"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
|
||||
import { expect, test as setup } from "@playwright/test"
|
||||
|
||||
import { SEED } from "./seed-constants"
|
||||
|
||||
/**
|
||||
* Global setup — authenticates as admin and saves the session state
|
||||
@@ -13,23 +16,41 @@ const authDir = path.join(__dirname, ".auth")
|
||||
const authFile = path.join(authDir, "admin.json")
|
||||
|
||||
setup("authenticate as admin", async ({ page, context }) => {
|
||||
const baseURL = "http://localhost:3000"
|
||||
const baseURL = process.env.BASE_URL || "http://localhost:3000"
|
||||
const apiUrl = process.env.API_URL || "http://localhost:8080"
|
||||
|
||||
// Use seed credentials (from seed-constants), overridable via env vars
|
||||
const email = process.env.TEST_ADMIN_EMAIL || SEED.admin.email
|
||||
const password = process.env.TEST_ADMIN_PASSWORD || SEED.admin.password
|
||||
|
||||
// Ensure .auth directory exists
|
||||
if (!fs.existsSync(authDir)) {
|
||||
fs.mkdirSync(authDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Wait for backend health (up to 60s)
|
||||
let healthy = false
|
||||
for (let i = 0; i < 30; i++) {
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/actuator/health`)
|
||||
if (res.ok) {
|
||||
healthy = true
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
/* retry */
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2000))
|
||||
}
|
||||
if (!healthy) throw new Error("Backend health check failed after 60s")
|
||||
|
||||
// Navigate to login page
|
||||
await page.goto(`${baseURL}/login`)
|
||||
await page.waitForLoadState("domcontentloaded")
|
||||
|
||||
// Fill credentials and submit
|
||||
await page.fill('input[name="email"], input[type="email"]', "admin@test.de")
|
||||
await page.fill(
|
||||
'input[name="password"], input[type="password"]',
|
||||
"test123"
|
||||
)
|
||||
await page.fill('input[name="email"], input[type="email"]', email)
|
||||
await page.fill('input[name="password"], input[type="password"]', password)
|
||||
await page.click('button[type="submit"]')
|
||||
|
||||
// Wait for successful redirect away from login
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
import { ApiClient } from "../api-client"
|
||||
import { SEED } from "../seed-constants"
|
||||
import { SEL } from "../selectors"
|
||||
|
||||
const apiClient = new ApiClient()
|
||||
|
||||
test.describe("Documents Page @smoke", () => {
|
||||
test.beforeEach(async () => {
|
||||
await apiClient.login(SEED.admin.email, SEED.admin.password)
|
||||
await apiClient.resetDb()
|
||||
})
|
||||
|
||||
test("displays seed documents", async ({ page }) => {
|
||||
await page.goto("/documents")
|
||||
await expect(page.getByText(SEED.documents.satzung.title)).toBeVisible()
|
||||
await expect(page.getByText(SEED.documents.protokoll.title)).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(SEED.documents.genehmigung.title)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(SEED.documents.mietvertrag.title)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("upload button opens dialog", async ({ page }) => {
|
||||
await page.goto("/documents")
|
||||
const uploadBtn = page.locator(SEL.documents.uploadButton)
|
||||
await expect(uploadBtn).toBeVisible()
|
||||
await uploadBtn.click()
|
||||
await expect(page.locator(SEL.documents.uploadDialog)).toBeVisible()
|
||||
await expect(page.locator(SEL.documents.titleInput)).toBeVisible()
|
||||
await expect(page.locator(SEL.documents.categorySelect)).toBeVisible()
|
||||
await expect(page.locator(SEL.documents.fileInput)).toBeVisible()
|
||||
})
|
||||
|
||||
test("upload form submits successfully", async ({ page }) => {
|
||||
// Requires backend
|
||||
await page.goto("/documents")
|
||||
await page.locator(SEL.documents.uploadButton).click()
|
||||
await expect(page.locator(SEL.documents.uploadDialog)).toBeVisible()
|
||||
|
||||
await page.locator(SEL.documents.titleInput).fill("Testdokument Upload")
|
||||
await page.locator(SEL.documents.categorySelect).click()
|
||||
await page.getByRole("option", { name: /satzung/i }).click()
|
||||
|
||||
// Upload a test file
|
||||
const fileInput = page.locator(SEL.documents.fileInput)
|
||||
await fileInput.setInputFiles({
|
||||
name: "test.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from("fake pdf content"),
|
||||
})
|
||||
|
||||
const submitBtn = page.locator(SEL.documents.submitUpload)
|
||||
await submitBtn.click()
|
||||
|
||||
// Verify success toast
|
||||
await expect(page.getByText(/erfolgreich|hochgeladen/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test("download button triggers download", async ({ page }) => {
|
||||
await page.goto("/documents")
|
||||
const downloadBtn = page.locator(
|
||||
SEL.documents.downloadButton(SEED.documents.satzung.id)
|
||||
)
|
||||
await expect(downloadBtn).toBeVisible()
|
||||
|
||||
// Verify clicking download doesn't throw an error
|
||||
const downloadPromise = page.waitForEvent("download")
|
||||
await downloadBtn.click()
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toBeTruthy()
|
||||
})
|
||||
|
||||
test("delete button shows confirmation and removes document", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Requires backend
|
||||
await page.goto("/documents")
|
||||
const deleteBtn = page.locator(
|
||||
SEL.documents.deleteButton(SEED.documents.mietvertrag.id)
|
||||
)
|
||||
await expect(deleteBtn).toBeVisible()
|
||||
await deleteBtn.click()
|
||||
|
||||
// Confirmation dialog appears
|
||||
await expect(page.locator(SEL.documents.deleteConfirm)).toBeVisible()
|
||||
await page.locator(SEL.documents.deleteConfirm).click()
|
||||
|
||||
// Document removed from list
|
||||
await expect(
|
||||
page.getByText(SEED.documents.mietvertrag.title)
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test("category badges display correctly", async ({ page }) => {
|
||||
await page.goto("/documents")
|
||||
await expect(
|
||||
page.locator(
|
||||
SEL.documents.categoryBadge(SEED.documents.satzung.category)
|
||||
)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.locator(
|
||||
SEL.documents.categoryBadge(SEED.documents.protokoll.category)
|
||||
)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.locator(
|
||||
SEL.documents.categoryBadge(SEED.documents.genehmigung.category)
|
||||
)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.locator(
|
||||
SEL.documents.categoryBadge(SEED.documents.mietvertrag.category)
|
||||
)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
import { ApiClient } from "../api-client"
|
||||
import { SEED } from "../seed-constants"
|
||||
import { SEL } from "../selectors"
|
||||
|
||||
const apiClient = new ApiClient()
|
||||
|
||||
test.describe("Board Page @smoke", () => {
|
||||
test.beforeEach(async () => {
|
||||
await apiClient.login(SEED.admin.email, SEED.admin.password)
|
||||
await apiClient.resetDb()
|
||||
})
|
||||
|
||||
test("displays seed board positions", async ({ page }) => {
|
||||
await page.goto("/board")
|
||||
await expect(page.getByText(SEED.board.vorsitz.title)).toBeVisible()
|
||||
await expect(page.getByText(SEED.board.kasse.title)).toBeVisible()
|
||||
await expect(page.getByText(SEED.board.schrift.title)).toBeVisible()
|
||||
})
|
||||
|
||||
test("shows elected members on filled positions", async ({ page }) => {
|
||||
await page.goto("/board")
|
||||
await expect(page.getByText(SEED.board.vorsitz.elected)).toBeVisible()
|
||||
await expect(page.getByText(SEED.board.kasse.elected)).toBeVisible()
|
||||
})
|
||||
|
||||
test("shows vacant status for unfilled positions", async ({ page }) => {
|
||||
await page.goto("/board")
|
||||
const schriftCard = page.locator(
|
||||
SEL.board.positionCard(SEED.board.schrift.id)
|
||||
)
|
||||
await expect(schriftCard).toBeVisible()
|
||||
await expect(schriftCard.getByText(/vakant|unbesetzt/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test("create position opens form and submits", async ({ page }) => {
|
||||
// Requires backend
|
||||
await page.goto("/board")
|
||||
await page.locator(SEL.board.createPositionButton).click()
|
||||
|
||||
// Fill form
|
||||
await page.getByLabel(/titel|bezeichnung/i).fill("Beisitzer/in")
|
||||
await page.getByRole("button", { name: /speichern|erstellen/i }).click()
|
||||
|
||||
// Verify new position appears
|
||||
await expect(page.getByText("Beisitzer/in")).toBeVisible()
|
||||
})
|
||||
|
||||
test("elect member to vacant position", async ({ page }) => {
|
||||
// Requires backend
|
||||
await page.goto("/board")
|
||||
|
||||
// Click elect on the vacant Schriftführung position
|
||||
const schriftCard = page.locator(
|
||||
SEL.board.positionCard(SEED.board.schrift.id)
|
||||
)
|
||||
await schriftCard.locator(SEL.board.electMemberButton).click()
|
||||
|
||||
// Select a member from dropdown/dialog
|
||||
await page.getByRole("option", { name: /Lisa Bauer/i }).click()
|
||||
await page.getByRole("button", { name: /speichern|wählen/i }).click()
|
||||
|
||||
// Verify member is now shown
|
||||
await expect(page.getByText(SEED.members.lisa.name)).toBeVisible()
|
||||
})
|
||||
|
||||
test("remove member from position shows confirmation", async ({ page }) => {
|
||||
// Requires backend
|
||||
await page.goto("/board")
|
||||
const removeBtn = page.locator(
|
||||
SEL.board.removeButton(SEED.board.vorsitz.id)
|
||||
)
|
||||
await removeBtn.click()
|
||||
|
||||
// Confirmation dialog
|
||||
await expect(
|
||||
page.locator(SEL.common.alertDialogConfirm)
|
||||
).toBeVisible()
|
||||
await page.locator(SEL.common.alertDialogConfirm).click()
|
||||
|
||||
// Member name no longer visible on that position
|
||||
const vorsitzCard = page.locator(
|
||||
SEL.board.positionCard(SEED.board.vorsitz.id)
|
||||
)
|
||||
await expect(
|
||||
vorsitzCard.getByText(SEED.board.vorsitz.elected)
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
import { ApiClient } from "../api-client"
|
||||
import { SEED } from "../seed-constants"
|
||||
import { SEL } from "../selectors"
|
||||
|
||||
const apiClient = new ApiClient()
|
||||
|
||||
test.describe("Distributions Page @smoke", () => {
|
||||
test.beforeEach(async () => {
|
||||
await apiClient.login(SEED.admin.email, SEED.admin.password)
|
||||
await apiClient.resetDb()
|
||||
})
|
||||
|
||||
test("displays recent distributions from seed", async ({ page }) => {
|
||||
await page.goto("/distributions")
|
||||
// Verify distributions table/list is visible
|
||||
await expect(
|
||||
page.locator(SEL.distributions.table).or(page.getByRole("table"))
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("date filter works", async ({ page }) => {
|
||||
await page.goto("/distributions")
|
||||
|
||||
// Look for filter buttons/tabs for today/week/month/all
|
||||
const todayFilter = page.getByRole("button", { name: /heute|today/i })
|
||||
const allFilter = page.getByRole("button", { name: /alle|all/i })
|
||||
|
||||
if (await todayFilter.isVisible()) {
|
||||
await todayFilter.click()
|
||||
// Page should update (no error)
|
||||
await expect(page.locator("body")).toBeVisible()
|
||||
}
|
||||
|
||||
if (await allFilter.isVisible()) {
|
||||
await allFilter.click()
|
||||
await expect(page.locator("body")).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test("new distribution button navigates to form", async ({ page }) => {
|
||||
await page.goto("/distributions")
|
||||
const newBtn = page
|
||||
.locator(SEL.distributions.newButton)
|
||||
.or(page.getByRole("link", { name: /neue ausgabe|new/i }))
|
||||
await expect(newBtn).toBeVisible()
|
||||
await newBtn.click()
|
||||
await page.waitForURL(/\/distributions\/new/)
|
||||
})
|
||||
|
||||
test("shows gram total display", async ({ page }) => {
|
||||
await page.goto("/distributions")
|
||||
// The page should show some kind of total/summary
|
||||
await expect(
|
||||
page.getByText(/gramm|gesamt|total/i).first()
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
import { ApiClient } from "../api-client"
|
||||
import { SEED } from "../seed-constants"
|
||||
import { SEL } from "../selectors"
|
||||
|
||||
const apiClient = new ApiClient()
|
||||
|
||||
test.describe("Stock Page @smoke", () => {
|
||||
test.beforeEach(async () => {
|
||||
await apiClient.login(SEED.admin.email, SEED.admin.password)
|
||||
await apiClient.resetDb()
|
||||
})
|
||||
|
||||
test("displays seed batches", async ({ page }) => {
|
||||
await page.goto("/stock")
|
||||
await expect(
|
||||
page.getByText(SEED.strains.northernLights.name)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(SEED.strains.cbdCriticalMass.name)
|
||||
).toBeVisible()
|
||||
await expect(page.getByText(SEED.strains.amnesiaHaze.name)).toBeVisible()
|
||||
await expect(page.getByText("500")).toBeVisible()
|
||||
await expect(page.getByText("300")).toBeVisible()
|
||||
await expect(page.getByText("200")).toBeVisible()
|
||||
})
|
||||
|
||||
test("status filter works", async ({ page }) => {
|
||||
await page.goto("/stock")
|
||||
|
||||
// Filter: All — should show all 3 batches
|
||||
const allFilter = page.getByRole("button", { name: /alle|all/i })
|
||||
if (await allFilter.isVisible()) {
|
||||
await allFilter.click()
|
||||
await expect(
|
||||
page.getByText(SEED.strains.northernLights.name)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(SEED.strains.amnesiaHaze.name)
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
// Filter: Available — should hide recalled batch
|
||||
const availableFilter = page.getByRole("button", {
|
||||
name: /verfügbar|available/i,
|
||||
})
|
||||
if (await availableFilter.isVisible()) {
|
||||
await availableFilter.click()
|
||||
await expect(
|
||||
page.getByText(SEED.strains.northernLights.name)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(SEED.strains.amnesiaHaze.name)
|
||||
).toBeHidden()
|
||||
}
|
||||
|
||||
// Filter: Recalled — should only show recalled batch
|
||||
const recalledFilter = page.getByRole("button", {
|
||||
name: /zurückgerufen|recalled/i,
|
||||
})
|
||||
if (await recalledFilter.isVisible()) {
|
||||
await recalledFilter.click()
|
||||
await expect(
|
||||
page.getByText(SEED.strains.amnesiaHaze.name)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(SEED.strains.northernLights.name)
|
||||
).toBeHidden()
|
||||
}
|
||||
})
|
||||
|
||||
test("new batch link navigates to /stock/new", async ({ page }) => {
|
||||
await page.goto("/stock")
|
||||
const addBtn = page
|
||||
.locator(SEL.stock.addButton)
|
||||
.or(page.getByRole("link", { name: /neue charge|new batch|hinzufügen/i }))
|
||||
await expect(addBtn).toBeVisible()
|
||||
await addBtn.click()
|
||||
await page.waitForURL(/\/stock\/new/)
|
||||
})
|
||||
|
||||
test("recall button opens AlertDialog confirmation", async ({ page }) => {
|
||||
await page.goto("/stock")
|
||||
const recallBtn = page.locator(
|
||||
SEL.stock.recallButton(SEED.batches.northernLights.id)
|
||||
)
|
||||
|
||||
if (await recallBtn.isVisible()) {
|
||||
await recallBtn.click()
|
||||
// AlertDialog should appear with confirm/cancel
|
||||
await expect(
|
||||
page
|
||||
.locator(SEL.common.alertDialogConfirm)
|
||||
.or(page.getByRole("alertdialog"))
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test("recalled batch shows RECALLED badge", async ({ page }) => {
|
||||
await page.goto("/stock")
|
||||
// The Amnesia Haze batch is RECALLED
|
||||
const recalledRow = page.locator(
|
||||
SEL.stock.row(SEED.batches.amnesiaHaze.id)
|
||||
)
|
||||
|
||||
if (await recalledRow.isVisible()) {
|
||||
await expect(
|
||||
recalledRow.getByText(/recalled|zurückgerufen/i)
|
||||
).toBeVisible()
|
||||
} else {
|
||||
// Fallback: look for the recalled badge near Amnesia Haze text
|
||||
const amnesia = page.getByText(SEED.strains.amnesiaHaze.name)
|
||||
await expect(amnesia).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(/recalled|zurückgerufen/i).first()
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
import { ApiClient } from "../api-client"
|
||||
import { SEED } from "../seed-constants"
|
||||
|
||||
const apiClient = new ApiClient()
|
||||
|
||||
test.describe("Calendar Page @full", () => {
|
||||
test.beforeEach(async () => {
|
||||
await apiClient.login(SEED.admin.email, SEED.admin.password)
|
||||
await apiClient.resetDb()
|
||||
})
|
||||
|
||||
test("renders current month", async ({ page }) => {
|
||||
await page.goto("/calendar")
|
||||
|
||||
// Calendar should show current month name
|
||||
const now = new Date()
|
||||
const monthNames = [
|
||||
"Januar",
|
||||
"Februar",
|
||||
"März",
|
||||
"April",
|
||||
"Mai",
|
||||
"Juni",
|
||||
"Juli",
|
||||
"August",
|
||||
"September",
|
||||
"Oktober",
|
||||
"November",
|
||||
"Dezember",
|
||||
]
|
||||
const currentMonth = monthNames[now.getMonth()]
|
||||
const currentYear = now.getFullYear().toString()
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByText(currentMonth, { exact: false })
|
||||
.or(page.getByText(currentYear))
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("seed events are visible", async ({ page }) => {
|
||||
await page.goto("/calendar")
|
||||
|
||||
// There should be an upcoming assembly event (~14 days from now)
|
||||
// and a past social event (~30 days ago) — look for event indicators
|
||||
await expect(
|
||||
page
|
||||
.getByText(/versammlung|assembly/i)
|
||||
.or(page.locator("[data-testid*='event']").first())
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("month navigation works", async ({ page }) => {
|
||||
await page.goto("/calendar")
|
||||
|
||||
// Find prev/next month buttons
|
||||
const nextBtn = page.getByRole("button", { name: /next|vor|nächst|›|>/i })
|
||||
const prevBtn = page.getByRole("button", {
|
||||
name: /prev|zurück|vorig|‹|</i,
|
||||
})
|
||||
|
||||
// Navigate forward
|
||||
if (await nextBtn.isVisible()) {
|
||||
await nextBtn.click()
|
||||
await page.waitForTimeout(300)
|
||||
// Page should still render without error
|
||||
await expect(page.locator("body")).toBeVisible()
|
||||
}
|
||||
|
||||
// Navigate backward twice (back to previous month)
|
||||
if (await prevBtn.isVisible()) {
|
||||
await prevBtn.click()
|
||||
await page.waitForTimeout(300)
|
||||
await prevBtn.click()
|
||||
await page.waitForTimeout(300)
|
||||
await expect(page.locator("body")).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test("create event opens dialog with form fields", async ({ page }) => {
|
||||
await page.goto("/calendar")
|
||||
|
||||
const createBtn = page
|
||||
.getByRole("button", { name: /erstellen|create|neues event|neu/i })
|
||||
.or(page.locator('[data-testid="calendar-create-event"]'))
|
||||
|
||||
if (await createBtn.isVisible()) {
|
||||
await createBtn.click()
|
||||
// Dialog should have form fields for event creation
|
||||
await expect(
|
||||
page.getByRole("dialog").or(page.locator("[role='dialog']"))
|
||||
).toBeVisible()
|
||||
// Expect title/name field
|
||||
await expect(
|
||||
page
|
||||
.getByLabel(/titel|name|bezeichnung/i)
|
||||
.or(page.locator("input[name*='title']"))
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test("cancel event button shows confirmation", async ({ page }) => {
|
||||
await page.goto("/calendar")
|
||||
|
||||
// Click on an existing event to open detail
|
||||
const eventEl = page.locator("[data-testid*='event']").first()
|
||||
|
||||
if (await eventEl.isVisible()) {
|
||||
await eventEl.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Look for cancel/delete button
|
||||
const cancelBtn = page.getByRole("button", {
|
||||
name: /absagen|löschen|cancel|delete/i,
|
||||
})
|
||||
|
||||
if (await cancelBtn.isVisible()) {
|
||||
await cancelBtn.click()
|
||||
// Should show confirmation dialog
|
||||
await expect(
|
||||
page.getByRole("alertdialog").or(page.getByText(/bestätigen|sicher/i))
|
||||
).toBeVisible()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
import { ApiClient } from "../api-client"
|
||||
import { SEED } from "../seed-constants"
|
||||
|
||||
const apiClient = new ApiClient()
|
||||
|
||||
test.describe("Forum Page @full", () => {
|
||||
test.beforeEach(async () => {
|
||||
await apiClient.login(SEED.admin.email, SEED.admin.password)
|
||||
await apiClient.resetDb()
|
||||
})
|
||||
|
||||
test("lists seed topics", async ({ page }) => {
|
||||
await page.goto("/forum")
|
||||
await expect(
|
||||
page.getByText("Neue Sorten für Sommer")
|
||||
).toBeVisible()
|
||||
await expect(page.getByText("Bewässerungssystem")).toBeVisible()
|
||||
})
|
||||
|
||||
test("topics show reply counts", async ({ page }) => {
|
||||
await page.goto("/forum")
|
||||
// Reply counts should be visible as numbers near topics
|
||||
await expect(
|
||||
page
|
||||
.getByText(/antwort|repl/i)
|
||||
.first()
|
||||
.or(page.locator("[data-testid*='reply-count']").first())
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("new topic button opens create form", async ({ page }) => {
|
||||
await page.goto("/forum")
|
||||
const newBtn = page
|
||||
.getByRole("button", { name: /neues thema|new topic|erstellen/i })
|
||||
.or(page.locator('[data-testid="forum-new-topic"]'))
|
||||
|
||||
await expect(newBtn).toBeVisible()
|
||||
await newBtn.click()
|
||||
|
||||
// Form should appear with title + content fields
|
||||
await expect(
|
||||
page
|
||||
.getByRole("dialog")
|
||||
.or(page.locator("form"))
|
||||
.or(page.getByLabel(/titel|title/i))
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("create topic submits and shows new topic", async ({ page }) => {
|
||||
await page.goto("/forum")
|
||||
|
||||
const newBtn = page
|
||||
.getByRole("button", { name: /neues thema|new topic|erstellen/i })
|
||||
.or(page.locator('[data-testid="forum-new-topic"]'))
|
||||
await newBtn.click()
|
||||
|
||||
// Fill title
|
||||
const titleInput = page
|
||||
.getByLabel(/titel|title|thema/i)
|
||||
.or(page.locator("input[name*='title']"))
|
||||
await titleInput.fill("E2E Test Topic")
|
||||
|
||||
// Fill content
|
||||
const contentInput = page
|
||||
.getByLabel(/inhalt|content|nachricht|text/i)
|
||||
.or(page.locator("textarea"))
|
||||
await contentInput.fill("This is an integration test topic body.")
|
||||
|
||||
// Submit
|
||||
const submitBtn = page.getByRole("button", {
|
||||
name: /erstellen|submit|speichern|post/i,
|
||||
})
|
||||
await submitBtn.click()
|
||||
|
||||
// New topic should appear
|
||||
await expect(page.getByText("E2E Test Topic")).toBeVisible({
|
||||
timeout: 5000,
|
||||
})
|
||||
})
|
||||
|
||||
test("pin and lock buttons visible on topics", async ({ page }) => {
|
||||
await page.goto("/forum")
|
||||
|
||||
// Admin should see pin/lock action buttons
|
||||
const pinBtn = page
|
||||
.getByRole("button", { name: /pin|anheften/i })
|
||||
.first()
|
||||
.or(page.locator("[data-testid*='pin']").first())
|
||||
const lockBtn = page
|
||||
.getByRole("button", { name: /lock|sperren/i })
|
||||
.first()
|
||||
.or(page.locator("[data-testid*='lock']").first())
|
||||
|
||||
// At least one should be visible for admin user
|
||||
const pinVisible = await pinBtn.isVisible()
|
||||
const lockVisible = await lockBtn.isVisible()
|
||||
expect(pinVisible || lockVisible).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
import { ApiClient } from "../api-client"
|
||||
import { SEED } from "../seed-constants"
|
||||
|
||||
const apiClient = new ApiClient()
|
||||
|
||||
test.describe("Info Board Page @full", () => {
|
||||
test.beforeEach(async () => {
|
||||
await apiClient.login(SEED.admin.email, SEED.admin.password)
|
||||
await apiClient.resetDb()
|
||||
})
|
||||
|
||||
test("lists seed posts with pinned post first", async ({ page }) => {
|
||||
await page.goto("/info-board")
|
||||
// Should have at least 2 posts visible
|
||||
const posts = page.locator("[data-testid*='info-post']").or(
|
||||
page.locator("article, [role='article']")
|
||||
)
|
||||
|
||||
// Wait for content to load
|
||||
await page.waitForTimeout(1000)
|
||||
await expect(page.locator("body")).toBeVisible()
|
||||
|
||||
// Verify posts are listed (look for post content or structure)
|
||||
const postElements = page
|
||||
.locator("[data-testid*='post']")
|
||||
.or(page.locator("article"))
|
||||
const count = await postElements.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("category filter dropdown works", async ({ page }) => {
|
||||
await page.goto("/info-board")
|
||||
|
||||
// Look for category filter
|
||||
const filterSelect = page
|
||||
.locator('[data-testid="info-board-category-filter"]')
|
||||
.or(page.getByRole("combobox"))
|
||||
.or(page.locator("select"))
|
||||
|
||||
if (await filterSelect.first().isVisible()) {
|
||||
await filterSelect.first().click()
|
||||
await page.waitForTimeout(300)
|
||||
// Options should appear
|
||||
await expect(page.locator("body")).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test("new post dialog opens and form submits", async ({ page }) => {
|
||||
await page.goto("/info-board")
|
||||
|
||||
const newBtn = page
|
||||
.getByRole("button", { name: /neuer beitrag|new post|erstellen/i })
|
||||
.or(page.locator('[data-testid="info-board-new-post"]'))
|
||||
|
||||
await expect(newBtn).toBeVisible()
|
||||
await newBtn.click()
|
||||
|
||||
// Dialog should open with form
|
||||
await expect(
|
||||
page.getByRole("dialog").or(page.locator("[role='dialog']"))
|
||||
).toBeVisible()
|
||||
|
||||
// Fill form fields
|
||||
const titleInput = page
|
||||
.getByLabel(/titel|title/i)
|
||||
.or(page.locator("input[name*='title']"))
|
||||
if (await titleInput.isVisible()) {
|
||||
await titleInput.fill("E2E Test Beitrag")
|
||||
}
|
||||
|
||||
const contentInput = page
|
||||
.getByLabel(/inhalt|content|text/i)
|
||||
.or(page.locator("textarea"))
|
||||
if (await contentInput.isVisible()) {
|
||||
await contentInput.fill("Test-Inhalt für Integration Test.")
|
||||
}
|
||||
|
||||
// Submit
|
||||
const submitBtn = page.getByRole("button", {
|
||||
name: /erstellen|speichern|submit|posten/i,
|
||||
})
|
||||
if (await submitBtn.isVisible()) {
|
||||
await submitBtn.click()
|
||||
// Should succeed (toast or new post visible)
|
||||
await page.waitForTimeout(1000)
|
||||
await expect(page.locator("body")).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test("pin indicator visible on pinned post", async ({ page }) => {
|
||||
await page.goto("/info-board")
|
||||
|
||||
// Look for pin icon/badge on the first (pinned) post
|
||||
await expect(
|
||||
page
|
||||
.locator("[data-testid*='pinned']")
|
||||
.first()
|
||||
.or(page.locator("[aria-label*='pin']").first())
|
||||
.or(page.getByText(/📌|angepinnt|pinned/i).first())
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("archive and delete buttons visible", async ({ page }) => {
|
||||
await page.goto("/info-board")
|
||||
|
||||
// Admin should see archive/delete actions
|
||||
const archiveBtn = page
|
||||
.getByRole("button", { name: /archiv/i })
|
||||
.first()
|
||||
.or(page.locator("[data-testid*='archive']").first())
|
||||
const deleteBtn = page
|
||||
.getByRole("button", { name: /löschen|delete/i })
|
||||
.first()
|
||||
.or(page.locator("[data-testid*='delete']").first())
|
||||
|
||||
const archiveVisible = await archiveBtn.isVisible()
|
||||
const deleteVisible = await deleteBtn.isVisible()
|
||||
expect(archiveVisible || deleteVisible).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
import { ApiClient } from "../api-client"
|
||||
import { SEED } from "../seed-constants"
|
||||
|
||||
const apiClient = new ApiClient()
|
||||
|
||||
test.describe("Grow Page @full", () => {
|
||||
test.beforeEach(async () => {
|
||||
await apiClient.login(SEED.admin.email, SEED.admin.password)
|
||||
await apiClient.resetDb()
|
||||
})
|
||||
|
||||
test("shows seed grow entries", async ({ page }) => {
|
||||
await page.goto("/grow")
|
||||
await expect(
|
||||
page.getByText("Northern Lights Batch #2")
|
||||
).toBeVisible()
|
||||
await expect(page.getByText("CBD Outdoor")).toBeVisible()
|
||||
})
|
||||
|
||||
test("displays grow stages", async ({ page }) => {
|
||||
await page.goto("/grow")
|
||||
// Should show VEGETATIVE and SEEDLING stage indicators
|
||||
await expect(
|
||||
page
|
||||
.getByText(/vegetativ|vegetative/i)
|
||||
.first()
|
||||
.or(page.locator("[data-testid*='stage-VEGETATIVE']").first())
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page
|
||||
.getByText(/sämling|seedling/i)
|
||||
.first()
|
||||
.or(page.locator("[data-testid*='stage-SEEDLING']").first())
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("stage progress indicators shown", async ({ page }) => {
|
||||
await page.goto("/grow")
|
||||
// Look for progress bars or step indicators
|
||||
const progressIndicators = page
|
||||
.locator("[role='progressbar']")
|
||||
.or(page.locator("[data-testid*='progress']"))
|
||||
.or(page.locator("[data-testid*='stage-indicator']"))
|
||||
|
||||
const count = await progressIndicators.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("new grow button links to correct path", async ({ page }) => {
|
||||
await page.goto("/grow")
|
||||
const newBtn = page
|
||||
.getByRole("link", { name: /neuer grow|new grow|anlegen/i })
|
||||
.or(page.locator('[data-testid="grow-new-button"]'))
|
||||
.or(page.getByRole("button", { name: /neuer grow|new grow|anlegen/i }))
|
||||
|
||||
await expect(newBtn).toBeVisible()
|
||||
await newBtn.click()
|
||||
await page.waitForURL(/\/grow\/new/)
|
||||
})
|
||||
|
||||
test("click on entry navigates to detail page", async ({ page }) => {
|
||||
await page.goto("/grow")
|
||||
|
||||
// Click on the first grow entry
|
||||
const entry = page
|
||||
.getByText("Northern Lights Batch #2")
|
||||
.or(page.locator("[data-testid*='grow-entry']").first())
|
||||
await entry.click()
|
||||
|
||||
// Should navigate to /grow/[id]
|
||||
await page.waitForURL(/\/grow\/[a-zA-Z0-9-]+/)
|
||||
await expect(page.locator("body")).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
import { ApiClient } from "../api-client"
|
||||
import { SEED } from "../seed-constants"
|
||||
|
||||
const apiClient = new ApiClient()
|
||||
|
||||
test.describe("Compliance Dashboard @full", () => {
|
||||
test.beforeEach(async () => {
|
||||
await apiClient.login(SEED.admin.email, SEED.admin.password)
|
||||
await apiClient.resetDb()
|
||||
})
|
||||
|
||||
test("compliance dashboard loads", async ({ page }) => {
|
||||
await page.goto("/compliance")
|
||||
// Page should load without error
|
||||
await expect(
|
||||
page
|
||||
.getByText(/compliance|konformität/i)
|
||||
.first()
|
||||
.or(page.getByRole("heading").first())
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("shows area status cards", async ({ page }) => {
|
||||
await page.goto("/compliance")
|
||||
// Should display compliance areas: KCANG, FINANCE, DSGVO, VEREIN
|
||||
await expect(page.getByText(/kcang/i)).toBeVisible()
|
||||
await expect(page.getByText(/finan/i).first()).toBeVisible()
|
||||
await expect(page.getByText(/dsgvo|datenschutz/i).first()).toBeVisible()
|
||||
await expect(page.getByText(/verein/i).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test("overdue deadlines highlighted", async ({ page }) => {
|
||||
await page.goto("/compliance")
|
||||
// EÜR Abgabe should be overdue and highlighted
|
||||
await expect(
|
||||
page.getByText(/EÜR/i).or(page.getByText(/überfällig|overdue/i).first())
|
||||
).toBeVisible()
|
||||
|
||||
// Overdue items should have visual distinction (red text, warning badge, etc.)
|
||||
const overdueIndicator = page
|
||||
.locator("[data-testid*='overdue']")
|
||||
.or(page.locator(".text-destructive, .text-red, [class*='overdue']"))
|
||||
.first()
|
||||
|
||||
if (await overdueIndicator.isVisible()) {
|
||||
await expect(overdueIndicator).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test("upcoming deadlines show days remaining", async ({ page }) => {
|
||||
await page.goto("/compliance")
|
||||
// Should display upcoming deadlines with days remaining
|
||||
await expect(
|
||||
page
|
||||
.getByText(/tag|day/i)
|
||||
.first()
|
||||
.or(page.locator("[data-testid*='deadline']").first())
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
import { ApiClient } from "../api-client"
|
||||
import { SEED } from "../seed-constants"
|
||||
|
||||
const apiClient = new ApiClient()
|
||||
|
||||
test.describe("Finance Page @full", () => {
|
||||
test.beforeEach(async () => {
|
||||
await apiClient.login(SEED.admin.email, SEED.admin.password)
|
||||
await apiClient.resetDb()
|
||||
})
|
||||
|
||||
test("finance page loads", async ({ page }) => {
|
||||
await page.goto("/finance")
|
||||
await expect(
|
||||
page
|
||||
.getByRole("heading", { name: /finan/i })
|
||||
.or(page.getByText(/finanzen|finance/i).first())
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("sub-navigation links exist", async ({ page }) => {
|
||||
await page.goto("/finance")
|
||||
// Should have sub-nav links for: payments, kassenbuch, import, fee-schedules, reports
|
||||
const links = [
|
||||
/zahlungen|payments/i,
|
||||
/kassenbuch/i,
|
||||
/import/i,
|
||||
/beitragsordnung|fee/i,
|
||||
/berichte|reports/i,
|
||||
]
|
||||
|
||||
for (const linkPattern of links) {
|
||||
const link = page
|
||||
.getByRole("link", { name: linkPattern })
|
||||
.or(page.getByRole("tab", { name: linkPattern }))
|
||||
.or(page.getByRole("button", { name: linkPattern }))
|
||||
await expect(link.first()).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test("payments sub-page loads", async ({ page }) => {
|
||||
await page.goto("/finance/payments")
|
||||
await expect(page.locator("body")).toBeVisible()
|
||||
// Should not show an error page
|
||||
await expect(page.getByText(/404|not found/i)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test("kassenbuch sub-page loads", async ({ page }) => {
|
||||
await page.goto("/finance/kassenbuch")
|
||||
await expect(page.locator("body")).toBeVisible()
|
||||
await expect(page.getByText(/404|not found/i)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test("import sub-page loads", async ({ page }) => {
|
||||
await page.goto("/finance/import")
|
||||
await expect(page.locator("body")).toBeVisible()
|
||||
await expect(page.getByText(/404|not found/i)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test("fee-schedules sub-page loads", async ({ page }) => {
|
||||
await page.goto("/finance/fee-schedules")
|
||||
await expect(page.locator("body")).toBeVisible()
|
||||
await expect(page.getByText(/404|not found/i)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test("reports sub-page loads", async ({ page }) => {
|
||||
await page.goto("/finance/reports")
|
||||
await expect(page.locator("body")).toBeVisible()
|
||||
await expect(page.getByText(/404|not found/i)).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
import { ApiClient } from "../api-client"
|
||||
import { SEED } from "../seed-constants"
|
||||
|
||||
const apiClient = new ApiClient()
|
||||
|
||||
test.describe("Audit Log Page @full", () => {
|
||||
test.beforeEach(async () => {
|
||||
await apiClient.login(SEED.admin.email, SEED.admin.password)
|
||||
await apiClient.resetDb()
|
||||
})
|
||||
|
||||
test("audit log page loads", async ({ page }) => {
|
||||
await page.goto("/audit-log")
|
||||
await expect(
|
||||
page
|
||||
.getByRole("heading", { name: /audit|protokoll/i })
|
||||
.or(page.getByText(/audit/i).first())
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("shows table or list structure", async ({ page }) => {
|
||||
await page.goto("/audit-log")
|
||||
// Should display audit entries in a table or list
|
||||
const table = page
|
||||
.getByRole("table")
|
||||
.or(page.locator("[data-testid='audit-log-table']"))
|
||||
.or(page.locator("[data-testid*='audit-entry']").first())
|
||||
|
||||
await expect(table.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test("has filter or search capability", async ({ page }) => {
|
||||
await page.goto("/audit-log")
|
||||
// Should have some kind of filter/search input
|
||||
const filterInput = page
|
||||
.getByRole("searchbox")
|
||||
.or(page.getByPlaceholder(/such|filter|search/i))
|
||||
.or(page.locator('[data-testid="audit-log-filter"]'))
|
||||
.or(page.locator("input[type='search']"))
|
||||
.or(page.getByRole("combobox"))
|
||||
|
||||
await expect(filterInput.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,295 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
import { ApiClient } from "../api-client"
|
||||
import { SEED } from "../seed-constants"
|
||||
|
||||
const apiClient = new ApiClient()
|
||||
|
||||
test.describe("KCanG Regulatory Edge Cases @full", () => {
|
||||
test.beforeEach(async () => {
|
||||
await apiClient.login(SEED.admin.email, SEED.admin.password)
|
||||
await apiClient.resetDb()
|
||||
})
|
||||
|
||||
// Requires: backend quota enforcement
|
||||
test("rejects adult distribution exceeding 25g/day", async ({ page }) => {
|
||||
await page.goto("/distributions/new")
|
||||
|
||||
// Select adult member (Max Mustermann)
|
||||
const memberSelect = page
|
||||
.getByLabel(/mitglied|member/i)
|
||||
.or(page.locator("[data-testid='distribution-member-select']"))
|
||||
await memberSelect.click()
|
||||
await page.getByText(SEED.members.max.name).click()
|
||||
|
||||
// Select strain
|
||||
const strainSelect = page
|
||||
.getByLabel(/sorte|strain|charge|batch/i)
|
||||
.or(page.locator("[data-testid='distribution-strain-select']"))
|
||||
await strainSelect.click()
|
||||
await page.getByText(SEED.strains.northernLights.name).click()
|
||||
|
||||
// Enter 26g (exceeds 25g daily limit)
|
||||
const amountInput = page
|
||||
.getByLabel(/menge|amount|gramm/i)
|
||||
.or(page.locator("input[name*='amount']"))
|
||||
await amountInput.fill("26")
|
||||
|
||||
// Submit
|
||||
const submitBtn = page.getByRole("button", {
|
||||
name: /ausgeben|submit|speichern/i,
|
||||
})
|
||||
await submitBtn.click()
|
||||
|
||||
// Should show rejection/error
|
||||
await expect(
|
||||
page.getByText(/überschr|exceeded|limit|abgelehnt|rejected/i)
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// Requires: backend quota enforcement
|
||||
test("accepts adult distribution of exactly 25g", async ({ page }) => {
|
||||
await page.goto("/distributions/new")
|
||||
|
||||
const memberSelect = page
|
||||
.getByLabel(/mitglied|member/i)
|
||||
.or(page.locator("[data-testid='distribution-member-select']"))
|
||||
await memberSelect.click()
|
||||
await page.getByText(SEED.members.max.name).click()
|
||||
|
||||
const strainSelect = page
|
||||
.getByLabel(/sorte|strain|charge|batch/i)
|
||||
.or(page.locator("[data-testid='distribution-strain-select']"))
|
||||
await strainSelect.click()
|
||||
await page.getByText(SEED.strains.northernLights.name).click()
|
||||
|
||||
const amountInput = page
|
||||
.getByLabel(/menge|amount|gramm/i)
|
||||
.or(page.locator("input[name*='amount']"))
|
||||
await amountInput.fill("25")
|
||||
|
||||
const submitBtn = page.getByRole("button", {
|
||||
name: /ausgeben|submit|speichern/i,
|
||||
})
|
||||
await submitBtn.click()
|
||||
|
||||
// Should succeed
|
||||
await expect(
|
||||
page.getByText(/erfolg|success|gespeichert/i)
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// Requires: backend quota enforcement
|
||||
test("rejects under-21 member with strain exceeding 10% THC", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/distributions/new")
|
||||
|
||||
// Select under-21 member (Jonas Weber)
|
||||
const memberSelect = page
|
||||
.getByLabel(/mitglied|member/i)
|
||||
.or(page.locator("[data-testid='distribution-member-select']"))
|
||||
await memberSelect.click()
|
||||
await page.getByText(SEED.members.jonas.name).click()
|
||||
|
||||
// Select Amnesia Haze (22% THC — exceeds 10% limit for under-21)
|
||||
const strainSelect = page
|
||||
.getByLabel(/sorte|strain|charge|batch/i)
|
||||
.or(page.locator("[data-testid='distribution-strain-select']"))
|
||||
await strainSelect.click()
|
||||
await page.getByText(SEED.strains.amnesiaHaze.name).click()
|
||||
|
||||
const amountInput = page
|
||||
.getByLabel(/menge|amount|gramm/i)
|
||||
.or(page.locator("input[name*='amount']"))
|
||||
await amountInput.fill("5")
|
||||
|
||||
const submitBtn = page.getByRole("button", {
|
||||
name: /ausgeben|submit|speichern/i,
|
||||
})
|
||||
await submitBtn.click()
|
||||
|
||||
// Should show THC rejection
|
||||
await expect(
|
||||
page.getByText(/thc|überschr|exceeded|limit|abgelehnt|rejected/i)
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// Requires: backend quota enforcement
|
||||
test("accepts under-21 member with strain within THC limit", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/distributions/new")
|
||||
|
||||
// Select under-21 member (Jonas)
|
||||
const memberSelect = page
|
||||
.getByLabel(/mitglied|member/i)
|
||||
.or(page.locator("[data-testid='distribution-member-select']"))
|
||||
await memberSelect.click()
|
||||
await page.getByText(SEED.members.jonas.name).click()
|
||||
|
||||
// Select CBD Critical Mass (5% THC — within 10% limit)
|
||||
const strainSelect = page
|
||||
.getByLabel(/sorte|strain|charge|batch/i)
|
||||
.or(page.locator("[data-testid='distribution-strain-select']"))
|
||||
await strainSelect.click()
|
||||
await page.getByText(SEED.strains.cbdCriticalMass.name).click()
|
||||
|
||||
const amountInput = page
|
||||
.getByLabel(/menge|amount|gramm/i)
|
||||
.or(page.locator("input[name*='amount']"))
|
||||
await amountInput.fill("5")
|
||||
|
||||
const submitBtn = page.getByRole("button", {
|
||||
name: /ausgeben|submit|speichern/i,
|
||||
})
|
||||
await submitBtn.click()
|
||||
|
||||
// Should succeed
|
||||
await expect(
|
||||
page.getByText(/erfolg|success|gespeichert/i)
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// Requires: backend quota enforcement
|
||||
test("rejects under-21 member exceeding 30g/month", async ({ page }) => {
|
||||
// This test assumes Jonas has already received close to 30g this month
|
||||
// The seed data should set up 31g attempted distribution
|
||||
await page.goto("/distributions/new")
|
||||
|
||||
const memberSelect = page
|
||||
.getByLabel(/mitglied|member/i)
|
||||
.or(page.locator("[data-testid='distribution-member-select']"))
|
||||
await memberSelect.click()
|
||||
await page.getByText(SEED.members.jonas.name).click()
|
||||
|
||||
const strainSelect = page
|
||||
.getByLabel(/sorte|strain|charge|batch/i)
|
||||
.or(page.locator("[data-testid='distribution-strain-select']"))
|
||||
await strainSelect.click()
|
||||
await page.getByText(SEED.strains.cbdCriticalMass.name).click()
|
||||
|
||||
// 31g exceeds the 30g/month limit for under-21
|
||||
const amountInput = page
|
||||
.getByLabel(/menge|amount|gramm/i)
|
||||
.or(page.locator("input[name*='amount']"))
|
||||
await amountInput.fill("31")
|
||||
|
||||
const submitBtn = page.getByRole("button", {
|
||||
name: /ausgeben|submit|speichern/i,
|
||||
})
|
||||
await submitBtn.click()
|
||||
|
||||
// Should show monthly quota rejection
|
||||
await expect(
|
||||
page.getByText(/überschr|exceeded|limit|monat|monthly|abgelehnt/i)
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// Requires: backend quota enforcement
|
||||
test("accepts near-quota member within daily limit", async ({ page }) => {
|
||||
// Thomas has 23g already this day — 2g more should be fine (25g total)
|
||||
await page.goto("/distributions/new")
|
||||
|
||||
const memberSelect = page
|
||||
.getByLabel(/mitglied|member/i)
|
||||
.or(page.locator("[data-testid='distribution-member-select']"))
|
||||
await memberSelect.click()
|
||||
await page.getByText(SEED.members.thomas.name).click()
|
||||
|
||||
const strainSelect = page
|
||||
.getByLabel(/sorte|strain|charge|batch/i)
|
||||
.or(page.locator("[data-testid='distribution-strain-select']"))
|
||||
await strainSelect.click()
|
||||
await page.getByText(SEED.strains.northernLights.name).click()
|
||||
|
||||
const amountInput = page
|
||||
.getByLabel(/menge|amount|gramm/i)
|
||||
.or(page.locator("input[name*='amount']"))
|
||||
await amountInput.fill("2")
|
||||
|
||||
const submitBtn = page.getByRole("button", {
|
||||
name: /ausgeben|submit|speichern/i,
|
||||
})
|
||||
await submitBtn.click()
|
||||
|
||||
// Should succeed (23g + 2g = 25g, exactly at limit)
|
||||
await expect(
|
||||
page.getByText(/erfolg|success|gespeichert/i)
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// Requires: backend quota enforcement
|
||||
test("rejects near-quota member exceeding daily cumulative", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Thomas has 23g already — 3g more would be 26g (exceeds 25g/day)
|
||||
await page.goto("/distributions/new")
|
||||
|
||||
const memberSelect = page
|
||||
.getByLabel(/mitglied|member/i)
|
||||
.or(page.locator("[data-testid='distribution-member-select']"))
|
||||
await memberSelect.click()
|
||||
await page.getByText(SEED.members.thomas.name).click()
|
||||
|
||||
const strainSelect = page
|
||||
.getByLabel(/sorte|strain|charge|batch/i)
|
||||
.or(page.locator("[data-testid='distribution-strain-select']"))
|
||||
await strainSelect.click()
|
||||
await page.getByText(SEED.strains.northernLights.name).click()
|
||||
|
||||
const amountInput = page
|
||||
.getByLabel(/menge|amount|gramm/i)
|
||||
.or(page.locator("input[name*='amount']"))
|
||||
await amountInput.fill("3")
|
||||
|
||||
const submitBtn = page.getByRole("button", {
|
||||
name: /ausgeben|submit|speichern/i,
|
||||
})
|
||||
await submitBtn.click()
|
||||
|
||||
// Should show daily cumulative rejection
|
||||
await expect(
|
||||
page.getByText(/überschr|exceeded|limit|abgelehnt|rejected/i)
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// Requires: backend quota enforcement
|
||||
test("shows THC warning for under-21 members on distribution page", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/distributions/new")
|
||||
|
||||
// Select under-21 member (Jonas)
|
||||
const memberSelect = page
|
||||
.getByLabel(/mitglied|member/i)
|
||||
.or(page.locator("[data-testid='distribution-member-select']"))
|
||||
await memberSelect.click()
|
||||
await page.getByText(SEED.members.jonas.name).click()
|
||||
|
||||
// Should show THC% warning/info for under-21
|
||||
await expect(
|
||||
page.getByText(/thc.*10|unter.*21|u21|jugendschutz/i).first()
|
||||
).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
// Requires: backend quota enforcement
|
||||
test("quota display shows correct remaining amount", async ({ page }) => {
|
||||
await page.goto("/distributions/new")
|
||||
|
||||
// Select Thomas (near-quota member, 23g already used today)
|
||||
const memberSelect = page
|
||||
.getByLabel(/mitglied|member/i)
|
||||
.or(page.locator("[data-testid='distribution-member-select']"))
|
||||
await memberSelect.click()
|
||||
await page.getByText(SEED.members.thomas.name).click()
|
||||
|
||||
// Should display remaining quota info
|
||||
await expect(
|
||||
page
|
||||
.getByText(/verbleibend|remaining|rest|kontingent|quota/i)
|
||||
.first()
|
||||
.or(page.locator("[data-testid*='quota']").first())
|
||||
).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
# Integration Tests
|
||||
|
||||
Full-stack integration tests that run against a real backend + database.
|
||||
|
||||
## Running locally
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.test.yml -f docker-compose.test.local.yml up --build
|
||||
```
|
||||
|
||||
## Running in CI
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
- Each spec file tests one page/feature
|
||||
- Tests use `data-testid` selectors from `../selectors.ts`
|
||||
- Expected values come from `../seed-constants.ts`
|
||||
- DB is reset before each test via `ApiClient.resetDb()`
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Deterministic seed data constants matching R__seed_test_data.sql.
|
||||
* Single source of truth for all integration test assertions.
|
||||
*/
|
||||
export const SEED = {
|
||||
club: {
|
||||
id: "a0000000-0000-0000-0000-000000000001",
|
||||
name: "Grüner Daumen e.V.",
|
||||
},
|
||||
admin: {
|
||||
id: "b1000000-0000-0000-0000-000000000001",
|
||||
email: "admin@gruener-daumen.de",
|
||||
password: "TestAdmin123!",
|
||||
},
|
||||
members: {
|
||||
max: {
|
||||
id: "c1000000-0000-0000-0000-000000000001",
|
||||
name: "Max Mustermann",
|
||||
status: "ACTIVE",
|
||||
},
|
||||
anna: {
|
||||
id: "c1000000-0000-0000-0000-000000000002",
|
||||
name: "Anna Schmidt",
|
||||
status: "ACTIVE",
|
||||
},
|
||||
jonas: {
|
||||
id: "c1000000-0000-0000-0000-000000000003",
|
||||
name: "Jonas Weber",
|
||||
status: "ACTIVE",
|
||||
isUnder21: true,
|
||||
},
|
||||
maria: {
|
||||
id: "c1000000-0000-0000-0000-000000000004",
|
||||
name: "Maria Müller",
|
||||
status: "SUSPENDED",
|
||||
},
|
||||
thomas: {
|
||||
id: "c1000000-0000-0000-0000-000000000005",
|
||||
name: "Thomas Müller",
|
||||
status: "ACTIVE",
|
||||
nearQuota: true,
|
||||
},
|
||||
lisa: {
|
||||
id: "c1000000-0000-0000-0000-000000000006",
|
||||
name: "Lisa Bauer",
|
||||
status: "ACTIVE",
|
||||
},
|
||||
karl: {
|
||||
id: "c1000000-0000-0000-0000-000000000007",
|
||||
name: "Karl Fischer",
|
||||
status: "EXPELLED",
|
||||
},
|
||||
},
|
||||
strains: {
|
||||
northernLights: {
|
||||
id: "d1000000-0000-0000-0000-000000000001",
|
||||
name: "Northern Lights",
|
||||
thc: 18.5,
|
||||
cbd: 0.5,
|
||||
},
|
||||
cbdCriticalMass: {
|
||||
id: "d1000000-0000-0000-0000-000000000002",
|
||||
name: "CBD Critical Mass",
|
||||
thc: 5.0,
|
||||
cbd: 12.0,
|
||||
},
|
||||
amnesiaHaze: {
|
||||
id: "d1000000-0000-0000-0000-000000000003",
|
||||
name: "Amnesia Haze",
|
||||
thc: 22.0,
|
||||
cbd: 0.1,
|
||||
},
|
||||
},
|
||||
batches: {
|
||||
northernLights: {
|
||||
id: "e1000000-0000-0000-0000-000000000001",
|
||||
quantity: 500,
|
||||
status: "AVAILABLE",
|
||||
},
|
||||
cbdCriticalMass: {
|
||||
id: "e1000000-0000-0000-0000-000000000002",
|
||||
quantity: 300,
|
||||
status: "AVAILABLE",
|
||||
},
|
||||
amnesiaHaze: {
|
||||
id: "e1000000-0000-0000-0000-000000000003",
|
||||
quantity: 200,
|
||||
status: "RECALLED",
|
||||
},
|
||||
},
|
||||
documents: {
|
||||
satzung: {
|
||||
id: "f1000000-0000-0000-0000-000000000001",
|
||||
title: "Vereinssatzung 2024",
|
||||
category: "SATZUNG",
|
||||
},
|
||||
protokoll: {
|
||||
id: "f1000000-0000-0000-0000-000000000002",
|
||||
title: "Protokoll MV März 2024",
|
||||
category: "PROTOKOLL",
|
||||
},
|
||||
genehmigung: {
|
||||
id: "f1000000-0000-0000-0000-000000000003",
|
||||
title: "KCanG-Genehmigung",
|
||||
category: "GENEHMIGUNG",
|
||||
},
|
||||
mietvertrag: {
|
||||
id: "f1000000-0000-0000-0000-000000000004",
|
||||
title: "Mietvertrag",
|
||||
category: "VERTRAG",
|
||||
},
|
||||
},
|
||||
board: {
|
||||
vorsitz: {
|
||||
id: "g1000000-0000-0000-0000-000000000001",
|
||||
title: "Vorsitzende/r",
|
||||
elected: "Max Mustermann",
|
||||
},
|
||||
kasse: {
|
||||
id: "g1000000-0000-0000-0000-000000000002",
|
||||
title: "Kassenführung",
|
||||
elected: "Anna Schmidt",
|
||||
},
|
||||
schrift: {
|
||||
id: "g1000000-0000-0000-0000-000000000003",
|
||||
title: "Schriftführung",
|
||||
vacant: true,
|
||||
},
|
||||
},
|
||||
counts: {
|
||||
totalMembers: 7,
|
||||
activeMembers: 5,
|
||||
documents: 4,
|
||||
batches: 3,
|
||||
availableBatches: 2,
|
||||
boardPositions: 3,
|
||||
vacantPositions: 1,
|
||||
},
|
||||
kcang: {
|
||||
adultDailyLimitGrams: 25,
|
||||
adultMonthlyLimitGrams: 50,
|
||||
under21MonthlyLimitGrams: 30,
|
||||
under21MaxThcPercent: 10,
|
||||
},
|
||||
} as const
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Centralized data-testid selectors for integration tests.
|
||||
* Naming convention: <page>-<component>-<identifier>
|
||||
*
|
||||
* Note: The actual data-testid attributes will be added incrementally
|
||||
* to frontend components during Phase 2E as tests are written.
|
||||
*/
|
||||
export const SEL = {
|
||||
// Sidebar / Navigation
|
||||
nav: {
|
||||
sidebar: '[data-testid="nav-sidebar"]',
|
||||
members: '[data-testid="nav-link-members"]',
|
||||
distributions: '[data-testid="nav-link-distributions"]',
|
||||
stock: '[data-testid="nav-link-stock"]',
|
||||
documents: '[data-testid="nav-link-documents"]',
|
||||
board: '[data-testid="nav-link-board"]',
|
||||
calendar: '[data-testid="nav-link-calendar"]',
|
||||
forum: '[data-testid="nav-link-forum"]',
|
||||
grow: '[data-testid="nav-link-grow"]',
|
||||
compliance: '[data-testid="nav-link-compliance"]',
|
||||
},
|
||||
// Members page
|
||||
members: {
|
||||
table: '[data-testid="members-table"]',
|
||||
searchInput: '[data-testid="members-search-input"]',
|
||||
addButton: '[data-testid="members-add-button"]',
|
||||
row: (id: string) => `[data-testid="members-row-${id}"]`,
|
||||
statusBadge: (id: string) => `[data-testid="members-status-${id}"]`,
|
||||
},
|
||||
// Documents page
|
||||
documents: {
|
||||
uploadButton: '[data-testid="documents-upload-button"]',
|
||||
uploadDialog: '[data-testid="documents-upload-dialog"]',
|
||||
titleInput: '[data-testid="documents-title-input"]',
|
||||
categorySelect: '[data-testid="documents-category-select"]',
|
||||
fileInput: '[data-testid="documents-file-input"]',
|
||||
submitUpload: '[data-testid="documents-submit-upload"]',
|
||||
downloadButton: (id: string) => `[data-testid="documents-download-${id}"]`,
|
||||
deleteButton: (id: string) => `[data-testid="documents-delete-${id}"]`,
|
||||
deleteConfirm: '[data-testid="documents-delete-confirm"]',
|
||||
categoryBadge: (category: string) =>
|
||||
`[data-testid="documents-category-${category}"]`,
|
||||
row: (id: string) => `[data-testid="documents-row-${id}"]`,
|
||||
},
|
||||
// Board page
|
||||
board: {
|
||||
createPositionButton: '[data-testid="board-create-position"]',
|
||||
electMemberButton: '[data-testid="board-elect-member"]',
|
||||
removeButton: (id: string) => `[data-testid="board-remove-${id}"]`,
|
||||
positionCard: (id: string) => `[data-testid="board-position-${id}"]`,
|
||||
},
|
||||
// Stock page
|
||||
stock: {
|
||||
addButton: '[data-testid="stock-add-button"]',
|
||||
recallButton: (id: string) => `[data-testid="stock-recall-${id}"]`,
|
||||
table: '[data-testid="stock-table"]',
|
||||
row: (id: string) => `[data-testid="stock-row-${id}"]`,
|
||||
},
|
||||
// Distributions page
|
||||
distributions: {
|
||||
newButton: '[data-testid="distributions-new-button"]',
|
||||
table: '[data-testid="distributions-table"]',
|
||||
row: (id: string) => `[data-testid="distributions-row-${id}"]`,
|
||||
},
|
||||
// Common/shared
|
||||
common: {
|
||||
toast: '[data-testid="toast"]',
|
||||
loadingSkeleton: '[data-testid="loading-skeleton"]',
|
||||
alertDialogConfirm: '[data-testid="alert-dialog-confirm"]',
|
||||
alertDialogCancel: '[data-testid="alert-dialog-cancel"]',
|
||||
},
|
||||
} as const
|
||||
@@ -44,7 +44,8 @@
|
||||
"emailInvalid": "Bitte gib eine gültige E-Mail-Adresse ein.",
|
||||
"passwordRequired": "Bitte gib dein Passwort ein.",
|
||||
"passwordTooShort": "Passwort muss mindestens 8 Zeichen lang sein.",
|
||||
"footerText": "Sichere Verwaltung für deinen Cannabis-Anbauverein"
|
||||
"footerText": "Sichere Verwaltung für deinen Cannabis-Anbauverein",
|
||||
"loginTitle": "Anmelden"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -668,6 +669,18 @@
|
||||
"starter": "E-Mail",
|
||||
"pro": "Priorität",
|
||||
"enterprise": "Dediziert"
|
||||
},
|
||||
"compStorage": {
|
||||
"label": "Speicher",
|
||||
"starter": "5 GB",
|
||||
"pro": "50 GB",
|
||||
"enterprise": "Individuell"
|
||||
},
|
||||
"compOverage": {
|
||||
"label": "Überschreitung",
|
||||
"starter": "Upgrade nötig",
|
||||
"pro": "0,15 €/GB/Mo",
|
||||
"enterprise": "—"
|
||||
}
|
||||
},
|
||||
"faq": {
|
||||
@@ -690,7 +703,29 @@
|
||||
"migration": {
|
||||
"question": "Kann ich den Plan später wechseln?",
|
||||
"answer": "Ja, du kannst jederzeit zwischen Starter und Pro wechseln. Ein Upgrade wird sofort wirksam, ein Downgrade zum nächsten Abrechnungszeitraum."
|
||||
},
|
||||
"storage": {
|
||||
"question": "Was passiert, wenn mein Speicher voll ist?",
|
||||
"answer": "Im Starter-Plan kannst du auf Pro upgraden. Im Pro-Plan wird zusätzlicher Speicher mit 0,15 €/GB/Monat berechnet. Enterprise-Kunden haben individuelle Speichervereinbarungen."
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"starter": "5 GB Speicher",
|
||||
"pro": "50 GB Speicher",
|
||||
"proOverage": "(danach 0,15 €/GB/Monat)",
|
||||
"enterprise": "Individueller Speicher",
|
||||
"comparisonTitle": "Funktionen im Vergleich",
|
||||
"featureMembers": "Mitglieder",
|
||||
"featureStorage": "Speicher",
|
||||
"featureOverage": "Überschreitung",
|
||||
"featureGrow": "Grow-Kalender",
|
||||
"featureApi": "API-Zugang",
|
||||
"featureMultiClub": "Multi-Club",
|
||||
"overageUpgrade": "Upgrade erforderlich",
|
||||
"overagePro": "0,15 €/GB/Mo",
|
||||
"overageEnterprise": "—",
|
||||
"unlimited": "Unbegrenzt",
|
||||
"custom": "Individuell"
|
||||
}
|
||||
},
|
||||
"impressum": {
|
||||
@@ -753,6 +788,46 @@
|
||||
"s9Content": "Der Anbieter verarbeitet personenbezogene Daten gemäß der Datenschutzerklärung und den Bestimmungen der DSGVO. Soweit der Anbieter Daten im Auftrag des Nutzers verarbeitet, wird ein gesonderter Auftragsverarbeitungsvertrag geschlossen.",
|
||||
"s10Title": "§ 10 Schlussbestimmungen",
|
||||
"s10Content": "Es gilt das Recht der Bundesrepublik Deutschland. Gerichtsstand ist, soweit gesetzlich zulässig, der Sitz des Anbieters. Sollten einzelne Bestimmungen dieser AGB unwirksam sein, bleibt die Wirksamkeit der übrigen Bestimmungen unberührt. Änderungen der AGB werden dem Nutzer rechtzeitig mitgeteilt."
|
||||
},
|
||||
"home": {
|
||||
"heroTitle": "Die smarte Verwaltung für deinen Anbauverein",
|
||||
"heroSubtitle": "Compliance, Anbau, Mitglieder und Abgaben — alles in einer Plattform. Rechtssicher nach KCanG.",
|
||||
"ctaPrimary": "Preise ansehen",
|
||||
"ctaSecondary": "Jetzt anmelden",
|
||||
"featuresTitle": "Alles, was dein Verein braucht",
|
||||
"featuresSubtitle": "Von der Mitgliederverwaltung bis zur Behördenmeldung — CannaManage deckt den gesamten Vereinsbetrieb ab.",
|
||||
"feature1Title": "Compliance Tracking",
|
||||
"feature1Desc": "Automatische Überwachung der KCanG-Vorgaben mit Fristen und Checklisten.",
|
||||
"feature2Title": "Grow Management",
|
||||
"feature2Desc": "Anbaukalender, Wachstumsphasen und Sensorintegration.",
|
||||
"feature3Title": "Mitglieder-Portal",
|
||||
"feature3Desc": "Self-Service für Mitglieder: Profil, Abgabehistorie, Dokumente.",
|
||||
"feature4Title": "Abgabe-Quotas",
|
||||
"feature4Desc": "Automatische Einhaltung der 25g/Tag und 50g/Monat Grenzen.",
|
||||
"feature5Title": "Dokumenten-Archiv",
|
||||
"feature5Desc": "GoBD-konforme Ablage mit Aufbewahrungsfristen und Versionierung.",
|
||||
"feature6Title": "Finanzverwaltung",
|
||||
"feature6Desc": "Mitgliedsbeiträge, SEPA-Export und Bankimport.",
|
||||
"trustTitle": "Vertrauen durch Compliance",
|
||||
"trustCanverg": "CanVerG-konform",
|
||||
"trustDsgvo": "DSGVO & GoBD",
|
||||
"trustEncryption": "TLS-verschlüsselt",
|
||||
"trustGerman": "Hosting in Deutschland",
|
||||
"ctaFinalTitle": "Bereit für den nächsten Schritt?",
|
||||
"ctaFinalSubtitle": "Starte jetzt mit CannaManage und bring deinen Verein auf das nächste Level.",
|
||||
"ctaFinalButton": "Kostenlos testen"
|
||||
},
|
||||
"nav": {
|
||||
"features": "Features",
|
||||
"pricing": "Preise",
|
||||
"login": "Anmelden",
|
||||
"footerTagline": "Die sichere Verwaltungssoftware für Cannabis-Anbauvereine in Deutschland.",
|
||||
"footerProduct": "Produkt",
|
||||
"footerLegal": "Rechtliches",
|
||||
"impressum": "Impressum",
|
||||
"datenschutz": "Datenschutz",
|
||||
"agb": "AGB",
|
||||
"allRightsReserved": "Alle Rechte vorbehalten."
|
||||
}
|
||||
},
|
||||
"infoBoard": {
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
"emailInvalid": "Please enter a valid email address.",
|
||||
"passwordRequired": "Please enter your password.",
|
||||
"passwordTooShort": "Password must be at least 8 characters.",
|
||||
"footerText": "Secure management for your cannabis cultivation club"
|
||||
"footerText": "Secure management for your cannabis cultivation club",
|
||||
"loginTitle": "Sign In"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -668,6 +669,18 @@
|
||||
"starter": "Email",
|
||||
"pro": "Priority",
|
||||
"enterprise": "Dedicated"
|
||||
},
|
||||
"compStorage": {
|
||||
"label": "Storage",
|
||||
"starter": "5 GB",
|
||||
"pro": "50 GB",
|
||||
"enterprise": "Custom"
|
||||
},
|
||||
"compOverage": {
|
||||
"label": "Overage",
|
||||
"starter": "Upgrade required",
|
||||
"pro": "€0.15/GB/mo",
|
||||
"enterprise": "—"
|
||||
}
|
||||
},
|
||||
"faq": {
|
||||
@@ -690,7 +703,29 @@
|
||||
"migration": {
|
||||
"question": "Can I switch plans later?",
|
||||
"answer": "Yes, you can switch between Starter and Pro at any time. Upgrades take effect immediately, downgrades at the next billing period."
|
||||
},
|
||||
"storage": {
|
||||
"question": "What happens when my storage is full?",
|
||||
"answer": "On the Starter plan, you can upgrade to Pro. On the Pro plan, additional storage is billed at €0.15/GB/month. Enterprise customers have custom storage agreements."
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"starter": "5 GB Storage",
|
||||
"pro": "50 GB Storage",
|
||||
"proOverage": "(then €0.15/GB/month)",
|
||||
"enterprise": "Custom Storage",
|
||||
"comparisonTitle": "Feature Comparison",
|
||||
"featureMembers": "Members",
|
||||
"featureStorage": "Storage",
|
||||
"featureOverage": "Overage",
|
||||
"featureGrow": "Grow Calendar",
|
||||
"featureApi": "API Access",
|
||||
"featureMultiClub": "Multi-Club",
|
||||
"overageUpgrade": "Upgrade required",
|
||||
"overagePro": "€0.15/GB/mo",
|
||||
"overageEnterprise": "—",
|
||||
"unlimited": "Unlimited",
|
||||
"custom": "Custom"
|
||||
}
|
||||
},
|
||||
"impressum": {
|
||||
@@ -753,6 +788,46 @@
|
||||
"s9Content": "The provider processes personal data in accordance with the privacy policy and GDPR provisions. Where the provider processes data on behalf of the user, a separate data processing agreement is concluded.",
|
||||
"s10Title": "§ 10 Final Provisions",
|
||||
"s10Content": "The law of the Federal Republic of Germany applies. The place of jurisdiction is, to the extent legally permissible, the registered office of the provider. Should individual provisions of these terms be invalid, the validity of the remaining provisions remains unaffected. Changes to these terms will be communicated to the user in good time."
|
||||
},
|
||||
"home": {
|
||||
"heroTitle": "The Smart Management for Your Cannabis Club",
|
||||
"heroSubtitle": "Compliance, growing, members and distributions — all in one platform. Legally compliant under KCanG.",
|
||||
"ctaPrimary": "View Pricing",
|
||||
"ctaSecondary": "Sign In",
|
||||
"featuresTitle": "Everything Your Club Needs",
|
||||
"featuresSubtitle": "From member management to regulatory reporting — CannaManage covers all aspects of your club operations.",
|
||||
"feature1Title": "Compliance Tracking",
|
||||
"feature1Desc": "Automatic monitoring of KCanG requirements with deadlines and checklists.",
|
||||
"feature2Title": "Grow Management",
|
||||
"feature2Desc": "Growing calendar, growth phases and sensor integration.",
|
||||
"feature3Title": "Member Portal",
|
||||
"feature3Desc": "Self-service for members: profile, distribution history, documents.",
|
||||
"feature4Title": "Distribution Quotas",
|
||||
"feature4Desc": "Automatic enforcement of 25g/day and 50g/month limits.",
|
||||
"feature5Title": "Document Archive",
|
||||
"feature5Desc": "GoBD-compliant storage with retention periods and versioning.",
|
||||
"feature6Title": "Financial Management",
|
||||
"feature6Desc": "Membership fees, SEPA export and bank import.",
|
||||
"trustTitle": "Trust Through Compliance",
|
||||
"trustCanverg": "CanVerG compliant",
|
||||
"trustDsgvo": "GDPR & GoBD",
|
||||
"trustEncryption": "TLS encrypted",
|
||||
"trustGerman": "Hosted in Germany",
|
||||
"ctaFinalTitle": "Ready for the Next Step?",
|
||||
"ctaFinalSubtitle": "Start with CannaManage now and take your club to the next level.",
|
||||
"ctaFinalButton": "Try for Free"
|
||||
},
|
||||
"nav": {
|
||||
"features": "Features",
|
||||
"pricing": "Pricing",
|
||||
"login": "Sign In",
|
||||
"footerTagline": "The secure management software for cannabis cultivation clubs in Germany.",
|
||||
"footerProduct": "Product",
|
||||
"footerLegal": "Legal",
|
||||
"impressum": "Imprint",
|
||||
"datenschutz": "Privacy Policy",
|
||||
"agb": "Terms",
|
||||
"allRightsReserved": "All rights reserved."
|
||||
}
|
||||
},
|
||||
"infoBoard": {
|
||||
|
||||
@@ -10,15 +10,10 @@ const nextConfig = {
|
||||
// Required for Docker standalone output
|
||||
output: "standalone",
|
||||
|
||||
// Proxy API calls to the Spring Boot backend
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/backend/:path*",
|
||||
destination: `${process.env.BACKEND_URL || "http://localhost:8080"}/api/v1/:path*`,
|
||||
},
|
||||
]
|
||||
},
|
||||
// NOTE: API calls to /api/backend/* are proxied by the server-side Route
|
||||
// Handler at src/app/api/backend/[...path]/route.ts, NOT by a static
|
||||
// rewrite. A static rewrite cannot inject the NextAuth Bearer token; the
|
||||
// route handler reads the session via auth() and forwards it. See that file.
|
||||
}
|
||||
|
||||
export default withNextIntl(nextConfig)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "shadboard-nextjs-starter-kit",
|
||||
"name": "cannamanage-frontend",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"author": {
|
||||
"name": "Layth Alqadhi",
|
||||
"url": "https://github.com/LaythAlqadhi"
|
||||
"name": "Patrick Plate",
|
||||
"url": "https://github.com/pplate"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -13,6 +13,7 @@
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"type-check": "tsc --noEmit",
|
||||
"format": "prettier --ignore-path .gitignore --write .",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
@@ -55,7 +56,7 @@
|
||||
"emoji-picker-react": "4.12.2",
|
||||
"input-otp": "1.4.2",
|
||||
"lucide-react": "0.446.0",
|
||||
"next": "15.5.18",
|
||||
"next": "15.5.19",
|
||||
"next-auth": "5.0.0-beta.31",
|
||||
"next-intl": "^4.13.0",
|
||||
"react": "19.1.3",
|
||||
@@ -87,7 +88,7 @@
|
||||
"@types/sockjs-client": "^1.5.4",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"eslint": "9.18.0",
|
||||
"eslint-config-next": "15.5.18",
|
||||
"eslint-config-next": "15.5.19",
|
||||
"eslint-config-prettier": "10.1.1",
|
||||
"eslint-plugin-prettier": "5.2.3",
|
||||
"jsdom": "^29.1.1",
|
||||
@@ -106,6 +107,9 @@
|
||||
"@types/react": "19.0.12",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"picomatch": ">=4.0.2",
|
||||
"postcss": ">=8.4.31"
|
||||
"postcss": ">=8.4.31",
|
||||
"minimatch": ">=5.1.6",
|
||||
"brace-expansion": ">=2.0.1",
|
||||
"ajv": ">=8.17.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineConfig } from "@playwright/test"
|
||||
import path from "path"
|
||||
|
||||
import { defineConfig } from "@playwright/test"
|
||||
|
||||
const authFile = path.join(__dirname, "e2e", ".auth", "admin.json")
|
||||
|
||||
export default defineConfig({
|
||||
@@ -9,7 +10,7 @@ export default defineConfig({
|
||||
retries: 0,
|
||||
timeout: 90_000,
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
baseURL: process.env.BASE_URL || "http://localhost:3000",
|
||||
screenshot: "on",
|
||||
trace: "on-first-retry",
|
||||
navigationTimeout: 60_000,
|
||||
@@ -22,8 +23,7 @@ export default defineConfig({
|
||||
},
|
||||
{
|
||||
name: "authenticated",
|
||||
testMatch:
|
||||
/authenticated-admin|visual-regression|accessibility/,
|
||||
testMatch: /authenticated-admin|visual-regression|accessibility/,
|
||||
dependencies: ["setup"],
|
||||
use: {
|
||||
storageState: authFile,
|
||||
@@ -36,6 +36,17 @@ export default defineConfig({
|
||||
/functional-flows|full-check|user-story-tests|system-test|staff-management|screenshot-tour|authenticated-tour/,
|
||||
use: { browserName: "chromium" },
|
||||
},
|
||||
{
|
||||
name: "integration",
|
||||
testMatch: /integration\//,
|
||||
dependencies: ["setup"],
|
||||
use: {
|
||||
storageState: authFile,
|
||||
browserName: "chromium",
|
||||
},
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 15_000 },
|
||||
},
|
||||
],
|
||||
outputDir: "./e2e/test-results",
|
||||
})
|
||||
|
||||
Generated
+56
-56
@@ -93,14 +93,14 @@ importers:
|
||||
specifier: 0.446.0
|
||||
version: 0.446.0(react@19.1.3)
|
||||
next:
|
||||
specifier: 15.5.18
|
||||
version: 15.5.18(@playwright/test@1.60.0)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)
|
||||
specifier: 15.5.19
|
||||
version: 15.5.19(@playwright/test@1.60.0)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)
|
||||
next-auth:
|
||||
specifier: 5.0.0-beta.31
|
||||
version: 5.0.0-beta.31(next@15.5.18(@playwright/test@1.60.0)(react-dom@19.1.3(react@19.1.3))(react@19.1.3))(react@19.1.3)
|
||||
version: 5.0.0-beta.31(next@15.5.19(@playwright/test@1.60.0)(react-dom@19.1.3(react@19.1.3))(react@19.1.3))(react@19.1.3)
|
||||
next-intl:
|
||||
specifier: ^4.13.0
|
||||
version: 4.13.0(next@15.5.18(@playwright/test@1.60.0)(react-dom@19.1.3(react@19.1.3))(react@19.1.3))(react@19.1.3)(typescript@5.9.3)
|
||||
version: 4.13.0(next@15.5.19(@playwright/test@1.60.0)(react-dom@19.1.3(react@19.1.3))(react@19.1.3))(react@19.1.3)(typescript@5.9.3)
|
||||
react:
|
||||
specifier: 19.1.3
|
||||
version: 19.1.3
|
||||
@@ -184,8 +184,8 @@ importers:
|
||||
specifier: 9.18.0
|
||||
version: 9.18.0(jiti@2.6.1)
|
||||
eslint-config-next:
|
||||
specifier: 15.5.18
|
||||
version: 15.5.18(eslint@9.18.0(jiti@2.6.1))(typescript@5.9.3)
|
||||
specifier: 15.5.19
|
||||
version: 15.5.19(eslint@9.18.0(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint-config-prettier:
|
||||
specifier: 10.1.1
|
||||
version: 10.1.1(eslint@9.18.0(jiti@2.6.1))
|
||||
@@ -679,56 +679,56 @@ packages:
|
||||
'@emnapi/core': ^1.7.1
|
||||
'@emnapi/runtime': ^1.7.1
|
||||
|
||||
'@next/env@15.5.18':
|
||||
resolution: {integrity: sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==}
|
||||
'@next/env@15.5.19':
|
||||
resolution: {integrity: sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw==}
|
||||
|
||||
'@next/eslint-plugin-next@15.5.18':
|
||||
resolution: {integrity: sha512-w4MYq8M26a8PNrfto0JosLf5/3ssln1rsyP96g2DkC8uFVymStM5DLSz5ElxxrPRg2XnTMnFo3kREFlhYvxhWw==}
|
||||
'@next/eslint-plugin-next@15.5.19':
|
||||
resolution: {integrity: sha512-Ctwb4qYuMbHN/1oXLlTdMchwG8h8Xzwq+wGZZMgF3o6+uwyBKAI2c96bdOsl+C62PaUD0Jkh+QpNkhUeDlam0Q==}
|
||||
|
||||
'@next/swc-darwin-arm64@15.5.18':
|
||||
resolution: {integrity: sha512-w0WvQf1n+txiwns/9pwIQteCJpZTbxzO2SE0FLcwuD4v0WEh1JPOjdyxWL21XwJsdpx8cFRjyzxzCS/siP7HcQ==}
|
||||
'@next/swc-darwin-arm64@15.5.19':
|
||||
resolution: {integrity: sha512-jx9wWlTKueHKPvVOndyr7WuaevWCkuYqsQ8gC0TMPKAVWG3MhcdMrjfo9tvIZNXd0QOUYXXvAcZ325y8Uq7uzg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-darwin-x64@15.5.18':
|
||||
resolution: {integrity: sha512-znn71QmDuxm+BOaglihMZfvyySMnNljkVIY5Z2TCssBmm+WqL6c19VhtH5ktFkHa8EZ2bnTUpcNcmNSQsg67og==}
|
||||
'@next/swc-darwin-x64@15.5.19':
|
||||
resolution: {integrity: sha512-291KFcsIQ3OenRdiUDFOR6W3wezzH4auENXm1gbm1Bjd4ANMMRgxPrWTUztQN43BnVoVuMnHCrLeECIMwgFKbA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-linux-arm64-gnu@15.5.18':
|
||||
resolution: {integrity: sha512-yPPe5MNL+igZUa+OsqQJisqSfh6oarIuA1Q0BDxljGJhRQyZeP+WRHh7rs/jZUGMh5aY0YdIjXZG0VohkKkUdw==}
|
||||
'@next/swc-linux-arm64-gnu@15.5.19':
|
||||
resolution: {integrity: sha512-WeH+nelQyyMeE2f8FxBRZNrGipya5zHZV2vjzfCOAYyiI6am+NbnWAAldOBFQBB2w0DjJcsvrKqoFT2b7+5YoA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-linux-arm64-musl@15.5.18':
|
||||
resolution: {integrity: sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw==}
|
||||
'@next/swc-linux-arm64-musl@15.5.19':
|
||||
resolution: {integrity: sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-linux-x64-gnu@15.5.18':
|
||||
resolution: {integrity: sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A==}
|
||||
'@next/swc-linux-x64-gnu@15.5.19':
|
||||
resolution: {integrity: sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-linux-x64-musl@15.5.18':
|
||||
resolution: {integrity: sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA==}
|
||||
'@next/swc-linux-x64-musl@15.5.19':
|
||||
resolution: {integrity: sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@15.5.18':
|
||||
resolution: {integrity: sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA==}
|
||||
'@next/swc-win32-arm64-msvc@15.5.19':
|
||||
resolution: {integrity: sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-x64-msvc@15.5.18':
|
||||
resolution: {integrity: sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg==}
|
||||
'@next/swc-win32-x64-msvc@15.5.19':
|
||||
resolution: {integrity: sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -2802,8 +2802,8 @@ packages:
|
||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
eslint-config-next@15.5.18:
|
||||
resolution: {integrity: sha512-HuoJU6uUPD00eyiud78IBnT4HLhztFj2V+ild2Uon5ZUrYZKe0Olu2QRD99e9IgL4/H1eg5Onka3BsfRW2U0Xw==}
|
||||
eslint-config-next@15.5.19:
|
||||
resolution: {integrity: sha512-UZwkuhBCNxVZfo93MSHRDOVNWXooJJGcAUyTAVIp0+9QFhH4SqJxWY0s6Mk9C2kMi777HPMn3dseOrZshWpG9Q==}
|
||||
peerDependencies:
|
||||
eslint: ^7.23.0 || ^8.0.0 || ^9.0.0
|
||||
typescript: '>=3.3.1'
|
||||
@@ -3655,8 +3655,8 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
next@15.5.18:
|
||||
resolution: {integrity: sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ==}
|
||||
next@15.5.19:
|
||||
resolution: {integrity: sha512-xNOW6tYshGX1/Oi3F8uuk4gpDeWsSUE/1Z0G5uUMekIxaQ0xc03UXd9II0VQHYMWviMeA0OHpJFAKsHf8bTYVg==}
|
||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -5051,34 +5051,34 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.2
|
||||
optional: true
|
||||
|
||||
'@next/env@15.5.18': {}
|
||||
'@next/env@15.5.19': {}
|
||||
|
||||
'@next/eslint-plugin-next@15.5.18':
|
||||
'@next/eslint-plugin-next@15.5.19':
|
||||
dependencies:
|
||||
fast-glob: 3.3.1
|
||||
|
||||
'@next/swc-darwin-arm64@15.5.18':
|
||||
'@next/swc-darwin-arm64@15.5.19':
|
||||
optional: true
|
||||
|
||||
'@next/swc-darwin-x64@15.5.18':
|
||||
'@next/swc-darwin-x64@15.5.19':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-gnu@15.5.18':
|
||||
'@next/swc-linux-arm64-gnu@15.5.19':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-musl@15.5.18':
|
||||
'@next/swc-linux-arm64-musl@15.5.19':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-gnu@15.5.18':
|
||||
'@next/swc-linux-x64-gnu@15.5.19':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-musl@15.5.18':
|
||||
'@next/swc-linux-x64-musl@15.5.19':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-arm64-msvc@15.5.18':
|
||||
'@next/swc-win32-arm64-msvc@15.5.19':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-x64-msvc@15.5.18':
|
||||
'@next/swc-win32-x64-msvc@15.5.19':
|
||||
optional: true
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
@@ -7020,9 +7020,9 @@ snapshots:
|
||||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
|
||||
eslint-config-next@15.5.18(eslint@9.18.0(jiti@2.6.1))(typescript@5.9.3):
|
||||
eslint-config-next@15.5.19(eslint@9.18.0(jiti@2.6.1))(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@next/eslint-plugin-next': 15.5.18
|
||||
'@next/eslint-plugin-next': 15.5.19
|
||||
'@rushstack/eslint-patch': 1.16.1
|
||||
'@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.18.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.18.0(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.61.0(eslint@9.18.0(jiti@2.6.1))(typescript@5.9.3)
|
||||
@@ -7875,22 +7875,22 @@ snapshots:
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
next-auth@5.0.0-beta.31(next@15.5.18(@playwright/test@1.60.0)(react-dom@19.1.3(react@19.1.3))(react@19.1.3))(react@19.1.3):
|
||||
next-auth@5.0.0-beta.31(next@15.5.19(@playwright/test@1.60.0)(react-dom@19.1.3(react@19.1.3))(react@19.1.3))(react@19.1.3):
|
||||
dependencies:
|
||||
'@auth/core': 0.41.2
|
||||
next: 15.5.18(@playwright/test@1.60.0)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)
|
||||
next: 15.5.19(@playwright/test@1.60.0)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)
|
||||
react: 19.1.3
|
||||
|
||||
next-intl-swc-plugin-extractor@4.13.0: {}
|
||||
|
||||
next-intl@4.13.0(next@15.5.18(@playwright/test@1.60.0)(react-dom@19.1.3(react@19.1.3))(react@19.1.3))(react@19.1.3)(typescript@5.9.3):
|
||||
next-intl@4.13.0(next@15.5.19(@playwright/test@1.60.0)(react-dom@19.1.3(react@19.1.3))(react@19.1.3))(react@19.1.3)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@formatjs/intl-localematcher': 0.8.10
|
||||
'@parcel/watcher': 2.5.6
|
||||
'@swc/core': 1.15.41
|
||||
icu-minify: 4.13.0
|
||||
negotiator: 1.0.0
|
||||
next: 15.5.18(@playwright/test@1.60.0)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)
|
||||
next: 15.5.19(@playwright/test@1.60.0)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)
|
||||
next-intl-swc-plugin-extractor: 4.13.0
|
||||
po-parser: 2.1.1
|
||||
react: 19.1.3
|
||||
@@ -7900,9 +7900,9 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@swc/helpers'
|
||||
|
||||
next@15.5.18(@playwright/test@1.60.0)(react-dom@19.1.3(react@19.1.3))(react@19.1.3):
|
||||
next@15.5.19(@playwright/test@1.60.0)(react-dom@19.1.3(react@19.1.3))(react@19.1.3):
|
||||
dependencies:
|
||||
'@next/env': 15.5.18
|
||||
'@next/env': 15.5.19
|
||||
'@swc/helpers': 0.5.15
|
||||
caniuse-lite: 1.0.30001799
|
||||
postcss: 8.4.31
|
||||
@@ -7910,14 +7910,14 @@ snapshots:
|
||||
react-dom: 19.1.3(react@19.1.3)
|
||||
styled-jsx: 5.1.6(react@19.1.3)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 15.5.18
|
||||
'@next/swc-darwin-x64': 15.5.18
|
||||
'@next/swc-linux-arm64-gnu': 15.5.18
|
||||
'@next/swc-linux-arm64-musl': 15.5.18
|
||||
'@next/swc-linux-x64-gnu': 15.5.18
|
||||
'@next/swc-linux-x64-musl': 15.5.18
|
||||
'@next/swc-win32-arm64-msvc': 15.5.18
|
||||
'@next/swc-win32-x64-msvc': 15.5.18
|
||||
'@next/swc-darwin-arm64': 15.5.19
|
||||
'@next/swc-darwin-x64': 15.5.19
|
||||
'@next/swc-linux-arm64-gnu': 15.5.19
|
||||
'@next/swc-linux-arm64-musl': 15.5.19
|
||||
'@next/swc-linux-x64-gnu': 15.5.19
|
||||
'@next/swc-linux-x64-musl': 15.5.19
|
||||
'@next/swc-win32-arm64-msvc': 15.5.19
|
||||
'@next/swc-win32-x64-msvc': 15.5.19
|
||||
'@playwright/test': 1.60.0
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
|
||||
@@ -98,11 +98,11 @@ export const mockStaffList = [
|
||||
]
|
||||
|
||||
export const mockQuotaStatus = {
|
||||
memberId: "m1",
|
||||
dailyUsedGrams: 15,
|
||||
dailyLimitGrams: 25,
|
||||
monthlyUsedGrams: 15,
|
||||
monthlyLimitGrams: 50,
|
||||
usedGrams: 15,
|
||||
remainingGrams: 35,
|
||||
distributionCount: 3,
|
||||
isUnder21: false,
|
||||
}
|
||||
|
||||
export const mockRecentDistributions = [
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("useQuotaQuery", () => {
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(result.current.data).toEqual(mockQuotaStatus)
|
||||
expect(result.current.data?.monthlyLimitGrams).toBe(50)
|
||||
expect(result.current.data?.usedGrams).toBe(15)
|
||||
expect(result.current.data?.dailyUsedGrams).toBe(15)
|
||||
})
|
||||
|
||||
it("is disabled when memberId is empty", async () => {
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("useMemberQuotaQuery", () => {
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(result.current.data).toEqual(mockQuotaStatus)
|
||||
expect(result.current.data?.remainingGrams).toBe(35)
|
||||
expect(result.current.data?.monthlyUsedGrams).toBe(15)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextIntlClientProvider } from "next-intl"
|
||||
import { getMessages } from "next-intl/server"
|
||||
import { Cannabis, ClipboardCheck, Scale, Users } from "lucide-react"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
@@ -11,10 +12,74 @@ export default async function AuthLayout({
|
||||
const messages = await getMessages()
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex min-h-screen items-center justify-center bg-background text-foreground p-4">
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</div>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<div className="fixed inset-0 z-50 flex min-h-screen bg-background text-foreground">
|
||||
{/* Left panel — branding (hidden on mobile) */}
|
||||
<div className="hidden md:flex md:w-1/2 lg:w-[55%] flex-col items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-background p-12 relative overflow-hidden">
|
||||
{/* Decorative background blur */}
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div className="absolute top-1/4 left-1/4 h-64 w-64 rounded-full bg-primary/10 blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 h-48 w-48 rounded-full bg-primary/5 blur-2xl" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-8 max-w-sm text-center">
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10 border border-primary/20">
|
||||
<Cannabis className="h-9 w-9 text-primary" />
|
||||
</div>
|
||||
|
||||
{/* App name & tagline */}
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold">CannaManage</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Dein Verein, digital verwaltet
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature highlights */}
|
||||
<div className="space-y-4 text-left w-full">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<ClipboardCheck className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">KCanG-Compliance</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Automatische Vorgaben-Überwachung
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Users className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Mitgliederverwaltung</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Portal, Profile und Dokumente
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Scale className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Abgabe-Tracking</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
25g/Tag und 50g/Monat automatisch
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel — form */}
|
||||
<div className="w-full md:w-1/2 lg:w-[45%] flex items-center justify-center p-6 sm:p-8">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { signIn } from "next-auth/react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
import { Cannabis, Loader2 } from "lucide-react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -55,13 +55,10 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
{/* Logo & Branding */}
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Cannabis className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">CannaManage</h1>
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
{/* Title — visible on mobile where left panel is hidden */}
|
||||
<div className="space-y-2 text-center md:text-left">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("loginTitle")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("loginSubtitle")}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
useBoardQuery,
|
||||
useCreatePositionMutation,
|
||||
useElectBoardMemberMutation,
|
||||
usePositionsQuery,
|
||||
useRemoveBoardMemberMutation,
|
||||
} from "@/services/board"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { Calendar, Edit, Plus, Shield, UserMinus, UserPlus } from "lucide-react"
|
||||
|
||||
import type { BoardMember, BoardPosition } from "@/services/board"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
@@ -20,7 +38,7 @@ import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select } from "@/components/ui/select"
|
||||
|
||||
// Mock data
|
||||
// Mock data (fallback)
|
||||
const mockPositions: BoardPosition[] = [
|
||||
{
|
||||
id: "1",
|
||||
@@ -142,8 +160,186 @@ const mockBoardMembers: (BoardMember & {
|
||||
|
||||
export default function BoardPage() {
|
||||
const t = useTranslations("board")
|
||||
|
||||
// --- React Query ---
|
||||
const { data: boardData } = useBoardQuery()
|
||||
const { data: positionsData } = usePositionsQuery()
|
||||
const createPositionMutation = useCreatePositionMutation()
|
||||
const electMutation = useElectBoardMemberMutation()
|
||||
const removeMutation = useRemoveBoardMemberMutation()
|
||||
|
||||
// Dual mode: detect if backend is unavailable (mock mode)
|
||||
const isMockMode = !boardData && !positionsData
|
||||
const [localPositions, setLocalPositions] =
|
||||
useState<BoardPosition[]>(mockPositions)
|
||||
const [localBoardMembers, setLocalBoardMembers] =
|
||||
useState<typeof mockBoardMembers>(mockBoardMembers)
|
||||
|
||||
// Use API data or local state (for mock mode operations)
|
||||
const positions = positionsData ?? localPositions
|
||||
const boardMembers =
|
||||
(boardData as typeof mockBoardMembers) ?? localBoardMembers
|
||||
|
||||
// --- UI state ---
|
||||
const [positionDialogOpen, setPositionDialogOpen] = useState(false)
|
||||
const [electDialogOpen, setElectDialogOpen] = useState(false)
|
||||
const [removeTarget, setRemoveTarget] = useState<
|
||||
(typeof mockBoardMembers)[0] | null
|
||||
>(null)
|
||||
|
||||
// Position form state
|
||||
const [posTitle, setPosTitle] = useState("")
|
||||
const [posDesc, setPosDesc] = useState("")
|
||||
const [sortOrder, setSortOrder] = useState(0)
|
||||
|
||||
// Elect form state
|
||||
const [electPositionId, setElectPositionId] = useState("")
|
||||
const [electMemberId, setElectMemberId] = useState("")
|
||||
const [electedAt, setElectedAt] = useState("")
|
||||
const [termStart, setTermStart] = useState("")
|
||||
const [termEnd, setTermEnd] = useState("")
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
function handleCreatePosition() {
|
||||
if (!posTitle.trim()) {
|
||||
toast.error("Bitte einen Positionstitel angeben.")
|
||||
return
|
||||
}
|
||||
|
||||
if (isMockMode) {
|
||||
const newPosition: BoardPosition = {
|
||||
id: crypto.randomUUID(),
|
||||
title: posTitle.trim(),
|
||||
description: posDesc.trim() || null,
|
||||
sortOrder: sortOrder || positions.length + 1,
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
setLocalPositions((prev) => [...prev, newPosition])
|
||||
toast.success("Position erfolgreich erstellt.")
|
||||
setPositionDialogOpen(false)
|
||||
setPosTitle("")
|
||||
setPosDesc("")
|
||||
setSortOrder(0)
|
||||
return
|
||||
}
|
||||
|
||||
createPositionMutation.mutate(
|
||||
{
|
||||
title: posTitle.trim(),
|
||||
description: posDesc.trim() || undefined,
|
||||
sortOrder: sortOrder || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Position erfolgreich erstellt.")
|
||||
setPositionDialogOpen(false)
|
||||
setPosTitle("")
|
||||
setPosDesc("")
|
||||
setSortOrder(0)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Fehler beim Erstellen der Position.")
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function handleElectMember() {
|
||||
if (!electPositionId || !electMemberId || !electedAt || !termStart) {
|
||||
toast.error("Bitte alle Pflichtfelder ausfüllen.")
|
||||
return
|
||||
}
|
||||
|
||||
if (isMockMode) {
|
||||
const position = positions.find((p) => p.id === electPositionId)
|
||||
const memberNames: Record<string, string> = {
|
||||
m1: "Max Mustermann",
|
||||
m2: "Anna Schmidt",
|
||||
m3: "Peter Weber",
|
||||
}
|
||||
const newMember = {
|
||||
id: crypto.randomUUID(),
|
||||
clubId: "c1",
|
||||
positionId: electPositionId,
|
||||
memberId: electMemberId,
|
||||
electedAt,
|
||||
termStart,
|
||||
termEnd: termEnd || null,
|
||||
isCurrent: true,
|
||||
electedInAssemblyId: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
memberName: memberNames[electMemberId] ?? electMemberId,
|
||||
positionTitle: position?.title ?? electPositionId,
|
||||
}
|
||||
setLocalBoardMembers((prev) => [...prev, newMember])
|
||||
toast.success("Vorstandsmitglied erfolgreich gewählt.")
|
||||
setElectDialogOpen(false)
|
||||
setElectPositionId("")
|
||||
setElectMemberId("")
|
||||
setElectedAt("")
|
||||
setTermStart("")
|
||||
setTermEnd("")
|
||||
return
|
||||
}
|
||||
|
||||
electMutation.mutate(
|
||||
{
|
||||
positionId: electPositionId,
|
||||
memberId: electMemberId,
|
||||
electedAt,
|
||||
termStart,
|
||||
termEnd: termEnd || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Vorstandsmitglied erfolgreich gewählt.")
|
||||
setElectDialogOpen(false)
|
||||
setElectPositionId("")
|
||||
setElectMemberId("")
|
||||
setElectedAt("")
|
||||
setTermStart("")
|
||||
setTermEnd("")
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Fehler bei der Wahl des Vorstandsmitglieds.")
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function handleRemove(bm: (typeof mockBoardMembers)[0]) {
|
||||
setRemoveTarget(bm)
|
||||
}
|
||||
|
||||
function confirmRemove() {
|
||||
if (!removeTarget) return
|
||||
|
||||
if (isMockMode) {
|
||||
setLocalBoardMembers((prev) =>
|
||||
prev.filter((m) => m.id !== removeTarget.id)
|
||||
)
|
||||
toast.success(
|
||||
`${removeTarget.memberName ?? "Mitglied"} wurde aus dem Vorstand entfernt.`
|
||||
)
|
||||
setRemoveTarget(null)
|
||||
return
|
||||
}
|
||||
|
||||
removeMutation.mutate(removeTarget.id, {
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
`${removeTarget.memberName ?? "Mitglied"} wurde aus dem Vorstand entfernt.`
|
||||
)
|
||||
setRemoveTarget(null)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Fehler beim Entfernen des Vorstandsmitglieds.")
|
||||
setRemoveTarget(null)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -158,7 +354,7 @@ export default function BoardPage() {
|
||||
onOpenChange={setPositionDialogOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Button variant="outline" data-testid="board-create-position">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("addPosition")}
|
||||
</Button>
|
||||
@@ -173,6 +369,8 @@ export default function BoardPage() {
|
||||
<Input
|
||||
id="posTitle"
|
||||
placeholder={t("positionTitlePlaceholder")}
|
||||
value={posTitle}
|
||||
onChange={(e) => setPosTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -180,24 +378,34 @@ export default function BoardPage() {
|
||||
<Input
|
||||
id="posDesc"
|
||||
placeholder={t("positionDescPlaceholder")}
|
||||
value={posDesc}
|
||||
onChange={(e) => setPosDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sortOrder">{t("sortOrder")}</Label>
|
||||
<Input id="sortOrder" type="number" defaultValue={0} />
|
||||
<Input
|
||||
id="sortOrder"
|
||||
type="number"
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => setPositionDialogOpen(false)}
|
||||
onClick={handleCreatePosition}
|
||||
disabled={createPositionMutation.isPending}
|
||||
>
|
||||
{t("save")}
|
||||
{createPositionMutation.isPending
|
||||
? "Wird gespeichert..."
|
||||
: t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={electDialogOpen} onOpenChange={setElectDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Button data-testid="board-elect-member">
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t("electMember")}
|
||||
</Button>
|
||||
@@ -209,9 +417,12 @@ export default function BoardPage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>{t("position")}</Label>
|
||||
<Select>
|
||||
<Select
|
||||
value={electPositionId}
|
||||
onChange={(e) => setElectPositionId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("selectPosition")}</option>
|
||||
{mockPositions.map((pos) => (
|
||||
{positions.map((pos) => (
|
||||
<option key={pos.id} value={pos.id}>
|
||||
{pos.title}
|
||||
</option>
|
||||
@@ -220,7 +431,10 @@ export default function BoardPage() {
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("member")}</Label>
|
||||
<Select>
|
||||
<Select
|
||||
value={electMemberId}
|
||||
onChange={(e) => setElectMemberId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("selectMember")}</option>
|
||||
<option value="m1">Max Mustermann</option>
|
||||
<option value="m2">Anna Schmidt</option>
|
||||
@@ -229,23 +443,41 @@ export default function BoardPage() {
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="electedAt">{t("electedAt")}</Label>
|
||||
<Input id="electedAt" type="date" />
|
||||
<Input
|
||||
id="electedAt"
|
||||
type="date"
|
||||
value={electedAt}
|
||||
onChange={(e) => setElectedAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="termStart">{t("termStart")}</Label>
|
||||
<Input id="termStart" type="date" />
|
||||
<Input
|
||||
id="termStart"
|
||||
type="date"
|
||||
value={termStart}
|
||||
onChange={(e) => setTermStart(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="termEnd">{t("termEnd")}</Label>
|
||||
<Input id="termEnd" type="date" />
|
||||
<Input
|
||||
id="termEnd"
|
||||
type="date"
|
||||
value={termEnd}
|
||||
onChange={(e) => setTermEnd(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => setElectDialogOpen(false)}
|
||||
onClick={handleElectMember}
|
||||
disabled={electMutation.isPending}
|
||||
>
|
||||
{t("confirmElection")}
|
||||
{electMutation.isPending
|
||||
? "Wird gespeichert..."
|
||||
: t("confirmElection")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -255,8 +487,8 @@ export default function BoardPage() {
|
||||
|
||||
{/* Current Board Members as cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{mockBoardMembers.map((bm) => (
|
||||
<Card key={bm.id}>
|
||||
{boardMembers.map((bm) => (
|
||||
<Card key={bm.id} data-testid={`board-position-${bm.id}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -269,6 +501,8 @@ export default function BoardPage() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
data-testid={`board-remove-${bm.id}`}
|
||||
onClick={() => handleRemove(bm)}
|
||||
>
|
||||
<UserMinus className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -322,7 +556,7 @@ export default function BoardPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{mockPositions.map((pos) => (
|
||||
{positions.map((pos) => (
|
||||
<div
|
||||
key={pos.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
@@ -343,6 +577,31 @@ export default function BoardPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Remove confirmation dialog */}
|
||||
<AlertDialog
|
||||
open={!!removeTarget}
|
||||
onOpenChange={(open) => !open && setRemoveTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Vorstandsmitglied entfernen?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Möchtest du {removeTarget?.memberName ?? "dieses Mitglied"} als{" "}
|
||||
{removeTarget?.positionTitle} wirklich aus dem Vorstand entfernen?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmRemove}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{removeMutation.isPending ? "Entfernen..." : "Entfernen"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { categoryLabels, formatFileSize } from "@/services/documents"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
categoryLabels,
|
||||
downloadDocument,
|
||||
formatFileSize,
|
||||
useDeleteDocumentMutation,
|
||||
useDocumentsQuery,
|
||||
useUploadDocumentMutation,
|
||||
} from "@/services/documents"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
BookOpen,
|
||||
CheckCircle,
|
||||
Download,
|
||||
File,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
Filter,
|
||||
Image,
|
||||
Shield,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react"
|
||||
|
||||
import type { ClubDocument, DocumentCategory } from "@/services/documents"
|
||||
import type {
|
||||
ClubDocument,
|
||||
DocumentAccessLevel,
|
||||
DocumentCategory,
|
||||
} from "@/services/documents"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { TableSkeleton } from "@/components/ui/data-skeleton"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -39,7 +65,7 @@ import {
|
||||
} from "@/components/ui/table"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
// Mock data for development
|
||||
// Mock data for development (fallback when API is unavailable)
|
||||
const mockDocuments: ClubDocument[] = [
|
||||
{
|
||||
id: "1",
|
||||
@@ -108,6 +134,56 @@ const mockDocuments: ClubDocument[] = [
|
||||
},
|
||||
]
|
||||
|
||||
// --- Category styling ---
|
||||
|
||||
const categoryStyles: Record<
|
||||
DocumentCategory,
|
||||
{ bg: string; text: string; icon: React.ReactNode }
|
||||
> = {
|
||||
SATZUNG: {
|
||||
bg: "bg-blue-100 dark:bg-blue-900/30",
|
||||
text: "text-blue-700 dark:text-blue-300",
|
||||
icon: <BookOpen className="h-3 w-3" />,
|
||||
},
|
||||
PROTOKOLL: {
|
||||
bg: "bg-purple-100 dark:bg-purple-900/30",
|
||||
text: "text-purple-700 dark:text-purple-300",
|
||||
icon: <FileText className="h-3 w-3" />,
|
||||
},
|
||||
VERTRAG: {
|
||||
bg: "bg-amber-100 dark:bg-amber-900/30",
|
||||
text: "text-amber-700 dark:text-amber-300",
|
||||
icon: <FileSpreadsheet className="h-3 w-3" />,
|
||||
},
|
||||
VERSICHERUNG: {
|
||||
bg: "bg-cyan-100 dark:bg-cyan-900/30",
|
||||
text: "text-cyan-700 dark:text-cyan-300",
|
||||
icon: <Shield className="h-3 w-3" />,
|
||||
},
|
||||
GENEHMIGUNG: {
|
||||
bg: "bg-green-100 dark:bg-green-900/30",
|
||||
text: "text-green-700 dark:text-green-300",
|
||||
icon: <CheckCircle className="h-3 w-3" />,
|
||||
},
|
||||
SONSTIGES: {
|
||||
bg: "bg-gray-100 dark:bg-gray-900/30",
|
||||
text: "text-gray-700 dark:text-gray-300",
|
||||
icon: <File className="h-3 w-3" />,
|
||||
},
|
||||
}
|
||||
|
||||
function CategoryBadge({ category }: { category: DocumentCategory }) {
|
||||
const style = categoryStyles[category]
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium ${style.bg} ${style.text}`}
|
||||
>
|
||||
{style.icon}
|
||||
{categoryLabels[category]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function getFileIcon(contentType: string) {
|
||||
if (contentType === "application/pdf") return <FileText className="h-4 w-4" />
|
||||
if (contentType.includes("spreadsheet"))
|
||||
@@ -116,29 +192,36 @@ function getFileIcon(contentType: string) {
|
||||
return <File className="h-4 w-4" />
|
||||
}
|
||||
|
||||
function getCategoryBadgeVariant(
|
||||
category: DocumentCategory
|
||||
): "default" | "secondary" | "destructive" | "outline" {
|
||||
const variants: Record<
|
||||
DocumentCategory,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
SATZUNG: "default",
|
||||
PROTOKOLL: "secondary",
|
||||
VERTRAG: "outline",
|
||||
VERSICHERUNG: "outline",
|
||||
GENEHMIGUNG: "destructive",
|
||||
SONSTIGES: "secondary",
|
||||
}
|
||||
return variants[category]
|
||||
}
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const t = useTranslations("documents")
|
||||
const [documents] = useState<ClubDocument[]>(mockDocuments)
|
||||
|
||||
// --- React Query ---
|
||||
const { data, isLoading } = useDocumentsQuery()
|
||||
const uploadMutation = useUploadDocumentMutation()
|
||||
const deleteMutation = useDeleteDocumentMutation()
|
||||
|
||||
// Dual mode: detect if backend is unavailable (mock mode)
|
||||
const isMockMode = !data
|
||||
const [localDocuments, setLocalDocuments] =
|
||||
useState<ClubDocument[]>(mockDocuments)
|
||||
|
||||
// Use API data or local state (for mock mode operations)
|
||||
const documents = data ?? localDocuments
|
||||
|
||||
// --- UI state ---
|
||||
const [uploadOpen, setUploadOpen] = useState(false)
|
||||
const [filterCategory, setFilterCategory] = useState<string>("ALL")
|
||||
const [deleteTarget, setDeleteTarget] = useState<ClubDocument | null>(null)
|
||||
|
||||
// Upload form state
|
||||
const [title, setTitle] = useState("")
|
||||
const [category, setCategory] = useState<DocumentCategory | "">("")
|
||||
const [accessLevel, setAccessLevel] =
|
||||
useState<DocumentAccessLevel>("ALL_MEMBERS")
|
||||
const [description, setDescription] = useState("")
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
|
||||
// --- Filtering ---
|
||||
const filteredDocuments =
|
||||
filterCategory === "ALL"
|
||||
? documents
|
||||
@@ -155,6 +238,126 @@ export default function DocumentsPage() {
|
||||
{} as Record<string, ClubDocument[]>
|
||||
)
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
function resetUploadForm() {
|
||||
setTitle("")
|
||||
setCategory("")
|
||||
setAccessLevel("ALL_MEMBERS")
|
||||
setDescription("")
|
||||
setFile(null)
|
||||
}
|
||||
|
||||
function handleUpload() {
|
||||
if (!title.trim() || !category || !file) {
|
||||
toast.error("Bitte Titel, Kategorie und Datei ausfüllen.")
|
||||
return
|
||||
}
|
||||
|
||||
if (isMockMode) {
|
||||
const newDoc: ClubDocument = {
|
||||
id: crypto.randomUUID(),
|
||||
title: title.trim(),
|
||||
category: category as DocumentCategory,
|
||||
filename: file.name,
|
||||
contentType: file.type || "application/octet-stream",
|
||||
fileSize: file.size,
|
||||
accessLevel,
|
||||
description: description.trim() || null,
|
||||
uploadedBy: "current-user",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: null,
|
||||
}
|
||||
setLocalDocuments((prev) => [newDoc, ...prev])
|
||||
toast.success("Dokument erfolgreich hochgeladen.")
|
||||
setUploadOpen(false)
|
||||
resetUploadForm()
|
||||
return
|
||||
}
|
||||
|
||||
uploadMutation.mutate(
|
||||
{
|
||||
title: title.trim(),
|
||||
category: category as DocumentCategory,
|
||||
accessLevel,
|
||||
description: description.trim() || null,
|
||||
file,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Dokument erfolgreich hochgeladen.")
|
||||
setUploadOpen(false)
|
||||
resetUploadForm()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Fehler beim Hochladen des Dokuments.")
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function handleDownload(id: string, filename: string) {
|
||||
if (isMockMode) {
|
||||
toast.info("Demo-Modus: Download nicht verfügbar.")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await downloadDocument(id)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
toast.error("Fehler beim Herunterladen.")
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(doc: ClubDocument) {
|
||||
setDeleteTarget(doc)
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (!deleteTarget) return
|
||||
|
||||
if (isMockMode) {
|
||||
setLocalDocuments((prev) => prev.filter((d) => d.id !== deleteTarget.id))
|
||||
toast.success(`"${deleteTarget.title}" wurde gelöscht.`)
|
||||
setDeleteTarget(null)
|
||||
return
|
||||
}
|
||||
|
||||
deleteMutation.mutate(deleteTarget.id, {
|
||||
onSuccess: () => {
|
||||
toast.success(`"${deleteTarget.title}" wurde gelöscht.`)
|
||||
setDeleteTarget(null)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Fehler beim Löschen des Dokuments.")
|
||||
setDeleteTarget(null)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- Loading state ---
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||
<p className="text-muted-foreground">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<TableSkeleton rows={5} columns={5} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -164,23 +367,39 @@ export default function DocumentsPage() {
|
||||
</div>
|
||||
<Dialog open={uploadOpen} onOpenChange={setUploadOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Button data-testid="documents-upload-button">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t("upload")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
data-testid="documents-upload-dialog"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("uploadDocument")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="title">{t("documentTitle")}</Label>
|
||||
<Input id="title" placeholder={t("titlePlaceholder")} />
|
||||
<Input
|
||||
id="title"
|
||||
data-testid="documents-title-input"
|
||||
placeholder={t("titlePlaceholder")}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="category">{t("category")}</Label>
|
||||
<Select id="category">
|
||||
<Select
|
||||
id="category"
|
||||
data-testid="documents-category-select"
|
||||
value={category}
|
||||
onChange={(e) =>
|
||||
setCategory(e.target.value as DocumentCategory | "")
|
||||
}
|
||||
>
|
||||
<option value="">{t("selectCategory")}</option>
|
||||
{Object.entries(categoryLabels).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
@@ -191,7 +410,13 @@ export default function DocumentsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="accessLevel">{t("accessLevel")}</Label>
|
||||
<Select id="accessLevel" defaultValue="ALL_MEMBERS">
|
||||
<Select
|
||||
id="accessLevel"
|
||||
value={accessLevel}
|
||||
onChange={(e) =>
|
||||
setAccessLevel(e.target.value as DocumentAccessLevel)
|
||||
}
|
||||
>
|
||||
<option value="ALL_MEMBERS">{t("allMembers")}</option>
|
||||
<option value="BOARD_ONLY">{t("boardOnly")}</option>
|
||||
</Select>
|
||||
@@ -201,22 +426,33 @@ export default function DocumentsPage() {
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder={t("descriptionPlaceholder")}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="file">{t("file")}</Label>
|
||||
<Input
|
||||
id="file"
|
||||
data-testid="documents-file-input"
|
||||
type="file"
|
||||
accept=".pdf,.docx,.xlsx,.png,.jpg,.jpeg"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("fileHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Button className="w-full" onClick={() => setUploadOpen(false)}>
|
||||
<Button
|
||||
className="w-full"
|
||||
data-testid="documents-submit-upload"
|
||||
onClick={handleUpload}
|
||||
disabled={uploadMutation.isPending}
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t("uploadButton")}
|
||||
{uploadMutation.isPending
|
||||
? "Wird hochgeladen..."
|
||||
: t("uploadButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -244,15 +480,11 @@ export default function DocumentsPage() {
|
||||
</div>
|
||||
|
||||
{/* Documents grouped by category */}
|
||||
{Object.entries(grouped).map(([category, docs]) => (
|
||||
<Card key={category}>
|
||||
{Object.entries(grouped).map(([cat, docs]) => (
|
||||
<Card key={cat}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Badge
|
||||
variant={getCategoryBadgeVariant(category as DocumentCategory)}
|
||||
>
|
||||
{categoryLabels[category as DocumentCategory]}
|
||||
</Badge>
|
||||
<CategoryBadge category={cat as DocumentCategory} />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({docs.length})
|
||||
</span>
|
||||
@@ -262,30 +494,35 @@ export default function DocumentsPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("access")}</TableHead>
|
||||
<TableHead>{t("size")}</TableHead>
|
||||
<TableHead>{t("date")}</TableHead>
|
||||
<TableHead className="text-right">{t("actions")}</TableHead>
|
||||
<TableHead className="max-w-[300px]">{t("name")}</TableHead>
|
||||
<TableHead className="w-[120px]">{t("access")}</TableHead>
|
||||
<TableHead className="w-[80px]">{t("size")}</TableHead>
|
||||
<TableHead className="w-[100px]">{t("date")}</TableHead>
|
||||
<TableHead className="w-[80px] text-right">
|
||||
{t("actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{docs.map((doc) => (
|
||||
<TableRow key={doc.id}>
|
||||
<TableCell>
|
||||
<TableRow
|
||||
key={doc.id}
|
||||
data-testid={`documents-row-${doc.id}`}
|
||||
>
|
||||
<TableCell className="max-w-[300px]">
|
||||
<div className="flex items-center gap-2">
|
||||
{getFileIcon(doc.contentType)}
|
||||
<div>
|
||||
<p className="font-medium">{doc.title}</p>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{doc.title}</p>
|
||||
{doc.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{doc.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="w-[120px]">
|
||||
<Badge
|
||||
variant={
|
||||
doc.accessLevel === "BOARD_ONLY"
|
||||
@@ -298,19 +535,28 @@ export default function DocumentsPage() {
|
||||
: t("allMembers")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatFileSize(doc.fileSize)}</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="w-[80px]">
|
||||
{formatFileSize(doc.fileSize)}
|
||||
</TableCell>
|
||||
<TableCell className="w-[100px]">
|
||||
{new Date(doc.createdAt).toLocaleDateString("de-DE")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TableCell className="w-[80px] text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-testid={`documents-download-${doc.id}`}
|
||||
onClick={() => handleDownload(doc.id, doc.filename)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive"
|
||||
data-testid={`documents-delete-${doc.id}`}
|
||||
onClick={() => handleDelete(doc)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -323,6 +569,32 @@ export default function DocumentsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Dokument löschen?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Möchtest du "{deleteTarget?.title}" wirklich löschen?
|
||||
Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-testid="documents-delete-confirm"
|
||||
onClick={confirmDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteMutation.isPending ? "Löschen..." : "Löschen"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/dashboard")
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import Link from "next/link"
|
||||
import { NextIntlClientProvider } from "next-intl"
|
||||
import { getMessages } from "next-intl/server"
|
||||
import { Cannabis } from "lucide-react"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import MarketingLayoutClient from "./marketing-layout-client"
|
||||
|
||||
// Force dynamic rendering — prevents NextAuth from being called at build time
|
||||
// (AUTH_URL is not available during Docker image build)
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -18,108 +18,7 @@ export default async function MarketingLayout({
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<div className="min-h-screen flex flex-col bg-background text-foreground overflow-x-hidden">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Cannabis className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<span className="text-lg font-bold">CannaManage</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Preise
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Anmelden
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1">{children}</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t bg-muted/50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Cannabis className="h-5 w-5 text-primary" />
|
||||
<span className="font-semibold">CannaManage</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Die sichere Verwaltungssoftware für Cannabis-Anbauvereine in
|
||||
Deutschland.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm mb-3">Produkt</h4>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Preise
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/login"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Anmelden
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm mb-3">Rechtliches</h4>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>
|
||||
<Link
|
||||
href="/impressum"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Impressum
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/datenschutz"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Datenschutz
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/agb"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
AGB
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 border-t pt-6 text-center text-xs text-muted-foreground">
|
||||
© {new Date().getFullYear()} CannaManage — Plate Software. Alle
|
||||
Rechte vorbehalten.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<MarketingLayoutClient>{children}</MarketingLayoutClient>
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Cannabis } from "lucide-react"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
export default function MarketingLayoutClient({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
const t = useTranslations("marketing.nav")
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-background text-foreground overflow-x-hidden">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Cannabis className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<span className="text-lg font-bold">CannaManage</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/#features"
|
||||
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("features")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("pricing")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{t("login")}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1">{children}</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t bg-muted/50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Cannabis className="h-5 w-5 text-primary" />
|
||||
<span className="font-semibold">CannaManage</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("footerTagline")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm mb-3">
|
||||
{t("footerProduct")}
|
||||
</h4>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>
|
||||
<Link
|
||||
href="/#features"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("features")}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("pricing")}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/login"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("login")}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm mb-3">{t("footerLegal")}</h4>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>
|
||||
<Link
|
||||
href="/impressum"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("impressum")}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/datenschutz"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("datenschutz")}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/agb"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("agb")}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 border-t pt-6 text-center text-xs text-muted-foreground">
|
||||
© {new Date().getFullYear()} CannaManage — Plate Software.{" "}
|
||||
{t("allRightsReserved")}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
ArrowRight,
|
||||
Cannabis,
|
||||
ClipboardCheck,
|
||||
FileArchive,
|
||||
Lock,
|
||||
Scale,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Sprout,
|
||||
Users,
|
||||
Wallet,
|
||||
} from "lucide-react"
|
||||
|
||||
const features = [
|
||||
{ id: "feature1", icon: ClipboardCheck },
|
||||
{ id: "feature2", icon: Sprout },
|
||||
{ id: "feature3", icon: Users },
|
||||
{ id: "feature4", icon: Scale },
|
||||
{ id: "feature5", icon: FileArchive },
|
||||
{ id: "feature6", icon: Wallet },
|
||||
]
|
||||
|
||||
const trustSignals = [
|
||||
{ id: "trustCanverg", icon: ShieldCheck },
|
||||
{ id: "trustDsgvo", icon: ClipboardCheck },
|
||||
{ id: "trustEncryption", icon: Lock },
|
||||
{ id: "trustGerman", icon: Server },
|
||||
]
|
||||
|
||||
export default function HomePage() {
|
||||
const t = useTranslations("marketing.home")
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* Hero Section */}
|
||||
<section className="relative overflow-hidden py-20 sm:py-28 lg:py-32">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mb-6 inline-flex items-center gap-2 rounded-full border bg-muted/50 px-4 py-1.5 text-sm">
|
||||
<Cannabis className="h-4 w-4 text-primary" />
|
||||
<span className="text-muted-foreground">KCanG-konform</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl">
|
||||
{t("heroTitle")}
|
||||
</h1>
|
||||
<p className="mt-6 text-lg text-muted-foreground sm:text-xl max-w-2xl mx-auto">
|
||||
{t("heroSubtitle")}
|
||||
</p>
|
||||
<div className="mt-10 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="inline-flex h-12 items-center justify-center gap-2 rounded-lg bg-primary px-6 text-base font-medium text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{t("ctaPrimary")}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex h-12 items-center justify-center gap-2 rounded-lg border bg-background px-6 text-base font-medium hover:bg-muted transition-colors"
|
||||
>
|
||||
{t("ctaSecondary")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Decorative gradient */}
|
||||
<div className="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
|
||||
<div className="absolute -top-40 left-1/2 -translate-x-1/2 h-[500px] w-[800px] rounded-full bg-primary/5 blur-3xl" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section id="features" className="py-20 bg-muted/30">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
{t("featuresTitle")}
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
{t("featuresSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 max-w-5xl mx-auto">
|
||||
{features.map((feature) => {
|
||||
const Icon = feature.icon
|
||||
return (
|
||||
<div
|
||||
key={feature.id}
|
||||
className="group rounded-xl border bg-card p-6 shadow-sm transition-all hover:shadow-md hover:border-primary/20"
|
||||
>
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary group-hover:bg-primary/15 transition-colors">
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{t(`${feature.id}Title`)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t(`${feature.id}Desc`)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Trust Signals Section */}
|
||||
<section className="py-20">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
{t("trustTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-6 sm:grid-cols-4 max-w-3xl mx-auto">
|
||||
{trustSignals.map((signal) => {
|
||||
const Icon = signal.icon
|
||||
return (
|
||||
<div
|
||||
key={signal.id}
|
||||
className="flex flex-col items-center gap-3 rounded-lg border bg-card p-5 text-center"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-sm font-medium">{t(signal.id)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA Section */}
|
||||
<section className="py-20 bg-muted/30">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
{t("ctaFinalTitle")}
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
{t("ctaFinalSubtitle")}
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="inline-flex h-12 items-center justify-center gap-2 rounded-lg bg-primary px-8 text-base font-medium text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{t("ctaFinalButton")}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const plans = [
|
||||
icon: Leaf,
|
||||
price: "19",
|
||||
memberLimit: "30",
|
||||
storage: "5",
|
||||
features: [
|
||||
"memberManagement",
|
||||
"distributionTracking",
|
||||
@@ -24,6 +25,7 @@ const plans = [
|
||||
icon: Cannabis,
|
||||
price: "49",
|
||||
memberLimit: "100",
|
||||
storage: "50",
|
||||
popular: true,
|
||||
features: [
|
||||
"allStarter",
|
||||
@@ -40,6 +42,7 @@ const plans = [
|
||||
icon: Building2,
|
||||
price: null,
|
||||
memberLimit: "unlimited",
|
||||
storage: "custom",
|
||||
features: [
|
||||
"allPro",
|
||||
"unlimitedMembers",
|
||||
@@ -58,6 +61,7 @@ const faqs = [
|
||||
{ id: "cancel" },
|
||||
{ id: "data" },
|
||||
{ id: "migration" },
|
||||
{ id: "storage" },
|
||||
]
|
||||
|
||||
export default function PricingPage() {
|
||||
@@ -129,6 +133,14 @@ export default function PricingPage() {
|
||||
limit: plan.memberLimit,
|
||||
})}
|
||||
</p>
|
||||
<div className="mt-2 inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium">
|
||||
{t(`storage.${plan.id}`)}
|
||||
{plan.id === "pro" && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
{t("storage.proOverage")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3 mb-8 flex-1">
|
||||
@@ -180,6 +192,8 @@ export default function PricingPage() {
|
||||
<tbody className="divide-y">
|
||||
{[
|
||||
"compMembers",
|
||||
"compStorage",
|
||||
"compOverage",
|
||||
"compDistributions",
|
||||
"compReports",
|
||||
"compGrow",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
import { Cannabis, Loader2 } from "lucide-react"
|
||||
import { Cannabis, ClockArrowUp, FileText, Loader2, User } from "lucide-react"
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -42,101 +42,162 @@ export default function PortalLoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex min-h-screen items-center justify-center bg-background text-foreground p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
{/* Logo & Branding */}
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Cannabis className="h-8 w-8 text-primary" />
|
||||
<div className="fixed inset-0 z-50 flex min-h-screen bg-background text-foreground">
|
||||
{/* Left panel — member-focused branding (hidden on mobile) */}
|
||||
<div className="hidden md:flex md:w-1/2 lg:w-[55%] flex-col items-center justify-center bg-gradient-to-br from-emerald-500/10 via-teal-500/5 to-background p-12 relative overflow-hidden">
|
||||
{/* Decorative background */}
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div className="absolute top-1/4 left-1/4 h-64 w-64 rounded-full bg-emerald-500/10 blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 h-48 w-48 rounded-full bg-teal-500/5 blur-2xl" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-8 max-w-sm text-center">
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-500/10 border border-emerald-500/20">
|
||||
<Cannabis className="h-9 w-9 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("loginSubtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="rounded-xl border bg-card p-6 shadow-sm">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
{/* Branding */}
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold">Mitgliederportal</h1>
|
||||
<p className="text-muted-foreground">Willkommen zurück</p>
|
||||
</div>
|
||||
|
||||
{/* Feature highlights */}
|
||||
<div className="space-y-4 text-left w-full">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-emerald-500/10">
|
||||
<ClockArrowUp className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email field */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="portal-email"
|
||||
className="text-sm font-medium leading-none"
|
||||
>
|
||||
{t("email")}
|
||||
</label>
|
||||
<input
|
||||
id="portal-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="max@beispiel.de"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
{...register("email")}
|
||||
aria-invalid={!!errors.email}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("invalidCredentials")}
|
||||
<div>
|
||||
<p className="text-sm font-medium">Abgabehistorie</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Alle Abgaben auf einen Blick
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password field */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="portal-password"
|
||||
className="text-sm font-medium leading-none"
|
||||
>
|
||||
{t("password")}
|
||||
</label>
|
||||
<input
|
||||
id="portal-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
{...register("password")}
|
||||
aria-invalid={!!errors.password}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("invalidCredentials")}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-emerald-500/10">
|
||||
<User className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Profil verwalten</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Daten und Einstellungen
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("loggingIn")}
|
||||
</>
|
||||
) : (
|
||||
t("loginButton")
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-emerald-500/10">
|
||||
<FileText className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Dokumente</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Bescheinigungen und Nachweise
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer link to admin */}
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-xs text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{t("adminLogin")}
|
||||
</Link>
|
||||
{/* Right panel — form */}
|
||||
<div className="w-full md:w-1/2 lg:w-[45%] flex items-center justify-center p-6 sm:p-8">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
{/* Title */}
|
||||
<div className="space-y-2 text-center md:text-left">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("loginSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="rounded-xl border bg-card p-6 shadow-sm">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email field */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="portal-email"
|
||||
className="text-sm font-medium leading-none"
|
||||
>
|
||||
{t("email")}
|
||||
</label>
|
||||
<input
|
||||
id="portal-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="max@beispiel.de"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
{...register("email")}
|
||||
aria-invalid={!!errors.email}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("invalidCredentials")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password field */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="portal-password"
|
||||
className="text-sm font-medium leading-none"
|
||||
>
|
||||
{t("password")}
|
||||
</label>
|
||||
<input
|
||||
id="portal-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
{...register("password")}
|
||||
aria-invalid={!!errors.password}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("invalidCredentials")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white ring-offset-background transition-colors hover:bg-emerald-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("loggingIn")}
|
||||
</>
|
||||
) : (
|
||||
t("loginButton")
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Footer link to admin */}
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-xs text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{t("adminLogin")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Server-side API proxy for the CannaManage backend.
|
||||
*
|
||||
* Replaces the old static `rewrites()` proxy in next.config.mjs. A static
|
||||
* rewrite forwards requests as-is and CANNOT inject an Authorization header,
|
||||
* which was the root cause of the systemic "no token reaches the backend" bug:
|
||||
* every browser fetch hit the backend unauthenticated → 401/500 → pages only
|
||||
* survived via mock fallbacks.
|
||||
*
|
||||
* This Route Handler runs on the server, reads the NextAuth session via
|
||||
* `auth()` (so the JWT never leaves the server), and forwards the request to
|
||||
* `${BACKEND_URL}/api/v1/<path>` with `Authorization: Bearer <accessToken>`.
|
||||
*
|
||||
* It is method-agnostic and content-agnostic:
|
||||
* - Query string is preserved.
|
||||
* - The raw request body is streamed through unparsed, so JSON,
|
||||
* multipart/form-data (file uploads) and any other content type work.
|
||||
* - The upstream response body is streamed back verbatim, so binary
|
||||
* downloads (PDF/CSV reports, attachments) are byte-exact.
|
||||
*/
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import type { NextRequest } from "next/server"
|
||||
|
||||
import { auth } from "@/lib/auth"
|
||||
|
||||
// Always run dynamically — this proxy depends on per-request auth + body.
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:8080"
|
||||
|
||||
// Hop-by-hop and host-specific headers that must not be forwarded upstream.
|
||||
const STRIPPED_REQUEST_HEADERS = new Set([
|
||||
"host",
|
||||
"connection",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"accept-encoding",
|
||||
])
|
||||
|
||||
// Headers that must not be copied from the upstream response back to the client.
|
||||
const STRIPPED_RESPONSE_HEADERS = new Set([
|
||||
"connection",
|
||||
"transfer-encoding",
|
||||
"content-encoding",
|
||||
"content-length",
|
||||
])
|
||||
|
||||
async function proxy(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
const session = await auth()
|
||||
const accessToken = session?.accessToken
|
||||
|
||||
// Build the upstream URL: /api/backend/<path> → BACKEND_URL/api/v1/<path>
|
||||
const search = req.nextUrl.search // includes leading "?" or ""
|
||||
const upstreamUrl = `${BACKEND_URL}/api/v1/${path.join("/")}${search}`
|
||||
|
||||
// Clone the incoming headers, stripping hop-by-hop/host ones, then inject auth.
|
||||
const headers = new Headers()
|
||||
req.headers.forEach((value, key) => {
|
||||
if (!STRIPPED_REQUEST_HEADERS.has(key.toLowerCase())) {
|
||||
headers.set(key, value)
|
||||
}
|
||||
})
|
||||
if (accessToken) {
|
||||
headers.set("Authorization", `Bearer ${accessToken}`)
|
||||
}
|
||||
|
||||
const method = req.method.toUpperCase()
|
||||
const hasBody = method !== "GET" && method !== "HEAD"
|
||||
|
||||
try {
|
||||
const upstream = await fetch(upstreamUrl, {
|
||||
method,
|
||||
headers,
|
||||
// Stream the raw body through unparsed (works for JSON + multipart + binary).
|
||||
body: hasBody ? req.body : undefined,
|
||||
// Required by undici/Node when sending a streaming request body.
|
||||
...(hasBody ? { duplex: "half" } : {}),
|
||||
redirect: "manual",
|
||||
cache: "no-store",
|
||||
} as RequestInit)
|
||||
|
||||
// Copy upstream response headers, dropping ones that break a re-emitted body.
|
||||
const responseHeaders = new Headers()
|
||||
upstream.headers.forEach((value, key) => {
|
||||
if (!STRIPPED_RESPONSE_HEADERS.has(key.toLowerCase())) {
|
||||
responseHeaders.set(key, value)
|
||||
}
|
||||
})
|
||||
|
||||
// Stream the body straight back — byte-exact for downloads.
|
||||
return new NextResponse(upstream.body, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers: responseHeaders,
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ code: "BACKEND_UNREACHABLE", message: "Unable to reach the API." },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Next.js 15: the second arg's `params` is a Promise.
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return proxy(req, (await ctx.params).path)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return proxy(req, (await ctx.params).path)
|
||||
}
|
||||
export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
return proxy(req, (await ctx.params).path)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return proxy(req, (await ctx.params).path)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return proxy(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -120,6 +120,20 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
session.user.role = token.role as string
|
||||
session.user.clubId = token.clubId as string
|
||||
session.error = token.error as string | undefined
|
||||
// Expose the backend access token on the session so the server-side proxy
|
||||
// Route Handler (app/api/backend/[...path]/route.ts) can read it via auth()
|
||||
// and inject it as a Bearer header on every API call.
|
||||
//
|
||||
// We use auth() (not getToken()) because it handles the cookie name
|
||||
// consistently across the public-HTTPS / internal-HTTP boundary: the
|
||||
// browser talks HTTPS to the Apache front, which proxies plain HTTP to
|
||||
// this container. getToken()'s __Secure- cookie-name autodetection keys
|
||||
// off the (internal, http) request URL and would miss the real secure
|
||||
// cookie. The tradeoff: accessToken is therefore also returned by
|
||||
// /api/auth/session — i.e. readable client-side. That is an accepted,
|
||||
// standard bearer-token-in-browser posture; the JWT is short-lived and is
|
||||
// already the browser's effective credential.
|
||||
session.accessToken = token.accessToken as string | undefined
|
||||
return session
|
||||
},
|
||||
async redirect({ url, baseUrl }) {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
const CLUB_ID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface BoardPosition {
|
||||
id: string
|
||||
title: string
|
||||
@@ -37,6 +45,8 @@ export interface ElectBoardMemberRequest {
|
||||
assemblyId?: string
|
||||
}
|
||||
|
||||
// --- Raw API functions ---
|
||||
|
||||
export function createPosition(
|
||||
clubId: string,
|
||||
data: CreatePositionRequest
|
||||
@@ -88,3 +98,51 @@ export function removeBoardMember(id: string, clubId: string): Promise<void> {
|
||||
export function getPortalBoard(clubId: string): Promise<BoardMember[]> {
|
||||
return apiClient<BoardMember[]>(`/portal/board?clubId=${clubId}`)
|
||||
}
|
||||
|
||||
// --- React Query Hooks ---
|
||||
|
||||
export function useBoardQuery() {
|
||||
return useQuery({
|
||||
queryKey: ["board", CLUB_ID],
|
||||
queryFn: () => getCurrentBoard(CLUB_ID),
|
||||
})
|
||||
}
|
||||
|
||||
export function usePositionsQuery() {
|
||||
return useQuery({
|
||||
queryKey: ["board-positions", CLUB_ID],
|
||||
queryFn: () => getPositions(CLUB_ID),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreatePositionMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: CreatePositionRequest) => createPosition(CLUB_ID, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["board-positions"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["board"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useElectBoardMemberMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: ElectBoardMemberRequest) =>
|
||||
electBoardMember(CLUB_ID, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["board"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveBoardMemberMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => removeBoardMember(id, CLUB_ID),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["board"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
const CLUB_ID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type DocumentCategory =
|
||||
| "SATZUNG"
|
||||
| "PROTOKOLL"
|
||||
@@ -28,6 +36,16 @@ export interface StorageUsage {
|
||||
bytesUsed: number
|
||||
}
|
||||
|
||||
export interface UploadDocumentRequest {
|
||||
title: string
|
||||
category: DocumentCategory
|
||||
accessLevel: DocumentAccessLevel
|
||||
description: string | null
|
||||
file: File
|
||||
}
|
||||
|
||||
// --- Raw API functions ---
|
||||
|
||||
export async function uploadDocument(
|
||||
clubId: string,
|
||||
title: string,
|
||||
@@ -55,7 +73,19 @@ export async function uploadDocument(
|
||||
body: formData,
|
||||
}
|
||||
)
|
||||
if (!res.ok) throw new Error("Upload failed")
|
||||
if (!res.ok) {
|
||||
if (res.status === 402) {
|
||||
const problem = await res.json()
|
||||
const error = new Error("Storage quota exceeded") as Error & {
|
||||
status: number
|
||||
problemDetail: unknown
|
||||
}
|
||||
error.status = 402
|
||||
error.problemDetail = problem
|
||||
throw error
|
||||
}
|
||||
throw new Error("Upload failed")
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
@@ -90,14 +120,53 @@ export function getPortalDocuments(clubId: string): Promise<ClubDocument[]> {
|
||||
return apiClient<ClubDocument[]>(`/portal/documents?clubId=${clubId}`)
|
||||
}
|
||||
|
||||
// Helper: format file size
|
||||
// --- React Query Hooks ---
|
||||
|
||||
export function useDocumentsQuery(category?: DocumentCategory) {
|
||||
return useQuery({
|
||||
queryKey: ["documents", CLUB_ID, category],
|
||||
queryFn: () => listDocuments(CLUB_ID, category),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUploadDocumentMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: UploadDocumentRequest) =>
|
||||
uploadDocument(
|
||||
CLUB_ID,
|
||||
data.title,
|
||||
data.category,
|
||||
data.accessLevel,
|
||||
data.description,
|
||||
data.file
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["documents"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteDocumentMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => deleteDocument(id, CLUB_ID),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["documents"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- Helper: format file size ---
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
// Category labels
|
||||
// --- Category labels ---
|
||||
|
||||
export const categoryLabels: Record<DocumentCategory, string> = {
|
||||
SATZUNG: "Satzung",
|
||||
PROTOKOLL: "Protokoll",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
|
||||
export interface StorageUsage {
|
||||
usedBytes: number
|
||||
limitBytes: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch current storage usage for the authenticated user's club.
|
||||
* Club ID is derived from JWT on the backend — no param needed.
|
||||
*/
|
||||
export function getStorageUsage(): Promise<StorageUsage> {
|
||||
return apiClient<StorageUsage>("/storage/usage")
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes into a human-readable string (e.g., "4.2 GB").
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B"
|
||||
const k = 1024
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"]
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an API error response indicates a storage quota exceeded (HTTP 402).
|
||||
*/
|
||||
export function isStorageQuotaError(error: unknown): boolean {
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"response" in error &&
|
||||
error.response &&
|
||||
typeof error.response === "object" &&
|
||||
"status" in error.response
|
||||
) {
|
||||
return (error.response as { status: number }).status === 402
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -7,6 +7,8 @@ declare module "next-auth" {
|
||||
clubId: string
|
||||
} & DefaultSession["user"]
|
||||
error?: string
|
||||
/** Backend JWT — server-side only, injected as Bearer by the /api/backend proxy. */
|
||||
accessToken?: string
|
||||
}
|
||||
|
||||
interface User {
|
||||
|
||||
+20
-51
@@ -90,57 +90,26 @@
|
||||
<artifactId>stripe-java</artifactId>
|
||||
<version>28.2.0</version>
|
||||
</dependency>
|
||||
<!--
|
||||
Jackson — explicit dependency required so ByteBuddy (Mockito's bytecode
|
||||
instrumentation engine) can resolve the ObjectMapper type when mocking
|
||||
AuditService, which holds a `private static final ObjectMapper
|
||||
METADATA_MAPPER` field. Without this explicit declaration, Jackson is
|
||||
only on the test classpath transitively via spring-boot-starter-test,
|
||||
and ByteBuddy's classloader walking fails with
|
||||
`ClassNotFoundException: ObjectMapper` during inline mock generation.
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>test</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>check</id>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>CLASS</element>
|
||||
<includes>
|
||||
<include>de.cannamanage.service.ComplianceService</include>
|
||||
</includes>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>LINE</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>1.00</minimum>
|
||||
</limit>
|
||||
<limit>
|
||||
<counter>BRANCH</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>1.00</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<!--
|
||||
Sprint 11: JaCoCo + Surefire are now configured centrally in the parent POM
|
||||
with risk-tiered per-package rules (bankimport/finance ≥ 90%, security ≥ 85%,
|
||||
business ≥ 75%, infra ≥ 70%, bundle ≥ 80%). The previous module-local
|
||||
ComplianceService = 100% rule was unsustainable for a growing class and is
|
||||
now subsumed by the package-level rules driven from the parent POM.
|
||||
-->
|
||||
</project>
|
||||
|
||||
@@ -5,6 +5,7 @@ import de.cannamanage.domain.enums.AuditEventType;
|
||||
import de.cannamanage.domain.enums.DocumentAccessLevel;
|
||||
import de.cannamanage.domain.enums.DocumentCategory;
|
||||
import de.cannamanage.service.repository.DocumentRepository;
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -35,16 +36,22 @@ public class DocumentService {
|
||||
|
||||
private final DocumentRepository documentRepository;
|
||||
private final AuditService auditService;
|
||||
private final StorageQuotaService storageQuotaService;
|
||||
|
||||
public DocumentService(DocumentRepository documentRepository, AuditService auditService) {
|
||||
public DocumentService(DocumentRepository documentRepository, AuditService auditService,
|
||||
StorageQuotaService storageQuotaService) {
|
||||
this.documentRepository = documentRepository;
|
||||
this.auditService = auditService;
|
||||
this.storageQuotaService = storageQuotaService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Document uploadDocument(UUID clubId, String title, DocumentCategory category,
|
||||
DocumentAccessLevel accessLevel, String description,
|
||||
MultipartFile file, UUID uploadedBy) throws IOException {
|
||||
// Check storage quota before upload
|
||||
storageQuotaService.checkQuota(clubId, file.getSize());
|
||||
|
||||
// Validate file
|
||||
if (file.isEmpty()) {
|
||||
throw new IllegalArgumentException("File is empty");
|
||||
@@ -87,6 +94,9 @@ public class DocumentService {
|
||||
|
||||
Document saved = documentRepository.save(doc);
|
||||
|
||||
// Increment storage usage counter after successful save
|
||||
storageQuotaService.incrementUsage(clubId, file.getSize());
|
||||
|
||||
auditService.log(AuditEventType.DOCUMENT_UPLOADED, uploadedBy, clubId,
|
||||
"Document uploaded: " + title + " (" + category + ")");
|
||||
|
||||
@@ -132,6 +142,9 @@ public class DocumentService {
|
||||
// Delete DB record
|
||||
documentRepository.delete(doc);
|
||||
|
||||
// Decrement storage usage counter after successful delete
|
||||
storageQuotaService.decrementUsage(clubId, doc.getFileSize());
|
||||
|
||||
auditService.log(AuditEventType.DOCUMENT_DELETED, deletedBy, clubId,
|
||||
"Document deleted: " + doc.getTitle());
|
||||
|
||||
@@ -198,14 +211,17 @@ public class DocumentService {
|
||||
if (original == null || original.isBlank()) {
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
// Strip path components — keep only the basename
|
||||
String name;
|
||||
try {
|
||||
name = Paths.get(original).getFileName().toString();
|
||||
} catch (RuntimeException e) {
|
||||
// Invalid path on this platform — fall back to a random name
|
||||
// Strip null bytes first — FilenameUtils.getName() throws on \0
|
||||
String safe = original.replace("\0", "");
|
||||
if (safe.isBlank()) {
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
// Strip path components using commons-io — handles both Unix and Windows separators
|
||||
// regardless of the current platform (unlike Paths.get which is platform-dependent)
|
||||
String name = FilenameUtils.getName(safe);
|
||||
if (name == null || name.isBlank()) {
|
||||
return "document";
|
||||
}
|
||||
// Remove control characters and path-/shell-/Windows-reserved characters
|
||||
name = name.replaceAll("[\\x00-\\x1F\\x7F/\\\\:*?\"<>|]", "_");
|
||||
// Limit length (filesystems usually cap individual segments at 255 bytes)
|
||||
@@ -214,7 +230,7 @@ public class DocumentService {
|
||||
}
|
||||
// Ensure not empty after sanitization
|
||||
if (name.isBlank() || ".".equals(name) || "..".equals(name)) {
|
||||
return UUID.randomUUID().toString();
|
||||
return "document";
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import de.cannamanage.service.repository.DistributionRepository;
|
||||
import de.cannamanage.service.repository.MemberRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -29,9 +29,11 @@ import java.util.*;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(name = "cannamanage.schedulers.enabled", havingValue = "true", matchIfMissing = false)
|
||||
public class RetentionService {
|
||||
|
||||
@Value("${cannamanage.schedulers.enabled:false}")
|
||||
private boolean schedulersEnabled;
|
||||
|
||||
private final ClubRepository clubRepository;
|
||||
private final MemberRepository memberRepository;
|
||||
private final DistributionRepository distributionRepository;
|
||||
@@ -39,11 +41,15 @@ public class RetentionService {
|
||||
|
||||
/**
|
||||
* Daily scheduled retention processing at 2:00 AM.
|
||||
* Only runs when schedulers are enabled.
|
||||
* Processes each club independently.
|
||||
*/
|
||||
@Scheduled(cron = "0 0 2 * * *")
|
||||
@Transactional
|
||||
public void processRetention() {
|
||||
if (!schedulersEnabled) {
|
||||
return;
|
||||
}
|
||||
log.info("Starting scheduled retention processing");
|
||||
List<Club> clubs = clubRepository.findAll();
|
||||
int totalAnonymized = 0;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.Club;
|
||||
import de.cannamanage.service.exception.StorageQuotaExceededException;
|
||||
import de.cannamanage.service.repository.ClubRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Manages storage quota enforcement for clubs.
|
||||
* Each club has a storage_limit_bytes based on their subscription tier
|
||||
* and a storage_used_bytes counter tracking actual usage.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class StorageQuotaService {
|
||||
|
||||
// Plan tier limits
|
||||
private static final long STARTER_LIMIT = 5L * 1024 * 1024 * 1024; // 5 GB
|
||||
private static final long PRO_LIMIT = 50L * 1024 * 1024 * 1024; // 50 GB
|
||||
private static final long ENTERPRISE_LIMIT = Long.MAX_VALUE; // Unlimited
|
||||
|
||||
private final ClubRepository clubRepository;
|
||||
|
||||
public StorageQuotaService(ClubRepository clubRepository) {
|
||||
this.clubRepository = clubRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current storage usage for a club.
|
||||
*/
|
||||
public StorageUsageDTO getUsage(UUID clubId) {
|
||||
Club club = clubRepository.findById(clubId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Club not found: " + clubId));
|
||||
long used = club.getStorageUsedBytes();
|
||||
long limit = club.getStorageLimitBytes();
|
||||
double percentage = limit > 0 ? (double) used / limit * 100 : 0;
|
||||
return new StorageUsageDTO(used, limit, percentage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if uploading additionalBytes would exceed the club's storage limit.
|
||||
* Throws StorageQuotaExceededException if it would.
|
||||
*/
|
||||
public void checkQuota(UUID clubId, long additionalBytes) {
|
||||
Club club = clubRepository.findById(clubId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Club not found: " + clubId));
|
||||
long newTotal = club.getStorageUsedBytes() + additionalBytes;
|
||||
if (newTotal > club.getStorageLimitBytes()) {
|
||||
throw new StorageQuotaExceededException(
|
||||
club.getStorageUsedBytes(), club.getStorageLimitBytes(), additionalBytes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the club's storage usage counter after a successful upload.
|
||||
*/
|
||||
@Transactional
|
||||
public void incrementUsage(UUID clubId, long bytes) {
|
||||
Club club = clubRepository.findById(clubId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Club not found: " + clubId));
|
||||
club.setStorageUsedBytes(club.getStorageUsedBytes() + bytes);
|
||||
clubRepository.save(club);
|
||||
log.debug("Club {} storage incremented by {} bytes (total: {})", clubId, bytes, club.getStorageUsedBytes());
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement the club's storage usage counter after a successful delete.
|
||||
*/
|
||||
@Transactional
|
||||
public void decrementUsage(UUID clubId, long bytes) {
|
||||
Club club = clubRepository.findById(clubId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Club not found: " + clubId));
|
||||
long newUsage = Math.max(0, club.getStorageUsedBytes() - bytes);
|
||||
club.setStorageUsedBytes(newUsage);
|
||||
clubRepository.save(club);
|
||||
log.debug("Club {} storage decremented by {} bytes (total: {})", clubId, bytes, newUsage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage limit in bytes for a given plan tier name.
|
||||
*/
|
||||
public static long getLimitForTier(String tier) {
|
||||
return switch (tier.toLowerCase()) {
|
||||
case "starter", "trial" -> STARTER_LIMIT;
|
||||
case "pro" -> PRO_LIMIT;
|
||||
case "enterprise" -> ENTERPRISE_LIMIT;
|
||||
default -> STARTER_LIMIT;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a club's subscription tier changes.
|
||||
* Updates storage_limit_bytes to match the new tier.
|
||||
*/
|
||||
@Transactional
|
||||
public void onTierChange(UUID clubId, String newTier) {
|
||||
long newLimit = getLimitForTier(newTier);
|
||||
Club club = clubRepository.findById(clubId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Club not found: " + clubId));
|
||||
club.setStorageLimitBytes(newLimit);
|
||||
clubRepository.save(club);
|
||||
log.info("Club {} tier changed to '{}' — storage limit updated to {} bytes", clubId, newTier, newLimit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a club is at or above a given usage threshold percentage.
|
||||
*/
|
||||
public boolean isNearLimit(UUID clubId, int thresholdPercent) {
|
||||
StorageUsageDTO usage = getUsage(clubId);
|
||||
return usage.percentage() >= thresholdPercent;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for storage usage response.
|
||||
*/
|
||||
public record StorageUsageDTO(long usedBytes, long limitBytes, double percentage) {}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package de.cannamanage.service.exception;
|
||||
|
||||
/**
|
||||
* Thrown when a document upload would exceed the club's storage quota.
|
||||
* Maps to HTTP 402 Payment Required — distinct from QuotaExceededException
|
||||
* which handles CanG distribution quotas (25g/day, 50g/month).
|
||||
*/
|
||||
public class StorageQuotaExceededException extends RuntimeException {
|
||||
|
||||
private final long currentUsage;
|
||||
private final long limit;
|
||||
private final long requestedBytes;
|
||||
|
||||
public StorageQuotaExceededException(long currentUsage, long limit, long requestedBytes) {
|
||||
super("Storage quota exceeded: current=%d, limit=%d, requested=%d"
|
||||
.formatted(currentUsage, limit, requestedBytes));
|
||||
this.currentUsage = currentUsage;
|
||||
this.limit = limit;
|
||||
this.requestedBytes = requestedBytes;
|
||||
}
|
||||
|
||||
public long getCurrentUsage() {
|
||||
return currentUsage;
|
||||
}
|
||||
|
||||
public long getLimit() {
|
||||
return limit;
|
||||
}
|
||||
|
||||
public long getRequestedBytes() {
|
||||
return requestedBytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Common base for Mockito-driven service unit tests in Sprint 11.
|
||||
*
|
||||
* <p>Provides:
|
||||
* <ul>
|
||||
* <li>Stable UUIDs for tenants, members, users, staff — readable and constant
|
||||
* across runs (no hidden randomness in assertions).</li>
|
||||
* <li>A fixed {@link Clock} pinned to 2026-06-15T10:00:00Z (Europe/Berlin) so
|
||||
* any time-dependent service can be tested deterministically.</li>
|
||||
* <li>Money helpers ({@link #cents(long)}, {@link #euros(String)}) that
|
||||
* enforce 2-decimal HALF_UP semantics consistent with the German
|
||||
* financial domain (GoBD, §147 AO).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Subclasses should declare their service under test with {@code @InjectMocks}
|
||||
* and collaborators with {@code @Mock}; the {@link MockitoExtension} is
|
||||
* already applied here.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public abstract class AbstractServiceTest {
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Stable identifiers — readable in assertions, constant across runs.
|
||||
// ---------------------------------------------------------------------
|
||||
protected static final UUID TEST_CLUB_ID = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
protected static final UUID TEST_MEMBER_ID = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
protected static final UUID TEST_USER_ID = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||
protected static final UUID TEST_STAFF_ID = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
|
||||
protected static final UUID TEST_BATCH_ID = UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
|
||||
protected static final UUID TEST_STRAIN_ID = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff");
|
||||
protected static final UUID TEST_PAYMENT_ID = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||
protected static final UUID TEST_INVOICE_ID = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Deterministic clock — 2026-06-15T10:00:00Z, Europe/Berlin.
|
||||
// Pinned to a date inside the active sprint so seasonal logic
|
||||
// (quotas, harvest cycles, reporting deadlines) is reproducible.
|
||||
// ---------------------------------------------------------------------
|
||||
protected static final ZoneId TEST_ZONE = ZoneId.of("Europe/Berlin");
|
||||
protected static final Instant TEST_INSTANT = Instant.parse("2026-06-15T10:00:00Z");
|
||||
protected static final LocalDate TEST_TODAY = LocalDate.of(2026, 6, 15);
|
||||
protected static final Clock TEST_CLOCK = Clock.fixed(TEST_INSTANT, TEST_ZONE);
|
||||
protected static final Clock TEST_UTC_CLOCK = Clock.fixed(TEST_INSTANT, ZoneOffset.UTC);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Money helpers — 2-decimal HALF_UP semantics for euro arithmetic.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Build a euro amount from integer cents (e.g. {@code cents(1234)} → 12.34 €). */
|
||||
protected static BigDecimal cents(long cents) {
|
||||
return new BigDecimal(cents).movePointLeft(2).setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/** Build a euro amount from a literal string (e.g. {@code euros("12.34")}). */
|
||||
protected static BigDecimal euros(String amount) {
|
||||
return new BigDecimal(amount).setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.*;
|
||||
import de.cannamanage.domain.enums.*;
|
||||
import de.cannamanage.service.repository.*;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Unit tests for AssemblyService — Mitgliederversammlung lifecycle.
|
||||
*/
|
||||
class AssemblyServiceTest extends AbstractServiceTest {
|
||||
|
||||
@Mock private AssemblyRepository assemblyRepository;
|
||||
@Mock private AssemblyAgendaItemRepository agendaItemRepository;
|
||||
@Mock private AssemblyAttendeeRepository attendeeRepository;
|
||||
@Mock private AssemblyVoteRepository voteRepository;
|
||||
@Mock private AssemblyVoteRecordRepository voteRecordRepository;
|
||||
@Mock private MemberRepository memberRepository;
|
||||
@Mock private NotificationService notificationService;
|
||||
@Mock private AuditService auditService;
|
||||
@Mock private AssemblyProtocolService assemblyProtocolService;
|
||||
@Mock private DocumentService documentArchiveService;
|
||||
|
||||
@InjectMocks
|
||||
private AssemblyService assemblyService;
|
||||
|
||||
private Assembly assembly;
|
||||
private static final UUID ASSEMBLY_ID = UUID.fromString("11112222-3333-4444-5555-666677778888");
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
assembly = new Assembly();
|
||||
assembly.setId(ASSEMBLY_ID);
|
||||
assembly.setClubId(TEST_CLUB_ID);
|
||||
assembly.setTitle("Ordentliche MV 2026");
|
||||
assembly.setAssemblyType(AssemblyType.ORDINARY);
|
||||
assembly.setScheduledAt(TEST_INSTANT.plusSeconds(86400));
|
||||
assembly.setLocation("Vereinsheim");
|
||||
assembly.setQuorumRequired(10);
|
||||
assembly.setCreatedBy(TEST_USER_ID);
|
||||
assembly.setStatus(AssemblyStatus.PLANNED);
|
||||
}
|
||||
|
||||
// === Create Assembly ===
|
||||
|
||||
@Test
|
||||
void testCreateAssembly_ordinary_success() {
|
||||
when(assemblyRepository.save(any(Assembly.class))).thenAnswer(inv -> {
|
||||
Assembly a = inv.getArgument(0);
|
||||
a.setId(ASSEMBLY_ID);
|
||||
return a;
|
||||
});
|
||||
|
||||
Assembly result = assemblyService.createAssembly(
|
||||
TEST_CLUB_ID, "Ordentliche MV 2026", AssemblyType.ORDINARY,
|
||||
TEST_INSTANT.plusSeconds(86400), "Vereinsheim", 10, TEST_USER_ID, null);
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(AssemblyStatus.PLANNED);
|
||||
assertThat(result.getAssemblyType()).isEqualTo(AssemblyType.ORDINARY);
|
||||
assertThat(result.getTitle()).isEqualTo("Ordentliche MV 2026");
|
||||
verify(assemblyRepository).save(any(Assembly.class));
|
||||
verify(auditService).log(any(AuditEventType.class), any(UUID.class), any(String.class), any(String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateAssembly_extraordinary_success() {
|
||||
when(assemblyRepository.save(any(Assembly.class))).thenAnswer(inv -> {
|
||||
Assembly a = inv.getArgument(0);
|
||||
a.setId(ASSEMBLY_ID);
|
||||
return a;
|
||||
});
|
||||
|
||||
Assembly result = assemblyService.createAssembly(
|
||||
TEST_CLUB_ID, "Außerordentliche MV", AssemblyType.EXTRAORDINARY,
|
||||
TEST_INSTANT.plusSeconds(86400), "Online", 5, TEST_USER_ID, null);
|
||||
|
||||
assertThat(result.getAssemblyType()).isEqualTo(AssemblyType.EXTRAORDINARY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateAssembly_withAgendaItems_createsItems() {
|
||||
when(assemblyRepository.save(any(Assembly.class))).thenAnswer(inv -> {
|
||||
Assembly a = inv.getArgument(0);
|
||||
a.setId(ASSEMBLY_ID);
|
||||
return a;
|
||||
});
|
||||
when(agendaItemRepository.save(any(AssemblyAgendaItem.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var items = List.of(
|
||||
new AssemblyService.AgendaItemInput("TOP 1: Begrüßung", "Eröffnung", AgendaItemType.INFORMATION),
|
||||
new AssemblyService.AgendaItemInput("TOP 2: Satzungsänderung", "§5 anpassen", AgendaItemType.VOTE)
|
||||
);
|
||||
|
||||
assemblyService.createAssembly(TEST_CLUB_ID, "MV", AssemblyType.ORDINARY,
|
||||
TEST_INSTANT.plusSeconds(86400), "Ort", 10, TEST_USER_ID, items);
|
||||
|
||||
verify(agendaItemRepository, times(2)).save(any(AssemblyAgendaItem.class));
|
||||
}
|
||||
|
||||
// === Start / Complete Assembly ===
|
||||
|
||||
@Test
|
||||
void testStartAssembly_fromPlanned_success() {
|
||||
assembly.setStatus(AssemblyStatus.PLANNED);
|
||||
when(assemblyRepository.findById(ASSEMBLY_ID)).thenReturn(Optional.of(assembly));
|
||||
when(assemblyRepository.save(any(Assembly.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Assembly result = assemblyService.startAssembly(ASSEMBLY_ID, TEST_USER_ID);
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(AssemblyStatus.IN_PROGRESS);
|
||||
assertThat(result.getOpenedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testStartAssembly_fromInvited_success() {
|
||||
assembly.setStatus(AssemblyStatus.INVITED);
|
||||
when(assemblyRepository.findById(ASSEMBLY_ID)).thenReturn(Optional.of(assembly));
|
||||
when(assemblyRepository.save(any(Assembly.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Assembly result = assemblyService.startAssembly(ASSEMBLY_ID, TEST_USER_ID);
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(AssemblyStatus.IN_PROGRESS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testStartAssembly_fromCompleted_throwsException() {
|
||||
assembly.setStatus(AssemblyStatus.COMPLETED);
|
||||
when(assemblyRepository.findById(ASSEMBLY_ID)).thenReturn(Optional.of(assembly));
|
||||
|
||||
assertThatThrownBy(() -> assemblyService.startAssembly(ASSEMBLY_ID, TEST_USER_ID))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("Cannot start assembly in status");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCompleteAssembly_inProgress_success() {
|
||||
assembly.setStatus(AssemblyStatus.IN_PROGRESS);
|
||||
assembly.setTenantId(TEST_CLUB_ID);
|
||||
when(assemblyRepository.findById(ASSEMBLY_ID)).thenReturn(Optional.of(assembly));
|
||||
when(assemblyRepository.save(any(Assembly.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(assemblyProtocolService.generateProtocol(ASSEMBLY_ID)).thenReturn(new byte[]{1, 2, 3});
|
||||
when(documentArchiveService.archiveProtocol(any(), any(), any(), any())).thenReturn(UUID.randomUUID());
|
||||
|
||||
Assembly result = assemblyService.completeAssembly(ASSEMBLY_ID, TEST_USER_ID);
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(AssemblyStatus.COMPLETED);
|
||||
assertThat(result.getClosedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCompleteAssembly_notInProgress_throwsException() {
|
||||
assembly.setStatus(AssemblyStatus.PLANNED);
|
||||
when(assemblyRepository.findById(ASSEMBLY_ID)).thenReturn(Optional.of(assembly));
|
||||
|
||||
assertThatThrownBy(() -> assemblyService.completeAssembly(ASSEMBLY_ID, TEST_USER_ID))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("Cannot complete assembly in status");
|
||||
}
|
||||
|
||||
// === Cancel Assembly ===
|
||||
|
||||
@Test
|
||||
void testCancelAssembly_success() {
|
||||
assembly.setInvitationSentAt(Instant.now());
|
||||
when(assemblyRepository.findById(ASSEMBLY_ID)).thenReturn(Optional.of(assembly));
|
||||
when(assemblyRepository.save(any(Assembly.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Assembly result = assemblyService.cancelAssembly(ASSEMBLY_ID, TEST_USER_ID);
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(AssemblyStatus.CANCELLED);
|
||||
verify(notificationService).sendToAllMembers(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCancelAssembly_noInvitationsSent_noNotification() {
|
||||
assembly.setInvitationSentAt(null);
|
||||
when(assemblyRepository.findById(ASSEMBLY_ID)).thenReturn(Optional.of(assembly));
|
||||
when(assemblyRepository.save(any(Assembly.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
assemblyService.cancelAssembly(ASSEMBLY_ID, TEST_USER_ID);
|
||||
|
||||
verify(notificationService, never()).sendToAllMembers(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// === Voting — VoteType scenarios ===
|
||||
|
||||
@Test
|
||||
void testCloseVote_simpleMajority_accepted() {
|
||||
AssemblyVote vote = createVote(VoteType.SIMPLE_MAJORITY, 6, 4, 2);
|
||||
when(voteRepository.findById(any())).thenReturn(Optional.of(vote));
|
||||
when(voteRepository.save(any(AssemblyVote.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
AssemblyVote result = assemblyService.closeVote(vote.getId());
|
||||
|
||||
assertThat(result.getResult()).isEqualTo(VoteResult.ACCEPTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCloseVote_simpleMajority_rejected() {
|
||||
AssemblyVote vote = createVote(VoteType.SIMPLE_MAJORITY, 4, 6, 2);
|
||||
when(voteRepository.findById(any())).thenReturn(Optional.of(vote));
|
||||
when(voteRepository.save(any(AssemblyVote.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
AssemblyVote result = assemblyService.closeVote(vote.getId());
|
||||
|
||||
assertThat(result.getResult()).isEqualTo(VoteResult.REJECTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCloseVote_twoThirds_accepted() {
|
||||
AssemblyVote vote = createVote(VoteType.TWO_THIRDS, 8, 4, 0);
|
||||
when(voteRepository.findById(any())).thenReturn(Optional.of(vote));
|
||||
when(voteRepository.save(any(AssemblyVote.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
AssemblyVote result = assemblyService.closeVote(vote.getId());
|
||||
|
||||
assertThat(result.getResult()).isEqualTo(VoteResult.ACCEPTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCloseVote_twoThirds_rejected() {
|
||||
AssemblyVote vote = createVote(VoteType.TWO_THIRDS, 7, 5, 0);
|
||||
when(voteRepository.findById(any())).thenReturn(Optional.of(vote));
|
||||
when(voteRepository.save(any(AssemblyVote.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
AssemblyVote result = assemblyService.closeVote(vote.getId());
|
||||
|
||||
assertThat(result.getResult()).isEqualTo(VoteResult.REJECTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCloseVote_unanimous_accepted() {
|
||||
AssemblyVote vote = createVote(VoteType.UNANIMOUS, 10, 0, 3);
|
||||
when(voteRepository.findById(any())).thenReturn(Optional.of(vote));
|
||||
when(voteRepository.save(any(AssemblyVote.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
AssemblyVote result = assemblyService.closeVote(vote.getId());
|
||||
|
||||
assertThat(result.getResult()).isEqualTo(VoteResult.ACCEPTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCloseVote_unanimous_rejected() {
|
||||
AssemblyVote vote = createVote(VoteType.UNANIMOUS, 9, 1, 2);
|
||||
when(voteRepository.findById(any())).thenReturn(Optional.of(vote));
|
||||
when(voteRepository.save(any(AssemblyVote.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
AssemblyVote result = assemblyService.closeVote(vote.getId());
|
||||
|
||||
assertThat(result.getResult()).isEqualTo(VoteResult.REJECTED);
|
||||
}
|
||||
|
||||
// === Quorum boundary ===
|
||||
|
||||
@Test
|
||||
void testCalculateQuorum_exactlyAtQuorum_met() {
|
||||
assembly.setQuorumRequired(5);
|
||||
assembly.setTenantId(TEST_CLUB_ID);
|
||||
when(assemblyRepository.findById(ASSEMBLY_ID)).thenReturn(Optional.of(assembly));
|
||||
when(attendeeRepository.countByAssemblyId(ASSEMBLY_ID)).thenReturn(5L);
|
||||
when(memberRepository.countByTenantIdAndStatus(TEST_CLUB_ID, MemberStatus.ACTIVE)).thenReturn(20L);
|
||||
|
||||
AssemblyService.QuorumInfo info = assemblyService.calculateQuorum(ASSEMBLY_ID);
|
||||
|
||||
assertThat(info.quorumMet()).isTrue();
|
||||
assertThat(info.attendees()).isEqualTo(5);
|
||||
assertThat(info.required()).isEqualTo(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCalculateQuorum_oneBelowQuorum_notMet() {
|
||||
assembly.setQuorumRequired(5);
|
||||
assembly.setTenantId(TEST_CLUB_ID);
|
||||
when(assemblyRepository.findById(ASSEMBLY_ID)).thenReturn(Optional.of(assembly));
|
||||
when(attendeeRepository.countByAssemblyId(ASSEMBLY_ID)).thenReturn(4L);
|
||||
when(memberRepository.countByTenantIdAndStatus(TEST_CLUB_ID, MemberStatus.ACTIVE)).thenReturn(20L);
|
||||
|
||||
AssemblyService.QuorumInfo info = assemblyService.calculateQuorum(ASSEMBLY_ID);
|
||||
|
||||
assertThat(info.quorumMet()).isFalse();
|
||||
assertThat(info.attendees()).isEqualTo(4);
|
||||
}
|
||||
|
||||
// === Abstention handling ===
|
||||
|
||||
@Test
|
||||
void testCloseVote_abstentionsNotCountedTowardMajority() {
|
||||
// 3 yes, 2 no, 10 abstain — abstentions don't count: 3/5 = 60% → ACCEPTED
|
||||
AssemblyVote vote = createVote(VoteType.SIMPLE_MAJORITY, 3, 2, 10);
|
||||
when(voteRepository.findById(any())).thenReturn(Optional.of(vote));
|
||||
when(voteRepository.save(any(AssemblyVote.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
AssemblyVote result = assemblyService.closeVote(vote.getId());
|
||||
|
||||
assertThat(result.getResult()).isEqualTo(VoteResult.ACCEPTED);
|
||||
}
|
||||
|
||||
// === Cast Vote ===
|
||||
|
||||
@Test
|
||||
void testCastVote_success() {
|
||||
AssemblyVote vote = new AssemblyVote();
|
||||
vote.setId(UUID.randomUUID());
|
||||
vote.setAssemblyId(ASSEMBLY_ID);
|
||||
vote.setTitle("Satzungsänderung");
|
||||
vote.setVoteType(VoteType.SIMPLE_MAJORITY);
|
||||
vote.setYesCount(0);
|
||||
vote.setNoCount(0);
|
||||
vote.setAbstainCount(0);
|
||||
|
||||
when(voteRepository.findById(vote.getId())).thenReturn(Optional.of(vote));
|
||||
when(voteRecordRepository.existsByVoteIdAndMemberId(vote.getId(), TEST_MEMBER_ID)).thenReturn(false);
|
||||
when(voteRecordRepository.save(any(AssemblyVoteRecord.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(voteRepository.save(any(AssemblyVote.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
AssemblyVote result = assemblyService.castVote(vote.getId(), TEST_MEMBER_ID, VoteDecision.YES, TEST_USER_ID);
|
||||
|
||||
assertThat(result.getYesCount()).isEqualTo(1);
|
||||
verify(voteRecordRepository).save(any(AssemblyVoteRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCastVote_alreadyVoted_throwsException() {
|
||||
UUID voteId = UUID.randomUUID();
|
||||
when(voteRecordRepository.existsByVoteIdAndMemberId(voteId, TEST_MEMBER_ID)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> assemblyService.castVote(voteId, TEST_MEMBER_ID, VoteDecision.YES, TEST_USER_ID))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("already voted");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCastVote_voteAlreadyClosed_throwsException() {
|
||||
AssemblyVote vote = new AssemblyVote();
|
||||
vote.setId(UUID.randomUUID());
|
||||
vote.setResult(VoteResult.ACCEPTED); // already closed
|
||||
|
||||
when(voteRecordRepository.existsByVoteIdAndMemberId(vote.getId(), TEST_MEMBER_ID)).thenReturn(false);
|
||||
when(voteRepository.findById(vote.getId())).thenReturn(Optional.of(vote));
|
||||
|
||||
assertThatThrownBy(() -> assemblyService.castVote(vote.getId(), TEST_MEMBER_ID, VoteDecision.NO, TEST_USER_ID))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("already closed");
|
||||
}
|
||||
|
||||
// === Helper ===
|
||||
|
||||
private AssemblyVote createVote(VoteType type, int yes, int no, int abstain) {
|
||||
AssemblyVote vote = new AssemblyVote();
|
||||
vote.setId(UUID.randomUUID());
|
||||
vote.setAssemblyId(ASSEMBLY_ID);
|
||||
vote.setTitle("Abstimmung");
|
||||
vote.setVoteType(type);
|
||||
vote.setYesCount(yes);
|
||||
vote.setNoCount(no);
|
||||
vote.setAbstainCount(abstain);
|
||||
vote.setResult(null); // not yet closed
|
||||
return vote;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.Document;
|
||||
import de.cannamanage.domain.enums.AuditEventType;
|
||||
import de.cannamanage.domain.enums.DocumentAccessLevel;
|
||||
import de.cannamanage.domain.enums.DocumentCategory;
|
||||
import de.cannamanage.service.repository.DocumentRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link DocumentService} covering upload validation,
|
||||
* filename sanitization (path traversal prevention), and tenant checks.
|
||||
* Filesystem operations are mocked via Mockito static mocking.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DocumentServiceTest {
|
||||
|
||||
@Mock
|
||||
private DocumentRepository documentRepository;
|
||||
|
||||
@Mock
|
||||
private AuditService auditService;
|
||||
|
||||
@Mock
|
||||
private StorageQuotaService storageQuotaService;
|
||||
|
||||
@InjectMocks
|
||||
private DocumentService documentService;
|
||||
|
||||
private UUID clubId;
|
||||
private UUID uploadedBy;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
clubId = UUID.randomUUID();
|
||||
uploadedBy = UUID.randomUUID();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUploadDocument_validFile_savesSuccessfully() throws IOException {
|
||||
MultipartFile file = mockValidFile("report.pdf", "application/pdf", 1024);
|
||||
when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
try (MockedStatic<Files> filesMock = mockStatic(Files.class)) {
|
||||
filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null);
|
||||
filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null);
|
||||
|
||||
Document result = documentService.uploadDocument(
|
||||
clubId, "Test Report", DocumentCategory.PROTOKOLL,
|
||||
DocumentAccessLevel.ALL_MEMBERS, "description", file, uploadedBy);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.getTitle()).isEqualTo("Test Report");
|
||||
assertThat(result.getClubId()).isEqualTo(clubId);
|
||||
assertThat(result.getFilename()).isEqualTo("report.pdf");
|
||||
verify(documentRepository).save(any(Document.class));
|
||||
verify(auditService).log(eq(AuditEventType.DOCUMENT_UPLOADED), eq(uploadedBy), eq(clubId), anyString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUploadDocument_pathTraversal_sanitizedToSafeName() throws IOException {
|
||||
MultipartFile file = mockValidFile("../../etc/passwd", "application/pdf", 512);
|
||||
when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
try (MockedStatic<Files> filesMock = mockStatic(Files.class)) {
|
||||
filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null);
|
||||
filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null);
|
||||
|
||||
Document result = documentService.uploadDocument(
|
||||
clubId, "Hacked", DocumentCategory.SONSTIGES,
|
||||
DocumentAccessLevel.BOARD_ONLY, null, file, uploadedBy);
|
||||
|
||||
// Path traversal stripped — only the basename remains
|
||||
assertThat(result.getFilename()).doesNotContain("..");
|
||||
assertThat(result.getFilename()).doesNotContain("/");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUploadDocument_backslashPathTraversal_sanitized() throws IOException {
|
||||
MultipartFile file = mockValidFile("..\\windows\\system32\\file.pdf", "application/pdf", 512);
|
||||
when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
try (MockedStatic<Files> filesMock = mockStatic(Files.class)) {
|
||||
filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null);
|
||||
filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null);
|
||||
|
||||
Document result = documentService.uploadDocument(
|
||||
clubId, "Win Traversal", DocumentCategory.SONSTIGES,
|
||||
DocumentAccessLevel.BOARD_ONLY, null, file, uploadedBy);
|
||||
|
||||
// On Unix, backslashes are not path separators — they get replaced with _
|
||||
// The filename won't contain literal backslash characters
|
||||
assertThat(result.getFilename()).doesNotContain("\\");
|
||||
// The sanitized name should not allow filesystem escape
|
||||
assertThat(result.getFilename()).doesNotContain("/");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUploadDocument_nullByteInFilename_sanitized() throws IOException {
|
||||
MultipartFile file = mockValidFile("file\u0000.exe.pdf", "application/pdf", 512);
|
||||
when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
try (MockedStatic<Files> filesMock = mockStatic(Files.class)) {
|
||||
filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null);
|
||||
filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null);
|
||||
|
||||
Document result = documentService.uploadDocument(
|
||||
clubId, "Null Byte", DocumentCategory.SONSTIGES,
|
||||
DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy);
|
||||
|
||||
assertThat(result.getFilename()).doesNotContain("\0");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUploadDocument_emptyFilename_uuidFallback() throws IOException {
|
||||
MultipartFile file = mockValidFile("", "application/pdf", 512);
|
||||
when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
try (MockedStatic<Files> filesMock = mockStatic(Files.class)) {
|
||||
filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null);
|
||||
filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null);
|
||||
|
||||
Document result = documentService.uploadDocument(
|
||||
clubId, "Empty Name", DocumentCategory.SONSTIGES,
|
||||
DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy);
|
||||
|
||||
// Empty filename falls back to UUID-based name
|
||||
assertThat(result.getFilename()).isNotBlank();
|
||||
assertThat(result.getFilename()).matches("[a-f0-9\\-]+");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUploadDocument_singleDotFilename_uuidFallback() throws IOException {
|
||||
// Single "." and ".." are caught by sanitizeFilename and replaced with UUID
|
||||
MultipartFile file = mockValidFile("..", "application/pdf", 512);
|
||||
when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
try (MockedStatic<Files> filesMock = mockStatic(Files.class)) {
|
||||
filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null);
|
||||
filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null);
|
||||
|
||||
Document result = documentService.uploadDocument(
|
||||
clubId, "Dots", DocumentCategory.SONSTIGES,
|
||||
DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy);
|
||||
|
||||
// ".." is explicitly caught → "document" fallback
|
||||
assertThat(result.getFilename()).isNotEqualTo("..");
|
||||
assertThat(result.getFilename()).isNotBlank();
|
||||
assertThat(result.getFilename()).isEqualTo("document");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUploadDocument_doubleExtension_preservedAsIs() throws IOException {
|
||||
MultipartFile file = mockValidFile("document.pdf.exe", "application/pdf", 512);
|
||||
when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
try (MockedStatic<Files> filesMock = mockStatic(Files.class)) {
|
||||
filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null);
|
||||
filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null);
|
||||
|
||||
Document result = documentService.uploadDocument(
|
||||
clubId, "Double Ext", DocumentCategory.SONSTIGES,
|
||||
DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy);
|
||||
|
||||
assertThat(result.getFilename()).contains("document");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUploadDocument_fileTooLarge_throwsException() {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn((long) (11 * 1024 * 1024));
|
||||
|
||||
assertThatThrownBy(() -> documentService.uploadDocument(
|
||||
clubId, "Large", DocumentCategory.SONSTIGES,
|
||||
DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("maximum size");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUploadDocument_disallowedContentType_throwsException() {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(512L);
|
||||
when(file.getContentType()).thenReturn("application/x-msdownload");
|
||||
|
||||
assertThatThrownBy(() -> documentService.uploadDocument(
|
||||
clubId, "Exe", DocumentCategory.SONSTIGES,
|
||||
DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("not allowed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUploadDocument_emptyFile_throwsException() {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> documentService.uploadDocument(
|
||||
clubId, "Empty", DocumentCategory.SONSTIGES,
|
||||
DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("empty");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteDocument_existingDocument_deletesAndAudits() throws IOException {
|
||||
UUID docId = UUID.randomUUID();
|
||||
Document doc = new Document();
|
||||
doc.setId(docId);
|
||||
doc.setClubId(clubId);
|
||||
doc.setTitle("To Delete");
|
||||
doc.setFileSize(1024L);
|
||||
doc.setStoragePath(clubId + "/" + docId + "_test.pdf");
|
||||
when(documentRepository.findById(docId)).thenReturn(Optional.of(doc));
|
||||
|
||||
try (MockedStatic<Files> filesMock = mockStatic(Files.class)) {
|
||||
filesMock.when(() -> Files.exists(any(Path.class))).thenReturn(true);
|
||||
filesMock.when(() -> Files.delete(any(Path.class))).then(inv -> null);
|
||||
|
||||
documentService.deleteDocument(docId, uploadedBy, clubId);
|
||||
|
||||
verify(documentRepository).delete(doc);
|
||||
verify(auditService).log(eq(AuditEventType.DOCUMENT_DELETED), eq(uploadedBy), eq(clubId), anyString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUploadDocument_controlCharsInFilename_stripped() throws IOException {
|
||||
MultipartFile file = mockValidFile("file\u0007name\u001B.pdf", "application/pdf", 512);
|
||||
when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
try (MockedStatic<Files> filesMock = mockStatic(Files.class)) {
|
||||
filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null);
|
||||
filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null);
|
||||
|
||||
Document result = documentService.uploadDocument(
|
||||
clubId, "Control Chars", DocumentCategory.SONSTIGES,
|
||||
DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy);
|
||||
|
||||
// Control characters should be replaced with underscores
|
||||
assertThat(result.getFilename()).doesNotContain("\u0007");
|
||||
assertThat(result.getFilename()).doesNotContain("\u001B");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Additional security tests for Sprint 13 ---
|
||||
|
||||
@Test
|
||||
void testUploadDocument_sanitizesPathTraversal_toBasename() throws IOException {
|
||||
// Verify that "../../etc/passwd" is stripped to just "passwd"
|
||||
MultipartFile file = mockValidFile("../../etc/passwd", "application/pdf", 512);
|
||||
when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
try (MockedStatic<Files> filesMock = mockStatic(Files.class)) {
|
||||
filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null);
|
||||
filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null);
|
||||
|
||||
Document result = documentService.uploadDocument(
|
||||
clubId, "Path Traversal Test", DocumentCategory.SONSTIGES,
|
||||
DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy);
|
||||
|
||||
assertThat(result.getFilename()).isEqualTo("passwd");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUploadDocument_nullFilename_usesFallback() throws IOException {
|
||||
MultipartFile file = mockValidFile(null, "application/pdf", 512);
|
||||
when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
try (MockedStatic<Files> filesMock = mockStatic(Files.class)) {
|
||||
filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null);
|
||||
filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null);
|
||||
|
||||
Document result = documentService.uploadDocument(
|
||||
clubId, "Null Filename", DocumentCategory.SONSTIGES,
|
||||
DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy);
|
||||
|
||||
// Null filename should produce a non-blank fallback (UUID)
|
||||
assertThat(result.getFilename()).isNotBlank();
|
||||
assertThat(result.getFilename()).doesNotContain("..");
|
||||
assertThat(result.getFilename()).doesNotContain("/");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUploadDocument_normalFilename_preserved() throws IOException {
|
||||
MultipartFile file = mockValidFile("report.pdf", "application/pdf", 1024);
|
||||
when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
try (MockedStatic<Files> filesMock = mockStatic(Files.class)) {
|
||||
filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null);
|
||||
filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null);
|
||||
|
||||
Document result = documentService.uploadDocument(
|
||||
clubId, "Normal File", DocumentCategory.PROTOKOLL,
|
||||
DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy);
|
||||
|
||||
assertThat(result.getFilename()).isEqualTo("report.pdf");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDownloadDocument_documentNotFound_throwsException() {
|
||||
UUID nonExistentId = UUID.randomUUID();
|
||||
when(documentRepository.findById(nonExistentId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> documentService.downloadDocument(nonExistentId))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("not found");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteDocument_documentNotFound_throwsException() {
|
||||
UUID nonExistentId = UUID.randomUUID();
|
||||
when(documentRepository.findById(nonExistentId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> documentService.deleteDocument(nonExistentId, uploadedBy, clubId))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("not found");
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private MultipartFile mockValidFile(String filename, String contentType, long size) {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(size);
|
||||
when(file.getContentType()).thenReturn(contentType);
|
||||
when(file.getOriginalFilename()).thenReturn(filename);
|
||||
try {
|
||||
when(file.getBytes()).thenReturn(new byte[(int) Math.min(size, 1024)]);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,6 @@ class EmailServiceTest {
|
||||
assertThatThrownBy(() ->
|
||||
emailService.sendInviteEmail("fail@example.com", "Fail User", "Club", "token123"))
|
||||
.isInstanceOf(RuntimeException.class)
|
||||
.hasMessageContaining("Failed to send invite email");
|
||||
.hasMessageContaining("Failed to send email");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.ClubEvent;
|
||||
import de.cannamanage.domain.entity.EventRsvp;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.enums.*;
|
||||
import de.cannamanage.service.repository.ClubEventRepository;
|
||||
import de.cannamanage.service.repository.EventRsvpRepository;
|
||||
import de.cannamanage.service.repository.MemberRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Unit tests for EventService — club event lifecycle, RSVP, recurring expansion.
|
||||
*/
|
||||
class EventServiceTest extends AbstractServiceTest {
|
||||
|
||||
@Mock private ClubEventRepository eventRepository;
|
||||
@Mock private EventRsvpRepository rsvpRepository;
|
||||
@Mock private MemberRepository memberRepository;
|
||||
@Mock private NotificationService notificationService;
|
||||
@Mock private InfoBoardService infoBoardService;
|
||||
@Mock private AuditService auditService;
|
||||
|
||||
@InjectMocks
|
||||
private EventService eventService;
|
||||
|
||||
private ClubEvent event;
|
||||
private static final UUID EVENT_ID = UUID.fromString("aaaa1111-bbbb-2222-cccc-333344445555");
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
event = new ClubEvent(TEST_CLUB_ID, "Vereinsabend", "Monatlicher Stammtisch",
|
||||
EventType.OTHER, TEST_INSTANT.plusSeconds(86400),
|
||||
TEST_INSTANT.plusSeconds(86400 + 7200), "Vereinsheim", 30, TEST_USER_ID);
|
||||
event.setId(EVENT_ID);
|
||||
event.setRecurring(false);
|
||||
}
|
||||
|
||||
// === Create Event ===
|
||||
|
||||
@Test
|
||||
void testCreateEvent_singleEvent_success() {
|
||||
when(eventRepository.save(any(ClubEvent.class))).thenAnswer(inv -> {
|
||||
ClubEvent e = inv.getArgument(0);
|
||||
e.setId(EVENT_ID);
|
||||
return e;
|
||||
});
|
||||
when(memberRepository.findByClubId(TEST_CLUB_ID)).thenReturn(Collections.emptyList());
|
||||
|
||||
ClubEvent result = eventService.createEvent(
|
||||
TEST_CLUB_ID, "Vereinsabend", "Stammtisch", EventType.OTHER,
|
||||
TEST_INSTANT.plusSeconds(86400), TEST_INSTANT.plusSeconds(86400 + 7200),
|
||||
"Vereinsheim", 30, false, null, null, TEST_USER_ID, false);
|
||||
|
||||
assertThat(result.getTitle()).isEqualTo("Vereinsabend");
|
||||
assertThat(result.isRecurring()).isFalse();
|
||||
verify(eventRepository).save(any(ClubEvent.class));
|
||||
verify(auditService).log(eq(AuditEventType.EVENT_CREATED), eq("ClubEvent"), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateEvent_recurringWeekly_success() {
|
||||
when(eventRepository.save(any(ClubEvent.class))).thenAnswer(inv -> {
|
||||
ClubEvent e = inv.getArgument(0);
|
||||
e.setId(EVENT_ID);
|
||||
return e;
|
||||
});
|
||||
when(memberRepository.findByClubId(TEST_CLUB_ID)).thenReturn(Collections.emptyList());
|
||||
|
||||
ClubEvent result = eventService.createEvent(
|
||||
TEST_CLUB_ID, "Wöchentliches Meeting", "Standup", EventType.MEETING,
|
||||
TEST_INSTANT, TEST_INSTANT.plusSeconds(3600),
|
||||
"Online", null, true, RecurrenceRule.WEEKLY,
|
||||
LocalDate.of(2026, 12, 31), TEST_USER_ID, false);
|
||||
|
||||
assertThat(result.isRecurring()).isTrue();
|
||||
assertThat(result.getRecurrenceRule()).isEqualTo(RecurrenceRule.WEEKLY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateEvent_recurringBiweekly_success() {
|
||||
when(eventRepository.save(any(ClubEvent.class))).thenAnswer(inv -> {
|
||||
ClubEvent e = inv.getArgument(0);
|
||||
e.setId(EVENT_ID);
|
||||
return e;
|
||||
});
|
||||
when(memberRepository.findByClubId(TEST_CLUB_ID)).thenReturn(Collections.emptyList());
|
||||
|
||||
ClubEvent result = eventService.createEvent(
|
||||
TEST_CLUB_ID, "Vorstand", "Vorstandssitzung", EventType.BOARD_MEETING,
|
||||
TEST_INSTANT, TEST_INSTANT.plusSeconds(3600),
|
||||
"Büro", 10, true, RecurrenceRule.BIWEEKLY,
|
||||
LocalDate.of(2026, 12, 31), TEST_USER_ID, false);
|
||||
|
||||
assertThat(result.getRecurrenceRule()).isEqualTo(RecurrenceRule.BIWEEKLY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateEvent_recurringMonthly_success() {
|
||||
when(eventRepository.save(any(ClubEvent.class))).thenAnswer(inv -> {
|
||||
ClubEvent e = inv.getArgument(0);
|
||||
e.setId(EVENT_ID);
|
||||
return e;
|
||||
});
|
||||
when(memberRepository.findByClubId(TEST_CLUB_ID)).thenReturn(Collections.emptyList());
|
||||
|
||||
ClubEvent result = eventService.createEvent(
|
||||
TEST_CLUB_ID, "MV-Vorbereitung", "Monatlich", EventType.MEETING,
|
||||
TEST_INSTANT, TEST_INSTANT.plusSeconds(7200),
|
||||
"Vereinsheim", null, true, RecurrenceRule.MONTHLY,
|
||||
LocalDate.of(2027, 6, 1), TEST_USER_ID, false);
|
||||
|
||||
assertThat(result.getRecurrenceRule()).isEqualTo(RecurrenceRule.MONTHLY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateEvent_withInfoBoardPost_postsToBoard() {
|
||||
when(eventRepository.save(any(ClubEvent.class))).thenAnswer(inv -> {
|
||||
ClubEvent e = inv.getArgument(0);
|
||||
e.setId(EVENT_ID);
|
||||
return e;
|
||||
});
|
||||
when(memberRepository.findByClubId(TEST_CLUB_ID)).thenReturn(Collections.emptyList());
|
||||
|
||||
eventService.createEvent(
|
||||
TEST_CLUB_ID, "Grillfest", "Sommer", EventType.HARVEST_FESTIVAL,
|
||||
TEST_INSTANT.plusSeconds(86400 * 7), null,
|
||||
"Garten", 50, false, null, null, TEST_USER_ID, true);
|
||||
|
||||
verify(infoBoardService).createPost(eq(TEST_CLUB_ID), contains("Grillfest"),
|
||||
any(), eq(InfoBoardCategory.EVENT), eq(false), eq(TEST_USER_ID));
|
||||
}
|
||||
|
||||
// === RSVP ===
|
||||
|
||||
@Test
|
||||
void testRsvp_accept_success() {
|
||||
when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(event));
|
||||
when(rsvpRepository.findByEventIdAndMemberId(EVENT_ID, TEST_MEMBER_ID)).thenReturn(Optional.empty());
|
||||
when(rsvpRepository.countByEventIdAndStatus(EVENT_ID, RsvpStatus.ACCEPTED)).thenReturn(5L);
|
||||
when(rsvpRepository.save(any(EventRsvp.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
EventRsvp result = eventService.rsvp(EVENT_ID, TEST_MEMBER_ID, RsvpStatus.ACCEPTED);
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(RsvpStatus.ACCEPTED);
|
||||
verify(rsvpRepository).save(any(EventRsvp.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRsvp_decline_success() {
|
||||
when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(event));
|
||||
when(rsvpRepository.findByEventIdAndMemberId(EVENT_ID, TEST_MEMBER_ID)).thenReturn(Optional.empty());
|
||||
when(rsvpRepository.save(any(EventRsvp.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
EventRsvp result = eventService.rsvp(EVENT_ID, TEST_MEMBER_ID, RsvpStatus.DECLINED);
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(RsvpStatus.DECLINED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRsvp_idempotent_updatesExisting() {
|
||||
EventRsvp existing = new EventRsvp(event, TEST_MEMBER_ID, RsvpStatus.DECLINED);
|
||||
when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(event));
|
||||
when(rsvpRepository.findByEventIdAndMemberId(EVENT_ID, TEST_MEMBER_ID)).thenReturn(Optional.of(existing));
|
||||
when(rsvpRepository.save(any(EventRsvp.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
EventRsvp result = eventService.rsvp(EVENT_ID, TEST_MEMBER_ID, RsvpStatus.ACCEPTED);
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(RsvpStatus.ACCEPTED);
|
||||
}
|
||||
|
||||
// === Cancel Event ===
|
||||
|
||||
@Test
|
||||
void testCancelEvent_notifiesAttendees() {
|
||||
when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(event));
|
||||
var rsvp = new EventRsvp(event, TEST_MEMBER_ID, RsvpStatus.ACCEPTED);
|
||||
when(rsvpRepository.findByEventIdAndStatusIn(eq(EVENT_ID), any())).thenReturn(List.of(rsvp));
|
||||
Member member = new Member();
|
||||
member.setId(TEST_MEMBER_ID);
|
||||
member.setUserId(TEST_USER_ID);
|
||||
when(memberRepository.findById(TEST_MEMBER_ID)).thenReturn(Optional.of(member));
|
||||
|
||||
eventService.cancelEvent(EVENT_ID);
|
||||
|
||||
verify(eventRepository).delete(event);
|
||||
verify(notificationService).sendNotification(eq(TEST_USER_ID), eq(NotificationType.EVENT_CANCELLED), any(), any(), any());
|
||||
verify(auditService).log(eq(AuditEventType.EVENT_CANCELLED), eq("ClubEvent"), anyString(), anyString());
|
||||
}
|
||||
|
||||
// === Max Capacity Enforcement ===
|
||||
|
||||
@Test
|
||||
void testRsvp_maxCapacityReached_throwsException() {
|
||||
event.setMaxAttendees(5);
|
||||
when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(event));
|
||||
when(rsvpRepository.findByEventIdAndMemberId(EVENT_ID, TEST_MEMBER_ID)).thenReturn(Optional.empty());
|
||||
when(rsvpRepository.countByEventIdAndStatus(EVENT_ID, RsvpStatus.ACCEPTED)).thenReturn(5L);
|
||||
|
||||
assertThatThrownBy(() -> eventService.rsvp(EVENT_ID, TEST_MEMBER_ID, RsvpStatus.ACCEPTED))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("EVENT_FULL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRsvp_maxCapacityReached_declineStillWorks() {
|
||||
event.setMaxAttendees(5);
|
||||
when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(event));
|
||||
when(rsvpRepository.findByEventIdAndMemberId(EVENT_ID, TEST_MEMBER_ID)).thenReturn(Optional.empty());
|
||||
when(rsvpRepository.save(any(EventRsvp.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
// Declining should work regardless of capacity
|
||||
EventRsvp result = eventService.rsvp(EVENT_ID, TEST_MEMBER_ID, RsvpStatus.DECLINED);
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(RsvpStatus.DECLINED);
|
||||
}
|
||||
|
||||
// === DST Transition Edge Case ===
|
||||
|
||||
@Test
|
||||
void testExpandRecurring_dstTransition_octoberLastSunday() {
|
||||
// DST transition in Germany: last Sunday of October 2026 is Oct 25
|
||||
// Clock goes back 1 hour at 03:00 → 02:00
|
||||
ZoneId berlinZone = ZoneId.of("Europe/Berlin");
|
||||
// Start event at Oct 12 2026, 19:00 Berlin time (weekly)
|
||||
LocalDateTime oct12 = LocalDateTime.of(2026, 10, 12, 19, 0);
|
||||
Instant startInstant = oct12.atZone(berlinZone).toInstant();
|
||||
|
||||
ClubEvent recurringEvent = new ClubEvent(TEST_CLUB_ID, "Wöchentlicher Treff", null,
|
||||
EventType.OTHER, startInstant, startInstant.plusSeconds(7200),
|
||||
"Vereinsheim", null, TEST_USER_ID);
|
||||
recurringEvent.setId(EVENT_ID);
|
||||
recurringEvent.setRecurring(true);
|
||||
recurringEvent.setRecurrenceRule(RecurrenceRule.WEEKLY);
|
||||
recurringEvent.setRecurrenceEndDate(LocalDate.of(2026, 11, 15));
|
||||
|
||||
// Range covering the DST switch
|
||||
Instant from = oct12.plusDays(1).atZone(berlinZone).toInstant();
|
||||
Instant to = LocalDateTime.of(2026, 11, 10, 23, 59).atZone(berlinZone).toInstant();
|
||||
|
||||
List<ClubEvent> occurrences = eventService.expandRecurring(recurringEvent, from, to);
|
||||
|
||||
// Should produce occurrences for Oct 19, Oct 26, Nov 2, Nov 9
|
||||
assertThat(occurrences).hasSizeGreaterThanOrEqualTo(4);
|
||||
// After DST switch, the event should still be at 19:00 local time
|
||||
for (ClubEvent occ : occurrences) {
|
||||
LocalTime localTime = occ.getStartAt().atZone(berlinZone).toLocalTime();
|
||||
assertThat(localTime).isEqualTo(LocalTime.of(19, 0));
|
||||
}
|
||||
}
|
||||
|
||||
// === Event not found ===
|
||||
|
||||
@Test
|
||||
void testCancelEvent_notFound_throwsException() {
|
||||
when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> eventService.cancelEvent(EVENT_ID))
|
||||
.isInstanceOf(NoSuchElementException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.*;
|
||||
import de.cannamanage.domain.enums.*;
|
||||
import de.cannamanage.service.repository.*;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Sprint 11 — Unit tests for {@link FinanceService}.
|
||||
* <p>
|
||||
* Covers fee schedule lifecycle, fee assignment transitions, payment recording
|
||||
* with dual ledger writes, void compensation entries, expense tracking, and
|
||||
* financial summary calculations. Every monetary path is verified for
|
||||
* §147 AO append-only correctness.
|
||||
*/
|
||||
class FinanceServiceTest extends AbstractServiceTest {
|
||||
|
||||
@Mock private FeeScheduleRepository feeScheduleRepository;
|
||||
@Mock private MemberFeeAssignmentRepository assignmentRepository;
|
||||
@Mock private PaymentRepository paymentRepository;
|
||||
@Mock private LedgerEntryRepository ledgerEntryRepository;
|
||||
@Mock private AuditService auditService;
|
||||
@Mock private NotificationService notificationService;
|
||||
@Mock private MemberRepository memberRepository;
|
||||
|
||||
@InjectMocks
|
||||
private FinanceService financeService;
|
||||
|
||||
private UUID scheduleId;
|
||||
private UUID paymentId;
|
||||
|
||||
@BeforeEach
|
||||
void initIds() {
|
||||
scheduleId = UUID.fromString("99999999-0000-0000-0000-000000000001");
|
||||
paymentId = TEST_PAYMENT_ID;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Fee Schedule CRUD
|
||||
// ============================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("Fee Schedule lifecycle")
|
||||
class FeeScheduleLifecycle {
|
||||
|
||||
@Test
|
||||
@DisplayName("createFeeSchedule with isDefault=true unsets the previous default")
|
||||
void createFeeSchedule_default_unsetsExistingDefault() {
|
||||
FeeSchedule existing = new FeeSchedule();
|
||||
existing.setId(UUID.fromString("99999999-0000-0000-0000-0000000000ee"));
|
||||
existing.setIsDefault(true);
|
||||
existing.setClubId(TEST_CLUB_ID);
|
||||
when(feeScheduleRepository.findByClubIdAndIsDefaultTrue(TEST_CLUB_ID))
|
||||
.thenReturn(Optional.of(existing));
|
||||
when(feeScheduleRepository.save(any(FeeSchedule.class)))
|
||||
.thenAnswer(inv -> {
|
||||
FeeSchedule s = inv.getArgument(0);
|
||||
if (s.getId() == null) s.setId(scheduleId);
|
||||
return s;
|
||||
});
|
||||
|
||||
FeeSchedule result = financeService.createFeeSchedule(
|
||||
TEST_CLUB_ID, "Standard", 2500, FeeInterval.MONTHLY, true);
|
||||
|
||||
assertThat(existing.getIsDefault()).isFalse();
|
||||
assertThat(result.getIsDefault()).isTrue();
|
||||
assertThat(result.getIsActive()).isTrue();
|
||||
assertThat(result.getAmountCents()).isEqualTo(2500);
|
||||
verify(feeScheduleRepository, times(2)).save(any(FeeSchedule.class));
|
||||
verify(auditService).log(eq(AuditEventType.FEE_SCHEDULE_CREATED),
|
||||
eq("FeeSchedule"), any(), contains("Standard"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createFeeSchedule with isDefault=false does not touch existing default")
|
||||
void createFeeSchedule_nonDefault_doesNotTouchExisting() {
|
||||
when(feeScheduleRepository.save(any(FeeSchedule.class)))
|
||||
.thenAnswer(inv -> {
|
||||
FeeSchedule s = inv.getArgument(0);
|
||||
if (s.getId() == null) s.setId(scheduleId);
|
||||
return s;
|
||||
});
|
||||
|
||||
financeService.createFeeSchedule(
|
||||
TEST_CLUB_ID, "Premium", 5000, FeeInterval.ANNUAL, false);
|
||||
|
||||
verify(feeScheduleRepository, never()).findByClubIdAndIsDefaultTrue(any());
|
||||
verify(feeScheduleRepository, times(1)).save(any(FeeSchedule.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("updateFeeSchedule only writes fields that were provided")
|
||||
void updateFeeSchedule_partialUpdate_onlyChangesProvidedFields() {
|
||||
FeeSchedule existing = new FeeSchedule();
|
||||
existing.setId(scheduleId);
|
||||
existing.setName("Old name");
|
||||
existing.setAmountCents(1000);
|
||||
existing.setInterval(FeeInterval.MONTHLY);
|
||||
existing.setIsDefault(false);
|
||||
when(feeScheduleRepository.findById(scheduleId)).thenReturn(Optional.of(existing));
|
||||
when(feeScheduleRepository.save(any(FeeSchedule.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
FeeSchedule result = financeService.updateFeeSchedule(
|
||||
scheduleId, "New name", null, null, null);
|
||||
|
||||
assertThat(result.getName()).isEqualTo("New name");
|
||||
assertThat(result.getAmountCents()).isEqualTo(1000); // unchanged
|
||||
assertThat(result.getInterval()).isEqualTo(FeeInterval.MONTHLY);
|
||||
assertThat(result.getIsDefault()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("updateFeeSchedule throws when schedule is unknown")
|
||||
void updateFeeSchedule_notFound_throws() {
|
||||
when(feeScheduleRepository.findById(scheduleId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> financeService.updateFeeSchedule(
|
||||
scheduleId, "X", null, null, null))
|
||||
.isInstanceOf(NoSuchElementException.class)
|
||||
.hasMessageContaining(scheduleId.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("updateFeeSchedule with isDefault=true unsets a different existing default")
|
||||
void updateFeeSchedule_setDefault_unsetsOther() {
|
||||
FeeSchedule target = new FeeSchedule();
|
||||
target.setId(scheduleId);
|
||||
target.setClubId(TEST_CLUB_ID);
|
||||
target.setIsDefault(false);
|
||||
FeeSchedule other = new FeeSchedule();
|
||||
UUID otherId = UUID.fromString("99999999-0000-0000-0000-000000000002");
|
||||
other.setId(otherId);
|
||||
other.setClubId(TEST_CLUB_ID);
|
||||
other.setIsDefault(true);
|
||||
|
||||
when(feeScheduleRepository.findById(scheduleId)).thenReturn(Optional.of(target));
|
||||
when(feeScheduleRepository.findByClubIdAndIsDefaultTrue(TEST_CLUB_ID))
|
||||
.thenReturn(Optional.of(other));
|
||||
when(feeScheduleRepository.save(any(FeeSchedule.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
financeService.updateFeeSchedule(scheduleId, null, null, null, true);
|
||||
|
||||
assertThat(other.getIsDefault()).isFalse();
|
||||
assertThat(target.getIsDefault()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("deactivateFeeSchedule sets inactive and removes default flag")
|
||||
void deactivateFeeSchedule_setsInactiveAndNonDefault() {
|
||||
FeeSchedule existing = new FeeSchedule();
|
||||
existing.setIsActive(true);
|
||||
existing.setIsDefault(true);
|
||||
when(feeScheduleRepository.findById(scheduleId)).thenReturn(Optional.of(existing));
|
||||
|
||||
financeService.deactivateFeeSchedule(scheduleId);
|
||||
|
||||
assertThat(existing.getIsActive()).isFalse();
|
||||
assertThat(existing.getIsDefault()).isFalse();
|
||||
verify(feeScheduleRepository).save(existing);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("deactivateFeeSchedule throws when not found")
|
||||
void deactivateFeeSchedule_notFound_throws() {
|
||||
when(feeScheduleRepository.findById(scheduleId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> financeService.deactivateFeeSchedule(scheduleId))
|
||||
.isInstanceOf(NoSuchElementException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getActiveFeeSchedules delegates to repository")
|
||||
void getActiveFeeSchedules_delegatesToRepository() {
|
||||
FeeSchedule s = new FeeSchedule();
|
||||
when(feeScheduleRepository.findByClubIdAndIsActiveTrue(TEST_CLUB_ID))
|
||||
.thenReturn(List.of(s));
|
||||
|
||||
List<FeeSchedule> result = financeService.getActiveFeeSchedules(TEST_CLUB_ID);
|
||||
|
||||
assertThat(result).containsExactly(s);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Fee Assignment
|
||||
// ============================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("Fee assignment transitions")
|
||||
class FeeAssignmentLifecycle {
|
||||
|
||||
@Test
|
||||
@DisplayName("assignFeeSchedule closes the previous open assignment with validTo = validFrom - 1")
|
||||
void assignFeeSchedule_closesExistingOpenAssignment() {
|
||||
MemberFeeAssignment existing = new MemberFeeAssignment();
|
||||
existing.setMemberId(TEST_MEMBER_ID);
|
||||
existing.setValidFrom(LocalDate.of(2025, 1, 1));
|
||||
when(assignmentRepository.findByMemberIdAndValidToIsNull(TEST_MEMBER_ID))
|
||||
.thenReturn(Optional.of(existing));
|
||||
when(assignmentRepository.save(any(MemberFeeAssignment.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
LocalDate from = LocalDate.of(2026, 7, 1);
|
||||
MemberFeeAssignment result = financeService.assignFeeSchedule(
|
||||
TEST_MEMBER_ID, TEST_CLUB_ID, scheduleId, from);
|
||||
|
||||
assertThat(existing.getValidTo()).isEqualTo(LocalDate.of(2026, 6, 30));
|
||||
assertThat(result.getValidFrom()).isEqualTo(from);
|
||||
assertThat(result.getMemberId()).isEqualTo(TEST_MEMBER_ID);
|
||||
assertThat(result.getFeeScheduleId()).isEqualTo(scheduleId);
|
||||
verify(assignmentRepository, times(2)).save(any(MemberFeeAssignment.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("assignFeeSchedule simply saves when no open assignment exists")
|
||||
void assignFeeSchedule_noExistingAssignment_simplySaves() {
|
||||
when(assignmentRepository.findByMemberIdAndValidToIsNull(TEST_MEMBER_ID))
|
||||
.thenReturn(Optional.empty());
|
||||
when(assignmentRepository.save(any(MemberFeeAssignment.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
financeService.assignFeeSchedule(
|
||||
TEST_MEMBER_ID, TEST_CLUB_ID, scheduleId, TEST_TODAY);
|
||||
|
||||
verify(assignmentRepository, times(1)).save(any(MemberFeeAssignment.class));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Payment recording + dual write to ledger + notification
|
||||
// ============================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("Payment recording / voiding")
|
||||
class PaymentLifecycle {
|
||||
|
||||
@Test
|
||||
@DisplayName("recordPayment creates Payment + INCOME LedgerEntry + audit log + notification")
|
||||
void recordPayment_createsPaymentLedgerEntryAndNotifies() {
|
||||
when(paymentRepository.save(any(Payment.class))).thenAnswer(inv -> {
|
||||
Payment p = inv.getArgument(0);
|
||||
if (p.getId() == null) p.setId(paymentId);
|
||||
return p;
|
||||
});
|
||||
when(ledgerEntryRepository.save(any(LedgerEntry.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
Member member = new Member();
|
||||
member.setUserId(TEST_USER_ID);
|
||||
when(memberRepository.findById(TEST_MEMBER_ID)).thenReturn(Optional.of(member));
|
||||
|
||||
Payment result = financeService.recordPayment(
|
||||
TEST_CLUB_ID, TEST_MEMBER_ID, 5000, PaymentMethod.BANK_TRANSFER,
|
||||
LocalDate.of(2026, 6, 1), LocalDate.of(2026, 6, 30),
|
||||
"REF-001", "Mai-Beitrag", TEST_STAFF_ID);
|
||||
|
||||
assertThat(result.getAmountCents()).isEqualTo(5000);
|
||||
assertThat(result.getStatus()).isEqualTo(PaymentStatus.PAID);
|
||||
assertThat(result.getRecordedBy()).isEqualTo(TEST_STAFF_ID);
|
||||
assertThat(result.getPaidAt()).isNotNull();
|
||||
|
||||
ArgumentCaptor<LedgerEntry> ledgerCaptor = ArgumentCaptor.forClass(LedgerEntry.class);
|
||||
verify(ledgerEntryRepository).save(ledgerCaptor.capture());
|
||||
LedgerEntry entry = ledgerCaptor.getValue();
|
||||
assertThat(entry.getTransactionType()).isEqualTo(TransactionType.INCOME);
|
||||
assertThat(entry.getCategory()).isEqualTo("MEMBERSHIP_FEE");
|
||||
assertThat(entry.getAmountCents()).isEqualTo(5000);
|
||||
assertThat(entry.getDescription()).contains("2026-06-01", "2026-06-30");
|
||||
|
||||
verify(auditService).log(eq(AuditEventType.PAYMENT_RECORDED),
|
||||
eq("Payment"), any(), contains("5000"));
|
||||
verify(notificationService).sendNotification(
|
||||
eq(TEST_USER_ID),
|
||||
eq(NotificationType.PAYMENT_RECEIVED),
|
||||
eq("Zahlung erfasst"),
|
||||
contains("50,00"),
|
||||
eq("/portal/finance"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("recordPayment skips notification when member has no linked user account")
|
||||
void recordPayment_memberWithoutUser_skipsNotification() {
|
||||
when(paymentRepository.save(any(Payment.class))).thenAnswer(inv -> {
|
||||
Payment p = inv.getArgument(0);
|
||||
if (p.getId() == null) p.setId(paymentId);
|
||||
return p;
|
||||
});
|
||||
when(ledgerEntryRepository.save(any(LedgerEntry.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
Member member = new Member();
|
||||
member.setUserId(null); // unlinked member
|
||||
when(memberRepository.findById(TEST_MEMBER_ID)).thenReturn(Optional.of(member));
|
||||
|
||||
financeService.recordPayment(
|
||||
TEST_CLUB_ID, TEST_MEMBER_ID, 2500, PaymentMethod.CASH,
|
||||
TEST_TODAY, TEST_TODAY, "R", "n", TEST_STAFF_ID);
|
||||
|
||||
verify(ledgerEntryRepository).save(any(LedgerEntry.class));
|
||||
verifyNoInteractions(notificationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("voidPayment creates a compensating EXPENSE entry and marks payment VOIDED")
|
||||
void voidPayment_createsCompensatingEntry() {
|
||||
Payment original = new Payment();
|
||||
original.setClubId(TEST_CLUB_ID);
|
||||
original.setAmountCents(5000);
|
||||
original.setStatus(PaymentStatus.PAID);
|
||||
original.setPeriodFrom(LocalDate.of(2026, 6, 1));
|
||||
original.setPeriodTo(LocalDate.of(2026, 6, 30));
|
||||
original.setId(paymentId);
|
||||
when(paymentRepository.findById(paymentId)).thenReturn(Optional.of(original));
|
||||
when(paymentRepository.save(any(Payment.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(ledgerEntryRepository.save(any(LedgerEntry.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Payment result = financeService.voidPayment(paymentId, TEST_STAFF_ID, "Doppelbuchung");
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(PaymentStatus.VOIDED);
|
||||
assertThat(result.getVoidedBy()).isEqualTo(TEST_STAFF_ID);
|
||||
assertThat(result.getVoidReason()).isEqualTo("Doppelbuchung");
|
||||
assertThat(result.getVoidedAt()).isNotNull();
|
||||
|
||||
ArgumentCaptor<LedgerEntry> ledgerCaptor = ArgumentCaptor.forClass(LedgerEntry.class);
|
||||
verify(ledgerEntryRepository).save(ledgerCaptor.capture());
|
||||
LedgerEntry comp = ledgerCaptor.getValue();
|
||||
assertThat(comp.getTransactionType()).isEqualTo(TransactionType.EXPENSE);
|
||||
assertThat(comp.getCategory()).isEqualTo("MEMBERSHIP_FEE_VOID");
|
||||
assertThat(comp.getAmountCents()).isEqualTo(5000);
|
||||
assertThat(comp.getDescription()).contains("Storno", "Doppelbuchung");
|
||||
|
||||
verify(auditService).log(eq(AuditEventType.PAYMENT_VOIDED),
|
||||
eq("Payment"), eq(paymentId.toString()), contains("Doppelbuchung"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("voidPayment throws IllegalStateException when payment is already voided")
|
||||
void voidPayment_alreadyVoided_throws() {
|
||||
Payment original = new Payment();
|
||||
original.setStatus(PaymentStatus.VOIDED);
|
||||
when(paymentRepository.findById(paymentId)).thenReturn(Optional.of(original));
|
||||
|
||||
assertThatThrownBy(() -> financeService.voidPayment(paymentId, TEST_STAFF_ID, "x"))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining(paymentId.toString());
|
||||
|
||||
verify(ledgerEntryRepository, never()).save(any());
|
||||
verify(auditService, never()).log(eq(AuditEventType.PAYMENT_VOIDED), anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("voidPayment throws NoSuchElementException when payment is missing")
|
||||
void voidPayment_notFound_throws() {
|
||||
when(paymentRepository.findById(paymentId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> financeService.voidPayment(paymentId, TEST_STAFF_ID, "x"))
|
||||
.isInstanceOf(NoSuchElementException.class);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Expenses + Summary + Outstanding
|
||||
// ============================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("Expenses, summaries, outstanding members")
|
||||
class ExpensesAndReports {
|
||||
|
||||
@Test
|
||||
@DisplayName("recordExpense persists EXPENSE LedgerEntry with full audit trail")
|
||||
void recordExpense_createsExpenseLedgerEntry() {
|
||||
when(ledgerEntryRepository.save(any(LedgerEntry.class)))
|
||||
.thenAnswer(inv -> {
|
||||
LedgerEntry e = inv.getArgument(0);
|
||||
if (e.getId() == null) e.setId(UUID.fromString("99999999-0000-0000-0000-0000000000aa"));
|
||||
return e;
|
||||
});
|
||||
|
||||
LedgerEntry result = financeService.recordExpense(
|
||||
TEST_CLUB_ID, ExpenseCategory.RENT, 80000,
|
||||
"Miete Juni", "INV-2026-06", TEST_STAFF_ID, LocalDate.of(2026, 6, 1));
|
||||
|
||||
assertThat(result.getTransactionType()).isEqualTo(TransactionType.EXPENSE);
|
||||
assertThat(result.getCategory()).isEqualTo("RENT");
|
||||
assertThat(result.getAmountCents()).isEqualTo(80000);
|
||||
assertThat(result.getDescription()).isEqualTo("Miete Juni");
|
||||
assertThat(result.getReference()).isEqualTo("INV-2026-06");
|
||||
|
||||
verify(auditService).log(eq(AuditEventType.EXPENSE_RECORDED),
|
||||
eq("LedgerEntry"), any(), contains("RENT"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getFinancialSummary computes net = income - expenses correctly")
|
||||
void getFinancialSummary_calculatesNetCorrectly() {
|
||||
LocalDate from = LocalDate.of(2026, 1, 1);
|
||||
LocalDate to = LocalDate.of(2026, 12, 31);
|
||||
when(ledgerEntryRepository.sumIncomeByClubAndDateRange(TEST_CLUB_ID, from, to)).thenReturn(15000L);
|
||||
when(ledgerEntryRepository.sumExpensesByClubAndDateRange(TEST_CLUB_ID, from, to)).thenReturn(8500L);
|
||||
when(ledgerEntryRepository.calculateBalance(TEST_CLUB_ID, to)).thenReturn(6500L);
|
||||
|
||||
Map<String, Object> summary = financeService.getFinancialSummary(TEST_CLUB_ID, from, to);
|
||||
|
||||
assertThat(summary)
|
||||
.containsEntry("totalIncomeCents", 15000L)
|
||||
.containsEntry("totalExpensesCents", 8500L)
|
||||
.containsEntry("netCents", 6500L)
|
||||
.containsEntry("balanceCents", 6500L)
|
||||
.containsEntry("periodFrom", "2026-01-01")
|
||||
.containsEntry("periodTo", "2026-12-31");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getOutstandingMembers returns only members with zero payments")
|
||||
void getOutstandingMembers_returnsOnlyZeroBalanceMembers() {
|
||||
UUID memberWithPayments = UUID.fromString("aaaaaaaa-0000-0000-0000-0000000000aa");
|
||||
UUID memberWithoutPayments = TEST_MEMBER_ID;
|
||||
|
||||
MemberFeeAssignment a1 = new MemberFeeAssignment();
|
||||
a1.setMemberId(memberWithPayments);
|
||||
a1.setFeeScheduleId(scheduleId);
|
||||
a1.setValidFrom(LocalDate.of(2026, 1, 1));
|
||||
|
||||
MemberFeeAssignment a2 = new MemberFeeAssignment();
|
||||
a2.setMemberId(memberWithoutPayments);
|
||||
a2.setFeeScheduleId(scheduleId);
|
||||
a2.setValidFrom(LocalDate.of(2026, 1, 1));
|
||||
|
||||
when(assignmentRepository.findByClubIdAndValidToIsNull(TEST_CLUB_ID))
|
||||
.thenReturn(List.of(a1, a2));
|
||||
when(paymentRepository.sumPaidByMember(TEST_CLUB_ID, memberWithPayments))
|
||||
.thenReturn(5000L);
|
||||
when(paymentRepository.sumPaidByMember(TEST_CLUB_ID, memberWithoutPayments))
|
||||
.thenReturn(0L);
|
||||
|
||||
List<Map<String, Object>> result = financeService.getOutstandingMembers(TEST_CLUB_ID);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0))
|
||||
.containsEntry("memberId", memberWithoutPayments)
|
||||
.containsEntry("totalPaidCents", 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("exportLedgerCsv produces ISO-8859-1 bytes with German header and EUR-formatted amounts")
|
||||
void exportLedgerCsv_producesIso88591WithGermanHeader() {
|
||||
LedgerEntry e1 = new LedgerEntry();
|
||||
e1.setTransactionDate(LocalDate.of(2026, 6, 1));
|
||||
e1.setTransactionType(TransactionType.INCOME);
|
||||
e1.setCategory("MEMBERSHIP_FEE");
|
||||
e1.setAmountCents(5000);
|
||||
e1.setDescription("Mitgliedsbeitrag Juni");
|
||||
e1.setReference("EREF+M-2025-001");
|
||||
|
||||
LocalDate from = LocalDate.of(2026, 6, 1);
|
||||
LocalDate to = LocalDate.of(2026, 6, 30);
|
||||
when(ledgerEntryRepository.findByClubIdAndTransactionDateBetween(TEST_CLUB_ID, from, to))
|
||||
.thenReturn(List.of(e1));
|
||||
|
||||
byte[] csvBytes = financeService.exportLedgerCsv(TEST_CLUB_ID, from, to);
|
||||
String csv = new String(csvBytes, java.nio.charset.StandardCharsets.ISO_8859_1);
|
||||
|
||||
assertThat(csv).startsWith("Datum;Typ;Kategorie;Betrag;Beschreibung;Referenz\n");
|
||||
assertThat(csv).contains("2026-06-01;INCOME;MEMBERSHIP_FEE;50");
|
||||
assertThat(csv).contains("Mitgliedsbeitrag Juni;EREF+M-2025-001");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getPaymentById delegates to repository lookup")
|
||||
void getPaymentById_delegatesToRepository() {
|
||||
Payment p = new Payment();
|
||||
when(paymentRepository.findById(paymentId)).thenReturn(Optional.of(p));
|
||||
|
||||
Optional<Payment> result = financeService.getPaymentById(paymentId);
|
||||
|
||||
assertThat(result).containsSame(p);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("buildAnnualReportData throws UnsupportedOperationException (stub)")
|
||||
void buildAnnualReportData_throwsStubException() {
|
||||
assertThatThrownBy(() -> financeService.buildAnnualReportData(TEST_CLUB_ID, 2026))
|
||||
.isInstanceOf(UnsupportedOperationException.class)
|
||||
.hasMessageContaining("FinancialReportService");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.*;
|
||||
import de.cannamanage.domain.enums.*;
|
||||
import de.cannamanage.service.repository.*;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Unit tests for ForumService — topics, replies, reactions, reports, moderation.
|
||||
*/
|
||||
class ForumServiceTest extends AbstractServiceTest {
|
||||
|
||||
@Mock private ForumTopicRepository topicRepository;
|
||||
@Mock private ForumReplyRepository replyRepository;
|
||||
@Mock private ForumReactionRepository reactionRepository;
|
||||
@Mock private ForumReportRepository reportRepository;
|
||||
@Mock private MemberRepository memberRepository;
|
||||
@Mock private NotificationService notificationService;
|
||||
@Mock private AuditService auditService;
|
||||
|
||||
@InjectMocks
|
||||
private ForumService forumService;
|
||||
|
||||
private ForumTopic topic;
|
||||
private ForumReply reply;
|
||||
private static final UUID TOPIC_ID = UUID.fromString("aabb1122-ccdd-3344-eeff-556677889900");
|
||||
private static final UUID REPLY_ID = UUID.fromString("11223344-5566-7788-99aa-bbccddeeff00");
|
||||
private static final UUID MODERATOR_ID = UUID.fromString("99998888-7777-6666-5555-444433332222");
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
topic = new ForumTopic(TEST_CLUB_ID, "Anbaufrage", "Welche Sorte empfiehlt ihr?", TEST_MEMBER_ID);
|
||||
topic.setId(TOPIC_ID);
|
||||
topic.setClubId(TEST_CLUB_ID);
|
||||
topic.setLocked(false);
|
||||
topic.setPinned(false);
|
||||
topic.setReplyCount(0);
|
||||
topic.setAuthorId(TEST_MEMBER_ID);
|
||||
|
||||
reply = new ForumReply(TOPIC_ID, TEST_CLUB_ID, "Ich empfehle Sorte A", TEST_USER_ID);
|
||||
reply.setId(REPLY_ID);
|
||||
reply.setCreatedAt(Instant.now());
|
||||
reply.setAuthorId(TEST_USER_ID);
|
||||
}
|
||||
|
||||
// === Topics ===
|
||||
|
||||
@Test
|
||||
void testCreateTopic_success() {
|
||||
when(topicRepository.save(any(ForumTopic.class))).thenAnswer(inv -> {
|
||||
ForumTopic t = inv.getArgument(0);
|
||||
t.setId(TOPIC_ID);
|
||||
return t;
|
||||
});
|
||||
when(memberRepository.findByClubId(TEST_CLUB_ID)).thenReturn(Collections.emptyList());
|
||||
|
||||
ForumTopic result = forumService.createTopic(TEST_CLUB_ID, "Neue Frage", "Inhalt", TEST_MEMBER_ID);
|
||||
|
||||
assertThat(result.getTitle()).isEqualTo("Neue Frage");
|
||||
verify(topicRepository).save(any(ForumTopic.class));
|
||||
verify(auditService).logEvent(eq(AuditEventType.FORUM_TOPIC_CREATED), eq(TEST_MEMBER_ID), any(), any());
|
||||
}
|
||||
|
||||
// === Replies ===
|
||||
|
||||
@Test
|
||||
void testCreateReply_success() {
|
||||
when(topicRepository.findById(TOPIC_ID)).thenReturn(Optional.of(topic));
|
||||
when(replyRepository.save(any(ForumReply.class))).thenAnswer(inv -> {
|
||||
ForumReply r = inv.getArgument(0);
|
||||
r.setId(REPLY_ID);
|
||||
return r;
|
||||
});
|
||||
when(topicRepository.save(any(ForumTopic.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
ForumReply result = forumService.createReply(TOPIC_ID, "Antwort", TEST_USER_ID);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
verify(replyRepository).save(any(ForumReply.class));
|
||||
verify(auditService).logEvent(eq(AuditEventType.FORUM_REPLY_CREATED), eq(TEST_USER_ID), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateReply_lockedTopic_throwsException() {
|
||||
topic.setLocked(true);
|
||||
when(topicRepository.findById(TOPIC_ID)).thenReturn(Optional.of(topic));
|
||||
|
||||
assertThatThrownBy(() -> forumService.createReply(TOPIC_ID, "Antwort", TEST_USER_ID))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("locked topic");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEditReply_withinTimeWindow_success() {
|
||||
reply.setCreatedAt(Instant.now().minus(Duration.ofMinutes(30))); // within 60-min window
|
||||
when(replyRepository.findById(REPLY_ID)).thenReturn(Optional.of(reply));
|
||||
when(replyRepository.save(any(ForumReply.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
ForumReply result = forumService.editReply(REPLY_ID, "Aktualisierte Antwort", TEST_USER_ID);
|
||||
|
||||
assertThat(result.getContent()).isEqualTo("Aktualisierte Antwort");
|
||||
assertThat(result.isEdited()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEditReply_pastTimeWindow_throwsException() {
|
||||
reply.setCreatedAt(Instant.now().minus(Duration.ofMinutes(61))); // past 60-min window
|
||||
when(replyRepository.findById(REPLY_ID)).thenReturn(Optional.of(reply));
|
||||
|
||||
assertThatThrownBy(() -> forumService.editReply(REPLY_ID, "Zu spät", TEST_USER_ID))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("Edit window");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEditReply_notAuthor_throwsException() {
|
||||
reply.setCreatedAt(Instant.now().minus(Duration.ofMinutes(5)));
|
||||
reply.setAuthorId(TEST_MEMBER_ID); // different from TEST_USER_ID
|
||||
when(replyRepository.findById(REPLY_ID)).thenReturn(Optional.of(reply));
|
||||
|
||||
assertThatThrownBy(() -> forumService.editReply(REPLY_ID, "Fremde Antwort", TEST_USER_ID))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("Only the author");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteReply_moderator_success() {
|
||||
when(replyRepository.findById(REPLY_ID)).thenReturn(Optional.of(reply));
|
||||
when(topicRepository.findById(TOPIC_ID)).thenReturn(Optional.of(topic));
|
||||
when(topicRepository.save(any(ForumTopic.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
forumService.deleteReply(REPLY_ID, MODERATOR_ID);
|
||||
|
||||
verify(replyRepository).delete(reply);
|
||||
verify(auditService).logEvent(eq(AuditEventType.FORUM_REPLY_DELETED), eq(MODERATOR_ID), any(), any());
|
||||
}
|
||||
|
||||
// === Reactions ===
|
||||
|
||||
@Test
|
||||
void testToggleReaction_add_success() {
|
||||
when(reactionRepository.findByTargetTypeAndTargetIdAndUserId(
|
||||
ForumTargetType.REPLY, REPLY_ID, TEST_USER_ID)).thenReturn(Optional.empty());
|
||||
when(reactionRepository.save(any(ForumReaction.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Optional<ForumReaction> result = forumService.toggleReaction(
|
||||
ForumTargetType.REPLY, REPLY_ID, TEST_USER_ID, ReactionType.THUMBS_UP);
|
||||
|
||||
assertThat(result).isPresent();
|
||||
assertThat(result.get().getReactionType()).isEqualTo(ReactionType.THUMBS_UP);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testToggleReaction_remove_sameReaction() {
|
||||
ForumReaction existing = new ForumReaction(ForumTargetType.REPLY, REPLY_ID, TEST_USER_ID, ReactionType.THUMBS_UP);
|
||||
when(reactionRepository.findByTargetTypeAndTargetIdAndUserId(
|
||||
ForumTargetType.REPLY, REPLY_ID, TEST_USER_ID)).thenReturn(Optional.of(existing));
|
||||
|
||||
Optional<ForumReaction> result = forumService.toggleReaction(
|
||||
ForumTargetType.REPLY, REPLY_ID, TEST_USER_ID, ReactionType.THUMBS_UP);
|
||||
|
||||
assertThat(result).isEmpty(); // toggled off
|
||||
verify(reactionRepository).delete(existing);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testToggleReaction_changeToDifferentType() {
|
||||
ForumReaction existing = new ForumReaction(ForumTargetType.REPLY, REPLY_ID, TEST_USER_ID, ReactionType.THUMBS_UP);
|
||||
when(reactionRepository.findByTargetTypeAndTargetIdAndUserId(
|
||||
ForumTargetType.REPLY, REPLY_ID, TEST_USER_ID)).thenReturn(Optional.of(existing));
|
||||
when(reactionRepository.save(any(ForumReaction.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Optional<ForumReaction> result = forumService.toggleReaction(
|
||||
ForumTargetType.REPLY, REPLY_ID, TEST_USER_ID, ReactionType.THUMBS_DOWN);
|
||||
|
||||
assertThat(result).isPresent();
|
||||
assertThat(result.get().getReactionType()).isEqualTo(ReactionType.THUMBS_DOWN);
|
||||
}
|
||||
|
||||
// === Pin / Unpin ===
|
||||
|
||||
@Test
|
||||
void testPinTopic_success() {
|
||||
when(topicRepository.findById(TOPIC_ID)).thenReturn(Optional.of(topic));
|
||||
when(topicRepository.save(any(ForumTopic.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
ForumTopic result = forumService.pinTopic(TOPIC_ID, MODERATOR_ID);
|
||||
|
||||
assertThat(result.isPinned()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUnpinTopic_success() {
|
||||
topic.setPinned(true);
|
||||
when(topicRepository.findById(TOPIC_ID)).thenReturn(Optional.of(topic));
|
||||
when(topicRepository.save(any(ForumTopic.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
ForumTopic result = forumService.unpinTopic(TOPIC_ID, MODERATOR_ID);
|
||||
|
||||
assertThat(result.isPinned()).isFalse();
|
||||
}
|
||||
|
||||
// === Report Content ===
|
||||
|
||||
@Test
|
||||
void testReportContent_success() {
|
||||
when(reportRepository.save(any(ForumReport.class))).thenAnswer(inv -> {
|
||||
ForumReport r = inv.getArgument(0);
|
||||
r.setId(UUID.randomUUID());
|
||||
return r;
|
||||
});
|
||||
|
||||
ForumReport result = forumService.reportContent(
|
||||
TEST_CLUB_ID, ForumTargetType.REPLY, REPLY_ID, TEST_MEMBER_ID, "Beleidigend");
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
verify(reportRepository).save(any(ForumReport.class));
|
||||
}
|
||||
|
||||
// === Lock / Unlock (close topic) ===
|
||||
|
||||
@Test
|
||||
void testLockTopic_preventsNewReplies() {
|
||||
when(topicRepository.findById(TOPIC_ID)).thenReturn(Optional.of(topic));
|
||||
when(topicRepository.save(any(ForumTopic.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
ForumTopic locked = forumService.lockTopic(TOPIC_ID, MODERATOR_ID);
|
||||
|
||||
assertThat(locked.isLocked()).isTrue();
|
||||
verify(auditService).logEvent(eq(AuditEventType.FORUM_TOPIC_LOCKED), eq(MODERATOR_ID), any(), any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.InfoBoardPost;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.entity.PostReadStatus;
|
||||
import de.cannamanage.domain.enums.AuditEventType;
|
||||
import de.cannamanage.domain.enums.InfoBoardCategory;
|
||||
import de.cannamanage.domain.enums.NotificationType;
|
||||
import de.cannamanage.service.repository.InfoBoardPostRepository;
|
||||
import de.cannamanage.service.repository.MemberRepository;
|
||||
import de.cannamanage.service.repository.PostReadStatusRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Unit tests for InfoBoardService — Schwarzes Brett (info board) CRUD & read tracking.
|
||||
*/
|
||||
class InfoBoardServiceTest extends AbstractServiceTest {
|
||||
|
||||
@Mock private InfoBoardPostRepository postRepository;
|
||||
@Mock private PostReadStatusRepository readStatusRepository;
|
||||
@Mock private MemberRepository memberRepository;
|
||||
@Mock private NotificationService notificationService;
|
||||
@Mock private AuditService auditService;
|
||||
|
||||
@InjectMocks
|
||||
private InfoBoardService infoBoardService;
|
||||
|
||||
private InfoBoardPost post;
|
||||
private static final UUID POST_ID = UUID.fromString("55556666-7777-8888-9999-aaaa0000bbbb");
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
post = new InfoBoardPost(TEST_CLUB_ID, "Wichtige Mitteilung", "Inhalt der Mitteilung",
|
||||
InfoBoardCategory.GENERAL, TEST_USER_ID);
|
||||
post.setId(POST_ID);
|
||||
post.setPinned(false);
|
||||
post.setArchived(false);
|
||||
}
|
||||
|
||||
// === Create Post ===
|
||||
|
||||
@Test
|
||||
void testCreatePost_general_success() {
|
||||
when(postRepository.save(any(InfoBoardPost.class))).thenAnswer(inv -> {
|
||||
InfoBoardPost p = inv.getArgument(0);
|
||||
p.setId(POST_ID);
|
||||
return p;
|
||||
});
|
||||
when(memberRepository.findAllByClubId(TEST_CLUB_ID)).thenReturn(Collections.emptyList());
|
||||
|
||||
InfoBoardPost result = infoBoardService.createPost(
|
||||
TEST_CLUB_ID, "Wichtige Mitteilung", "Inhalt",
|
||||
InfoBoardCategory.GENERAL, false, TEST_USER_ID);
|
||||
|
||||
assertThat(result.getTitle()).isEqualTo("Wichtige Mitteilung");
|
||||
assertThat(result.getCategory()).isEqualTo(InfoBoardCategory.GENERAL);
|
||||
verify(postRepository).save(any(InfoBoardPost.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreatePost_event_pinned() {
|
||||
when(postRepository.save(any(InfoBoardPost.class))).thenAnswer(inv -> {
|
||||
InfoBoardPost p = inv.getArgument(0);
|
||||
p.setId(POST_ID);
|
||||
return p;
|
||||
});
|
||||
when(memberRepository.findAllByClubId(TEST_CLUB_ID)).thenReturn(Collections.emptyList());
|
||||
|
||||
InfoBoardPost result = infoBoardService.createPost(
|
||||
TEST_CLUB_ID, "Erntefest", "Am Samstag",
|
||||
InfoBoardCategory.EVENT, true, TEST_USER_ID);
|
||||
|
||||
assertThat(result.isPinned()).isTrue();
|
||||
assertThat(result.getCategory()).isEqualTo(InfoBoardCategory.EVENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreatePost_notifiesMembers() {
|
||||
when(postRepository.save(any(InfoBoardPost.class))).thenAnswer(inv -> {
|
||||
InfoBoardPost p = inv.getArgument(0);
|
||||
p.setId(POST_ID);
|
||||
return p;
|
||||
});
|
||||
Member member = new Member();
|
||||
member.setId(TEST_MEMBER_ID);
|
||||
member.setUserId(TEST_USER_ID);
|
||||
when(memberRepository.findAllByClubId(TEST_CLUB_ID)).thenReturn(List.of(member));
|
||||
|
||||
infoBoardService.createPost(TEST_CLUB_ID, "News", "Content",
|
||||
InfoBoardCategory.GENERAL, false, TEST_USER_ID);
|
||||
|
||||
verify(notificationService).sendNotification(eq(TEST_USER_ID),
|
||||
eq(NotificationType.INFO_BOARD_POST), any(), any(), any());
|
||||
}
|
||||
|
||||
// === Toggle Pin ===
|
||||
|
||||
@Test
|
||||
void testTogglePin_unpinnedToPin() {
|
||||
post.setPinned(false);
|
||||
when(postRepository.findById(POST_ID)).thenReturn(Optional.of(post));
|
||||
when(postRepository.save(any(InfoBoardPost.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
InfoBoardPost result = infoBoardService.togglePin(POST_ID);
|
||||
|
||||
assertThat(result.isPinned()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTogglePin_pinnedToUnpin() {
|
||||
post.setPinned(true);
|
||||
when(postRepository.findById(POST_ID)).thenReturn(Optional.of(post));
|
||||
when(postRepository.save(any(InfoBoardPost.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
InfoBoardPost result = infoBoardService.togglePin(POST_ID);
|
||||
|
||||
assertThat(result.isPinned()).isFalse();
|
||||
}
|
||||
|
||||
// === Archive / Delete ===
|
||||
|
||||
@Test
|
||||
void testArchivePost_success() {
|
||||
when(postRepository.findById(POST_ID)).thenReturn(Optional.of(post));
|
||||
when(postRepository.save(any(InfoBoardPost.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
InfoBoardPost result = infoBoardService.archivePost(POST_ID);
|
||||
|
||||
assertThat(result.isArchived()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeletePost_success() {
|
||||
when(postRepository.findById(POST_ID)).thenReturn(Optional.of(post));
|
||||
|
||||
infoBoardService.deletePost(POST_ID);
|
||||
|
||||
verify(postRepository).delete(post);
|
||||
}
|
||||
|
||||
// === Update Post ===
|
||||
|
||||
@Test
|
||||
void testUpdatePost_success() {
|
||||
when(postRepository.findById(POST_ID)).thenReturn(Optional.of(post));
|
||||
when(postRepository.save(any(InfoBoardPost.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
InfoBoardPost result = infoBoardService.updatePost(POST_ID, "Neuer Titel", "Neuer Inhalt",
|
||||
InfoBoardCategory.RULE, true);
|
||||
|
||||
assertThat(result.getTitle()).isEqualTo("Neuer Titel");
|
||||
assertThat(result.getContent()).isEqualTo("Neuer Inhalt");
|
||||
assertThat(result.getCategory()).isEqualTo(InfoBoardCategory.RULE);
|
||||
assertThat(result.isPinned()).isTrue();
|
||||
}
|
||||
|
||||
// === Mark as Read ===
|
||||
|
||||
@Test
|
||||
void testMarkAsRead_firstTime_saves() {
|
||||
when(readStatusRepository.existsByPostIdAndMemberId(POST_ID, TEST_MEMBER_ID)).thenReturn(false);
|
||||
|
||||
infoBoardService.markAsRead(POST_ID, TEST_MEMBER_ID);
|
||||
|
||||
verify(readStatusRepository).save(any(PostReadStatus.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMarkAsRead_alreadyRead_noOp() {
|
||||
when(readStatusRepository.existsByPostIdAndMemberId(POST_ID, TEST_MEMBER_ID)).thenReturn(true);
|
||||
|
||||
infoBoardService.markAsRead(POST_ID, TEST_MEMBER_ID);
|
||||
|
||||
verify(readStatusRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
package de.cannamanage.service.bankimport;
|
||||
|
||||
import de.cannamanage.domain.entity.BankImportSession;
|
||||
import de.cannamanage.domain.entity.BankTransaction;
|
||||
import de.cannamanage.domain.entity.CsvColumnMapping;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.entity.Payment;
|
||||
import de.cannamanage.domain.enums.BankFormat;
|
||||
import de.cannamanage.domain.enums.ImportSessionStatus;
|
||||
import de.cannamanage.domain.enums.MatchStatus;
|
||||
import de.cannamanage.service.AbstractServiceTest;
|
||||
import de.cannamanage.service.AuditService;
|
||||
import de.cannamanage.service.FinanceService;
|
||||
import de.cannamanage.service.NotificationService;
|
||||
import de.cannamanage.service.repository.BankImportSessionRepository;
|
||||
import de.cannamanage.service.repository.BankTransactionRepository;
|
||||
import de.cannamanage.service.repository.MemberRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Sprint 11 — BankImportServiceTest verifies the orchestrator for bank statement import.
|
||||
* <p>
|
||||
* Tests cover: upload validation, duplicate detection, format detection delegation,
|
||||
* session lifecycle (PENDING → IN_REVIEW → COMPLETED / FAILED), GoBD immutability
|
||||
* enforcement, confirm/skip/assign operations, and file size limits.
|
||||
*/
|
||||
@DisplayName("BankImportService — Sprint 10 import orchestrator")
|
||||
class BankImportServiceTest extends AbstractServiceTest {
|
||||
|
||||
@Mock private BankImportSessionRepository sessionRepository;
|
||||
@Mock private BankTransactionRepository transactionRepository;
|
||||
@Mock private MemberRepository memberRepository;
|
||||
@Mock private BankStatementParserService parserService;
|
||||
@Mock private PaymentMatchingService matchingService;
|
||||
@Mock private FinanceService financeService;
|
||||
@Mock private AuditService auditService;
|
||||
@Mock private NotificationService notificationService;
|
||||
|
||||
@InjectMocks
|
||||
private BankImportService service;
|
||||
|
||||
private static final UUID SESSION_ID = UUID.fromString("99999999-9999-9999-9999-999999999999");
|
||||
private static final UUID TXN_ID = UUID.fromString("88888888-8888-8888-8888-888888888888");
|
||||
|
||||
private BankImportSession activeSession;
|
||||
private BankTransaction sampleTransaction;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
activeSession = new BankImportSession();
|
||||
activeSession.setId(SESSION_ID);
|
||||
activeSession.setClubId(TEST_CLUB_ID);
|
||||
activeSession.setStatus(ImportSessionStatus.IN_REVIEW);
|
||||
activeSession.setFilename("test.mt940");
|
||||
activeSession.setFormat(BankFormat.MT940);
|
||||
activeSession.setUploadedBy(TEST_USER_ID);
|
||||
activeSession.setConfirmedCount(0);
|
||||
activeSession.setSkippedCount(0);
|
||||
|
||||
sampleTransaction = new BankTransaction();
|
||||
sampleTransaction.setId(TXN_ID);
|
||||
sampleTransaction.setSessionId(SESSION_ID);
|
||||
sampleTransaction.setAmountCents(5000);
|
||||
sampleTransaction.setBookingDate(LocalDate.of(2026, 6, 15));
|
||||
sampleTransaction.setMatchStatus(MatchStatus.MATCHED);
|
||||
sampleTransaction.setMatchedMemberId(TEST_MEMBER_ID);
|
||||
sampleTransaction.setMatchConfidence(95);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Upload + Parse
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Upload and parse")
|
||||
class UploadAndParse {
|
||||
|
||||
@Test
|
||||
@DisplayName("#1 Upload valid file creates IN_REVIEW session")
|
||||
void testUploadAndParse_ValidFile_CreatesSession() throws IOException {
|
||||
MultipartFile file = mockFile("statement.mt940", "valid content".getBytes(), 100);
|
||||
|
||||
when(sessionRepository.existsByClubIdAndFileHash(eq(TEST_CLUB_ID), anyString())).thenReturn(false);
|
||||
when(parserService.detectFormat(anyString(), any(byte[].class))).thenReturn(BankFormat.MT940);
|
||||
when(parserService.parse(any(), anyString(), eq(BankFormat.MT940), any()))
|
||||
.thenReturn(new ParseResult(
|
||||
List.of(new ParsedTransaction(LocalDate.of(2026, 6, 15), LocalDate.of(2026, 6, 15),
|
||||
5000, "EUR", "Beitrag", "Max", "DE89370400440532013000", "REF1")),
|
||||
"DE89370400440532013000", LocalDate.of(2026, 6, 15), 100000, 105000, List.of()));
|
||||
when(matchingService.matchTransactions(any(), eq(TEST_CLUB_ID), any()))
|
||||
.thenReturn(List.of(sampleTransaction));
|
||||
when(sessionRepository.save(any(BankImportSession.class))).thenAnswer(inv -> {
|
||||
BankImportSession s = inv.getArgument(0);
|
||||
if (s.getId() == null) s.setId(SESSION_ID);
|
||||
return s;
|
||||
});
|
||||
|
||||
BankImportSession result = service.uploadAndParse(TEST_CLUB_ID, TEST_USER_ID, file, null);
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(ImportSessionStatus.IN_REVIEW);
|
||||
verify(sessionRepository, atLeastOnce()).save(any(BankImportSession.class));
|
||||
verify(auditService).log(any(), eq(TEST_USER_ID), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#2 Upload duplicate file (same hash) throws CONFLICT")
|
||||
void testUploadAndParse_DuplicateHash_ThrowsConflict() throws IOException {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(100L);
|
||||
when(file.getBytes()).thenReturn("duplicate content".getBytes());
|
||||
|
||||
when(sessionRepository.existsByClubIdAndFileHash(eq(TEST_CLUB_ID), anyString())).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.uploadAndParse(TEST_CLUB_ID, TEST_USER_ID, file, null))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("bereits importiert");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#3 Upload empty file throws BAD_REQUEST")
|
||||
void testUploadAndParse_EmptyFile_ThrowsBadRequest() {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.uploadAndParse(TEST_CLUB_ID, TEST_USER_ID, file, null))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("leer");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#4 Upload file exceeding max size throws PAYLOAD_TOO_LARGE")
|
||||
void testUploadAndParse_OversizedFile_ThrowsPayloadTooLarge() {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(BankImportService.MAX_FILE_SIZE_BYTES + 1);
|
||||
|
||||
assertThatThrownBy(() -> service.uploadAndParse(TEST_CLUB_ID, TEST_USER_ID, file, null))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("zu groß");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#5 Invalid format rejection throws BAD_REQUEST")
|
||||
void testUploadAndParse_UnrecognizedFormat_ThrowsBadRequest() throws IOException {
|
||||
MultipartFile file = mockFile("garbage.bin", "not a bank file".getBytes(), 100);
|
||||
|
||||
when(sessionRepository.existsByClubIdAndFileHash(eq(TEST_CLUB_ID), anyString())).thenReturn(false);
|
||||
when(parserService.detectFormat(anyString(), any(byte[].class)))
|
||||
.thenThrow(new BankStatementParserService.UnrecognizedFormatException("Unknown format"));
|
||||
when(sessionRepository.save(any(BankImportSession.class))).thenAnswer(inv -> {
|
||||
BankImportSession s = inv.getArgument(0);
|
||||
if (s.getId() == null) s.setId(SESSION_ID);
|
||||
return s;
|
||||
});
|
||||
|
||||
assertThatThrownBy(() -> service.uploadAndParse(TEST_CLUB_ID, TEST_USER_ID, file, null))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("nicht erkannt");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#6 File format auto-detection delegates to parserService")
|
||||
void testUploadAndParse_AutoDetectsFormat() throws IOException {
|
||||
MultipartFile file = mockFile("export.xml", "<?xml version=\"1.0\"?><BkToCstmrStmt/>".getBytes(), 100);
|
||||
|
||||
when(sessionRepository.existsByClubIdAndFileHash(eq(TEST_CLUB_ID), anyString())).thenReturn(false);
|
||||
when(parserService.detectFormat(anyString(), any(byte[].class))).thenReturn(BankFormat.CAMT053);
|
||||
when(parserService.parse(any(), anyString(), eq(BankFormat.CAMT053), any()))
|
||||
.thenReturn(new ParseResult(List.of(), null, null, null, null, List.of()));
|
||||
when(matchingService.matchTransactions(any(), eq(TEST_CLUB_ID), any()))
|
||||
.thenReturn(List.of());
|
||||
when(sessionRepository.save(any(BankImportSession.class))).thenAnswer(inv -> {
|
||||
BankImportSession s = inv.getArgument(0);
|
||||
if (s.getId() == null) s.setId(SESSION_ID);
|
||||
return s;
|
||||
});
|
||||
|
||||
service.uploadAndParse(TEST_CLUB_ID, TEST_USER_ID, file, null);
|
||||
|
||||
verify(parserService).detectFormat(eq("export.xml"), any(byte[].class));
|
||||
verify(parserService).parse(any(), eq("export.xml"), eq(BankFormat.CAMT053), any());
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Session lifecycle
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Session lifecycle")
|
||||
class SessionLifecycle {
|
||||
|
||||
@Test
|
||||
@DisplayName("#7 completeSession transitions to COMPLETED")
|
||||
void testCompleteSession_TransitionsToCompleted() {
|
||||
when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(activeSession));
|
||||
when(sessionRepository.save(any(BankImportSession.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
BankImportSession result = service.completeSession(SESSION_ID, TEST_USER_ID);
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(ImportSessionStatus.COMPLETED);
|
||||
assertThat(result.getCompletedAt()).isNotNull();
|
||||
verify(auditService).log(any(), eq(TEST_USER_ID), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#8 completeSession on COMPLETED session throws CONFLICT (GoBD)")
|
||||
void testCompleteSession_AlreadyCompleted_ThrowsConflict() {
|
||||
activeSession.setStatus(ImportSessionStatus.COMPLETED);
|
||||
when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(activeSession));
|
||||
|
||||
assertThatThrownBy(() -> service.completeSession(SESSION_ID, TEST_USER_ID))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("GoBD");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#9 Operations on FAILED session throw CONFLICT")
|
||||
void testMutation_FailedSession_ThrowsConflict() {
|
||||
activeSession.setStatus(ImportSessionStatus.FAILED);
|
||||
when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(activeSession));
|
||||
|
||||
assertThatThrownBy(() -> service.completeSession(SESSION_ID, TEST_USER_ID))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("fehlgeschlagen");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Confirm / skip / assign
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Confirm, skip, assign")
|
||||
class ConfirmSkipAssign {
|
||||
|
||||
@Test
|
||||
@DisplayName("#10 confirmMatch creates payment and sets CONFIRMED")
|
||||
void testConfirmMatch_ValidTransaction_CreatesPayment() {
|
||||
when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(activeSession));
|
||||
when(transactionRepository.findById(TXN_ID)).thenReturn(Optional.of(sampleTransaction));
|
||||
Member member = new Member();
|
||||
member.setId(TEST_MEMBER_ID);
|
||||
member.setClubId(TEST_CLUB_ID);
|
||||
when(memberRepository.findById(TEST_MEMBER_ID)).thenReturn(Optional.of(member));
|
||||
Payment payment = new Payment();
|
||||
payment.setId(TEST_PAYMENT_ID);
|
||||
when(financeService.recordPayment(any(), any(), anyInt(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(payment);
|
||||
when(transactionRepository.save(any(BankTransaction.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(sessionRepository.save(any(BankImportSession.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
BankTransaction result = service.confirmMatch(SESSION_ID, TXN_ID, TEST_MEMBER_ID, TEST_USER_ID);
|
||||
|
||||
assertThat(result.getMatchStatus()).isEqualTo(MatchStatus.CONFIRMED);
|
||||
assertThat(result.getMatchedPaymentId()).isEqualTo(TEST_PAYMENT_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#11 confirmMatch on already-confirmed transaction throws CONFLICT")
|
||||
void testConfirmMatch_AlreadyConfirmed_ThrowsConflict() {
|
||||
sampleTransaction.setMatchStatus(MatchStatus.CONFIRMED);
|
||||
when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(activeSession));
|
||||
when(transactionRepository.findById(TXN_ID)).thenReturn(Optional.of(sampleTransaction));
|
||||
|
||||
assertThatThrownBy(() -> service.confirmMatch(SESSION_ID, TXN_ID, TEST_MEMBER_ID, TEST_USER_ID))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("bereits bestätigt");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#12 skipTransaction marks as SKIPPED")
|
||||
void testSkipTransaction_SetsSkippedStatus() {
|
||||
when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(activeSession));
|
||||
when(transactionRepository.findById(TXN_ID)).thenReturn(Optional.of(sampleTransaction));
|
||||
when(transactionRepository.save(any(BankTransaction.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(sessionRepository.save(any(BankImportSession.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
BankTransaction result = service.skipTransaction(SESSION_ID, TXN_ID, "Nicht relevant", TEST_USER_ID);
|
||||
|
||||
assertThat(result.getMatchStatus()).isEqualTo(MatchStatus.SKIPPED);
|
||||
assertThat(result.getSkipReason()).isEqualTo("Nicht relevant");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#13 manualAssign sets member and 100% confidence")
|
||||
void testManualAssign_SetsMatchedWith100Confidence() {
|
||||
sampleTransaction.setMatchStatus(MatchStatus.UNMATCHED);
|
||||
when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(activeSession));
|
||||
when(transactionRepository.findById(TXN_ID)).thenReturn(Optional.of(sampleTransaction));
|
||||
Member member = new Member();
|
||||
member.setId(TEST_MEMBER_ID);
|
||||
member.setClubId(TEST_CLUB_ID);
|
||||
when(memberRepository.findById(TEST_MEMBER_ID)).thenReturn(Optional.of(member));
|
||||
when(transactionRepository.save(any(BankTransaction.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
BankTransaction result = service.manualAssign(SESSION_ID, TXN_ID, TEST_MEMBER_ID, TEST_USER_ID);
|
||||
|
||||
assertThat(result.getMatchStatus()).isEqualTo(MatchStatus.MATCHED);
|
||||
assertThat(result.getMatchConfidence()).isEqualTo(100);
|
||||
assertThat(result.getMatchedMemberId()).isEqualTo(TEST_MEMBER_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#14 confirmMatch rejects member from different club")
|
||||
void testConfirmMatch_WrongClub_ThrowsForbidden() {
|
||||
when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(activeSession));
|
||||
when(transactionRepository.findById(TXN_ID)).thenReturn(Optional.of(sampleTransaction));
|
||||
Member wrongClubMember = new Member();
|
||||
wrongClubMember.setId(TEST_MEMBER_ID);
|
||||
wrongClubMember.setClubId(UUID.fromString("77777777-7777-7777-7777-777777777777")); // different club
|
||||
when(memberRepository.findById(TEST_MEMBER_ID)).thenReturn(Optional.of(wrongClubMember));
|
||||
|
||||
assertThatThrownBy(() -> service.confirmMatch(SESSION_ID, TXN_ID, TEST_MEMBER_ID, TEST_USER_ID))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("nicht zum aktuellen Verein");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static MultipartFile mockFile(String filename, byte[] content, long size) throws IOException {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(size);
|
||||
when(file.getBytes()).thenReturn(content);
|
||||
when(file.getOriginalFilename()).thenReturn(filename);
|
||||
return file;
|
||||
}
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
package de.cannamanage.service.bankimport;
|
||||
|
||||
import de.cannamanage.domain.entity.CsvColumnMapping;
|
||||
import de.cannamanage.domain.enums.BankFormat;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Sprint 11 — BankStatementParserServiceTest verifies the façade that detects
|
||||
* bank statement formats and routes parsing to the correct parser.
|
||||
* <p>
|
||||
* Tests cover: format detection delegation, MT940/CAMT.053/CSV routing,
|
||||
* unknown format exception, null/empty input handling, and the detectAndParse
|
||||
* convenience method.
|
||||
*/
|
||||
@DisplayName("BankStatementParserService — format detection + routing façade")
|
||||
class BankStatementParserServiceTest {
|
||||
|
||||
private BankStatementParserService service;
|
||||
private Mt940Parser mt940Parser;
|
||||
private Camt053Parser camt053Parser;
|
||||
private CsvBankParser csvParser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mt940Parser = new Mt940Parser();
|
||||
camt053Parser = new Camt053Parser();
|
||||
csvParser = new CsvBankParser();
|
||||
service = new BankStatementParserService(List.of(mt940Parser, camt053Parser, csvParser));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Format detection
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Format detection")
|
||||
class FormatDetection {
|
||||
|
||||
@Test
|
||||
@DisplayName("#1 Detect MT940 format from content")
|
||||
void testDetectFormat_Mt940Content_ReturnsMt940() {
|
||||
byte[] content = ":20:STARTUMSE\n:25:50050201/0001234567\n:60F:C260601EUR100,00\n"
|
||||
.getBytes(StandardCharsets.ISO_8859_1);
|
||||
|
||||
BankFormat result = service.detectFormat("statement.sta", content);
|
||||
|
||||
assertThat(result).isEqualTo(BankFormat.MT940);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#2 Detect CAMT.053 format from XML content")
|
||||
void testDetectFormat_CamtContent_ReturnsCamt053() {
|
||||
byte[] content = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||
<BkToCstmrStmt></BkToCstmrStmt>
|
||||
</Document>
|
||||
""".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
BankFormat result = service.detectFormat("export.xml", content);
|
||||
|
||||
assertThat(result).isEqualTo(BankFormat.CAMT053);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#3 Detect CSV format from extension and content")
|
||||
void testDetectFormat_CsvContent_ReturnsCsv() {
|
||||
byte[] content = "Datum;Betrag;Verwendungszweck\n15.06.2026;50,00;Beitrag\n"
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
BankFormat result = service.detectFormat("umsaetze.csv", content);
|
||||
|
||||
assertThat(result).isEqualTo(BankFormat.CSV);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#4 Unknown format throws UnrecognizedFormatException")
|
||||
void testDetectFormat_UnknownContent_ThrowsException() {
|
||||
byte[] content = "TOTALLY RANDOM BINARY CONTENT 0x00 0xFF".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
assertThatThrownBy(() -> service.detectFormat("mystery.dat", content))
|
||||
.isInstanceOf(BankStatementParserService.UnrecognizedFormatException.class)
|
||||
.hasMessageContaining("mystery.dat");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Parse routing
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Parse routing")
|
||||
class ParseRouting {
|
||||
|
||||
@Test
|
||||
@DisplayName("#5 Null/empty input throws NullPointerException")
|
||||
void testParse_NullInput_Throws() {
|
||||
assertThatThrownBy(() -> service.parse(null, "test.xml", BankFormat.CAMT053, null))
|
||||
.isInstanceOf(NullPointerException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#6 CSV format without mapping throws IllegalArgumentException")
|
||||
void testParse_CsvWithoutMapping_Throws() {
|
||||
InputStream is = new ByteArrayInputStream("data".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
assertThatThrownBy(() -> service.parse(is, "test.csv", BankFormat.CSV, null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("csvMapping is required");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// detectAndParse convenience
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("detectAndParse convenience method")
|
||||
class DetectAndParse {
|
||||
|
||||
@Test
|
||||
@DisplayName("#7 detectAndParse routes MT940 content to MT940 parser")
|
||||
void testDetectAndParse_Mt940_RoutesCorrectly() {
|
||||
// Minimal MT940 that the parser can handle
|
||||
String mt940 = """
|
||||
:20:STARTUMSE
|
||||
:25:50050201/0001234567
|
||||
:28C:00001/001
|
||||
:60F:C260601EUR100,00
|
||||
:61:2606150615CR50,00NTRFNONREF//BANKREF
|
||||
:86:Mitgliedsbeitrag
|
||||
:62F:C260615EUR150,00
|
||||
""";
|
||||
byte[] content = mt940.getBytes(StandardCharsets.ISO_8859_1);
|
||||
|
||||
ParseResult result = service.detectAndParse(content, "export.sta", null);
|
||||
|
||||
assertThat(result.transactions()).isNotEmpty();
|
||||
assertThat(result.transactions().get(0).amountCents()).isEqualTo(5000);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#8 detectAndParse routes CAMT.053 to CAMT parser")
|
||||
void testDetectAndParse_Camt053_RoutesCorrectly() {
|
||||
String camt = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||
<BkToCstmrStmt>
|
||||
<Stmt>
|
||||
<Id>S1</Id>
|
||||
<Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct>
|
||||
<Ntry>
|
||||
<Amt Ccy="EUR">42.00</Amt>
|
||||
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||
<BookgDt><Dt>2026-06-15</Dt></BookgDt>
|
||||
<NtryRef>REF1</NtryRef>
|
||||
</Ntry>
|
||||
</Stmt>
|
||||
</BkToCstmrStmt>
|
||||
</Document>
|
||||
""";
|
||||
byte[] content = camt.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
ParseResult result = service.detectAndParse(content, "statement.xml", null);
|
||||
|
||||
assertThat(result.transactions()).hasSize(1);
|
||||
assertThat(result.transactions().get(0).amountCents()).isEqualTo(4200);
|
||||
assertThat(result.accountIban()).isEqualTo("DE89370400440532013000");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#9 supportedFormats returns all three formats")
|
||||
void testSupportedFormats_ReturnsAllThree() {
|
||||
assertThat(service.supportedFormats())
|
||||
.containsExactlyInAnyOrder(BankFormat.MT940, BankFormat.CAMT053, BankFormat.CSV);
|
||||
}
|
||||
}
|
||||
}
|
||||
+493
@@ -0,0 +1,493 @@
|
||||
package de.cannamanage.service.bankimport;
|
||||
|
||||
import de.cannamanage.domain.enums.BankFormat;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Sprint 11 — Camt053ParserTest verifies the ISO 20022 CAMT.053 XML parser.
|
||||
* <p>
|
||||
* Tests cover: happy-path parsing, multi-statement files, debit handling,
|
||||
* empty documents, XXE hardening, encoding, and date formats.
|
||||
*/
|
||||
@DisplayName("Camt053Parser — Sprint 10 CAMT.053 XML parser")
|
||||
class Camt053ParserTest {
|
||||
|
||||
private final Camt053Parser parser = new Camt053Parser();
|
||||
|
||||
// Minimal valid CAMT.053 template
|
||||
private static final String CAMT_HEADER = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||
<BkToCstmrStmt>
|
||||
""";
|
||||
private static final String CAMT_FOOTER = """
|
||||
</BkToCstmrStmt>
|
||||
</Document>
|
||||
""";
|
||||
|
||||
private static String stmt(String iban, String entries) {
|
||||
return """
|
||||
<Stmt>
|
||||
<Id>STMT001</Id>
|
||||
<Acct><Id><IBAN>%s</IBAN></Id></Acct>
|
||||
<Bal>
|
||||
<Tp><Cd>OPBD</Cd></Tp>
|
||||
<Amt Ccy="EUR">1000.00</Amt>
|
||||
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||
<Dt><Dt>2026-06-01</Dt></Dt>
|
||||
</Bal>
|
||||
<Bal>
|
||||
<Tp><Cd>CLBD</Cd></Tp>
|
||||
<Amt Ccy="EUR">1050.00</Amt>
|
||||
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||
<Dt><Dt>2026-06-15</Dt></Dt>
|
||||
</Bal>
|
||||
%s
|
||||
</Stmt>
|
||||
""".formatted(iban, entries);
|
||||
}
|
||||
|
||||
private static String entry(String amount, String cdtDbt, String date, String ref, String name) {
|
||||
return """
|
||||
<Ntry>
|
||||
<Amt Ccy="EUR">%s</Amt>
|
||||
<CdtDbtInd>%s</CdtDbtInd>
|
||||
<BookgDt><Dt>%s</Dt></BookgDt>
|
||||
<ValDt><Dt>%s</Dt></ValDt>
|
||||
<NtryRef>%s</NtryRef>
|
||||
<NtryDtls><TxDtls>
|
||||
<RmtInf><Ustrd>Mitgliedsbeitrag Juni</Ustrd></RmtInf>
|
||||
<RltdPties><Dbtr><Nm>%s</Nm></Dbtr></RltdPties>
|
||||
</TxDtls></NtryDtls>
|
||||
</Ntry>
|
||||
""".formatted(amount, cdtDbt, date, date, ref, name);
|
||||
}
|
||||
|
||||
private ParseResult parse(String xml) {
|
||||
InputStream is = new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8));
|
||||
return parser.parse(is, "test.xml", null);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Format detection
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Format detection")
|
||||
class FormatDetection {
|
||||
|
||||
@Test
|
||||
@DisplayName("#1 getSupportedFormat returns CAMT053")
|
||||
void testGetSupportedFormat_ReturnsCamt053() {
|
||||
assertThat(parser.getSupportedFormat()).isEqualTo(BankFormat.CAMT053);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#2 canParse: null/empty bytes → false")
|
||||
void testCanParse_EmptyOrNull_ReturnsFalse() {
|
||||
assertThat(parser.canParse("test.xml", null)).isFalse();
|
||||
assertThat(parser.canParse("test.xml", new byte[0])).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#3 canParse: XML with BkToCstmrStmt → true")
|
||||
void testCanParse_WithBkToCstmrStmt_ReturnsTrue() {
|
||||
byte[] header = CAMT_HEADER.getBytes(StandardCharsets.UTF_8);
|
||||
assertThat(parser.canParse("statement.xml", header)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#4 canParse: XML with camt.053 namespace → true")
|
||||
void testCanParse_WithCamtNamespace_ReturnsTrue() {
|
||||
byte[] header = "<?xml version=\"1.0\"?><Doc xmlns=\"urn:iso:std:iso:20022:tech:xsd:camt.053.001.08\">".getBytes(StandardCharsets.UTF_8);
|
||||
assertThat(parser.canParse("export.xml", header)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#5 canParse: non-XML content → false")
|
||||
void testCanParse_NonXml_ReturnsFalse() {
|
||||
byte[] header = ":20:STARTUMSE\n:25:50050201".getBytes(StandardCharsets.ISO_8859_1);
|
||||
assertThat(parser.canParse("statement.sta", header)).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Happy path
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Happy path parsing")
|
||||
class HappyPath {
|
||||
|
||||
@Test
|
||||
@DisplayName("#6 Parse valid single-entry CAMT.053")
|
||||
void testParse_ValidSingleEntry_ReturnsOneTransaction() {
|
||||
String xml = CAMT_HEADER
|
||||
+ stmt("DE89370400440532013000", entry("50.00", "CRDT", "2026-06-10", "REF001", "Max Mustermann"))
|
||||
+ CAMT_FOOTER;
|
||||
|
||||
ParseResult result = parse(xml);
|
||||
|
||||
assertThat(result.transactions()).hasSize(1);
|
||||
assertThat(result.accountIban()).isEqualTo("DE89370400440532013000");
|
||||
assertThat(result.openingBalanceCents()).isEqualTo(100000);
|
||||
assertThat(result.closingBalanceCents()).isEqualTo(105000);
|
||||
|
||||
ParsedTransaction tx = result.transactions().get(0);
|
||||
assertThat(tx.amountCents()).isEqualTo(5000);
|
||||
assertThat(tx.bookingDate()).isEqualTo(LocalDate.of(2026, 6, 10));
|
||||
assertThat(tx.currency()).isEqualTo("EUR");
|
||||
assertThat(tx.bankReference()).isEqualTo("REF001");
|
||||
assertThat(tx.referenceText()).isEqualTo("Mitgliedsbeitrag Juni");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#7 Parse multi-statement file")
|
||||
void testParse_MultiStatement_AggregatesEntries() {
|
||||
String xml = CAMT_HEADER
|
||||
+ stmt("DE11111111111111111111", entry("25.00", "CRDT", "2026-06-01", "R1", "Alice"))
|
||||
+ stmt("DE22222222222222222222", entry("30.00", "CRDT", "2026-06-02", "R2", "Bob"))
|
||||
+ CAMT_FOOTER;
|
||||
|
||||
ParseResult result = parse(xml);
|
||||
|
||||
// Both statements' entries are collected
|
||||
assertThat(result.transactions()).hasSize(2);
|
||||
// First IBAN encountered is kept
|
||||
assertThat(result.accountIban()).isEqualTo("DE11111111111111111111");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#8 Parse with multiple entries in one statement")
|
||||
void testParse_MultipleEntries_ReturnsAll() {
|
||||
String entries = entry("10.00", "CRDT", "2026-06-05", "A", "Alice")
|
||||
+ entry("20.00", "CRDT", "2026-06-06", "B", "Bob")
|
||||
+ entry("30.00", "CRDT", "2026-06-07", "C", "Carol");
|
||||
String xml = CAMT_HEADER + stmt("DE89370400440532013000", entries) + CAMT_FOOTER;
|
||||
|
||||
ParseResult result = parse(xml);
|
||||
|
||||
assertThat(result.transactions()).hasSize(3);
|
||||
assertThat(result.transactions().get(0).amountCents()).isEqualTo(1000);
|
||||
assertThat(result.transactions().get(1).amountCents()).isEqualTo(2000);
|
||||
assertThat(result.transactions().get(2).amountCents()).isEqualTo(3000);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Debit / negative amounts
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Debit handling")
|
||||
class DebitHandling {
|
||||
|
||||
@Test
|
||||
@DisplayName("#9 Negative amount for debit entries")
|
||||
void testParse_DebitEntry_NegativeAmount() {
|
||||
String xml = CAMT_HEADER
|
||||
+ stmt("DE89370400440532013000", entry("75.50", "DBIT", "2026-06-12", "DEBIT1", "Stromversorger"))
|
||||
+ CAMT_FOOTER;
|
||||
|
||||
ParseResult result = parse(xml);
|
||||
|
||||
assertThat(result.transactions()).hasSize(1);
|
||||
assertThat(result.transactions().get(0).amountCents()).isEqualTo(-7550);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Edge cases
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Edge cases")
|
||||
class EdgeCases {
|
||||
|
||||
@Test
|
||||
@DisplayName("#10 Empty document (no entries)")
|
||||
void testParse_EmptyDocument_NoTransactions() {
|
||||
String xml = CAMT_HEADER + """
|
||||
<Stmt>
|
||||
<Id>EMPTY</Id>
|
||||
<Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct>
|
||||
</Stmt>
|
||||
""" + CAMT_FOOTER;
|
||||
|
||||
ParseResult result = parse(xml);
|
||||
|
||||
assertThat(result.transactions()).isEmpty();
|
||||
assertThat(result.warnings()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#11 Missing mandatory fields produces warning, not crash")
|
||||
void testParse_MissingMandatoryFields_WarningNotCrash() {
|
||||
// Entry without CdtDbtInd — should be skipped with a warning
|
||||
String xml = CAMT_HEADER + """
|
||||
<Stmt>
|
||||
<Id>S1</Id>
|
||||
<Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct>
|
||||
<Ntry>
|
||||
<Amt Ccy="EUR">50.00</Amt>
|
||||
<BookgDt><Dt>2026-06-10</Dt></BookgDt>
|
||||
<NtryRef>REF_INCOMPLETE</NtryRef>
|
||||
</Ntry>
|
||||
</Stmt>
|
||||
""" + CAMT_FOOTER;
|
||||
|
||||
ParseResult result = parse(xml);
|
||||
|
||||
assertThat(result.transactions()).isEmpty();
|
||||
assertThat(result.warnings()).hasSize(1);
|
||||
assertThat(result.warnings().get(0)).contains("missing required fields");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#12 Currency code from Ccy attribute")
|
||||
void testParse_CurrencyCode_ExtractedFromAttribute() {
|
||||
String xml = CAMT_HEADER + """
|
||||
<Stmt>
|
||||
<Id>S1</Id>
|
||||
<Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct>
|
||||
<Ntry>
|
||||
<Amt Ccy="CHF">100.00</Amt>
|
||||
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||
<BookgDt><Dt>2026-06-10</Dt></BookgDt>
|
||||
<NtryRef>CHF_REF</NtryRef>
|
||||
</Ntry>
|
||||
</Stmt>
|
||||
""" + CAMT_FOOTER;
|
||||
|
||||
ParseResult result = parse(xml);
|
||||
|
||||
assertThat(result.transactions()).hasSize(1);
|
||||
assertThat(result.transactions().get(0).currency()).isEqualTo("CHF");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#13 Date parsing with datetime format (T-suffix stripped)")
|
||||
void testParse_DateWithTimePortion_ParsesCorrectly() {
|
||||
// Use dateTime style "2026-06-10T14:30:00" in BookgDt
|
||||
String xml = CAMT_HEADER + """
|
||||
<Stmt>
|
||||
<Id>S1</Id>
|
||||
<Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct>
|
||||
<Ntry>
|
||||
<Amt Ccy="EUR">42.00</Amt>
|
||||
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||
<BookgDt><Dt>2026-06-10T14:30:00</Dt></BookgDt>
|
||||
<NtryRef>DATETIME_REF</NtryRef>
|
||||
</Ntry>
|
||||
</Stmt>
|
||||
""" + CAMT_FOOTER;
|
||||
|
||||
ParseResult result = parse(xml);
|
||||
|
||||
assertThat(result.transactions()).hasSize(1);
|
||||
assertThat(result.transactions().get(0).bookingDate()).isEqualTo(LocalDate.of(2026, 6, 10));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#14 UTF-8 with German special characters (ü, ö, ä, ß)")
|
||||
void testParse_Utf8SpecialChars_PreservedInOutput() {
|
||||
String xml = CAMT_HEADER + """
|
||||
<Stmt>
|
||||
<Id>S1</Id>
|
||||
<Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct>
|
||||
<Ntry>
|
||||
<Amt Ccy="EUR">15.00</Amt>
|
||||
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||
<BookgDt><Dt>2026-06-10</Dt></BookgDt>
|
||||
<NtryRef>UMLAUT</NtryRef>
|
||||
<NtryDtls><TxDtls>
|
||||
<RmtInf><Ustrd>Überweisung für Größe</Ustrd></RmtInf>
|
||||
<RltdPties><Dbtr><Nm>Jürgen Müller-Straße</Nm></Dbtr></RltdPties>
|
||||
</TxDtls></NtryDtls>
|
||||
</Ntry>
|
||||
</Stmt>
|
||||
""" + CAMT_FOOTER;
|
||||
|
||||
ParseResult result = parse(xml);
|
||||
|
||||
assertThat(result.transactions()).hasSize(1);
|
||||
ParsedTransaction tx = result.transactions().get(0);
|
||||
assertThat(tx.referenceText()).isEqualTo("Überweisung für Größe");
|
||||
assertThat(tx.counterpartyName()).isEqualTo("Jürgen Müller-Straße");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// XXE hardening (Security)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("XXE hardening")
|
||||
class XxeHardening {
|
||||
|
||||
@Test
|
||||
@DisplayName("#15 XXE prevention: DOCTYPE entity injection — entity not resolved")
|
||||
void testParse_XxeDoctype_EntityNotResolved() {
|
||||
// With SUPPORT_DTD=false the StAX parser silently ignores DTDs.
|
||||
// The security guarantee: /etc/passwd content is never exposed.
|
||||
String xxeXml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE foo [
|
||||
<!ENTITY xxe SYSTEM "file:///etc/passwd">
|
||||
]>
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||
<BkToCstmrStmt>
|
||||
<Stmt>
|
||||
<Id>CLEAN</Id>
|
||||
<Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct>
|
||||
<Ntry>
|
||||
<Amt Ccy="EUR">10.00</Amt>
|
||||
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||
<BookgDt><Dt>2026-06-10</Dt></BookgDt>
|
||||
<NtryRef>SAFE_REF</NtryRef>
|
||||
</Ntry>
|
||||
</Stmt>
|
||||
</BkToCstmrStmt>
|
||||
</Document>
|
||||
""";
|
||||
|
||||
try {
|
||||
ParseResult result = parse(xxeXml);
|
||||
// Parsed successfully — verify no sensitive file content leaked
|
||||
assertThat(result.accountIban()).doesNotContain("root:");
|
||||
for (ParsedTransaction tx : result.transactions()) {
|
||||
assertThat(tx.bankReference()).doesNotContain("root:");
|
||||
assertThat(tx.referenceText() == null ? "" : tx.referenceText()).doesNotContain("root:");
|
||||
}
|
||||
} catch (BankStatementParseException e) {
|
||||
// Throwing is also acceptable — DTD was rejected outright
|
||||
assertThat(e.getMessage()).contains("XML");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#16 Billion laughs attack — entities not expanded")
|
||||
void testParse_BillionLaughs_EntitiesNotExpanded() {
|
||||
// With DTD support disabled, recursive entity expansion cannot happen.
|
||||
String billionLaughs = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE lolz [
|
||||
<!ENTITY lol "lol">
|
||||
<!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
|
||||
<!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
|
||||
]>
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||
<BkToCstmrStmt>
|
||||
<Stmt>
|
||||
<Id>SAFE</Id>
|
||||
<Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct>
|
||||
</Stmt>
|
||||
</BkToCstmrStmt>
|
||||
</Document>
|
||||
""";
|
||||
|
||||
try {
|
||||
ParseResult result = parse(billionLaughs);
|
||||
// If it parses, the entities were NOT expanded (no memory bomb)
|
||||
assertThat(result).isNotNull();
|
||||
} catch (BankStatementParseException e) {
|
||||
// Throwing is also acceptable
|
||||
assertThat(e.getMessage()).contains("XML");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#17 SSRF via external entity — entity not resolved")
|
||||
void testParse_SsrfExternalEntity_EntityNotResolved() {
|
||||
String ssrfXml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE foo [
|
||||
<!ENTITY xxe SYSTEM "http://evil.com/secret">
|
||||
]>
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||
<BkToCstmrStmt>
|
||||
<Stmt>
|
||||
<Id>SAFE</Id>
|
||||
<Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct>
|
||||
<Ntry>
|
||||
<Amt Ccy="EUR">5.00</Amt>
|
||||
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||
<BookgDt><Dt>2026-06-10</Dt></BookgDt>
|
||||
<NtryRef>SAFE</NtryRef>
|
||||
</Ntry>
|
||||
</Stmt>
|
||||
</BkToCstmrStmt>
|
||||
</Document>
|
||||
""";
|
||||
|
||||
try {
|
||||
ParseResult result = parse(ssrfXml);
|
||||
// No external content fetched — entities remain unresolved
|
||||
assertThat(result.transactions()).isNotNull();
|
||||
for (ParsedTransaction tx : result.transactions()) {
|
||||
assertThat(tx.bankReference()).doesNotContain("evil");
|
||||
}
|
||||
} catch (BankStatementParseException e) {
|
||||
// Throwing is also acceptable
|
||||
assertThat(e.getMessage()).contains("XML");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Performance
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Performance")
|
||||
class Performance {
|
||||
|
||||
@Test
|
||||
@DisplayName("#18 Large file (500 entries) completes within 2 seconds")
|
||||
void testParse_LargeFile_CompletesWithinTimeout() {
|
||||
StringBuilder xml = new StringBuilder(CAMT_HEADER);
|
||||
xml.append("<Stmt><Id>LARGE</Id><Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct>");
|
||||
for (int i = 0; i < 500; i++) {
|
||||
xml.append(entry(String.valueOf(i + 1) + ".00", "CRDT",
|
||||
"2026-06-" + String.format("%02d", (i % 28) + 1), "REF" + i, "Member" + i));
|
||||
}
|
||||
xml.append("</Stmt>");
|
||||
xml.append(CAMT_FOOTER);
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
ParseResult result = parse(xml.toString());
|
||||
long elapsed = System.currentTimeMillis() - start;
|
||||
|
||||
assertThat(result.transactions()).hasSize(500);
|
||||
assertThat(elapsed).isLessThan(2000);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Unit: parseAmountToCents
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("parseAmountToCents")
|
||||
class AmountParsing {
|
||||
|
||||
@Test
|
||||
@DisplayName("#19 Standard amounts")
|
||||
void testParseAmountToCents_StandardAmounts() {
|
||||
assertThat(Camt053Parser.parseAmountToCents("50.00")).isEqualTo(5000);
|
||||
assertThat(Camt053Parser.parseAmountToCents("1234.56")).isEqualTo(123456);
|
||||
assertThat(Camt053Parser.parseAmountToCents("0.99")).isEqualTo(99);
|
||||
assertThat(Camt053Parser.parseAmountToCents("100")).isEqualTo(10000);
|
||||
}
|
||||
}
|
||||
}
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
package de.cannamanage.service.bankimport;
|
||||
|
||||
import de.cannamanage.domain.entity.CsvColumnMapping;
|
||||
import de.cannamanage.domain.enums.BankFormat;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Sprint 11 — CsvBankParserTest verifies the generic CSV bank statement parser.
|
||||
* <p>
|
||||
* CSV exports vary wildly by bank: delimiter, encoding, column layout, header rows.
|
||||
* This parser relies on {@link CsvColumnMapping} for configuration. Tests cover
|
||||
* semicolons, quoted fields, BOM handling, tab-separated, empty lines, and encoding.
|
||||
*/
|
||||
@DisplayName("CsvBankParser — Sprint 10 generic CSV bank parser")
|
||||
class CsvBankParserTest {
|
||||
|
||||
private final CsvBankParser parser = new CsvBankParser();
|
||||
|
||||
/** Standard German Sparkasse-style mapping: semicolon, dd.MM.yyyy, comma decimal. */
|
||||
private CsvColumnMapping sparkasseMapping;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
sparkasseMapping = new CsvColumnMapping();
|
||||
sparkasseMapping.setName("Sparkasse Export");
|
||||
sparkasseMapping.setDateColumn(0);
|
||||
sparkasseMapping.setAmountColumn(1);
|
||||
sparkasseMapping.setReferenceColumn(2);
|
||||
sparkasseMapping.setCounterpartyColumn(3);
|
||||
sparkasseMapping.setIbanColumn(4);
|
||||
sparkasseMapping.setDelimiter(";");
|
||||
sparkasseMapping.setDateFormat("dd.MM.yyyy");
|
||||
sparkasseMapping.setDecimalSeparator(",");
|
||||
sparkasseMapping.setSkipHeaderRows(1);
|
||||
sparkasseMapping.setEncoding("UTF-8");
|
||||
}
|
||||
|
||||
private ParseResult parse(String csv) {
|
||||
return parse(csv, sparkasseMapping);
|
||||
}
|
||||
|
||||
private ParseResult parse(String csv, CsvColumnMapping mapping) {
|
||||
InputStream is = new ByteArrayInputStream(csv.getBytes(StandardCharsets.UTF_8));
|
||||
return parser.parse(is, "test.csv", mapping);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Format detection
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Format detection")
|
||||
class FormatDetection {
|
||||
|
||||
@Test
|
||||
@DisplayName("#1 getSupportedFormat returns CSV")
|
||||
void testGetSupportedFormat_ReturnsCsv() {
|
||||
assertThat(parser.getSupportedFormat()).isEqualTo(BankFormat.CSV);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#2 canParse: .csv extension → true")
|
||||
void testCanParse_CsvExtension_ReturnsTrue() {
|
||||
byte[] header = "Datum;Betrag;Verwendungszweck\n".getBytes(StandardCharsets.UTF_8);
|
||||
assertThat(parser.canParse("umsaetze.csv", header)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#3 canParse: null filename or bytes → false")
|
||||
void testCanParse_NullInputs_ReturnsFalse() {
|
||||
assertThat(parser.canParse(null, new byte[]{1})).isFalse();
|
||||
assertThat(parser.canParse("file.csv", null)).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Happy path: standard CSV parsing
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Happy path parsing")
|
||||
class HappyPath {
|
||||
|
||||
@Test
|
||||
@DisplayName("#4 Parse valid CSV with standard semicolon columns")
|
||||
void testParse_ValidSemicolonCsv_ReturnsTransactions() {
|
||||
String csv = """
|
||||
Datum;Betrag;Verwendungszweck;Name;IBAN
|
||||
15.06.2026;50,00;Mitgliedsbeitrag;Max Mustermann;DE89370400440532013000
|
||||
14.06.2026;-30,00;Stromrechnung;Stadtwerke;DE11111111111111111111
|
||||
""";
|
||||
|
||||
ParseResult result = parse(csv);
|
||||
|
||||
assertThat(result.transactions()).hasSize(2);
|
||||
ParsedTransaction tx1 = result.transactions().get(0);
|
||||
assertThat(tx1.bookingDate()).isEqualTo(LocalDate.of(2026, 6, 15));
|
||||
assertThat(tx1.amountCents()).isEqualTo(5000);
|
||||
assertThat(tx1.referenceText()).isEqualTo("Mitgliedsbeitrag");
|
||||
assertThat(tx1.counterpartyName()).isEqualTo("Max Mustermann");
|
||||
assertThat(tx1.counterpartyIban()).isEqualTo("DE89370400440532013000");
|
||||
|
||||
ParsedTransaction tx2 = result.transactions().get(1);
|
||||
assertThat(tx2.amountCents()).isEqualTo(-3000);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#5 Quoted fields with embedded separators")
|
||||
void testParse_QuotedFieldsWithSeparators_ParsedCorrectly() {
|
||||
String csv = """
|
||||
Datum;Betrag;Verwendungszweck;Name;IBAN
|
||||
10.06.2026;100,00;"Beitrag; Juni 2026";Hans Schmidt;DE89370400440532013000
|
||||
""";
|
||||
|
||||
ParseResult result = parse(csv);
|
||||
|
||||
assertThat(result.transactions()).hasSize(1);
|
||||
assertThat(result.transactions().get(0).referenceText()).isEqualTo("Beitrag; Juni 2026");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#6 Tab-separated variant")
|
||||
void testParse_TabSeparated_ParsedCorrectly() {
|
||||
CsvColumnMapping tabMapping = new CsvColumnMapping();
|
||||
tabMapping.setName("Tab-separated");
|
||||
tabMapping.setDateColumn(0);
|
||||
tabMapping.setAmountColumn(1);
|
||||
tabMapping.setReferenceColumn(2);
|
||||
tabMapping.setDelimiter("\\t");
|
||||
tabMapping.setDateFormat("dd.MM.yyyy");
|
||||
tabMapping.setDecimalSeparator(",");
|
||||
tabMapping.setSkipHeaderRows(1);
|
||||
tabMapping.setEncoding("UTF-8");
|
||||
|
||||
String csv = "Datum\tBetrag\tVerwendungszweck\n"
|
||||
+ "15.06.2026\t42,50\tMitgliedsbeitrag\n";
|
||||
|
||||
ParseResult result = parse(csv, tabMapping);
|
||||
|
||||
assertThat(result.transactions()).hasSize(1);
|
||||
assertThat(result.transactions().get(0).amountCents()).isEqualTo(4250);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Edge cases
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Edge cases")
|
||||
class EdgeCases {
|
||||
|
||||
@Test
|
||||
@DisplayName("#7 BOM (byte order mark) at start of file")
|
||||
void testParse_Bom_HandledGracefully() {
|
||||
// UTF-8 BOM: EF BB BF — skip it by using a mapping that skips 1 header row
|
||||
// The BOM will be on the header row which gets skipped
|
||||
String csvWithBom = "\uFEFF" + """
|
||||
Datum;Betrag;Ref
|
||||
15.06.2026;25,00;Beitrag
|
||||
""";
|
||||
|
||||
CsvColumnMapping mapping = new CsvColumnMapping();
|
||||
mapping.setName("BOM test");
|
||||
mapping.setDateColumn(0);
|
||||
mapping.setAmountColumn(1);
|
||||
mapping.setReferenceColumn(2);
|
||||
mapping.setDelimiter(";");
|
||||
mapping.setDateFormat("dd.MM.yyyy");
|
||||
mapping.setDecimalSeparator(",");
|
||||
mapping.setSkipHeaderRows(1);
|
||||
mapping.setEncoding("UTF-8");
|
||||
|
||||
ParseResult result = parse(csvWithBom, mapping);
|
||||
|
||||
assertThat(result.transactions()).hasSize(1);
|
||||
assertThat(result.transactions().get(0).amountCents()).isEqualTo(2500);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#8 Empty lines handling")
|
||||
void testParse_EmptyLines_Ignored() {
|
||||
String csv = """
|
||||
Datum;Betrag;Ref;Name;IBAN
|
||||
15.06.2026;10,00;Ref1;Alice;DE11111111111111111111
|
||||
|
||||
16.06.2026;20,00;Ref2;Bob;DE22222222222222222222
|
||||
|
||||
""";
|
||||
|
||||
ParseResult result = parse(csv);
|
||||
|
||||
assertThat(result.transactions()).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#9 Header-only file (no data rows)")
|
||||
void testParse_HeaderOnly_EmptyResult() {
|
||||
String csv = "Datum;Betrag;Verwendungszweck;Name;IBAN\n";
|
||||
|
||||
ParseResult result = parse(csv);
|
||||
|
||||
assertThat(result.transactions()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#10 Null mapping throws BankStatementParseException")
|
||||
void testParse_NullMapping_Throws() {
|
||||
InputStream is = new ByteArrayInputStream("data".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
assertThatThrownBy(() -> parser.parse(is, "test.csv", null))
|
||||
.isInstanceOf(BankStatementParseException.class)
|
||||
.hasMessageContaining("CsvColumnMapping");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#11 Large file (1000 rows) completes without error")
|
||||
void testParse_LargeFile_CompletesSuccessfully() {
|
||||
StringBuilder csv = new StringBuilder("Datum;Betrag;Ref;Name;IBAN\n");
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
csv.append(String.format("15.06.2026;%d,00;REF%d;Member%d;DE89370400440532013000%n",
|
||||
i + 1, i, i));
|
||||
}
|
||||
|
||||
ParseResult result = parse(csv.toString());
|
||||
|
||||
assertThat(result.transactions()).hasSize(1000);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#12 Wrong encoding detection falls back to ISO-8859-1")
|
||||
void testParse_UnknownEncoding_FallsBackToIso() {
|
||||
CsvColumnMapping mapping = new CsvColumnMapping();
|
||||
mapping.setName("Bad encoding");
|
||||
mapping.setDateColumn(0);
|
||||
mapping.setAmountColumn(1);
|
||||
mapping.setDelimiter(";");
|
||||
mapping.setDateFormat("dd.MM.yyyy");
|
||||
mapping.setDecimalSeparator(",");
|
||||
mapping.setSkipHeaderRows(0);
|
||||
mapping.setEncoding("TOTALLY-INVALID-CHARSET");
|
||||
|
||||
// ISO-8859-1 encoded content should still parse fine with fallback
|
||||
String csv = "15.06.2026;99,00\n";
|
||||
InputStream is = new ByteArrayInputStream(csv.getBytes(StandardCharsets.ISO_8859_1));
|
||||
|
||||
ParseResult result = parser.parse(is, "test.csv", mapping);
|
||||
|
||||
assertThat(result.transactions()).hasSize(1);
|
||||
assertThat(result.transactions().get(0).amountCents()).isEqualTo(9900);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#13 Trailing newlines don't produce extra transactions")
|
||||
void testParse_TrailingNewlines_NoExtraTransactions() {
|
||||
String csv = "Datum;Betrag;Ref;Name;IBAN\n"
|
||||
+ "15.06.2026;50,00;Ref1;Alice;DE11111111111111111111\n"
|
||||
+ "\n\n\n";
|
||||
|
||||
ParseResult result = parse(csv);
|
||||
|
||||
assertThat(result.transactions()).hasSize(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Unit: parseAmount
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("parseAmount")
|
||||
class AmountParsing {
|
||||
|
||||
@Test
|
||||
@DisplayName("#14 German amounts with comma decimal separator")
|
||||
void testParseAmount_GermanFormat() {
|
||||
assertThat(CsvBankParser.parseAmount("1.234,56", ',')).isEqualTo(123456);
|
||||
assertThat(CsvBankParser.parseAmount("-30,00", ',')).isEqualTo(-3000);
|
||||
assertThat(CsvBankParser.parseAmount("100", ',')).isEqualTo(10000);
|
||||
assertThat(CsvBankParser.parseAmount("0,5", ',')).isEqualTo(50);
|
||||
assertThat(CsvBankParser.parseAmount("+42,99", ',')).isEqualTo(4299);
|
||||
}
|
||||
}
|
||||
}
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
package de.cannamanage.service.bankimport;
|
||||
|
||||
import de.cannamanage.domain.enums.BankFormat;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Sprint 11 — Mt940ParserTest verifies the MT940 SWIFT statement parser.
|
||||
* <p>
|
||||
* MT940 is the backbone format for German bank exports. Robustness here is
|
||||
* critical: malformed input must produce warnings (not crashes), proprietary
|
||||
* headers must be tolerated, and edge cases like century-boundary years and
|
||||
* sentinel amount overflows must be handled deterministically.
|
||||
*/
|
||||
@DisplayName("Mt940Parser — Sprint 10 SWIFT MT940 parser")
|
||||
class Mt940ParserTest {
|
||||
|
||||
private final Mt940Parser parser = new Mt940Parser();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Format detection (canParse + getSupportedFormat)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Format detection")
|
||||
class FormatDetection {
|
||||
|
||||
@Test
|
||||
@DisplayName("#1 getSupportedFormat returns MT940")
|
||||
void testGetSupportedFormat_ReturnsMt940() {
|
||||
assertThat(parser.getSupportedFormat()).isEqualTo(BankFormat.MT940);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#2 canParse: empty/null bytes → false")
|
||||
void testCanParse_EmptyOrNullBytes_ReturnsFalse() {
|
||||
assertThat(parser.canParse("any.mt940", null)).isFalse();
|
||||
assertThat(parser.canParse("any.mt940", new byte[0])).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#3 canParse: missing :20: tag → false")
|
||||
void testCanParse_MissingStartTag_ReturnsFalse() {
|
||||
byte[] bytes = ":25:50050201/0001234567\n:60F:C260601EUR100,00".getBytes(StandardCharsets.ISO_8859_1);
|
||||
assertThat(parser.canParse("statement.txt", bytes)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#4 canParse: :20: + :25: → true (Sparkasse-style)")
|
||||
void testCanParse_WithStartAndAccountTag_ReturnsTrue() {
|
||||
byte[] bytes = ":20:STARTUMSE\n:25:50050201/0001234567".getBytes(StandardCharsets.ISO_8859_1);
|
||||
assertThat(parser.canParse("statement.mt940", bytes)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#5 canParse: :20: + :61: → true (entry-only export)")
|
||||
void testCanParse_WithStartAndEntryTag_ReturnsTrue() {
|
||||
byte[] bytes = ":20:REF\n:61:2606010601CR50,00NTRFNONREF//B1".getBytes(StandardCharsets.ISO_8859_1);
|
||||
assertThat(parser.canParse("entries.mt940", bytes)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Happy path: full statement parsing
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Happy path parsing")
|
||||
class HappyPath {
|
||||
|
||||
@Test
|
||||
@DisplayName("#6 parses 4 transactions from sample.mt940 with correct sign convention")
|
||||
void testParse_StandardSample_ReturnsAllTransactions() {
|
||||
ParseResult result = parser.parse(loadResource("/bankimport/sample.mt940"), "sample.mt940", null);
|
||||
|
||||
assertThat(result.transactions()).hasSize(4);
|
||||
// credits positive, debits negative
|
||||
assertThat(result.transactions().get(0).amountCents()).isEqualTo(5000); // +50,00 €
|
||||
assertThat(result.transactions().get(1).amountCents()).isEqualTo(-3000); // -30,00 €
|
||||
assertThat(result.transactions().get(2).amountCents()).isEqualTo(10000); // +100,00 €
|
||||
assertThat(result.transactions().get(3).amountCents()).isEqualTo(-1299); // -12,99 €
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#7 extracts account IBAN, opening/closing balance, statement date")
|
||||
void testParse_StandardSample_ExtractsStatementMetadata() {
|
||||
ParseResult result = parser.parse(loadResource("/bankimport/sample.mt940"), "sample.mt940", null);
|
||||
|
||||
// :25: in sample.mt940 carries BLZ/account — no IBAN → accountIban null is acceptable
|
||||
assertThat(result.openingBalanceCents()).isEqualTo(123456); // 1234,56 €
|
||||
assertThat(result.closingBalanceCents()).isEqualTo(134157); // 1341,57 €
|
||||
assertThat(result.statementDate()).isEqualTo(LocalDate.of(2026, 6, 30));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#8 parses :86: ?NN subfields → reference, name, counterparty IBAN")
|
||||
void testParse_Tag86Subfields_ExtractsAllParts() {
|
||||
ParseResult result = parser.parse(loadResource("/bankimport/sample.mt940"), "sample.mt940", null);
|
||||
|
||||
ParsedTransaction t0 = result.transactions().get(0);
|
||||
// ?20-?29 = Verwendungszweck, may include SVWZ+ embedded value
|
||||
assertThat(t0.referenceText()).contains("Mitgliedsbeitrag", "M-001");
|
||||
// ?32/?33 = name
|
||||
assertThat(t0.counterpartyName()).contains("Mueller");
|
||||
// ?31 = IBAN
|
||||
assertThat(t0.counterpartyIban()).isEqualTo("DE12345678901234567890");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#9 parses real Sparkasse-style file with {...} braces and SOLADES1 header")
|
||||
void testParse_SparkasseBraceWrapper_SkipsProprietaryHeader() {
|
||||
ParseResult result = parser.parse(
|
||||
loadResource("/bankimport/sample-real-sparkasse.mt940"),
|
||||
"sample-real-sparkasse.mt940", null);
|
||||
|
||||
assertThat(result.transactions()).hasSize(3);
|
||||
assertThat(result.transactions().get(0).amountCents()).isEqualTo(185000); // +1850,00 €
|
||||
assertThat(result.transactions().get(1).amountCents()).isEqualTo(-85000); // -850,00 €
|
||||
assertThat(result.transactions().get(2).amountCents()).isEqualTo(7500); // +75,00 €
|
||||
// Counterparty IBAN extracted from ?31
|
||||
assertThat(result.transactions().get(0).counterpartyIban()).isEqualTo("DE89370400440532013000");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#10 extracts bank reference from // separator in :61: rest")
|
||||
void testParse_BankReference_ExtractedFromSlashSeparator() {
|
||||
ParseResult result = parser.parse(loadResource("/bankimport/sample.mt940"), "sample.mt940", null);
|
||||
|
||||
assertThat(result.transactions().get(0).bankReference()).isEqualTo("B-1");
|
||||
assertThat(result.transactions().get(3).bankReference()).isEqualTo("B-4");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Date handling — century boundary + booking-date inference
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Date handling")
|
||||
class DateHandling {
|
||||
|
||||
@Test
|
||||
@DisplayName("#11 parseSwiftDate: YY < 70 → 20YY; YY >= 70 → 19YY (German banking convention)")
|
||||
void testParseSwiftDate_AppliesCenturyBoundary() {
|
||||
// 26 < 70 → 2026
|
||||
assertThat(Mt940Parser.parseSwiftDate("260615")).isEqualTo(LocalDate.of(2026, 6, 15));
|
||||
// 69 < 70 → 2069 (upper bound of the 2000s window)
|
||||
assertThat(Mt940Parser.parseSwiftDate("691231")).isEqualTo(LocalDate.of(2069, 12, 31));
|
||||
// 70 >= 70 → 1970 (lower bound of the 1900s window — legacy archive)
|
||||
assertThat(Mt940Parser.parseSwiftDate("700101")).isEqualTo(LocalDate.of(1970, 1, 1));
|
||||
// 99 >= 70 → 1999 (Y2K-era statement)
|
||||
assertThat(Mt940Parser.parseSwiftDate("991231")).isEqualTo(LocalDate.of(1999, 12, 31));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#12 booking date MMDD near year-end uses correct year (Dec→Jan rollover)")
|
||||
void testParse_BookingDateRollover_PicksNearestYear() {
|
||||
// value date 2026-01-02, booking MMDD = 1231 → expected booking 2025-12-31 (delta 2 days)
|
||||
String mt940 =
|
||||
":20:ROLLOVER\n" +
|
||||
":25:50050201/0001234567\n" +
|
||||
":60F:C260101EUR0,00\n" +
|
||||
":61:2601021231CR10,00NTRFNONREF//B1\n" +
|
||||
":86:Year rollover\n" +
|
||||
":62F:C260101EUR10,00\n";
|
||||
ParseResult result = parser.parse(toStream(mt940), "rollover.mt940", null);
|
||||
|
||||
assertThat(result.transactions()).hasSize(1);
|
||||
assertThat(result.transactions().get(0).valueDate()).isEqualTo(LocalDate.of(2026, 1, 2));
|
||||
assertThat(result.transactions().get(0).bookingDate()).isEqualTo(LocalDate.of(2025, 12, 31));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Amount parsing
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Amount parsing")
|
||||
class AmountParsing {
|
||||
|
||||
@Test
|
||||
@DisplayName("#13 parseAmountToCents handles comma-separated cents, single decimal, no decimal")
|
||||
void testParseAmountToCents_HandlesAllFormats() {
|
||||
assertThat(Mt940Parser.parseAmountToCents("1234,56")).isEqualTo(123456);
|
||||
assertThat(Mt940Parser.parseAmountToCents("1234,5")).isEqualTo(123450); // single-digit fract padded
|
||||
assertThat(Mt940Parser.parseAmountToCents("1234,")) .isEqualTo(123400); // empty fract = .00
|
||||
assertThat(Mt940Parser.parseAmountToCents("1234")) .isEqualTo(123400); // no comma at all
|
||||
assertThat(Mt940Parser.parseAmountToCents("0,01")) .isEqualTo(1); // smallest unit
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#14 reversal indicators RC (rev. credit→debit) and RD (rev. debit→credit) flip sign correctly")
|
||||
void testParse_ReversalIndicators_FlipSign() {
|
||||
String mt940 =
|
||||
":20:REVERSALS\n" +
|
||||
":25:50050201/0001234567\n" +
|
||||
":60F:C260601EUR0,00\n" +
|
||||
// RC = reversal of credit → effectively a debit (negative)
|
||||
":61:2606010601RC25,00NTRFNONREF//RC1\n" +
|
||||
":86:Reversed credit\n" +
|
||||
// RD = reversal of debit → effectively a credit (positive)
|
||||
":61:2606020602RD15,00NTRFNONREF//RD1\n" +
|
||||
":86:Reversed debit\n" +
|
||||
":62F:C260603EUR-10,00\n";
|
||||
ParseResult result = parser.parse(toStream(mt940), "reversals.mt940", null);
|
||||
|
||||
assertThat(result.transactions()).hasSize(2);
|
||||
assertThat(result.transactions().get(0).amountCents()).isEqualTo(-2500); // RC → negative
|
||||
assertThat(result.transactions().get(1).amountCents()).isEqualTo(1500); // RD → positive
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Robustness — malformed inputs must produce warnings, not throw
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Robustness against malformed input")
|
||||
class Robustness {
|
||||
|
||||
@Test
|
||||
@DisplayName("#15 malformed :61: entry line yields warning + zero transactions, no throw")
|
||||
void testParse_MalformedEntryLine_YieldsWarning() {
|
||||
ParseResult result = parser.parse(
|
||||
loadResource("/bankimport/malformed.mt940"),
|
||||
"malformed.mt940", null);
|
||||
|
||||
assertThat(result.transactions()).isEmpty();
|
||||
assertThat(result.warnings()).isNotEmpty();
|
||||
assertThat(result.warnings()).anyMatch(w -> w.contains("unparseable :61:"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#16 truncated file (no closing :62F:, no SWIFT block-end) still yields the partial entry")
|
||||
void testParse_TruncatedFile_EmitsPartialTransaction() {
|
||||
ParseResult result = parser.parse(
|
||||
loadResource("/bankimport/malformed-truncated.mt940"),
|
||||
"malformed-truncated.mt940", null);
|
||||
|
||||
// Even with truncation, the :61: + partial :86: should still flush on EOF
|
||||
assertThat(result.transactions()).hasSize(1);
|
||||
assertThat(result.transactions().get(0).amountCents()).isEqualTo(5000);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#17 amount overflow (> Integer.MAX_VALUE cents) throws BankStatementParseException")
|
||||
void testParse_AmountOverflow_ThrowsParseException() {
|
||||
// 99999999999999999999999999999999,99 — billions of euros, will overflow int parsing
|
||||
assertThatThrownBy(() ->
|
||||
parser.parse(loadResource("/bankimport/malformed-overflow.mt940"),
|
||||
"malformed-overflow.mt940", null))
|
||||
.isInstanceOf(RuntimeException.class); // NumberFormatException or wrapped
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#18 empty file with no :20: tag returns zero transactions, no throw")
|
||||
void testParse_EmptyFile_ReturnsEmpty() {
|
||||
ParseResult result = parser.parse(toStream(""), "empty.mt940", null);
|
||||
|
||||
assertThat(result.transactions()).isEmpty();
|
||||
assertThat(result.openingBalanceCents()).isNull();
|
||||
assertThat(result.closingBalanceCents()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#19 ISO-8859-1 umlauts in :86: name field decoded correctly")
|
||||
void testParse_Iso88591Umlauts_DecodedCorrectly() {
|
||||
// 0xFC = ü, 0xE4 = ä, 0xF6 = ö in ISO-8859-1
|
||||
byte[] mt940 = (
|
||||
":20:UMLAUTS\n" +
|
||||
":25:DE12500105170123456789\n" +
|
||||
":60F:C260601EUR0,00\n" +
|
||||
":61:2606010601CR10,00NTRFNONREF//B1\n" +
|
||||
":86:166?00GUTSCHRIFT?20Beitrag?32M\u00fcller, J\u00f6rg\n" +
|
||||
":62F:C260601EUR10,00\n"
|
||||
).getBytes(StandardCharsets.ISO_8859_1);
|
||||
|
||||
ParseResult result = parser.parse(new ByteArrayInputStream(mt940), "umlauts.mt940", null);
|
||||
|
||||
assertThat(result.transactions()).hasSize(1);
|
||||
assertThat(result.transactions().get(0).counterpartyName()).isEqualTo("Müller, Jörg");
|
||||
// IBAN extracted from :25:
|
||||
assertThat(result.accountIban()).isEqualTo("DE12500105170123456789");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#20 proprietary lines BEFORE :20: are skipped with warning, parsing continues")
|
||||
void testParse_PreambleLines_SkippedWithWarning() {
|
||||
String mt940 =
|
||||
"STARMONEY EXPORT V2.5\n" +
|
||||
"ACCOUNT: 1234567 BLZ: 50050201\n" +
|
||||
":20:REAL-START\n" +
|
||||
":25:50050201/0001234567\n" +
|
||||
":60F:C260601EUR0,00\n" +
|
||||
":61:2606010601CR42,00NTRFNONREF//B1\n" +
|
||||
":86:Real transaction\n" +
|
||||
":62F:C260601EUR42,00\n";
|
||||
ParseResult result = parser.parse(toStream(mt940), "preamble.mt940", null);
|
||||
|
||||
assertThat(result.transactions()).hasSize(1);
|
||||
assertThat(result.transactions().get(0).amountCents()).isEqualTo(4200);
|
||||
// Preamble lines are non-tag lines so they're not skipped with a warning per se,
|
||||
// but the parser must not crash and must find the :20: correctly.
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#21 CRLF line endings (Windows-exported MT940) are stripped correctly")
|
||||
void testParse_CrlfLineEndings_StripsTrailingCr() {
|
||||
// Real-world MT940 files from German banks are routinely CRLF — the parser
|
||||
// must strip the trailing \r so tag dispatch and amount parsing aren't
|
||||
// polluted with stray control characters.
|
||||
String mt940 =
|
||||
":20:CRLF-TEST\r\n" +
|
||||
":25:50050201/0001234567\r\n" +
|
||||
":60F:C260601EUR0,00\r\n" +
|
||||
":61:2606010601CR7,50NTRFNONREF//CRLF-1\r\n" +
|
||||
":86:CRLF entry\r\n" +
|
||||
":62F:C260601EUR7,50\r\n";
|
||||
ParseResult result = parser.parse(toStream(mt940), "crlf.mt940", null);
|
||||
|
||||
assertThat(result.transactions()).hasSize(1);
|
||||
// 7,50 € = 750 cents. If the trailing \r leaked into amount parsing this
|
||||
// would either throw NumberFormatException or produce a wrong value.
|
||||
assertThat(result.transactions().get(0).amountCents()).isEqualTo(750);
|
||||
assertThat(result.transactions().get(0).bankReference()).isEqualTo("CRLF-1");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private InputStream loadResource(String path) {
|
||||
InputStream is = Mt940ParserTest.class.getResourceAsStream(path);
|
||||
if (is == null) {
|
||||
throw new IllegalStateException("Test resource not found: " + path);
|
||||
}
|
||||
return is;
|
||||
}
|
||||
|
||||
private InputStream toStream(String content) {
|
||||
return new ByteArrayInputStream(content.getBytes(StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package de.cannamanage.service.bankimport;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* Sprint 11 — Test builder for {@link ParsedTransaction}.
|
||||
* <p>
|
||||
* Provides sensible defaults so tests can focus on the fields under test:
|
||||
* <pre>
|
||||
* var tx = ParsedTransactionBuilder.builder()
|
||||
* .amountCents(5000)
|
||||
* .referenceText("EREF+M-2025-001")
|
||||
* .build();
|
||||
* </pre>
|
||||
* <p>
|
||||
* Default values are deterministic and aligned with the {@code AbstractServiceTest}
|
||||
* clock (2026-06-15) for predictable assertions.
|
||||
*/
|
||||
public final class ParsedTransactionBuilder {
|
||||
|
||||
private LocalDate bookingDate = LocalDate.of(2026, 6, 15);
|
||||
private LocalDate valueDate = LocalDate.of(2026, 6, 15);
|
||||
private int amountCents = 5000; // +50,00 EUR by default
|
||||
private String currency = "EUR";
|
||||
private String referenceText = "EREF+TEST-REF";
|
||||
private String counterpartyName = "Test Counterparty";
|
||||
private String counterpartyIban = "DE89370400440532013000";
|
||||
private String bankReference = "B-TEST-001";
|
||||
|
||||
private ParsedTransactionBuilder() {}
|
||||
|
||||
public static ParsedTransactionBuilder builder() {
|
||||
return new ParsedTransactionBuilder();
|
||||
}
|
||||
|
||||
public ParsedTransactionBuilder bookingDate(LocalDate v) { this.bookingDate = v; return this; }
|
||||
public ParsedTransactionBuilder valueDate(LocalDate v) { this.valueDate = v; return this; }
|
||||
public ParsedTransactionBuilder amountCents(int v) { this.amountCents = v; return this; }
|
||||
public ParsedTransactionBuilder currency(String v) { this.currency = v; return this; }
|
||||
public ParsedTransactionBuilder referenceText(String v) { this.referenceText = v; return this; }
|
||||
public ParsedTransactionBuilder counterpartyName(String v) { this.counterpartyName = v; return this; }
|
||||
public ParsedTransactionBuilder counterpartyIban(String v) { this.counterpartyIban = v; return this; }
|
||||
public ParsedTransactionBuilder bankReference(String v) { this.bankReference = v; return this; }
|
||||
|
||||
/** Convenience: set both bookingDate and valueDate to the same value. */
|
||||
public ParsedTransactionBuilder onDate(LocalDate v) {
|
||||
this.bookingDate = v;
|
||||
this.valueDate = v;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Convenience: amount in whole euros (e.g. {@code euros(50)} → 5000 cents). */
|
||||
public ParsedTransactionBuilder euros(int wholeEuros) {
|
||||
this.amountCents = wholeEuros * 100;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ParsedTransaction build() {
|
||||
return new ParsedTransaction(
|
||||
bookingDate,
|
||||
valueDate,
|
||||
amountCents,
|
||||
currency,
|
||||
referenceText,
|
||||
counterpartyName,
|
||||
counterpartyIban,
|
||||
bankReference
|
||||
);
|
||||
}
|
||||
}
|
||||
+610
@@ -0,0 +1,610 @@
|
||||
package de.cannamanage.service.bankimport;
|
||||
|
||||
import de.cannamanage.domain.entity.BankTransaction;
|
||||
import de.cannamanage.domain.entity.FeeSchedule;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.entity.MemberFeeAssignment;
|
||||
import de.cannamanage.domain.enums.MatchStatus;
|
||||
import de.cannamanage.domain.enums.MemberStatus;
|
||||
import de.cannamanage.service.AbstractServiceTest;
|
||||
import de.cannamanage.service.repository.FeeScheduleRepository;
|
||||
import de.cannamanage.service.repository.MemberFeeAssignmentRepository;
|
||||
import de.cannamanage.service.repository.MemberRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
|
||||
/**
|
||||
* Sprint 11 — Unit tests for {@link PaymentMatchingService} (Phase 2.2).
|
||||
*
|
||||
* <p>Covers the deterministic, weighted-confidence matching engine that pairs
|
||||
* parsed bank-statement transactions to club members. Plan §2.2: 22 test
|
||||
* methods spanning scoring criteria, double-payment safety, German-locale
|
||||
* normalisation, boundary conditions, null tolerance and the early-exit
|
||||
* performance optimisation.
|
||||
*/
|
||||
@DisplayName("PaymentMatchingService — Sprint 10 matching engine")
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class PaymentMatchingServiceTest extends AbstractServiceTest {
|
||||
|
||||
@Mock private MemberRepository memberRepository;
|
||||
@Mock private MemberFeeAssignmentRepository feeAssignmentRepository;
|
||||
@Mock private FeeScheduleRepository feeScheduleRepository;
|
||||
|
||||
@InjectMocks
|
||||
private PaymentMatchingService service;
|
||||
|
||||
// Deterministic ids — readable in failure messages.
|
||||
private static final UUID SESSION_ID = UUID.fromString("99999999-0000-0000-0000-000000000099");
|
||||
private static final UUID FEE_ID = UUID.fromString("99999999-0000-0000-0000-000000000001");
|
||||
private static final UUID MEMBER_A_ID = UUID.fromString("0000000a-0000-0000-0000-000000000001");
|
||||
private static final UUID MEMBER_B_ID = UUID.fromString("0000000b-0000-0000-0000-000000000002");
|
||||
|
||||
private Member memberA;
|
||||
private Member memberB;
|
||||
private FeeSchedule fee2500;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
memberA = buildMember(MEMBER_A_ID, "Max", "Mustermann", "M-001", "DE89370400440532013000");
|
||||
memberB = buildMember(MEMBER_B_ID, "Erika", "Müller", "M-002", "DE02120300000000202051");
|
||||
|
||||
fee2500 = new FeeSchedule();
|
||||
fee2500.setId(FEE_ID);
|
||||
fee2500.setClubId(TEST_CLUB_ID);
|
||||
fee2500.setName("Standard");
|
||||
fee2500.setAmountCents(2500);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Section 1 — Core scoring (Plan §2.2 #1-#4)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Core scoring")
|
||||
class CoreScoring {
|
||||
|
||||
@Test
|
||||
@DisplayName("#1 exact member#+amount+name+IBAN scores 100 → MATCHED")
|
||||
void testMatch_ExactMemberNumber_ScoresAbove90() {
|
||||
stubClubMembers(memberA);
|
||||
stubFeeAssignment(memberA, FEE_ID);
|
||||
stubFeeSchedules(fee2500);
|
||||
|
||||
// All four criteria perfect → 35 + 30 + 20 + 15 = 100
|
||||
ParsedTransaction txn = txn(2500, "Beitrag M-001 Juni", "Max Mustermann",
|
||||
memberA.getIban());
|
||||
|
||||
List<BankTransaction> result = service.matchTransactions(List.of(txn), TEST_CLUB_ID, SESSION_ID);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getMatchStatus()).isEqualTo(MatchStatus.MATCHED);
|
||||
assertThat(result.get(0).getMatchConfidence()).isGreaterThanOrEqualTo(90);
|
||||
assertThat(result.get(0).getMatchedMemberId()).isEqualTo(MEMBER_A_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#2 amount + fuzzy name + IBAN (no member#) scores 60-89 → SUGGESTED")
|
||||
void testMatch_AmountAndName_ScoresAbove60() {
|
||||
stubClubMembers(memberA);
|
||||
stubFeeAssignment(memberA, FEE_ID);
|
||||
stubFeeSchedules(fee2500);
|
||||
|
||||
// amount(100*0.35=35) + name(100*0.20=20) + iban(100*0.15=15) = 70 → SUGGESTED
|
||||
// (No member# in reference text — proves SUGGESTED is reachable without it.)
|
||||
ParsedTransaction txn = txn(2500, "Mitgliedsbeitrag", "Max Mustermann",
|
||||
memberA.getIban());
|
||||
|
||||
List<BankTransaction> result = service.matchTransactions(List.of(txn), TEST_CLUB_ID, SESSION_ID);
|
||||
|
||||
assertThat(result.get(0).getMatchStatus()).isEqualTo(MatchStatus.SUGGESTED);
|
||||
assertThat(result.get(0).getMatchConfidence())
|
||||
.isGreaterThanOrEqualTo(60)
|
||||
.isLessThan(90);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#3 unrelated transaction scores < 60 → UNMATCHED")
|
||||
void testMatch_NoMatch_ScoresBelow60() {
|
||||
stubClubMembers(memberA);
|
||||
stubFeeAssignment(memberA, FEE_ID);
|
||||
stubFeeSchedules(fee2500);
|
||||
|
||||
// Amount wildly off (× 100) and no name/member# overlap → early-exit, no candidate
|
||||
ParsedTransaction txn = txn(999_99, "Stromrechnung Stadtwerke", "EON Energie", null);
|
||||
|
||||
List<BankTransaction> result = service.matchTransactions(List.of(txn), TEST_CLUB_ID, SESSION_ID);
|
||||
|
||||
assertThat(result.get(0).getMatchStatus()).isEqualTo(MatchStatus.UNMATCHED);
|
||||
assertThat(result.get(0).getMatchedMemberId()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#4 IBAN exact match boosts confidence over no-IBAN baseline")
|
||||
void testMatch_IbanExactMatch_AddsPoints() {
|
||||
stubClubMembers(memberA);
|
||||
stubFeeAssignment(memberA, FEE_ID);
|
||||
stubFeeSchedules(fee2500);
|
||||
|
||||
// Baseline: member# in reference + amount → 35 + 30 = 65 (no IBAN, no full-name match in counterparty empty)
|
||||
// With IBAN: 35 + 30 + 15 = 80
|
||||
ParsedTransaction withoutIban = txn(2500, "Beitrag M-001", "", null);
|
||||
ParsedTransaction withIban = txn(2500, "Beitrag M-001", "",
|
||||
memberA.getIban());
|
||||
|
||||
int confWithout = zeroIfNull(service.matchTransactions(List.of(withoutIban),
|
||||
TEST_CLUB_ID, SESSION_ID).get(0).getMatchConfidence());
|
||||
int confWith = zeroIfNull(service.matchTransactions(List.of(withIban),
|
||||
TEST_CLUB_ID, SESSION_ID).get(0).getMatchConfidence());
|
||||
|
||||
assertThat(confWith).isGreaterThan(confWithout);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Section 2 — Amount tolerance + boundaries (Plan §2.2 #5, #6, #18, #19)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Amount tolerance + boundaries")
|
||||
class AmountTolerance {
|
||||
|
||||
@Test
|
||||
@DisplayName("#5 amount within ±20% (e.g. 2400 vs 2500) still earns amount points")
|
||||
void testMatch_AmountTolerance20Percent_Matches() {
|
||||
stubClubMembers(memberA);
|
||||
stubFeeAssignment(memberA, FEE_ID);
|
||||
stubFeeSchedules(fee2500);
|
||||
|
||||
ParsedTransaction txn = txn(2400, "Beitrag M-001", "Max Mustermann", null);
|
||||
List<BankTransaction> result = service.matchTransactions(List.of(txn), TEST_CLUB_ID, SESSION_ID);
|
||||
|
||||
// 4% deviation triggers the early-exit's amount-plausible branch (and 50% amount score)
|
||||
assertThat(result.get(0).getMatchStatus()).isNotEqualTo(MatchStatus.UNMATCHED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#6 amount off by >20% AND no member# → no candidate (early-exit)")
|
||||
void testMatch_AmountExceeds20Percent_NoAmountScore() {
|
||||
stubClubMembers(memberA);
|
||||
stubFeeAssignment(memberA, FEE_ID);
|
||||
stubFeeSchedules(fee2500);
|
||||
|
||||
// 50% over with no member# in text → early-exit drops the only candidate
|
||||
ParsedTransaction txn = txn(5000, "Spende fuer den Verein", "Unbekannt", null);
|
||||
List<BankTransaction> result = service.matchTransactions(List.of(txn), TEST_CLUB_ID, SESSION_ID);
|
||||
|
||||
assertThat(result.get(0).getMatchStatus()).isEqualTo(MatchStatus.UNMATCHED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#18 exactly 20% deviation is inclusive — still scores 50 amount points")
|
||||
void testMatch_AmountExactlyAt20PercentBoundary_Included() {
|
||||
stubClubMembers(memberA);
|
||||
stubFeeAssignment(memberA, FEE_ID);
|
||||
stubFeeSchedules(fee2500);
|
||||
|
||||
// 2500 × 0.80 = 2000 (exactly -20%)
|
||||
ParsedTransaction txn = txn(2000, "Beitrag M-001", "Max Mustermann", null);
|
||||
List<BankTransaction> result = service.matchTransactions(List.of(txn), TEST_CLUB_ID, SESSION_ID);
|
||||
|
||||
assertThat(result.get(0).getMatchStatus()).isNotEqualTo(MatchStatus.UNMATCHED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#19 21% deviation excludes the amount-plausible branch")
|
||||
void testMatch_AmountJustOver20PercentBoundary_Excluded() {
|
||||
stubClubMembers(memberA);
|
||||
stubFeeAssignment(memberA, FEE_ID);
|
||||
stubFeeSchedules(fee2500);
|
||||
|
||||
// 21% under, no member# → early-exit
|
||||
ParsedTransaction txn = txn(1975, "Mitgliedsbeitrag", "Unbekannt", null);
|
||||
List<BankTransaction> result = service.matchTransactions(List.of(txn), TEST_CLUB_ID, SESSION_ID);
|
||||
|
||||
assertThat(result.get(0).getMatchStatus()).isEqualTo(MatchStatus.UNMATCHED);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Section 3 — Double-payment safety (Plan §2.2 #7)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("#7 same member best for ≥ 2 transactions → all matched/suggested but NOT auto-MATCHED")
|
||||
void testMatch_DoublePaymentSafety_DowngradesToSuggested() {
|
||||
stubClubMembers(memberA);
|
||||
stubFeeAssignment(memberA, FEE_ID);
|
||||
stubFeeSchedules(fee2500);
|
||||
|
||||
// Two perfect-MATCHED hits (all 4 criteria → 100) for the same member.
|
||||
// After double-payment safety: both downgraded to SUGGESTED, neither MATCHED.
|
||||
ParsedTransaction txn1 = txn(2500, "Beitrag M-001 Mai", "Max Mustermann", memberA.getIban());
|
||||
ParsedTransaction txn2 = txn(2500, "Beitrag M-001 Juni", "Max Mustermann", memberA.getIban());
|
||||
|
||||
List<BankTransaction> result = service.matchTransactions(List.of(txn1, txn2), TEST_CLUB_ID, SESSION_ID);
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
assertThat(result).allSatisfy(tx -> {
|
||||
assertThat(tx.getMatchStatus())
|
||||
.as("must not be auto-MATCHED — double-payment safety must downgrade")
|
||||
.isNotEqualTo(MatchStatus.MATCHED);
|
||||
assertThat(tx.getMatchStatus()).isEqualTo(MatchStatus.SUGGESTED);
|
||||
assertThat(tx.getMatchedMemberId()).isEqualTo(MEMBER_A_ID);
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Section 4 — German-locale + case (Plan §2.2 #8, #11)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("#8 'Müller' vs 'Mueller' normalised to identical strings")
|
||||
void testMatch_GermanUmlauts_NormalizedComparison() {
|
||||
// Sanity-check the package-private normaliser directly; both must collapse to the same token.
|
||||
assertThat(PaymentMatchingService.normalize("Müller"))
|
||||
.isEqualTo(PaymentMatchingService.normalize("Mueller"));
|
||||
|
||||
// End-to-end: counterparty written as 'Mueller' still matches member 'Müller'
|
||||
stubClubMembers(memberB);
|
||||
stubFeeAssignment(memberB, FEE_ID);
|
||||
stubFeeSchedules(fee2500);
|
||||
|
||||
ParsedTransaction txn = txn(2500, "Beitrag M-002", "Erika Mueller", null);
|
||||
List<BankTransaction> result = service.matchTransactions(List.of(txn), TEST_CLUB_ID, SESSION_ID);
|
||||
|
||||
assertThat(result.get(0).getMatchedMemberId()).isEqualTo(MEMBER_B_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#11 member number is case-insensitive in reference text — same confidence either way")
|
||||
void testMatch_MemberNumberInReference_CaseInsensitive() {
|
||||
stubClubMembers(memberA);
|
||||
stubFeeAssignment(memberA, FEE_ID);
|
||||
stubFeeSchedules(fee2500);
|
||||
|
||||
ParsedTransaction lower = txn(2500, "beitrag m-001 mai", "Max Mustermann", null);
|
||||
ParsedTransaction upper = txn(2500, "BEITRAG M-001 MAI", "Max Mustermann", null);
|
||||
|
||||
int confLower = zeroIfNull(service.matchTransactions(List.of(lower), TEST_CLUB_ID, SESSION_ID)
|
||||
.get(0).getMatchConfidence());
|
||||
int confUpper = zeroIfNull(service.matchTransactions(List.of(upper), TEST_CLUB_ID, SESSION_ID)
|
||||
.get(0).getMatchConfidence());
|
||||
|
||||
// Case-folding is the property under test — both must produce the identical score
|
||||
// and that score must clear the SUGGEST threshold.
|
||||
assertThat(confLower).isEqualTo(confUpper).isGreaterThanOrEqualTo(60);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Section 5 — Edge cases / null safety (Plan §2.2 #9, #10, #15, #21, #22)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("#9 empty transaction list → empty result, no NPE")
|
||||
void testMatch_EmptyTransactionList_ReturnsEmpty() {
|
||||
stubClubMembers(); // no members needed — algorithm short-circuits per txn
|
||||
lenient().when(feeAssignmentRepository.findByClubId(TEST_CLUB_ID)).thenReturn(List.of());
|
||||
lenient().when(feeScheduleRepository.findByClubId(TEST_CLUB_ID)).thenReturn(List.of());
|
||||
|
||||
List<BankTransaction> result = service.matchTransactions(List.of(), TEST_CLUB_ID, SESSION_ID);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#10 no active members → every transaction UNMATCHED")
|
||||
void testMatch_NoActiveMembers_AllUnmatched() {
|
||||
stubClubMembers(); // empty
|
||||
lenient().when(feeAssignmentRepository.findByClubId(TEST_CLUB_ID)).thenReturn(List.of());
|
||||
lenient().when(feeScheduleRepository.findByClubId(TEST_CLUB_ID)).thenReturn(List.of());
|
||||
|
||||
ParsedTransaction txn = txn(2500, "Beitrag M-001", "Max Mustermann", null);
|
||||
List<BankTransaction> result = service.matchTransactions(List.of(txn), TEST_CLUB_ID, SESSION_ID);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getMatchStatus()).isEqualTo(MatchStatus.UNMATCHED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#15 partial member number 'M-00' does NOT match 'M-001'")
|
||||
void testMatch_PartialMemberNumber_NoMatch() {
|
||||
stubClubMembers(memberA);
|
||||
stubFeeAssignment(memberA, FEE_ID);
|
||||
stubFeeSchedules(fee2500);
|
||||
|
||||
// "M-00" — substring of "M-001" but reversed contains direction; numeric fallback
|
||||
// also fails because "00" has only 2 digits (< MIN_NUMERIC_MATCH_LENGTH=3).
|
||||
// Amount also way off so no early-exit branch survives.
|
||||
ParsedTransaction txn = txn(9999, "Beitrag M-00 unklar", "Anonym", null);
|
||||
List<BankTransaction> result = service.matchTransactions(List.of(txn), TEST_CLUB_ID, SESSION_ID);
|
||||
|
||||
assertThat(result.get(0).getMatchStatus()).isEqualTo(MatchStatus.UNMATCHED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#21 null reference text does not throw NPE — yields UNMATCHED")
|
||||
void testMatch_NullReference_NoNpe() {
|
||||
stubClubMembers(memberA);
|
||||
stubFeeAssignment(memberA, FEE_ID);
|
||||
stubFeeSchedules(fee2500);
|
||||
|
||||
// amount off + null reference → early-exit path; no NPE anywhere in the score loop
|
||||
ParsedTransaction txn = txn(9999, null, null, null);
|
||||
List<BankTransaction> result = service.matchTransactions(List.of(txn), TEST_CLUB_ID, SESSION_ID);
|
||||
|
||||
assertThat(result.get(0).getMatchStatus()).isEqualTo(MatchStatus.UNMATCHED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#22 blank counterparty name scores 0 for the name component")
|
||||
void testMatch_EmptyName_NoNameScore() {
|
||||
// Direct unit test of the package-private scorer — avoids whole-pipeline noise.
|
||||
int score = PaymentMatchingService.scoreName(
|
||||
"beitrag mai", // normalized reference
|
||||
"", // blank counterparty
|
||||
"max mustermann"); // normalized member name
|
||||
assertThat(score).isEqualTo(0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Section 6 — Booking-date context + fee selection (Plan §2.2 #12, #13)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("#12 fee selection uses the assignment valid at the booking-date context")
|
||||
void testMatch_MultipleFeesForMember_UsesClosestAmount() {
|
||||
// Member A has TWO fee schedules across history: old €25, new €30.
|
||||
FeeSchedule oldFee = new FeeSchedule();
|
||||
oldFee.setId(UUID.fromString("aaaaaaaa-0000-0000-0000-000000000010"));
|
||||
oldFee.setClubId(TEST_CLUB_ID);
|
||||
oldFee.setName("Old"); oldFee.setAmountCents(2500);
|
||||
|
||||
FeeSchedule newFee = new FeeSchedule();
|
||||
newFee.setId(UUID.fromString("aaaaaaaa-0000-0000-0000-000000000020"));
|
||||
newFee.setClubId(TEST_CLUB_ID);
|
||||
newFee.setName("New"); newFee.setAmountCents(3000);
|
||||
|
||||
// Old assignment ran 2025-01-01 → 2026-01-01 (closed)
|
||||
MemberFeeAssignment oldAssign = new MemberFeeAssignment();
|
||||
oldAssign.setMemberId(MEMBER_A_ID); oldAssign.setClubId(TEST_CLUB_ID);
|
||||
oldAssign.setFeeScheduleId(oldFee.getId());
|
||||
oldAssign.setValidFrom(LocalDate.of(2025, 1, 1));
|
||||
oldAssign.setValidTo(LocalDate.of(2026, 1, 1));
|
||||
// New assignment active since 2026-01-01
|
||||
MemberFeeAssignment newAssign = new MemberFeeAssignment();
|
||||
newAssign.setMemberId(MEMBER_A_ID); newAssign.setClubId(TEST_CLUB_ID);
|
||||
newAssign.setFeeScheduleId(newFee.getId());
|
||||
newAssign.setValidFrom(LocalDate.of(2026, 1, 1));
|
||||
|
||||
lenient().when(memberRepository.findByClubIdAndStatus(TEST_CLUB_ID, MemberStatus.ACTIVE))
|
||||
.thenReturn(List.of(memberA));
|
||||
lenient().when(feeAssignmentRepository.findByClubId(TEST_CLUB_ID))
|
||||
.thenReturn(List.of(oldAssign, newAssign));
|
||||
lenient().when(feeScheduleRepository.findByClubId(TEST_CLUB_ID))
|
||||
.thenReturn(List.of(oldFee, newFee));
|
||||
|
||||
// Booking date 2026-06-15 → falls into the NEW assignment (€30 expected).
|
||||
// We pay exactly €30 with full member# + name + IBAN → 100 (MATCHED).
|
||||
// €25 payment is 16.7% off the expected €30 → amount-plausible (≤20%) → score 50,
|
||||
// plus member# (30) + name (20) + IBAN (15) = matches but with lower confidence.
|
||||
ParsedTransaction matchesNew = txn(3000, "Beitrag M-001", "Max Mustermann",
|
||||
memberA.getIban(), LocalDate.of(2026, 6, 15));
|
||||
ParsedTransaction matchesOld = txn(2500, "Beitrag M-001", "Max Mustermann",
|
||||
memberA.getIban(), LocalDate.of(2026, 6, 15));
|
||||
|
||||
List<BankTransaction> result = service.matchTransactions(
|
||||
List.of(matchesNew, matchesOld), TEST_CLUB_ID, SESSION_ID);
|
||||
|
||||
// The €30 payment must score higher than the €25 payment under the active (€30) fee.
|
||||
assertThat(result.get(0).getMatchedMemberId()).isEqualTo(MEMBER_A_ID);
|
||||
assertThat(result.get(1).getMatchedMemberId()).isEqualTo(MEMBER_A_ID);
|
||||
assertThat(zeroIfNull(result.get(0).getMatchConfidence()))
|
||||
.isGreaterThan(zeroIfNull(result.get(1).getMatchConfidence()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#13 most-frequent booking date wins as the fee-selection context")
|
||||
void testMatch_BookingDateContext_UsesCorrectPeriod() {
|
||||
// 3 transactions all booked in December 2025 → pickBookingDateContext picks 2025-12-15
|
||||
List<ParsedTransaction> decTxns = List.of(
|
||||
txn(2500, "Beitrag M-001", "Max Mustermann", null, LocalDate.of(2025, 12, 15)),
|
||||
txn(2500, "Beitrag M-001", "Max Mustermann", null, LocalDate.of(2025, 12, 15)),
|
||||
txn(2500, "Beitrag M-001", "Max Mustermann", null, LocalDate.of(2025, 12, 15))
|
||||
);
|
||||
LocalDate ctx = PaymentMatchingService.pickBookingDateContext(decTxns);
|
||||
assertThat(ctx).isEqualTo(LocalDate.of(2025, 12, 15));
|
||||
|
||||
// Empty batch falls back to today
|
||||
assertThat(PaymentMatchingService.pickBookingDateContext(List.of()))
|
||||
.isEqualTo(LocalDate.now());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Section 7 — Performance + early-exit (Plan §2.2 #14, #17)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("#14 early-exit skips name/IBAN scoring when amount + member# both miss")
|
||||
void testMatch_EarlyExit_SkipsExpensiveChecks() {
|
||||
// The early-exit is observable via score: a member that matches ONLY on name
|
||||
// (amount way off, no member# in text) yields UNMATCHED — no name points are added,
|
||||
// proving the loop body was skipped before name scoring ran.
|
||||
stubClubMembers(memberA);
|
||||
stubFeeAssignment(memberA, FEE_ID);
|
||||
stubFeeSchedules(fee2500);
|
||||
|
||||
// Name in txn matches memberA exactly; amount × 10; no member#.
|
||||
ParsedTransaction txn = txn(25_000, "Rueckzahlung", "Max Mustermann", null);
|
||||
List<BankTransaction> result = service.matchTransactions(List.of(txn), TEST_CLUB_ID, SESSION_ID);
|
||||
|
||||
assertThat(result.get(0).getMatchStatus()).isEqualTo(MatchStatus.UNMATCHED);
|
||||
assertThat(result.get(0).getMatchedMemberId()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#17 100 transactions × 1 member finish well under 1 second")
|
||||
void testMatch_100Transactions_CompletesUnder1Second() {
|
||||
stubClubMembers(memberA);
|
||||
stubFeeAssignment(memberA, FEE_ID);
|
||||
stubFeeSchedules(fee2500);
|
||||
|
||||
List<ParsedTransaction> txns = new ArrayList<>(100);
|
||||
for (int i = 0; i < 100; i++) {
|
||||
txns.add(txn(2500, "Beitrag M-001 Nr " + i, "Max Mustermann", null));
|
||||
}
|
||||
|
||||
long start = System.nanoTime();
|
||||
List<BankTransaction> result = service.matchTransactions(txns, TEST_CLUB_ID, SESSION_ID);
|
||||
long durationMs = (System.nanoTime() - start) / 1_000_000L;
|
||||
|
||||
assertThat(result).hasSize(100);
|
||||
assertThat(durationMs).as("100 txns × 1 member must run < 1000 ms").isLessThan(1000);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Section 8 — Concurrency + whitespace robustness (Plan §2.2 #16, #20)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("#16 stateless service — 10 threads matching concurrently produce identical results")
|
||||
void testMatch_ConcurrentMatching_ThreadSafe() throws Exception {
|
||||
stubClubMembers(memberA);
|
||||
stubFeeAssignment(memberA, FEE_ID);
|
||||
stubFeeSchedules(fee2500);
|
||||
|
||||
ParsedTransaction txn = txn(2500, "Beitrag M-001", "Max Mustermann", memberA.getIban());
|
||||
|
||||
int threads = 10;
|
||||
ExecutorService pool = Executors.newFixedThreadPool(threads);
|
||||
try {
|
||||
CountDownLatch ready = new CountDownLatch(threads);
|
||||
CountDownLatch go = new CountDownLatch(1);
|
||||
CountDownLatch done = new CountDownLatch(threads);
|
||||
ConcurrentHashMap<Integer, Integer> confidences = new ConcurrentHashMap<>();
|
||||
AtomicInteger errors = new AtomicInteger();
|
||||
|
||||
for (int i = 0; i < threads; i++) {
|
||||
final int id = i;
|
||||
pool.submit(() -> {
|
||||
ready.countDown();
|
||||
try {
|
||||
go.await();
|
||||
BankTransaction tx = service
|
||||
.matchTransactions(List.of(txn), TEST_CLUB_ID, SESSION_ID).get(0);
|
||||
confidences.put(id, tx.getMatchConfidence());
|
||||
} catch (Exception ex) {
|
||||
errors.incrementAndGet();
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
ready.await();
|
||||
go.countDown();
|
||||
assertThat(done.await(5, TimeUnit.SECONDS))
|
||||
.as("all worker threads must finish within 5s").isTrue();
|
||||
assertThat(errors.get()).isZero();
|
||||
assertThat(confidences.values()).hasSize(threads);
|
||||
// All threads observed the exact same deterministic confidence.
|
||||
assertThat(confidences.values().stream().distinct().count()).isEqualTo(1L);
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("#20 'M - 001' (spaces around dash) still matches via numeric-fallback ≥3 digits")
|
||||
void testMatch_MemberNumberWithSpaces_Normalized() {
|
||||
stubClubMembers(memberA);
|
||||
stubFeeAssignment(memberA, FEE_ID);
|
||||
stubFeeSchedules(fee2500);
|
||||
|
||||
// 'M - 001': exact-substring path fails (the hyphen is surrounded by spaces),
|
||||
// but the numeric-only fallback ('001', 3 digits) still hits.
|
||||
ParsedTransaction txn = txn(2500, "Beitrag M - 001 Mai", "Max Mustermann", null);
|
||||
List<BankTransaction> result = service.matchTransactions(List.of(txn), TEST_CLUB_ID, SESSION_ID);
|
||||
|
||||
assertThat(result.get(0).getMatchedMemberId()).isEqualTo(MEMBER_A_ID);
|
||||
assertThat(result.get(0).getMatchStatus()).isNotEqualTo(MatchStatus.UNMATCHED);
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Helpers
|
||||
// ==================================================================
|
||||
|
||||
private void stubClubMembers(Member... members) {
|
||||
// NB: the service calls findByClubIdAndStatus (default method) which delegates
|
||||
// to findByTenantIdAndStatus. Mockito does NOT execute default methods on mocks,
|
||||
// so we must stub the *delegating* method that the production code actually calls.
|
||||
lenient().when(memberRepository.findByClubIdAndStatus(eq(TEST_CLUB_ID), eq(MemberStatus.ACTIVE)))
|
||||
.thenReturn(members.length == 0 ? Collections.emptyList() : List.of(members));
|
||||
}
|
||||
|
||||
private void stubFeeAssignment(Member member, UUID feeScheduleId) {
|
||||
MemberFeeAssignment assignment = new MemberFeeAssignment();
|
||||
assignment.setMemberId(member.getId());
|
||||
assignment.setClubId(TEST_CLUB_ID);
|
||||
assignment.setFeeScheduleId(feeScheduleId);
|
||||
assignment.setValidFrom(LocalDate.of(2020, 1, 1));
|
||||
// open-ended (validTo == null) — valid for the whole sprint timeframe
|
||||
lenient().when(feeAssignmentRepository.findByClubId(TEST_CLUB_ID))
|
||||
.thenReturn(List.of(assignment));
|
||||
}
|
||||
|
||||
private void stubFeeSchedules(FeeSchedule... schedules) {
|
||||
lenient().when(feeScheduleRepository.findByClubId(TEST_CLUB_ID))
|
||||
.thenReturn(List.of(schedules));
|
||||
}
|
||||
|
||||
private Member buildMember(UUID id, String first, String last, String memberNo, String iban) {
|
||||
Member m = new Member();
|
||||
m.setId(id);
|
||||
m.setClubId(TEST_CLUB_ID);
|
||||
m.setFirstName(first);
|
||||
m.setLastName(last);
|
||||
m.setEmail(first.toLowerCase() + "." + last.toLowerCase() + "@example.de");
|
||||
m.setDateOfBirth(LocalDate.of(1990, 1, 1));
|
||||
m.setMembershipDate(LocalDate.of(2024, 1, 1));
|
||||
m.setMembershipNumber(memberNo);
|
||||
m.setStatus(MemberStatus.ACTIVE);
|
||||
m.setIban(iban);
|
||||
return m;
|
||||
}
|
||||
|
||||
private static ParsedTransaction txn(int amountCents, String reference, String counterparty, String iban) {
|
||||
return txn(amountCents, reference, counterparty, iban, TEST_TODAY);
|
||||
}
|
||||
|
||||
private static ParsedTransaction txn(int amountCents, String reference, String counterparty,
|
||||
String iban, LocalDate bookingDate) {
|
||||
return new ParsedTransaction(
|
||||
bookingDate, bookingDate, amountCents, "EUR",
|
||||
reference, counterparty, iban, "BANK-REF-" + amountCents);
|
||||
}
|
||||
|
||||
private static int zeroIfNull(Integer i) {
|
||||
return i == null ? 0 : i;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
:20:ENCODING-001
|
||||
:25:50050201/0001234567
|
||||
:28C:00100/001
|
||||
:60F:C260601EUR1234,56
|
||||
:61:2606010601C50,00NMSCNONREF//B12345
|
||||
EREF+M-2025-001
|
||||
:86:166?00GUTSCHRIFT?20EREF+M-2025-001?21SVWZ+Müllgebühr Köln Straße?22GRÜNE WIESE GMBH ÄÖÜ?30COBADEFFXXX?31DE89370400440532013000?32Grüne Wiese GmbH
|
||||
:62F:C260601EUR1284,56
|
||||
-
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user