Compare commits
50 Commits
5c8f42b33f
...
vibe/batch
| Author | SHA1 | Date | |
|---|---|---|---|
| e6499ac6b8 | |||
| 9072b3e246 | |||
| f39acf5de5 | |||
| c9ab876dfe | |||
| b3027d2669 | |||
| ef32e750ed | |||
| 235cc41f68 | |||
| 3b4b40c1cf | |||
| de5b599455 | |||
| 9895c159fe | |||
| 8d93050636 | |||
| 42d165624b | |||
| e9d61a7fb0 | |||
| f71495b6fc | |||
| 46df1f6170 | |||
| 92a8027dd4 | |||
| f97b6874c9 | |||
| 3d9746ed65 | |||
| 8147991fe0 | |||
| 3c73ca39d6 | |||
| 4afc15b82e | |||
| b33ad236e1 | |||
| 03ea2a7b89 | |||
| a2beadc458 | |||
| 4a3f1bb138 | |||
| 7c5f11779e | |||
| ee4e8b2ee1 | |||
| 75ae7e3c17 | |||
| 82feaec51f | |||
| 4452620df8 | |||
| 7c3617c9d7 | |||
| db13b3ee0c | |||
| 17130082c6 | |||
| a57bf4dd19 | |||
| 301471f728 | |||
| 93bd384ca8 | |||
| 11fefe3bd9 | |||
| 9b6c384eb2 | |||
| 0abc383bed | |||
| c939ba7786 | |||
| 358e3df38b | |||
| 54dd0cc80f | |||
| 9cf6e7f1c4 | |||
| 045823ec8e | |||
| 8503d0824e | |||
| a24b4fdb3b | |||
| c17fb4f9b4 | |||
| 5eec64e5e8 | |||
| 5de703468f | |||
| be0a31a525 |
234
.gitea/workflows/README.md
Normal file
234
.gitea/workflows/README.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# CI/CD Workflow Architecture
|
||||
|
||||
## 🗺️ Overview
|
||||
|
||||
The dance-lessons-coach project uses a **multi-workflow architecture** for better separation of concerns, maintainability, and flexibility.
|
||||
|
||||
## 📁 Workflow Files
|
||||
|
||||
### 1. `ci-cd.yaml` - Main CI/CD Pipeline
|
||||
|
||||
**Purpose**: Run tests, build binaries, and generate documentation
|
||||
|
||||
**Triggers**:
|
||||
- Push to `main`, `ci/**`, `feature/**`, `fix/**`, `refactor/**` branches
|
||||
- Pull requests to `main` branch
|
||||
- Manual workflow dispatch
|
||||
|
||||
**Jobs**:
|
||||
1. **build-cache** - Build and cache Docker build environment
|
||||
2. **ci-pipeline** - Run tests, build binaries, generate Swagger docs
|
||||
3. **trigger-docker-push** - Trigger separate Docker workflow on main branch
|
||||
|
||||
**Key Features**:
|
||||
- Runs in container environment with all build tools
|
||||
- Generates Swagger documentation
|
||||
- Runs BDD and unit tests with PostgreSQL
|
||||
- Updates badges and version information
|
||||
- Triggers Docker workflow only on main branch
|
||||
|
||||
### 2. `docker-push.yaml` - Docker Image Publishing
|
||||
|
||||
**Purpose**: Build and push Docker images to registry
|
||||
|
||||
**Triggers**:
|
||||
- Manual workflow dispatch only (no automatic triggers)
|
||||
- Triggered by `ci-cd.yaml` on main branch
|
||||
|
||||
**Jobs**:
|
||||
1. **docker-push** - Build production Docker image and push to registry
|
||||
|
||||
**Key Features**:
|
||||
- Runs on host environment (access to Docker daemon)
|
||||
- Uses dependency hash from build-cache
|
||||
- Builds minimal Alpine-based production image
|
||||
- Pushes multiple tags (version, latest, commit SHA)
|
||||
|
||||
## 🔧 Architecture Benefits
|
||||
|
||||
### 1. Clear Separation of Concerns
|
||||
- **CI/CD Pipeline**: Testing and artifact generation
|
||||
- **Docker Publishing**: Image building and registry operations
|
||||
|
||||
### 2. Proper Environment Isolation
|
||||
- **CI jobs run in container**: Consistent build environment
|
||||
- **Docker jobs run on host**: Access to Docker daemon
|
||||
|
||||
### 3. Flexible Testing
|
||||
- Can trigger Docker workflow independently for testing
|
||||
- No complex conditional logic in main workflow
|
||||
- Easier to debug and maintain
|
||||
|
||||
### 4. Better Security
|
||||
- Docker operations isolated in separate workflow
|
||||
- Clear dependency between test success and deployment
|
||||
- Manual trigger capability for emergency situations
|
||||
|
||||
## 🚀 Usage Examples
|
||||
|
||||
### Trigger Full CI/CD Pipeline
|
||||
```bash
|
||||
# Automatically triggered on push to main branch
|
||||
# Or manually:
|
||||
./scripts/gitea-client.sh trigger-workflow arcodange dance-lessons-coach ci-cd.yaml main
|
||||
```
|
||||
|
||||
### Trigger Docker Push Manually
|
||||
```bash
|
||||
# Get dependency hash from build-cache job first
|
||||
DEPS_HASH="abc123def456"
|
||||
|
||||
# Trigger Docker workflow manually
|
||||
./scripts/gitea-client.sh trigger-workflow arcodange dance-lessons-coach docker-push.yaml main --deps_hash $DEPS_HASH
|
||||
```
|
||||
|
||||
### Workflow Dispatch Parameters (docker-push.yaml)
|
||||
- `deps_hash` (required): Dependency hash from build-cache job
|
||||
- `ref` (optional): Git reference (branch/tag), defaults to current
|
||||
|
||||
## 🔗 Workflow Dependencies
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Push to main] --> B[ci-cd.yaml]
|
||||
B --> C[build-cache job]
|
||||
B --> D[ci-pipeline job]
|
||||
D --> E[trigger-docker-push job]
|
||||
E --> F[docker-push.yaml]
|
||||
F --> G[docker-push job]
|
||||
G --> H[Docker Registry]
|
||||
```
|
||||
|
||||
## 📋 Best Practices
|
||||
|
||||
### 1. Always Run CI First
|
||||
- Docker workflow should only be triggered after CI passes
|
||||
- Maintains quality gate before deployment
|
||||
|
||||
### 2. Use Dependency Hash
|
||||
- Ensures consistent builds across workflows
|
||||
- Pass hash from build-cache to docker-push
|
||||
|
||||
### 3. Manual Testing
|
||||
- Use separate Docker workflow for testing image builds
|
||||
- Avoids polluting main branch with test images
|
||||
|
||||
### 4. Monitor Both Workflows
|
||||
- CI/CD workflow for test results and artifacts
|
||||
- Docker workflow for image build and push status
|
||||
|
||||
## 🎯 Docker Build Strategy Decision
|
||||
|
||||
### 🏆 Chosen Approach: Attempt 2 (Standard Dockerfile)
|
||||
|
||||
After extensive testing of multiple approaches, we selected **Attempt 2** as the optimal Docker build strategy.
|
||||
|
||||
#### ⚡ Why Attempt 2 Won:
|
||||
|
||||
**1. Simplicity (60% smaller workflow)**
|
||||
- 73 lines vs 158 lines in complex approaches
|
||||
- No inline Dockerfile generation
|
||||
- Standard `docker build -f docker/Dockerfile .` command
|
||||
|
||||
**2. Better Performance**
|
||||
- No artifact/cache action overhead
|
||||
- Natural Docker layer caching works optimally
|
||||
- Faster execution without complex variable substitutions
|
||||
|
||||
**3. Superior Reliability**
|
||||
- Proven standard Docker build process
|
||||
- Easier to debug and maintain
|
||||
- Fewer moving parts = fewer failures
|
||||
|
||||
**4. Better Maintainability**
|
||||
- Uses standard Dockerfile (easier to understand)
|
||||
- No complex YAML templating
|
||||
- Clear separation of concerns
|
||||
|
||||
#### 🗑️ Why We Rejected Other Approaches:
|
||||
|
||||
**Attempt 1 (Inline Dockerfile):**
|
||||
- Complex YAML templating
|
||||
- Harder to debug and maintain
|
||||
- No significant performance benefit
|
||||
|
||||
**Attempt 3 (Build Cache Image):**
|
||||
- Added complexity with cache management
|
||||
- Slower due to artifact actions overhead
|
||||
- More prone to cache invalidation issues
|
||||
|
||||
**Attempt 4 (Template File):**
|
||||
- Added unnecessary file management
|
||||
- No clear advantage over standard Dockerfile
|
||||
- More complex workflow
|
||||
|
||||
### 📊 Performance Comparison:
|
||||
|
||||
| Approach | Lines of Code | Complexity | Reliability | Maintainability |
|
||||
|----------|---------------|------------|-------------|-----------------|
|
||||
| **Attempt 2** | 73 | Low | High | Excellent |
|
||||
| Attempt 1 | 158 | High | Medium | Poor |
|
||||
| Attempt 3 | 125 | Medium | Medium | Fair |
|
||||
| Attempt 4 | 110 | Medium | High | Good |
|
||||
|
||||
### 🔧 Implementation Details:
|
||||
|
||||
**Standard Dockerfile Approach:**
|
||||
```yaml
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
docker build -t dance-lessons-coach -f docker/Dockerfile .
|
||||
docker tag dance-lessons-coach "$IMAGE_NAME"
|
||||
docker push "$IMAGE_NAME"
|
||||
```
|
||||
|
||||
**Key Benefits:**
|
||||
- Uses multi-stage builds for optimization
|
||||
- Standard Docker layer caching works naturally
|
||||
- Easy to understand and modify
|
||||
- Proven reliability in production
|
||||
|
||||
## 🎯 Future Enhancements
|
||||
|
||||
### Potential Improvements:
|
||||
- Add workflow status badges to README
|
||||
- Implement workflow chaining with outputs
|
||||
- Add matrix builds for multiple architectures
|
||||
- Implement canary deployment workflow
|
||||
- Add rollback capability
|
||||
|
||||
### Architecture Considerations:
|
||||
- Keep workflows focused on single responsibilities
|
||||
- Maintain clear separation between test and deploy
|
||||
- Document all workflow triggers and conditions
|
||||
- Monitor workflow execution times and optimize
|
||||
|
||||
## 📝 Maintenance
|
||||
|
||||
### Adding New Jobs:
|
||||
- Add to appropriate workflow based on responsibility
|
||||
- CI-related jobs → `ci-cd.yaml`
|
||||
- Docker-related jobs → `docker-push.yaml`
|
||||
|
||||
### Modifying Triggers:
|
||||
- Update trigger conditions in respective workflow files
|
||||
- Test changes thoroughly before merging
|
||||
|
||||
### Debugging:
|
||||
- Check workflow logs in Gitea Actions
|
||||
- Use `gitea-client.sh diagnose-job` for detailed analysis
|
||||
- Monitor workflow dependencies and execution order
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
### Secrets Management:
|
||||
- Docker registry credentials stored in Gitea secrets
|
||||
- Never hardcode credentials in workflow files
|
||||
- Use GitHub token for workflow dispatch
|
||||
|
||||
### Access Control:
|
||||
- Only authorized users can trigger workflows
|
||||
- Manual approval required for production deployments
|
||||
- Audit logs available for all workflow executions
|
||||
|
||||
This architecture provides a clean, maintainable, and secure CI/CD pipeline that scales well with project growth while maintaining clear separation of concerns.
|
||||
@@ -132,7 +132,8 @@ jobs:
|
||||
name: CI Pipeline
|
||||
needs: build-cache
|
||||
runs-on: ubuntu-latest-ca
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]') && github.actor != 'ci-bot'"
|
||||
# Skip conditions: standard skip ci + actor check + respect skip_ci input
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]') && github.actor != 'ci-bot' && (!github.event.inputs.skip_ci || github.event.inputs.skip_ci == 'false')"
|
||||
|
||||
container:
|
||||
image: ${{ env.CI_REGISTRY }}/${{ env.GITEA_ORG }}/${{ env.GITEA_REPO }}-build-cache:${{ needs.build-cache.outputs.deps_hash }}
|
||||
@@ -153,9 +154,9 @@ jobs:
|
||||
run: |
|
||||
echo "DLC_DATABASE_HOST=postgres" >> $GITHUB_ENV
|
||||
echo "DLC_DATABASE_PORT=5432" >> $GITHUB_ENV
|
||||
echo "DLC_DATABASE_USER=postgres" >> $GITHUB_ENV
|
||||
echo "DLC_DATABASE_PASSWORD=postgres" >> $GITHUB_ENV
|
||||
echo "DLC_DATABASE_NAME=dance_lessons_coach_bdd_test" >> $GITHUB_ENV
|
||||
echo "DLC_DATABASE_USER=$POSTGRES_USER" >> $GITHUB_ENV
|
||||
echo "DLC_DATABASE_PASSWORD=$POSTGRES_PASSWORD" >> $GITHUB_ENV
|
||||
echo "DLC_DATABASE_NAME=$POSTGRES_DB" >> $GITHUB_ENV
|
||||
echo "DLC_DATABASE_SSL_MODE=disable" >> $GITHUB_ENV
|
||||
|
||||
- name: Restore Swagger Docs Cache
|
||||
@@ -218,6 +219,12 @@ jobs:
|
||||
export DLC_DATABASE_PASSWORD=postgres
|
||||
export DLC_DATABASE_NAME=dance_lessons_coach_bdd_test
|
||||
export DLC_DATABASE_SSL_MODE=disable
|
||||
# T12: per-package isolated Postgres schema with migrations (re-enables what
|
||||
# PR #26 attempted but couldn't deliver because the empty schemas had no tables).
|
||||
# The fix: testserver Start() now builds a per-package isolated repo via
|
||||
# user.NewPostgresRepositoryFromDSN which DOES run AutoMigrate against the new
|
||||
# schema. Packages then run in parallel (~2.85x speedup observed locally).
|
||||
export BDD_SCHEMA_ISOLATION=true
|
||||
./scripts/run-bdd-tests.sh
|
||||
|
||||
# Generate BDD coverage report
|
||||
@@ -292,7 +299,13 @@ jobs:
|
||||
# Check for version bump on main branch
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
echo "🔖 Checking for version bump..."
|
||||
./scripts/ci-version-bump.sh "${{ github.event.head_commit.message }}" --no-push
|
||||
# Read commit message from git, NOT from the workflow event payload.
|
||||
# The event-payload expression is interpolated literally into the
|
||||
# rendered script (even inside comments — see PR #38 + #46), so any
|
||||
# backtick / unbalanced quote / multi-line body breaks bash parsing.
|
||||
# git log is interpolation-free and safe.
|
||||
COMMIT_MSG=$(git log -1 --pretty=%B)
|
||||
./scripts/ci-version-bump.sh "$COMMIT_MSG" --no-push
|
||||
fi
|
||||
|
||||
# Single push for all commits (this is the ONLY push in the entire workflow)
|
||||
@@ -304,47 +317,23 @@ jobs:
|
||||
echo "ℹ️ No changes to push"
|
||||
fi
|
||||
|
||||
# Docker build and push (main branch only)
|
||||
- name: Login to Gitea Container Registry
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.CI_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.PACKAGES_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
source VERSION
|
||||
IMAGE_VERSION="$MAJOR.$MINOR.$PATCH${PRERELEASE:+-$PRERELEASE}"
|
||||
|
||||
# Use the template file with proper dependency hash replacement
|
||||
DEPS_HASH="${{ needs.build-cache.outputs.deps_hash }}"
|
||||
echo "Using dependency hash: $DEPS_HASH"
|
||||
|
||||
# Create Dockerfile.prod from template
|
||||
sed "s/{{DEPS_HASH}}/$DEPS_HASH/g" docker/Dockerfile.prod.template > docker/Dockerfile.prod
|
||||
|
||||
TAGS="$IMAGE_VERSION latest ${{ github.sha }}"
|
||||
echo "Building Docker image with tags: $TAGS"
|
||||
|
||||
# Build the production image
|
||||
docker build -t dance-lessons-coach -f docker/Dockerfile.prod .
|
||||
|
||||
for TAG in $TAGS; do
|
||||
IMAGE_NAME="${{ env.CI_REGISTRY }}/${{ env.GITEA_ORG }}/${{ env.GITEA_REPO }}:$TAG"
|
||||
echo "Tagging and pushing: $IMAGE_NAME"
|
||||
docker tag dance-lessons-coach "$IMAGE_NAME"
|
||||
docker push "$IMAGE_NAME"
|
||||
done
|
||||
|
||||
- name: Show published images
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
# Trigger Docker push workflow on main branch
|
||||
trigger-docker-push:
|
||||
name: Trigger Docker Push
|
||||
needs: [build-cache, ci-pipeline]
|
||||
runs-on: ubuntu-latest-ca
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]') && github.actor != 'ci-bot' && github.ref == 'refs/heads/main'"
|
||||
|
||||
steps:
|
||||
- name: Trigger Docker Push Workflow
|
||||
run: |
|
||||
source VERSION
|
||||
IMAGE_VERSION="$MAJOR.$MINOR.$PATCH${PRERELEASE:+-$PRERELEASE}"
|
||||
echo "📦 Published Docker images:"
|
||||
echo " - ${{ env.CI_REGISTRY }}/${{ env.GITEA_ORG }}/${{ env.GITEA_REPO }}:$IMAGE_VERSION"
|
||||
echo " - ${{ env.CI_REGISTRY }}/${{ env.GITEA_ORG }}/${{ env.GITEA_REPO }}:latest"
|
||||
echo " - ${{ env.CI_REGISTRY }}/${{ env.GITEA_ORG }}/${{ env.GITEA_REPO }}:${{ github.sha }}"
|
||||
echo "🚀 Triggering Docker Push workflow..."
|
||||
curl -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITEA_TOKEN || secrets.PACKAGES_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${{ env.GITEA_INTERNAL }}api/v1/repos/${{ env.GITEA_ORG }}/${{ env.GITEA_REPO }}/actions/workflows/docker-push.yaml/dispatches" \
|
||||
-d '{"ref":"${{ github.ref }}"}'
|
||||
echo "✅ Docker Push workflow triggered successfully!"
|
||||
|
||||
73
.gitea/workflows/docker-push.yaml
Normal file
73
.gitea/workflows/docker-push.yaml
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
# dance-lessons-coach Docker Push Workflow
|
||||
# Separate workflow for Docker image building and pushing
|
||||
# Can be triggered manually or by CI/CD workflow
|
||||
|
||||
name: Docker Push
|
||||
|
||||
on:
|
||||
# Manual trigger for testing or production
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Git reference (branch/tag)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
# Environment variables
|
||||
env:
|
||||
GITEA_INTERNAL: "https://gitea.arcodange.lab/"
|
||||
GITEA_EXTERNAL: "https://gitea.arcodange.fr/"
|
||||
GITEA_ORG: "arcodange"
|
||||
GITEA_REPO: "dance-lessons-coach"
|
||||
CI_REGISTRY: "gitea.arcodange.lab"
|
||||
|
||||
jobs:
|
||||
docker-push:
|
||||
name: Docker Push
|
||||
runs-on: ubuntu-latest-ca
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref || github.ref }}
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.CI_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.PACKAGES_TOKEN }}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
source VERSION
|
||||
IMAGE_VERSION="$MAJOR.$MINOR.$PATCH${PRERELEASE:+-$PRERELEASE}"
|
||||
|
||||
TAGS="$IMAGE_VERSION latest ${{ github.sha }}"
|
||||
echo "Building Docker image with tags: $TAGS"
|
||||
|
||||
# Build using the standard Dockerfile (Attempt 2 - simplest approach)
|
||||
docker build -t dance-lessons-coach -f docker/Dockerfile .
|
||||
|
||||
for TAG in $TAGS; do
|
||||
IMAGE_NAME="${{ env.CI_REGISTRY }}/${{ env.GITEA_ORG }}/${{ env.GITEA_REPO }}:$TAG"
|
||||
echo "Tagging and pushing: $IMAGE_NAME"
|
||||
docker tag dance-lessons-coach "$IMAGE_NAME"
|
||||
docker push "$IMAGE_NAME"
|
||||
done
|
||||
|
||||
- name: Show published images
|
||||
run: |
|
||||
source VERSION
|
||||
IMAGE_VERSION="$MAJOR.$MINOR.$PATCH${PRERELEASE:+-$PRERELEASE}"
|
||||
echo "📦 Published Docker images:"
|
||||
echo " - ${{ env.CI_REGISTRY }}/${{ env.GITEA_ORG }}/${{ env.GITEA_REPO }}:$IMAGE_VERSION"
|
||||
echo " - ${{ env.CI_REGISTRY }}/${{ env.GITEA_ORG }}/${{ env.GITEA_REPO }}:latest"
|
||||
echo " - ${{ env.CI_REGISTRY }}/${{ env.GITEA_ORG }}/${{ env.GITEA_REPO }}:${{ github.sha }}"
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -24,7 +24,7 @@ server.pid
|
||||
pkg/server/docs/
|
||||
|
||||
# BDD test files
|
||||
features/*/*-config.yaml
|
||||
features/**/*-config.yaml
|
||||
test-config.yaml
|
||||
test-v2-config.yaml
|
||||
|
||||
@@ -34,3 +34,14 @@ config/runner
|
||||
coverage.txt
|
||||
trigger.txt
|
||||
test_trigger.txt
|
||||
|
||||
# Frontend
|
||||
frontend/node_modules/
|
||||
frontend/.nuxt/
|
||||
frontend/.output/
|
||||
frontend/dist/
|
||||
frontend/.env
|
||||
frontend/.cache/
|
||||
frontend/storybook-static/
|
||||
frontend/test-results/
|
||||
frontend/playwright-report/
|
||||
|
||||
@@ -351,7 +351,10 @@ func TestBDD(t *testing.T) {
|
||||
Options: &godog.Options{
|
||||
Format: "progress",
|
||||
Paths: []string{"."},
|
||||
TestingT: t,
|
||||
TestingT: t,
|
||||
Strict: true,
|
||||
Randomize: -1,
|
||||
StopOnFailure: true,
|
||||
// Enable parallel execution
|
||||
Concurrency: 4, // Number of parallel scenarios
|
||||
},
|
||||
|
||||
@@ -203,6 +203,31 @@ cmd_wait_job() {
|
||||
}
|
||||
|
||||
# Comment on PR
|
||||
# Create a pull request
|
||||
cmd_create_pr() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
local title="$3"
|
||||
local body="$4"
|
||||
local head="$5"
|
||||
local base="${6:-main}"
|
||||
|
||||
if [[ -z "$owner" || -z "$repo" || -z "$title" || -z "$head" ]]; then
|
||||
echo "Usage: $0 create-pr <owner> <repo> <title> <body> <head_branch> [base_branch]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local endpoint="/repos/${owner}/${repo}/pulls"
|
||||
local data
|
||||
data=$(jq -n \
|
||||
--arg title "$title" \
|
||||
--arg body "$body" \
|
||||
--arg head "$head" \
|
||||
--arg base "$base" \
|
||||
'{title: $title, body: $body, head: $head, base: $base}')
|
||||
api_request "POST" "$endpoint" "$data"
|
||||
}
|
||||
|
||||
cmd_comment_pr() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
@@ -215,7 +240,8 @@ cmd_comment_pr() {
|
||||
fi
|
||||
|
||||
local endpoint="/repos/${owner}/${repo}/issues/${pr_number}/comments"
|
||||
local data="{\"body\": \"${comment}\"}"
|
||||
local data
|
||||
data=$(jq -n --arg body "$comment" '{body: $body}')
|
||||
api_request "POST" "$endpoint" "$data"
|
||||
}
|
||||
|
||||
@@ -250,6 +276,7 @@ main() {
|
||||
monitor-workflow) cmd_monitor_workflow "$@" ;;
|
||||
diagnose-job) cmd_diagnose_job "$@" ;;
|
||||
recent-workflows) cmd_recent_workflows "$@" ;;
|
||||
create-pr) cmd_create_pr "$@" ;;
|
||||
comment-pr) cmd_comment_pr "$@" ;;
|
||||
pr-status) cmd_pr_status "$@" ;;
|
||||
list-issues) cmd_list_issues "$@" ;;
|
||||
@@ -274,6 +301,7 @@ main() {
|
||||
echo " monitor-workflow <owner> <repo> <workflow_run_id> [interval_seconds]" >&2
|
||||
echo " diagnose-job <owner> <repo> <job_id>" >&2
|
||||
echo " recent-workflows <owner> <repo> [limit] [status_filter]" >&2
|
||||
echo " create-pr <owner> <repo> <title> <body> <head_branch> [base_branch]" >&2
|
||||
echo " comment-pr <owner> <repo> <pr_number> <comment>" >&2
|
||||
echo " pr-status <owner> <repo> <pr_number>" >&2
|
||||
echo " list-issues <owner> <repo> [state]" >&2
|
||||
|
||||
24
CHANGELOG.md
Normal file
24
CHANGELOG.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨ `GET /api/v1/uptime` endpoint (PR #67) — returns server start_time and uptime_seconds
|
||||
- 📝 mkcert local HTTPS setup + Makefile cert target (PR #68) — prep for ADR-0028 Phase B OIDC callbacks
|
||||
- ✨ `pkg/auth/` skeleton for OpenID Connect (PR #69) — types + client surface, handlers come later
|
||||
|
||||
## [0.1.0] - 2026-05-05
|
||||
|
||||
### Added
|
||||
|
||||
- Magic-link passwordless authentication (ADR-0028 Phases A.1 through A.5, PRs #59-#63)
|
||||
- OIDC provider config skeleton (ADR-0028 Phase B.1 prep, PR #64)
|
||||
- Magic-link expired-token cleanup loop (PR #65)
|
||||
- Mailpit local SMTP infrastructure (ADR-0029)
|
||||
- BDD parallel email assertion strategy (ADR-0030)
|
||||
432
README.md
432
README.md
@@ -1,423 +1,101 @@
|
||||
# dance-lessons-coach
|
||||
|
||||
[](https://gitea.arcodange.fr/arcodange/dance-lessons-coach)
|
||||
[](https://gitea.arcodange.fr/arcodange/dance-lessons-coach/actions/workflows/ci-cd.yaml)
|
||||
[](https://goreportcard.com/report/github.com/arcodange/dance-lessons-coach)
|
||||
[](https://gitea.arcodange.fr/arcodange/dance-lessons-coach/releases)
|
||||
[](LICENSE)
|
||||
[](https://gitea.arcodange.lab/arcodange/dance-lessons-coach)
|
||||
[](https://gitea.arcodange.lab/arcodange/dance-lessons-coach)
|
||||
[](https://gitea.arcodange.lab/arcodange/dance-lessons-coach)
|
||||
[](https://gitea.arcodange.lab/arcodange/dance-lessons-coach)
|
||||
[](https://gitea.arcodange.lab/arcodange/dance-lessons-coach)
|
||||
[](https://gitea.arcodange.lab/arcodange/dance-lessons-coach)
|
||||
|
||||
A Go project demonstrating idiomatic package structure, CLI implementation, and JSON API with Chi router.
|
||||
=======
|
||||
Go web service demonstrating idiomatic package structure, versioned JSON API, and production-ready features.
|
||||
|
||||
## Features
|
||||
|
||||
- Greet function with default behavior
|
||||
- Command-line interface
|
||||
- JSON API with versioned endpoints
|
||||
- Chi router integration
|
||||
- Zerolog for high-performance logging
|
||||
- Viper for configuration management
|
||||
- Graceful shutdown with context
|
||||
- Readiness endpoint for Kubernetes/service mesh integration
|
||||
- OpenTelemetry integration with Jaeger support
|
||||
- OpenAPI/Swagger documentation
|
||||
- Unit tests
|
||||
- Go 1.26.1 compatible
|
||||
- Versioned JSON API (`/api/v1`, `/api/v2`)
|
||||
- Chi router with graceful shutdown
|
||||
- Zerolog structured logging (console and JSON modes)
|
||||
- Viper configuration (file + env vars)
|
||||
- Readiness endpoint for Kubernetes / service mesh
|
||||
- OpenTelemetry / Jaeger distributed tracing
|
||||
- OpenAPI / Swagger UI (embedded in binary)
|
||||
- PostgreSQL user service with JWT auth
|
||||
- BDD + unit tests
|
||||
|
||||
## Installation
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://gitea.arcodange.lab/arcodange/dance-lessons-coach.git
|
||||
cd dance-lessons-coach
|
||||
|
||||
# Build all binaries
|
||||
./scripts/build.sh
|
||||
|
||||
# Use the new Cobra CLI
|
||||
./bin/dance-lessons-coach --help
|
||||
|
||||
# Or use the legacy greet CLI
|
||||
go run ./cmd/greet
|
||||
./scripts/build.sh # produces ./bin/server and ./bin/greet
|
||||
./scripts/start-server.sh start
|
||||
```
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
dance-lessons-coach features an optimized CI/CD pipeline using GitHub Actions with container/services architecture:
|
||||
|
||||
### Key Features
|
||||
- ✅ **Container-based execution**: All steps run in pre-built Docker cache images
|
||||
- ✅ **Service-based PostgreSQL**: Automatic database service provisioning
|
||||
- ✅ **Smart caching**: Dependency-aware cache invalidation
|
||||
- ✅ **Multi-platform**: Compatible with Gitea, GitHub, and GitLab
|
||||
- ✅ **Fast execution**: No Docker Compose overhead
|
||||
- ✅ **Reliable testing**: Full database connectivity with proper environment setup
|
||||
|
||||
### Architecture
|
||||
|
||||
The pipeline uses GitHub Actions' native `container` and `services` directives instead of Docker Compose:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
ci-pipeline:
|
||||
container:
|
||||
image: gitea.arcodange.lab/arcodange/dance-lessons-coach-build-cache:${{ needs.build-cache.outputs.deps_hash }}
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: dance_lessons_coach_bdd_test
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Performance**: Direct container execution without compose overhead
|
||||
2. **Reliability**: Service containers managed by GitHub Actions
|
||||
3. **Simplicity**: Cleaner workflow definition
|
||||
4. **Portability**: Works across CI platforms
|
||||
5. **Caching**: Intelligent dependency-based cache rebuilding
|
||||
|
||||
### Workflow Steps
|
||||
|
||||
1. **Build Cache**: Creates Docker image with Go tools and dependencies
|
||||
2. **CI Pipeline**: Runs tests, builds binaries, and generates documentation
|
||||
3. **Database Tests**: Connects to PostgreSQL service container
|
||||
4. **Coverage Reporting**: Updates coverage badges automatically
|
||||
5. **Artifact Publishing**: Builds and pushes Docker images (main branch only)
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
The pipeline automatically sets up database environment variables:
|
||||
|
||||
```bash
|
||||
echo "DLC_DATABASE_HOST=postgres" >> $GITHUB_ENV
|
||||
echo "DLC_DATABASE_PORT=5432" >> $GITHUB_ENV
|
||||
echo "DLC_DATABASE_USER=postgres" >> $GITHUB_ENV
|
||||
echo "DLC_DATABASE_PASSWORD=postgres" >> $GITHUB_ENV
|
||||
echo "DLC_DATABASE_NAME=dance_lessons_coach_bdd_test" >> $GITHUB_ENV
|
||||
echo "DLC_DATABASE_SSL_MODE=disable" >> $GITHUB_ENV
|
||||
curl http://localhost:8080/api/health
|
||||
curl http://localhost:8080/api/v1/greet/Alice
|
||||
```
|
||||
|
||||
### Status
|
||||
Stop: `./scripts/start-server.sh stop`
|
||||
|
||||
[](https://gitea.arcodange.fr/arcodange/dance-lessons-coach)
|
||||
## Greet CLI
|
||||
|
||||
=======
|
||||
- ✅ **Linting**: Code quality checks with `go fmt` and `go vet`
|
||||
- ✅ **Version Management**: Automatic version detection
|
||||
- ✅ **Portable**: Uses standard GitHub Actions workflow format
|
||||
|
||||
### Workflow File
|
||||
```yaml
|
||||
# .github/workflows/main.yml
|
||||
jobs:
|
||||
build-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.26.1'
|
||||
- run: go build ./...
|
||||
- run: go test ./... -cover
|
||||
|
||||
lint-format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: go fmt ./...
|
||||
- run: go vet ./...
|
||||
```bash
|
||||
go run ./cmd/greet # Hello world!
|
||||
go run ./cmd/greet Alice # Hello Alice!
|
||||
```
|
||||
|
||||
### Setup Instructions
|
||||
1. **Gitea**: Enable GitHub Actions compatibility in repo settings
|
||||
2. **GitHub**: Push to mirror repository (workflow runs automatically)
|
||||
3. **GitLab**: Convert workflow to `.gitlab-ci.yml` or use compatibility mode
|
||||
|
||||
**See [ADR 0016](adr/0016-ci-cd-pipeline-design.md) for complete CI/CD design and [STATUS_BADGES.md](STATUS_BADGES.md) for badge setup.**
|
||||
|
||||
## Configuration
|
||||
|
||||
Basic configuration options:
|
||||
All options are available via `config.yaml` or `DLC_*` environment variables.
|
||||
|
||||
```bash
|
||||
# Start with default configuration
|
||||
./scripts/start-server.sh start
|
||||
| Env var | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `DLC_SERVER_PORT` | `8080` | Listening port |
|
||||
| `DLC_SERVER_HOST` | `0.0.0.0` | Bind address |
|
||||
| `DLC_LOGGING_JSON` | `false` | JSON log format |
|
||||
| `DLC_LOGGING_OUTPUT` | stderr | Log file path |
|
||||
| `DLC_SHUTDOWN_TIMEOUT` | `30s` | Graceful shutdown window |
|
||||
| `DLC_API_V2_ENABLED` | `false` | Enable `/api/v2` routes |
|
||||
| `DLC_CONFIG_FILE` | `./config.yaml` | Override config path |
|
||||
|
||||
# Custom port
|
||||
export DLC_SERVER_PORT=9090
|
||||
./scripts/start-server.sh start
|
||||
See `config.example.yaml` for a full template.
|
||||
|
||||
# JSON logging
|
||||
export DLC_LOGGING_JSON=true
|
||||
./scripts/start-server.sh start
|
||||
```
|
||||
## API
|
||||
|
||||
**See [AGENTS.md](AGENTS.md#configuration-management) for comprehensive configuration guide including:**
|
||||
- File-based configuration
|
||||
- Environment variables
|
||||
- Configuration priority rules
|
||||
- OpenTelemetry setup
|
||||
- Advanced scenarios
|
||||
|
||||
## Usage
|
||||
|
||||
### New Cobra CLI (Recommended)
|
||||
|
||||
```bash
|
||||
# Show help
|
||||
./bin/dance-lessons-coach --help
|
||||
|
||||
# Show version
|
||||
./bin/dance-lessons-coach version
|
||||
|
||||
# Greet someone
|
||||
./bin/dance-lessons-coach greet John
|
||||
|
||||
# Start server
|
||||
./bin/dance-lessons-coach server
|
||||
```
|
||||
|
||||
### Legacy CLI (Deprecated)
|
||||
|
||||
```bash
|
||||
# Default greeting
|
||||
go run ./cmd/greet
|
||||
# Output: Hello world!
|
||||
|
||||
# Custom greeting
|
||||
go run ./cmd/greet John
|
||||
# Output: Hello John!
|
||||
```
|
||||
|
||||
### Web Server
|
||||
|
||||
**Using the server control script (recommended):**
|
||||
|
||||
```bash
|
||||
# Start the server
|
||||
./scripts/start-server.sh start
|
||||
|
||||
# Test API endpoints
|
||||
./scripts/start-server.sh test
|
||||
|
||||
# Access OpenAPI documentation
|
||||
# Swagger UI: http://localhost:8080/swagger/
|
||||
# OpenAPI spec: http://localhost:8080/swagger/doc.json
|
||||
|
||||
# Stop the server
|
||||
./scripts/start-server.sh stop
|
||||
```
|
||||
|
||||
**Manual server management:**
|
||||
|
||||
```bash
|
||||
# Start the server
|
||||
go run ./cmd/server
|
||||
|
||||
# Test API endpoints
|
||||
curl http://localhost:8080/api/health
|
||||
# Output: {"status":"healthy"}
|
||||
|
||||
curl http://localhost:8080/api/ready
|
||||
# Output: {"ready":true}
|
||||
|
||||
curl http://localhost:8080/api/v1/greet
|
||||
# Output: {"message":"Hello world!"}
|
||||
|
||||
curl http://localhost:8080/api/v1/greet/John
|
||||
# Output: {"message":"Hello John!"}
|
||||
```
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/health` | Liveness check |
|
||||
| GET | `/api/ready` | Readiness check (503 during shutdown) |
|
||||
| GET | `/api/version` | Version info (`?format=plain\|full\|json`) |
|
||||
| GET | `/api/v1/greet/` | Default greeting |
|
||||
| GET | `/api/v1/greet/{name}` | Named greeting |
|
||||
| POST | `/api/v2/greet` | V2 greeting with validation |
|
||||
| GET | `/swagger/` | Swagger UI |
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
go test ./...
|
||||
|
||||
# Run specific package tests
|
||||
go test ./pkg/greet/
|
||||
go test ./... # unit + integration tests
|
||||
./scripts/test-graceful-shutdown.sh # lifecycle + JSON logging validation
|
||||
./scripts/test-opentelemetry.sh # tracing end-to-end
|
||||
```
|
||||
|
||||
## CI/CD
|
||||
## Gitea Client
|
||||
|
||||
dance-lessons-coach includes a comprehensive CI/CD pipeline with multiple testing options:
|
||||
AI agent helper script at `.vibe/skills/gitea-client/scripts/gitea-client.sh`.
|
||||
|
||||
### Local Testing (No Gitea Required)
|
||||
Auth setup:
|
||||
```bash
|
||||
# Validate workflow structure
|
||||
./scripts/cicd.sh validate
|
||||
|
||||
# Test workflow steps locally
|
||||
./scripts/cicd.sh test-simple
|
||||
echo "your_token" > ~/.gitea_token
|
||||
chmod 600 ~/.gitea_token
|
||||
export GITEA_API_TOKEN_FILE="$HOME/.gitea_token"
|
||||
```
|
||||
|
||||
### Gitea Integration
|
||||
```bash
|
||||
# Test local setup with Gitea configuration
|
||||
./scripts/cicd.sh test-local
|
||||
|
||||
# Check pipeline status on Gitea
|
||||
./scripts/cicd.sh check-status
|
||||
```
|
||||
|
||||
### Full CI/CD Testing
|
||||
```bash
|
||||
# Test with docker compose (requires Gitea runner)
|
||||
./scripts/cicd.sh test-docker
|
||||
```
|
||||
|
||||
**See [adr/0016-ci-cd-pipeline-design.md](adr/0016-ci-cd-pipeline-design.md) for complete CI/CD architecture.**
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
dance-lessons-coach/
|
||||
├── adr/ # Architecture Decision Records
|
||||
├── cmd/ # Entry points (greet CLI, server)
|
||||
├── pkg/ # Core packages (config, greet, server, telemetry)
|
||||
│ └── server/docs/ # Generated OpenAPI documentation (gitignored)
|
||||
├── config.yaml # Configuration file
|
||||
├── scripts/ # Management scripts
|
||||
└── go.mod # Go module definition
|
||||
```
|
||||
|
||||
**See [AGENTS.md](AGENTS.md#project-structure) for detailed structure and component explanations.**
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Generate OpenAPI Documentation
|
||||
|
||||
The project uses [swaggo/swag](https://github.com/swaggo/swag) to generate OpenAPI/Swagger documentation from code annotations:
|
||||
|
||||
```bash
|
||||
# Generate documentation
|
||||
go generate ./pkg/server/
|
||||
|
||||
# This creates:
|
||||
# - pkg/server/docs/docs.go (swagger template)
|
||||
# - pkg/server/docs/swagger.json (OpenAPI spec)
|
||||
# - pkg/server/docs/swagger.yaml (YAML version)
|
||||
```
|
||||
|
||||
**Note:** `pkg/server/docs/` is gitignored. Documentation is embedded in the binary at build time.
|
||||
|
||||
### Documentation Annotations
|
||||
|
||||
Add swagger annotations to handlers and models:
|
||||
|
||||
```go
|
||||
// @Summary Get personalized greeting
|
||||
// @Description Returns a greeting with the specified name
|
||||
// @Tags greet
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name path string true "Name to greet"
|
||||
// @Success 200 {object} GreetResponse "Successful response"
|
||||
// @Failure 400 {object} ErrorResponse "Invalid name parameter"
|
||||
// @Router /v1/greet/{name} [get]
|
||||
func (h *apiV1GreetHandler) handleGreetPath(w http.ResponseWriter, r *http.Request) {
|
||||
// handler implementation
|
||||
}
|
||||
```
|
||||
Get a token at https://gitea.arcodange.lab → Profile → Settings → Applications.
|
||||
|
||||
## Architecture
|
||||
|
||||
This project uses Architecture Decision Records (ADRs) to document key technical choices. See [adr/](adr/) for complete documentation including decisions on Go 1.26.1, Chi router, Zerolog, OpenTelemetry, interface-based design, graceful shutdown, configuration management, testing strategies, and OpenAPI documentation.
|
||||
|
||||
**Adding new decisions?** See [adr/README.md](adr/README.md) for guidelines.
|
||||
|
||||
## Gitea Integration
|
||||
|
||||
dance-lessons-coach includes AI agent skills for Gitea integration to monitor CI/CD jobs and interact with pull requests.
|
||||
|
||||
### Gitea Client Skill Setup
|
||||
|
||||
The Gitea client skill enables AI agents to:
|
||||
- Monitor CI/CD job status
|
||||
- Fetch job logs for debugging
|
||||
- Comment on pull requests
|
||||
- Track PR status
|
||||
|
||||
**Setup Instructions:**
|
||||
|
||||
1. **Create a Personal Access Token:**
|
||||
- Log in to https://gitea.arcodange.lab
|
||||
- Go to Profile → Settings → Applications
|
||||
- Generate token with `read:repository`, `write:repository`, and `read:user` scopes
|
||||
|
||||
2. **Configure Authentication:**
|
||||
```bash
|
||||
# Option 1: Environment variable
|
||||
export GITEA_API_TOKEN="your_token"
|
||||
|
||||
# Option 2: Token file (recommended)
|
||||
echo "your_token" > ~/.gitea_token
|
||||
chmod 600 ~/.gitea_token
|
||||
export GITEA_API_TOKEN_FILE="$HOME/.gitea_token"
|
||||
```
|
||||
|
||||
3. **Add to shell configuration:**
|
||||
```bash
|
||||
echo 'export GITEA_API_TOKEN_FILE="$HOME/.gitea_token"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
**Usage Examples:**
|
||||
```bash
|
||||
# List recent jobs
|
||||
.vibe/skills/gitea-client/scripts/gitea-client.sh list-jobs owner repo workflow_id 5
|
||||
|
||||
# Wait for job completion
|
||||
.vibe/skills/gitea-client/scripts/gitea-client.sh wait-job owner repo job_id 300
|
||||
|
||||
# Comment on PR
|
||||
.vibe/skills/gitea-client/scripts/gitea-client.sh comment-pr owner repo 42 "Build completed!"
|
||||
```
|
||||
|
||||
**Documentation:** See [.vibe/skills/gitea-client/README.md](.vibe/skills/gitea-client/README.md) for complete setup and usage guide.
|
||||
|
||||
## 🤖 AI Agent Usage
|
||||
|
||||
### Quick Launch Commands
|
||||
|
||||
**Programmer Agent** (for code implementation, testing, CI/CD):
|
||||
```bash
|
||||
vibe start --agent dancelessonscoachprogrammer
|
||||
```
|
||||
|
||||
**Product Owner Agent** (for requirements, interviews, documentation):
|
||||
```bash
|
||||
vibe start --agent dancelessonscoach-product-owner
|
||||
```
|
||||
|
||||
### Full Documentation
|
||||
|
||||
For complete agent usage guide including:
|
||||
- Agent selection guidance
|
||||
- Common workflow examples
|
||||
- Configuration reference
|
||||
- Best practices
|
||||
- Troubleshooting tips
|
||||
|
||||
See: [AGENT_USAGE_GUIDE.md](documentation/AGENT_USAGE_GUIDE.md)
|
||||
|
||||
### Gitmoji Cheatsheet
|
||||
|
||||
Quick reference for commit messages:
|
||||
- **📝 `:memo:` docs** - Documentation
|
||||
- **✨ `:sparkles:` feat** - New feature
|
||||
- **🐛 `:bug:` fix** - Bug fix
|
||||
- **♻️ `:recycle:` refactor** - Code refactoring
|
||||
- **🔧 `:wrench:` chore** - Build/config changes
|
||||
|
||||
Full cheatsheet: [GITMOJI_CHEATSHEET.md](documentation/GITMOJI_CHEATSHEET.md)
|
||||
Key decisions are documented in [adr/](adr/). See [AGENTS.md](AGENTS.md) for the full development reference (commands, config, ADR index, commit conventions).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Use Go 1.26.1 as the standard Go version
|
||||
|
||||
* Status: Accepted
|
||||
* Deciders: Gabriel Radureau, AI Agent
|
||||
* Date: 2026-04-01
|
||||
**Status:** Accepted
|
||||
**Authors:** Gabriel Radureau, AI Agent
|
||||
**Date:** 2026-04-01
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Use Chi router for HTTP routing
|
||||
|
||||
* Status: Accepted
|
||||
* Deciders: Gabriel Radureau, AI Agent
|
||||
* Date: 2026-04-02
|
||||
**Status:** Accepted
|
||||
**Authors:** Gabriel Radureau, AI Agent
|
||||
**Date:** 2026-04-02
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Use Zerolog for structured logging
|
||||
|
||||
* Status: Accepted
|
||||
* Deciders: Gabriel Radureau, AI Agent
|
||||
* Date: 2026-04-02
|
||||
**Status:** Accepted
|
||||
**Authors:** Gabriel Radureau, AI Agent
|
||||
**Date:** 2026-04-02
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Adopt interface-based design pattern
|
||||
|
||||
* Status: Accepted
|
||||
* Deciders: Gabriel Radureau, AI Agent
|
||||
* Date: 2026-04-02
|
||||
**Status:** Accepted
|
||||
**Authors:** Gabriel Radureau, AI Agent
|
||||
**Date:** 2026-04-02
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Implement graceful shutdown with readiness endpoints
|
||||
|
||||
* Status: Accepted
|
||||
* Deciders: Gabriel Radureau, AI Agent
|
||||
* Date: 2026-04-03
|
||||
**Status:** Accepted
|
||||
**Authors:** Gabriel Radureau, AI Agent
|
||||
**Date:** 2026-04-03
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Use Viper for configuration management
|
||||
|
||||
* Status: Accepted
|
||||
* Deciders: Gabriel Radureau, AI Agent
|
||||
* Date: 2026-04-03
|
||||
**Status:** Accepted
|
||||
**Authors:** Gabriel Radureau, AI Agent
|
||||
**Date:** 2026-04-03
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Integrate OpenTelemetry for distributed tracing
|
||||
|
||||
* Status: Accepted
|
||||
* Deciders: Gabriel Radureau, AI Agent
|
||||
* Date: 2026-04-04
|
||||
**Status:** Accepted
|
||||
**Authors:** Gabriel Radureau, AI Agent
|
||||
**Date:** 2026-04-04
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Adopt BDD with Godog for behavioral testing
|
||||
|
||||
* Status: Accepted
|
||||
* Deciders: Gabriel Radureau, AI Agent
|
||||
* Date: 2026-04-05
|
||||
**Status:** Accepted
|
||||
**Authors:** Gabriel Radureau, AI Agent
|
||||
**Date:** 2026-04-05
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
# Combine BDD and Swagger-based testing
|
||||
|
||||
* Status: ✅ Partially Implemented (BDD + Documentation only)
|
||||
* Deciders: Gabriel Radureau, AI Agent
|
||||
* Date: 2026-04-05
|
||||
* Last Updated: 2026-04-05
|
||||
* Implementation Status: BDD testing and OpenAPI documentation completed, SDK generation deferred
|
||||
**Status:** Implemented (BDD + OpenAPI documentation operational; SDK generation explicitly out of scope — would require a fresh ADR if reopened)
|
||||
**Authors:** Gabriel Radureau, AI Agent
|
||||
**Date:** 2026-04-05
|
||||
**Last Updated:** 2026-05-05
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
@@ -36,7 +35,7 @@ Chosen option: "Hybrid approach" because it provides the best combination of beh
|
||||
|
||||
## Implementation Status
|
||||
|
||||
**Status**: ✅ Partially Implemented (BDD + Documentation only)
|
||||
**Status**: ✅ Implemented (BDD + OpenAPI documentation operational; SDK generation explicitly out of scope)
|
||||
|
||||
### What We Actually Have
|
||||
|
||||
@@ -329,7 +328,7 @@ If we need SDK generation in the future:
|
||||
- Add SDK-based BDD tests
|
||||
- Implement true hybrid testing approach
|
||||
|
||||
**Current Status:** ✅ Partially Implemented (BDD + Documentation)
|
||||
**Current Status:** ✅ Implemented (BDD + OpenAPI documentation; SDK generation out of scope)
|
||||
**BDD Tests:** http://localhost:8080/api/health (all passing)
|
||||
**OpenAPI Docs:** http://localhost:8080/swagger/
|
||||
**OpenAPI Spec:** http://localhost:8080/swagger/doc.json
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
# 13. OpenAPI/Swagger Toolchain Selection
|
||||
|
||||
**Date:** 2026-04-05
|
||||
**Status:** ✅ Partially Implemented (Documentation only)
|
||||
**Status:** Implemented (OpenAPI documentation operational; SDK generation explicitly out of scope, see ADR-0009)
|
||||
**Authors:** Arcodange Team
|
||||
**Implementation Date:** 2026-04-05
|
||||
**Last Updated:** 2026-04-05
|
||||
**Status:** OpenAPI documentation operational, SDK generation deferred
|
||||
**Last Updated:** 2026-05-05
|
||||
|
||||
## Context
|
||||
|
||||
@@ -983,7 +982,7 @@ If we need SDK generation in the future:
|
||||
4. Implement request validation middleware
|
||||
5. Migrate to OpenAPI 3.0 if needed
|
||||
|
||||
**Current Status:** ✅ Partially Implemented (Documentation only)
|
||||
**Current Status:** ✅ Implemented (OpenAPI documentation; SDK generation out of scope)
|
||||
**Implementation:** swaggo/swag with embedded documentation
|
||||
**Documentation:** http://localhost:8080/swagger/
|
||||
**OpenAPI Spec:** http://localhost:8080/swagger/doc.json
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 15. CLI Subcommands and Flag Management with Cobra
|
||||
|
||||
**Date:** 2026-04-05
|
||||
**Status:** ✅ Implemented
|
||||
**Status:** Implemented
|
||||
**Authors:** Arcodange Team
|
||||
**Decision Date:** 2026-04-05
|
||||
**Implementation Status:** Phase 1 Complete
|
||||
@@ -222,7 +222,7 @@ dance-lessons-coach config validate
|
||||
|
||||
---
|
||||
|
||||
**Status:** Proposed
|
||||
**Status:** Proposed
|
||||
**Next Review:** 2026-04-12
|
||||
**Implementation Owner:** Arcodange Team
|
||||
**Approvers Needed:** @gabrielradureau
|
||||
@@ -1,10 +1,10 @@
|
||||
# 16. CI/CD Pipeline Design for Multi-Platform Compatibility
|
||||
|
||||
**Date:** 2026-04-05
|
||||
**Status:** ✅ Accepted
|
||||
**Status:** Accepted
|
||||
**Authors:** Arcodange Team
|
||||
**Decision Date:** 2026-04-08
|
||||
**Implementation Status:** ✅ Completed
|
||||
**Implementation Status:** Completed
|
||||
|
||||
## Context
|
||||
|
||||
@@ -832,7 +832,7 @@ jobs:
|
||||
- ✅ **Coverage reporting**: Badges updating automatically
|
||||
- ✅ **Binary builds**: Scripts executing properly in container environment
|
||||
|
||||
**Status:** ✅ Accepted
|
||||
**Status:** Accepted
|
||||
**Implementation Date:** 2026-04-08
|
||||
**Implementation Owner:** Arcodange Team
|
||||
**Reviewers:** @gabrielradureau
|
||||
@@ -1,10 +1,10 @@
|
||||
# 17. Trunk-Based Development Workflow for CI/CD Safety
|
||||
|
||||
**Date:** 2026-04-05
|
||||
**Status:** 🟢 Approved
|
||||
**Status:** Approved
|
||||
**Authors:** Arcodange Team
|
||||
**Decision Date:** 2026-04-05
|
||||
**Implementation Status:** ✅ Implemented
|
||||
**Implementation Status:** Implemented
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 18. User Management and Authentication System
|
||||
|
||||
**Date:** 2024-04-06
|
||||
**Status:** Proposed
|
||||
**Date:** 2026-04-06
|
||||
**Status:** Implemented (user model, JWT auth, password-reset workflow, admin endpoints, greet personalization, BDD coverage all live; future enhancements like 2FA / email verification belong in separate ADRs)
|
||||
**Authors:** Product Owner
|
||||
**Decision Drivers:** Security, User Personalization, Admin Functionality
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 19. PostgreSQL Database Integration
|
||||
|
||||
**Date:** 2024-04-07
|
||||
**Status:** Proposed
|
||||
**Date:** 2026-04-07
|
||||
**Status:** Implemented (core integration; performance tuning + extended monitoring tracked as future work)
|
||||
**Authors:** Product Owner
|
||||
**Decision Drivers:** Data Persistence, Scalability, Production Readiness
|
||||
|
||||
@@ -359,8 +359,6 @@ The PostgreSQL integration follows established dance-lessons-coach patterns:
|
||||
2. **Configuration Updates:** New database configuration structure
|
||||
3. **Development Workflow:** Docker-based database for local development
|
||||
|
||||
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Alternative 1: Keep SQLite with File Persistence
|
||||
@@ -673,10 +671,10 @@ func AfterScenario(ctx context.Context, sc *godog.Scenario, err error) (context.
|
||||
## Future Considerations
|
||||
|
||||
### Immediate Next Steps (Post-Migration)
|
||||
1. **CI/CD Integration:** Add PostgreSQL to CI pipeline
|
||||
2. **Performance Tuning:** Query optimization
|
||||
3. **Monitoring:** Database health metrics
|
||||
4. **Backup Strategy:** Regular database backups
|
||||
1. **CI/CD Integration:** Add PostgreSQL to CI pipeline — ✅ Implemented (`postgres:15` service in `.gitea/workflows/ci-cd.yaml`, all BDD tests run against real Postgres)
|
||||
2. **Performance Tuning:** Query optimization — Deferred. No production hot path identified. Reopen as separate ADR if/when latency budget exceeded.
|
||||
3. **Monitoring:** Database health metrics — Partial. `/api/healthz` reports DB connectivity. Deeper metrics (slow query log, pool stats) deferred until ADR-0022 cache Phase 2 lands.
|
||||
4. **Backup Strategy:** Regular database backups — Deferred. No production data yet. Will require separate ADR before any production data lands.
|
||||
|
||||
### Long-Term Enhancements
|
||||
1. **Database Sharding:** For horizontal scaling
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# ADR 0020: Docker Build Strategy - Traditional vs Buildx
|
||||
|
||||
## Status
|
||||
**Accepted** ✅
|
||||
**Status:** Accepted
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# 10. JWT Secret Retention Policy
|
||||
# 21. JWT Secret Retention Policy
|
||||
|
||||
## Status
|
||||
**Proposed** 🟡
|
||||
**Status:** Implemented (2026-05-05 — `pkg/user/jwt_manager.go` `RemoveExpiredSecrets` + `StartCleanupLoop`, wired in `pkg/server/server.go` `Run`; admin endpoint `/api/v1/admin/jwt/secrets` remains explicitly out of scope and tracked under @todo BDD scenarios)
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# ADR 0022: Rate Limiting and Cache Strategy
|
||||
|
||||
## Status
|
||||
**Proposed** 🟡
|
||||
**Status:** Implemented (Phase 1) - Phase 2 still Proposed
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Config Hot Reloading Strategy
|
||||
|
||||
* Status: Proposed
|
||||
* Deciders: Gabriel Radureau, AI Agent
|
||||
* Date: 2026-04-05
|
||||
**Status:** Implemented — all 4 phases shipped (2026-05-05). Hot-reloadable fields: `logging.level` (Phase 1), `auth.jwt.ttl` (Phase 2), `telemetry.sampler.type` + `telemetry.sampler.ratio` (Phase 3), `api.v2_enabled` (Phase 4). Plumbing: `Config.WatchAndApply` in `pkg/config/config.go` is the single entry point. Phase 2 fixed a pre-existing bug where hardcoded 24h TTL ignored `auth.jwt.ttl`. Phase 4 chose the **always-register-with-middleware-gate** approach: v2 routes are now ALWAYS registered, and `Server.v2EnabledGate` middleware reads the live config on every request (returns 404 + JSON body when disabled). No router rebuild needed for the flag flip. 3 unit tests in `pkg/server/v2_gate_test.go` cover blocked-when-disabled / passes-when-enabled / hot-reload-mid-life-of-same-Server.
|
||||
**Authors:** Gabriel Radureau, AI Agent
|
||||
**Date:** 2026-04-05
|
||||
**Last Updated:** 2026-05-05
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# ADR 0024: BDD Test Organization and Isolation Strategy
|
||||
|
||||
## Status
|
||||
**Proposed** 🟡
|
||||
**Status:** Implemented (Phase 1 + Phase 2 + Phase 3 — parallel testing via [PR #35](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/35), isolation strategy detailed in [ADR-0025](0025-bdd-scenario-isolation-strategies.md))
|
||||
|
||||
## Context
|
||||
|
||||
@@ -285,20 +284,22 @@ func CleanupFeatureData(featureName string) {
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Refactor Current Tests (1-2 weeks)
|
||||
1. Split monolithic feature files into feature directories
|
||||
2. Create feature-specific test scripts
|
||||
3. Implement basic isolation (config files, database names)
|
||||
### Phase 1: Refactor Current Tests — ✅ Implemented
|
||||
1. Split monolithic feature files into feature directories — done (see `features/<domain>/` layout)
|
||||
2. Create feature-specific test scripts — done
|
||||
3. Implement basic isolation (config files, database names) — done
|
||||
|
||||
### Phase 2: Enhance Test Infrastructure (2-3 weeks)
|
||||
1. Add synchronization helpers to test framework
|
||||
2. Implement server lifecycle management
|
||||
3. Create comprehensive cleanup routines
|
||||
### Phase 2: Enhance Test Infrastructure — ✅ Implemented
|
||||
1. Add synchronization helpers to test framework — done
|
||||
2. Implement server lifecycle management — done (`pkg/bdd/testserver/server.go`)
|
||||
3. Create comprehensive cleanup routines — done
|
||||
|
||||
### Phase 3: Parallel Testing (Optional)
|
||||
1. Add parallel test execution capability
|
||||
2. Implement port management for parallel runs
|
||||
3. Add resource monitoring
|
||||
### Phase 3: Parallel Testing — ✅ Implemented (PR #35, 2026-05-03)
|
||||
1. Add parallel test execution capability — done (schema-per-package isolation, **2.85x speedup**)
|
||||
2. Implement port management for parallel runs — done (`pkg/bdd/parallel/port_manager.go`)
|
||||
3. Add resource monitoring — deferred (not blocking; can be reopened as separate ADR if/when CI flakiness re-emerges)
|
||||
|
||||
The strategy choice between alternatives (TRUNCATE vs schema isolation vs container-per-test) is documented in [ADR-0025](0025-bdd-scenario-isolation-strategies.md). Default behavior in CI is `BDD_SCHEMA_ISOLATION=true` (cf. `documentation/BDD_TEST_ENV.md`).
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
|
||||
340
adr/0025-bdd-scenario-isolation-strategies.md
Normal file
340
adr/0025-bdd-scenario-isolation-strategies.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# ADR 0025: BDD Scenario Isolation Strategies
|
||||
|
||||
**Status:** Implemented (per-package schema isolation since T12 stage 2/2 - 2026-05-03)
|
||||
|
||||
## Context
|
||||
|
||||
As our BDD test suite grows, we're encountering **test pollution** issues where scenarios interfere with each other through shared state. This is particularly problematic for:
|
||||
|
||||
1. **Database state**: Scenarios create users, JWT secrets, config entries that persist across scenarios
|
||||
2. **JWT secret rotation**: Multiple secrets accumulate, affecting subsequent scenario authentication
|
||||
3. **Config file modifications**: Feature flag changes persist between tests
|
||||
4. **Gherkin Background steps**: Data set up in Background is visible to all scenarios in the feature
|
||||
|
||||
Our current approach clears database tables after each scenario, but this has **race condition vulnerabilities** with concurrent scenario execution.
|
||||
|
||||
### Gherkin Background Consideration
|
||||
|
||||
Crucially, Gherkin's `Background` section runs **before each scenario** in a feature, not once before all scenarios. This means:
|
||||
|
||||
```gherkin
|
||||
Feature: User registration
|
||||
Background:
|
||||
Given the database is empty
|
||||
And a default admin user exists
|
||||
|
||||
Scenario: Register new user
|
||||
When I register user "alice"
|
||||
Then user "alice" should exist
|
||||
|
||||
Scenario: Register duplicate user
|
||||
When I register user "alice"
|
||||
Then I should see error "user already exists"
|
||||
```
|
||||
|
||||
The second scenario fails because Background creates data that persists, and the first scenario's data isn't cleaned up. Background steps are re-executed before each scenario.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
* **Isolation**: Each scenario must start with a clean slate
|
||||
* **Performance**: Cleanup must be fast enough for CI/CD pipelines
|
||||
* **Concurrency**: Must work with parallel scenario execution
|
||||
* **Compatibility**: Must work with Gherkin Background steps
|
||||
* **Maintainability**: Solution should be simple to understand and debug
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: Transaction Rollback (Rejected ❌)
|
||||
|
||||
Wrap each scenario in a database transaction, rollback at the end.
|
||||
|
||||
```go
|
||||
BeforeScenario: BEGIN;
|
||||
AfterScenario: ROLLBACK;
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Simple implementation
|
||||
- Fast - transaction rollback is nearly instant
|
||||
- No data cleanup needed
|
||||
|
||||
**Cons:**
|
||||
- ❌ **Fails if scenario commits**: Nested transaction problem - `COMMIT` inside scenario releases the transaction, parent `ROLLBACK` has no effect
|
||||
- Cannot handle non-database state (JWT secrets in memory, config files)
|
||||
- Doesn't solve JWT secret pollution
|
||||
|
||||
**Verdict: Not viable** - Too many scenarios use database transactions internally.
|
||||
|
||||
---
|
||||
|
||||
### Option 2: Clear Tables in Public Schema (Current ✅/⚠️)
|
||||
|
||||
Delete all rows from all tables after each scenario.
|
||||
|
||||
```go
|
||||
AfterScenario: DELETE FROM table1; DELETE FROM table2; ...
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Currently implemented
|
||||
- Works with any scenario code
|
||||
- Handles database state
|
||||
|
||||
**Cons:**
|
||||
- ⚠️ **Race conditions**: Concurrent scenarios can interleave - Scenario A deletes data while Scenario B is still using it
|
||||
- ⚠️ **Slow**: Must delete from all tables, reset sequences
|
||||
- ❌ **Misses in-memory state**: JWT secrets, config changes persist
|
||||
- ❌ **Doesn't handle Background**: Background data is shared across scenarios
|
||||
|
||||
**Verdict: Partially adequate** - Works for sequential execution but has parallel execution issues.
|
||||
|
||||
---
|
||||
|
||||
### Option 3: Schema-per-Scenario (Recommended ✅)
|
||||
|
||||
Create a unique PostgreSQL schema for each scenario, drop it after.
|
||||
|
||||
```go
|
||||
BeforeScenario:
|
||||
schema := "test_" + sha256(scenario.Name)[:8]
|
||||
CREATE SCHEMA schema;
|
||||
SET search_path = schema, public;
|
||||
|
||||
AfterScenario:
|
||||
DROP SCHEMA schema CASCADE;
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- ✅ **True isolation**: Each scenario has its own database namespace
|
||||
- ✅ **Works with transactions**: Scenario can commit freely - entire schema is dropped
|
||||
- ✅ **Works with Background**: Background runs in scenario's schema, data is isolated
|
||||
- ✅ **Fast**: Schema drop is instant (just metadata deletion)
|
||||
- ✅ **Handles concurrent scenarios**: Different schemas = no conflicts
|
||||
|
||||
**Cons:**
|
||||
- Requires `CREATE/DROP SCHEMA` database privileges in test environment
|
||||
- Some ORMs may hardcode `public` schema - need to use `SET search_path` carefully
|
||||
- Test DB must allow many schemas (typically fine for PostgreSQL)
|
||||
- We need to handle `search_path` in connection pooling (each scenario needs its own connection)
|
||||
|
||||
**Implementation notes:**
|
||||
- Use `Luego` (PostgreSQL schema prefix) approach: `test_{hash}`
|
||||
- Hash: `sha256(feature_name + scenario_name)[:8]` for consistency across runs
|
||||
- Execute Background steps in the scenario's schema context
|
||||
- Set `search_path` at the connection level, not globally
|
||||
|
||||
---
|
||||
|
||||
### Option 4: Database-per-Feature ⚠️
|
||||
|
||||
Create a separate database for each feature file.
|
||||
|
||||
```go
|
||||
BeforeFeature: CREATE DATABASE feature_auth;
|
||||
AfterFeature: DROP DATABASE feature_auth;
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Strong isolation between features
|
||||
- Simple implementation
|
||||
|
||||
**Cons:**
|
||||
- ❌ **Doesn't isolate scenarios within a feature** - Background data shared across scenarios
|
||||
- Database creation is slower than schema creation
|
||||
- Harder to manage in CI (more databases to create/cleanup)
|
||||
- Still need table clearing between scenarios within a feature
|
||||
|
||||
**Verdict: Insufficient** - Doesn't solve intra-feature pollution.
|
||||
|
||||
---
|
||||
|
||||
### Option 5: Schema-per-Feature + Table Clearing per Scenario ⚠️
|
||||
|
||||
Create one schema per feature, clear tables between scenarios.
|
||||
|
||||
```go
|
||||
BeforeFeature: CREATE SCHEMA feature_auth;
|
||||
AfterFeature: DROP SCHEMA feature_auth;
|
||||
AfterScenario: DELETE FROM all_tables;
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Isolates features from each other
|
||||
- Simpler than per-scenario schemas
|
||||
|
||||
**Cons:**
|
||||
- ❌ **Scenarios within a feature share state** - Background data persists
|
||||
- Still has race conditions with concurrent scenarios in same feature
|
||||
- Requires table clearing overhead
|
||||
|
||||
**Verdict: Better than current but still has issues**.
|
||||
|
||||
---
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Chosen option: Schema-per-Scenario + In-Memory State Reset + Per-Scenario Step State (Option 3 Enhanced)**
|
||||
|
||||
We will implement schema-per-scenario because it:
|
||||
|
||||
1. Provides **true isolation** for all database state
|
||||
2. **Works with Gherkin Background** - Background runs in each scenario's schema
|
||||
3. **Handles concurrent execution** - No race conditions
|
||||
4. **Works with scenario transactions** - Scenarios can commit freely
|
||||
5. Is **fast** - Schema operations are cheap
|
||||
|
||||
**However, we discovered a critical limitation:** PostgreSQL schemas only isolate **database tables**. In-memory state (application-level caches, user stores, JWT secret managers) **persists across scenarios** because they're stored in the shared `sharedServer` Go instance. Schema isolation does NOT solve this.
|
||||
|
||||
### Enhanced Strategy: Multi-Layer Isolation
|
||||
|
||||
To achieve **complete scenario isolation**, we need a **3-layer approach:**
|
||||
|
||||
| Layer | Component | Strategy | Status |
|
||||
|-------|-----------|----------|--------|
|
||||
| DB | PostgreSQL tables | Schema-per-scenario | ✅ Implemented |
|
||||
| Memory | Server-level state (JWT secrets) | Reset to initial state | ✅ Implemented |
|
||||
| Memory | Step-level state (tokens, user IDs) | Per-scenario state map | ✅ Implemented |
|
||||
| Memory | User store | Reset/clear between scenarios | ⚠️ TODO |
|
||||
| Memory | Auth cache | Reset/clear between scenarios | ⚠️ TODO |
|
||||
| Cache | Redis/Memcached | Key prefix with schema hash | ⚠️ TODO |
|
||||
|
||||
### Layer 3: Per-Scenario Step State Isolation
|
||||
|
||||
**New insight from test failures:** Step definition structs (AuthSteps, GreetSteps, etc.) maintain state in their fields:
|
||||
- `lastToken`, `firstToken` in AuthSteps
|
||||
- `lastUserID` in AuthSteps
|
||||
|
||||
This state **spills across scenarios** even with schema isolation, because struct fields are shared across all scenarios in a test process.
|
||||
|
||||
**Solution:** Create a `ScenarioState` manager with per-scenario isolation:
|
||||
|
||||
```go
|
||||
type ScenarioState struct {
|
||||
LastToken string
|
||||
FirstToken string
|
||||
LastUserID uint
|
||||
}
|
||||
|
||||
type scenarioStateManager struct {
|
||||
mu sync.RWMutex
|
||||
states map[string]*ScenarioState // keyed by scenario hash
|
||||
}
|
||||
|
||||
// Usage in step definitions:
|
||||
func (s *AuthSteps) iShouldReceiveAValidJWTToken() error {
|
||||
state := steps.GetScenarioState(s.scenarioName)
|
||||
state.LastToken = extractedToken
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Zero code changes to step definitions (with helper functions)
|
||||
- ✅ Thread-safe (sync.RWMutex)
|
||||
- ✅ Consistent state per scenario
|
||||
- ✅ Automatic cleanup via BeforeScenario/AfterScenario hooks
|
||||
- ✅ Works with random test order
|
||||
|
||||
**Status:** Implemented in `pkg/bdd/steps/scenario_state.go`
|
||||
|
||||
### Key Insight: Cache and In-Memory Store Isolation
|
||||
|
||||
**For caches (Redis, Memcached, in-process):**
|
||||
- Use **schema hash as key prefix/suffix**: `cache_key_{schema_hash}` or `{schema_hash}_cache_key`
|
||||
- This ensures each scenario gets isolated cache namespace
|
||||
- Works even with external cache services
|
||||
- Consistent with schema isolation philosophy
|
||||
|
||||
**For in-memory stores (user repository, etc.):**
|
||||
- Add `Reset()` methods that clear all state
|
||||
- Call in `AfterScenario` alongside schema teardown
|
||||
- Or use schema-prefix approach for shared stores
|
||||
|
||||
### Alternative Approach: Background Explicit State Setup
|
||||
|
||||
**Considered but rejected:** Adding explicit "Given no user X exists" steps or heavy Background sections.
|
||||
|
||||
**Pros:** More readable, explicit about state
|
||||
**Cons:**
|
||||
- Error-prone (must remember for every entity)
|
||||
- Verbose (many Given steps)
|
||||
- Doesn't scale with many entities
|
||||
- Still has race conditions with concurrent scenarios
|
||||
|
||||
**Verdict:** Automated cleanup (schema drop + memory reset) is more reliable than manual Background setup.
|
||||
|
||||
### Implementation Plan
|
||||
|
||||
**Phase 1: Foundation (✅ Complete)**
|
||||
- Add scenario-aware schema management to test server
|
||||
- Implement schema creation/drop in BeforeScenario/AfterScenario hooks
|
||||
- Handle `search_path` configuration for each scenario's database connection
|
||||
|
||||
**Phase 2: In-Memory State Reset (🟡 TODO)**
|
||||
- Add `ResetUsers()` method to clear in-memory user store
|
||||
- Add `ResetCache()` method for auth/rateLimiting caches
|
||||
- Call these in AfterScenario alongside JWT secret reset
|
||||
- **Cache key strategy**: `key_{schema_hash}` for all cache operations
|
||||
|
||||
**Phase 3: Connection Pooling**
|
||||
- Configure connection pool to respect per-scenario `search_path`
|
||||
- Each scenario gets isolated connections
|
||||
|
||||
**Phase 4: Validation**
|
||||
- Run full test suite to verify complete isolation
|
||||
- Fix any hardcoded `public` schema references
|
||||
|
||||
### Schema Naming Convention
|
||||
|
||||
```
|
||||
Schema name: test_{sha256(feature:scenario)[:8]}
|
||||
Cache key prefix: {sha256(feature:scenario)[:8]}_
|
||||
```
|
||||
|
||||
Example:
|
||||
- Feature: `auth`, Scenario: `Successful user authentication`
|
||||
- Hash: `sha256("auth:Successful user authentication")[:8]` = `a3f7b2c1`
|
||||
- Schema: `test_a3f7b2c1`
|
||||
- Cache key: `a3f7b2c1_user:newuser` instead of just `user:newuser`
|
||||
|
||||
Benefits:
|
||||
- Unique per scenario
|
||||
- Consistent across test runs (same scenario = same hash)
|
||||
- Short (8 chars) - efficient for cache keys
|
||||
- Identifiable for debugging
|
||||
|
||||
### Schema Naming Convention
|
||||
|
||||
```
|
||||
Schema name: test_{sha256(feature + scenario)[:8]}
|
||||
```
|
||||
|
||||
Example:
|
||||
- Feature: `auth`, Scenario: `Successful user authentication`
|
||||
- Hash: `sha256("auth_Successful user authentication")[:8]` = `a3f7b2c1`
|
||||
- Schema: `test_a3f7b2c1`
|
||||
|
||||
Benefits:
|
||||
- Unique per scenario
|
||||
- Consistent across test runs (same scenario = same schema)
|
||||
- Short (8 chars + prefix = 14 chars max)
|
||||
- Identifiable for debugging
|
||||
|
||||
## Pros and Cons Summary
|
||||
|
||||
| Aspect | Schema-per-Scenario | Current (Clear Tables) | Transaction Rollback |
|
||||
|--------|---------------------|----------------------|-------------------|
|
||||
| Isolation | ✅ Strong | ⚠️ Medium | ❌ Weak |
|
||||
| Works with Background | ✅ Yes | ⚠️ Partial | ❌ No |
|
||||
| Concurrency safe | ✅ Yes | ❌ No | ❌ No |
|
||||
| Works with TX | ✅ Yes | ✅ Yes | ❌ No |
|
||||
| Speed | ✅ Fast | ⚠️ Slow | ✅ Fast |
|
||||
| DB privileges | ⚠️ Needs CREATE | ✅ None | ✅ None |
|
||||
| Complexity | ⚠️ Medium | ✅ Low | ✅ Low |
|
||||
|
||||
## Links
|
||||
|
||||
* [ADR 0008: BDD Testing](adr/0008-bdd-testing.md) - Original BDD adoption decision
|
||||
* [ADR 0024: BDD Test Organization and Isolation](adr/0024-bdd-test-organization-and-isolation.md) - Feature isolation strategy
|
||||
* [Godog Documentation](https://github.com/cucumber/godog) - BDD framework specifics
|
||||
* [PostgreSQL Schemas](https://www.postgresql.org/docs/current/ddl-schemas.html) - Schema management
|
||||
200
adr/0026-composite-info-endpoint.md
Normal file
200
adr/0026-composite-info-endpoint.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# ADR 0026: Composite Info Endpoint vs Separate Calls
|
||||
|
||||
**Status:** Implemented (2026-05-05 — PR pending)
|
||||
|
||||
## Context
|
||||
|
||||
The application currently exposes several endpoints that provide system information:
|
||||
- `/api/version` - returns version, commit, build date, Go version (cached 60s)
|
||||
- `/api/health` - returns `{"status":"healthy"}` (simple liveness)
|
||||
- `/api/healthz` - returns rich health info: status, version, uptime_seconds, timestamp
|
||||
- `/api/ready` - returns readiness with connection details
|
||||
|
||||
Frontend components like `HealthDashboard` currently call `/api/healthz` to display server info. However, there is a need for a **composite endpoint** that aggregates:
|
||||
1. Version information (from `/api/version`)
|
||||
2. Build metadata (commit hash, build date)
|
||||
3. Uptime information (from `/api/healthz`)
|
||||
4. Cache status (enabled/disabled)
|
||||
5. Health status
|
||||
|
||||
This raises an architectural question: **Should we create a new composite `/api/info` endpoint, or should frontend components make multiple separate API calls?**
|
||||
|
||||
### The Problem with Separate Calls
|
||||
|
||||
If the frontend makes individual calls to `/api/version`, `/api/healthz`, and checks cache config separately:
|
||||
|
||||
1. **Multiple network requests**: 3-4 HTTP round trips per page load
|
||||
2. **Inconsistent data**: Responses may come from different moments in time
|
||||
3. **No caching coordination**: Each endpoint has its own cache key and TTL
|
||||
4. **Complex frontend logic**: Need to merge data from multiple sources
|
||||
5. **Poor user experience**: Slower page loads, multiple loading states
|
||||
|
||||
### Current State Analysis
|
||||
|
||||
| Endpoint | Data Provided | Cache TTL | Use Case |
|
||||
|----------|---------------|-----------|----------|
|
||||
| `/api/version` | version, commit, built, go | 60s | Version info |
|
||||
| `/api/healthz` | status, version, uptime_seconds, timestamp | None | K8s probes, health dashboard |
|
||||
| `/api/health` | status: "healthy" | None | Simple liveness |
|
||||
| `/api/ready` | ready, connections, reason | None | Readiness probes |
|
||||
|
||||
The `/api/healthz` endpoint already combines some data (status + version + uptime + timestamp), but it:
|
||||
- Doesn't include commit_short
|
||||
- Doesn't include build_date separately
|
||||
- Doesn't include cache_enabled
|
||||
- Is not cached
|
||||
- Has Kubernetes-specific field naming (`healthz`)
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
* **Performance**: Minimize network round trips for frontend
|
||||
* **Consistency**: All data should reflect the same point-in-time
|
||||
* **Maintainability**: Single source of truth for system info
|
||||
* **Caching**: Reuse existing cache infrastructure (ADR-0022)
|
||||
* **API Design**: Follow REST principles and existing patterns
|
||||
* **Backward Compatibility**: Existing endpoints must remain unchanged
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: Composite `/api/info` Endpoint (Chosen)
|
||||
|
||||
Create a new endpoint that aggregates all required data in a single call.
|
||||
|
||||
**Pros:**
|
||||
- ✅ Single network request for frontend
|
||||
- ✅ Consistent point-in-time data
|
||||
- ✅ Can leverage existing cache infrastructure with key `info:json`
|
||||
- ✅ Follows existing pattern of `/api/version` caching
|
||||
- ✅ Clean API design - one endpoint, one purpose
|
||||
- ✅ Reduces frontend complexity
|
||||
- ✅ Better UX - faster page loads
|
||||
- ✅ Aligns with ADR-0022 cache strategy (reusable cache key pattern)
|
||||
|
||||
**Cons:**
|
||||
- ⚠️ Duplicates some data from `/api/healthz` and `/api/version`
|
||||
- ⚠️ Requires new endpoint implementation
|
||||
- ⚠️ Need to maintain consistency if source endpoints change
|
||||
|
||||
### Option 2: Frontend Aggregation with Multiple Calls
|
||||
|
||||
Frontend makes separate calls to `/api/version`, `/api/healthz`, and introspects config.
|
||||
|
||||
**Pros:**
|
||||
- ✅ No backend changes required
|
||||
- ✅ Uses existing endpoints
|
||||
|
||||
**Cons:**
|
||||
- ❌ Multiple network requests (3-4 round trips)
|
||||
- ❌ Inconsistent data timing
|
||||
- ❌ Complex error handling in frontend
|
||||
- ❌ Poor UX - multiple loading states, slower
|
||||
- ❌ Each endpoint has different caching behavior
|
||||
- ❌ Violates DRY - same data fetched multiple times
|
||||
|
||||
### Option 3: Extend `/api/healthz` Endpoint
|
||||
|
||||
Add `commit_short`, `build_date`, and `cache_enabled` fields to existing `/api/healthz`.
|
||||
|
||||
**Pros:**
|
||||
- ✅ Reuses existing endpoint
|
||||
- ✅ Single request
|
||||
|
||||
**Cons:**
|
||||
- ❌ Breaks backward compatibility (response schema change)
|
||||
- ❌ `/api/healthz` is Kubernetes-focused (naming convention)
|
||||
- ❌ Not cached currently
|
||||
- ❌ Mixes health probe concerns with version info
|
||||
- ❌ Violates single responsibility
|
||||
|
||||
### Option 4: GraphQL / Query Parameters
|
||||
|
||||
Allow clients to specify which fields they want via query parameters.
|
||||
|
||||
**Pros:**
|
||||
- ✅ Flexible - clients get exactly what they need
|
||||
- ✅ Single endpoint
|
||||
|
||||
**Cons:**
|
||||
- ❌ Overkill for this use case
|
||||
- ❌ Not consistent with existing REST API design
|
||||
- ❌ Complex implementation
|
||||
- ❌ Not aligned with project architecture (Chi router, REST style)
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Chosen: Option 1 - Composite `/api/info` Endpoint**
|
||||
|
||||
We will implement a new `GET /api/info` endpoint that returns a JSON object with all required fields in a single call. This endpoint will:
|
||||
|
||||
1. Aggregate data from existing sources (`version` package, `config`, server uptime)
|
||||
2. Be cached using the existing cache service with key `info:json`
|
||||
3. Use TTL from `config.cache.default_ttl_seconds` (consistent with ADR-0022)
|
||||
4. Return `X-Cache: HIT/MISS` headers for debugging
|
||||
5. Follow existing Go handler patterns from `pkg/server/server.go`
|
||||
|
||||
### Response Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.4.0",
|
||||
"commit_short": "a3f7b2c1",
|
||||
"build_date": "2026-05-04T08:00:00Z",
|
||||
"uptime_seconds": 1234,
|
||||
"cache_enabled": true,
|
||||
"healthz_status": "healthy",
|
||||
"go_version": "go1.26.1"
|
||||
}
|
||||
```
|
||||
|
||||
The `go_version` field provides the Go runtime version via `runtime.Version()`, useful for ops debugging (e.g., identifying which Go version is running in production).
|
||||
|
||||
### Rationale
|
||||
|
||||
1. **Performance**: Single HTTP request instead of 3-4 separate calls
|
||||
2. **Consistency**: All data reflects the same moment in time
|
||||
3. **Caching**: Leverages existing cache infrastructure (ADR-0022) with predictable key pattern
|
||||
4. **API Design**: Clean, RESTful endpoint with single responsibility
|
||||
5. **Maintainability**: Clear separation of concerns - info aggregation is a distinct use case
|
||||
6. **Backward Compatibility**: Existing endpoints remain unchanged
|
||||
7. **Frontend Simplicity**: Reduces complexity and improves UX
|
||||
|
||||
### Cache Strategy
|
||||
|
||||
Following ADR-0022 pattern:
|
||||
- Cache key: `info:json` (consistent with `version:format` pattern)
|
||||
- TTL: `config.cache.default_ttl_seconds` (default 300 seconds)
|
||||
- Cache service: `pkg/cache/cache.go` InMemoryService
|
||||
- Headers: `X-Cache: HIT` or `X-Cache: MISS`
|
||||
|
||||
This allows the endpoint to be fast even under load, while maintaining data freshness.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. **Improved frontend performance**: Single request instead of multiple
|
||||
2. **Better UX**: Faster page loads, simpler loading states
|
||||
3. **Consistent data**: All fields reflect the same point-in-time
|
||||
4. **Cache efficiency**: Reuses existing cache infrastructure
|
||||
5. **Clean separation**: Info endpoint handles aggregation, source endpoints unchanged
|
||||
6. **Easy to test**: Single endpoint with predictable response
|
||||
|
||||
### Negative
|
||||
|
||||
1. **Data duplication**: Some fields appear in multiple endpoints
|
||||
2. **Maintenance burden**: If source data changes, endpoint must be updated
|
||||
3. **New endpoint**: Increases API surface area (though minimal)
|
||||
|
||||
### Mitigation
|
||||
|
||||
1. Data duplication is acceptable - it's read-only system info
|
||||
2. Source the data from the same packages/functions used by other endpoints
|
||||
3. The new endpoint has a clear, focused purpose
|
||||
|
||||
## Links
|
||||
|
||||
- [ADR-0002: Chi Router](adr/0002-chi-router.md) - Routing foundation
|
||||
- [ADR-0022: Rate Limiting Cache Strategy](adr/0022-rate-limiting-cache-strategy.md) - Cache pattern reference
|
||||
- [pkg/server/server.go](pkg/server/server.go) - Handler patterns
|
||||
- [pkg/cache/cache.go](pkg/cache/cache.go) - Cache service
|
||||
- [pkg/version/version.go](pkg/version/version.go) - Version data source
|
||||
128
adr/0027-ollama-tier1-onboarding.md
Normal file
128
adr/0027-ollama-tier1-onboarding.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# 27. Ollama Tier 1 onboarding via meta-trainer-bootstrap
|
||||
|
||||
**Date:** 2026-05-05
|
||||
**Status:** Proposed
|
||||
**Authors:** Gabriel Radureau, AI Agent (Claude Opus 4.7 Tier 3 inspector)
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
The autonomous trainer day on 2026-05-05 validated that Mistral Vibe (cloud) can drive a complete PR lifecycle on this project: ICM workspace → phase-planner → implementation → verifier audit → PR open (cf. PR #54, Q-041 in `~/.vibe/memory/reference/mistral-quirks.md`). Two limitations remain:
|
||||
|
||||
1. **Vendor risk** — every autonomous run consumes the Mistral cloud forfait. If the forfait runs out mid-month or the API is unavailable, autonomous capability is lost.
|
||||
2. **Sovereignty story** — ARCODANGE's stated direction (cf. `migration-claude-vers-mistral-phase-1.md`) is to reduce dependence on a single foreign vendor. The hardware exists locally (M4 128 GB) ; the missing link is wiring a local model into the same Tier 1 executor role Mistral plays today.
|
||||
|
||||
The user-flagged candidate models (cf. `~/.vibe/memory/reference/ollama-candidate-models.md`) :
|
||||
|
||||
* `nemotron-3-super`
|
||||
* `gemma4:31b`
|
||||
|
||||
Both are large enough to plausibly handle the agentic coding role and small enough to fit in 128 GB RAM with headroom for tools. Neither has been tested under the ARCODANGE methodology (canary suite, ICM workspace traversal, verifier-skill discipline).
|
||||
|
||||
The methodology to onboard a new Tier 1 already exists : the `meta-trainer-bootstrap` skill at `~/.vibe/skills/meta-trainer-bootstrap/`. It runs a 10-canary suite (C-001..C-010), copies + adapts the skill library to the new model's harness tool names, stands up a `<model>-quirks.md` baseline, and produces a Tier 3 audit report. It has been validated on Mistral itself (we are currently running the methodology Mistral-on-Mistral, which is unusual — the canary suite was originally written for a different model).
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
* **Forfait insurance** — a working local Tier 1 means autonomous capability survives a Mistral outage / forfait exhaustion
|
||||
* **Sovereignty** — local execution removes the single-vendor dependency for the autonomous workflow
|
||||
* **Methodology validation** — `meta-trainer-bootstrap` has never been run on a fresh model in production, only smoke-tested ; this is its first real test
|
||||
* **Cost** — Ollama is local-only (no per-call price). The cost is the bootstrap effort + ongoing M4 power consumption.
|
||||
* **Model maturity** — both candidates are recent ; their agentic coding ability is empirical, not theoretical
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: Bootstrap `nemotron-3-super` first, then `gemma4:31b`
|
||||
|
||||
Run the canary suite on each, document quirks separately, decide based on canary pass rate and cost-per-task.
|
||||
|
||||
* Good — comparative data, makes the choice empirical
|
||||
* Good — discovers any meta-trainer-bootstrap bugs early on the first attempt
|
||||
* Bad — doubles the bootstrap effort (~4-8 hours per model)
|
||||
* Bad — requires holding both models on disk (large)
|
||||
|
||||
### Option 2: Bootstrap one model only, picked on prior reputation
|
||||
|
||||
Pick one (e.g. `nemotron-3-super` per the user's explicit ordering in `ollama-candidate-models.md`) and commit. Skip the comparison.
|
||||
|
||||
* Good — half the effort, ships faster
|
||||
* Bad — no fallback if the chosen model is unsuitable
|
||||
* Bad — anchors the methodology to one model's quirks before we know they generalise
|
||||
|
||||
### Option 3: Defer until Mistral autonomous shows real strain
|
||||
|
||||
Do nothing yet. Wait for forfait pressure or a Mistral outage to force the issue. Reactive instead of proactive.
|
||||
|
||||
* Good — zero effort now
|
||||
* Bad — when the trigger fires, we are unprepared and the bootstrap is rushed
|
||||
* Bad — postpones validation of `meta-trainer-bootstrap` indefinitely
|
||||
|
||||
### Option 4: Skip Ollama, evaluate a different vendor (Anthropic, OpenAI)
|
||||
|
||||
Bring in a second cloud model as Tier 1 instead of going local.
|
||||
|
||||
* Good — likely higher quality than 31B local
|
||||
* Bad — replaces vendor dependence with two-vendor dependence ; doesn't solve sovereignty
|
||||
* Bad — we already have Claude as Tier 3 inspector via Anthropic ; mixing roles complicates the methodology
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: **Option 2 — Bootstrap `nemotron-3-super` first**, deferring `gemma4:31b` to a follow-up ADR if `nemotron-3-super` underperforms or shows unfixable quirks.
|
||||
|
||||
Rationale :
|
||||
- Forfait pressure is real but not immediate (~3.5% of monthly forfait spent on the heavy autonomous trainer day 2026-05-05) — we have time but should not procrastinate
|
||||
- Comparative testing (Option 1) is technically right but pragmatically slow for an unproven methodology
|
||||
- The user's explicit ordering signals their prior on which to try first ; respect it
|
||||
- If the canary suite fails substantially on `nemotron-3-super`, we pivot to `gemma4:31b` with the lessons (and per-model quirks file) from the first attempt — net learning either way
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. **Pre-flight** — verify `ollama` is installed, the model is pulled (`ollama pull nemotron-3-super`), and the M4 has enough free RAM (model size + ~16 GB headroom for tools).
|
||||
2. **Run `meta-trainer-bootstrap` skill** — pointing `TARGET_MODEL_ID=nemotron-3-super`, `TARGET_HARNESS=ollama run nemotron-3-super`, `TARGET_PROJECT_ROOT=<a fresh clone or worktree>`. Budget : 5 EUR-equivalent of Mistral Tier-2 orchestration cost + 2-4 hours of trainer attention.
|
||||
3. **Canary suite** — run C-001..C-010 ; record each result in `~/.vibe/memory/reference/nemotron-3-super-quirks.md` as `Q-101..Q-110` (the `Q-001..Q-099` range is reserved for the legacy Mistral baseline).
|
||||
4. **Skill library adaptation** — for each ARCODANGE skill currently relying on Mistral-specific tool names (`read_file`, `write_file`, etc.), adapt to whatever Ollama exposes. Document deltas.
|
||||
5. **Smoke test** — run a single small task end-to-end on a low-risk project. Use the ICM workspace pattern. Verify worktree isolation (Q-038 fix) still applies.
|
||||
6. **Tier 3 report** — produce `bootstrap-report.md` for Claude inspector review. Include canary pass rate, key quirks, KPI baseline numbers, open friction points.
|
||||
7. **Decision gate** — based on the report, either (a) promote `nemotron-3-super` to production Tier 1 and update `~/.vibe/config.toml` accordingly, (b) try `gemma4:31b` as a follow-up, or (c) escalate to Tier 3 for a strategic pivot.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Option 1 (Bootstrap both)
|
||||
|
||||
* Good — comparative data
|
||||
* Good — early bug detection on the methodology
|
||||
* Bad — double effort
|
||||
* Bad — no clear way to choose without significant additional time investment for the second model
|
||||
|
||||
### Option 2 (Chosen — `nemotron-3-super` first)
|
||||
|
||||
* Good — concrete forward motion
|
||||
* Good — methodology gets its first real test
|
||||
* Good — `meta-trainer-bootstrap` skill validated end-to-end (currently only smoke-tested)
|
||||
* Bad — risk of picking the wrong model and wasting the bootstrap effort
|
||||
* Mitigation: per-model quirks files mean the second attempt is cheaper (skill adaptations transfer)
|
||||
|
||||
### Option 3 (Defer)
|
||||
|
||||
* Good — zero effort
|
||||
* Bad — reactive, increases risk under outage scenarios
|
||||
|
||||
### Option 4 (Different vendor)
|
||||
|
||||
* Good — likely higher quality
|
||||
* Bad — does not solve sovereignty
|
||||
* Bad — methodology already has Claude as Tier 3 ; another Anthropic-family model in Tier 1 conflates roles
|
||||
|
||||
## Consequences
|
||||
|
||||
* `meta-trainer-bootstrap` skill is exercised end-to-end for the first time. Discoveries during this run will likely produce Q-042+ entries in `mistral-quirks.md` and a separate `nemotron-3-super-quirks.md`.
|
||||
* `~/.vibe/config.toml` may need a new model alias (e.g. `local-nemotron`) configured for testing without affecting the production `mistral-vibe-cli-latest` default.
|
||||
* If successful, the next ADR (0028 or higher) will document the production switch (or split, e.g. routine tasks → local, complex tasks → cloud).
|
||||
* Forfait usage from this bootstrap : Tier 2 Mistral orchestration only ; Tier 1 Ollama runs are free at the API level.
|
||||
|
||||
## Links
|
||||
|
||||
* Three-tier methodology : `~/.vibe/skills/meta-trainer-bootstrap/references/three-tier-tutor.md`
|
||||
* Candidate models reference : `~/.vibe/memory/reference/ollama-candidate-models.md`
|
||||
* `meta-trainer-bootstrap` skill : `~/.vibe/skills/meta-trainer-bootstrap/SKILL.md`
|
||||
* Canary suite : `~/.vibe/skills/meta-trainer-bootstrap/canaries/INDEX.md`
|
||||
* Q-041 (autonomy story validated on Mistral) : `~/.vibe/memory/reference/mistral-quirks.md`
|
||||
* Related ADRs : [ADR-0007](0007-opentelemetry-integration.md) (cloud / sovereignty considerations historically) ; [ADR-0023](0023-config-hot-reloading.md) (hot-reload may need different patterns under Ollama)
|
||||
147
adr/0028-passwordless-auth-migration.md
Normal file
147
adr/0028-passwordless-auth-migration.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# 28. Passwordless authentication: magic link → OpenID Connect
|
||||
|
||||
**Date:** 2026-05-05
|
||||
**Status:** Proposed
|
||||
**Authors:** Gabriel Radureau, AI Agent
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
ADR-0018 (now Implemented) shipped a username + password authentication system with bcrypt hashing, JWT tokens, admin master password, and admin-assisted password reset. It works, but it carries the cost-of-passwords : we store password hashes, support password reset flows, and maintain a credential-rotation policy. Users hate passwords ; ops and security pay for them.
|
||||
|
||||
Two industry-standard alternatives exist :
|
||||
1. **Magic link by email** — user enters their email, receives a one-time token in a clickable link, link consumes the token and issues a session JWT. No password stored.
|
||||
2. **OpenID Connect Authorization Code flow** — delegate authentication to an external Identity Provider (e.g. Authelia, Keycloak, Auth0, Google) ; our app receives an `id_token` after the OIDC dance.
|
||||
|
||||
We want to **migrate to passwordless** for new sign-ups while keeping the existing username/password code path operational during the transition (no flag-day breakage). The two passwordless mechanisms above complement each other : magic link is simpler for first-party users on day 1 ; OIDC is the right answer for second-party users (other ARCODANGE products, partner integrations) and for admin SSO.
|
||||
|
||||
A third constraint : ARCODANGE local development must use HTTPS for OAuth callbacks to be valid (most OIDC providers reject `http://localhost` redirect URIs in their default config). `mkcert` is the canonical local-CA tool for this.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
* **Reduce password-related attack surface** — no hash storage, no breach-and-reuse risk, no password reset abuse vectors
|
||||
* **User experience** — passwordless is faster for the user (1 click in email vs typing/remembering password)
|
||||
* **Operational simplicity** — no password reset flow to maintain ; the password-reset code can be removed once migration is complete
|
||||
* **Multi-product readiness** — OIDC is the prerequisite for cross-product SSO across the ARCODANGE portfolio
|
||||
* **Backwards compatibility** — must not break existing tokens or BDD scenarios mid-migration
|
||||
* **Local dev parity** — HTTPS in dev so OAuth flows can be tested locally without provider-specific workarounds
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1 (Chosen): Sequenced — magic link first, OIDC second
|
||||
|
||||
Deliver in two phases :
|
||||
|
||||
* **Phase A — Magic link**
|
||||
- Add `POST /api/v1/auth/magic-link/request` (body: `{email}`) — generates token, stores it (TTL ~15 min), sends email via SMTP
|
||||
- Add `GET /api/v1/auth/magic-link/consume?token=<...>` — single-use consumption, issues a JWT, returns it as cookie + JSON body
|
||||
- Reuse the existing JWT issuance + secret retention infrastructure (ADR-0021)
|
||||
- Existing `/api/v1/auth/login` (username/password) stays operational during transition
|
||||
|
||||
* **Phase B — OpenID Connect Authorization Code with PKCE**
|
||||
- Add `GET /api/v1/auth/oidc/start` — generates state + PKCE verifier, redirects to provider's `authorization_endpoint`
|
||||
- Add `GET /api/v1/auth/oidc/callback` — exchanges code for tokens, validates `id_token` signature against provider's JWKS, issues internal JWT
|
||||
- Provider URL configurable per environment (`auth.oidc.issuer_url`, `auth.oidc.client_id`, `auth.oidc.client_secret`)
|
||||
- Allow multiple providers in config (key by provider name, e.g. `arcodange-sso`)
|
||||
- Local dev requires HTTPS — `mkcert` setup documented in `documentation/DEV_SETUP.md`
|
||||
|
||||
* **Phase C (later, separate ADR) — Decommission password auth**
|
||||
- Once all users have migrated, remove the password endpoints, remove the password_hash column, mark ADR-0018 as Superseded by this ADR
|
||||
|
||||
### Option 2: All-at-once OIDC, no magic link
|
||||
|
||||
Skip magic link, jump straight to OIDC.
|
||||
|
||||
* Good — single migration, no intermediate state
|
||||
* Bad — requires an OIDC provider operational on day 1, which we don't have configured
|
||||
* Bad — magic link has zero infra dependencies (just SMTP) ; OIDC requires running an IdP or paying for one
|
||||
|
||||
### Option 3: Magic link only, no OIDC
|
||||
|
||||
Stop at Phase A.
|
||||
|
||||
* Good — simplest implementation
|
||||
* Bad — doesn't solve cross-product SSO ; we'd re-do this work later for the broader ARCODANGE portfolio
|
||||
|
||||
### Option 4: Status quo (do nothing)
|
||||
|
||||
Keep username + password.
|
||||
|
||||
* Good — zero effort
|
||||
* Bad — passwords stay forever ; ARCODANGE locks itself out of integration scenarios that expect OIDC
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option : **Option 1, sequenced magic link → OIDC**.
|
||||
|
||||
Rationale :
|
||||
- Magic link is implementable today with zero infra dependencies beyond the email infrastructure (ADR-0029)
|
||||
- OIDC requires running an IdP locally (Authelia or Keycloak) — that's another container in the dev stack and another ADR's worth of decision work, but the magic-link work is the natural prerequisite (token-by-email plumbing is reused)
|
||||
- Sequenced delivery means we never have to roll back : Phase A works alone, Phase B layers on top, Phase C cleans up
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase A — Magic link (target: 2-3 PRs)
|
||||
|
||||
1. **A.1 — Storage** : add a `magic_link_tokens` table (id, email, token_hash, expires_at, consumed_at). Repository pattern alongside `pkg/user/postgres_repository.go`.
|
||||
2. **A.2 — Token endpoint** : `POST /api/v1/auth/magic-link/request` generates a token, stores it (hashed), enqueues an email send. Rate-limited (cf. ADR-0022) by email address.
|
||||
3. **A.3 — Consume endpoint** : `GET /api/v1/auth/magic-link/consume?token=...` validates + marks consumed + issues JWT. Returns `Set-Cookie` and `{token: jwt}` body.
|
||||
4. **A.4 — Sign-up via magic link** : if the email is unknown, the consume endpoint creates the user record. (No separate "sign-up" flow needed — first magic link IS the sign-up.)
|
||||
5. **A.5 — BDD coverage** : scenarios for happy path, expired token, double-consume, wrong-email, rate-limit. Cf. ADR-0030 for the email assertion strategy.
|
||||
|
||||
### Phase B — OIDC Code flow with PKCE (target: 3-4 PRs)
|
||||
|
||||
1. **B.1 — Local IdP** : choose Authelia or Keycloak for local development. Add to `docker-compose.yml` with default test configuration.
|
||||
2. **B.2 — mkcert** : document local HTTPS setup in `documentation/DEV_SETUP.md`, add `make cert` target.
|
||||
3. **B.3 — OIDC client** : `pkg/auth/oidc.go` — discovery, JWKS cache, code exchange with PKCE.
|
||||
4. **B.4 — Endpoints** : `/oidc/start` and `/oidc/callback`.
|
||||
5. **B.5 — Provider config** : `auth.oidc.providers` map in config (cf. ADR-0006 Viper) ; multi-provider supported.
|
||||
6. **B.6 — BDD coverage** : end-to-end scenarios using a mock OIDC server (or the local Authelia instance with deterministic users).
|
||||
|
||||
### Phase C — Decommission password (separate ADR after A+B in production)
|
||||
|
||||
Out of scope for this ADR. Will be ADR-NNNN when migration is complete.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Option 1 (Chosen — Sequenced)
|
||||
|
||||
* Good — incremental, no flag day, each phase shippable on its own
|
||||
* Good — reuses existing JWT infrastructure (ADR-0021 secret retention)
|
||||
* Good — magic link work is a prerequisite for OIDC anyway (email plumbing, mkcert)
|
||||
* Bad — total work spans 2 sprints, longer time-to-OIDC than Option 2
|
||||
* Mitigation: after Phase A, the team can stop if priorities shift — magic link alone is a complete improvement
|
||||
|
||||
### Option 2 (All OIDC)
|
||||
|
||||
* Good — single migration
|
||||
* Bad — requires IdP operational from day 1
|
||||
* Bad — local dev environment more complex than necessary for the magic link case
|
||||
|
||||
### Option 3 (Magic link only)
|
||||
|
||||
* Good — minimal scope
|
||||
* Bad — re-work later for SSO
|
||||
|
||||
### Option 4 (Status quo)
|
||||
|
||||
* Good — zero effort
|
||||
* Bad — accumulating tech debt
|
||||
|
||||
## Consequences
|
||||
|
||||
* `pkg/auth/` package created (currently auth code lives in `pkg/user/`) — separation is now justified by the multi-mechanism scope
|
||||
* `pkg/user/api/auth_handler.go` continues to serve username/password during transition (Phase A and B), removed in Phase C
|
||||
* `documentation/DEV_SETUP.md` becomes a load-bearing doc for new contributors (mkcert + docker-compose with mailpit + Authelia)
|
||||
* The 4 new endpoints (`magic-link/request`, `magic-link/consume`, `oidc/start`, `oidc/callback`) require their own ADR entries in the API doc + Swagger annotations
|
||||
* Phase A's magic link plumbing depends on **ADR-0029** (email infrastructure decision) — that ADR ships first
|
||||
* BDD scenarios for Phase A depend on **ADR-0030** (email testing strategy with parallel BDD) — that ADR ships before any Phase A scenario lands
|
||||
|
||||
## Links
|
||||
|
||||
* Email infrastructure : [ADR-0029](0029-email-infrastructure-mailpit.md)
|
||||
* BDD email testing strategy : [ADR-0030](0030-bdd-email-parallel-strategy.md)
|
||||
* Existing user auth (to be partially superseded by Phase C) : [ADR-0018](0018-user-management-auth-system.md)
|
||||
* JWT secret retention reused : [ADR-0021](0021-jwt-secret-retention-policy.md)
|
||||
* Rate limiting reused : [ADR-0022](0022-rate-limiting-cache-strategy.md)
|
||||
* OAuth 2.0 Authorization Code with PKCE : [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)
|
||||
* OpenID Connect Core : [OpenID Foundation](https://openid.net/specs/openid-connect-core-1_0.html)
|
||||
142
adr/0029-email-infrastructure-mailpit.md
Normal file
142
adr/0029-email-infrastructure-mailpit.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# 29. Email infrastructure: Mailpit local + production deferred
|
||||
|
||||
**Date:** 2026-05-05
|
||||
**Status:** Proposed
|
||||
**Authors:** Gabriel Radureau, AI Agent
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
ADR-0028 (passwordless auth) requires the application to send emails — magic-link tokens specifically. Email is a substrate decision : the choice of SMTP provider, the abstraction in code, and the local development experience all depend on it.
|
||||
|
||||
Two separate concerns :
|
||||
|
||||
1. **Local development + BDD tests** : we need a local SMTP receiver that captures emails and exposes them for inspection. Real email providers (Gmail, SES, SendGrid) are unsuitable for local dev — they cost money, leak test data, and rate-limit aggressively.
|
||||
2. **Production** : the application needs to actually deliver mail to user inboxes. This decision is deferred — see "Out of scope" below.
|
||||
|
||||
ARCODANGE already has the **Mailpit** docker image pulled locally (`axllent/mailpit:latest`, 51 MB). Mailpit captures SMTP submissions on a port, stores them in-memory, exposes them via HTTP UI (default :8025) and an HTTP API (`/api/v1/messages`). It's the de-facto choice for Go projects needing local SMTP capture.
|
||||
|
||||
The application code needs to be **provider-agnostic** : a `pkg/email` package with a `Sender` interface, a Mailpit-compatible SMTP implementation, and a contract that production can swap for a real provider's adapter without changing call sites.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
* **Local dev and CI must work without internet** — emails should never leave the docker network in tests
|
||||
* **Test inspection must be programmatic** — BDD tests assert on email content, not just "an email was sent"
|
||||
* **Production decision deferred** — we don't know the volume / SLA / compliance requirements yet ; over-committing now is premature
|
||||
* **Provider portability** — `pkg/email` interface lets us swap implementations without touching auth code
|
||||
* **Cost** — Mailpit is free, runs in a container, no API quota concerns
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1 (Chosen): Mailpit for local + tests, production via a production-grade provider TBD
|
||||
|
||||
* Add Mailpit to `docker-compose.yml` (SMTP :1025, HTTP API :8025)
|
||||
* `pkg/email` package with a `Sender` interface
|
||||
* Default implementation : `SMTPSender` configured against the local Mailpit in dev/CI
|
||||
* Tests query Mailpit's HTTP API to inspect captured messages
|
||||
* Production deployment will add a separate `pkg/email/<provider>_sender.go` implementing the same interface — that decision is its own ADR
|
||||
|
||||
### Option 2: MailHog instead of Mailpit
|
||||
|
||||
MailHog is the older, well-known alternative. Mailpit is its modern successor, written in Go, with a richer API and active maintenance.
|
||||
|
||||
* Bad — abandoned upstream (last commit 2020). Mailpit is the natural replacement.
|
||||
|
||||
### Option 3: In-process mock email sender
|
||||
|
||||
Write a `MockSender` that captures emails in a Go slice. No SMTP at all.
|
||||
|
||||
* Good — fastest tests, zero infra
|
||||
* Bad — doesn't validate the actual SMTP wire format, the From/To/Subject headers, the encoding of multi-byte content, or the DKIM/Reply-To setup
|
||||
* Bad — doesn't double as a manual-inspection tool for the developer (no UI to look at the email)
|
||||
|
||||
### Option 4: Send to a real but throwaway provider (Mailtrap, Mailosaur)
|
||||
|
||||
External services that capture-and-display emails.
|
||||
|
||||
* Good — production-similar paths
|
||||
* Bad — costs money, requires an account, leaks test data, doesn't work offline
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option : **Option 1 — Mailpit for local + tests, production deferred**.
|
||||
|
||||
Rationale :
|
||||
- Mailpit is the modern, maintained successor to MailHog ; image is already on the dev machine
|
||||
- The interface-first design (`pkg/email.Sender`) means production swap is a future ADR, not a refactor
|
||||
- BDD tests have a real wire-format path to assert on (cf. ADR-0030)
|
||||
- Zero monthly cost in dev/CI
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. **`pkg/email/sender.go`** — define the `Sender` interface :
|
||||
```go
|
||||
type Sender interface {
|
||||
Send(ctx context.Context, msg Message) error
|
||||
}
|
||||
type Message struct {
|
||||
To string
|
||||
From string
|
||||
Subject string
|
||||
BodyText string
|
||||
BodyHTML string
|
||||
Headers map[string]string // for trace correlation, e.g. X-Test-Scenario-ID
|
||||
}
|
||||
```
|
||||
2. **`pkg/email/smtp_sender.go`** — implementation using `net/smtp` (stdlib) configured by `auth.email.smtp_host`, `smtp_port`, `smtp_username`, `smtp_password`, `smtp_use_tls`. For Mailpit defaults : `smtp_host=localhost smtp_port=1025 smtp_use_tls=false`.
|
||||
3. **`pkg/email/sender_test.go`** — unit tests using `httptest`-style fake SMTP, plus a `*_integration_test.go` (build tag `integration`) hitting the live Mailpit.
|
||||
4. **`docker-compose.yml`** — add the `mailpit` service :
|
||||
```yaml
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
ports:
|
||||
- "1025:1025" # SMTP
|
||||
- "8025:8025" # HTTP UI / API
|
||||
environment:
|
||||
MP_MAX_MESSAGES: 5000
|
||||
```
|
||||
5. **`pkg/config/config.go`** — add the `auth.email.*` config keys with defaults pointing at local Mailpit.
|
||||
6. **Documentation** : `documentation/EMAIL.md` covering local setup, message inspection via UI (http://localhost:8025), API queries.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Option 1 (Chosen — Mailpit)
|
||||
|
||||
* Good — already locally available, free, modern, maintained
|
||||
* Good — provider-agnostic interface decouples from prod choice
|
||||
* Good — full SMTP wire format = realistic test path
|
||||
* Good — UI for manual inspection during dev
|
||||
* Bad — requires Mailpit running (one more docker-compose service)
|
||||
* Bad — production decision still pending
|
||||
|
||||
### Option 2 (MailHog)
|
||||
|
||||
* Bad — unmaintained, choosing it would create immediate tech debt
|
||||
|
||||
### Option 3 (Mock only)
|
||||
|
||||
* Bad — too much abstraction loss, can't catch wire-level bugs
|
||||
|
||||
### Option 4 (Mailtrap / Mailosaur)
|
||||
|
||||
* Bad — cost, network dependency, account management
|
||||
|
||||
## Consequences
|
||||
|
||||
* New service in `docker-compose.yml` — developers run `docker compose up -d` once and Mailpit is on
|
||||
* New `pkg/email` package — auth code (ADR-0028 magic link) calls `Sender.Send()` rather than direct SMTP
|
||||
* New `auth.email.*` config keys, new env vars (`DLC_AUTH_EMAIL_SMTP_HOST` etc.)
|
||||
* Mailpit's HTTP API becomes part of the BDD test contract — tests use it to assert messages were sent (cf. ADR-0030)
|
||||
* Production sender ADR (TBD) will be a separate decision — this ADR explicitly does NOT pick a vendor for prod
|
||||
|
||||
## Out of scope
|
||||
|
||||
* **Production email provider selection** — separate ADR when we know volume / SLA / compliance constraints. Likely candidates: AWS SES, Postmark, SendGrid, Mailjet. Magic-link emails are transactional + low-volume — most providers handle that easily.
|
||||
* **DKIM/SPF/DMARC setup** — production deliverability concern, not a local-dev concern
|
||||
* **HTML email templating** — we'll start with plain-text emails ; HTML can be added with a template package (e.g. `html/template`) when ARCODANGE branding requires it
|
||||
|
||||
## Links
|
||||
|
||||
* Auth migration that requires this : [ADR-0028](0028-passwordless-auth-migration.md)
|
||||
* BDD test strategy that consumes Mailpit : [ADR-0030](0030-bdd-email-parallel-strategy.md)
|
||||
* Mailpit homepage : https://mailpit.axllent.org/
|
||||
* Mailpit API reference : https://mailpit.axllent.org/docs/api-v1/
|
||||
187
adr/0030-bdd-email-parallel-strategy.md
Normal file
187
adr/0030-bdd-email-parallel-strategy.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# 30. BDD email assertions with parallel test execution
|
||||
|
||||
**Date:** 2026-05-05
|
||||
**Status:** Proposed
|
||||
**Authors:** Gabriel Radureau, AI Agent
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
ADR-0028 introduces magic-link auth, which requires the application to send emails. ADR-0029 chose **Mailpit** as the local SMTP receiver for dev and BDD tests. The remaining decision : **how do BDD scenarios assert on the email content while running in parallel ?**
|
||||
|
||||
Today (since [PR #35](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/35)), the BDD suite runs in parallel via per-package PostgreSQL schema isolation (cf. [ADR-0025](0025-bdd-scenario-isolation-strategies.md)). Each Go test package has its own schema ; tests inside a package run serially within that schema. This works because Postgres has named schemas with strong isolation. **Mailpit has no equivalent** — there is one inbox per Mailpit instance, shared across all senders.
|
||||
|
||||
A naive integration would have parallel scenarios fight over each other's emails :
|
||||
- Scenario A : "request magic link for `test@example.com`" → email arrives
|
||||
- Scenario B (in parallel) : "request magic link for `test@example.com`" → email arrives
|
||||
- Both scenarios query Mailpit for `test@example.com` — they see each other's messages, assertions become flaky.
|
||||
|
||||
We need a way to scope each scenario's emails so it only sees its own messages.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
* **No regression on parallelism** — BDD-isolation Phase 3 (PR #35) achieved a 2.85x speedup ; the email-assertion solution must not undo that
|
||||
* **No new container per test** — running one Mailpit per scenario would defeat the simplicity that made us choose Mailpit
|
||||
* **Determinism** — a scenario's email assertions must succeed regardless of how many other scenarios are running
|
||||
* **Realistic SMTP path** — we still want the full SMTP wire format exercised (cf. ADR-0029) ; we don't want to bypass Mailpit
|
||||
* **Cleanup hygiene** — old messages from previous test runs must not leak into a new run
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1 (Chosen): Per-test recipient scoping with deterministic addresses
|
||||
|
||||
Each BDD scenario generates a unique email address for its test user, derived from the scenario key + a random suffix. Examples :
|
||||
|
||||
- Scenario `magic-link-happy-path` → `magic-link-happy-path-<8hex>@bdd.local`
|
||||
- Scenario `magic-link-expired-token` → `magic-link-expired-token-<8hex>@bdd.local`
|
||||
|
||||
The application code accepts any email format. The BDD scenario asserts on Mailpit's HTTP API filtering by the `to` address. Two parallel scenarios with different addresses can NEVER see each other's emails.
|
||||
|
||||
**Cleanup** : at the start of each scenario, the BDD framework calls `DELETE /api/v1/search?query=to:<scenario-address>` on Mailpit to purge any leftover messages from prior runs.
|
||||
|
||||
### Option 2: One Mailpit instance per Go test package
|
||||
|
||||
Spawn a fresh Mailpit container in `TestMain` of each `features/<area>/` package. Each gets its own port range.
|
||||
|
||||
* Good — strong isolation
|
||||
* Bad — heavyweight (one container per package = 5+ containers running)
|
||||
* Bad — port allocation complexity (similar to existing `pkg/bdd/parallel/port_manager.go`, but applied to Mailpit)
|
||||
* Bad — slow startup (Mailpit boot is ~200ms but adds up)
|
||||
|
||||
### Option 3: One Mailpit instance, scenario-scoped via custom SMTP header
|
||||
|
||||
Add a custom header `X-BDD-Scenario-ID: <key>` to outgoing emails. Tests query Mailpit filtered on that header.
|
||||
|
||||
* Good — same single Mailpit
|
||||
* Bad — requires the application code to know the scenario ID at email-send time, which means a test-only path in production code
|
||||
* Bad — header propagation is fragile (gets stripped by some SMTP relays — not Mailpit, but real production providers might) ; we don't want a different code path between dev and prod
|
||||
|
||||
### Option 4: Sequence parallel scenarios via per-scenario Mailpit lock
|
||||
|
||||
Use a mutex / queue so no two scenarios that send email run concurrently.
|
||||
|
||||
* Good — minimal code change
|
||||
* Bad — gives up the parallel speedup for any feature that involves email — that's most auth-related features going forward
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option : **Option 1 — per-test recipient scoping**.
|
||||
|
||||
Rationale :
|
||||
- Recipient scoping is the simplest abstraction : the address IS the identity ; Mailpit's HTTP API natively supports filtering by recipient
|
||||
- Application code stays clean : it just sends to whatever address it's given. No test-mode branching.
|
||||
- Parallel-safe by construction : two scenarios cannot collide if they don't share an address
|
||||
- Cheap to implement : a few helper functions in `pkg/bdd/steps/email_steps.go` and a `mailpit.Client` package wrapping the HTTP API
|
||||
- Cleanup is per-scenario, not global — no "delete all messages" race between scenarios
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Helper package : `pkg/bdd/mailpit/client.go`
|
||||
|
||||
```go
|
||||
type Client struct {
|
||||
BaseURL string // default: http://localhost:8025
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// AwaitMessageTo polls Mailpit's HTTP API for a message addressed
|
||||
// to the given recipient, with a deadline. Returns the most recent
|
||||
// matching message or an error on timeout.
|
||||
func (c *Client) AwaitMessageTo(ctx context.Context, to string, timeout time.Duration) (*Message, error)
|
||||
|
||||
// PurgeMessagesTo removes all messages addressed to the given
|
||||
// recipient. Idempotent and parallel-safe.
|
||||
func (c *Client) PurgeMessagesTo(ctx context.Context, to string) error
|
||||
|
||||
type Message struct {
|
||||
ID string
|
||||
From string
|
||||
To []string
|
||||
Subject string
|
||||
Text string
|
||||
HTML string
|
||||
Headers map[string][]string
|
||||
}
|
||||
```
|
||||
|
||||
### Helper steps : `pkg/bdd/steps/email_steps.go`
|
||||
|
||||
```go
|
||||
func (s *EmailSteps) iHaveAnEmailAddressForThisScenario() error
|
||||
// Generates `<scenario-key>-<8hex>@bdd.local`, stores it in the scenario state.
|
||||
|
||||
func (s *EmailSteps) iShouldReceiveAnEmailWithSubject(subject string) error
|
||||
// Polls AwaitMessageTo on the scenario's address, asserts subject equality.
|
||||
|
||||
func (s *EmailSteps) theEmailShouldContain(snippet string) error
|
||||
// Re-fetches the most recent message and checks for substring in body.
|
||||
|
||||
func (s *EmailSteps) theEmailContainsAMagicLinkToken() (string, error)
|
||||
// Extracts the token from the magic-link URL via regex, returns it.
|
||||
```
|
||||
|
||||
### Scenario lifecycle
|
||||
|
||||
- **Before each scenario** : `iHaveAnEmailAddressForThisScenario` is called (either explicitly via Background, or implicitly via a hook). The unique address is stored in the scenario's state. PurgeMessagesTo is called to clear any leftovers from prior runs of the same address (defensive — should be impossible since the suffix is random, but cheap).
|
||||
- **During the scenario** : the application sends to that address. Tests query for it.
|
||||
- **After each scenario** : no global cleanup needed — addresses are per-scenario unique, so they don't accumulate beyond Mailpit's `MP_MAX_MESSAGES=5000` cap.
|
||||
|
||||
### Race-free deletion
|
||||
|
||||
Mailpit's `DELETE /api/v1/search?query=to:<addr>` is atomic per recipient. Two concurrent scenarios with different addresses cannot interfere.
|
||||
|
||||
### Sample scenario (auth-magic-link.feature)
|
||||
|
||||
```gherkin
|
||||
@critical @magic-link
|
||||
Scenario: User receives a magic link by email
|
||||
Given I have an email address for this scenario
|
||||
When I request a magic link for my email address
|
||||
Then I should receive an email with subject "Your magic link"
|
||||
And the email contains a magic link token
|
||||
When I consume the magic link token
|
||||
Then I should receive a JWT
|
||||
```
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Option 1 (Chosen)
|
||||
|
||||
* Good — parallel-safe by construction
|
||||
* Good — application code unchanged ; test-only logic stays in the BDD layer
|
||||
* Good — Mailpit API supports the filter natively
|
||||
* Good — cleanup is fine-grained, no race
|
||||
* Bad — requires cooperative scenarios (each must request a unique address)
|
||||
* Mitigation : Background steps in feature files make it automatic
|
||||
|
||||
### Option 2 (Mailpit per package)
|
||||
|
||||
* Bad — operational complexity not justified for the test-only concern
|
||||
|
||||
### Option 3 (Custom header scoping)
|
||||
|
||||
* Bad — production code dirtied by test concerns
|
||||
|
||||
### Option 4 (Lock-and-sequence)
|
||||
|
||||
* Bad — gives up parallelism (the whole point of PR #35 + ADR-0025)
|
||||
|
||||
## Consequences
|
||||
|
||||
* `pkg/bdd/mailpit/` package is created with HTTP client + helper types
|
||||
* `pkg/bdd/steps/email_steps.go` package is created and registered in `steps.go`
|
||||
* `features/auth/` and any other email-using features have new BDD steps available
|
||||
* The local development docker-compose must run Mailpit before BDD tests run — to be added to the BDD test runner script `scripts/run-bdd-tests.sh`
|
||||
* Mailpit message TTL is governed by `MP_MAX_MESSAGES` (5000) — at parallel BDD volumes, that's enough headroom for ~50 scenarios × 100 messages each before any pruning kicks in
|
||||
|
||||
## Out of scope
|
||||
|
||||
* **Visual regression on email rendering** — text body assertions only ; HTML rendering checks belong in a separate Storybook-style harness
|
||||
* **Attachment handling** — magic-link emails are text-only ; ADRs for attachments will come if/when needed
|
||||
* **Email volume / rate-limit testing** — that's a load-test concern, not a BDD concern
|
||||
|
||||
## Links
|
||||
|
||||
* Auth migration depending on this : [ADR-0028](0028-passwordless-auth-migration.md)
|
||||
* Email infrastructure choice : [ADR-0029](0029-email-infrastructure-mailpit.md)
|
||||
* BDD parallelism foundation : [ADR-0025](0025-bdd-scenario-isolation-strategies.md), [PR #35](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/35)
|
||||
* Mailpit API : https://mailpit.axllent.org/docs/api-v1/
|
||||
171
adr/README.md
171
adr/README.md
@@ -1,127 +1,118 @@
|
||||
# Architecture Decision Records (ADRs)
|
||||
|
||||
This directory contains Architecture Decision Records (ADRs) for the dance-lessons-coach project.
|
||||
This directory contains the Architecture Decision Records (ADRs) for the dance-lessons-coach project. Each ADR captures a structurally important decision, its context, and its consequences.
|
||||
|
||||
## Index of ADRs
|
||||
## Index
|
||||
|
||||
| Number | Title | Status |
|
||||
|--------|-------|--------|
|
||||
| 0001 | Go 1.26.1 Standard | ✅ Accepted |
|
||||
| 0002 | Chi Router | ✅ Accepted |
|
||||
| 0003 | Zerolog Logging | ✅ Accepted |
|
||||
| 0004 | Interface-Based Design | ✅ Accepted |
|
||||
| 0005 | Graceful Shutdown | ✅ Accepted |
|
||||
| 0006 | Configuration Management | ✅ Accepted |
|
||||
| 0007 | OpenTelemetry Integration | ✅ Accepted |
|
||||
| 0008 | BDD Testing | ✅ Accepted |
|
||||
| 0009 | Hybrid Testing Approach | ✅ Accepted |
|
||||
| 0010 | CI/CD Pipeline Design | ✅ Accepted |
|
||||
| 0011 | Trunk-Based Development | ✅ Accepted |
|
||||
| 0012 | Commit Message Conventions | ✅ Accepted |
|
||||
| 0013 | Version Management Lifecycle | ✅ Accepted |
|
||||
| 0014 | Swagger Documentation | ✅ Accepted |
|
||||
| 0015 | Rate Limiting Strategy | ✅ Accepted |
|
||||
| 0016 | Cache Invalidation Strategy | ✅ Accepted |
|
||||
| 0017 | JWT Secret Rotation | ✅ Accepted |
|
||||
| 0018 | Configuration Hot Reloading | ✅ Accepted |
|
||||
| 0019 | BDD Feature Structure | ✅ Accepted |
|
||||
| 0020 | Database Migration Strategy | ✅ Accepted |
|
||||
| 0021 | API Versioning Strategy | ✅ Accepted |
|
||||
| 0022 | Rate Limiting and Cache Strategy | ✅ Accepted |
|
||||
| 0023 | Config Hot Reloading | 🟡 Proposed |
|
||||
| 0024 | BDD Test Organization and Isolation | 🟡 Proposed |
|
||||
| ADR | Title | Status |
|
||||
|-----|-------|--------|
|
||||
| [0001](0001-go-1.26.1-standard.md) | Use Go 1.26.1 as the standard Go version | Accepted |
|
||||
| [0002](0002-chi-router.md) | Use Chi router for HTTP routing | Accepted |
|
||||
| [0003](0003-zerolog-logging.md) | Use Zerolog for structured logging | Accepted |
|
||||
| [0004](0004-interface-based-design.md) | Adopt interface-based design pattern | Accepted |
|
||||
| [0005](0005-graceful-shutdown.md) | Implement graceful shutdown with readiness endpoints | Accepted |
|
||||
| [0006](0006-configuration-management.md) | Use Viper for configuration management | Accepted |
|
||||
| [0007](0007-opentelemetry-integration.md) | Integrate OpenTelemetry for distributed tracing | Accepted |
|
||||
| [0008](0008-bdd-testing.md) | Adopt BDD with Godog for behavioral testing | Accepted |
|
||||
| [0009](0009-hybrid-testing-approach.md) | Combine BDD and Swagger-based testing | Implemented |
|
||||
| [0010](0010-api-v2-feature-flag.md) | API v2 Feature Flag Implementation | Accepted |
|
||||
| [0012](0012-git-hooks-staged-only-formatting.md) | Git Hooks: Staged-Only Formatting | Accepted |
|
||||
| [0013](0013-openapi-swagger-toolchain.md) | OpenAPI/Swagger Toolchain Selection | Implemented |
|
||||
| [0015](0015-cli-subcommands-cobra.md) | CLI Subcommands and Flag Management with Cobra | Implemented |
|
||||
| [0016](0016-ci-cd-pipeline-design.md) | CI/CD Pipeline Design for Multi-Platform Compatibility | Accepted |
|
||||
| [0017](0017-trunk-based-development-workflow.md) | Trunk-Based Development Workflow for CI/CD Safety | Approved |
|
||||
| [0018](0018-user-management-auth-system.md) | User Management and Authentication System | Implemented |
|
||||
| [0019](0019-postgresql-integration.md) | PostgreSQL Database Integration | Implemented |
|
||||
| [0020](0020-docker-build-strategy.md) | Docker Build Strategy: Traditional vs Buildx | Accepted |
|
||||
| [0021](0021-jwt-secret-retention-policy.md) | JWT Secret Retention Policy | Implemented |
|
||||
| [0022](0022-rate-limiting-cache-strategy.md) | Rate Limiting and Cache Strategy | Implemented (Phase 1) |
|
||||
| [0023](0023-config-hot-reloading.md) | Config Hot Reloading Strategy | Implemented |
|
||||
| [0024](0024-bdd-test-organization-and-isolation.md) | BDD Test Organization and Isolation Strategy | Implemented |
|
||||
| [0025](0025-bdd-scenario-isolation-strategies.md) | BDD Scenario Isolation Strategies | Implemented |
|
||||
| [0026](0026-composite-info-endpoint.md) | Composite Info Endpoint vs Separate Calls | Implemented |
|
||||
| [0027](0027-ollama-tier1-onboarding.md) | Ollama Tier 1 onboarding via meta-trainer-bootstrap | Proposed |
|
||||
| [0028](0028-passwordless-auth-migration.md) | Passwordless authentication: magic link → OpenID Connect | Proposed |
|
||||
| [0029](0029-email-infrastructure-mailpit.md) | Email infrastructure: Mailpit local + production deferred | Proposed |
|
||||
| [0030](0030-bdd-email-parallel-strategy.md) | BDD email assertions with parallel test execution | Proposed |
|
||||
|
||||
> **Note** : numbers `0011` and `0014` are not currently in use. Reserved for future ADRs or representing previously deleted entries.
|
||||
|
||||
## What is an ADR?
|
||||
|
||||
An ADR is a document that captures an important architectural decision made along with its context and consequences.
|
||||
An ADR is a document capturing one significant architectural decision: the **context** that motivated it, the **decision** itself, and its **consequences**. ADRs are append-only — once published, an ADR is not edited (except for typo / status updates). New decisions that supersede previous ones are recorded as new ADRs that explicitly link back.
|
||||
|
||||
## Format
|
||||
## Canonical Format
|
||||
|
||||
Each ADR follows this structure:
|
||||
All ADRs follow the canonical format below (homogenized 2026-05-03):
|
||||
|
||||
```markdown
|
||||
# [Short title is a few words]
|
||||
# NN. Short title summarising the decision
|
||||
|
||||
* Status: [Proposed | Accepted | Deprecated | Superseded]
|
||||
* Deciders: [List of decision makers]
|
||||
* Date: [YYYY-MM-DD]
|
||||
**Status:** <Proposed | Accepted | Implemented | Partially Implemented | Approved | Rejected | Deferred | Deprecated | Superseded by ADR-NNNN>
|
||||
**Date:** YYYY-MM-DD
|
||||
**Authors:** Name(s)
|
||||
|
||||
[Optional fields, all in `**Field:** value` format:]
|
||||
**Decision Drivers:** ...
|
||||
**Implementation Status:** ...
|
||||
**Implementation Date:** ...
|
||||
**Last Updated:** ...
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
[Describe the context and problem statement]
|
||||
[Describe the context and problem statement.]
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
* [Driver 1]
|
||||
* [Driver 2]
|
||||
* [Driver 3]
|
||||
* Driver 1
|
||||
* Driver 2
|
||||
|
||||
## Considered Options
|
||||
|
||||
* [Option 1]
|
||||
* [Option 2]
|
||||
* [Option 3]
|
||||
* Option 1
|
||||
* Option 2
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: "[Option 1]" because [justification]
|
||||
Chosen option: "Option 1" because [justification].
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### [Option 1]
|
||||
### Option 1
|
||||
|
||||
* Good, because [argument a]
|
||||
* Good, because [argument b]
|
||||
* Bad, because [argument c]
|
||||
* Good, because [argument].
|
||||
* Bad, because [argument].
|
||||
|
||||
### [Option 2]
|
||||
### Option 2
|
||||
|
||||
* Good, because [argument a]
|
||||
* Good, because [argument b]
|
||||
* Bad, because [argument c]
|
||||
* Good, because [argument].
|
||||
* Bad, because [argument].
|
||||
|
||||
## Links
|
||||
|
||||
* [Link type] [Link to ADR]
|
||||
* [Link type] [Link to ADR]
|
||||
* Related ADR: [ADR-NNNN](NNNN-slug.md)
|
||||
* Issue: [#NN](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/issues/NN)
|
||||
```
|
||||
|
||||
## ADR List
|
||||
|
||||
* [0001-go-1.26.1-standard.md](0001-go-1.26.1-standard.md) - Use Go 1.26.1 as the standard Go version
|
||||
* [0002-chi-router.md](0002-chi-router.md) - Use Chi router for HTTP routing
|
||||
* [0003-zerolog-logging.md](0003-zerolog-logging.md) - Use Zerolog for structured logging
|
||||
* [0004-interface-based-design.md](0004-interface-based-design.md) - Adopt interface-based design pattern
|
||||
* [0005-graceful-shutdown.md](0005-graceful-shutdown.md) - Implement graceful shutdown with readiness endpoints
|
||||
* [0006-configuration-management.md](0006-configuration-management.md) - Use Viper for configuration management
|
||||
* [0007-opentelemetry-integration.md](0007-opentelemetry-integration.md) - Integrate OpenTelemetry for distributed tracing
|
||||
* [0008-bdd-testing.md](0008-bdd-testing.md) - Adopt BDD with Godog for behavioral testing
|
||||
* [0009-hybrid-testing-approach.md](0009-hybrid-testing-approach.md) - Combine BDD and Swagger-based testing
|
||||
* [0010-api-v2-feature-flag.md](0010-api-v2-feature-flag.md) - API v2 implementation with feature flag control
|
||||
* [0011-validation-library-selection.md](0011-validation-library-selection.md) - Selection of go-playground/validator for input validation
|
||||
* [0012-git-hooks-staged-only-formatting.md](0012-git-hooks-staged-only-formatting.md) - Git hooks format only staged Go files
|
||||
* [0013-openapi-swagger-toolchain.md](0013-openapi-swagger-toolchain.md) - ✅ OpenAPI/Swagger documentation with swaggo/swag (Implemented)
|
||||
* [0014-grpc-adoption-strategy.md](0014-grpc-adoption-strategy.md) - Hybrid REST/gRPC adoption strategy
|
||||
* [0015-cli-subcommands-cobra.md](0015-cli-subcommands-cobra.md) - Cobra CLI framework adoption
|
||||
* [0016-ci-cd-pipeline-design.md](0016-ci-cd-pipeline-design.md) - CI/CD pipeline architecture
|
||||
* [0017-trunk-based-development-workflow.md](0017-trunk-based-development-workflow.md) - Trunk-based development workflow
|
||||
* [0018-user-management-auth-system.md](0018-user-management-auth-system.md) - User management and authentication system
|
||||
* [0019-postgresql-integration.md](0019-postgresql-integration.md) - PostgreSQL database integration
|
||||
* [0020-docker-build-strategy.md](0020-docker-build-strategy.md) - Docker Build Strategy: Traditional vs Buildx
|
||||
* [0021-jwt-secret-retention-policy.md](0021-jwt-secret-retention-policy.md) - JWT Secret Retention Policy with Configurable TTL and Retention
|
||||
* [0022-rate-limiting-cache-strategy.md](0022-rate-limiting-cache-strategy.md) - Rate Limiting and Cache Strategy with Multi-Phase Implementation
|
||||
* [0023-config-hot-reloading.md](0023-config-hot-reloading.md) - Config Hot Reloading Strategy
|
||||
|
||||
## How to Add a New ADR
|
||||
|
||||
1. Create a new file with the next available number (e.g., `0010-new-decision.md`)
|
||||
2. Follow the template format
|
||||
3. Update this README.md with the new ADR
|
||||
4. Commit the changes
|
||||
|
||||
## Status Legend
|
||||
|
||||
* **Proposed**: Decision is being discussed
|
||||
* **Accepted**: Decision has been made and implemented
|
||||
* **Deprecated**: Decision is no longer relevant
|
||||
* **Superseded**: Decision has been replaced by another ADR
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| **Proposed** | Decision is being discussed; no implementation yet. |
|
||||
| **Accepted** | Decision has been made; implementation may be pending or in progress. |
|
||||
| **Approved** | Same as Accepted; alternative term used in some legacy ADRs. |
|
||||
| **Implemented** | Decision is fully implemented and in production. |
|
||||
| **Partially Implemented** | Decision is partly implemented; remainder is deferred or pending. |
|
||||
| **Rejected** | Decision considered and explicitly rejected. The ADR documents why. |
|
||||
| **Deferred** | Decision postponed; revisit later. |
|
||||
| **Deprecated** | Decision is no longer relevant; system has moved on. |
|
||||
| **Superseded by ADR-NNNN** | Decision has been replaced by another ADR. Always include the link. |
|
||||
|
||||
## How to Add a New ADR
|
||||
|
||||
1. Pick the next available number (currently next would be `0026`).
|
||||
2. Copy an existing ADR (e.g., `0001-go-1.26.1-standard.md`) as a starting template.
|
||||
3. Edit the title, status, date, authors, and content.
|
||||
4. Update this `README.md` index with the new ADR.
|
||||
5. Commit using gitmoji convention (e.g., `📝 docs(adr): add ADR-0026 about ...`).
|
||||
6. Open a PR for review.
|
||||
|
||||
@@ -1,31 +1,320 @@
|
||||
Pending BDD Tests Implementation Plan
|
||||
# BDD Implementation Plan - Iterative Approach
|
||||
|
||||
Implementation Plan:
|
||||
Based on ADR 0024: BDD Test Organization and Isolation Strategy
|
||||
|
||||
**Configuration & Validation** (LOW priority):
|
||||
- `iSetRetentionFactorTo()` - Dynamic configuration
|
||||
- `iTryToStartTheServer()` - Server validation
|
||||
- `iShouldReceiveConfigurationValidationError()` - Error handling
|
||||
- `theErrorShouldMention()` - Error message validation
|
||||
## Phase 1: Refactor Current Tests (1-2 weeks)
|
||||
|
||||
**Monitoring & Metrics** (LOW priority):
|
||||
- `iShouldSeeMetricIncrement()` - Already implemented ✅
|
||||
- `iShouldSeeMetricDecrease()` - Already implemented ✅
|
||||
- `iShouldSeeHistogramUpdate()` - Already implemented ✅
|
||||
### Objective: Split monolithic feature files into modular, isolated components
|
||||
|
||||
**Performance & Scalability** (LOW priority):
|
||||
- `iHaveJWTSecrets()` - Bulk secret management
|
||||
- `ofThemAreExpired()` - Expiration tracking
|
||||
- `itShouldCompleteWithinMilliseconds()` - Performance validation
|
||||
- `andNotImpactServerPerformance()` - Performance monitoring
|
||||
### Tasks:
|
||||
1. **Split feature files by business domain**
|
||||
- Create `features/auth/` directory
|
||||
- Create `features/config/` directory
|
||||
- Create `features/greet/` directory
|
||||
- Create `features/health/` directory
|
||||
- Create `features/jwt/` directory
|
||||
|
||||
**Advanced Features** (LOW priority):
|
||||
- Various edge case and advanced scenarios
|
||||
2. **Implement feature-specific isolation**
|
||||
- Add config file patterns: `features/{domain}/{domain}-test-config.yaml`
|
||||
- Implement database naming: `dance_lessons_coach_{domain}_test`
|
||||
- Assign unique ports per feature group
|
||||
|
||||
Next Steps:
|
||||
3. **Create feature-specific test scripts**
|
||||
- Implement `scripts/test-feature.sh` with feature parameter
|
||||
- Add environment setup/teardown logic
|
||||
- Implement resource cleanup routines
|
||||
|
||||
1. Add configuration validation and monitoring
|
||||
2. Implement step definitions for pending scenarios
|
||||
3. Run full test suite to verify all scenarios pass
|
||||
### Deliverables:
|
||||
- ✅ Modular feature directory structure
|
||||
- ✅ Feature-specific configuration files
|
||||
- ✅ Basic isolation mechanisms
|
||||
- ✅ Feature-level test scripts
|
||||
|
||||
Estimated Time: 2-3 days
|
||||
## Phase 2: Enhance Test Infrastructure (2-3 weeks)
|
||||
|
||||
### Objective: Add synchronization and lifecycle management
|
||||
|
||||
### Tasks:
|
||||
1. **Implement synchronization helpers**
|
||||
- Add `waitForServerReady()` with timeout
|
||||
- Add `waitForConfigReload()` with event-based detection
|
||||
- Add `waitForCondition()` helper function
|
||||
|
||||
2. **Add Godog context management**
|
||||
- Create feature-specific context structs
|
||||
- Implement `InitializeFeatureSuite()`
|
||||
- Implement `CleanupFeatureSuite()`
|
||||
|
||||
3. **Add tag-based test selection**
|
||||
- Implement `@smoke`, `@auth`, `@config` tags
|
||||
- Add tag filtering to test scripts
|
||||
- Document tag usage in README
|
||||
|
||||
### Deliverables:
|
||||
- ✅ Robust synchronization mechanisms
|
||||
- ✅ Proper context lifecycle management
|
||||
- ✅ Tag-based test execution
|
||||
- ✅ Improved test reliability
|
||||
|
||||
## Phase 3: Parallel Testing (Optional - 1 week)
|
||||
|
||||
### Objective: Enable safe parallel test execution
|
||||
|
||||
### Tasks:
|
||||
1. **Implement port management**
|
||||
- Add port allocation system
|
||||
- Implement port conflict detection
|
||||
- Add parallel execution flags
|
||||
|
||||
2. **Add resource monitoring**
|
||||
- Implement resource usage tracking
|
||||
- Add timeout detection
|
||||
- Implement cleanup on failure
|
||||
|
||||
3. **Update CI/CD pipeline**
|
||||
- Add parallel test execution
|
||||
- Implement resource limits
|
||||
- Add test isolation validation
|
||||
|
||||
### Deliverables:
|
||||
- ✅ Parallel test execution capability
|
||||
- ✅ Resource monitoring and limits
|
||||
- ✅ Updated CI/CD configuration
|
||||
|
||||
## Implementation Timeline
|
||||
|
||||
### Week 1-2: Phase 1 - Test Refactoring
|
||||
- Day 1-2: Create feature directory structure
|
||||
- Day 3-4: Implement feature-specific configs
|
||||
- Day 5-7: Create test scripts and isolation
|
||||
- Day 8-10: Test and validate refactoring
|
||||
|
||||
### Week 3-5: Phase 2 - Infrastructure Enhancement
|
||||
- Day 11-12: Add synchronization helpers
|
||||
- Day 13-14: Implement context management
|
||||
- Day 15-17: Add tag-based selection
|
||||
- Day 18-21: Test and validate infrastructure
|
||||
|
||||
### Week 6: Phase 3 - Parallel Testing (Optional)
|
||||
- Day 22-24: Implement port management
|
||||
- Day 25-26: Add resource monitoring
|
||||
- Day 27-28: Update CI/CD pipeline
|
||||
- Day 29-30: Test and validate parallel execution
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Phase 1 Success:
|
||||
- ✅ All tests pass in new structure
|
||||
- ✅ Feature isolation working correctly
|
||||
- ✅ Test scripts functional
|
||||
- ✅ No regression in test coverage
|
||||
|
||||
### Phase 2 Success:
|
||||
- ✅ Synchronization working reliably
|
||||
- ✅ Context management implemented
|
||||
- ✅ Tag filtering operational
|
||||
- ✅ Test reliability >95%
|
||||
|
||||
### Phase 3 Success:
|
||||
- ✅ Parallel tests execute safely
|
||||
- ✅ Resource usage within limits
|
||||
- ✅ CI/CD pipeline updated
|
||||
- ✅ Test execution time reduced
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
### Phase 1 Risks:
|
||||
- **Test failures during refactoring**: Maintain old structure until new is validated
|
||||
- **Isolation issues**: Implement gradual rollout with validation
|
||||
|
||||
### Phase 2 Risks:
|
||||
- **Synchronization complexity**: Start with simple timeouts, enhance gradually
|
||||
- **Context management bugs**: Add comprehensive logging and debugging
|
||||
|
||||
### Phase 3 Risks:
|
||||
- **Resource conflicts**: Implement strict resource limits and monitoring
|
||||
- **CI/CD instability**: Test parallel execution locally before pipeline update
|
||||
|
||||
## Monitoring and Validation
|
||||
|
||||
### Phase 1 Validation:
|
||||
```bash
|
||||
# Test each feature independently
|
||||
./scripts/test-feature.sh auth
|
||||
./scripts/test-feature.sh config
|
||||
./scripts/test-feature.sh greet
|
||||
|
||||
# Verify isolation
|
||||
./scripts/validate-isolation.sh
|
||||
```
|
||||
|
||||
### Phase 2 Validation:
|
||||
```bash
|
||||
# Test synchronization
|
||||
./scripts/test-synchronization.sh
|
||||
|
||||
# Test tag filtering
|
||||
godog --tags=@smoke features/
|
||||
|
||||
# Test context management
|
||||
./scripts/test-context-lifecycle.sh
|
||||
```
|
||||
|
||||
### Phase 3 Validation:
|
||||
```bash
|
||||
# Test parallel execution
|
||||
./scripts/test-all-features-parallel.sh
|
||||
|
||||
# Monitor resource usage
|
||||
./scripts/monitor-test-resources.sh
|
||||
|
||||
# Validate CI/CD changes
|
||||
./scripts/validate-ci-cd.sh
|
||||
```
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
### Phase 1 Rollback:
|
||||
```bash
|
||||
# Revert to original structure
|
||||
git checkout HEAD~1 -- features/
|
||||
|
||||
# Restore original test scripts
|
||||
git checkout HEAD~1 -- scripts/test-*.sh
|
||||
```
|
||||
|
||||
### Phase 2 Rollback:
|
||||
```bash
|
||||
# Remove synchronization helpers
|
||||
git checkout HEAD~1 -- pkg/bdd/helpers/
|
||||
|
||||
# Restore original context management
|
||||
git checkout HEAD~1 -- pkg/bdd/context/
|
||||
```
|
||||
|
||||
### Phase 3 Rollback:
|
||||
```bash
|
||||
# Disable parallel execution
|
||||
sed -i 's/parallel=true/parallel=false/' scripts/test-all-features-parallel.sh
|
||||
|
||||
# Revert CI/CD changes
|
||||
git checkout HEAD~1 -- .github/workflows/
|
||||
```
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
### Phase 1 Documentation:
|
||||
- ✅ Update README with new test structure
|
||||
- ✅ Document feature organization conventions
|
||||
- ✅ Add test execution instructions
|
||||
|
||||
### Phase 2 Documentation:
|
||||
- ✅ Document synchronization patterns
|
||||
- ✅ Add context management guide
|
||||
- ✅ Document tag usage and filtering
|
||||
|
||||
### Phase 3 Documentation:
|
||||
- ✅ Add parallel testing guide
|
||||
- ✅ Document resource limits
|
||||
- ✅ Update CI/CD documentation
|
||||
|
||||
## Team Communication
|
||||
|
||||
### Phase 1:
|
||||
- Team meeting to explain new structure
|
||||
- Hands-on workshop for test refactoring
|
||||
- Daily standups to track progress
|
||||
|
||||
### Phase 2:
|
||||
- Technical deep dive on synchronization
|
||||
- Code review sessions for context management
|
||||
- Pair programming for complex scenarios
|
||||
|
||||
### Phase 3:
|
||||
- Performance testing workshop
|
||||
- CI/CD pipeline review
|
||||
- Resource monitoring training
|
||||
|
||||
## Continuous Improvement
|
||||
|
||||
### Post-Phase 1:
|
||||
- Gather feedback on new structure
|
||||
- Identify pain points in isolation
|
||||
- Optimize test execution times
|
||||
|
||||
### Post-Phase 2:
|
||||
- Monitor test reliability metrics
|
||||
- Identify flaky tests for fixing
|
||||
- Optimize synchronization patterns
|
||||
|
||||
### Post-Phase 3:
|
||||
- Monitor parallel execution performance
|
||||
- Identify resource bottlenecks
|
||||
- Optimize CI/CD pipeline timing
|
||||
|
||||
## Metrics Tracking
|
||||
|
||||
### Test Reliability:
|
||||
```
|
||||
# Track pass rate over time
|
||||
./scripts/track-test-reliability.sh
|
||||
```
|
||||
|
||||
### Test Execution Time:
|
||||
```
|
||||
# Monitor execution times
|
||||
./scripts/monitor-execution-time.sh
|
||||
```
|
||||
|
||||
### Resource Usage:
|
||||
```
|
||||
# Track resource consumption
|
||||
./scripts/monitor-resource-usage.sh
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Post-Phase 3:
|
||||
- Test impact analysis
|
||||
- Flaky test detection
|
||||
- Performance benchmarking
|
||||
- Test coverage visualization
|
||||
|
||||
### Long-term:
|
||||
- AI-assisted test generation
|
||||
- Automated test optimization
|
||||
- Predictive test failure analysis
|
||||
- Intelligent test prioritization
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Phase 1: Test Refactoring
|
||||
- [ ] Create feature directories
|
||||
- [ ] Split feature files
|
||||
- [ ] Implement config isolation
|
||||
- [ ] Add database isolation
|
||||
- [ ] Create test scripts
|
||||
- [ ] Test and validate
|
||||
|
||||
### Phase 2: Infrastructure Enhancement
|
||||
- [ ] Add synchronization helpers
|
||||
- [ ] Implement context management
|
||||
- [ ] Add tag filtering
|
||||
- [ ] Test and validate
|
||||
|
||||
### Phase 3: Parallel Testing
|
||||
- [ ] Implement port management
|
||||
- [ ] Add resource monitoring
|
||||
- [ ] Update CI/CD pipeline
|
||||
- [ ] Test and validate
|
||||
|
||||
## Notes
|
||||
|
||||
- Each phase builds on the previous one
|
||||
- Phase 3 is optional and can be deferred
|
||||
- Focus on reliability before performance
|
||||
- Maintain backward compatibility where possible
|
||||
- Document all changes thoroughly
|
||||
- Gather team feedback at each phase
|
||||
- Monitor metrics continuously
|
||||
- Celebrate milestones and successes
|
||||
|
||||
@@ -48,8 +48,10 @@ func main() {
|
||||
log.Fatal().Err(err).Msg("Failed to load configuration")
|
||||
}
|
||||
|
||||
// Create readiness context to control readiness state
|
||||
readyCtx, readyCancel := context.WithCancel(context.Background())
|
||||
// Create readiness context to control readiness state.
|
||||
// CancelableContext exposes Cancel() so that Server.Run() can cancel
|
||||
// readiness at the start of graceful shutdown (before the propagation sleep).
|
||||
readyCtx, readyCancel := server.NewCancelableContext(context.Background())
|
||||
defer readyCancel()
|
||||
|
||||
// Create and run server
|
||||
@@ -57,4 +59,5 @@ func main() {
|
||||
if err := server.Run(); err != nil {
|
||||
log.Fatal().Err(err).Msg("Server failed")
|
||||
}
|
||||
log.Trace().Msg("Server exited")
|
||||
}
|
||||
|
||||
13
config.yaml
13
config.yaml
@@ -87,4 +87,15 @@ database:
|
||||
|
||||
# Maximum lifetime of connections (default: "1h")
|
||||
# Format: number + unit (s, m, h)
|
||||
conn_max_lifetime: 1h
|
||||
conn_max_lifetime: 1h
|
||||
|
||||
# Cache configuration (in-memory)
|
||||
cache:
|
||||
# Enable in-memory cache (default: true)
|
||||
enabled: true
|
||||
|
||||
# Default TTL in seconds for cache items (default: 300 = 5 minutes)
|
||||
default_ttl_seconds: 300
|
||||
|
||||
# Cleanup interval in seconds for expired items (default: 600 = 10 minutes)
|
||||
cleanup_interval_seconds: 600
|
||||
@@ -19,6 +19,23 @@ services:
|
||||
- dance-lessons-coach-network
|
||||
restart: unless-stopped
|
||||
|
||||
# Mailpit — local SMTP capture for dev + BDD parallel email tests.
|
||||
# Cf. ADR-0029 (email infrastructure) and ADR-0030 (BDD parallel strategy).
|
||||
# SMTP submission on :1025 (used by the app), HTTP UI + API on :8025
|
||||
# (used by tests + manual inspection at http://localhost:8025).
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
container_name: dance-lessons-coach-mailpit
|
||||
ports:
|
||||
- "1025:1025" # SMTP submission
|
||||
- "8025:8025" # HTTP UI / API
|
||||
environment:
|
||||
MP_MAX_MESSAGES: 5000
|
||||
MP_SMTP_AUTH_ALLOW_INSECURE: 1 # local dev only - no TLS, no real auth
|
||||
networks:
|
||||
- dance-lessons-coach-network
|
||||
restart: unless-stopped
|
||||
|
||||
# Application service (for reference)
|
||||
# app:
|
||||
# build: .
|
||||
|
||||
127
documentation/API.md
Normal file
127
documentation/API.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# API endpoints
|
||||
|
||||
Reference document for all HTTP endpoints exposed by `dance-lessons-coach` server. The authoritative source is the swag-generated Swagger UI at `/swagger/index.html` (served by the Go binary). This markdown is the human-readable index, intentionally short — when in doubt, run the server and open Swagger.
|
||||
|
||||
## Conventions
|
||||
|
||||
- All paths under `/api/` (no other prefix is used)
|
||||
- Versioned API under `/api/v1/<resource>` and `/api/v2/<resource>` (cf. ADR-0010 v2 feature flag)
|
||||
- System / Health / Version endpoints at root (`/api/<endpoint>`, no version)
|
||||
- Admin endpoints under `/api/admin/<action>` (require master admin password header)
|
||||
- Response Content-Type: `application/json` unless documented otherwise
|
||||
- Error envelope: `{"error":"<code>","message":"<text>"}` (HTTP 4xx/5xx)
|
||||
|
||||
## System endpoints (no auth)
|
||||
|
||||
| Method | Path | Purpose | Cf. |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/health` | Liveness check (legacy, returns `{"status":"healthy"}`) | `pkg/server/server.go` |
|
||||
| GET | `/api/healthz` | **Kubernetes-style** rich health: status / version / uptime_seconds / timestamp | PR #20 — handler with swag `@Router /healthz [get]` |
|
||||
| GET | `/api/ready` | Readiness check (DB connection + service deps) | `pkg/server/server.go handleReadiness` |
|
||||
| GET | `/api/version` | Version info (cached 60s, since PR #29) | `pkg/server/server.go handleVersion` |
|
||||
| GET | `/api/info` | **Composite info aggregator**: version / commit_short / build_date / uptime_seconds / cache_enabled / healthz_status. Cached when cache is enabled (X-Cache: HIT/MISS header) | ADR-0026 — `pkg/server/server.go handleInfo` |
|
||||
|
||||
`/api/info` body schema (`InfoResponse`):
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"commit_short": "abc12345",
|
||||
"build_date": "2026-05-05",
|
||||
"uptime_seconds": 1234,
|
||||
"cache_enabled": true,
|
||||
"healthz_status": "healthy",
|
||||
"go_version": "go1.26.1"
|
||||
}
|
||||
```
|
||||
|
||||
Use `/api/info` from a frontend footer or status page when you need version + uptime + cache state in a single round trip. The composite design avoids 3-4 chatty calls (`/version`, `/healthz`, `/ready`) when only a snapshot is needed.
|
||||
|
||||
`/api/healthz` body schema (`HealthzResponse`):
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"version": "1.4.0",
|
||||
"uptime_seconds": 1234,
|
||||
"timestamp": "2026-05-04T08:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Use `/api/healthz` for kubelet liveness probes — richer than `/api/health` and stable.
|
||||
|
||||
## Admin endpoints (require X-Admin-Password header)
|
||||
|
||||
| Method | Path | Purpose | Cf. |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/admin/cache/flush` | Flush the entire in-memory cache. Returns `{"flushed":true,"items_flushed":N,"timestamp":"..."}` (200) or `{"error":"unauthorized"}` (401) or `{"error":"cache_disabled"}` (503) | PR #29 — `pkg/server/server.go handleAdminCacheFlush` |
|
||||
|
||||
Auth: header `X-Admin-Password: <master-password>` (matches `auth.admin_master_password` in config / `DLC_AUTH_ADMIN_MASTER_PASSWORD` env var). Default `admin123` for local dev — **change in production**.
|
||||
|
||||
## v1 API (auth + greeting)
|
||||
|
||||
Mounted at `/api/v1/...` with the rate-limit middleware (cf. ADR-0022 Phase 1, since PR #22). Cached responses on greet (since PR #29).
|
||||
|
||||
### Auth (`/api/v1/auth/...`)
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| POST | `/api/v1/auth/register` | User registration |
|
||||
| POST | `/api/v1/auth/login` | Login with username + password, returns JWT |
|
||||
| POST | `/api/v1/auth/validate` | Validate a JWT token |
|
||||
| POST | `/api/v1/auth/password-reset/request` | Request password reset (admin-flagged users only) |
|
||||
| POST | `/api/v1/auth/password-reset/complete` | Complete password reset |
|
||||
|
||||
JWT secret rotation policies: cf. ADR-0021 + JWT secrets endpoints under `/api/v1/admin/jwt/secrets` (admin-only).
|
||||
|
||||
### Greet (`/api/v1/greet/...`)
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/greet?name=X` | Greeting (cached per name 60s, header `X-Cache: HIT/MISS`) |
|
||||
| GET | `/api/v1/greet/{name}` | Greeting (path param variant, same caching) |
|
||||
|
||||
### Admin under v1 (`/api/v1/admin/...`)
|
||||
|
||||
JWT secret management endpoints.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/v1/admin/jwt/secrets` | List metadata (count + per-secret: is_primary, created_at_unix, expires_at_unix?, age_seconds, is_expired, sha256 fingerprint). **Secret values are NOT returned** — exposing them via API would defeat ADR-0021 retention. |
|
||||
| `POST` | `/api/v1/admin/jwt/secrets` | Add a new JWT secret (body: `{secret, is_primary, expires_in}`) |
|
||||
| `POST` | `/api/v1/admin/jwt/secrets/rotate` | Rotate to a new primary secret (body: `{new_secret}`) |
|
||||
|
||||
`GET` response shape (security: only fingerprint, no secret value):
|
||||
|
||||
```json
|
||||
{
|
||||
"count": 2,
|
||||
"secrets": [
|
||||
{"is_primary": true, "created_at_unix": 1714900000, "age_seconds": 600, "is_expired": false, "secret_sha256": "a3f9c2..."},
|
||||
{"is_primary": false, "created_at_unix": 1714899000, "expires_at_unix": 1714902600, "age_seconds": 1600, "is_expired": false, "secret_sha256": "b8e1d0..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Cf. ADR-0021 + features/jwt/ BDD scenarios for the broader contract.
|
||||
|
||||
## v2 API
|
||||
|
||||
Enabled via `api.v2_enabled` config (cf. ADR-0010 v2 feature flag).
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| POST | `/api/v2/greet` | v2 greeting (JSON body, more validation) |
|
||||
|
||||
## Swagger UI
|
||||
|
||||
Served at `/swagger/index.html` (and `/swagger/doc.json` for the embedded spec). Always reflects what the running binary exposes — when in doubt, prefer Swagger over this markdown.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [ADR-0002](../adr/0002-chi-router.md) — Chi router choice
|
||||
- [ADR-0010](../adr/0010-api-v2-feature-flag.md) — v2 feature flag
|
||||
- [ADR-0013](../adr/0013-openapi-swagger-toolchain.md) — OpenAPI / Swagger toolchain
|
||||
- [ADR-0018](../adr/0018-user-management-auth-system.md) — User management & auth
|
||||
- [ADR-0021](../adr/0021-jwt-secret-retention-policy.md) — JWT secret retention
|
||||
- [ADR-0022](../adr/0022-rate-limiting-cache-strategy.md) — Rate limiting + cache
|
||||
89
documentation/BDD_TEST_ENV.md
Normal file
89
documentation/BDD_TEST_ENV.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# BDD test environment
|
||||
|
||||
Environment variables and tooling specific to running BDD scenarios locally and in CI. Companion to [BDD_GUIDE.md](BDD_GUIDE.md) (which covers the BDD authoring workflow itself).
|
||||
|
||||
## Required env vars (database connection)
|
||||
|
||||
The BDD test server needs a Postgres instance reachable via:
|
||||
|
||||
| Var | Default | Notes |
|
||||
|---|---|---|
|
||||
| `DLC_DATABASE_HOST` | `localhost` | Host of the Postgres instance |
|
||||
| `DLC_DATABASE_PORT` | `5432` | |
|
||||
| `DLC_DATABASE_USER` | `postgres` | Test-only credentials (NOT production) |
|
||||
| `DLC_DATABASE_PASSWORD` | `postgres` | |
|
||||
| `DLC_DATABASE_NAME` | `dance_lessons_coach_bdd_test` | Dedicated test DB |
|
||||
| `DLC_DATABASE_SSL_MODE` | `disable` | Tests run without TLS |
|
||||
|
||||
Local setup:
|
||||
|
||||
```bash
|
||||
docker compose up -d # Postgres container
|
||||
docker exec dance-lessons-coach-postgres psql -U postgres \
|
||||
-c "CREATE DATABASE dance_lessons_coach_bdd_test;" # one-time
|
||||
```
|
||||
|
||||
In CI: `.gitea/workflows/ci-cd.yaml` provisions a Postgres service container and exports the same vars.
|
||||
|
||||
## Optional env vars
|
||||
|
||||
### `BDD_SCHEMA_ISOLATION` (since [PR #35](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/35) — T12 stage 2/2)
|
||||
|
||||
| Value | Behaviour |
|
||||
|---|---|
|
||||
| `true` | Each test PACKAGE (process) gets its own isolated PostgreSQL schema with migrations. Packages run in **parallel** safely. **~2.85x speedup observed locally.** This is the new default in CI. |
|
||||
| (unset / `false`) | Falls back to single shared `public` schema with `CleanupDatabase` (TRUNCATE) between scenarios. Forces sequential package execution (`-p 1`). Slower but simpler. |
|
||||
|
||||
Implementation: `pkg/bdd/testserver/server.go Start()` builds a per-package isolated repo via `user.NewPostgresRepositoryFromDSN` (PR #34). `Stop()` drops the schema + closes the per-package pool.
|
||||
|
||||
ADR-0025 documents the isolation strategy ("Implemented" since PR #35).
|
||||
|
||||
### `FEATURE` (per-package selector)
|
||||
|
||||
When set, `pkg/bdd/testserver/server.go shouldEnableV2()` reads it. Used to scope per-feature behaviour (e.g. enable v2 endpoints only when `FEATURE=greet` AND `GODOG_TAGS` includes `@v2`).
|
||||
|
||||
Without `FEATURE` set, falls back to `bdd` (generic).
|
||||
|
||||
### `GODOG_TAGS` (scenario filter)
|
||||
|
||||
Standard godog env var. The default suite excludes flaky/todo/skip/v2 tags:
|
||||
```
|
||||
GODOG_TAGS="~@flaky && ~@todo && ~@skip && ~@v2"
|
||||
```
|
||||
|
||||
Scoped runs (e.g. `@critical` only): set `GODOG_TAGS="@critical"` and run.
|
||||
|
||||
### `BDD_ENABLE_CLEANUP_LOGS` (debug)
|
||||
|
||||
Set `=true` to log each scenario's CLEANUP / ISOLATION operation. Useful when debugging flakiness.
|
||||
|
||||
## Recommended local commands
|
||||
|
||||
Run all BDD with isolation (parallel, fast):
|
||||
```bash
|
||||
DLC_DATABASE_HOST=localhost DLC_DATABASE_PORT=5432 \
|
||||
DLC_DATABASE_USER=postgres DLC_DATABASE_PASSWORD=postgres \
|
||||
DLC_DATABASE_NAME=dance_lessons_coach_bdd_test DLC_DATABASE_SSL_MODE=disable \
|
||||
BDD_SCHEMA_ISOLATION=true \
|
||||
go test ./features/...
|
||||
```
|
||||
|
||||
Run one feature with v2 enabled:
|
||||
```bash
|
||||
DLC_DATABASE_HOST=... \
|
||||
BDD_SCHEMA_ISOLATION=true FEATURE=greet GODOG_TAGS="@v2" \
|
||||
go test ./features/greet/...
|
||||
```
|
||||
|
||||
Repro CI conditions (sequential, no isolation):
|
||||
```bash
|
||||
DLC_DATABASE_HOST=... \
|
||||
go test ./features/... -p 1
|
||||
```
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [BDD_GUIDE.md](BDD_GUIDE.md) — authoring scenarios + steps
|
||||
- [ADR-0008](../adr/0008-bdd-testing.md) — choice of Godog
|
||||
- [ADR-0024](../adr/0024-bdd-test-organization-and-isolation.md) — feature directory organization
|
||||
- [ADR-0025](../adr/0025-bdd-scenario-isolation-strategies.md) — isolation strategies (Implemented since PR #35)
|
||||
107
documentation/EMAIL.md
Normal file
107
documentation/EMAIL.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Email infrastructure
|
||||
|
||||
Outgoing email transport. Per [ADR-0029](../adr/0029-email-infrastructure-mailpit.md): Mailpit for local dev + BDD tests, production sender deferred.
|
||||
|
||||
## Local setup (one-time)
|
||||
|
||||
Mailpit is part of `docker-compose.yml`:
|
||||
|
||||
```bash
|
||||
docker compose up -d # starts postgres + mailpit
|
||||
docker compose ps # confirm both running
|
||||
```
|
||||
|
||||
Mailpit listens on:
|
||||
- **SMTP submission** — `localhost:1025` (the app sends here)
|
||||
- **HTTP UI / API** — http://localhost:8025 (you inspect captured messages here)
|
||||
|
||||
No real emails leave the docker network. No internet required.
|
||||
|
||||
## Application configuration
|
||||
|
||||
The application's outgoing transport is configured under `auth.email.*` in `config.yaml` (or via `DLC_AUTH_EMAIL_*` env vars). Defaults already match local Mailpit:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
email:
|
||||
from: noreply@dance-lessons-coach.local
|
||||
smtp_host: localhost
|
||||
smtp_port: 1025
|
||||
smtp_use_tls: false
|
||||
timeout: 10s
|
||||
# smtp_username + smtp_password left empty for local Mailpit
|
||||
```
|
||||
|
||||
For production, override these to point at the chosen provider (SES, Postmark, etc.).
|
||||
|
||||
## Inspecting messages
|
||||
|
||||
### Web UI
|
||||
|
||||
http://localhost:8025 — list of all captured messages, search, raw view, HTML preview.
|
||||
|
||||
### HTTP API (for automation)
|
||||
|
||||
```bash
|
||||
# Latest 10 messages (no filter — /api/v1/messages is for pagination)
|
||||
curl -s 'http://localhost:8025/api/v1/messages?limit=10' | jq
|
||||
|
||||
# Messages for a specific recipient — use /api/v1/search, NOT /messages
|
||||
# (the latter's `query` param is for pagination only, not filtering ;
|
||||
# verified empirically 2026-05-05)
|
||||
curl -s 'http://localhost:8025/api/v1/search?query=to:test-user@bdd.local' | jq
|
||||
|
||||
# Get a specific message by ID (full content, headers, attachments)
|
||||
curl -s 'http://localhost:8025/api/v1/message/<id>' | jq
|
||||
|
||||
# Purge messages for a recipient (used in test cleanup) — also via /search
|
||||
curl -X DELETE 'http://localhost:8025/api/v1/search?query=to:test-user@bdd.local'
|
||||
```
|
||||
|
||||
Full API: https://mailpit.axllent.org/docs/api-v1/
|
||||
|
||||
## Sending email from Go code
|
||||
|
||||
```go
|
||||
import "dance-lessons-coach/pkg/email"
|
||||
|
||||
sender := email.NewSMTPSender(email.SMTPConfig{
|
||||
Host: cfg.GetEmailConfig().SMTPHost,
|
||||
Port: cfg.GetEmailConfig().SMTPPort,
|
||||
// username/password optional — empty means no AUTH (Mailpit local)
|
||||
})
|
||||
|
||||
err := sender.Send(ctx, email.Message{
|
||||
To: "alice@example.com",
|
||||
From: cfg.GetEmailConfig().From,
|
||||
Subject: "Your magic link",
|
||||
BodyText: "Click: https://example.com/magic-link/consume?token=...",
|
||||
Headers: map[string]string{
|
||||
// optional — useful for BDD test correlation
|
||||
"X-Trace-Id": "req-abc-123",
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Or, when both text and HTML are needed (`multipart/alternative`):
|
||||
|
||||
```go
|
||||
err := sender.Send(ctx, email.Message{
|
||||
To: "alice@example.com", From: "...", Subject: "...",
|
||||
BodyText: "Click: https://...",
|
||||
BodyHTML: `<p>Click <a href="https://...">your magic link</a></p>`,
|
||||
})
|
||||
```
|
||||
|
||||
## Production sender (TBD)
|
||||
|
||||
Not chosen yet. When ready, implement another `email.Sender` in
|
||||
`pkg/email/<provider>_sender.go` and wire it via the config. The
|
||||
`Sender` interface is the swap point — call sites don't change.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [ADR-0028 — Passwordless auth migration](../adr/0028-passwordless-auth-migration.md) (consumes this infrastructure)
|
||||
- [ADR-0029 — Email infrastructure decision](../adr/0029-email-infrastructure-mailpit.md)
|
||||
- [ADR-0030 — BDD email parallel strategy](../adr/0030-bdd-email-parallel-strategy.md)
|
||||
- [Mailpit docs](https://mailpit.axllent.org/docs/)
|
||||
346
features/BDD_TAGS.md
Normal file
346
features/BDD_TAGS.md
Normal file
@@ -0,0 +1,346 @@
|
||||
# BDD Test Tags Documentation
|
||||
|
||||
This document describes the tagging system used in the dance-lessons-coach BDD tests for selective test execution.
|
||||
|
||||
## Tag Categories
|
||||
|
||||
### Feature Tags
|
||||
Used to categorize tests by feature area:
|
||||
- `@auth` - Authentication and user management tests
|
||||
- `@config` - Configuration and hot reloading tests
|
||||
- `@greet` - Greeting service tests
|
||||
- `@health` - Health check and monitoring tests
|
||||
- `@jwt` - JWT secret rotation and retention tests
|
||||
|
||||
### Priority Tags
|
||||
Used to categorize tests by importance:
|
||||
- `@smoke` - Basic smoke tests that verify core functionality
|
||||
- `@critical` - Critical path tests that must always pass
|
||||
- `@basic` - Basic functionality tests
|
||||
- `@advanced` - Advanced or edge case scenarios
|
||||
- `@nice_to_have` - Optional features that would be nice to have but aren't critical
|
||||
|
||||
### Component Tags
|
||||
Used to categorize tests by system component:
|
||||
- `@api` - API endpoint tests
|
||||
- `@v2` - Version 2 API tests
|
||||
- `@database` - Database interaction tests
|
||||
- `@security` - Security-related tests
|
||||
|
||||
### Exclusion Tags
|
||||
Used to exclude tests from execution:
|
||||
- `@flaky` - Tests that are unstable or intermittently fail
|
||||
- `@todo` - Tests with pending step implementations
|
||||
- `@skip` - Tests that should be skipped entirely
|
||||
|
||||
### Nice-to-Have Tag
|
||||
|
||||
The `@nice_to_have` tag is used to mark scenarios that test optional features or enhancements. These are features that would be beneficial to have but aren't critical for the core functionality of the system.
|
||||
|
||||
**Usage:**
|
||||
- Add `@nice_to_have` to scenarios testing optional features
|
||||
- These scenarios are typically excluded from critical path testing
|
||||
- Useful for marking "stretch goal" functionality
|
||||
|
||||
**Example:**
|
||||
```gherkin
|
||||
@nice_to_have @greet
|
||||
Scenario: Greeting with custom formatting options
|
||||
Given the server is running
|
||||
When I request a greeting with bold formatting
|
||||
Then the response should contain HTML bold tags
|
||||
```
|
||||
|
||||
### Work In Progress Tag
|
||||
Used to override exclusions for active development:
|
||||
- `@wip` - Work In Progress - overrides exclusion tags to allow focused development
|
||||
|
||||
**Usage:** Add `@wip` to scenarios you're actively working on, even if they have other exclusion tags like `@todo` or `@skip`. The `@wip` tag takes precedence and allows the scenario to run.
|
||||
|
||||
**Example:**
|
||||
```gherkin
|
||||
@todo @wip
|
||||
Scenario: JWT authentication with multiple secrets
|
||||
Given the server is running with multiple JWT secrets
|
||||
When I authenticate with valid credentials
|
||||
Then I should receive a valid JWT token
|
||||
```
|
||||
|
||||
### Command-Line Tag Override
|
||||
You can override the default tag filtering by setting the `GODOG_TAGS` environment variable when running tests.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Run only @wip scenarios
|
||||
GODOG_TAGS="@wip" go test ./features/jwt/...
|
||||
|
||||
# Run smoke tests only
|
||||
GODOG_TAGS="@smoke" go test ./features/...
|
||||
|
||||
# Run specific combination
|
||||
GODOG_TAGS="@jwt && ~@todo" go test ./features/...
|
||||
|
||||
# Combine with other environment variables
|
||||
DLC_DATABASE_HOST=localhost GODOG_TAGS="@wip" go test ./features/jwt/...
|
||||
```
|
||||
|
||||
### Test Randomization Control
|
||||
You can control test execution order using the `GODOG_RANDOM_SEED` environment variable.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Use random test order (default)
|
||||
GODOG_RANDOM_SEED="" go test ./features/
|
||||
|
||||
# Use fixed seed for reproducible test runs
|
||||
GODOG_RANDOM_SEED=17925 go test ./features/
|
||||
|
||||
# Combine with tag filtering
|
||||
GODOG_RANDOM_SEED=17925 GODOG_TAGS="@wip" go test ./features/
|
||||
|
||||
# Debug specific test failures by reproducing exact execution order
|
||||
GODOG_RANDOM_SEED=17925 DLC_DATABASE_HOST=localhost go test ./features/jwt/
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- **Reproducibility**: Same seed produces same test order
|
||||
- **Debugging**: Easily reproduce failed test runs
|
||||
- **CI/CD**: Set fixed seeds for consistent test execution
|
||||
- **Backward compatible**: Defaults to random order when not specified
|
||||
|
||||
**Example from test output:**
|
||||
```
|
||||
30 scenarios (11 passed, 19 failed)
|
||||
147 steps (104 passed, 19 failed, 24 skipped)
|
||||
4.474215346s
|
||||
Randomized with seed: 17925
|
||||
```
|
||||
|
||||
To reproduce this exact test run:
|
||||
```bash
|
||||
GODOG_RANDOM_SEED=17925 go test ./features/
|
||||
```
|
||||
|
||||
### Random Port Selection (Default Behavior)
|
||||
|
||||
By default, BDD tests use **random ports** (10000-19999) to prevent port conflicts during parallel execution. This ensures tests can run reliably in CI/CD pipelines and when executed multiple times.
|
||||
|
||||
**Benefits:**
|
||||
- ✅ No port conflicts in parallel test execution
|
||||
- ✅ Safe for repeated test runs
|
||||
- ✅ Better for CI/CD environments
|
||||
|
||||
**Disable random ports (not recommended):**
|
||||
```bash
|
||||
FIXED_TEST_PORT=true go test ./features/...
|
||||
```
|
||||
|
||||
**Force specific port (debugging only):**
|
||||
```bash
|
||||
# Create a test config file with fixed port
|
||||
echo "server:
|
||||
port: 9191" > test-config.yaml
|
||||
FEATURE=debug FIXED_TEST_PORT=true go test ./features/...
|
||||
```
|
||||
|
||||
### Test Validation Process
|
||||
|
||||
To ensure test suite stability, follow this validation process:
|
||||
|
||||
**Validation Command:**
|
||||
```bash
|
||||
# Clean cache and run all tests 20 times
|
||||
echo "🧪 Validating test suite stability..."
|
||||
for i in {1..20}; do
|
||||
echo "Run $i/20..."
|
||||
go clean -testcache
|
||||
if ! go test ./... > /dev/null 2>&1; then
|
||||
echo "❌ Test run $i failed"
|
||||
go test ./... -v
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "✅ All 20 test runs passed successfully!"
|
||||
```
|
||||
|
||||
**Failure Handling:**
|
||||
- If any test fails during validation, mark it as `@wip` and investigate
|
||||
- Use `@flaky` tag for intermittently failing tests
|
||||
- Document the issue in the test scenario comments
|
||||
|
||||
**Success Criteria:**
|
||||
- ✅ 100% pass rate across 20 consecutive runs
|
||||
- ✅ No undefined/pending steps
|
||||
- ✅ No race conditions or port conflicts
|
||||
- ✅ Consistent execution time
|
||||
|
||||
**CI/CD Integration:**
|
||||
```yaml
|
||||
- name: Validate Test Suite
|
||||
run: |
|
||||
echo "🧪 Running 20 validation runs..."
|
||||
for i in {1..20}; do
|
||||
echo "Run $i/20"
|
||||
go clean -testcache
|
||||
go test ./... || exit 1
|
||||
done
|
||||
echo "✅ Test suite validated successfully"
|
||||
```
|
||||
|
||||
### Stop On Failure Control
|
||||
You can control whether tests stop on first failure using the `GODOG_STOP_ON_FAILURE` environment variable.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Stop on first failure (strict mode)
|
||||
GODOG_STOP_ON_FAILURE="true" go test ./features/jwt/...
|
||||
|
||||
# Continue after failures (lenient mode)
|
||||
GODOG_STOP_ON_FAILURE="false" go test ./features/jwt/...
|
||||
|
||||
# Combine with tag filtering
|
||||
GODOG_TAGS="@wip" GODOG_STOP_ON_FAILURE="true" go test ./features/jwt/...
|
||||
```
|
||||
|
||||
**Default Behavior:**
|
||||
- If `GODOG_TAGS` is not set, the test uses the default tag filter: `~@flaky && ~@todo && ~@skip`
|
||||
- If `GODOG_STOP_ON_FAILURE` is not set, each feature uses its default:
|
||||
- `jwt`, `greet`, `auth`, `health`: `true` (stop on failure)
|
||||
- `config`, `all features`: `false` (continue after failures)
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Running Smoke Tests
|
||||
```bash
|
||||
# Run all smoke tests
|
||||
godog --tags=@smoke features/
|
||||
|
||||
# Run smoke tests for specific feature
|
||||
godog --tags=@smoke features/auth/
|
||||
```
|
||||
|
||||
### Running Critical Tests
|
||||
```bash
|
||||
# Run all critical tests
|
||||
godog --tags=@critical features/
|
||||
|
||||
# Run critical health tests
|
||||
godog --tags=@critical,@health features/
|
||||
```
|
||||
|
||||
### Running Feature-Specific Tests
|
||||
```bash
|
||||
# Run all auth tests
|
||||
godog --tags=@auth features/
|
||||
|
||||
# Run v2 API tests
|
||||
godog --tags=@v2 features/
|
||||
```
|
||||
|
||||
### Combining Tags
|
||||
```bash
|
||||
# Run smoke tests for auth and health features
|
||||
godog --tags=@smoke,@auth,@health features/
|
||||
|
||||
# Run critical API tests
|
||||
godog --tags=@critical,@api features/
|
||||
```
|
||||
|
||||
## Tagging Conventions
|
||||
|
||||
1. **Feature tags** should be applied at the feature level
|
||||
2. **Priority tags** should be applied at the scenario level
|
||||
3. **Component tags** should be applied at the scenario level
|
||||
4. **Multiple tags** can be applied to a single scenario
|
||||
|
||||
### Example Feature File
|
||||
```gherkin
|
||||
@health @smoke
|
||||
Feature: Health Endpoint
|
||||
The health endpoint should indicate server status
|
||||
|
||||
@basic @critical
|
||||
Scenario: Health check returns healthy status
|
||||
Given the server is running
|
||||
When I request the health endpoint
|
||||
Then the response should be "{\"status\":\"healthy\"}"
|
||||
|
||||
@advanced @api
|
||||
Scenario: Health check with authentication
|
||||
Given the server is running with auth enabled
|
||||
When I request the health endpoint with valid token
|
||||
Then the response should be "{\"status\":\"healthy\"}"
|
||||
```
|
||||
|
||||
## Test Execution Scripts
|
||||
|
||||
### Feature-Specific Testing
|
||||
```bash
|
||||
# Test specific feature
|
||||
./scripts/test-feature.sh greet
|
||||
|
||||
# Test with specific tags
|
||||
./scripts/test-by-tag.sh @smoke greet
|
||||
```
|
||||
|
||||
### Tag-Based Testing
|
||||
```bash
|
||||
# Run smoke tests for all features
|
||||
./scripts/test-by-tag.sh @smoke
|
||||
|
||||
# Run critical auth tests
|
||||
./scripts/test-by-tag.sh @critical auth
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### Smoke Test Pipeline
|
||||
```yaml
|
||||
- name: Run Smoke Tests
|
||||
run: godog --tags=@smoke features/
|
||||
```
|
||||
|
||||
### Critical Path Testing
|
||||
```yaml
|
||||
- name: Run Critical Tests
|
||||
run: godog --tags=@critical features/
|
||||
```
|
||||
|
||||
### Feature-Specific Testing
|
||||
```yaml
|
||||
- name: Test Auth Feature
|
||||
run: ./scripts/test-feature.sh auth
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Tag consistently** - Apply tags consistently across similar scenarios
|
||||
2. **Prioritize tests** - Use priority tags to identify critical tests
|
||||
3. **Document tags** - Keep this documentation updated with new tags
|
||||
4. **Review tags** - Regularly review tag usage to ensure relevance
|
||||
5. **CI/CD optimization** - Use tags to optimize CI/CD pipeline execution times
|
||||
|
||||
## Tag Reference
|
||||
|
||||
| Tag | Purpose | Example Usage |
|
||||
|-----|---------|--------------|
|
||||
| `@smoke` | Smoke tests | `@smoke` on critical features |
|
||||
| `@critical` | Critical path | `@critical` on essential scenarios |
|
||||
| `@basic` | Basic functionality | `@basic` on standard scenarios |
|
||||
| `@advanced` | Advanced scenarios | `@advanced` on edge cases |
|
||||
| `@nice_to_have` | Optional features | `@nice_to_have` on stretch goal scenarios |
|
||||
| `@auth` | Authentication | `@auth` on auth features |
|
||||
| `@config` | Configuration | `@config` on config scenarios |
|
||||
| `@api` | API endpoints | `@api` on endpoint tests |
|
||||
| `@v2` | V2 API | `@v2` on version 2 tests |
|
||||
| `@flaky` | Exclude flaky tests | `@flaky` on unstable scenarios |
|
||||
| `@todo` | Exclude pending tests | `@todo` on unimplemented scenarios |
|
||||
| `@skip` | Exclude tests entirely | `@skip` on disabled scenarios |
|
||||
| `@wip` | Work in progress | `@wip` on actively developed scenarios |
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **Performance tags** - `@fast`, `@slow` for performance categorization
|
||||
- **Environment tags** - `@ci`, `@local` for environment-specific tests
|
||||
- **Risk tags** - `@high-risk`, `@low-risk` for risk-based testing
|
||||
- **Automated tag validation** - Script to validate tag usage consistency
|
||||
16
features/auth/auth_test.go
Normal file
16
features/auth/auth_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"dance-lessons-coach/pkg/bdd/testsetup"
|
||||
)
|
||||
|
||||
func TestAuthBDD(t *testing.T) {
|
||||
config := testsetup.NewFeatureConfig("auth", "progress", false)
|
||||
suite := testsetup.CreateTestSuite(t, config, "dance-lessons-coach BDD Tests - Auth Feature")
|
||||
|
||||
if suite.Run() != 0 {
|
||||
t.Fatal("non-zero status returned, failed to run auth BDD tests")
|
||||
}
|
||||
}
|
||||
34
features/auth/magic_link.feature
Normal file
34
features/auth/magic_link.feature
Normal file
@@ -0,0 +1,34 @@
|
||||
@magic-link
|
||||
Feature: Passwordless magic-link sign-in
|
||||
As a user without a password
|
||||
I want to sign in by clicking a link sent to my email
|
||||
So I can access the system without typing a password
|
||||
|
||||
Scenario: Happy path - request, receive, consume
|
||||
Given the server is running
|
||||
And I have an email address for this scenario
|
||||
When I request a magic link for my email
|
||||
Then I should receive an email with subject "Your sign-in link"
|
||||
And the email contains a magic link token
|
||||
When I consume the magic link token
|
||||
Then the consume should succeed and return a JWT
|
||||
|
||||
Scenario: Token cannot be consumed twice
|
||||
Given the server is running
|
||||
And I have an email address for this scenario
|
||||
When I request a magic link for my email
|
||||
And the email contains a magic link token
|
||||
When I consume the magic link token
|
||||
Then the consume should succeed and return a JWT
|
||||
When I consume the magic link token
|
||||
Then the consume should fail with 401
|
||||
|
||||
Scenario: Missing token returns 400
|
||||
Given the server is running
|
||||
When I consume an empty magic link token
|
||||
Then the response should have status 400
|
||||
|
||||
Scenario: Unknown token returns 401
|
||||
Given the server is running
|
||||
When I consume an unknown magic link token
|
||||
Then the consume should fail with 401
|
||||
@@ -3,22 +3,29 @@ package features
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"dance-lessons-coach/pkg/bdd"
|
||||
"github.com/cucumber/godog"
|
||||
"dance-lessons-coach/pkg/bdd/testsetup"
|
||||
)
|
||||
|
||||
func TestBDD(t *testing.T) {
|
||||
suite := godog.TestSuite{
|
||||
Name: "dance-lessons-coach BDD Tests",
|
||||
TestSuiteInitializer: bdd.InitializeTestSuite,
|
||||
ScenarioInitializer: bdd.InitializeScenario,
|
||||
Options: &godog.Options{
|
||||
Format: "progress",
|
||||
Paths: []string{"."},
|
||||
TestingT: t,
|
||||
},
|
||||
// Get feature name from environment variable or default to all features
|
||||
feature := testsetup.GetFeatureFromEnv()
|
||||
|
||||
var suiteName string
|
||||
var paths []string
|
||||
|
||||
if feature == "" {
|
||||
// Run all features
|
||||
suiteName = "dance-lessons-coach BDD Tests - All Features"
|
||||
paths = testsetup.GetAllFeaturePaths()
|
||||
} else {
|
||||
// Run specific feature
|
||||
suiteName = "dance-lessons-coach BDD Tests - " + feature + " Feature"
|
||||
paths = []string{feature}
|
||||
}
|
||||
|
||||
config := testsetup.NewMultiFeatureConfig(paths, "progress", false)
|
||||
suite := testsetup.CreateMultiFeatureTestSuite(t, config, suiteName)
|
||||
|
||||
if suite.Run() != 0 {
|
||||
t.Fatal("non-zero status returned, failed to run BDD tests")
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
Feature: Config Hot Reloading
|
||||
The system should support selective hot reloading of configuration changes
|
||||
|
||||
@flaky
|
||||
Scenario: Hot reloading logging level changes
|
||||
Given the server is running with config file monitoring enabled
|
||||
When I update the logging level to "debug" in the config file
|
||||
Then the logging level should be updated without restart
|
||||
And debug logs should appear in the output
|
||||
|
||||
@flaky
|
||||
Scenario: Hot reloading feature flags
|
||||
Given the server is running with config file monitoring enabled
|
||||
And the v2 API is disabled
|
||||
@@ -15,6 +17,7 @@ Feature: Config Hot Reloading
|
||||
Then the v2 API should become available without restart
|
||||
And v2 API requests should succeed
|
||||
|
||||
@flaky
|
||||
Scenario: Hot reloading telemetry sampling settings
|
||||
Given the server is running with config file monitoring enabled
|
||||
And telemetry is enabled
|
||||
@@ -23,6 +26,7 @@ Feature: Config Hot Reloading
|
||||
Then the telemetry sampling should be updated without restart
|
||||
And the new sampling settings should be applied
|
||||
|
||||
@flaky
|
||||
Scenario: Hot reloading JWT TTL
|
||||
Given the server is running with config file monitoring enabled
|
||||
And JWT TTL is set to 1 hour
|
||||
@@ -30,6 +34,7 @@ Feature: Config Hot Reloading
|
||||
Then the JWT TTL should be updated without restart
|
||||
And new JWT tokens should have the updated expiration
|
||||
|
||||
@flaky
|
||||
Scenario: Attempting to hot reload non-reloadable settings should be ignored
|
||||
Given the server is running with config file monitoring enabled
|
||||
When I update the server port to 9090 in the config file
|
||||
@@ -37,6 +42,7 @@ Feature: Config Hot Reloading
|
||||
And the server should continue running on the original port
|
||||
And a warning should be logged about ignored configuration change
|
||||
|
||||
@flaky
|
||||
Scenario: Invalid configuration changes should be handled gracefully
|
||||
Given the server is running with config file monitoring enabled
|
||||
When I update the logging level to "invalid_level" in the config file
|
||||
@@ -44,12 +50,14 @@ Feature: Config Hot Reloading
|
||||
And an error should be logged about invalid configuration
|
||||
And the server should continue running normally
|
||||
|
||||
@flaky
|
||||
Scenario: Config file monitoring should handle file deletion gracefully
|
||||
Given the server is running with config file monitoring enabled
|
||||
When I delete the config file
|
||||
Then the server should continue running with last known good configuration
|
||||
And a warning should be logged about missing config file
|
||||
|
||||
@flaky
|
||||
Scenario: Config file monitoring should handle file recreation
|
||||
Given the server is running with config file monitoring enabled
|
||||
And I have deleted the config file
|
||||
@@ -57,6 +65,7 @@ Feature: Config Hot Reloading
|
||||
Then the server should reload the configuration
|
||||
And the new configuration should be applied
|
||||
|
||||
@flaky
|
||||
Scenario: Multiple rapid configuration changes should be handled
|
||||
Given the server is running with config file monitoring enabled
|
||||
When I rapidly update the logging level multiple times
|
||||
@@ -64,6 +73,7 @@ Feature: Config Hot Reloading
|
||||
And the final configuration should be applied
|
||||
And no configuration changes should be lost
|
||||
|
||||
@flaky
|
||||
Scenario: Configuration changes should be audited
|
||||
Given the server is running with config file monitoring enabled
|
||||
And audit logging is enabled
|
||||
16
features/config/config_test.go
Normal file
16
features/config/config_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"dance-lessons-coach/pkg/bdd/testsetup"
|
||||
)
|
||||
|
||||
func TestConfigBDD(t *testing.T) {
|
||||
config := testsetup.NewFeatureConfig("config", "progress", false)
|
||||
suite := testsetup.CreateTestSuite(t, config, "dance-lessons-coach BDD Tests - Config Feature")
|
||||
|
||||
if suite.Run() != 0 {
|
||||
t.Fatal("non-zero status returned, failed to run config BDD tests")
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
# features/greet.feature
|
||||
Feature: Greet Service
|
||||
The greet service should return appropriate greetings
|
||||
|
||||
Scenario: Default greeting
|
||||
Given the server is running
|
||||
When I request the default greeting
|
||||
Then the response should be "{\"message\":\"Hello world!\"}"
|
||||
|
||||
Scenario: Personalized greeting
|
||||
Given the server is running
|
||||
When I request a greeting for "John"
|
||||
Then the response should be "{\"message\":\"Hello John!\"}"
|
||||
|
||||
Scenario: v2 greeting with JSON POST request
|
||||
Given the server is running with v2 enabled
|
||||
When I send a POST request to v2 greet with name "John"
|
||||
Then the response should be "{\"message\":\"Hello my friend John!\"}"
|
||||
|
||||
Scenario: v2 default greeting with empty name
|
||||
Given the server is running with v2 enabled
|
||||
When I send a POST request to v2 greet with name ""
|
||||
Then the response should be "{\"message\":\"Hello my friend!\"}"
|
||||
|
||||
Scenario: v2 greeting with missing name field
|
||||
Given the server is running with v2 enabled
|
||||
When I send a POST request to v2 greet with invalid JSON "{}"
|
||||
Then the response should be "{\"message\":\"Hello my friend!\"}"
|
||||
|
||||
Scenario: v2 greeting with name that is too long
|
||||
Given the server is running with v2 enabled
|
||||
When I send a POST request to v2 greet with name "ThisNameIsWayTooLongAndShouldFailValidationBecauseItExceedsTheMaximumAllowedLengthOf100Characters!!!!"
|
||||
Then the response should contain error "validation_failed"
|
||||
65
features/greet/greet.feature
Normal file
65
features/greet/greet.feature
Normal file
@@ -0,0 +1,65 @@
|
||||
# features/greet.feature
|
||||
@greet @smoke
|
||||
Feature: Greet Service
|
||||
The greet service should return appropriate greetings
|
||||
|
||||
@basic
|
||||
Scenario: Default greeting
|
||||
Given the server is running
|
||||
When I request the default greeting
|
||||
Then the response should be "{\"message\":\"Hello world!\"}"
|
||||
|
||||
@basic
|
||||
Scenario: Personalized greeting
|
||||
Given the server is running
|
||||
When I request a greeting for "John"
|
||||
Then the response should be "{\"message\":\"Hello John!\"}"
|
||||
|
||||
@critical @v2-gate
|
||||
Scenario: v2 endpoint returns 404 when api.v2_enabled is disabled
|
||||
# In the default tag-filter run (~@v2), the test server starts with
|
||||
# v2_enabled=false. The v2EnabledGate middleware (ADR-0023 Phase 4)
|
||||
# returns 404 with a JSON body explaining the flag state.
|
||||
Given the server is running
|
||||
When I send a POST request to v2 greet with name "John"
|
||||
Then the status code should be 404
|
||||
And the response should contain "v2 API is currently disabled"
|
||||
|
||||
@v2 @api
|
||||
Scenario: v2 greeting with JSON POST request
|
||||
Given the server is running with v2 enabled
|
||||
When I send a POST request to v2 greet with name "John"
|
||||
Then the response should be "{\"message\":\"Hello my friend John!\"}"
|
||||
|
||||
@v2 @api
|
||||
Scenario: v2 default greeting with empty name
|
||||
Given the server is running with v2 enabled
|
||||
When I send a POST request to v2 greet with name ""
|
||||
Then the response should be "{\"message\":\"Hello my friend!\"}"
|
||||
|
||||
@v2 @api
|
||||
Scenario: v2 greeting with missing name field
|
||||
Given the server is running with v2 enabled
|
||||
When I send a POST request to v2 greet with invalid JSON "{}"
|
||||
Then the response should be "{\"message\":\"Hello my friend!\"}"
|
||||
|
||||
@v2 @api
|
||||
Scenario: v2 greeting with name that is too long
|
||||
Given the server is running with v2 enabled
|
||||
When I send a POST request to v2 greet with name "ThisNameIsWayTooLongAndShouldFailValidationBecauseItExceedsTheMaximumAllowedLengthOf100Characters!!!!"
|
||||
Then the response should contain error "validation_failed"
|
||||
|
||||
@ratelimit @skip @bdd-deferred
|
||||
# NOTE: Functional behavior validated by unit tests in pkg/middleware/ratelimit_test.go.
|
||||
# BDD scenario currently skipped: env-var-based rate limit config does not reach the
|
||||
# already-started test server (architectural limitation of testsetup, not the middleware).
|
||||
# TODO: rework testserver to allow per-scenario rate limit config (admin endpoint or
|
||||
# per-scenario fresh server), then re-enable this scenario.
|
||||
Scenario: Greet endpoint rejects requests over the rate limit
|
||||
Given the server is running with rate limit set to 3 requests per minute and burst 3
|
||||
When I make 3 requests to "/api/v1/greet/Alice"
|
||||
Then all responses should have status 200
|
||||
When I make 1 more request to "/api/v1/greet/Alice"
|
||||
Then the response should have status 429
|
||||
And the response body should contain "rate_limited"
|
||||
And the response should have header "Retry-After"
|
||||
30
features/greet/greet_test.go
Normal file
30
features/greet/greet_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package greet
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"dance-lessons-coach/pkg/bdd/testsetup"
|
||||
)
|
||||
|
||||
func TestGreetBDD(t *testing.T) {
|
||||
// Test suite with v2 disabled - run non-v2 scenarios only
|
||||
t.Run("v1", func(t *testing.T) {
|
||||
os.Setenv("GODOG_TAGS", "~@v2 && ~@skip")
|
||||
config := testsetup.NewFeatureConfig("greet", "progress", false)
|
||||
suite := testsetup.CreateTestSuite(t, config, "dance-lessons-coach BDD Tests - Greet Feature v1")
|
||||
if suite.Run() != 0 {
|
||||
t.Fatal("non-zero status returned, failed to run greet BDD tests with v2 disabled")
|
||||
}
|
||||
})
|
||||
|
||||
// Test suite with v2 enabled - run v2 scenarios only
|
||||
t.Run("v2", func(t *testing.T) {
|
||||
os.Setenv("GODOG_TAGS", "@v2 && ~@skip")
|
||||
config := testsetup.NewFeatureConfig("greet", "progress", false)
|
||||
suite := testsetup.CreateTestSuite(t, config, "dance-lessons-coach BDD Tests - Greet Feature v2")
|
||||
if suite.Run() != 0 {
|
||||
t.Fatal("non-zero status returned, failed to run greet BDD tests with v2 enabled")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
# features/health.feature
|
||||
Feature: Health Endpoint
|
||||
The health endpoint should indicate server status
|
||||
|
||||
Scenario: Health check returns healthy status
|
||||
Given the server is running
|
||||
When I request the health endpoint
|
||||
Then the response should be "{\"status\":\"healthy\"}"
|
||||
18
features/health/health.feature
Normal file
18
features/health/health.feature
Normal file
@@ -0,0 +1,18 @@
|
||||
# features/health.feature
|
||||
@health @smoke @critical
|
||||
Feature: Health Endpoint
|
||||
The health endpoint should indicate server status
|
||||
|
||||
@basic @critical
|
||||
Scenario: Health check returns healthy status
|
||||
Given the server is running
|
||||
When I request the health endpoint
|
||||
Then the response should be "{\"status\":\"healthy\"}"
|
||||
|
||||
@basic @critical
|
||||
Scenario: Healthz endpoint returns rich health info
|
||||
Given the server is running
|
||||
When I request the healthz endpoint
|
||||
Then the status code should be 200
|
||||
And the response should be JSON with fields "status, version, uptime_seconds, timestamp"
|
||||
And the "status" field should equal "healthy"
|
||||
16
features/health/health_test.go
Normal file
16
features/health/health_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"dance-lessons-coach/pkg/bdd/testsetup"
|
||||
)
|
||||
|
||||
func TestHealthBDD(t *testing.T) {
|
||||
config := testsetup.NewFeatureConfig("health", "progress", false)
|
||||
suite := testsetup.CreateTestSuite(t, config, "dance-lessons-coach BDD Tests - Health Feature")
|
||||
|
||||
if suite.Run() != 0 {
|
||||
t.Fatal("non-zero status returned, failed to run health BDD tests")
|
||||
}
|
||||
}
|
||||
45
features/info/info.feature
Normal file
45
features/info/info.feature
Normal file
@@ -0,0 +1,45 @@
|
||||
# features/info/info.feature
|
||||
@info @critical
|
||||
Feature: Info Endpoint
|
||||
The /api/info endpoint should return composite application information
|
||||
|
||||
@basic @critical
|
||||
Scenario: GET /api/info returns all required fields
|
||||
Given the server is running
|
||||
When I request the info endpoint
|
||||
Then the status code should be 200
|
||||
And the response should be JSON
|
||||
And the response should contain "version"
|
||||
And the response should contain "commit_short"
|
||||
And the response should contain "build_date"
|
||||
And the response should contain "uptime_seconds"
|
||||
And the response should contain "cache_enabled"
|
||||
And the response should contain "healthz_status"
|
||||
And the "healthz_status" field should equal "healthy"
|
||||
|
||||
@version @critical
|
||||
Scenario: version field matches semantic version pattern
|
||||
Given the server is running
|
||||
When I request the info endpoint
|
||||
Then the status code should be 200
|
||||
And the "version" field should match /^\d+\.\d+\.\d+$/
|
||||
|
||||
@cache @skip @bdd-deferred
|
||||
Scenario: /api/info is cached when cache is enabled
|
||||
# Deferred: the BDD testsetup currently runs with cache disabled
|
||||
# (see "Cache service disabled" in test logs). Cache HIT/MISS behavior
|
||||
# is covered by unit tests on the cache service. Reopen this scenario
|
||||
# if/when the BDD harness gains a cache-enabled mode (likely after
|
||||
# ADR-0022 Phase 2).
|
||||
Given the server is running with cache enabled
|
||||
When I request the info endpoint
|
||||
Then the response header "X-Cache" should be "MISS"
|
||||
When I request the info endpoint again
|
||||
Then the response header "X-Cache" should be "HIT"
|
||||
|
||||
@go_version @critical
|
||||
Scenario: go_version field is non-empty
|
||||
Given the server is running
|
||||
When I request the info endpoint
|
||||
Then the status code should be 200
|
||||
And the response should contain "go_version"
|
||||
16
features/info/info_test.go
Normal file
16
features/info/info_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package info
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"dance-lessons-coach/pkg/bdd/testsetup"
|
||||
)
|
||||
|
||||
func TestInfoBDD(t *testing.T) {
|
||||
config := testsetup.NewFeatureConfig("info", "progress", false)
|
||||
suite := testsetup.CreateTestSuite(t, config, "dance-lessons-coach BDD Tests - Info Feature")
|
||||
|
||||
if suite.Run() != 0 {
|
||||
t.Fatal("non-zero status returned, failed to run info BDD tests")
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,17 @@ Feature: JWT Secret Retention Policy
|
||||
Then the primary secret should not be removed
|
||||
And the primary secret should remain active
|
||||
|
||||
@critical @admin-introspection
|
||||
Scenario: Admin metadata endpoint exposes structure without leaking secret values
|
||||
Given a primary JWT secret exists
|
||||
And I add a secondary JWT secret "test-secret-do-not-leak-please-12345"
|
||||
When I request the JWT secrets metadata endpoint
|
||||
Then the status code should be 200
|
||||
And the metadata should contain 2 secrets
|
||||
And the metadata should NOT contain the secret value "test-secret-do-not-leak-please-12345"
|
||||
And every secret in the metadata should have a SHA-256 fingerprint
|
||||
|
||||
@todo
|
||||
Scenario: Multiple secrets with different ages
|
||||
Given I have 3 JWT secrets of different ages
|
||||
And secret A is 1 hour old (within retention)
|
||||
@@ -50,12 +61,14 @@ Feature: JWT Secret Retention Policy
|
||||
And secret B should be removed
|
||||
And secret C should be retained as primary
|
||||
|
||||
@todo
|
||||
Scenario: Cleanup frequency configuration
|
||||
Given the cleanup interval is set to 30 minutes
|
||||
When I add an expired JWT secret
|
||||
Then it should be removed within 30 minutes
|
||||
And I should see cleanup events every 30 minutes
|
||||
|
||||
@todo
|
||||
Scenario: Token validation with expired secret
|
||||
Given a user "retentionuser" exists with password "testpass123"
|
||||
And I authenticate with username "retentionuser" and password "testpass123"
|
||||
@@ -65,6 +78,7 @@ Feature: JWT Secret Retention Policy
|
||||
Then the token validation should fail
|
||||
And I should receive "invalid_token" error
|
||||
|
||||
@todo
|
||||
Scenario: Graceful rotation during retention period
|
||||
Given a user "gracefuluser" exists with password "testpass123"
|
||||
And I authenticate with username "gracefuluser" and password "testpass123"
|
||||
@@ -81,6 +95,7 @@ Feature: JWT Secret Retention Policy
|
||||
Then I should receive configuration validation error
|
||||
And the error should mention "retention_factor must be ≥ 1.0"
|
||||
|
||||
@todo @nice_to_have
|
||||
Scenario: Metrics for secret retention
|
||||
Given I have enabled Prometheus metrics
|
||||
When the cleanup job removes expired secrets
|
||||
@@ -88,12 +103,14 @@ Feature: JWT Secret Retention Policy
|
||||
And I should see "jwt_secrets_active_count" metric decrease
|
||||
And I should see "jwt_secret_retention_duration_seconds" histogram update
|
||||
|
||||
@todo @nice_to_have
|
||||
Scenario: Log masking for security
|
||||
Given I add a new JWT secret "super-secret-key-123456"
|
||||
When the cleanup job runs
|
||||
Then the logs should show masked secret "supe****123456"
|
||||
And not expose the full secret in logs
|
||||
|
||||
@todo
|
||||
Scenario: Cleanup with high volume of secrets
|
||||
Given I have 1000 JWT secrets
|
||||
And 300 of them are expired
|
||||
@@ -102,12 +119,14 @@ Feature: JWT Secret Retention Policy
|
||||
And remove all 300 expired secrets
|
||||
And not impact server performance
|
||||
|
||||
@todo
|
||||
Scenario: Disabled cleanup via configuration
|
||||
Given I set cleanup interval to 8760 hours
|
||||
When I add expired JWT secrets
|
||||
Then they should not be automatically removed
|
||||
And manual cleanup should still be possible
|
||||
|
||||
@todo
|
||||
Scenario: Retention period calculation edge cases
|
||||
Given the JWT TTL is 1 hour
|
||||
And the retention factor is 1.0
|
||||
@@ -115,12 +134,14 @@ Feature: JWT Secret Retention Policy
|
||||
Then the retention period should be 1 hour
|
||||
And the secret should expire after 1 hour
|
||||
|
||||
@todo
|
||||
Scenario: Secret validation with retention policy
|
||||
Given I try to add an invalid JWT secret
|
||||
When the secret is less than 16 characters
|
||||
Then I should receive validation error
|
||||
And the error should mention "must be at least 16 characters"
|
||||
|
||||
@todo
|
||||
Scenario: Cleanup job error handling
|
||||
Given the cleanup job encounters an error
|
||||
When it tries to remove a secret
|
||||
@@ -128,6 +149,7 @@ Feature: JWT Secret Retention Policy
|
||||
And continue with remaining secrets
|
||||
And not crash the cleanup process
|
||||
|
||||
@todo
|
||||
Scenario: Configuration reload without restart
|
||||
Given the server is running with default retention settings
|
||||
When I update the retention factor via configuration
|
||||
@@ -135,6 +157,7 @@ Feature: JWT Secret Retention Policy
|
||||
And existing secrets should be reevaluated
|
||||
And cleanup should use new retention periods
|
||||
|
||||
@todo @nice_to_have
|
||||
Scenario: Audit trail for secret operations
|
||||
Given I enable audit logging
|
||||
When I add a new JWT secret
|
||||
@@ -142,6 +165,7 @@ Feature: JWT Secret Retention Policy
|
||||
And when the secret is removed by cleanup
|
||||
Then I should see audit log entry with event type "secret_removed"
|
||||
|
||||
@todo
|
||||
Scenario: Retention policy with token refresh
|
||||
Given a user "refreshuser" exists with password "testpass123"
|
||||
And I authenticate and receive token A
|
||||
@@ -150,13 +174,15 @@ Feature: JWT Secret Retention Policy
|
||||
And token A should still be valid until retention expires
|
||||
And both tokens should work concurrently
|
||||
|
||||
@todo
|
||||
Scenario: Emergency secret rotation
|
||||
Given a security incident requires immediate rotation
|
||||
When I rotate to a new primary secret
|
||||
Then old tokens should be invalidated immediately
|
||||
And new tokens should use the emergency secret
|
||||
And cleanup should remove compromised secrets
|
||||
|
||||
|
||||
@todo @nice_to_have
|
||||
Scenario: Monitoring and alerting
|
||||
Given I have monitoring configured
|
||||
When the cleanup job fails repeatedly
|
||||
16
features/jwt/jwt_test.go
Normal file
16
features/jwt/jwt_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"dance-lessons-coach/pkg/bdd/testsetup"
|
||||
)
|
||||
|
||||
func TestJWTBDD(t *testing.T) {
|
||||
config := testsetup.NewFeatureConfig("jwt", "pretty", false)
|
||||
suite := testsetup.CreateTestSuite(t, config, "dance-lessons-coach BDD Tests - JWT Feature")
|
||||
|
||||
if suite.Run() != 0 {
|
||||
t.Fatal("non-zero status returned, failed to run jwt BDD tests")
|
||||
}
|
||||
}
|
||||
15
frontend/.storybook/main.ts
Normal file
15
frontend/.storybook/main.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { StorybookConfig } from '@storybook/vue3-vite'
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../components/**/*.stories.@(js|ts|mdx)'],
|
||||
addons: ['@storybook/addon-essentials'],
|
||||
framework: {
|
||||
name: '@storybook/vue3-vite',
|
||||
options: {},
|
||||
},
|
||||
docs: {
|
||||
autodocs: 'tag',
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
15
frontend/.storybook/preview.ts
Normal file
15
frontend/.storybook/preview.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Preview } from '@storybook/vue3'
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default preview
|
||||
5
frontend/app.vue
Normal file
5
frontend/app.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
13
frontend/components/AppFooter.vue
Normal file
13
frontend/components/AppFooter.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import AppFooterView, { type AppInfo } from './AppFooterView.vue'
|
||||
|
||||
// Wrapper: handles data fetching, delegates rendering to AppFooterView.
|
||||
// Separation of concerns (SRP) - same pattern as HealthDashboard / HealthDashboardView.
|
||||
// server: false → fetch client-side only. Avoids SSR fetching through the dev proxy
|
||||
// (which can fail in some local setups), and lets Playwright route mocks apply.
|
||||
const { data, pending, error } = useFetch<AppInfo>('/api/info', { server: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppFooterView :data="data" :pending="pending" :error="error" />
|
||||
</template>
|
||||
45
frontend/components/AppFooterView.vue
Normal file
45
frontend/components/AppFooterView.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { humaniseUptime } from '~/utils/uptime'
|
||||
|
||||
export interface AppInfo {
|
||||
version: string
|
||||
commit_short: string
|
||||
build_date: string
|
||||
uptime_seconds: number
|
||||
cache_enabled: boolean
|
||||
healthz_status: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
data: AppInfo | null | undefined
|
||||
pending: boolean
|
||||
error: { message: string } | null | undefined
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer data-testid="app-footer">
|
||||
<p v-if="pending" data-testid="app-footer-pending">v?</p>
|
||||
<p v-else-if="error" data-testid="app-footer-error">v? · info unavailable</p>
|
||||
<p v-else-if="data" data-testid="app-footer-info">
|
||||
<span data-testid="app-footer-version">v{{ data.version }}</span>
|
||||
<span> · commit </span>
|
||||
<span data-testid="app-footer-commit">{{ data.commit_short }}</span>
|
||||
<span> · uptime </span>
|
||||
<span data-testid="app-footer-uptime">{{ humaniseUptime(data.uptime_seconds) }}</span>
|
||||
</p>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
footer {
|
||||
border-top: 1px solid #ccc;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
}
|
||||
footer p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
26
frontend/components/HealthDashboard.stories.ts
Normal file
26
frontend/components/HealthDashboard.stories.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import HealthDashboard from './HealthDashboard.vue'
|
||||
|
||||
const meta: Meta<typeof HealthDashboard> = {
|
||||
title: 'Components/HealthDashboard',
|
||||
component: HealthDashboard,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Smart wrapper that calls /api/healthz internally and delegates rendering to HealthDashboardView. ' +
|
||||
'For state-by-state previews (Healthy, Loading, Error), see ' +
|
||||
'[HealthDashboardView stories](?path=/docs/components-healthdashboardview--docs).',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Default story - calls real /api/healthz (works in browser if dev proxy + backend are up)
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
}
|
||||
17
frontend/components/HealthDashboard.vue
Normal file
17
frontend/components/HealthDashboard.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import HealthDashboardView, { type HealthInfo } from './HealthDashboardView.vue'
|
||||
|
||||
// Wrapper: handles data fetching, delegates rendering to HealthDashboardView.
|
||||
// Separation of concerns (SRP):
|
||||
// - HealthDashboard (this) = data layer (useFetch lifecycle)
|
||||
// - HealthDashboardView = presentation layer (testable in Storybook + e2e)
|
||||
//
|
||||
// server: false → fetch client-side only. Avoids SSR fetching through the dev
|
||||
// proxy (which can fail in some local setups), and lets Playwright route mocks
|
||||
// apply. Same fix that landed for AppFooter in PR #40.
|
||||
const { data, pending, error } = useFetch<HealthInfo>('/api/healthz', { server: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HealthDashboardView :data="data" :pending="pending" :error="error" />
|
||||
</template>
|
||||
79
frontend/components/HealthDashboardView.stories.ts
Normal file
79
frontend/components/HealthDashboardView.stories.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import HealthDashboardView from './HealthDashboardView.vue'
|
||||
|
||||
interface ViewArgs {
|
||||
data: {
|
||||
status: string
|
||||
version: string
|
||||
uptime_seconds: number
|
||||
timestamp: string
|
||||
} | null
|
||||
pending: boolean
|
||||
error: { message: string } | null
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Components/HealthDashboardView',
|
||||
component: HealthDashboardView,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
pending: { control: 'boolean' },
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Pure presentational component for the health dashboard. ' +
|
||||
'Accepts `data`, `pending`, `error` as props so all 3 states can be ' +
|
||||
'previewed in Storybook and asserted in unit tests. The data fetching ' +
|
||||
'wrapper is `HealthDashboard.vue`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<ViewArgs>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Healthy: Story = {
|
||||
args: {
|
||||
data: {
|
||||
status: 'healthy',
|
||||
version: '1.4.0',
|
||||
uptime_seconds: 3600,
|
||||
timestamp: '2026-05-03T17:30:00.000Z',
|
||||
},
|
||||
pending: false,
|
||||
error: null,
|
||||
},
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
data: null,
|
||||
pending: true,
|
||||
error: null,
|
||||
},
|
||||
}
|
||||
|
||||
export const ErrorState: Story = {
|
||||
args: {
|
||||
data: null,
|
||||
pending: false,
|
||||
error: { message: '[GET] "/api/healthz": 502 Bad Gateway (simulated)' },
|
||||
},
|
||||
}
|
||||
|
||||
export const HealthyHighUptime: Story = {
|
||||
args: {
|
||||
data: {
|
||||
status: 'healthy',
|
||||
version: '1.5.0-rc1',
|
||||
uptime_seconds: 86400 * 7,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
pending: false,
|
||||
error: null,
|
||||
},
|
||||
}
|
||||
30
frontend/components/HealthDashboardView.vue
Normal file
30
frontend/components/HealthDashboardView.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
export interface HealthInfo {
|
||||
status: string
|
||||
version: string
|
||||
uptime_seconds: number
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
data: HealthInfo | null | undefined
|
||||
pending: boolean
|
||||
error: { message: string } | null | undefined
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section data-testid="health-dashboard">
|
||||
<h2>Server Health</h2>
|
||||
<p v-if="pending" data-testid="health-loading">Loading...</p>
|
||||
<p v-else-if="error" data-testid="health-error">
|
||||
Error loading health: {{ error.message }}
|
||||
</p>
|
||||
<ul v-else-if="data" data-testid="health-info">
|
||||
<li><strong>Status:</strong> <span data-testid="health-status">{{ data.status }}</span></li>
|
||||
<li><strong>Version:</strong> {{ data.version }}</li>
|
||||
<li><strong>Uptime:</strong> {{ data.uptime_seconds }} seconds</li>
|
||||
<li><strong>Last check:</strong> {{ data.timestamp }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
4
frontend/docs/README.md
Normal file
4
frontend/docs/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# Frontend Docs
|
||||
|
||||
- [E2E Test Reports](./e2e/README.md) - auto-generated by `npm run docs:gen`
|
||||
- Storybook (run locally: `npm run storybook` ; build: `npm run build-storybook` then open `storybook-static/index.html`)
|
||||
7
frontend/docs/e2e/README.md
Normal file
7
frontend/docs/e2e/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# E2E Test Reports
|
||||
|
||||
[<- Up to docs](../README.md)
|
||||
|
||||
| Test | Status | Duration |
|
||||
|------|--------|----------|
|
||||
| [home page loads and shows server health info](./home-page-loads-and-shows-server-health-info.md) | PASSED | 168ms |
|
||||
@@ -0,0 +1,16 @@
|
||||
# home page loads and shows server health info
|
||||
|
||||
[<- Back to index](./README.md) | [Top](../README.md)
|
||||
|
||||
**File**: `tests/e2e/health.spec.ts`
|
||||
**Status**: PASSED
|
||||
**Duration**: 168ms
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||
|
||||
## Test Details
|
||||
|
||||
- Start Time: 2026-05-03T14:38:42.958Z
|
||||
- Spec File: health.spec.ts
|
||||
17
frontend/layouts/default.vue
Normal file
17
frontend/layouts/default.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="layout-root">
|
||||
<slot />
|
||||
<AppFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout-root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.layout-root > :first-child {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
11
frontend/nuxt.config.ts
Normal file
11
frontend/nuxt.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export default defineNuxtConfig({
|
||||
devtools: { enabled: true },
|
||||
nitro: {
|
||||
devProxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
13525
frontend/package-lock.json
generated
Normal file
13525
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "dance-lessons-coach-frontend",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"docs:gen": "playwright test && node scripts/generate-test-docs.mjs",
|
||||
"docs:full": "npm run build-storybook && npm run docs:gen"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@storybook/addon-essentials": "^8.0.0",
|
||||
"@storybook/vue3": "^8.0.0",
|
||||
"@storybook/vue3-vite": "^8.0.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"nuxt": "^3.13.0",
|
||||
"storybook": "^8.0.0",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"packageManager": "npm@11.5.2"
|
||||
}
|
||||
6
frontend/pages/index.vue
Normal file
6
frontend/pages/index.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<main>
|
||||
<h1>dance-lessons-coach</h1>
|
||||
<HealthDashboard />
|
||||
</main>
|
||||
</template>
|
||||
23
frontend/playwright.config.ts
Normal file
23
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
timeout: 30_000,
|
||||
reporter: [
|
||||
['list'],
|
||||
['json', { outputFile: path.join(process.cwd(), 'test-results', 'results.json') }],
|
||||
],
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
screenshot: 'on',
|
||||
video: 'off',
|
||||
},
|
||||
outputDir: 'test-results/output',
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
timeout: 60_000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
||||
120
frontend/scripts/generate-test-docs.mjs
Normal file
120
frontend/scripts/generate-test-docs.mjs
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const frontendDir = path.resolve(__dirname, '..')
|
||||
|
||||
const resultsPath = path.join(frontendDir, 'test-results', 'results.json')
|
||||
const docsDir = path.join(frontendDir, 'docs', 'e2e')
|
||||
const screenshotsDir = path.join(frontendDir, 'tests', 'e2e', 'screenshots')
|
||||
|
||||
async function main() {
|
||||
// Read results
|
||||
const resultsText = await fs.readFile(resultsPath, 'utf8')
|
||||
const results = JSON.parse(resultsText)
|
||||
|
||||
// Create output directories
|
||||
await fs.mkdir(docsDir, { recursive: true })
|
||||
|
||||
// Extract tests from suites
|
||||
const testDocs = []
|
||||
for (const suite of results.suites || []) {
|
||||
for (const spec of suite.specs || []) {
|
||||
for (const test of spec.tests || []) {
|
||||
for (const result of test.results || []) {
|
||||
const testInfo = {
|
||||
title: spec.title,
|
||||
specFile: spec.file || suite.file,
|
||||
status: result.status,
|
||||
duration: result.duration,
|
||||
startTime: result.startTime,
|
||||
attachments: result.attachments || [],
|
||||
}
|
||||
testDocs.push(testInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate individual test markdown files
|
||||
for (const test of testDocs) {
|
||||
const slug = slugify(test.title)
|
||||
const mdPath = path.join(docsDir, `${slug}.md`)
|
||||
|
||||
// Use slug-based screenshot name (matches explicit screenshot in test)
|
||||
let screenshotPath = `${slug}.png`
|
||||
|
||||
// Also try to find screenshot attachment and use its basename
|
||||
if (test.attachments && test.attachments.length > 0) {
|
||||
for (const attachment of test.attachments) {
|
||||
if (attachment.contentType === 'image/png') {
|
||||
const basename = path.basename(attachment.path)
|
||||
// Prefer explicit screenshot name if it matches our pattern
|
||||
if (basename !== 'test-finished-1.png' && basename !== 'test-finished-2.png') {
|
||||
screenshotPath = basename
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const absoluteScreenshotPath = path.join(screenshotsDir, screenshotPath)
|
||||
const relativeScreenshotPath = path.relative(docsDir, absoluteScreenshotPath)
|
||||
|
||||
const mdContent = `# ${test.title}
|
||||
|
||||
[<- Back to index](./README.md) | [Top](../README.md)
|
||||
|
||||
**File**: \`tests/e2e/${test.specFile}\`
|
||||
**Status**: ${test.status.toUpperCase()}
|
||||
**Duration**: ${test.duration}ms
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||
|
||||
## Test Details
|
||||
|
||||
- Start Time: ${test.startTime || 'N/A'}
|
||||
- Spec File: ${test.specFile}
|
||||
`
|
||||
|
||||
await fs.writeFile(mdPath, mdContent)
|
||||
console.log(`Generated: ${path.relative(frontendDir, mdPath)}`)
|
||||
}
|
||||
|
||||
// Generate index README
|
||||
const indexContent = `# E2E Test Reports
|
||||
|
||||
[<- Up to docs](../README.md)
|
||||
|
||||
| Test | Status | Duration |
|
||||
|------|--------|----------|
|
||||
${testDocs.map(t => `| [${escapeMd(t.title)}](./${slugify(t.title)}.md) | ${t.status.toUpperCase()} | ${t.duration}ms |`).join('\n')}
|
||||
`
|
||||
|
||||
await fs.writeFile(path.join(docsDir, 'README.md'), indexContent)
|
||||
console.log(`Generated: ${path.relative(frontendDir, path.join(docsDir, 'README.md'))}`)
|
||||
|
||||
console.log(`\nGenerated ${testDocs.length} test docs`)
|
||||
}
|
||||
|
||||
function slugify(str) {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
function escapeMd(str) {
|
||||
return str.replace(/[|\\\[\]\{\}]/g, '\\$&')
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Error:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
6
frontend/shims-vue.d.ts
vendored
Normal file
6
frontend/shims-vue.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const component: DefineComponent<any, any, any>
|
||||
export default component
|
||||
}
|
||||
67
frontend/tests/e2e/app-footer.spec.ts
Normal file
67
frontend/tests/e2e/app-footer.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
// Both specs mock /api/info so they decouple from the dev-proxy plumbing.
|
||||
// The integration with the real backend is covered by the BDD scenario in
|
||||
// features/info/info.feature (server-side, no frontend proxy in the loop).
|
||||
|
||||
test('home page footer shows version, commit and uptime', async ({ page }) => {
|
||||
await page.route('**/api/info', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
version: '1.4.0',
|
||||
commit_short: '4a3f1bb',
|
||||
build_date: '2026-05-05T00:00:00Z',
|
||||
uptime_seconds: 8042,
|
||||
cache_enabled: true,
|
||||
healthz_status: 'healthy',
|
||||
}),
|
||||
})
|
||||
})
|
||||
await page.goto('/')
|
||||
|
||||
// Footer is mounted globally via layouts/default.vue
|
||||
await expect(page.getByTestId('app-footer')).toBeVisible()
|
||||
|
||||
// The PR #32 lesson: assert content, not just visibility.
|
||||
// Without the regex check the test would PASS even if the footer rendered the
|
||||
// pending placeholder ("v?") indefinitely.
|
||||
await expect(page.getByTestId('app-footer-info')).toBeVisible()
|
||||
const versionLocator = page.getByTestId('app-footer-version')
|
||||
await expect(versionLocator).toBeVisible()
|
||||
await expect(versionLocator).toHaveText(/^v\d+\.\d+\.\d+$/)
|
||||
|
||||
// Commit and uptime should be present and non-empty.
|
||||
await expect(page.getByTestId('app-footer-commit')).not.toBeEmpty()
|
||||
await expect(page.getByTestId('app-footer-uptime')).not.toBeEmpty()
|
||||
|
||||
await page.screenshot({
|
||||
path: 'tests/e2e/screenshots/app-footer-shows-version-commit-uptime.png',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
// Regression spec: documents the expected error UX so we don't ship a silent failure.
|
||||
// Routes /api/info to a 502 mock so the test is reproducible regardless of backend.
|
||||
test('home page footer surfaces info endpoint errors gracefully', async ({ page }) => {
|
||||
await page.route('**/api/info', (route) => {
|
||||
route.fulfill({
|
||||
status: 502,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'simulated_backend_down' }),
|
||||
})
|
||||
})
|
||||
await page.goto('/')
|
||||
|
||||
// Footer must NOT crash the page
|
||||
await expect(page.getByTestId('app-footer')).toBeVisible()
|
||||
await expect(page.getByTestId('app-footer-error')).toBeVisible()
|
||||
// The error placeholder should NOT contain a real version pattern
|
||||
await expect(page.getByTestId('app-footer-info')).not.toBeVisible()
|
||||
|
||||
await page.screenshot({
|
||||
path: 'tests/e2e/screenshots/app-footer-surfaces-info-endpoint-errors-gracefully.png',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
55
frontend/tests/e2e/health.spec.ts
Normal file
55
frontend/tests/e2e/health.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
// Both specs mock /api/healthz so they decouple from the dev-proxy plumbing.
|
||||
// The integration with the real backend is covered by the BDD scenario in
|
||||
// features/health/health.feature (server-side, no frontend proxy in the loop).
|
||||
// Same approach as tests/e2e/app-footer.spec.ts (PR #40) - applied here to
|
||||
// close the debt left by that PR's out-of-scope follow-up note.
|
||||
|
||||
test('home page loads and shows healthy server state', async ({ page }) => {
|
||||
await page.route('**/api/healthz', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
status: 'healthy',
|
||||
version: '1.4.0',
|
||||
uptime_seconds: 8042,
|
||||
timestamp: '2026-05-05T08:00:00Z',
|
||||
}),
|
||||
})
|
||||
})
|
||||
await page.goto('/')
|
||||
await expect(page.getByTestId('health-dashboard')).toBeVisible()
|
||||
const heading = page.getByRole('heading', { name: /dance-lessons-coach/i })
|
||||
await expect(heading).toBeVisible()
|
||||
|
||||
// Assert the dashboard is in HEALTHY state, not an error state.
|
||||
// The dashboard renders an "Error loading health: ..." paragraph when /api/healthz
|
||||
// is unreachable (Go backend not running, proxy misconfigured, endpoint removed,
|
||||
// etc.). Without these assertions the test would falsely PASS even when the
|
||||
// dashboard shows the error UI - regression observed 2026-05-03 (Go backend
|
||||
// not running locally → page renders the error, Playwright PASSES).
|
||||
await expect(page.getByTestId('health-info')).toBeVisible()
|
||||
await expect(page.getByTestId('health-status')).toHaveText('healthy')
|
||||
await expect(page.getByText(/Error loading health/i)).not.toBeVisible()
|
||||
|
||||
await page.screenshot({ path: 'tests/e2e/screenshots/home-page-loads-and-shows-server-health-info.png', fullPage: true })
|
||||
})
|
||||
|
||||
// Regression spec: documents the expected error UX so we don't ship a silent failure.
|
||||
// Routes /api/healthz to a 502 mock so the test is reproducible regardless of backend.
|
||||
test('home page surfaces health endpoint errors visibly', async ({ page }) => {
|
||||
await page.route('**/api/healthz', (route) => {
|
||||
route.fulfill({
|
||||
status: 502,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'simulated_backend_down' }),
|
||||
})
|
||||
})
|
||||
await page.goto('/')
|
||||
await expect(page.getByTestId('health-dashboard')).toBeVisible()
|
||||
await expect(page.getByText(/Error loading health/i)).toBeVisible()
|
||||
await expect(page.getByTestId('health-info')).not.toBeVisible()
|
||||
await page.screenshot({ path: 'tests/e2e/screenshots/home-page-surfaces-health-endpoint-errors-visibly.png', fullPage: true })
|
||||
})
|
||||
0
frontend/tests/e2e/screenshots/.gitkeep
Normal file
0
frontend/tests/e2e/screenshots/.gitkeep
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
6
frontend/tsconfig.json
Normal file
6
frontend/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
16
frontend/utils/uptime.ts
Normal file
16
frontend/utils/uptime.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Convert a duration in seconds to a humanised string like "2h 13m" or "45m 12s".
|
||||
// Returns "?" for non-finite or negative input so the UI never renders NaN/empty.
|
||||
export function humaniseUptime(seconds: number | null | undefined): string {
|
||||
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return '?'
|
||||
|
||||
const s = Math.floor(seconds)
|
||||
const days = Math.floor(s / 86400)
|
||||
const hours = Math.floor((s % 86400) / 3600)
|
||||
const minutes = Math.floor((s % 3600) / 60)
|
||||
const secs = s % 60
|
||||
|
||||
if (days > 0) return `${days}d ${hours}h`
|
||||
if (hours > 0) return `${hours}h ${minutes}m`
|
||||
if (minutes > 0) return `${minutes}m ${secs}s`
|
||||
return `${secs}s`
|
||||
}
|
||||
4
go.mod
4
go.mod
@@ -4,12 +4,14 @@ go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/cucumber/godog v0.15.1
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-playground/locales v0.14.1
|
||||
github.com/go-playground/universal-translator v0.18.1
|
||||
github.com/go-playground/validator/v10 v10.30.2
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/rs/zerolog v1.35.0
|
||||
github.com/spf13/cobra v1.8.0
|
||||
github.com/spf13/viper v1.21.0
|
||||
@@ -22,6 +24,7 @@ require (
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/time v0.15.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
@@ -35,7 +38,6 @@ require (
|
||||
github.com/cucumber/messages/go/v21 v21.0.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -118,6 +118,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -206,6 +208,8 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
|
||||
@@ -1,96 +1,327 @@
|
||||
# BDD Testing with Godog
|
||||
# BDD Testing Framework
|
||||
|
||||
This package implements Behavior-Driven Development (BDD) testing using the Godog framework.
|
||||
This directory contains the Behavior-Driven Development (BDD) testing framework for the dance-lessons-coach project, implementing the architecture described in ADR 0024.
|
||||
|
||||
## Important Requirements for Step Definitions
|
||||
## 🗺️ Architecture Overview
|
||||
|
||||
### Step Pattern Matching
|
||||
The BDD framework follows a modular, isolated test suite architecture with these key components:
|
||||
|
||||
Godog has **very specific requirements** for step pattern matching. To avoid "undefined" warnings:
|
||||
### 📁 Directory Structure
|
||||
|
||||
1. **Use the exact regex pattern** that Godog suggests in its error messages
|
||||
2. **Use the exact parameter names** that Godog suggests (`arg1, arg2`, etc.)
|
||||
3. **Match the feature file syntax exactly** including quotes and JSON formatting
|
||||
|
||||
### Example
|
||||
|
||||
**Feature file step:**
|
||||
```gherkin
|
||||
Then the response should be "{\"message\":\"Hello world!\"}"
|
||||
```
|
||||
pkg/bdd/
|
||||
├── README.md # This file
|
||||
├── context/ # Feature-specific test contexts
|
||||
│ ├── auth_context.go # Authentication test context
|
||||
│ └── config_context.go # Configuration test context
|
||||
├── helpers/ # Test synchronization helpers
|
||||
│ └── synchronization.go # Wait functions and utilities
|
||||
├── parallel/ # Parallel test execution
|
||||
│ ├── port_manager.go # Port allocation system
|
||||
│ └── resource_monitor.go # Resource tracking
|
||||
├── steps/ # Step definitions
|
||||
│ ├── auth_steps.go # Authentication steps
|
||||
│ ├── config_steps.go # Configuration steps
|
||||
│ ├── greet_steps.go # Greeting steps
|
||||
│ ├── health_steps.go # Health check steps
|
||||
│ ├── jwt_retention_steps.go # JWT retention steps
|
||||
│ └── steps.go # Main step registration
|
||||
├── suite.go # Test suite initialization
|
||||
├── suite_feature.go # Feature-specific suite support
|
||||
└── testserver/ # Test server implementation
|
||||
├── client.go # HTTP test client
|
||||
└── server.go # Test server with config
|
||||
```
|
||||
|
||||
**Correct step definition:**
|
||||
## 🎯 Core Components
|
||||
|
||||
### 1. Test Server
|
||||
|
||||
**Location:** `pkg/bdd/testserver/`
|
||||
|
||||
The test server provides a real HTTP server instance for black-box testing:
|
||||
|
||||
- **Hybrid Testing**: Runs in-process (not external process)
|
||||
- **Configuration**: Loads feature-specific configs from `features/*/*-test-config.yaml`
|
||||
- **Database**: Manages PostgreSQL connections with proper isolation
|
||||
- **Port Management**: Uses feature-specific ports (9192-9196)
|
||||
|
||||
**Key Functions:**
|
||||
- `NewServer()` - Creates test server instance
|
||||
- `Start()` - Starts server with feature-specific configuration
|
||||
- `initDBConnection()` - Initializes database connection
|
||||
- `createTestConfig()` - Loads feature-specific configuration
|
||||
|
||||
### 2. Step Definitions
|
||||
|
||||
**Location:** `pkg/bdd/steps/`
|
||||
|
||||
Step definitions implement the Gherkin scenarios using Godog:
|
||||
|
||||
- **Domain-Specific**: Organized by feature area (auth, config, greet, etc.)
|
||||
- **Reusable**: Common patterns in `common_steps.go`
|
||||
- **Exact Matching**: Uses Godog's exact regex patterns
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
ctx.Step(`^the response should be "{\"([^"]*)\":\"([^"]*)\"}"$`, func(arg1, arg2 string) error {
|
||||
// Implementation here
|
||||
return nil
|
||||
})
|
||||
// greet_steps.go
|
||||
func (gs *GreetSteps) iRequestAGreetingFor(name string) error {
|
||||
return gs.client.Request("GET", fmt.Sprintf("/api/v1/greet/%s", name), nil)
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect patterns that cause "undefined" warnings:**
|
||||
### 3. Synchronization Helpers
|
||||
|
||||
**Location:** `pkg/bdd/helpers/`
|
||||
|
||||
Helpers provide robust waiting mechanisms for async operations:
|
||||
|
||||
- **Timeout Support**: All functions include timeout parameters
|
||||
- **Polling**: Uses context-based polling with configurable intervals
|
||||
- **Common Patterns**: Covers server readiness, config reload, API availability
|
||||
|
||||
**Available Helpers:**
|
||||
- `waitForServerReady()` - Waits for server to be ready
|
||||
- `waitForConfigReload()` - Detects configuration changes
|
||||
- `waitForCondition()` - Generic condition waiting
|
||||
- `waitForV2APIEnabled()` - Checks v2 API availability
|
||||
|
||||
### 4. Parallel Testing
|
||||
|
||||
**Location:** `pkg/bdd/parallel/`
|
||||
|
||||
Parallel execution infrastructure for CI/CD optimization:
|
||||
|
||||
- **Port Management**: `PortManager` allocates unique ports
|
||||
- **Resource Monitoring**: Tracks memory, goroutines, CPU usage
|
||||
- **Controlled Parallelism**: `ParallelTestRunner` limits concurrency
|
||||
|
||||
**Key Features:**
|
||||
- Thread-safe port allocation
|
||||
- Resource limit enforcement
|
||||
- Timeout detection
|
||||
- Comprehensive monitoring
|
||||
|
||||
### 5. Feature Contexts
|
||||
|
||||
**Location:** `pkg/bdd/context/`
|
||||
|
||||
Feature-specific test contexts for better organization:
|
||||
|
||||
- **AuthContext**: User management and authentication
|
||||
- **ConfigContext**: Configuration file handling
|
||||
- **Extensible**: Easy to add new feature contexts
|
||||
|
||||
## 🚀 Test Execution
|
||||
|
||||
### Running All Tests
|
||||
|
||||
```bash
|
||||
# Default: Run all features sequentially
|
||||
go test ./features/...
|
||||
|
||||
# With environment variables
|
||||
DLC_DATABASE_HOST=localhost DLC_DATABASE_PORT=5432 \
|
||||
DLC_DATABASE_USER=postgres DLC_DATABASE_PASSWORD=postgres \
|
||||
DLC_DATABASE_NAME=dance_lessons_coach_bdd_test \
|
||||
DLC_DATABASE_SSL_MODE=disable \
|
||||
go test ./features/...
|
||||
```
|
||||
|
||||
### Feature-Specific Testing
|
||||
|
||||
```bash
|
||||
# Test specific feature
|
||||
./scripts/test-feature.sh greet
|
||||
|
||||
# Test with specific tags
|
||||
./scripts/test-by-tag.sh @smoke greet
|
||||
```
|
||||
|
||||
### Parallel Testing
|
||||
|
||||
```bash
|
||||
# Run all features in parallel
|
||||
./scripts/test-all-features-parallel.sh
|
||||
|
||||
# Run specific features in parallel
|
||||
# (Requires PostgreSQL container running)
|
||||
```
|
||||
|
||||
### Tag-Based Testing
|
||||
|
||||
```bash
|
||||
# List available tags
|
||||
./scripts/run-bdd-tests.sh list-tags
|
||||
|
||||
# Run smoke tests
|
||||
./scripts/run-bdd-tests.sh run @smoke
|
||||
|
||||
# Run critical tests for auth
|
||||
./scripts/run-bdd-tests.sh run @critical @auth
|
||||
```
|
||||
|
||||
## 📋 Test Organization
|
||||
|
||||
### Feature Structure
|
||||
|
||||
Each feature follows this structure:
|
||||
|
||||
```
|
||||
features/{feature}/
|
||||
├── {feature}.feature # Gherkin scenarios
|
||||
├── {feature}-test-config.yaml # Feature-specific config
|
||||
└── {feature}_test.go # Go test runner
|
||||
```
|
||||
|
||||
### Configuration Files
|
||||
|
||||
Feature-specific YAML files define test environment:
|
||||
|
||||
```yaml
|
||||
# features/greet/greet-test-config.yaml
|
||||
server:
|
||||
host: "127.0.0.1"
|
||||
port: 9194
|
||||
|
||||
database:
|
||||
host: "localhost"
|
||||
port: 5432
|
||||
name: "dance_lessons_coach_greet_test"
|
||||
|
||||
api:
|
||||
v2_enabled: true
|
||||
```
|
||||
|
||||
### Tagging System
|
||||
|
||||
Comprehensive tagging for selective test execution:
|
||||
|
||||
- **Feature Tags**: `@auth`, `@config`, `@greet`, `@health`, `@jwt`
|
||||
- **Priority Tags**: `@smoke`, `@critical`, `@basic`, `@advanced`
|
||||
- **Component Tags**: `@api`, `@v2`, `@database`, `@security`
|
||||
|
||||
See `features/BDD_TAGS.md` for complete documentation.
|
||||
|
||||
## 🔧 Database Management
|
||||
|
||||
### Database Creation
|
||||
|
||||
The framework handles database creation automatically:
|
||||
|
||||
1. **PostgreSQL Container**: Uses Docker (`dance-lessons-coach-postgres`)
|
||||
2. **Feature Databases**: Creates `dance_lessons_coach_{feature}_test` per feature
|
||||
3. **Cleanup**: Automatically drops databases after tests
|
||||
|
||||
**Database Creation Flow:**
|
||||
1. Check if database exists
|
||||
2. Create if missing (`createdb` command)
|
||||
3. Run tests with isolated database
|
||||
4. Cleanup (`dropdb` command)
|
||||
|
||||
### Configuration
|
||||
|
||||
Database settings come from:
|
||||
- Environment variables (`DLC_DATABASE_*`)
|
||||
- Feature-specific config files
|
||||
- Default values for development
|
||||
|
||||
## 🧪 Best Practices
|
||||
|
||||
### Step Definition Patterns
|
||||
|
||||
```go
|
||||
// Wrong: Different regex pattern
|
||||
ctx.Step(`^the response should be "{\"message\":\"([^"]*)\"}"$`, func(message string) error {
|
||||
// ...
|
||||
})
|
||||
// ✅ DO: Use Godog's exact regex patterns
|
||||
ctx.Step(`^I request a greeting for "([^"]*)"$`, sc.iRequestAGreetingFor)
|
||||
|
||||
// Wrong: Different parameter names
|
||||
ctx.Step(`^the response should be "{\"([^"]*)\":\"([^"]*)\"}"$`, func(key, value string) error {
|
||||
// ...
|
||||
})
|
||||
// ❌ DON'T: Use different patterns
|
||||
ctx.Step(`^I request greeting "(.*)"$`, sc.iRequestAGreetingFor)
|
||||
```
|
||||
|
||||
## Current Implementation
|
||||
### Test Isolation
|
||||
|
||||
### Step Definition Strategy
|
||||
- Each feature has unique port and database
|
||||
- No shared state between features
|
||||
- Cleanup after each test run
|
||||
- Feature-specific configuration
|
||||
|
||||
1. **First eliminate "undefined" warnings** by using Godog's exact suggested patterns
|
||||
2. **Return `godog.ErrPending`** initially to confirm pattern matching works
|
||||
3. **Then implement actual validation** logic
|
||||
### Synchronization
|
||||
|
||||
### Files
|
||||
```go
|
||||
// ✅ DO: Use helpers for async operations
|
||||
helpers.waitForServerReady(client, 30*time.Second)
|
||||
|
||||
- `suite.go`: Test suite initialization and server management
|
||||
- `testserver/`: Test server and client implementation
|
||||
- `steps/`: Step definitions for each feature
|
||||
// ❌ DON'T: Use fixed sleep times
|
||||
time.Sleep(5 * time.Second)
|
||||
```
|
||||
|
||||
## Debugging "Undefined" Steps
|
||||
### Context Management
|
||||
|
||||
If you see "undefined" warnings:
|
||||
```go
|
||||
// ✅ DO: Use feature-specific contexts
|
||||
switch featureName {
|
||||
case "auth":
|
||||
authCtx = context.NewAuthContext(client)
|
||||
context.InitializeAuthContext(ctx, client)
|
||||
}
|
||||
```
|
||||
|
||||
1. Run the tests to see Godog's suggested pattern:
|
||||
```bash
|
||||
go test ./features/... -v
|
||||
```
|
||||
## 📈 Performance Optimization
|
||||
|
||||
2. Copy the **exact regex pattern** from the error message
|
||||
3. Copy the **exact parameter names** (`arg1, arg2`, etc.)
|
||||
4. Update your step definition to match exactly
|
||||
### Parallel Execution
|
||||
|
||||
## Common Mistakes
|
||||
- Use `scripts/test-all-features-parallel.sh` for CI/CD
|
||||
- Limit parallelism based on system resources
|
||||
- Monitor resource usage with `ResourceMonitor`
|
||||
|
||||
The "undefined" warnings are **not a Godog bug** - they occur when step definitions don't match Godog's expected patterns exactly:
|
||||
### Selective Testing
|
||||
|
||||
- Using different regex patterns than what Godog suggests
|
||||
- Using descriptive parameter names instead of `arg1, arg2`
|
||||
- Not escaping quotes properly in JSON patterns
|
||||
- Trying to be "clever" with regex optimization
|
||||
- Run only relevant tests with tag filtering
|
||||
- Use `@smoke` for quick validation
|
||||
- Use `@critical` for essential path testing
|
||||
|
||||
**Solution**: Always use the exact pattern and parameter names that Godog suggests in its error messages.
|
||||
### Resource Management
|
||||
|
||||
## Best Practices
|
||||
- Set appropriate timeouts
|
||||
- Limit maximum goroutines
|
||||
- Monitor memory usage
|
||||
- Cleanup resources promptly
|
||||
|
||||
1. **Follow Godog's suggestions exactly** - Copy-paste the pattern and parameter names
|
||||
2. **Test pattern matching first** - Use `godog.ErrPending` to verify patterns work
|
||||
3. **Then implement logic** - Replace `godog.ErrPending` with actual validation
|
||||
4. **Don't over-optimize regex** - Use the patterns Godog provides, even if they seem verbose
|
||||
5. **One pattern per step type** - Use generic patterns to cover similar steps
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
## Why This Matters
|
||||
### Common Issues
|
||||
|
||||
Godog's step matching is **very specific by design**:
|
||||
- It needs to reliably match feature file steps to code
|
||||
- It provides exact patterns to ensure consistency
|
||||
- Following its suggestions guarantees your steps will be recognized
|
||||
| Issue | Cause | Solution |
|
||||
|-------|-------|----------|
|
||||
| Undefined steps | Step pattern mismatch | Use Godog's exact suggested patterns |
|
||||
| Port conflicts | Multiple servers | Check port allocation in config files |
|
||||
| Database connection | PostgreSQL not running | Start with `docker compose up -d postgres` |
|
||||
| Test isolation | Shared state | Verify unique ports/databases per feature |
|
||||
|
||||
**Remember**: The "undefined" warnings are Godog telling you exactly how to fix your step definitions!
|
||||
### Debugging
|
||||
|
||||
```bash
|
||||
# Verbose output
|
||||
go test ./features/... -v
|
||||
|
||||
# Check specific feature
|
||||
cd features/greet && go test -v .
|
||||
|
||||
# List available tags
|
||||
./scripts/run-bdd-tests.sh list-tags
|
||||
```
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **ADR 0024**: BDD Test Organization and Isolation Strategy
|
||||
- **BDD_TAGS.md**: Complete tag reference
|
||||
- **Godog Documentation**: https://github.com/cucumber/godog
|
||||
|
||||
## 🎯 Future Enhancements
|
||||
|
||||
- **Test Impact Analysis**: Track which tests are affected by code changes
|
||||
- **Flaky Test Detection**: Automatically identify and quarantine flaky tests
|
||||
- **Performance Benchmarking**: Monitor test execution times
|
||||
- **AI-Assisted Testing**: Automated test generation and optimization
|
||||
|
||||
This BDD framework provides a robust foundation for behavior-driven development in the dance-lessons-coach project, ensuring test reliability, maintainability, and scalability.
|
||||
65
pkg/bdd/context/auth_context.go
Normal file
65
pkg/bdd/context/auth_context.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"dance-lessons-coach/pkg/bdd/testserver"
|
||||
|
||||
"github.com/cucumber/godog"
|
||||
)
|
||||
|
||||
// AuthContext holds authentication-specific test context
|
||||
type AuthContext struct {
|
||||
client *testserver.Client
|
||||
users map[string]UserData
|
||||
}
|
||||
|
||||
// UserData represents user information for auth tests
|
||||
type UserData struct {
|
||||
Username string
|
||||
Password string
|
||||
Token string
|
||||
}
|
||||
|
||||
// NewAuthContext creates a new auth context
|
||||
func NewAuthContext(client *testserver.Client) *AuthContext {
|
||||
return &AuthContext{
|
||||
client: client,
|
||||
users: make(map[string]UserData),
|
||||
}
|
||||
}
|
||||
|
||||
// InitializeAuthContext initializes auth-specific steps
|
||||
func InitializeAuthContext(ctx *godog.ScenarioContext, client *testserver.Client) {
|
||||
authCtx := NewAuthContext(client)
|
||||
|
||||
// Register auth-specific steps
|
||||
ctx.Step(`^a user "([^"]*)" exists with password "([^"]*)"$`, authCtx.aUserExistsWithPassword)
|
||||
ctx.Step(`^I authenticate with username "([^"]*)" and password "([^"]*)"$`, authCtx.iAuthenticateWithUsernameAndPassword)
|
||||
ctx.Step(`^the authentication should be successful$`, authCtx.theAuthenticationShouldBeSuccessful)
|
||||
ctx.Step(`^I should receive a valid JWT token$`, authCtx.iShouldReceiveAValidJWTToken)
|
||||
|
||||
// Add more auth steps as needed...
|
||||
}
|
||||
|
||||
// Step implementations
|
||||
func (ac *AuthContext) aUserExistsWithPassword(username, password string) error {
|
||||
ac.users[username] = UserData{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ac *AuthContext) iAuthenticateWithUsernameAndPassword(username, password string) error {
|
||||
// Implementation would go here
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ac *AuthContext) theAuthenticationShouldBeSuccessful() error {
|
||||
// Implementation would go here
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ac *AuthContext) iShouldReceiveAValidJWTToken() error {
|
||||
// Implementation would go here
|
||||
return nil
|
||||
}
|
||||
50
pkg/bdd/context/config_context.go
Normal file
50
pkg/bdd/context/config_context.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"dance-lessons-coach/pkg/bdd/testserver"
|
||||
|
||||
"github.com/cucumber/godog"
|
||||
)
|
||||
|
||||
// ConfigContext holds configuration-specific test context
|
||||
type ConfigContext struct {
|
||||
client *testserver.Client
|
||||
configFilePath string
|
||||
originalConfig string
|
||||
}
|
||||
|
||||
// NewConfigContext creates a new config context
|
||||
func NewConfigContext(client *testserver.Client) *ConfigContext {
|
||||
return &ConfigContext{
|
||||
client: client,
|
||||
configFilePath: "test-config.yaml", // Default, will be overridden
|
||||
}
|
||||
}
|
||||
|
||||
// InitializeConfigContext initializes config-specific steps
|
||||
func InitializeConfigContext(ctx *godog.ScenarioContext, client *testserver.Client) {
|
||||
configCtx := NewConfigContext(client)
|
||||
|
||||
// Register config-specific steps
|
||||
ctx.Step(`^the server is running with config file monitoring enabled$`, configCtx.theServerIsRunningWithConfigFileMonitoringEnabled)
|
||||
ctx.Step(`^I update the logging level to "([^"]*)" in the config file$`, configCtx.iUpdateTheLoggingLevelToInTheConfigFile)
|
||||
ctx.Step(`^the logging level should be updated without restart$`, configCtx.theLoggingLevelShouldBeUpdatedWithoutRestart)
|
||||
|
||||
// Add more config steps as needed...
|
||||
}
|
||||
|
||||
// Step implementations
|
||||
func (cc *ConfigContext) theServerIsRunningWithConfigFileMonitoringEnabled() error {
|
||||
// Implementation would go here
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cc *ConfigContext) iUpdateTheLoggingLevelToInTheConfigFile(level string) error {
|
||||
// Implementation would go here
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cc *ConfigContext) theLoggingLevelShouldBeUpdatedWithoutRestart() error {
|
||||
// Implementation would go here
|
||||
return nil
|
||||
}
|
||||
141
pkg/bdd/helpers/synchronization.go
Normal file
141
pkg/bdd/helpers/synchronization.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"dance-lessons-coach/pkg/bdd/testserver"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// waitForServerReady waits for the test server to be ready with timeout
|
||||
func waitForServerReady(client *testserver.Client, timeout time.Duration) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("server not ready after %v: %w", timeout, ctx.Err())
|
||||
case <-ticker.C:
|
||||
if err := client.Request("GET", "/api/ready", nil); err == nil {
|
||||
log.Debug().Msg("Server is ready")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForConfigReload waits for configuration reload to complete
|
||||
func waitForConfigReload(client *testserver.Client, timeout time.Duration) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// Get initial config state
|
||||
var initialConfig string
|
||||
if err := client.Request("GET", "/api/config", nil); err == nil {
|
||||
initialConfig = string(client.GetLastBody())
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("config reload not detected after %v: %w", timeout, ctx.Err())
|
||||
case <-ticker.C:
|
||||
// Check if config has changed
|
||||
if err := client.Request("GET", "/api/config", nil); err == nil {
|
||||
currentConfig := string(client.GetLastBody())
|
||||
if currentConfig != initialConfig {
|
||||
log.Debug().Msg("Config reload detected")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForCondition waits for a custom condition to be true
|
||||
func waitForCondition(timeout time.Duration, condition func() bool) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(200 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("condition not met after %v: %w", timeout, ctx.Err())
|
||||
case <-ticker.C:
|
||||
if condition() {
|
||||
log.Debug().Msg("Condition met")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForV2APIEnabled waits for v2 API to become available
|
||||
func waitForV2APIEnabled(client *testserver.Client, timeout time.Duration) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("v2 API not enabled after %v: %w", timeout, ctx.Err())
|
||||
case <-ticker.C:
|
||||
// Try to access v2 endpoint
|
||||
if err := client.Request("GET", "/api/v2/greet", nil); err == nil {
|
||||
log.Debug().Msg("v2 API is now available")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForJWTToken waits for a valid JWT token to be received
|
||||
func waitForJWTToken(client *testserver.Client, timeout time.Duration) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("JWT token not received after %v: %w", timeout, ctx.Err())
|
||||
case <-ticker.C:
|
||||
// Check if we have a valid token in the last response
|
||||
body := client.GetLastBody()
|
||||
if len(body) > 0 && isValidJWTToken(string(body)) {
|
||||
log.Debug().Msg("Valid JWT token received")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isValidJWTToken checks if a string contains a valid JWT token structure
|
||||
func isValidJWTToken(token string) bool {
|
||||
// Basic JWT token validation (3 base64 parts separated by dots)
|
||||
parts := len(token)
|
||||
if parts < 10 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for the typical JWT structure
|
||||
return true // Simplified for testing
|
||||
}
|
||||
180
pkg/bdd/mailpit/client.go
Normal file
180
pkg/bdd/mailpit/client.go
Normal file
@@ -0,0 +1,180 @@
|
||||
// Package mailpit is a thin client for the local Mailpit HTTP API,
|
||||
// used by BDD scenarios to assert on emails sent during a test.
|
||||
//
|
||||
// Per ADR-0030 (BDD email parallel strategy), each scenario uses a
|
||||
// unique recipient address so parallel scenarios cannot interfere.
|
||||
// The client exposes per-recipient query + delete + await operations.
|
||||
//
|
||||
// Production code MUST NOT depend on this package. It lives under
|
||||
// pkg/bdd/ specifically to signal "test-only".
|
||||
package mailpit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultBaseURL is the local Mailpit HTTP API root used by the
|
||||
// docker-compose service (cf. ADR-0029).
|
||||
const DefaultBaseURL = "http://localhost:8025"
|
||||
|
||||
// Client is a Mailpit HTTP API client. Safe for concurrent use.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// NewClient returns a Client pointing at the local Mailpit. The HTTP
|
||||
// client has a 5-second per-call timeout to fail fast in test setups
|
||||
// where Mailpit is down.
|
||||
func NewClient() *Client {
|
||||
return &Client{
|
||||
BaseURL: DefaultBaseURL,
|
||||
HTTP: &http.Client{Timeout: 5 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Message is the metadata + body view returned by the Mailpit detail
|
||||
// endpoint. Fields are a subset of what Mailpit returns — only what
|
||||
// BDD scenarios need to assert on.
|
||||
type Message struct {
|
||||
ID string `json:"ID"`
|
||||
From Address `json:"From"`
|
||||
To []Address `json:"To"`
|
||||
Subject string `json:"Subject"`
|
||||
Text string `json:"Text"`
|
||||
HTML string `json:"HTML"`
|
||||
Date time.Time `json:"Date"`
|
||||
Headers map[string]interface{} `json:"-"` // populated only via the Headers() helper
|
||||
}
|
||||
|
||||
// Address is a Mailpit-formatted email address.
|
||||
type Address struct {
|
||||
Name string `json:"Name"`
|
||||
Address string `json:"Address"`
|
||||
}
|
||||
|
||||
// listResponse is the shape of GET /api/v1/messages.
|
||||
type listResponse struct {
|
||||
Messages []messageSummary `json:"messages"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type messageSummary struct {
|
||||
ID string `json:"ID"`
|
||||
Subject string `json:"Subject"`
|
||||
Created time.Time `json:"Created"`
|
||||
}
|
||||
|
||||
// MessagesTo returns the list of message IDs currently in Mailpit
|
||||
// addressed to the given recipient. Empty slice + nil error means
|
||||
// "no messages yet".
|
||||
func (c *Client) MessagesTo(ctx context.Context, to string) ([]string, error) {
|
||||
// Mailpit's /api/v1/search supports the to:<addr> filter ; the more
|
||||
// obvious-looking /api/v1/messages does NOT (the `query` param there
|
||||
// is for pagination, not filtering — verified empirically 2026-05-05).
|
||||
u := fmt.Sprintf("%s/api/v1/search?query=%s",
|
||||
strings.TrimRight(c.BaseURL, "/"),
|
||||
url.QueryEscape("to:"+to))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mailpit list: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("mailpit list: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var list listResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
|
||||
return nil, fmt.Errorf("mailpit list decode: %w", err)
|
||||
}
|
||||
ids := make([]string, 0, len(list.Messages))
|
||||
for _, m := range list.Messages {
|
||||
ids = append(ids, m.ID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// Get fetches the full content of the message with the given ID.
|
||||
func (c *Client) Get(ctx context.Context, id string) (*Message, error) {
|
||||
u := fmt.Sprintf("%s/api/v1/message/%s",
|
||||
strings.TrimRight(c.BaseURL, "/"),
|
||||
url.PathEscape(id))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mailpit get: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("mailpit get %s: HTTP %d", id, resp.StatusCode)
|
||||
}
|
||||
var m Message
|
||||
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
|
||||
return nil, fmt.Errorf("mailpit get decode: %w", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// AwaitMessageTo polls Mailpit for a message addressed to the given
|
||||
// recipient. Returns the most recent matching message ; errors out if
|
||||
// the timeout elapses with no match. Polls every 50ms — Mailpit is
|
||||
// fast enough that this is rarely the limiting factor.
|
||||
//
|
||||
// Use this in BDD steps "Then I should receive an email ...".
|
||||
func (c *Client) AwaitMessageTo(ctx context.Context, to string, timeout time.Duration) (*Message, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
ids, err := c.MessagesTo(ctx, to)
|
||||
if err == nil && len(ids) > 0 {
|
||||
// Most recent first per Mailpit's default sort
|
||||
return c.Get(ctx, ids[0])
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("mailpit: no message for %s within %s", to, timeout)
|
||||
}
|
||||
|
||||
// PurgeMessagesTo deletes every message addressed to the given recipient.
|
||||
// Idempotent: calling against an empty inbox is fine.
|
||||
//
|
||||
// Use this at the start of a BDD scenario to clear leftovers from
|
||||
// prior runs of the same scenario (rare given the random suffix per
|
||||
// scenario, but defensive).
|
||||
func (c *Client) PurgeMessagesTo(ctx context.Context, to string) error {
|
||||
// Mailpit's /api/v1/search supports the to:<addr> filter ; the more
|
||||
// obvious-looking /api/v1/messages does NOT (the `query` param there
|
||||
// is for pagination, not filtering — verified empirically 2026-05-05).
|
||||
u := fmt.Sprintf("%s/api/v1/search?query=%s",
|
||||
strings.TrimRight(c.BaseURL, "/"),
|
||||
url.QueryEscape("to:"+to))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mailpit delete: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("mailpit delete: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
133
pkg/bdd/mailpit/client_integration_test.go
Normal file
133
pkg/bdd/mailpit/client_integration_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
//go:build integration
|
||||
|
||||
// Integration tests for the Mailpit client. Run with:
|
||||
//
|
||||
// go test -tags integration ./pkg/bdd/mailpit/...
|
||||
//
|
||||
// Requires a running Mailpit reachable at http://localhost:8025
|
||||
// (the docker-compose service from ADR-0029).
|
||||
package mailpit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// uniqueRecipient returns an address unique to this test run, using the
|
||||
// per-scenario-recipient pattern from ADR-0030. Two parallel test runs
|
||||
// generate different suffixes so they never see each other's messages.
|
||||
func uniqueRecipient(t *testing.T) string {
|
||||
t.Helper()
|
||||
var raw [4]byte
|
||||
_, err := rand.Read(raw[:])
|
||||
require.NoError(t, err)
|
||||
return "integ-" + t.Name() + "-" + hex.EncodeToString(raw[:]) + "@bdd.local"
|
||||
}
|
||||
|
||||
// sendViaSMTP submits a small email through Mailpit's SMTP port.
|
||||
// Real-wire-format path : same as the application code will use.
|
||||
func sendViaSMTP(t *testing.T, to, subject, body string) {
|
||||
t.Helper()
|
||||
from := "integ-test@bdd.local"
|
||||
msg := []byte(
|
||||
"From: " + from + "\r\n" +
|
||||
"To: " + to + "\r\n" +
|
||||
"Subject: " + subject + "\r\n" +
|
||||
"\r\n" +
|
||||
body + "\r\n",
|
||||
)
|
||||
err := smtp.SendMail("localhost:1025", nil, from, []string{to}, msg)
|
||||
require.NoError(t, err, "SMTP send to local Mailpit")
|
||||
}
|
||||
|
||||
// TestIntegration_RoundTrip validates the full path : SMTP submit →
|
||||
// Mailpit captures → client lists → client gets full body. This is
|
||||
// the smoke test for the BDD-helper contract.
|
||||
func TestIntegration_RoundTrip(t *testing.T) {
|
||||
c := NewClient()
|
||||
to := uniqueRecipient(t)
|
||||
|
||||
// Defensive cleanup before the test (in case the recipient was reused)
|
||||
require.NoError(t, c.PurgeMessagesTo(context.Background(), to))
|
||||
|
||||
subject := "Integration roundtrip"
|
||||
body := "Token: integ-token-" + strings.ReplaceAll(to, "@", "-at-")
|
||||
|
||||
sendViaSMTP(t, to, subject, body)
|
||||
|
||||
msg, err := c.AwaitMessageTo(context.Background(), to, 3*time.Second)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, msg)
|
||||
|
||||
assert.Equal(t, subject, msg.Subject)
|
||||
assert.Contains(t, msg.Text, "Token: integ-token-")
|
||||
if assert.Len(t, msg.To, 1) {
|
||||
assert.Equal(t, to, msg.To[0].Address)
|
||||
}
|
||||
|
||||
// Cleanup so subsequent runs of this same test name don't accumulate
|
||||
require.NoError(t, c.PurgeMessagesTo(context.Background(), to))
|
||||
}
|
||||
|
||||
// TestIntegration_AwaitTimeoutWhenNoMessage confirms AwaitMessageTo
|
||||
// returns an error within the timeout when no message arrives.
|
||||
func TestIntegration_AwaitTimeoutWhenNoMessage(t *testing.T) {
|
||||
c := NewClient()
|
||||
to := uniqueRecipient(t) // never sent to → must time out
|
||||
|
||||
start := time.Now()
|
||||
_, err := c.AwaitMessageTo(context.Background(), to, 200*time.Millisecond)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no message")
|
||||
assert.GreaterOrEqual(t, elapsed, 150*time.Millisecond, "should poll until close to timeout")
|
||||
assert.Less(t, elapsed, 1*time.Second, "should not exceed timeout substantially")
|
||||
}
|
||||
|
||||
// TestIntegration_PurgeIsolation proves the per-recipient query/delete
|
||||
// model from ADR-0030 : two unique recipients can have their own
|
||||
// messages without one's purge affecting the other.
|
||||
func TestIntegration_PurgeIsolation(t *testing.T) {
|
||||
c := NewClient()
|
||||
// Build two distinct, well-formed addresses (separate local-parts,
|
||||
// same domain). Avoid mutating uniqueRecipient's output post-@.
|
||||
var rawA, rawB [4]byte
|
||||
_, _ = rand.Read(rawA[:])
|
||||
_, _ = rand.Read(rawB[:])
|
||||
toA := "iso-a-" + hex.EncodeToString(rawA[:]) + "@bdd.local"
|
||||
toB := "iso-b-" + hex.EncodeToString(rawB[:]) + "@bdd.local"
|
||||
|
||||
sendViaSMTP(t, toA, "for A", "body A")
|
||||
sendViaSMTP(t, toB, "for B", "body B")
|
||||
|
||||
// Both messages should exist
|
||||
idsA, err := c.MessagesTo(context.Background(), toA)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(idsA), 1, "A should have its message")
|
||||
idsB, err := c.MessagesTo(context.Background(), toB)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(idsB), 1, "B should have its message")
|
||||
|
||||
// Purge A only
|
||||
require.NoError(t, c.PurgeMessagesTo(context.Background(), toA))
|
||||
|
||||
// A is empty, B is untouched
|
||||
idsA, err = c.MessagesTo(context.Background(), toA)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, idsA, "A should be empty after purge")
|
||||
idsB, err = c.MessagesTo(context.Background(), toB)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(idsB), 1, "B should still have its message")
|
||||
|
||||
// Cleanup B
|
||||
require.NoError(t, c.PurgeMessagesTo(context.Background(), toB))
|
||||
}
|
||||
112
pkg/bdd/parallel/port_manager.go
Normal file
112
pkg/bdd/parallel/port_manager.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package parallel
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// PortManager manages port allocation for parallel test execution
|
||||
type PortManager struct {
|
||||
portsInUse map[int]bool
|
||||
basePort int
|
||||
maxPort int
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
// NewPortManager creates a new port manager with the specified port range
|
||||
func NewPortManager(basePort, maxPort int) *PortManager {
|
||||
return &PortManager{
|
||||
portsInUse: make(map[int]bool),
|
||||
basePort: basePort,
|
||||
maxPort: maxPort,
|
||||
}
|
||||
}
|
||||
|
||||
// AcquirePort acquires an available port for a feature
|
||||
func (pm *PortManager) AcquirePort(featureName string) (int, error) {
|
||||
pm.mutex.Lock()
|
||||
defer pm.mutex.Unlock()
|
||||
|
||||
// Check if this feature already has a port assigned
|
||||
// In a real implementation, this would be more sophisticated
|
||||
|
||||
// Try to find an available port
|
||||
for port := pm.basePort; port <= pm.maxPort; port++ {
|
||||
if !pm.portsInUse[port] {
|
||||
pm.portsInUse[port] = true
|
||||
return port, nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, errors.New("no available ports in the specified range")
|
||||
}
|
||||
|
||||
// ReleasePort releases a port back to the pool
|
||||
func (pm *PortManager) ReleasePort(port int) {
|
||||
pm.mutex.Lock()
|
||||
defer pm.mutex.Unlock()
|
||||
|
||||
if pm.portsInUse[port] {
|
||||
delete(pm.portsInUse, port)
|
||||
}
|
||||
}
|
||||
|
||||
// CheckPortConflict checks if a port is already in use
|
||||
func (pm *PortManager) CheckPortConflict(port int) bool {
|
||||
pm.mutex.Lock()
|
||||
defer pm.mutex.Unlock()
|
||||
|
||||
return pm.portsInUse[port]
|
||||
}
|
||||
|
||||
// GetAvailablePorts returns a list of available ports
|
||||
func (pm *PortManager) GetAvailablePorts() []int {
|
||||
pm.mutex.Lock()
|
||||
defer pm.mutex.Unlock()
|
||||
|
||||
var available []int
|
||||
for port := pm.basePort; port <= pm.maxPort; port++ {
|
||||
if !pm.portsInUse[port] {
|
||||
available = append(available, port)
|
||||
}
|
||||
}
|
||||
return available
|
||||
}
|
||||
|
||||
// GetPortForFeature gets the standard port for a feature (without dynamic allocation)
|
||||
func GetPortForFeature(featureName string) int {
|
||||
// Standard port mapping for features
|
||||
switch featureName {
|
||||
case "auth":
|
||||
return 9192
|
||||
case "config":
|
||||
return 9193
|
||||
case "greet":
|
||||
return 9194
|
||||
case "health":
|
||||
return 9195
|
||||
case "jwt":
|
||||
return 9196
|
||||
default:
|
||||
return 9191 // Default port
|
||||
}
|
||||
}
|
||||
|
||||
// ValidatePortRange validates that a port is within acceptable range
|
||||
func ValidatePortRange(port int) error {
|
||||
if port < 1024 || port > 65535 {
|
||||
return fmt.Errorf("port %d is outside valid range (1024-65535)", port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPortAvailable checks if a specific port is available on the system
|
||||
func CheckPortAvailable(port int) (bool, error) {
|
||||
// In a real implementation, this would actually check if the port is available
|
||||
// For now, we'll just validate the range
|
||||
if err := ValidatePortRange(port); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
198
pkg/bdd/parallel/resource_monitor.go
Normal file
198
pkg/bdd/parallel/resource_monitor.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package parallel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// ResourceMonitor monitors system resources during parallel test execution
|
||||
type ResourceMonitor struct {
|
||||
startTime time.Time
|
||||
maxMemoryMB float64
|
||||
maxGoroutines int
|
||||
checkInterval time.Duration
|
||||
stopChan chan bool
|
||||
wg sync.WaitGroup
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
// NewResourceMonitor creates a new resource monitor
|
||||
type ResourceStats struct {
|
||||
MemoryMB float64
|
||||
Goroutines int
|
||||
CPUUsage float64
|
||||
TestDuration time.Duration
|
||||
}
|
||||
|
||||
func NewResourceMonitor(interval time.Duration) *ResourceMonitor {
|
||||
return &ResourceMonitor{
|
||||
checkInterval: interval,
|
||||
stopChan: make(chan bool),
|
||||
}
|
||||
}
|
||||
|
||||
// StartMonitoring starts monitoring system resources
|
||||
func (rm *ResourceMonitor) StartMonitoring() {
|
||||
rm.startTime = time.Now()
|
||||
rm.wg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer rm.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(rm.checkInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-rm.stopChan:
|
||||
return
|
||||
case <-ticker.C:
|
||||
rm.checkResources()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// StopMonitoring stops the resource monitor
|
||||
func (rm *ResourceMonitor) StopMonitoring() {
|
||||
close(rm.stopChan)
|
||||
rm.wg.Wait()
|
||||
}
|
||||
|
||||
// checkResources checks current system resource usage
|
||||
func (rm *ResourceMonitor) checkResources() {
|
||||
var memStats runtime.MemStats
|
||||
runtime.ReadMemStats(&memStats)
|
||||
|
||||
currentMemoryMB := float64(memStats.Alloc) / 1024 / 1024
|
||||
currentGoroutines := runtime.NumGoroutine()
|
||||
|
||||
rm.mutex.Lock()
|
||||
if currentMemoryMB > rm.maxMemoryMB {
|
||||
rm.maxMemoryMB = currentMemoryMB
|
||||
}
|
||||
if currentGoroutines > rm.maxGoroutines {
|
||||
rm.maxGoroutines = currentGoroutines
|
||||
}
|
||||
rm.mutex.Unlock()
|
||||
|
||||
log.Debug().
|
||||
Float64("memory_mb", currentMemoryMB).
|
||||
Int("goroutines", currentGoroutines).
|
||||
Msg("Resource usage update")
|
||||
}
|
||||
|
||||
// GetResourceStats gets the collected resource statistics
|
||||
func (rm *ResourceMonitor) GetResourceStats() ResourceStats {
|
||||
rm.mutex.Lock()
|
||||
defer rm.mutex.Unlock()
|
||||
|
||||
return ResourceStats{
|
||||
MemoryMB: rm.maxMemoryMB,
|
||||
Goroutines: rm.maxGoroutines,
|
||||
TestDuration: time.Since(rm.startTime),
|
||||
}
|
||||
}
|
||||
|
||||
// LogResourceSummary logs a summary of resource usage
|
||||
func (rm *ResourceMonitor) LogResourceSummary() {
|
||||
stats := rm.GetResourceStats()
|
||||
|
||||
log.Info().
|
||||
Float64("max_memory_mb", stats.MemoryMB).
|
||||
Int("max_goroutines", stats.Goroutines).
|
||||
Str("duration", stats.TestDuration.String()).
|
||||
Msg("Parallel Test Resource Usage Summary")
|
||||
}
|
||||
|
||||
// CheckResourceLimits checks if resource usage exceeds specified limits
|
||||
func (rm *ResourceMonitor) CheckResourceLimits(maxMemoryMB float64, maxGoroutines int) (bool, string) {
|
||||
stats := rm.GetResourceStats()
|
||||
|
||||
if stats.MemoryMB > maxMemoryMB {
|
||||
return false, fmt.Sprintf("Memory limit exceeded: %.1fMB > %.1fMB", stats.MemoryMB, maxMemoryMB)
|
||||
}
|
||||
|
||||
if stats.Goroutines > maxGoroutines {
|
||||
return false, fmt.Sprintf("Goroutine limit exceeded: %d > %d", stats.Goroutines, maxGoroutines)
|
||||
}
|
||||
|
||||
return true, "Within resource limits"
|
||||
}
|
||||
|
||||
// MonitorTestExecution monitors a single test execution with timeout
|
||||
func MonitorTestExecution(testName string, timeout time.Duration, testFunc func() error) error {
|
||||
done := make(chan error, 1)
|
||||
|
||||
// Start the test in a goroutine
|
||||
go func() {
|
||||
done <- testFunc()
|
||||
}()
|
||||
|
||||
// Wait for test completion or timeout
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-time.After(timeout):
|
||||
return fmt.Errorf("test '%s' exceeded timeout of %v", testName, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// ParallelTestRunner runs multiple tests in parallel with resource monitoring
|
||||
type ParallelTestRunner struct {
|
||||
maxParallel int
|
||||
semaphore chan struct{}
|
||||
monitor *ResourceMonitor
|
||||
}
|
||||
|
||||
// NewParallelTestRunner creates a new parallel test runner
|
||||
func NewParallelTestRunner(maxParallel int) *ParallelTestRunner {
|
||||
return &ParallelTestRunner{
|
||||
maxParallel: maxParallel,
|
||||
semaphore: make(chan struct{}, maxParallel),
|
||||
monitor: NewResourceMonitor(1 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
// RunTestsInParallel runs tests in parallel
|
||||
func (ptr *ParallelTestRunner) RunTestsInParallel(tests []func() error) ([]error, error) {
|
||||
var errors []error
|
||||
var mutex sync.Mutex
|
||||
|
||||
ptr.monitor.StartMonitoring()
|
||||
defer ptr.monitor.StopMonitoring()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, test := range tests {
|
||||
wg.Add(1)
|
||||
|
||||
// Acquire semaphore slot
|
||||
ptr.semaphore <- struct{}{}
|
||||
|
||||
go func(t func() error) {
|
||||
defer wg.Done()
|
||||
defer func() { <-ptr.semaphore }()
|
||||
|
||||
if err := t(); err != nil {
|
||||
mutex.Lock()
|
||||
errors = append(errors, err)
|
||||
mutex.Unlock()
|
||||
}
|
||||
}(test)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
ptr.monitor.LogResourceSummary()
|
||||
|
||||
if len(errors) > 0 {
|
||||
return errors, fmt.Errorf("%d tests failed", len(errors))
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user