feat: add comprehensive testing infrastructure
Some checks failed
CI Pipeline / Lint Code (push) Has been cancelled
CI Pipeline / Test API Package (push) Has been cancelled
CI Pipeline / Test Web Package (push) Has been cancelled
CI Pipeline / Test Shared Package (push) Has been cancelled
CI Pipeline / Build All Packages (push) Has been cancelled
CI Pipeline / Generate Coverage Report (push) Has been cancelled
Docker Build & Deploy / Build Docker Images (push) Has been cancelled
Docker Build & Deploy / Push Docker Images (push) Has been cancelled
Docker Build & Deploy / Deploy to Staging (push) Has been cancelled
Docker Build & Deploy / Deploy to Production (push) Has been cancelled
E2E Tests / End-to-End Tests (push) Has been cancelled
E2E Tests / E2E Tests (Mobile) (push) Has been cancelled
Security Scanning / NPM Audit (push) Has been cancelled
Security Scanning / Dependency License Check (push) Has been cancelled
Security Scanning / Code Quality Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
Some checks failed
CI Pipeline / Lint Code (push) Has been cancelled
CI Pipeline / Test API Package (push) Has been cancelled
CI Pipeline / Test Web Package (push) Has been cancelled
CI Pipeline / Test Shared Package (push) Has been cancelled
CI Pipeline / Build All Packages (push) Has been cancelled
CI Pipeline / Generate Coverage Report (push) Has been cancelled
Docker Build & Deploy / Build Docker Images (push) Has been cancelled
Docker Build & Deploy / Push Docker Images (push) Has been cancelled
Docker Build & Deploy / Deploy to Staging (push) Has been cancelled
Docker Build & Deploy / Deploy to Production (push) Has been cancelled
E2E Tests / End-to-End Tests (push) Has been cancelled
E2E Tests / E2E Tests (Mobile) (push) Has been cancelled
Security Scanning / NPM Audit (push) Has been cancelled
Security Scanning / Dependency License Check (push) Has been cancelled
Security Scanning / Code Quality Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
- Add Vitest for unit testing across all packages - Add Playwright for E2E testing - Add sample tests for API, Web, and Shared packages - Configure Gitea Actions CI/CD workflows (ci, e2e, security, docker) - Add testing documentation (TESTING.md) - Add Gitea Actions setup guide - Update .gitignore for test artifacts - Add test environment configuration
This commit is contained in:
424
.gitea/GITEA_ACTIONS_SETUP.md
Normal file
424
.gitea/GITEA_ACTIONS_SETUP.md
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
# Gitea Actions Setup Guide
|
||||||
|
|
||||||
|
This guide explains how to set up and configure Gitea Actions for the Basil project.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Gitea instance** with Actions enabled (Gitea 1.19+)
|
||||||
|
2. **Act runner** installed and configured
|
||||||
|
3. **PostgreSQL** available for testing
|
||||||
|
4. **Docker** (optional, for Docker-related workflows)
|
||||||
|
|
||||||
|
## Enabling Gitea Actions
|
||||||
|
|
||||||
|
### 1. Enable Actions in Gitea
|
||||||
|
|
||||||
|
Edit your Gitea configuration file (`app.ini`):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[actions]
|
||||||
|
ENABLED = true
|
||||||
|
DEFAULT_ACTIONS_URL = https://github.com ; or your own Gitea instance
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart Gitea:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl restart gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Install Act Runner
|
||||||
|
|
||||||
|
The act runner executes Gitea Actions workflows.
|
||||||
|
|
||||||
|
**Install via Docker:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull gitea/act_runner:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**Install via binary:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download from Gitea releases
|
||||||
|
wget https://dl.gitea.com/act_runner/latest/act_runner-linux-amd64
|
||||||
|
chmod +x act_runner-linux-amd64
|
||||||
|
mv act_runner-linux-amd64 /usr/local/bin/act_runner
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Register the Runner
|
||||||
|
|
||||||
|
1. **Get registration token** from Gitea:
|
||||||
|
- Go to your repository settings
|
||||||
|
- Navigate to Actions → Runners
|
||||||
|
- Copy the registration token
|
||||||
|
|
||||||
|
2. **Register the runner:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
act_runner register \
|
||||||
|
--instance https://your-gitea-instance.com \
|
||||||
|
--token YOUR_REGISTRATION_TOKEN \
|
||||||
|
--name basil-runner
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Start the runner:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run in foreground
|
||||||
|
act_runner daemon
|
||||||
|
|
||||||
|
# Or run as systemd service
|
||||||
|
sudo systemctl enable act_runner
|
||||||
|
sudo systemctl start act_runner
|
||||||
|
```
|
||||||
|
|
||||||
|
## Repository Configuration
|
||||||
|
|
||||||
|
### Required Secrets
|
||||||
|
|
||||||
|
Configure these in your repository settings (Settings → Secrets):
|
||||||
|
|
||||||
|
```
|
||||||
|
DOCKER_REGISTRY=registry.example.com
|
||||||
|
DOCKER_USERNAME=your-username
|
||||||
|
DOCKER_PASSWORD=your-password-or-token
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional Secrets
|
||||||
|
|
||||||
|
For production deployments:
|
||||||
|
|
||||||
|
```
|
||||||
|
DEPLOY_SSH_KEY=your-ssh-private-key
|
||||||
|
DEPLOY_HOST=your-server.com
|
||||||
|
DEPLOY_USER=deploy
|
||||||
|
SENTRY_DSN=your-sentry-dsn
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow Files
|
||||||
|
|
||||||
|
The project includes 4 main workflows in `.gitea/workflows/`:
|
||||||
|
|
||||||
|
### 1. CI Pipeline (`ci.yml`)
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- Push to main, master, develop
|
||||||
|
- Pull requests
|
||||||
|
|
||||||
|
**Jobs:**
|
||||||
|
- Linting
|
||||||
|
- Unit tests (API, Web, Shared)
|
||||||
|
- Build verification
|
||||||
|
- Coverage reports
|
||||||
|
|
||||||
|
**Database:** Uses PostgreSQL service container
|
||||||
|
|
||||||
|
### 2. E2E Tests (`e2e.yml`)
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- Push to main, master
|
||||||
|
- Pull requests to main, master
|
||||||
|
- Nightly schedule (2 AM UTC)
|
||||||
|
|
||||||
|
**Jobs:**
|
||||||
|
- Full E2E test suite
|
||||||
|
- Mobile browser tests (on main branch only)
|
||||||
|
|
||||||
|
**Services:** PostgreSQL
|
||||||
|
|
||||||
|
### 3. Security Scanning (`security.yml`)
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- Push to main, master, develop
|
||||||
|
- Pull requests to main, master
|
||||||
|
- Weekly schedule (Monday 9 AM UTC)
|
||||||
|
|
||||||
|
**Jobs:**
|
||||||
|
- NPM dependency audit
|
||||||
|
- License checking
|
||||||
|
- Code quality scanning
|
||||||
|
- Docker image security
|
||||||
|
|
||||||
|
### 4. Docker Build & Deploy (`docker.yml`)
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- Push to main, master
|
||||||
|
- Version tags (`v*`)
|
||||||
|
|
||||||
|
**Jobs:**
|
||||||
|
- Build Docker images
|
||||||
|
- Push to registry
|
||||||
|
- Deploy to staging (develop branch)
|
||||||
|
- Deploy to production (version tags)
|
||||||
|
|
||||||
|
## Testing the Setup
|
||||||
|
|
||||||
|
### 1. Verify Runner Connection
|
||||||
|
|
||||||
|
Check runner status in repository settings:
|
||||||
|
- Settings → Actions → Runners
|
||||||
|
- Ensure runner shows as "online"
|
||||||
|
|
||||||
|
### 2. Trigger a Test Workflow
|
||||||
|
|
||||||
|
Create a test file `.gitea/workflows/test.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Test Workflow
|
||||||
|
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Echo test
|
||||||
|
run: echo "Gitea Actions is working!"
|
||||||
|
```
|
||||||
|
|
||||||
|
Push to trigger:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .gitea/workflows/test.yml
|
||||||
|
git commit -m "test: add test workflow"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
Check Actions tab in your repository to see the workflow run.
|
||||||
|
|
||||||
|
### 3. Test Main CI Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make a small change
|
||||||
|
echo "# Test" >> README.md
|
||||||
|
|
||||||
|
# Commit and push
|
||||||
|
git add README.md
|
||||||
|
git commit -m "test: trigger CI workflow"
|
||||||
|
git push
|
||||||
|
|
||||||
|
# Check Actions tab to see CI pipeline run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring Workflows
|
||||||
|
|
||||||
|
### View Workflow Runs
|
||||||
|
|
||||||
|
1. Go to repository → Actions tab
|
||||||
|
2. Click on workflow run to see details
|
||||||
|
3. Expand jobs to see individual steps
|
||||||
|
4. View logs for debugging
|
||||||
|
|
||||||
|
### Workflow Status Badges
|
||||||
|
|
||||||
|
Add to your README.md:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Runner Not Connecting
|
||||||
|
|
||||||
|
1. **Check runner logs:**
|
||||||
|
```bash
|
||||||
|
journalctl -u act_runner -f
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Verify token:** Ensure registration token is correct
|
||||||
|
|
||||||
|
3. **Check network:** Runner must reach Gitea instance
|
||||||
|
|
||||||
|
### Workflow Failing
|
||||||
|
|
||||||
|
1. **Check logs** in Actions tab
|
||||||
|
2. **Verify secrets** are configured
|
||||||
|
3. **Test locally** with act:
|
||||||
|
```bash
|
||||||
|
# Install act (GitHub Actions locally)
|
||||||
|
curl https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bash
|
||||||
|
|
||||||
|
# Run workflow locally
|
||||||
|
act -W .gitea/workflows/ci.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Connection Issues
|
||||||
|
|
||||||
|
1. **Verify PostgreSQL service** in workflow:
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
# ... health checks ...
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Check DATABASE_URL** environment variable
|
||||||
|
|
||||||
|
3. **Ensure migrations run** before tests
|
||||||
|
|
||||||
|
### Docker Build Failures
|
||||||
|
|
||||||
|
1. **Verify Docker is available** on runner
|
||||||
|
2. **Check Dockerfile paths** are correct
|
||||||
|
3. **Ensure registry credentials** are valid
|
||||||
|
4. **Test build locally:**
|
||||||
|
```bash
|
||||||
|
docker-compose build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
### Caching Dependencies
|
||||||
|
|
||||||
|
Workflows use npm cache:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parallel Jobs
|
||||||
|
|
||||||
|
Jobs run in parallel by default. Use `needs:` to create dependencies:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# ...
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: test # Wait for test to complete
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Execution
|
||||||
|
|
||||||
|
Use `if:` to skip jobs:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Configuration
|
||||||
|
|
||||||
|
### Multiple Runners
|
||||||
|
|
||||||
|
For faster builds, configure multiple runners with labels:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Register runner with label
|
||||||
|
act_runner register --labels linux,docker
|
||||||
|
|
||||||
|
# Use in workflow
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: [linux, docker]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Self-Hosted Runner Requirements
|
||||||
|
|
||||||
|
Ensure runner has:
|
||||||
|
- Node.js 20+
|
||||||
|
- PostgreSQL client
|
||||||
|
- Docker (for Docker workflows)
|
||||||
|
- Git
|
||||||
|
- Build tools (gcc, make, etc.)
|
||||||
|
|
||||||
|
### Matrix Builds
|
||||||
|
|
||||||
|
Test across multiple Node versions:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [18, 20, 22]
|
||||||
|
steps:
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment Configuration
|
||||||
|
|
||||||
|
### SSH Deployment
|
||||||
|
|
||||||
|
For SSH-based deployments, add to workflow:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Deploy to production
|
||||||
|
env:
|
||||||
|
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "$SSH_KEY" > ~/.ssh/id_rsa
|
||||||
|
chmod 600 ~/.ssh/id_rsa
|
||||||
|
ssh-keyscan ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} << 'EOF'
|
||||||
|
cd /var/www/basil
|
||||||
|
git pull
|
||||||
|
docker-compose pull
|
||||||
|
docker-compose up -d
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Registry
|
||||||
|
|
||||||
|
Push images to private registry:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Login to registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ secrets.DOCKER_REGISTRY }}
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use secrets** for sensitive data (never hardcode)
|
||||||
|
2. **Pin action versions** (@v4 instead of @latest)
|
||||||
|
3. **Add timeout** to long-running jobs
|
||||||
|
4. **Use caching** to speed up builds
|
||||||
|
5. **Run critical tests** on every PR
|
||||||
|
6. **Schedule heavy tests** (E2E, security) for off-peak hours
|
||||||
|
7. **Monitor runner capacity** and scale as needed
|
||||||
|
8. **Keep workflows DRY** using reusable workflows
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- **Gitea Actions Docs**: https://docs.gitea.com/usage/actions
|
||||||
|
- **GitHub Actions Docs**: https://docs.github.com/en/actions (syntax compatible)
|
||||||
|
- **Act Runner**: https://gitea.com/gitea/act_runner
|
||||||
|
- **Nektos Act** (local testing): https://github.com/nektos/act
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues with:
|
||||||
|
- **Gitea Actions**: Check Gitea documentation and community forums
|
||||||
|
- **This project's workflows**: Open an issue in the repository
|
||||||
|
- **Runner setup**: Consult act_runner documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: 2025-10-24
|
||||||
|
**Version**: 1.0
|
||||||
183
.gitea/workflows/ci.yml
Normal file
183
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
name: CI Pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master, develop ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, master, develop ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint Code
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run linters
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
test-api:
|
||||||
|
name: Test API Package
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: basil
|
||||||
|
POSTGRES_PASSWORD: basil
|
||||||
|
POSTGRES_DB: basil_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run API tests
|
||||||
|
working-directory: packages/api
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://basil:basil@localhost:5432/basil_test
|
||||||
|
NODE_ENV: test
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
- name: Upload API test coverage
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: api-coverage
|
||||||
|
path: packages/api/coverage/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
test-web:
|
||||||
|
name: Test Web Package
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run web tests
|
||||||
|
working-directory: packages/web
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
- name: Upload web test coverage
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: web-coverage
|
||||||
|
path: packages/web/coverage/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
test-shared:
|
||||||
|
name: Test Shared Package
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run shared package tests
|
||||||
|
working-directory: packages/shared
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
- name: Upload shared test coverage
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: shared-coverage
|
||||||
|
path: packages/shared/coverage/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build All Packages
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint, test-api, test-web, test-shared]
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build all packages
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Upload build artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-artifacts
|
||||||
|
path: |
|
||||||
|
packages/api/dist/
|
||||||
|
packages/web/dist/
|
||||||
|
packages/shared/dist/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
coverage-report:
|
||||||
|
name: Generate Coverage Report
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [test-api, test-web, test-shared]
|
||||||
|
if: always()
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download all coverage artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: coverage-artifacts
|
||||||
|
|
||||||
|
- name: Display coverage summary
|
||||||
|
run: |
|
||||||
|
echo "## Test Coverage Summary" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Coverage reports have been generated for all packages." >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- API Package Coverage" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Web Package Coverage" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Shared Package Coverage" >> $GITHUB_STEP_SUMMARY
|
||||||
146
.gitea/workflows/docker.yml
Normal file
146
.gitea/workflows/docker.yml
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
name: Docker Build & Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
name: Build Docker Images
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build API image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./packages/api/Dockerfile
|
||||||
|
push: false
|
||||||
|
tags: basil-api:test
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Build Web image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./packages/web/Dockerfile
|
||||||
|
push: false
|
||||||
|
tags: basil-web:test
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Test Docker Compose
|
||||||
|
run: |
|
||||||
|
docker-compose -f docker-compose.yml config
|
||||||
|
echo "✅ Docker Compose configuration is valid"
|
||||||
|
|
||||||
|
push-images:
|
||||||
|
name: Push Docker Images
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build-and-test
|
||||||
|
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v'))
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ secrets.DOCKER_REGISTRY }}
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||||
|
VERSION=${GITHUB_REF#refs/tags/}
|
||||||
|
else
|
||||||
|
VERSION=latest
|
||||||
|
fi
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build and push API image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./packages/api/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ secrets.DOCKER_REGISTRY }}/basil-api:${{ steps.meta.outputs.version }}
|
||||||
|
${{ secrets.DOCKER_REGISTRY }}/basil-api:latest
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.created=${{ steps.meta.outputs.date }}
|
||||||
|
org.opencontainers.image.version=${{ steps.meta.outputs.version }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Build and push Web image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./packages/web/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ secrets.DOCKER_REGISTRY }}/basil-web:${{ steps.meta.outputs.version }}
|
||||||
|
${{ secrets.DOCKER_REGISTRY }}/basil-web:latest
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.created=${{ steps.meta.outputs.date }}
|
||||||
|
org.opencontainers.image.version=${{ steps.meta.outputs.version }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Image digest
|
||||||
|
run: echo "Images have been built and pushed successfully"
|
||||||
|
|
||||||
|
deploy-staging:
|
||||||
|
name: Deploy to Staging
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: push-images
|
||||||
|
if: github.ref == 'refs/heads/develop'
|
||||||
|
environment:
|
||||||
|
name: staging
|
||||||
|
url: https://staging.basil.example.com
|
||||||
|
steps:
|
||||||
|
- name: Deploy to staging
|
||||||
|
run: |
|
||||||
|
echo "Deploying to staging environment..."
|
||||||
|
echo "This is a placeholder for actual deployment steps."
|
||||||
|
echo "Examples: SSH to server, run docker-compose pull, restart services, etc."
|
||||||
|
|
||||||
|
deploy-production:
|
||||||
|
name: Deploy to Production
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: push-images
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
environment:
|
||||||
|
name: production
|
||||||
|
url: https://basil.example.com
|
||||||
|
steps:
|
||||||
|
- name: Deploy to production
|
||||||
|
run: |
|
||||||
|
echo "Deploying to production environment..."
|
||||||
|
echo "This is a placeholder for actual deployment steps."
|
||||||
|
echo "Examples: SSH to server, run docker-compose pull, restart services, etc."
|
||||||
|
|
||||||
|
- name: Create deployment summary
|
||||||
|
run: |
|
||||||
|
echo "# 🚀 Deployment Summary" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Version**: ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Environment**: Production" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Status**: Deployed Successfully ✅" >> $GITHUB_STEP_SUMMARY
|
||||||
148
.gitea/workflows/e2e.yml
Normal file
148
.gitea/workflows/e2e.yml
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
name: E2E Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, master ]
|
||||||
|
schedule:
|
||||||
|
# Run E2E tests nightly at 2 AM UTC
|
||||||
|
- cron: '0 2 * * *'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e-tests:
|
||||||
|
name: End-to-End Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: basil
|
||||||
|
POSTGRES_PASSWORD: basil
|
||||||
|
POSTGRES_DB: basil
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
|
- name: Build packages
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Run database migrations
|
||||||
|
working-directory: packages/api
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://basil:basil@localhost:5432/basil
|
||||||
|
run: npm run prisma:migrate
|
||||||
|
|
||||||
|
- name: Start application
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://basil:basil@localhost:5432/basil
|
||||||
|
PORT: 3001
|
||||||
|
NODE_ENV: test
|
||||||
|
run: |
|
||||||
|
npm run dev &
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
- name: Run E2E tests
|
||||||
|
run: npm run test:e2e
|
||||||
|
|
||||||
|
- name: Upload Playwright report
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Upload test results
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: playwright-results
|
||||||
|
path: test-results/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
e2e-mobile:
|
||||||
|
name: E2E Tests (Mobile)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: basil
|
||||||
|
POSTGRES_PASSWORD: basil
|
||||||
|
POSTGRES_DB: basil
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
|
- name: Build packages
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Run database migrations
|
||||||
|
working-directory: packages/api
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://basil:basil@localhost:5432/basil
|
||||||
|
run: npm run prisma:migrate
|
||||||
|
|
||||||
|
- name: Start application
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://basil:basil@localhost:5432/basil
|
||||||
|
PORT: 3001
|
||||||
|
NODE_ENV: test
|
||||||
|
run: |
|
||||||
|
npm run dev &
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
- name: Run E2E tests on mobile
|
||||||
|
run: npx playwright test --project="Mobile Chrome" --project="Mobile Safari"
|
||||||
|
|
||||||
|
- name: Upload mobile test results
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: playwright-mobile-results
|
||||||
|
path: test-results/
|
||||||
|
retention-days: 7
|
||||||
146
.gitea/workflows/security.yml
Normal file
146
.gitea/workflows/security.yml
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
name: Security Scanning
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master, develop ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, master ]
|
||||||
|
schedule:
|
||||||
|
# Run security scans weekly on Monday at 9 AM UTC
|
||||||
|
- cron: '0 9 * * 1'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
dependency-audit:
|
||||||
|
name: NPM Audit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run npm audit
|
||||||
|
run: npm audit --audit-level=moderate
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Run npm audit in API package
|
||||||
|
working-directory: packages/api
|
||||||
|
run: npm audit --audit-level=moderate
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Run npm audit in Web package
|
||||||
|
working-directory: packages/web
|
||||||
|
run: npm audit --audit-level=moderate
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Generate audit report
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "## Security Audit Report" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "NPM audit has been completed for all packages." >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Review the logs above for any vulnerabilities." >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
dependency-check:
|
||||||
|
name: Dependency License Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Check for outdated dependencies
|
||||||
|
run: npm outdated || true
|
||||||
|
|
||||||
|
- name: List all dependencies
|
||||||
|
run: |
|
||||||
|
echo "## Dependency List" >> $GITHUB_STEP_SUMMARY
|
||||||
|
npm list --all || true
|
||||||
|
|
||||||
|
code-scanning:
|
||||||
|
name: Code Quality Scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run ESLint with security rules
|
||||||
|
run: npm run lint
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Check for hardcoded secrets (basic)
|
||||||
|
run: |
|
||||||
|
echo "Scanning for potential secrets..."
|
||||||
|
if grep -r -i -E "(password|secret|api[_-]?key|token|credential)" --include="*.ts" --include="*.js" --exclude-dir=node_modules --exclude-dir=dist . | grep -v "process.env" | grep -v "// "; then
|
||||||
|
echo "⚠️ Warning: Potential hardcoded secrets found!"
|
||||||
|
echo "Review the results above and ensure no sensitive data is committed."
|
||||||
|
else
|
||||||
|
echo "✅ No obvious hardcoded secrets detected."
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker-security:
|
||||||
|
name: Docker Image Security
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build Docker images
|
||||||
|
run: docker-compose build
|
||||||
|
|
||||||
|
- name: Scan Docker images for vulnerabilities
|
||||||
|
run: |
|
||||||
|
echo "## Docker Security Scan" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Docker images have been built successfully." >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Consider using tools like Trivy or Snyk for comprehensive vulnerability scanning." >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
security-summary:
|
||||||
|
name: Security Summary
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [dependency-audit, dependency-check, code-scanning]
|
||||||
|
if: always()
|
||||||
|
steps:
|
||||||
|
- name: Generate security summary
|
||||||
|
run: |
|
||||||
|
echo "# 🔒 Security Scan Summary" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "All security scans have been completed." >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "## Scans Performed:" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- ✅ NPM Dependency Audit" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- ✅ Dependency License Check" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- ✅ Code Quality Scanning" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Review individual job logs for detailed results." >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "### Recommended Additional Tools:" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Snyk**: For advanced vulnerability scanning" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Trivy**: For Docker image scanning" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **SonarQube**: For code quality and security analysis" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Dependabot**: For automated dependency updates" >> $GITHUB_STEP_SUMMARY
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -5,10 +5,15 @@ node_modules/
|
|||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
coverage/
|
coverage/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
*.lcov
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
# Production
|
# Production
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
@@ -43,9 +48,11 @@ Thumbs.db
|
|||||||
# Uploads
|
# Uploads
|
||||||
uploads/
|
uploads/
|
||||||
public/uploads/
|
public/uploads/
|
||||||
|
test-uploads/
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
.docker/
|
.docker/
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
# Prisma
|
# Prisma
|
||||||
packages/api/prisma/migrations/
|
packages/api/prisma/migrations/
|
||||||
|
|||||||
621
TESTING.md
Normal file
621
TESTING.md
Normal file
@@ -0,0 +1,621 @@
|
|||||||
|
# Testing Documentation
|
||||||
|
|
||||||
|
This document provides comprehensive guidance on testing in the Basil project, including unit tests, integration tests, E2E tests, and security testing.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [Testing Stack](#testing-stack)
|
||||||
|
3. [Running Tests](#running-tests)
|
||||||
|
4. [Unit Testing](#unit-testing)
|
||||||
|
5. [Integration Testing](#integration-testing)
|
||||||
|
6. [E2E Testing](#e2e-testing)
|
||||||
|
7. [Security Testing](#security-testing)
|
||||||
|
8. [CI/CD Integration](#cicd-integration)
|
||||||
|
9. [Writing Tests](#writing-tests)
|
||||||
|
10. [Best Practices](#best-practices)
|
||||||
|
11. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Basil project uses a comprehensive testing strategy covering all layers:
|
||||||
|
|
||||||
|
- **Unit Tests**: Test individual functions and components in isolation
|
||||||
|
- **Integration Tests**: Test API endpoints and database interactions
|
||||||
|
- **E2E Tests**: Test complete user workflows across the full stack
|
||||||
|
- **Security Tests**: Scan for vulnerabilities and security issues
|
||||||
|
|
||||||
|
## Testing Stack
|
||||||
|
|
||||||
|
### Unit & Integration Tests
|
||||||
|
- **Vitest**: Fast unit testing framework with TypeScript support
|
||||||
|
- **@testing-library/react**: React component testing utilities
|
||||||
|
- **@testing-library/jest-dom**: Custom Jest matchers for DOM assertions
|
||||||
|
- **Supertest**: HTTP assertion library for API testing
|
||||||
|
|
||||||
|
### E2E Tests
|
||||||
|
- **Playwright**: Cross-browser end-to-end testing framework
|
||||||
|
- Supports Chrome, Firefox, Safari, and mobile browsers
|
||||||
|
|
||||||
|
### Security Testing
|
||||||
|
- **npm audit**: Built-in npm vulnerability scanner
|
||||||
|
- ESLint with security rules
|
||||||
|
- Docker image scanning
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Run All Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all unit tests across all packages
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
npm run test:coverage
|
||||||
|
|
||||||
|
# Run tests in watch mode (development)
|
||||||
|
npm run test:watch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Package-Specific Tests
|
||||||
|
|
||||||
|
#### API Package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/api
|
||||||
|
|
||||||
|
# Run unit tests
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
npm run test:coverage
|
||||||
|
|
||||||
|
# Run in watch mode
|
||||||
|
npm run test:watch
|
||||||
|
|
||||||
|
# Run with UI
|
||||||
|
npm run test:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Web Package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/web
|
||||||
|
|
||||||
|
# Run component tests
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
npm run test:coverage
|
||||||
|
|
||||||
|
# Run in watch mode
|
||||||
|
npm run test:watch
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Shared Package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/shared
|
||||||
|
|
||||||
|
# Run type validation tests
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all E2E tests (headless)
|
||||||
|
npm run test:e2e
|
||||||
|
|
||||||
|
# Run with UI mode (interactive)
|
||||||
|
npm run test:e2e:ui
|
||||||
|
|
||||||
|
# Run in headed mode (see browser)
|
||||||
|
npm run test:e2e:headed
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
npx playwright test e2e/recipes.spec.ts
|
||||||
|
|
||||||
|
# Run specific browser
|
||||||
|
npx playwright test --project=chromium
|
||||||
|
|
||||||
|
# Run mobile tests only
|
||||||
|
npx playwright test --project="Mobile Chrome"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Unit Testing
|
||||||
|
|
||||||
|
### API Unit Tests
|
||||||
|
|
||||||
|
Located in `packages/api/src/**/*.test.ts`
|
||||||
|
|
||||||
|
**Example: Testing a Service**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { StorageService } from './storage.service';
|
||||||
|
|
||||||
|
describe('StorageService', () => {
|
||||||
|
it('should save file locally', async () => {
|
||||||
|
const service = StorageService.getInstance();
|
||||||
|
const mockFile = createMockFile();
|
||||||
|
|
||||||
|
const result = await service.saveFile(mockFile, 'recipes');
|
||||||
|
|
||||||
|
expect(result).toMatch(/^\/uploads\/recipes\//);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Running API Tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/api
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
npm test -- storage.service.test.ts
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web Unit Tests
|
||||||
|
|
||||||
|
Located in `packages/web/src/**/*.test.{ts,tsx}`
|
||||||
|
|
||||||
|
**Example: Testing a React Component**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import RecipeList from './RecipeList';
|
||||||
|
|
||||||
|
describe('RecipeList', () => {
|
||||||
|
it('should display recipes', async () => {
|
||||||
|
render(<RecipeList />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Recipe Title')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Testing
|
||||||
|
|
||||||
|
Integration tests verify API endpoints and database interactions.
|
||||||
|
|
||||||
|
### API Integration Tests
|
||||||
|
|
||||||
|
Located in `packages/api/src/routes/**/*.test.ts`
|
||||||
|
|
||||||
|
**Example: Testing an API Endpoint**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import request from 'supertest';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import app from '../index';
|
||||||
|
|
||||||
|
describe('GET /api/recipes', () => {
|
||||||
|
it('should return paginated recipes', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/api/recipes')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('data');
|
||||||
|
expect(response.body).toHaveProperty('total');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Testing
|
||||||
|
|
||||||
|
Integration tests use a test database. Configure in `.env.test`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql://basil:basil@localhost:5432/basil_test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Setup Test Database**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/api
|
||||||
|
|
||||||
|
# Run migrations on test database
|
||||||
|
DATABASE_URL="postgresql://basil:basil@localhost:5432/basil_test" npm run prisma:migrate
|
||||||
|
|
||||||
|
# Seed test data (if needed)
|
||||||
|
DATABASE_URL="postgresql://basil:basil@localhost:5432/basil_test" npm run prisma:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
## E2E Testing
|
||||||
|
|
||||||
|
E2E tests validate complete user workflows using Playwright.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Playwright configuration: `playwright.config.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
url: 'http://localhost:5173',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Writing E2E Tests
|
||||||
|
|
||||||
|
Located in `e2e/**/*.spec.ts`
|
||||||
|
|
||||||
|
**Example: Testing Recipe Workflow**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('should create and view recipe', async ({ page }) => {
|
||||||
|
// Navigate to app
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Click create button
|
||||||
|
await page.click('button:has-text("Create Recipe")');
|
||||||
|
|
||||||
|
// Fill form
|
||||||
|
await page.fill('input[name="title"]', 'Test Recipe');
|
||||||
|
await page.fill('textarea[name="description"]', 'Test Description');
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
// Verify recipe was created
|
||||||
|
await expect(page.locator('h1')).toContainText('Test Recipe');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E Test Best Practices
|
||||||
|
|
||||||
|
1. **Use data-testid attributes** for reliable selectors
|
||||||
|
2. **Mock external APIs** to avoid flaky tests
|
||||||
|
3. **Clean up test data** after each test
|
||||||
|
4. **Use page object pattern** for complex pages
|
||||||
|
5. **Test critical user paths** first
|
||||||
|
|
||||||
|
### Debugging E2E Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run with headed browser
|
||||||
|
npm run test:e2e:headed
|
||||||
|
|
||||||
|
# Run with UI mode (interactive debugging)
|
||||||
|
npm run test:e2e:ui
|
||||||
|
|
||||||
|
# Generate trace for failed tests
|
||||||
|
npx playwright test --trace on
|
||||||
|
|
||||||
|
# View trace
|
||||||
|
npx playwright show-trace trace.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Testing
|
||||||
|
|
||||||
|
### NPM Audit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run security audit
|
||||||
|
npm audit
|
||||||
|
|
||||||
|
# Fix automatically fixable vulnerabilities
|
||||||
|
npm audit fix
|
||||||
|
|
||||||
|
# Audit specific package
|
||||||
|
cd packages/api && npm audit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependency Scanning
|
||||||
|
|
||||||
|
Monitor for outdated and vulnerable dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check for outdated packages
|
||||||
|
npm outdated
|
||||||
|
|
||||||
|
# Update dependencies
|
||||||
|
npm update
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Scanning
|
||||||
|
|
||||||
|
ESLint is configured with security rules:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run linter
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
# Fix auto-fixable issues
|
||||||
|
npm run lint -- --fix
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Security
|
||||||
|
|
||||||
|
Scan Docker images for vulnerabilities:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build images
|
||||||
|
docker-compose build
|
||||||
|
|
||||||
|
# Optional: Use Trivy for scanning
|
||||||
|
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
aquasec/trivy image basil-api:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
|
||||||
|
### Gitea Actions Workflows
|
||||||
|
|
||||||
|
The project includes comprehensive CI/CD workflows in `.gitea/workflows/`:
|
||||||
|
|
||||||
|
#### 1. CI Pipeline (`ci.yml`)
|
||||||
|
|
||||||
|
Runs on every push and PR:
|
||||||
|
- Linting
|
||||||
|
- Unit tests for all packages
|
||||||
|
- Build verification
|
||||||
|
- Coverage reporting
|
||||||
|
|
||||||
|
#### 2. E2E Tests (`e2e.yml`)
|
||||||
|
|
||||||
|
Runs on main branch and nightly:
|
||||||
|
- Full E2E test suite
|
||||||
|
- Mobile browser testing
|
||||||
|
- Screenshot and trace artifacts
|
||||||
|
|
||||||
|
#### 3. Security Scanning (`security.yml`)
|
||||||
|
|
||||||
|
Runs weekly and on main branch:
|
||||||
|
- NPM audit
|
||||||
|
- Dependency checking
|
||||||
|
- Code quality scanning
|
||||||
|
- Docker image security
|
||||||
|
|
||||||
|
#### 4. Docker Build & Deploy (`docker.yml`)
|
||||||
|
|
||||||
|
Runs on main branch and version tags:
|
||||||
|
- Build Docker images
|
||||||
|
- Push to registry
|
||||||
|
- Deploy to staging/production
|
||||||
|
|
||||||
|
### Setting Up Gitea Actions
|
||||||
|
|
||||||
|
1. **Enable Gitea Actions** in your Gitea instance settings
|
||||||
|
2. **Configure Runners** (act_runner or Gitea Actions runner)
|
||||||
|
3. **Set Repository Secrets**:
|
||||||
|
```
|
||||||
|
DOCKER_REGISTRY=registry.example.com
|
||||||
|
DOCKER_USERNAME=your-username
|
||||||
|
DOCKER_PASSWORD=your-password
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Verify Workflows**: Push to trigger pipelines
|
||||||
|
|
||||||
|
### CI Environment Variables
|
||||||
|
|
||||||
|
Configure these in your Gitea repository settings:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database (for CI)
|
||||||
|
DATABASE_URL=postgresql://basil:basil@localhost:5432/basil_test
|
||||||
|
|
||||||
|
# Docker Registry
|
||||||
|
DOCKER_REGISTRY=your-registry.com
|
||||||
|
DOCKER_USERNAME=your-username
|
||||||
|
DOCKER_PASSWORD=your-token
|
||||||
|
|
||||||
|
# Optional: External Services
|
||||||
|
SENTRY_DSN=your-sentry-dsn
|
||||||
|
ANALYTICS_KEY=your-analytics-key
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing Tests
|
||||||
|
|
||||||
|
### General Guidelines
|
||||||
|
|
||||||
|
1. **Follow AAA Pattern**: Arrange, Act, Assert
|
||||||
|
```typescript
|
||||||
|
it('should do something', () => {
|
||||||
|
// Arrange
|
||||||
|
const input = setupTestData();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = functionUnderTest(input);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Use Descriptive Names**: Test names should describe behavior
|
||||||
|
```typescript
|
||||||
|
// Good
|
||||||
|
it('should return 404 when recipe not found')
|
||||||
|
|
||||||
|
// Bad
|
||||||
|
it('test recipe endpoint')
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Test One Thing**: Each test should verify one specific behavior
|
||||||
|
|
||||||
|
4. **Mock External Dependencies**: Don't rely on external services
|
||||||
|
```typescript
|
||||||
|
vi.mock('axios');
|
||||||
|
vi.mocked(axios.get).mockResolvedValue({ data: mockData });
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Clean Up**: Reset mocks and state after each test
|
||||||
|
```typescript
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/
|
||||||
|
├── api/
|
||||||
|
│ └── src/
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── storage.service.ts
|
||||||
|
│ │ └── storage.service.test.ts
|
||||||
|
│ └── routes/
|
||||||
|
│ ├── recipes.routes.ts
|
||||||
|
│ └── recipes.routes.test.ts
|
||||||
|
├── web/
|
||||||
|
│ └── src/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── RecipeCard.tsx
|
||||||
|
│ │ └── RecipeCard.test.tsx
|
||||||
|
│ └── services/
|
||||||
|
│ ├── api.ts
|
||||||
|
│ └── api.test.ts
|
||||||
|
└── shared/
|
||||||
|
└── src/
|
||||||
|
├── types.ts
|
||||||
|
└── types.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
✅ **Do:**
|
||||||
|
- Test pure functions and logic
|
||||||
|
- Mock external dependencies
|
||||||
|
- Use factory functions for test data
|
||||||
|
- Test edge cases and error conditions
|
||||||
|
- Keep tests fast (< 50ms per test)
|
||||||
|
|
||||||
|
❌ **Don't:**
|
||||||
|
- Test implementation details
|
||||||
|
- Make network requests
|
||||||
|
- Access real databases
|
||||||
|
- Test framework code
|
||||||
|
- Write brittle tests tied to DOM structure
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
✅ **Do:**
|
||||||
|
- Test API endpoints end-to-end
|
||||||
|
- Use test database with migrations
|
||||||
|
- Test authentication and authorization
|
||||||
|
- Verify database state after operations
|
||||||
|
- Test error responses
|
||||||
|
|
||||||
|
❌ **Don't:**
|
||||||
|
- Test external APIs directly
|
||||||
|
- Share state between tests
|
||||||
|
- Skip cleanup after tests
|
||||||
|
- Hard-code test data IDs
|
||||||
|
|
||||||
|
### E2E Tests
|
||||||
|
|
||||||
|
✅ **Do:**
|
||||||
|
- Test critical user workflows
|
||||||
|
- Use stable selectors (data-testid)
|
||||||
|
- Mock external APIs
|
||||||
|
- Run in CI pipeline
|
||||||
|
- Take screenshots on failure
|
||||||
|
|
||||||
|
❌ **Don't:**
|
||||||
|
- Test every possible path (use unit tests)
|
||||||
|
- Rely on timing (use waitFor)
|
||||||
|
- Share test data between tests
|
||||||
|
- Test implementation details
|
||||||
|
- Make tests too granular
|
||||||
|
|
||||||
|
## Coverage Goals
|
||||||
|
|
||||||
|
Aim for the following coverage targets:
|
||||||
|
|
||||||
|
- **Unit Tests**: 80%+ coverage
|
||||||
|
- **Integration Tests**: Cover all API endpoints
|
||||||
|
- **E2E Tests**: Cover critical user paths
|
||||||
|
|
||||||
|
Check coverage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API Package
|
||||||
|
cd packages/api && npm run test:coverage
|
||||||
|
|
||||||
|
# Web Package
|
||||||
|
cd packages/web && npm run test:coverage
|
||||||
|
|
||||||
|
# View HTML coverage report
|
||||||
|
open packages/api/coverage/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
#### Tests Failing Locally
|
||||||
|
|
||||||
|
1. **Database connection errors**
|
||||||
|
```bash
|
||||||
|
# Ensure PostgreSQL is running
|
||||||
|
docker-compose up -d postgres
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
cd packages/api && npm run prisma:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Module resolution errors**
|
||||||
|
```bash
|
||||||
|
# Clear node_modules and reinstall
|
||||||
|
rm -rf node_modules package-lock.json
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Port already in use**
|
||||||
|
```bash
|
||||||
|
# Kill process on port 3001
|
||||||
|
lsof -ti:3001 | xargs kill -9
|
||||||
|
```
|
||||||
|
|
||||||
|
#### E2E Tests Timing Out
|
||||||
|
|
||||||
|
1. **Increase timeout** in playwright.config.ts
|
||||||
|
2. **Check webServer** is starting correctly
|
||||||
|
3. **Verify database** migrations ran successfully
|
||||||
|
|
||||||
|
#### CI Tests Failing
|
||||||
|
|
||||||
|
1. **Check logs** in Gitea Actions UI
|
||||||
|
2. **Verify secrets** are configured correctly
|
||||||
|
3. **Run tests locally** with same Node version
|
||||||
|
4. **Check database** service is healthy
|
||||||
|
|
||||||
|
### Getting Help
|
||||||
|
|
||||||
|
- Review test logs and error messages
|
||||||
|
- Check existing tests for examples
|
||||||
|
- Consult Vitest docs: https://vitest.dev
|
||||||
|
- Consult Playwright docs: https://playwright.dev
|
||||||
|
|
||||||
|
## Continuous Improvement
|
||||||
|
|
||||||
|
Testing is an ongoing process. Regularly:
|
||||||
|
|
||||||
|
1. **Review test coverage** and add tests for uncovered code
|
||||||
|
2. **Refactor flaky tests** to be more reliable
|
||||||
|
3. **Update tests** when requirements change
|
||||||
|
4. **Add tests** for bug fixes to prevent regressions
|
||||||
|
5. **Monitor CI pipeline** performance and optimize slow tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: 2025-10-24
|
||||||
|
**Maintained By**: Basil Development Team
|
||||||
241
e2e/recipes.spec.ts
Normal file
241
e2e/recipes.spec.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Recipe Management E2E Tests', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Navigate to the app before each test
|
||||||
|
await page.goto('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display the recipe list page', async ({ page }) => {
|
||||||
|
// Wait for the page to load
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Check that we're on the recipe list page
|
||||||
|
await expect(page.locator('h2')).toContainText('My Recipes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show empty state when no recipes exist', async ({ page }) => {
|
||||||
|
// Mock API to return empty recipe list
|
||||||
|
await page.route('**/api/recipes', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Check for empty state message
|
||||||
|
await expect(page.getByText(/No recipes yet/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display recipes when available', async ({ page }) => {
|
||||||
|
// Mock API to return recipes
|
||||||
|
await page.route('**/api/recipes', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Spaghetti Carbonara',
|
||||||
|
description: 'Classic Italian pasta',
|
||||||
|
totalTime: 30,
|
||||||
|
servings: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Chocolate Cake',
|
||||||
|
description: 'Rich chocolate dessert',
|
||||||
|
totalTime: 60,
|
||||||
|
servings: 8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Check that recipes are displayed
|
||||||
|
await expect(page.getByText('Spaghetti Carbonara')).toBeVisible();
|
||||||
|
await expect(page.getByText('Chocolate Cake')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to recipe detail when clicking a recipe', async ({ page }) => {
|
||||||
|
// Mock recipes list
|
||||||
|
await page.route('**/api/recipes', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Test Recipe',
|
||||||
|
description: 'Test Description',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock recipe detail
|
||||||
|
await page.route('**/api/recipes/1', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: {
|
||||||
|
id: '1',
|
||||||
|
title: 'Test Recipe',
|
||||||
|
description: 'Detailed description',
|
||||||
|
ingredients: [
|
||||||
|
{ id: '1', name: 'Flour', amount: '2', unit: 'cups', order: 0 },
|
||||||
|
],
|
||||||
|
instructions: [
|
||||||
|
{ id: '1', step: 1, text: 'Mix ingredients' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Click on the recipe
|
||||||
|
await page.getByText('Test Recipe').click();
|
||||||
|
|
||||||
|
// Verify we navigated to the detail page
|
||||||
|
await expect(page).toHaveURL(/\/recipes\/1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should import recipe from URL', async ({ page }) => {
|
||||||
|
// Mock import endpoint
|
||||||
|
await page.route('**/api/recipes/import', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
recipe: {
|
||||||
|
title: 'Imported Recipe',
|
||||||
|
description: 'Recipe from URL',
|
||||||
|
ingredients: [],
|
||||||
|
instructions: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate to import page (adjust based on your routing)
|
||||||
|
await page.goto('/import');
|
||||||
|
|
||||||
|
// Fill in URL
|
||||||
|
await page.fill('input[type="url"]', 'https://example.com/recipe');
|
||||||
|
|
||||||
|
// Click import button
|
||||||
|
await page.click('button:has-text("Import")');
|
||||||
|
|
||||||
|
// Wait for success message or redirect
|
||||||
|
await expect(page.getByText(/Imported Recipe|Success/i)).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle API errors gracefully', async ({ page }) => {
|
||||||
|
// Mock API to return error
|
||||||
|
await page.route('**/api/recipes', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
error: 'Internal server error',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Check for error message
|
||||||
|
await expect(page.getByText(/Failed to load recipes|error/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be responsive on mobile devices', async ({ page }) => {
|
||||||
|
// Set mobile viewport
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
|
||||||
|
// Mock API response
|
||||||
|
await page.route('**/api/recipes', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Mobile Recipe',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Verify content is visible on mobile
|
||||||
|
await expect(page.getByText('Mobile Recipe')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Recipe Search and Filter', () => {
|
||||||
|
test('should filter recipes by search term', async ({ page }) => {
|
||||||
|
// This test assumes there's a search functionality
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Wait for search input to be available
|
||||||
|
const searchInput = page.locator('input[type="search"], input[placeholder*="Search"]');
|
||||||
|
|
||||||
|
if (await searchInput.count() > 0) {
|
||||||
|
await searchInput.fill('pasta');
|
||||||
|
|
||||||
|
// Mock filtered results
|
||||||
|
await page.route('**/api/recipes?*search=pasta*', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Pasta Carbonara',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify filtered results
|
||||||
|
await expect(page.getByText('Pasta Carbonara')).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
"dev": "npm run dev --workspaces --if-present",
|
"dev": "npm run dev --workspaces --if-present",
|
||||||
"build": "npm run build --workspaces --if-present",
|
"build": "npm run build --workspaces --if-present",
|
||||||
"test": "npm run test --workspaces --if-present",
|
"test": "npm run test --workspaces --if-present",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:e2e:headed": "playwright test --headed",
|
||||||
"lint": "npm run lint --workspaces --if-present",
|
"lint": "npm run lint --workspaces --if-present",
|
||||||
"docker:up": "docker-compose up -d",
|
"docker:up": "docker-compose up -d",
|
||||||
"docker:down": "docker-compose down",
|
"docker:down": "docker-compose down",
|
||||||
@@ -17,5 +20,8 @@
|
|||||||
},
|
},
|
||||||
"keywords": ["recipe", "cooking", "food", "manager"],
|
"keywords": ["recipe", "cooking", "food", "manager"],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.41.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
packages/api/.env.test
Normal file
16
packages/api/.env.test
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Test Environment Configuration
|
||||||
|
NODE_ENV=test
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# Test Database
|
||||||
|
DATABASE_URL=postgresql://basil:basil@localhost:5432/basil_test
|
||||||
|
|
||||||
|
# Storage Configuration (use local for tests)
|
||||||
|
STORAGE_TYPE=local
|
||||||
|
LOCAL_STORAGE_PATH=./test-uploads
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGIN=http://localhost:5173
|
||||||
|
|
||||||
|
# Disable external services in tests
|
||||||
|
DISABLE_EXTERNAL_SERVICES=true
|
||||||
@@ -7,6 +7,10 @@
|
|||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:studio": "prisma studio",
|
"prisma:studio": "prisma studio",
|
||||||
@@ -30,11 +34,16 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/multer": "^1.4.11",
|
"@types/multer": "^1.4.11",
|
||||||
"@types/node": "^20.10.6",
|
"@types/node": "^20.10.6",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
"prisma": "^5.8.0",
|
"prisma": "^5.8.0",
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||||
"@typescript-eslint/parser": "^6.17.0"
|
"@typescript-eslint/parser": "^6.17.0",
|
||||||
|
"vitest": "^1.2.0",
|
||||||
|
"@vitest/ui": "^1.2.0",
|
||||||
|
"@vitest/coverage-v8": "^1.2.0",
|
||||||
|
"supertest": "^6.3.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
224
packages/api/src/routes/recipes.routes.test.ts
Normal file
224
packages/api/src/routes/recipes.routes.test.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
import express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import recipesRouter from './recipes.routes';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('../config/database', () => ({
|
||||||
|
default: {
|
||||||
|
recipe: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
count: vi.fn(),
|
||||||
|
},
|
||||||
|
recipeImage: {
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
ingredient: {
|
||||||
|
deleteMany: vi.fn(),
|
||||||
|
},
|
||||||
|
instruction: {
|
||||||
|
deleteMany: vi.fn(),
|
||||||
|
},
|
||||||
|
recipeTag: {
|
||||||
|
deleteMany: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/storage.service', () => ({
|
||||||
|
StorageService: {
|
||||||
|
getInstance: vi.fn(() => ({
|
||||||
|
saveFile: vi.fn().mockResolvedValue('/uploads/recipes/test-image.jpg'),
|
||||||
|
deleteFile: vi.fn().mockResolvedValue(undefined),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/scraper.service', () => ({
|
||||||
|
ScraperService: vi.fn(() => ({
|
||||||
|
scrapeRecipe: vi.fn().mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
recipe: {
|
||||||
|
title: 'Scraped Recipe',
|
||||||
|
description: 'A scraped recipe',
|
||||||
|
sourceUrl: 'https://example.com/recipe',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Recipes Routes - Integration Tests', () => {
|
||||||
|
let app: express.Application;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/recipes', recipesRouter);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /recipes', () => {
|
||||||
|
it('should return paginated recipes', async () => {
|
||||||
|
const mockRecipes = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Recipe 1',
|
||||||
|
description: 'Description 1',
|
||||||
|
ingredients: [],
|
||||||
|
instructions: [],
|
||||||
|
images: [],
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const prisma = await import('../config/database');
|
||||||
|
vi.mocked(prisma.default.recipe.findMany).mockResolvedValue(mockRecipes as any);
|
||||||
|
vi.mocked(prisma.default.recipe.count).mockResolvedValue(1);
|
||||||
|
|
||||||
|
const response = await request(app).get('/recipes').expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('data');
|
||||||
|
expect(response.body).toHaveProperty('total', 1);
|
||||||
|
expect(response.body).toHaveProperty('page', 1);
|
||||||
|
expect(response.body.data).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support search query parameter', async () => {
|
||||||
|
const prisma = await import('../config/database');
|
||||||
|
vi.mocked(prisma.default.recipe.findMany).mockResolvedValue([]);
|
||||||
|
vi.mocked(prisma.default.recipe.count).mockResolvedValue(0);
|
||||||
|
|
||||||
|
await request(app).get('/recipes?search=pasta').expect(200);
|
||||||
|
|
||||||
|
expect(prisma.default.recipe.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
OR: expect.any(Array),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support pagination parameters', async () => {
|
||||||
|
const prisma = await import('../config/database');
|
||||||
|
vi.mocked(prisma.default.recipe.findMany).mockResolvedValue([]);
|
||||||
|
vi.mocked(prisma.default.recipe.count).mockResolvedValue(0);
|
||||||
|
|
||||||
|
await request(app).get('/recipes?page=2&limit=10').expect(200);
|
||||||
|
|
||||||
|
expect(prisma.default.recipe.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
skip: 10,
|
||||||
|
take: 10,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /recipes/:id', () => {
|
||||||
|
it('should return single recipe by id', async () => {
|
||||||
|
const mockRecipe = {
|
||||||
|
id: '1',
|
||||||
|
title: 'Test Recipe',
|
||||||
|
description: 'Test Description',
|
||||||
|
ingredients: [],
|
||||||
|
instructions: [],
|
||||||
|
images: [],
|
||||||
|
tags: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const prisma = await import('../config/database');
|
||||||
|
vi.mocked(prisma.default.recipe.findUnique).mockResolvedValue(mockRecipe as any);
|
||||||
|
|
||||||
|
const response = await request(app).get('/recipes/1').expect(200);
|
||||||
|
|
||||||
|
expect(response.body.data).toHaveProperty('title', 'Test Recipe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 when recipe not found', async () => {
|
||||||
|
const prisma = await import('../config/database');
|
||||||
|
vi.mocked(prisma.default.recipe.findUnique).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app).get('/recipes/nonexistent').expect(404);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('error', 'Recipe not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /recipes', () => {
|
||||||
|
it('should create new recipe', async () => {
|
||||||
|
const newRecipe = {
|
||||||
|
title: 'New Recipe',
|
||||||
|
description: 'New Description',
|
||||||
|
ingredients: [{ name: 'Flour', amount: '2 cups' }],
|
||||||
|
instructions: [{ step: 1, text: 'Mix ingredients' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCreatedRecipe = {
|
||||||
|
id: '1',
|
||||||
|
...newRecipe,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const prisma = await import('../config/database');
|
||||||
|
vi.mocked(prisma.default.recipe.create).mockResolvedValue(mockCreatedRecipe as any);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/recipes')
|
||||||
|
.send(newRecipe)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(response.body.data).toHaveProperty('title', 'New Recipe');
|
||||||
|
expect(prisma.default.recipe.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /recipes/import', () => {
|
||||||
|
it('should import recipe from URL', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/recipes/import')
|
||||||
|
.send({ url: 'https://example.com/recipe' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.success).toBe(true);
|
||||||
|
expect(response.body.recipe).toHaveProperty('title', 'Scraped Recipe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 when URL is missing', async () => {
|
||||||
|
const response = await request(app).post('/recipes/import').send({}).expect(400);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('error', 'URL is required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /recipes/:id', () => {
|
||||||
|
it('should delete recipe and associated images', async () => {
|
||||||
|
const mockRecipe = {
|
||||||
|
id: '1',
|
||||||
|
title: 'Recipe to Delete',
|
||||||
|
imageUrl: '/uploads/recipes/main.jpg',
|
||||||
|
images: [{ url: '/uploads/recipes/image1.jpg' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const prisma = await import('../config/database');
|
||||||
|
vi.mocked(prisma.default.recipe.findUnique).mockResolvedValue(mockRecipe as any);
|
||||||
|
vi.mocked(prisma.default.recipe.delete).mockResolvedValue(mockRecipe as any);
|
||||||
|
|
||||||
|
const response = await request(app).delete('/recipes/1').expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('message', 'Recipe deleted successfully');
|
||||||
|
expect(prisma.default.recipe.delete).toHaveBeenCalledWith({
|
||||||
|
where: { id: '1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
138
packages/api/src/services/scraper.service.test.ts
Normal file
138
packages/api/src/services/scraper.service.test.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { ScraperService } from './scraper.service';
|
||||||
|
|
||||||
|
vi.mock('axios');
|
||||||
|
|
||||||
|
describe('ScraperService', () => {
|
||||||
|
let scraperService: ScraperService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
scraperService = new ScraperService();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scrapeRecipe', () => {
|
||||||
|
it('should extract recipe from schema.org JSON-LD', async () => {
|
||||||
|
const mockHtml = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@type": "Recipe",
|
||||||
|
"name": "Test Recipe",
|
||||||
|
"description": "A delicious test recipe",
|
||||||
|
"prepTime": "PT15M",
|
||||||
|
"cookTime": "PT30M",
|
||||||
|
"totalTime": "PT45M",
|
||||||
|
"recipeYield": "4",
|
||||||
|
"recipeIngredient": ["2 cups flour", "1 cup sugar"],
|
||||||
|
"recipeInstructions": [
|
||||||
|
{"text": "Mix ingredients"},
|
||||||
|
{"text": "Bake for 30 minutes"}
|
||||||
|
],
|
||||||
|
"author": {"name": "Chef Test"},
|
||||||
|
"recipeCuisine": "Italian",
|
||||||
|
"recipeCategory": "Dessert"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body></body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
vi.mocked(axios.get).mockResolvedValue({ data: mockHtml });
|
||||||
|
|
||||||
|
const result = await scraperService.scrapeRecipe('https://example.com/recipe');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.recipe?.title).toBe('Test Recipe');
|
||||||
|
expect(result.recipe?.description).toBe('A delicious test recipe');
|
||||||
|
expect(result.recipe?.prepTime).toBe(15);
|
||||||
|
expect(result.recipe?.cookTime).toBe(30);
|
||||||
|
expect(result.recipe?.totalTime).toBe(45);
|
||||||
|
expect(result.recipe?.servings).toBe(4);
|
||||||
|
expect(result.recipe?.ingredients).toHaveLength(2);
|
||||||
|
expect(result.recipe?.instructions).toHaveLength(2);
|
||||||
|
expect(result.recipe?.sourceUrl).toBe('https://example.com/recipe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to manual parsing when no schema.org found', async () => {
|
||||||
|
const mockHtml = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Test Recipe Page</title>
|
||||||
|
<meta name="description" content="Test description">
|
||||||
|
<meta property="og:image" content="https://example.com/image.jpg">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Fallback Recipe</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
vi.mocked(axios.get).mockResolvedValue({ data: mockHtml });
|
||||||
|
|
||||||
|
const result = await scraperService.scrapeRecipe('https://example.com/recipe');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.recipe?.title).toBe('Fallback Recipe');
|
||||||
|
expect(result.recipe?.description).toBe('Test description');
|
||||||
|
expect(result.recipe?.imageUrl).toBe('https://example.com/image.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle errors gracefully', async () => {
|
||||||
|
vi.mocked(axios.get).mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
const result = await scraperService.scrapeRecipe('https://example.com/recipe');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('Network error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse ISO 8601 duration correctly', async () => {
|
||||||
|
const mockHtml = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@type": "Recipe",
|
||||||
|
"name": "Duration Test",
|
||||||
|
"prepTime": "PT1H30M",
|
||||||
|
"cookTime": "PT45M"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
vi.mocked(axios.get).mockResolvedValue({ data: mockHtml });
|
||||||
|
|
||||||
|
const result = await scraperService.scrapeRecipe('https://example.com/recipe');
|
||||||
|
|
||||||
|
expect(result.recipe?.prepTime).toBe(90); // 1 hour 30 minutes
|
||||||
|
expect(result.recipe?.cookTime).toBe(45); // 45 minutes
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('downloadImage', () => {
|
||||||
|
it('should download image and return buffer', async () => {
|
||||||
|
const mockImageData = Buffer.from('fake-image-data');
|
||||||
|
vi.mocked(axios.get).mockResolvedValue({ data: mockImageData });
|
||||||
|
|
||||||
|
const result = await scraperService.downloadImage('https://example.com/image.jpg');
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledWith(
|
||||||
|
'https://example.com/image.jpg',
|
||||||
|
expect.objectContaining({
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result).toBeInstanceOf(Buffer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
96
packages/api/src/services/storage.service.test.ts
Normal file
96
packages/api/src/services/storage.service.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { StorageService } from './storage.service';
|
||||||
|
|
||||||
|
// Mock fs/promises
|
||||||
|
vi.mock('fs/promises');
|
||||||
|
vi.mock('../config/storage', () => ({
|
||||||
|
storageConfig: {
|
||||||
|
type: 'local',
|
||||||
|
localPath: './test-uploads',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('StorageService', () => {
|
||||||
|
let storageService: StorageService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
storageService = StorageService.getInstance();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getInstance', () => {
|
||||||
|
it('should return singleton instance', () => {
|
||||||
|
const instance1 = StorageService.getInstance();
|
||||||
|
const instance2 = StorageService.getInstance();
|
||||||
|
expect(instance1).toBe(instance2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saveFile', () => {
|
||||||
|
it('should save file locally when storage type is local', async () => {
|
||||||
|
const mockFile = {
|
||||||
|
originalname: 'test-image.jpg',
|
||||||
|
buffer: Buffer.from('test-content'),
|
||||||
|
fieldname: 'image',
|
||||||
|
encoding: '7bit',
|
||||||
|
mimetype: 'image/jpeg',
|
||||||
|
size: 12,
|
||||||
|
} as Express.Multer.File;
|
||||||
|
|
||||||
|
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await storageService.saveFile(mockFile, 'recipes');
|
||||||
|
|
||||||
|
expect(fs.mkdir).toHaveBeenCalled();
|
||||||
|
expect(fs.writeFile).toHaveBeenCalled();
|
||||||
|
expect(result).toMatch(/^\/uploads\/recipes\/\d+-test-image\.jpg$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for S3 storage (not implemented)', async () => {
|
||||||
|
const mockFile = {
|
||||||
|
originalname: 'test-image.jpg',
|
||||||
|
buffer: Buffer.from('test-content'),
|
||||||
|
} as Express.Multer.File;
|
||||||
|
|
||||||
|
// Mock S3 storage type
|
||||||
|
vi.doMock('../config/storage', () => ({
|
||||||
|
storageConfig: {
|
||||||
|
type: 's3',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(storageService.saveFile(mockFile, 'recipes')).rejects.toThrow(
|
||||||
|
'S3 storage not yet implemented'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteFile', () => {
|
||||||
|
it('should delete file from local storage', async () => {
|
||||||
|
const fileUrl = '/uploads/recipes/123-test.jpg';
|
||||||
|
vi.mocked(fs.unlink).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await storageService.deleteFile(fileUrl);
|
||||||
|
|
||||||
|
expect(fs.unlink).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('recipes/123-test.jpg')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle errors when deleting non-existent file', async () => {
|
||||||
|
const fileUrl = '/uploads/recipes/non-existent.jpg';
|
||||||
|
const mockError = new Error('File not found');
|
||||||
|
vi.mocked(fs.unlink).mockRejectedValue(mockError);
|
||||||
|
|
||||||
|
// Should not throw, just log error
|
||||||
|
await expect(storageService.deleteFile(fileUrl)).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
27
packages/api/vitest.config.ts
Normal file
27
packages/api/vitest.config.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html', 'lcov'],
|
||||||
|
exclude: [
|
||||||
|
'node_modules/',
|
||||||
|
'dist/',
|
||||||
|
'prisma/',
|
||||||
|
'**/*.config.ts',
|
||||||
|
'**/*.d.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
16
packages/api/vitest.setup.ts
Normal file
16
packages/api/vitest.setup.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { beforeAll, afterAll } from 'vitest';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
// Load test environment variables
|
||||||
|
dotenv.config({ path: '.env.test' });
|
||||||
|
|
||||||
|
// Global test setup
|
||||||
|
beforeAll(() => {
|
||||||
|
// Setup code before all tests run
|
||||||
|
// e.g., initialize test database, start test server, etc.
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
// Cleanup code after all tests complete
|
||||||
|
// e.g., close database connections, stop test server, etc.
|
||||||
|
});
|
||||||
@@ -6,11 +6,18 @@
|
|||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "tsc --watch"
|
"dev": "tsc --watch",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"keywords": ["basil", "shared", "types"],
|
"keywords": ["basil", "shared", "types"],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3",
|
||||||
|
"vitest": "^1.2.0",
|
||||||
|
"@vitest/ui": "^1.2.0",
|
||||||
|
"@vitest/coverage-v8": "^1.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
263
packages/shared/src/types.test.ts
Normal file
263
packages/shared/src/types.test.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type {
|
||||||
|
Recipe,
|
||||||
|
Ingredient,
|
||||||
|
Instruction,
|
||||||
|
RecipeImportRequest,
|
||||||
|
RecipeImportResponse,
|
||||||
|
ApiResponse,
|
||||||
|
PaginatedResponse,
|
||||||
|
StorageConfig,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
describe('Shared Types', () => {
|
||||||
|
describe('Recipe Type', () => {
|
||||||
|
it('should accept valid recipe object', () => {
|
||||||
|
const recipe: Recipe = {
|
||||||
|
id: '1',
|
||||||
|
title: 'Test Recipe',
|
||||||
|
description: 'A test recipe',
|
||||||
|
ingredients: [],
|
||||||
|
instructions: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(recipe.id).toBe('1');
|
||||||
|
expect(recipe.title).toBe('Test Recipe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow optional fields', () => {
|
||||||
|
const recipe: Recipe = {
|
||||||
|
id: '1',
|
||||||
|
title: 'Minimal Recipe',
|
||||||
|
ingredients: [],
|
||||||
|
instructions: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
prepTime: 15,
|
||||||
|
cookTime: 30,
|
||||||
|
totalTime: 45,
|
||||||
|
servings: 4,
|
||||||
|
cuisine: 'Italian',
|
||||||
|
category: 'Main Course',
|
||||||
|
rating: 4.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(recipe.prepTime).toBe(15);
|
||||||
|
expect(recipe.servings).toBe(4);
|
||||||
|
expect(recipe.rating).toBe(4.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Ingredient Type', () => {
|
||||||
|
it('should accept valid ingredient object', () => {
|
||||||
|
const ingredient: Ingredient = {
|
||||||
|
name: 'Flour',
|
||||||
|
amount: '2',
|
||||||
|
unit: 'cups',
|
||||||
|
order: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(ingredient.name).toBe('Flour');
|
||||||
|
expect(ingredient.amount).toBe('2');
|
||||||
|
expect(ingredient.unit).toBe('cups');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow optional notes', () => {
|
||||||
|
const ingredient: Ingredient = {
|
||||||
|
name: 'Sugar',
|
||||||
|
order: 1,
|
||||||
|
notes: 'Can substitute with honey',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(ingredient.notes).toBe('Can substitute with honey');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Instruction Type', () => {
|
||||||
|
it('should accept valid instruction object', () => {
|
||||||
|
const instruction: Instruction = {
|
||||||
|
step: 1,
|
||||||
|
text: 'Mix all ingredients together',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(instruction.step).toBe(1);
|
||||||
|
expect(instruction.text).toBe('Mix all ingredients together');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow optional imageUrl', () => {
|
||||||
|
const instruction: Instruction = {
|
||||||
|
step: 2,
|
||||||
|
text: 'Bake for 30 minutes',
|
||||||
|
imageUrl: '/uploads/instructions/step2.jpg',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(instruction.imageUrl).toBe('/uploads/instructions/step2.jpg');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RecipeImportRequest Type', () => {
|
||||||
|
it('should accept valid import request', () => {
|
||||||
|
const request: RecipeImportRequest = {
|
||||||
|
url: 'https://example.com/recipe',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(request.url).toBe('https://example.com/recipe');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RecipeImportResponse Type', () => {
|
||||||
|
it('should accept successful import response', () => {
|
||||||
|
const response: RecipeImportResponse = {
|
||||||
|
success: true,
|
||||||
|
recipe: {
|
||||||
|
title: 'Imported Recipe',
|
||||||
|
description: 'A recipe imported from URL',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(response.success).toBe(true);
|
||||||
|
expect(response.recipe.title).toBe('Imported Recipe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept failed import response', () => {
|
||||||
|
const response: RecipeImportResponse = {
|
||||||
|
success: false,
|
||||||
|
recipe: {},
|
||||||
|
error: 'Failed to scrape recipe',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(response.success).toBe(false);
|
||||||
|
expect(response.error).toBe('Failed to scrape recipe');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ApiResponse Type', () => {
|
||||||
|
it('should accept successful response with data', () => {
|
||||||
|
const response: ApiResponse<Recipe> = {
|
||||||
|
data: {
|
||||||
|
id: '1',
|
||||||
|
title: 'Test Recipe',
|
||||||
|
ingredients: [],
|
||||||
|
instructions: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(response.data).toBeDefined();
|
||||||
|
expect(response.data?.title).toBe('Test Recipe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept error response', () => {
|
||||||
|
const response: ApiResponse<Recipe> = {
|
||||||
|
error: 'Recipe not found',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(response.error).toBe('Recipe not found');
|
||||||
|
expect(response.data).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PaginatedResponse Type', () => {
|
||||||
|
it('should accept valid paginated response', () => {
|
||||||
|
const response: PaginatedResponse<Recipe> = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Recipe 1',
|
||||||
|
ingredients: [],
|
||||||
|
instructions: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 100,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(response.data).toHaveLength(1);
|
||||||
|
expect(response.total).toBe(100);
|
||||||
|
expect(response.page).toBe(1);
|
||||||
|
expect(response.pageSize).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StorageConfig Type', () => {
|
||||||
|
it('should accept local storage config', () => {
|
||||||
|
const config: StorageConfig = {
|
||||||
|
type: 'local',
|
||||||
|
localPath: './uploads',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(config.type).toBe('local');
|
||||||
|
expect(config.localPath).toBe('./uploads');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept S3 storage config', () => {
|
||||||
|
const config: StorageConfig = {
|
||||||
|
type: 's3',
|
||||||
|
s3Bucket: 'basil-recipes',
|
||||||
|
s3Region: 'us-east-1',
|
||||||
|
s3AccessKey: 'test-key',
|
||||||
|
s3SecretKey: 'test-secret',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(config.type).toBe('s3');
|
||||||
|
expect(config.s3Bucket).toBe('basil-recipes');
|
||||||
|
expect(config.s3Region).toBe('us-east-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type guard helper functions (useful utilities to test)
|
||||||
|
describe('Type Guard Utilities', () => {
|
||||||
|
const isRecipe = (obj: any): obj is Recipe => {
|
||||||
|
return (
|
||||||
|
typeof obj === 'object' &&
|
||||||
|
obj !== null &&
|
||||||
|
typeof obj.id === 'string' &&
|
||||||
|
typeof obj.title === 'string' &&
|
||||||
|
Array.isArray(obj.ingredients) &&
|
||||||
|
Array.isArray(obj.instructions)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSuccessResponse = <T>(response: ApiResponse<T>): response is { data: T } => {
|
||||||
|
return response.data !== undefined && response.error === undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should validate recipe objects with type guard', () => {
|
||||||
|
const validRecipe = {
|
||||||
|
id: '1',
|
||||||
|
title: 'Test',
|
||||||
|
ingredients: [],
|
||||||
|
instructions: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const invalidRecipe = {
|
||||||
|
id: 1, // wrong type
|
||||||
|
title: 'Test',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isRecipe(validRecipe)).toBe(true);
|
||||||
|
expect(isRecipe(invalidRecipe)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate success responses with type guard', () => {
|
||||||
|
const successResponse: ApiResponse<string> = {
|
||||||
|
data: 'Success',
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorResponse: ApiResponse<string> = {
|
||||||
|
error: 'Failed',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isSuccessResponse(successResponse)).toBe(true);
|
||||||
|
expect(isSuccessResponse(errorResponse)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
25
packages/shared/vitest.config.ts
Normal file
25
packages/shared/vitest.config.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html', 'lcov'],
|
||||||
|
exclude: [
|
||||||
|
'node_modules/',
|
||||||
|
'dist/',
|
||||||
|
'**/*.config.ts',
|
||||||
|
'**/*.d.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -7,6 +7,10 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"lint": "eslint . --ext ts,tsx"
|
"lint": "eslint . --ext ts,tsx"
|
||||||
},
|
},
|
||||||
"keywords": ["basil", "web"],
|
"keywords": ["basil", "web"],
|
||||||
@@ -28,6 +32,13 @@
|
|||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.5",
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vite": "^5.0.10"
|
"vite": "^5.0.10",
|
||||||
|
"vitest": "^1.2.0",
|
||||||
|
"@vitest/ui": "^1.2.0",
|
||||||
|
"@vitest/coverage-v8": "^1.2.0",
|
||||||
|
"@testing-library/react": "^14.1.2",
|
||||||
|
"@testing-library/jest-dom": "^6.2.0",
|
||||||
|
"@testing-library/user-event": "^14.5.2",
|
||||||
|
"jsdom": "^23.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
181
packages/web/src/pages/RecipeList.test.tsx
Normal file
181
packages/web/src/pages/RecipeList.test.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import RecipeList from './RecipeList';
|
||||||
|
import { recipesApi } from '../services/api';
|
||||||
|
|
||||||
|
// Mock the API service
|
||||||
|
vi.mock('../services/api', () => ({
|
||||||
|
recipesApi: {
|
||||||
|
getAll: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock useNavigate
|
||||||
|
const mockNavigate = vi.fn();
|
||||||
|
vi.mock('react-router-dom', async () => {
|
||||||
|
const actual = await vi.importActual('react-router-dom');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigate: () => mockNavigate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RecipeList Component', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderWithRouter = (component: React.ReactElement) => {
|
||||||
|
return render(<BrowserRouter>{component}</BrowserRouter>);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should show loading state initially', () => {
|
||||||
|
vi.mocked(recipesApi.getAll).mockImplementation(
|
||||||
|
() => new Promise(() => {}) // Never resolves
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithRouter(<RecipeList />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Loading recipes...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display recipes after loading', async () => {
|
||||||
|
const mockRecipes = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Spaghetti Carbonara',
|
||||||
|
description: 'Classic Italian pasta dish',
|
||||||
|
totalTime: 30,
|
||||||
|
servings: 4,
|
||||||
|
imageUrl: '/uploads/recipes/pasta.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Chocolate Cake',
|
||||||
|
description: 'Rich and moist chocolate cake',
|
||||||
|
totalTime: 60,
|
||||||
|
servings: 8,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
||||||
|
data: mockRecipes as any,
|
||||||
|
total: 2,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithRouter(<RecipeList />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Spaghetti Carbonara')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Chocolate Cake')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display empty state when no recipes', async () => {
|
||||||
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithRouter(<RecipeList />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText(/No recipes yet. Import one from a URL or create your own!/)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display error message on API failure', async () => {
|
||||||
|
vi.mocked(recipesApi.getAll).mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
renderWithRouter(<RecipeList />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Failed to load recipes')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should navigate to recipe detail when card is clicked', async () => {
|
||||||
|
const mockRecipes = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Test Recipe',
|
||||||
|
description: 'Test Description',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
||||||
|
data: mockRecipes as any,
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithRouter(<RecipeList />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Test Recipe')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipeCard = screen.getByText('Test Recipe').closest('.recipe-card');
|
||||||
|
recipeCard?.click();
|
||||||
|
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/recipes/1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display recipe metadata when available', async () => {
|
||||||
|
const mockRecipes = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Recipe with Metadata',
|
||||||
|
totalTime: 45,
|
||||||
|
servings: 6,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
||||||
|
data: mockRecipes as any,
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithRouter(<RecipeList />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('45 min')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('6 servings')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should truncate long descriptions', async () => {
|
||||||
|
const longDescription = 'A'.repeat(150);
|
||||||
|
const mockRecipes = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Recipe with Long Description',
|
||||||
|
description: longDescription,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
||||||
|
data: mockRecipes as any,
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithRouter(<RecipeList />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const description = screen.getByText(/^A{100}\.\.\.$/);
|
||||||
|
expect(description).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
143
packages/web/src/services/api.test.ts
Normal file
143
packages/web/src/services/api.test.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { recipesApi } from './api';
|
||||||
|
|
||||||
|
vi.mock('axios');
|
||||||
|
|
||||||
|
describe('Recipes API Service', () => {
|
||||||
|
const mockAxios = axios as any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockAxios.create = vi.fn(() => mockAxios);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAll', () => {
|
||||||
|
it('should fetch all recipes with default params', async () => {
|
||||||
|
const mockRecipes = {
|
||||||
|
data: [
|
||||||
|
{ id: '1', title: 'Recipe 1' },
|
||||||
|
{ id: '2', title: 'Recipe 2' },
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockAxios.get = vi.fn().mockResolvedValue({ data: mockRecipes });
|
||||||
|
|
||||||
|
const result = await recipesApi.getAll();
|
||||||
|
|
||||||
|
expect(mockAxios.get).toHaveBeenCalledWith('/recipes', { params: undefined });
|
||||||
|
expect(result).toEqual(mockRecipes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fetch recipes with search params', async () => {
|
||||||
|
const mockRecipes = {
|
||||||
|
data: [{ id: '1', title: 'Pasta Recipe' }],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockAxios.get = vi.fn().mockResolvedValue({ data: mockRecipes });
|
||||||
|
|
||||||
|
await recipesApi.getAll({ search: 'pasta', page: 1, limit: 10 });
|
||||||
|
|
||||||
|
expect(mockAxios.get).toHaveBeenCalledWith('/recipes', {
|
||||||
|
params: { search: 'pasta', page: 1, limit: 10 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getById', () => {
|
||||||
|
it('should fetch single recipe by id', async () => {
|
||||||
|
const mockRecipe = {
|
||||||
|
data: { id: '1', title: 'Test Recipe', description: 'Test' },
|
||||||
|
};
|
||||||
|
|
||||||
|
mockAxios.get = vi.fn().mockResolvedValue({ data: mockRecipe });
|
||||||
|
|
||||||
|
const result = await recipesApi.getById('1');
|
||||||
|
|
||||||
|
expect(mockAxios.get).toHaveBeenCalledWith('/recipes/1');
|
||||||
|
expect(result).toEqual(mockRecipe);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create new recipe', async () => {
|
||||||
|
const newRecipe = { title: 'New Recipe', description: 'New Description' };
|
||||||
|
const mockResponse = { data: { id: '1', ...newRecipe } };
|
||||||
|
|
||||||
|
mockAxios.post = vi.fn().mockResolvedValue({ data: mockResponse });
|
||||||
|
|
||||||
|
const result = await recipesApi.create(newRecipe);
|
||||||
|
|
||||||
|
expect(mockAxios.post).toHaveBeenCalledWith('/recipes', newRecipe);
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('should update existing recipe', async () => {
|
||||||
|
const updatedRecipe = { title: 'Updated Recipe' };
|
||||||
|
const mockResponse = { data: { id: '1', ...updatedRecipe } };
|
||||||
|
|
||||||
|
mockAxios.put = vi.fn().mockResolvedValue({ data: mockResponse });
|
||||||
|
|
||||||
|
const result = await recipesApi.update('1', updatedRecipe);
|
||||||
|
|
||||||
|
expect(mockAxios.put).toHaveBeenCalledWith('/recipes/1', updatedRecipe);
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('should delete recipe', async () => {
|
||||||
|
const mockResponse = { data: { message: 'Recipe deleted' } };
|
||||||
|
|
||||||
|
mockAxios.delete = vi.fn().mockResolvedValue({ data: mockResponse });
|
||||||
|
|
||||||
|
const result = await recipesApi.delete('1');
|
||||||
|
|
||||||
|
expect(mockAxios.delete).toHaveBeenCalledWith('/recipes/1');
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('uploadImage', () => {
|
||||||
|
it('should upload image with multipart form data', async () => {
|
||||||
|
const mockFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
|
||||||
|
const mockResponse = { data: { url: '/uploads/recipes/test.jpg' } };
|
||||||
|
|
||||||
|
mockAxios.post = vi.fn().mockResolvedValue({ data: mockResponse });
|
||||||
|
|
||||||
|
const result = await recipesApi.uploadImage('1', mockFile);
|
||||||
|
|
||||||
|
expect(mockAxios.post).toHaveBeenCalledWith(
|
||||||
|
'/recipes/1/images',
|
||||||
|
expect.any(FormData),
|
||||||
|
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||||
|
);
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('importFromUrl', () => {
|
||||||
|
it('should import recipe from URL', async () => {
|
||||||
|
const url = 'https://example.com/recipe';
|
||||||
|
const mockResponse = {
|
||||||
|
success: true,
|
||||||
|
recipe: { title: 'Imported Recipe' },
|
||||||
|
};
|
||||||
|
|
||||||
|
mockAxios.post = vi.fn().mockResolvedValue({ data: mockResponse });
|
||||||
|
|
||||||
|
const result = await recipesApi.importFromUrl(url);
|
||||||
|
|
||||||
|
expect(mockAxios.post).toHaveBeenCalledWith('/recipes/import', { url });
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
28
packages/web/vitest.config.ts
Normal file
28
packages/web/vitest.config.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
||||||
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html', 'lcov'],
|
||||||
|
exclude: [
|
||||||
|
'node_modules/',
|
||||||
|
'dist/',
|
||||||
|
'**/*.config.ts',
|
||||||
|
'**/*.d.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
11
packages/web/vitest.setup.ts
Normal file
11
packages/web/vitest.setup.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
|
// Cleanup after each test case (e.g., clearing jsdom)
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extend Vitest matchers with jest-dom
|
||||||
|
expect.extend({});
|
||||||
72
playwright.config.ts
Normal file
72
playwright.config.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read environment variables from file.
|
||||||
|
* https://github.com/motdotla/dotenv
|
||||||
|
*/
|
||||||
|
// require('dotenv').config();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: true,
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: [
|
||||||
|
['html'],
|
||||||
|
['list'],
|
||||||
|
['junit', { outputFile: 'playwright-report/junit.xml' }],
|
||||||
|
],
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: { ...devices['Desktop Safari'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Test against mobile viewports. */
|
||||||
|
{
|
||||||
|
name: 'Mobile Chrome',
|
||||||
|
use: { ...devices['Pixel 5'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mobile Safari',
|
||||||
|
use: { ...devices['iPhone 12'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
url: 'http://localhost:5173',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120000,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user