From 06e41023c3500c543e8ab5e3d1e7c63a80ddb7d6 Mon Sep 17 00:00:00 2001 From: Nicholai Date: Sun, 2 Nov 2025 01:38:30 -0600 Subject: [PATCH] first commit --- .env.example | 77 ++++ .gitea/issue_templates/bug_report.md | 26 ++ .gitea/issue_templates/enhancement.md | 15 + .gitea/issue_templates/feature_request.md | 23 ++ .gitea/pull_request_template.md | 29 ++ .gitea/workflows/ci.yaml | 99 +++++ .gitea/workflows/ci.yml | 42 ++ .gitea/workflows/deploy.yaml | 201 +++++++++ .gitea/workflows/enhanced-ci.yaml | 382 ++++++++++++++++++ .gitea/workflows/performance.yaml | 267 ++++++++++++ .gitea/workflows/pr-lint.yml | 17 + .gitea/workflows/security.yaml | 261 ++++++++++++ .gitignore | 153 +++++++ CONTRIBUTING.md | 46 +++ README.md | 240 +++++++++++ __tests__/README.md | 11 + __tests__/api/artists.test.ts | 119 ++++++ __tests__/components/aftercare-page.test.tsx | 82 ++++ .../components/artist-portfolio.test.tsx | 99 +++++ __tests__/components/artists-grid.test.tsx | 202 +++++++++ __tests__/components/hero-section.test.tsx | 132 ++++++ __tests__/components/privacy-page.test.tsx | 109 +++++ .../api-appointments-booking-disabled.test.ts | 34 ++ __tests__/flags/api-uploads-disabled.test.ts | 23 ++ .../flags/artists-section.static.test.tsx | 25 ++ __tests__/flags/booking-form.disabled.test.ts | 22 + __tests__/hooks/use-parallax.test.tsx | 199 +++++++++ __tests__/lib/data-migration.test.ts | 144 +++++++ __tests__/lib/db.test.ts | 269 ++++++++++++ __tests__/lib/flags.test.ts | 92 +++++ __tests__/lib/validations.test.ts | 92 +++++ docs/bootstrapping.md | 46 +++ docs/edge-cases.md | 50 +++ docs/stack-decisions.md | 32 ++ docs/testing-blueprints.md | 41 ++ scripts/bootstrap-template.sh | 90 +++++ 36 files changed, 3791 insertions(+) create mode 100644 .env.example create mode 100644 .gitea/issue_templates/bug_report.md create mode 100644 .gitea/issue_templates/enhancement.md create mode 100644 .gitea/issue_templates/feature_request.md create mode 100644 .gitea/pull_request_template.md create mode 100644 .gitea/workflows/ci.yaml create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/deploy.yaml create mode 100644 .gitea/workflows/enhanced-ci.yaml create mode 100644 .gitea/workflows/performance.yaml create mode 100644 .gitea/workflows/pr-lint.yml create mode 100644 .gitea/workflows/security.yaml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 README.md create mode 100644 __tests__/README.md create mode 100644 __tests__/api/artists.test.ts create mode 100644 __tests__/components/aftercare-page.test.tsx create mode 100644 __tests__/components/artist-portfolio.test.tsx create mode 100644 __tests__/components/artists-grid.test.tsx create mode 100644 __tests__/components/hero-section.test.tsx create mode 100644 __tests__/components/privacy-page.test.tsx create mode 100644 __tests__/flags/api-appointments-booking-disabled.test.ts create mode 100644 __tests__/flags/api-uploads-disabled.test.ts create mode 100644 __tests__/flags/artists-section.static.test.tsx create mode 100644 __tests__/flags/booking-form.disabled.test.ts create mode 100644 __tests__/hooks/use-parallax.test.tsx create mode 100644 __tests__/lib/data-migration.test.ts create mode 100644 __tests__/lib/db.test.ts create mode 100644 __tests__/lib/flags.test.ts create mode 100644 __tests__/lib/validations.test.ts create mode 100644 docs/bootstrapping.md create mode 100644 docs/edge-cases.md create mode 100644 docs/stack-decisions.md create mode 100644 docs/testing-blueprints.md create mode 100755 scripts/bootstrap-template.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0fbfd85 --- /dev/null +++ b/.env.example @@ -0,0 +1,77 @@ +# --------------------------------------------------------------------------- +# LOCAL DEVELOPMENT TEMPLATE +# Copy this file to `.env` and fill only the sections that apply to your app. +# Treat blanks as intentional—remove unused sections after bootstrapping. +# --------------------------------------------------------------------------- + +# === Core application === +APP_NAME="my-app" +NODE_ENV="development" +APP_URL="http://localhost:3000" + +# === Database (PostgreSQL recommended) === +# Example: postgres://user:password@host:5432/db?sslmode=require +DATABASE_URL="" +DIRECT_URL="" # Optional: connection string for migration tooling + +# === Authentication (Optional) === +# Generate with `openssl rand -base64 32` +AUTH_SECRET="" + +# Provider toggles: set to "true" or leave blank. +AUTH_ENABLE_EMAIL=""; +AUTH_ENABLE_GOOGLE=""; +AUTH_ENABLE_GITHUB=""; + +# Email magic links / passwordless +EMAIL_SERVER_HOST="" +EMAIL_SERVER_PORT="" +EMAIL_SERVER_USER="" +EMAIL_SERVER_PASSWORD="" +EMAIL_FROM="" + +# Google OAuth +GOOGLE_CLIENT_ID="" +GOOGLE_CLIENT_SECRET="" + +# GitHub OAuth +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" + +# === Storage (Optional) === +# Works for AWS S3, Cloudflare R2, or any S3-compatible service. +STORAGE_DRIVER="s3" # s3 | r2 | minio | filesystem +STORAGE_ACCESS_KEY_ID="" +STORAGE_SECRET_ACCESS_KEY="" +STORAGE_REGION="us-east-1" +STORAGE_BUCKET="" +STORAGE_ENDPOINT="" # Required for R2 / MinIO. Leave blank for AWS. + +# === Scheduling & Calendars (Optional) === +CALENDAR_PROVIDER="" # nextcloud | google | none +CALENDAR_BASE_URL="" +CALENDAR_USERNAME="" +CALENDAR_PASSWORD="" +CALENDAR_CLIENT_ID="" +CALENDAR_CLIENT_SECRET="" +CALENDAR_DEFAULT_TIMEZONE="America/Denver" + +# === Feature Flags (Optional) === +FLAGS_PROVIDER="" # launchdarkly | growthbook | configcat | none +FLAGS_CLIENT_KEY="" +FLAGS_SERVER_KEY="" + +# === Analytics & Observability (Optional) === +ANALYTICS_PROVIDER="" # plausible | umami | vercel | none +ANALYTICS_SITE_ID="" +SENTRY_DSN="" +LOG_LEVEL="info" + +# === Edge / Worker deploys (Optional) === +CLOUDFLARE_ACCOUNT_ID="" +CLOUDFLARE_API_TOKEN="" +WRANGLER_PROFILE="" + +# === Miscellaneous === +NEXT_PUBLIC_APP_NAME="${APP_NAME}" +NEXT_PUBLIC_FEATURE_FLAGS="" diff --git a/.gitea/issue_templates/bug_report.md b/.gitea/issue_templates/bug_report.md new file mode 100644 index 0000000..67cb886 --- /dev/null +++ b/.gitea/issue_templates/bug_report.md @@ -0,0 +1,26 @@ +--- +name: "🐞 Bug report" +about: Report a reproducible problem +labels: ["type:fix"] +--- + +### What happened? + + +### Expected behavior + +### Repro steps +1. +2. +3. + +### Logs / screenshots + + +### Environment +- Browser: +- Node: `node -v` +- pnpm: `pnpm -v` +- App commit SHA: + +### Extra context diff --git a/.gitea/issue_templates/enhancement.md b/.gitea/issue_templates/enhancement.md new file mode 100644 index 0000000..46cc524 --- /dev/null +++ b/.gitea/issue_templates/enhancement.md @@ -0,0 +1,15 @@ +--- +name: "✨ Enhancement" +about: Improve an existing capability +labels: ["type:docs","type:chore"] +--- + +### Current behavior + +### Desired behavior + +### Acceptance criteria +- [ ] AC1 +- [ ] AC2 + +### Notes / risks diff --git a/.gitea/issue_templates/feature_request.md b/.gitea/issue_templates/feature_request.md new file mode 100644 index 0000000..91aa07f --- /dev/null +++ b/.gitea/issue_templates/feature_request.md @@ -0,0 +1,23 @@ +--- +name: "🚀 Feature request" +about: Propose a net-new capability +labels: ["type:feat"] +--- + +### Problem this solves + +### Proposed solution (what & why) + +### Non-goals / constraints + +### Acceptance criteria +- [ ] AC1 +- [ ] AC2 + +### Alternatives considered + +### Impacted areas +- [ ] UI +- [ ] RunCoordinator DO +- [ ] Scoring/validators +- [ ] Storage (D1/R2) diff --git a/.gitea/pull_request_template.md b/.gitea/pull_request_template.md new file mode 100644 index 0000000..378fca3 --- /dev/null +++ b/.gitea/pull_request_template.md @@ -0,0 +1,29 @@ +## Summary + + +## Type +- [ ] feat +- [ ] fix +- [ ] docs +- [ ] chore +- [ ] refactor +- [ ] test + +## Screenshots / logs + + +## How to test +1. +2. +3. + +## Checklist +- [ ] Tests added/updated (unit or integration) +- [ ] Typecheck & lint pass (`pnpm check` or equivalent) +- [ ] Builds locally (`pnpm build` or equivalent) +- [ ] Docs/README/ADR updated if needed +- [ ] No secrets committed + +## Linked issues +Fixes # +Refs # diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..0f0782c --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,99 @@ +name: CI + +on: + push: + branches: + - main + - master + pull_request: + branches: + - main + - master + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Use Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Cache npm + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install dependencies + run: npm install --no-audit --no-fund + + - name: Ensure CI dev deps present (fallback) + run: | + # Workaround when package-lock is stale vs package.json + npm i --no-save eslint@^8.57.0 eslint-config-next@14.2.16 @vitest/coverage-v8@^3.2.4 || true + + - name: Lint + run: npm run ci:lint + + - name: Typecheck + run: npm run ci:typecheck + + - name: Unit tests (coverage) + run: npm run ci:test + + - name: Build (OpenNext) + run: npm run ci:build + + - name: Preview smoke check + shell: bash + run: | + set -euo pipefail + # Start preview via local CLI in background and verify it doesn't crash immediately + npm run preview > preview.log 2>&1 & + PREVIEW_PID=$! + # Give it a moment to start + sleep 5 + if ! kill -0 "$PREVIEW_PID" 2>/dev/null; then + echo "Preview process exited prematurely. Logs:" >&2 + sed -n '1,200p' preview.log >&2 || true + exit 1 + fi + # Cleanly stop the preview + kill "$PREVIEW_PID" || true + wait "$PREVIEW_PID" || true + echo "Preview started successfully (smoke check passed)." + + - name: Budgets check + run: npm run ci:budgets + env: + TOTAL_STATIC_MAX_BYTES: ${{ vars.TOTAL_STATIC_MAX_BYTES }} + MAX_ASSET_BYTES: ${{ vars.MAX_ASSET_BYTES }} + + - name: Upload budgets report + if: always() + uses: actions/upload-artifact@v4 + with: + name: budgets-report + path: .vercel/output/static-budgets-report.txt + + - name: D1 migration dry-run (best-effort) + shell: bash + continue-on-error: true + run: | + set -euo pipefail + if [ -f sql/schema.sql ]; then + echo "Attempting D1 migration dry-run (local mode)..." + if npx wrangler d1 execute united-tattoo --local --file=./sql/schema.sql; then + echo "D1 migration dry-run completed successfully." + else + echo "D1 dry-run skipped or failed due to missing local bindings. This is expected until CI bindings are configured." >&2 + fi + else + echo "No sql/schema.sql found; skipping D1 dry-run." + fi diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..73ec255 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +jobs: + build-test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./bandit-runner-app + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + cache-dependency-path: bandit-runner-app/pnpm-lock.yaml + + - name: Install deps + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: npx tsc --noEmit + + - name: Build (OpenNext) + run: pnpm build diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..8f0dd8b --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -0,0 +1,201 @@ +name: Deployment Pipeline + +on: + workflow_dispatch: + inputs: + environment: + description: 'Target environment' + required: true + default: 'preview' + type: choice + options: + - preview + - production + skip_tests: + description: 'Skip tests (emergency deployment)' + required: false + default: false + type: boolean + force_deploy: + description: 'Force deployment even if checks fail' + required: false + default: false + type: boolean + schedule: + # Deploy to preview every Sunday at 2 AM UTC + - cron: '0 2 * * 0' + +env: + NODE_VERSION: '20' + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + +jobs: + pre-deployment-checks: + name: Pre-Deployment Checks + runs-on: ubuntu-latest + timeout-minutes: 10 + if: ${{ !inputs.skip_tests }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + - name: Quick lint check + run: npm run ci:lint + + - name: TypeScript check + run: npm run ci:typecheck + + - name: Run tests + run: npm run ci:test + + build-and-deploy: + name: Build and Deploy + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [pre-deployment-checks] + if: always() && (needs.pre-deployment-checks.result == 'success' || inputs.skip_tests || inputs.force_deploy) + environment: ${{ inputs.environment || 'preview' }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + - name: Build application + run: | + echo "Building application for ${{ inputs.environment || 'preview' }} environment..." + npm run ci:build + + # Generate deployment ID + DEPLOY_ID=$(date +%Y%m%d-%H%M%S)-${GITHUB_SHA::8} + echo "DEPLOY_ID=$DEPLOY_ID" >> $GITHUB_ENV + echo "Deployment ID: $DEPLOY_ID" + + - name: Database migration (Production only) + if: inputs.environment == 'production' + run: | + echo "Running database migrations for production..." + # In a real scenario, this would run actual migrations + echo "Database migrations completed (simulated)" + + - name: Deploy to Cloudflare + run: | + echo "Deploying to Cloudflare ${{ inputs.environment || 'preview' }} environment..." + CLOUDFLARE_ACCOUNT_ID=${{ env.CLOUDFLARE_ACCOUNT_ID }} npx @opennextjs/cloudflare deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + + - name: Wait for deployment + run: | + echo "Waiting for deployment to propagate..." + sleep 15 + + - name: Health check + run: | + echo "Performing health check..." + MAX_RETRIES=5 + RETRY_COUNT=0 + + while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if curl -f -s https://united-tattoo.christyl116.workers.dev > /dev/null; then + echo "✅ Health check passed!" + break + else + RETRY_COUNT=$((RETRY_COUNT + 1)) + echo "Health check failed, retrying... ($RETRY_COUNT/$MAX_RETRIES)" + sleep 10 + fi + done + + if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then + echo "❌ Health check failed after $MAX_RETRIES attempts" + exit 1 + fi + + - name: Performance check + run: | + echo "Running performance check..." + # Basic performance check + RESPONSE_TIME=$(curl -o /dev/null -s -w '%{time_total}' https://united-tattoo.christyl116.workers.dev) + echo "Response time: ${RESPONSE_TIME}s" + + # Check if response time is acceptable (less than 2 seconds) + if (( $(echo "$RESPONSE_TIME < 2.0" | bc -l) )); then + echo "✅ Performance check passed" + else + echo "⚠️ Performance check warning: Response time is ${RESPONSE_TIME}s" + fi + + - name: SEO check + run: | + echo "Checking SEO metadata..." + curl -s https://united-tattoo.christyl116.workers.dev | grep -q "application/ld+json" && echo "✅ JSON-LD found" || echo "⚠️ JSON-LD not found" + curl -s https://united-tattoo.christyl116.workers.dev | grep -q "og:title" && echo "✅ Open Graph tags found" || echo "⚠️ Open Graph tags not found" + + - name: Create deployment record + run: | + echo "Creating deployment record..." + # In a real scenario, this would create a record in your database or logging system + echo "Deployment ID: $DEPLOY_ID" > deployment-info.txt + echo "Environment: ${{ inputs.environment || 'preview' }}" >> deployment-info.txt + echo "Commit: $GITHUB_SHA" >> deployment-info.txt + echo "Timestamp: $(date -u)" >> deployment-info.txt + echo "URL: https://united-tattoo.christyl116.workers.dev" >> deployment-info.txt + + - name: Upload deployment info + uses: actions/upload-artifact@v4 + with: + name: deployment-info-${{ inputs.environment || 'preview' }}-${{ env.DEPLOY_ID }} + path: deployment-info.txt + retention-days: 90 + + - name: Notify success + if: success() + run: | + echo "🎉 Deployment to ${{ inputs.environment || 'preview' }} completed successfully!" + echo "Deployment ID: $DEPLOY_ID" + echo "URL: https://united-tattoo.christyl116.workers.dev" + + - name: Notify failure + if: failure() + run: | + echo "❌ Deployment to ${{ inputs.environment || 'preview' }} failed!" + echo "Deployment ID: $DEPLOY_ID" + echo "Please check the logs for details." + + rollback: + name: Rollback (if needed) + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [build-and-deploy] + if: failure() && inputs.environment == 'production' + environment: production + steps: + - name: Rollback deployment + run: | + echo "Rolling back production deployment..." + # In a real scenario, this would implement actual rollback logic + echo "Rollback completed (simulated)" + + - name: Verify rollback + run: | + echo "Verifying rollback..." + curl -f https://united-tattoo.christyl116.workers.dev || exit 1 + echo "✅ Rollback verification successful" diff --git a/.gitea/workflows/enhanced-ci.yaml b/.gitea/workflows/enhanced-ci.yaml new file mode 100644 index 0000000..a847d2d --- /dev/null +++ b/.gitea/workflows/enhanced-ci.yaml @@ -0,0 +1,382 @@ +name: Enhanced CI/CD Pipeline + +on: + push: + branches: + - main + - master + - 'ci-run-*' + pull_request: + branches: + - main + - master + workflow_dispatch: + inputs: + environment: + description: 'Deployment environment' + required: true + default: 'preview' + type: choice + options: + - preview + - production + +env: + NODE_VERSION: '20' + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + +jobs: + # =========================================== + # QUALITY GATES + # =========================================== + + lint-and-format: + name: Code Quality + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + - name: ESLint + run: npm run ci:lint + continue-on-error: false + + - name: TypeScript check + run: npm run ci:typecheck + continue-on-error: false + + - name: Format check + run: | + echo "Checking code formatting..." + if ! npm run format:check 2>/dev/null; then + echo "Code formatting issues found. Run 'npm run format' to fix." + exit 1 + fi + + - name: Upload lint results + if: always() + uses: actions/upload-artifact@v4 + with: + name: lint-results + path: | + .next/ + eslint-results.json + retention-days: 7 + + security-scan: + name: Security Scan + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + - name: Audit dependencies + run: | + echo "Running security audit..." + npm audit --audit-level=moderate --json > audit-results.json || true + + # Check for high/critical vulnerabilities + if npm audit --audit-level=high; then + echo "No high/critical vulnerabilities found" + else + echo "High/critical vulnerabilities detected!" + echo "Audit results:" + cat audit-results.json | jq '.metadata.vulnerabilities' + exit 1 + fi + + - name: License check + run: | + echo "Checking for problematic licenses..." + npx license-checker --summary --onlyAllow 'MIT;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;Unlicense' + + - name: Upload security results + if: always() + uses: actions/upload-artifact@v4 + with: + name: security-results + path: audit-results.json + retention-days: 30 + + test: + name: Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [lint-and-format] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + - name: Run unit tests + run: npm run ci:test + env: + CI: true + + - name: Upload coverage reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: | + coverage/ + vitest-results.xml + retention-days: 30 + + - name: Comment coverage on PR + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const path = require('path'); + + try { + const coveragePath = path.join(process.cwd(), 'coverage', 'lcov-report', 'index.html'); + if (fs.existsSync(coveragePath)) { + const coverage = fs.readFileSync(coveragePath, 'utf8'); + const match = coverage.match(/(\d+\.?\d*)%/); + if (match) { + const percentage = match[1]; + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `## 📊 Test Coverage: ${percentage}% + + Coverage report generated successfully.` + }); + } + } + } catch (error) { + console.log('Could not generate coverage comment:', error.message); + } + + # =========================================== + # BUILD AND DEPLOY + # =========================================== + + build: + name: Build Application + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: [lint-and-format, security-scan, test] + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + outputs: + build-id: ${{ steps.build.outputs.build-id }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + - name: Build application + id: build + run: | + echo "Building Next.js application..." + npm run ci:build + + # Generate build ID for tracking + BUILD_ID=$(date +%Y%m%d-%H%M%S)-${GITHUB_SHA::8} + echo "build-id=$BUILD_ID" >> $GITHUB_OUTPUT + echo "Build ID: $BUILD_ID" + + - name: Budget check + run: npm run ci:budgets + env: + TOTAL_STATIC_MAX_BYTES: ${{ vars.TOTAL_STATIC_MAX_BYTES || '3000000' }} + MAX_ASSET_BYTES: ${{ vars.MAX_ASSET_BYTES || '1500000' }} + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-artifacts-${{ steps.build.outputs.build-id }} + path: | + .vercel/output/ + .open-next/ + retention-days: 7 + + - name: Upload budgets report + if: always() + uses: actions/upload-artifact@v4 + with: + name: budgets-report-${{ steps.build.outputs.build-id }} + path: .vercel/output/static-budgets-report.txt + retention-days: 30 + + deploy-preview: + name: Deploy to Preview + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [build] + if: github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'preview') + environment: preview + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: build-artifacts-${{ needs.build.outputs.build-id }} + path: . + + - name: Deploy to Cloudflare (Preview) + run: | + echo "Deploying to Cloudflare preview environment..." + CLOUDFLARE_ACCOUNT_ID=${{ env.CLOUDFLARE_ACCOUNT_ID }} npx @opennextjs/cloudflare deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + + - name: Update PR comment + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `## 🚀 Preview Deployment Complete + + **Build ID:** ${{ needs.build.outputs.build-id }} + **Environment:** Preview + **Status:** ✅ Deployed successfully + + Preview URL: https://united-tattoo.christyl116.workers.dev + + --- + *This is an automated deployment for PR #${{ github.event.number }}*` + }); + + deploy-production: + name: Deploy to Production + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: [build] + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' || (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'production') + environment: production + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: build-artifacts-${{ needs.build.outputs.build-id }} + path: . + + - name: Database migration check + run: | + echo "Checking database migration status..." + # This would run actual migrations in a real scenario + echo "Migration check completed (dry-run mode)" + + - name: Deploy to Cloudflare (Production) + run: | + echo "Deploying to Cloudflare production environment..." + CLOUDFLARE_ACCOUNT_ID=${{ env.CLOUDFLARE_ACCOUNT_ID }} npx @opennextjs/cloudflare deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + + - name: Health check + run: | + echo "Performing health check..." + sleep 10 + curl -f https://united-tattoo.christyl116.workers.dev || exit 1 + echo "Health check passed!" + + - name: Notify deployment success + if: success() + run: | + echo "✅ Production deployment successful!" + echo "Build ID: ${{ needs.build.outputs.build-id }}" + echo "URL: https://united-tattoo.christyl116.workers.dev" + + # =========================================== + # POST-DEPLOYMENT CHECKS + # =========================================== + + post-deployment: + name: Post-Deployment Checks + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [deploy-production] + if: always() && needs.deploy-production.result == 'success' + steps: + - name: Lighthouse CI + run: | + echo "Running Lighthouse performance audit..." + npx @lhci/cli@0.12.x autorun + env: + LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} + + - name: SEO Check + run: | + echo "Checking SEO metadata..." + curl -s https://united-tattoo.christyl116.workers.dev | grep -E "(og:|twitter:|application/ld\+json)" || echo "SEO metadata found" + + - name: Security Headers Check + run: | + echo "Checking security headers..." + curl -I https://united-tattoo.christyl116.workers.dev | grep -E "(X-Frame-Options|X-Content-Type-Options|X-XSS-Protection)" || echo "Security headers check completed" + + # =========================================== + # CLEANUP + # =========================================== + + cleanup: + name: Cleanup + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [deploy-production, post-deployment] + if: always() + steps: + - name: Cleanup old artifacts + run: | + echo "Cleaning up old build artifacts..." + # This would clean up old deployments in a real scenario + echo "Cleanup completed" + + - name: Update deployment status + run: | + echo "Deployment pipeline completed" + echo "Final status: ${{ needs.deploy-production.result }}" diff --git a/.gitea/workflows/performance.yaml b/.gitea/workflows/performance.yaml new file mode 100644 index 0000000..0d904a7 --- /dev/null +++ b/.gitea/workflows/performance.yaml @@ -0,0 +1,267 @@ +name: Performance Monitoring + +on: + push: + branches: + - main + - master + pull_request: + branches: + - main + - master + schedule: + # Run performance check daily at 4 AM UTC + - cron: '0 4 * * *' + workflow_dispatch: + +env: + NODE_VERSION: '20' + SITE_URL: 'https://united-tattoo.christyl116.workers.dev' + +jobs: + lighthouse-audit: + name: Lighthouse Performance Audit + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + - name: Install Lighthouse CI + run: npm install -g @lhci/cli@0.12.x + + - name: Run Lighthouse CI + run: | + echo "Running Lighthouse performance audit..." + + # Create lighthouse config + cat > lighthouserc.js << EOF + module.exports = { + ci: { + collect: { + url: ['${{ env.SITE_URL }}'], + numberOfRuns: 3, + settings: { + chromeFlags: '--no-sandbox --headless', + }, + }, + assert: { + assertions: { + 'categories:performance': ['warn', {minScore: 0.8}], + 'categories:accessibility': ['error', {minScore: 0.9}], + 'categories:best-practices': ['warn', {minScore: 0.8}], + 'categories:seo': ['error', {minScore: 0.9}], + }, + }, + upload: { + target: 'filesystem', + outputDir: './lighthouse-results', + }, + }, + }; + EOF + + # Run Lighthouse + lhci autorun + + - name: Upload Lighthouse results + uses: actions/upload-artifact@v4 + with: + name: lighthouse-results + path: lighthouse-results/ + retention-days: 30 + + bundle-analysis: + name: Bundle Size Analysis + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + - name: Build application + run: npm run ci:build + + - name: Analyze bundle size + run: | + echo "Analyzing bundle sizes..." + + # Check total build size + BUILD_SIZE=$(du -sh .vercel/output/static | cut -f1) + echo "Total build size: $BUILD_SIZE" + + # Check individual chunk sizes + echo "Largest chunks:" + find .vercel/output/static/_next/static/chunks -name "*.js" -exec du -h {} \; | sort -hr | head -10 + + # Check for large files + echo "Large files (>500KB):" + find .vercel/output/static -type f -size +500k -exec ls -lh {} \; + + - name: Run budget check + run: npm run ci:budgets + env: + TOTAL_STATIC_MAX_BYTES: ${{ vars.TOTAL_STATIC_MAX_BYTES || '3000000' }} + MAX_ASSET_BYTES: ${{ vars.MAX_ASSET_BYTES || '1500000' }} + + - name: Upload bundle analysis + uses: actions/upload-artifact@v4 + with: + name: bundle-analysis + path: | + .vercel/output/static-budgets-report.txt + .vercel/output/static/ + retention-days: 30 + + core-web-vitals: + name: Core Web Vitals Check + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + - name: Check Core Web Vitals + run: | + echo "Checking Core Web Vitals..." + + # Basic performance check + RESPONSE_TIME=$(curl -o /dev/null -s -w '%{time_total}' ${{ env.SITE_URL }}) + echo "Response time: ${RESPONSE_TIME}s" + + # Check if response time is acceptable + if (( $(echo "$RESPONSE_TIME < 2.0" | bc -l) )); then + echo "✅ Response time is good (< 2s)" + else + echo "⚠️ Response time is slow (> 2s)" + fi + + # Check for gzip compression + COMPRESSED_SIZE=$(curl -H "Accept-Encoding: gzip" -s -w '%{size_download}' -o /dev/null ${{ env.SITE_URL }}) + UNCOMPRESSED_SIZE=$(curl -s -w '%{size_download}' -o /dev/null ${{ env.SITE_URL }}) + + if [ "$COMPRESSED_SIZE" -lt "$UNCOMPRESSED_SIZE" ]; then + echo "✅ Gzip compression is working" + else + echo "⚠️ Gzip compression may not be working" + fi + + - name: Check SEO performance + run: | + echo "Checking SEO performance..." + + # Check for meta tags + curl -s ${{ env.SITE_URL }} | grep -q "og:title" && echo "✅ Open Graph tags present" || echo "❌ Open Graph tags missing" + curl -s ${{ env.SITE_URL }} | grep -q "twitter:card" && echo "✅ Twitter Card tags present" || echo "❌ Twitter Card tags missing" + curl -s ${{ env.SITE_URL }} | grep -q "application/ld+json" && echo "✅ JSON-LD structured data present" || echo "❌ JSON-LD structured data missing" + + # Check for canonical URL + curl -s ${{ env.SITE_URL }} | grep -q "canonical" && echo "✅ Canonical URL present" || echo "❌ Canonical URL missing" + + - name: Check security headers + run: | + echo "Checking security headers..." + + # Check for security headers + curl -I ${{ env.SITE_URL }} | grep -q "X-Frame-Options" && echo "✅ X-Frame-Options present" || echo "⚠️ X-Frame-Options missing" + curl -I ${{ env.SITE_URL }} | grep -q "X-Content-Type-Options" && echo "✅ X-Content-Type-Options present" || echo "⚠️ X-Content-Type-Options missing" + curl -I ${{ env.SITE_URL }} | grep -q "X-XSS-Protection" && echo "✅ X-XSS-Protection present" || echo "⚠️ X-XSS-Protection missing" + + performance-report: + name: Generate Performance Report + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [lighthouse-audit, bundle-analysis, core-web-vitals] + if: always() + steps: + - name: Download performance results + uses: actions/download-artifact@v4 + with: + name: lighthouse-results + path: lighthouse-results/ + + - name: Generate performance report + run: | + echo "# Performance Report" > performance-report.md + echo "Generated: $(date -u)" >> performance-report.md + echo "Site URL: ${{ env.SITE_URL }}" >> performance-report.md + echo "" >> performance-report.md + + # Add Lighthouse results + if [ -d "lighthouse-results" ]; then + echo "## Lighthouse Scores" >> performance-report.md + echo "" >> performance-report.md + + # Extract scores from Lighthouse results + if [ -f "lighthouse-results/manifest.json" ]; then + echo "Lighthouse audit completed successfully" >> performance-report.md + else + echo "Lighthouse audit results not found" >> performance-report.md + fi + echo "" >> performance-report.md + fi + + echo "## Performance Checks" >> performance-report.md + echo "" >> performance-report.md + echo "- Lighthouse Audit: ${{ needs.lighthouse-audit.result }}" >> performance-report.md + echo "- Bundle Analysis: ${{ needs.bundle-analysis.result }}" >> performance-report.md + echo "- Core Web Vitals: ${{ needs.core-web-vitals.result }}" >> performance-report.md + echo "" >> performance-report.md + + echo "## Recommendations" >> performance-report.md + echo "" >> performance-report.md + echo "1. Monitor Core Web Vitals regularly" >> performance-report.md + echo "2. Keep bundle sizes under budget limits" >> performance-report.md + echo "3. Ensure Lighthouse scores remain above thresholds" >> performance-report.md + echo "4. Check for performance regressions in PRs" >> performance-report.md + + - name: Upload performance report + uses: actions/upload-artifact@v4 + with: + name: performance-report + path: performance-report.md + retention-days: 90 + + - name: Comment on PR + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const report = fs.readFileSync('performance-report.md', 'utf8'); + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `## 📊 Performance Report + + ${report}` + }); diff --git a/.gitea/workflows/pr-lint.yml b/.gitea/workflows/pr-lint.yml new file mode 100644 index 0000000..dc13715 --- /dev/null +++ b/.gitea/workflows/pr-lint.yml @@ -0,0 +1,17 @@ +name: PR Title Lint + +on: + pull_request: + types: [opened, edited, synchronize] + +jobs: + pr-title: + runs-on: ubuntu-latest + steps: + - name: Check PR title + run: | + title="$(jq -r '.pull_request.title' < "$GITHUB_EVENT_PATH")" + if ! grep -Eq '^(feat|fix|docs|chore|refactor|test)(\(.+\))?: .+' <<<"$title"; then + echo "PR title must follow Conventional Commits. Got: $title" + exit 1 + fi diff --git a/.gitea/workflows/security.yaml b/.gitea/workflows/security.yaml new file mode 100644 index 0000000..e1b36f4 --- /dev/null +++ b/.gitea/workflows/security.yaml @@ -0,0 +1,261 @@ +name: Security and Dependency Scanning + +on: + push: + branches: + - main + - master + pull_request: + branches: + - main + - master + schedule: + # Run security scan daily at 3 AM UTC + - cron: '0 3 * * *' + workflow_dispatch: + +env: + NODE_VERSION: '20' + +jobs: + dependency-scan: + name: Dependency Security Scan + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + - name: Run npm audit + run: | + echo "Running npm security audit..." + npm audit --audit-level=moderate --json > audit-results.json || true + + # Extract vulnerability counts + HIGH_VULNS=$(cat audit-results.json | jq '.metadata.vulnerabilities.high // 0') + CRITICAL_VULNS=$(cat audit-results.json | jq '.metadata.vulnerabilities.critical // 0') + + echo "High vulnerabilities: $HIGH_VULNS" + echo "Critical vulnerabilities: $CRITICAL_VULNS" + + # Fail if critical vulnerabilities found + if [ "$CRITICAL_VULNS" -gt 0 ]; then + echo "❌ Critical vulnerabilities found!" + cat audit-results.json | jq '.vulnerabilities[] | select(.severity == "critical")' + exit 1 + fi + + # Warn if high vulnerabilities found + if [ "$HIGH_VULNS" -gt 0 ]; then + echo "⚠️ High vulnerabilities found!" + cat audit-results.json | jq '.vulnerabilities[] | select(.severity == "high")' + fi + + - name: License check + run: | + echo "Checking package licenses..." + npx license-checker --summary --onlyAllow 'MIT;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;Unlicense;CC0-1.0' || { + echo "⚠️ Some packages have non-approved licenses" + echo "Run 'npx license-checker --summary' to see details" + } + + - name: Check for outdated packages + run: | + echo "Checking for outdated packages..." + npm outdated --json > outdated-packages.json || true + + # Count outdated packages + OUTDATED_COUNT=$(cat outdated-packages.json | jq 'length') + echo "Outdated packages: $OUTDATED_COUNT" + + if [ "$OUTDATED_COUNT" -gt 0 ]; then + echo "⚠️ Found $OUTDATED_COUNT outdated packages" + cat outdated-packages.json | jq 'keys[]' + fi + + - name: Upload security results + if: always() + uses: actions/upload-artifact@v4 + with: + name: security-scan-results + path: | + audit-results.json + outdated-packages.json + retention-days: 30 + + code-security-scan: + name: Code Security Scan + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + - name: Install security tools + run: | + npm install -g @eslint/eslintrc + npm install -g eslint-plugin-security + + - name: Security linting + run: | + echo "Running security-focused linting..." + # Check for common security issues + if grep -r "eval(" --include="*.js" --include="*.ts" --include="*.tsx" .; then + echo "❌ Found eval() usage - potential security risk" + exit 1 + fi + + if grep -r "innerHTML" --include="*.js" --include="*.ts" --include="*.tsx" .; then + echo "⚠️ Found innerHTML usage - review for XSS risks" + fi + + if grep -r "dangerouslySetInnerHTML" --include="*.js" --include="*.ts" --include="*.tsx" .; then + echo "⚠️ Found dangerouslySetInnerHTML usage - review for XSS risks" + fi + + - name: Check for hardcoded secrets + run: | + echo "Checking for potential hardcoded secrets..." + # Check for common secret patterns + if grep -rE "(password|secret|key|token).*=.*['\"][^'\"]{8,}['\"]" --include="*.js" --include="*.ts" --include="*.tsx" --exclude-dir=node_modules .; then + echo "⚠️ Potential hardcoded secrets found - review manually" + fi + + # Check for API keys + if grep -rE "(api[_-]?key|apikey)" --include="*.js" --include="*.ts" --include="*.tsx" --exclude-dir=node_modules .; then + echo "⚠️ Potential API key references found - ensure no hardcoded keys" + fi + + - name: Check environment variable usage + run: | + echo "Checking environment variable usage..." + # Ensure sensitive data uses environment variables + if grep -r "process\.env\." --include="*.js" --include="*.ts" --include="*.tsx" .; then + echo "✅ Environment variables are being used" + fi + + container-security: + name: Container Security Scan + runs-on: ubuntu-latest + timeout-minutes: 10 + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Check Dockerfile security + run: | + if [ -f "Dockerfile" ]; then + echo "Checking Dockerfile security..." + + # Check for root user + if grep -q "USER root" Dockerfile; then + echo "⚠️ Dockerfile runs as root - consider using non-root user" + fi + + # Check for latest tags + if grep -q ":latest" Dockerfile; then + echo "⚠️ Dockerfile uses 'latest' tag - consider pinning versions" + fi + + # Check for security updates + if grep -q "apt-get update" Dockerfile; then + echo "✅ Dockerfile includes package updates" + fi + else + echo "No Dockerfile found - skipping container security check" + fi + + security-report: + name: Generate Security Report + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [dependency-scan, code-security-scan, container-security] + if: always() + steps: + - name: Download security results + uses: actions/download-artifact@v4 + with: + name: security-scan-results + path: security-results/ + + - name: Generate security report + run: | + echo "# Security Scan Report" > security-report.md + echo "Generated: $(date -u)" >> security-report.md + echo "" >> security-report.md + + # Add dependency scan results + if [ -f "security-results/audit-results.json" ]; then + echo "## Dependency Security" >> security-report.md + echo "" >> security-report.md + + CRITICAL=$(cat security-results/audit-results.json | jq '.metadata.vulnerabilities.critical // 0') + HIGH=$(cat security-results/audit-results.json | jq '.metadata.vulnerabilities.high // 0') + MODERATE=$(cat security-results/audit-results.json | jq '.metadata.vulnerabilities.moderate // 0') + LOW=$(cat security-results/audit-results.json | jq '.metadata.vulnerabilities.low // 0') + + echo "- Critical: $CRITICAL" >> security-report.md + echo "- High: $HIGH" >> security-report.md + echo "- Moderate: $MODERATE" >> security-report.md + echo "- Low: $LOW" >> security-report.md + echo "" >> security-report.md + fi + + # Add outdated packages + if [ -f "security-results/outdated-packages.json" ]; then + echo "## Outdated Packages" >> security-report.md + echo "" >> security-report.md + + OUTDATED_COUNT=$(cat security-results/outdated-packages.json | jq 'length') + echo "Total outdated packages: $OUTDATED_COUNT" >> security-report.md + echo "" >> security-report.md + fi + + echo "## Scan Status" >> security-report.md + echo "" >> security-report.md + echo "- Dependency Scan: ${{ needs.dependency-scan.result }}" >> security-report.md + echo "- Code Security Scan: ${{ needs.code-security-scan.result }}" >> security-report.md + echo "- Container Security: ${{ needs.container-security.result }}" >> security-report.md + + - name: Upload security report + uses: actions/upload-artifact@v4 + with: + name: security-report + path: security-report.md + retention-days: 90 + + - name: Comment on PR + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const report = fs.readFileSync('security-report.md', 'utf8'); + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `## 🔒 Security Scan Results + + ${report}` + }); diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37015b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,153 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules/ +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage +coverage/ +.nyc_output/ + +# next.js +/.next/ +/out/ +.next/ +next-env.d.ts +*.tsbuildinfo + +# vercel +.vercel/ +.vercel + +# production +/build +dist/ +build/ + +# misc +.DS_Store +*.pem +.vscode/ +.idea/ + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +lerna-debug.log* + +# local env files +.env +.env*.local +.env.local +.env.development.local +.env.test.local +.env.production.local +.env.production + +# typescript +*.log + +# IDEs +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.swp +*.swo +*~ +.idea/ +*.sublime-project +*.sublime-workspace + +# OS +.DS_Store +Thumbs.db + +# project temp and large binary assets (avoid committing raw media dumps) +temp/ +temp/** +*.mp4 +*.mov +*.avi +*.mkv +*.psd +*.ai +*.zip +*.7z +*.rar + +# BMAD (local only) +.bmad-core/ +.bmad-*/ + +# database backups (local exports) +backups/ +*.sql.bak +*.db-backup + +# wrangler/cloudflare local state (do not commit) +.wrangler/ +.wrangler/** +.dev.vars +.mf/ + +# opennext build files (NEVER commit these - they are build artifacts) +.open-next/ +.open-next/** +.open next/ +.vercel/output/ +.vercel/output/** + +# Cache directories +.cache/ +.parcel-cache/ +.turbo/ +.swc/ + +# Lock files (keep only one) +# Uncomment the ones you don't use +# package-lock.json +# yarn.lock +# pnpm-lock.yaml + +# Vitest +.vitest/ + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids/ +*.pid +*.seed +*.pid.lock + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Sentry +.sentryclirc + +# React Query +.react-query/ + +# Supabase +.supabase/ +supabase/.temp/ + +# Tanstack Query Devtools +.tanstack/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9ce4f3e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,46 @@ +Contributing Workflow +===================== + +Use this guidance when you spin up a new project from the template or accept contributions from collaborators. + +Branching model +--------------- +- Work from short-lived topic branches cut from `main`. +- Prefix branches with the work type: `feat/`, `fix/`, `docs/`, `chore/`, `refactor/`, `test/`. +- Keep branch names descriptive but short (e.g. `feat/billing-invoices`, `fix/auth-timeout`). + +Commit messages +--------------- +Follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). +``` +(): +``` +Examples: +- `feat(api): add artist listing endpoint` +- `fix(infra): handle wrangler env missing` +- `docs(adr): record storage strategy` + +Pull request checklist +---------------------- +1. Rebase onto `main` before opening the PR. +2. Fill out `.gitea/pull_request_template.md` so reviewers know how to test. +3. Ensure automated checks pass locally: + ```bash + pnpm install + pnpm lint + pnpm test + pnpm build + ``` + Adjust the commands if your project uses a different toolchain. +4. Link issues with `Fixes #id` or `Refs #id` as appropriate. +5. Squash-merge once approved to keep history clean (use the Conventional Commit format for the squash message). + +Quality expectations +-------------------- +- Keep docs current. Update the README, edge-case catalogue, or stack decisions when behaviour changes. +- Add or update tests alongside your changes—tests are treated as executable documentation. +- Avoid committing secrets or large binaries; rely on `.env` files, secret managers, or storage buckets instead. + +Questions? +---------- +Open an issue or start a draft PR and document what you are unsure about. Future readers will thank you for the breadcrumbs. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b7761c1 --- /dev/null +++ b/README.md @@ -0,0 +1,240 @@ +
+ + +
+

STARTER COMMAND

+ ./scripts/bootstrap-template.sh +
+ + + + +[![Contributors][contributors-shield]][contributors-url] +[![Forks][forks-shield]][forks-url] +[![Stargazers][stars-shield]][stars-url] +[![Issues][issues-shield]][issues-url] +[![LinkedIn][linkedin-shield]][linkedin-url] + + +
+
+ + Template Logo + + +

Development Project Template

+ +

+ Opinionated starter kit for new projects deployed through my self-hosted Gitea.
+ Documentation-first • Edge-case aware • Automation ready +
+
+ Quick Start » + · + Suggest Improvement +

+
+ +--- + + +
+

Table of Contents

+
    +
  1. About The Template + +
  2. +
  3. Tech Stack
  4. +
  5. Architecture
  6. +
  7. Getting Started + +
  8. +
  9. Development + +
  10. +
  11. Edge Cases
  12. +
  13. Testing
  14. +
  15. Contributing
  16. +
  17. License
  18. +
  19. Contact
  20. +
+
+ +--- + +
+ +## About The Template + +
+ Template Dashboard Mock +
+ +
+ +This repository is the baseline I use when starting a new product or service. It keeps the process consistent, reduces the time spent wiring boilerplate, and reminds me to account for the edge cases that usually appear late in a project. + +### Why This Exists + +- **Primed documentation:** Every project starts with a README, stack decision log, bootstrapping checklist, and edge-case catalogue. +- **Automation on day one:** `scripts/` holds helpers to rename the project, configure remotes, and clean example assets. +- **Testing blueprints:** Example Vitest suites (`__tests__/`) demonstrate how to structure API, component, flag, hook, and library tests. +- **Gitea ready:** Pull request templates, Conventional Commit guidance, and workflows match my self-hosted setup. + +### Core Principles + +| Principle | What it means | +| --- | --- | +| Documentation-first | Write down intent and constraints before diving into code. | +| Edge-case aware | Capture the failure scenarios that repeatedly cause incidents. | +| Reproducible setup | Every project can be re-created from scratch via scripts and docs. | +| Automation ready | Scripts and CI pipelines are easy to adapt or extend. | + +

back to top ↑

+ +## Tech Stack + +- **Framework**: Next.js + TypeScript (adjust as needed) +- **Testing**: Vitest + Testing Library +- **Styling**: Tailwind CSS or CSS Modules (pick one per project) +- **Database**: PostgreSQL (Supabase/Neon friendly) +- **Storage**: S3-compatible providers (AWS S3, Cloudflare R2) +- **Auth**: NextAuth.js or custom token flows +- **Deployment**: Wrangler + Cloudflare Pages/Workers (swap for your platform) + +Document any deviations in `docs/stack-decisions.md`. + +

back to top ↑

+ +## Architecture + +```mermaid +flowchart TD + A[Client] -->|HTTP| B[Next.js App] + B -->|API Routes| C[(PostgreSQL)] + B -->|Edge Functions| D[Cloudflare Workers] + B -->|Auth| E[Identity Provider] + B -->|Storage SDK| F[(S3/R2 Bucket)] + D -->|Feature Flags| G[Config Service] +``` + +- Keep infrastructure definitions under `infra/` once you create them. +- Capture architectural decisions and trade-offs in `docs/stack-decisions.md`. + +

back to top ↑

+ +## Getting Started + +### Prerequisites + +- Node.js 20+ +- pnpm (preferred) or your package manager of choice +- `jq` (optional, used by bootstrap script) +- Git & access to your Gitea instance + +### Installation + +1. **Clone / duplicate the template** + ```bash + git clone git@git.biohazardvfx.com:nicholai/template.git my-new-project + cd my-new-project + ``` +2. **Bootstrap** + ```bash + ./scripts/bootstrap-template.sh + ``` +3. **Install dependencies** + ```bash + pnpm install + ``` +4. **Follow the checklist** + - Open `docs/bootstrapping.md` and complete each item. + +### Environment Variables + +Copy `.env.example` to `.env` and fill only the sections you need. The file is structured by concern (database, auth, storage, observability) so you can strip unused parts. + +

back to top ↑

+ +## Development + +### Common Commands + +| Command | Description | +| --- | --- | +| `pnpm dev` | Start the Next.js dev server. | +| `pnpm lint` | Run ESLint / formatting checks. | +| `pnpm test` | Execute the Vitest suites. | +| `pnpm build` | Generate a production build. | + +### Docs & Checklists + +- `docs/bootstrapping.md` — tasks to run through when spinning up a new project. +- `docs/edge-cases.md` — prompts for the weird scenarios that usually break things. +- `docs/stack-decisions.md` — record “why” for each notable tech choice. +- `docs/testing-blueprints.md` — guidance for adapting the example tests. + +

back to top ↑

+ +## Edge Cases + +Edge-case awareness is built into the template: + +- Feature flags default to safe behaviour when providers fail. +- Auth, storage, scheduling, and third-party integrations each have dedicated prompts. +- The example tests in `__tests__/flags/` and `__tests__/lib/` show how to assert defensive behaviour. + +Add new lessons learned back into `docs/edge-cases.md` so the template evolves with every incident. + +

back to top ↑

+ +## Testing + +- Tests are organised by domain: `api/`, `components/`, `hooks/`, `flags/`, `lib/`. +- Each suite mocks external dependencies and asserts on both happy-path and failure scenarios. +- See `docs/testing-blueprints.md` for tips on customising them to your project. + +

back to top ↑

+ +## Contributing + +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for branching conventions, commit style, and review expectations. + +

back to top ↑

+ +## License + +Use, remix, or extract any portion of this template for your own projects. Attribution is appreciated but not required. + +

back to top ↑

+ +## Contact + +Nicholai — [@biohazardvfx](https://linkedin.com/in/biohazardvfx) — nicholai@biohazardvfx.com + +Project Link: [https://git.biohazardvfx.com/nicholai/template-repo](https://git.biohazardvfx.com/nicholai/template-repo) + +

back to top ↑

+ + + +[contributors-shield]: https://img.shields.io/gitea/contributors/nicholai/template?style=for-the-badge +[contributors-url]: https://git.biohazardvfx.com/nicholai/template/graphs/contributors +[forks-shield]: https://img.shields.io/gitea/forks/nicholai/template?style=for-the-badge +[forks-url]: https://git.biohazardvfx.com/nicholai/template/network/members +[stars-shield]: https://img.shields.io/gitea/stars/nicholai/template?style=for-the-badge +[stars-url]: https://git.biohazardvfx.com/nicholai/template/stars +[issues-shield]: https://img.shields.io/gitea/issues/nicholai/template?style=for-the-badge +[issues-url]: https://git.biohazardvfx.com/nicholai/template/issues +[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=for-the-badge&logo=linkedin&colorB=555 +[linkedin-url]: https://linkedin.com/in/biohazardvfx diff --git a/__tests__/README.md b/__tests__/README.md new file mode 100644 index 0000000..2297927 --- /dev/null +++ b/__tests__/README.md @@ -0,0 +1,11 @@ +# Test Suite Overview + +The tests in this directory act as executable specifications. They were copied from real projects and are meant to be adapted, not run verbatim. + +How to use them: +- Rename folders to match the first features you build. +- Replace imports from `@/...` with your actual modules once they exist. +- Trim scenarios that do not apply and add new ones that cover risky behaviours or integrations you care about. +- Keep the error-handling and edge-case checks—they are the reason these suites exist. + +Once your implementation is in place, run `pnpm test` (or your preferred command) and fix failing specs until everything passes. The goal is to evolve these tests into living documentation for the application you are building off this template. diff --git a/__tests__/api/artists.test.ts b/__tests__/api/artists.test.ts new file mode 100644 index 0000000..b930baf --- /dev/null +++ b/__tests__/api/artists.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { GET } from '@/app/api/artists/route' +import { NextRequest } from 'next/server' + +// Mock the database functions +vi.mock('@/lib/db', () => ({ + getPublicArtists: vi.fn(), +})) + +import { getPublicArtists } from '@/lib/db' + +describe('GET /api/artists', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return artists successfully', async () => { + const mockArtists = [ + { + id: '1', + slug: 'test-artist', + name: 'Test Artist', + bio: 'Test bio', + specialties: ['Traditional', 'Realism'], + instagramHandle: '@testartist', + portfolioImages: [], + isActive: true, + hourlyRate: 150, + }, + ] + + vi.mocked(getPublicArtists).mockResolvedValue(mockArtists) + + const request = new NextRequest('http://localhost:3000/api/artists') + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.artists).toHaveLength(1) + expect(data.artists[0].name).toBe('Test Artist') + }) + + it('should apply specialty filter', async () => { + const mockArtists = [ + { + id: '1', + slug: 'traditional-artist', + name: 'Traditional Artist', + bio: 'Test bio', + specialties: ['Traditional'], + portfolioImages: [], + isActive: true, + }, + ] + + vi.mocked(getPublicArtists).mockResolvedValue(mockArtists) + + const request = new NextRequest('http://localhost:3000/api/artists?specialty=Traditional') + await GET(request) + + expect(getPublicArtists).toHaveBeenCalledWith( + expect.objectContaining({ + specialty: 'Traditional', + }), + undefined + ) + }) + + it('should apply search filter', async () => { + vi.mocked(getPublicArtists).mockResolvedValue([]) + + const request = new NextRequest('http://localhost:3000/api/artists?search=John') + await GET(request) + + expect(getPublicArtists).toHaveBeenCalledWith( + expect.objectContaining({ + search: 'John', + }), + undefined + ) + }) + + it('should apply pagination', async () => { + vi.mocked(getPublicArtists).mockResolvedValue([]) + + const request = new NextRequest('http://localhost:3000/api/artists?limit=10&page=2') + await GET(request) + + expect(getPublicArtists).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 10, + offset: 10, // page 2 with limit 10 = offset 10 + }), + undefined + ) + }) + + it('should handle database errors gracefully', async () => { + vi.mocked(getPublicArtists).mockRejectedValue(new Error('Database error')) + + const request = new NextRequest('http://localhost:3000/api/artists') + const response = await GET(request) + + expect(response.status).toBe(500) + const data = await response.json() + expect(data).toHaveProperty('error') + }) + + it('should return empty array when no artists found', async () => { + vi.mocked(getPublicArtists).mockResolvedValue([]) + + const request = new NextRequest('http://localhost:3000/api/artists') + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.artists).toEqual([]) + }) +}) diff --git a/__tests__/components/aftercare-page.test.tsx b/__tests__/components/aftercare-page.test.tsx new file mode 100644 index 0000000..8ca9a28 --- /dev/null +++ b/__tests__/components/aftercare-page.test.tsx @@ -0,0 +1,82 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { AftercarePage } from '@/components/aftercare-page' + +describe('AftercarePage ShadCN UI Consistency', () => { + it('uses ShadCN design tokens and primitives correctly', () => { + render() + + // Verify main container uses ShadCN background tokens + const mainContainer = document.querySelector('.min-h-screen') + expect(mainContainer).toHaveClass('bg-background', 'text-foreground') + + // Verify Tabs primitives are present + expect(screen.getByRole('tablist')).toBeInTheDocument() + expect(screen.getByRole('tab', { name: /general tattoo aftercare/i })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: /transparent bandage aftercare/i })).toBeInTheDocument() + + // Verify Alert primitives are present (there are multiple alerts) + const alerts = screen.getAllByRole('alert') + expect(alerts.length).toBeGreaterThan(0) + + // Verify Card primitives are present (multiple cards should exist) + const cards = document.querySelectorAll('[data-slot="card"]') + expect(cards.length).toBeGreaterThan(0) + + // Verify no ad-hoc color classes are used (specifically no text-white) + const htmlContent = document.documentElement.innerHTML + expect(htmlContent).not.toContain('text-white') + + // Verify ShadCN design tokens are used + expect(htmlContent).toContain('text-muted-foreground') + expect(htmlContent).toContain('bg-background') + expect(htmlContent).toContain('text-foreground') + }) + + it('uses consistent ShadCN component structure', () => { + render() + + // Verify TabsList has proper ShadCN structure + const tabsList = screen.getByRole('tablist') + expect(tabsList).toHaveClass('grid', 'w-full', 'grid-cols-2', 'bg-muted', 'border') + + // Verify Alert uses ShadCN structure with proper icon placement + const alerts = screen.getAllByRole('alert') + expect(alerts[0]).toHaveAttribute('data-slot', 'alert') + + // Verify Cards use proper ShadCN structure + const cardHeaders = document.querySelectorAll('[data-slot="card-header"]') + expect(cardHeaders.length).toBeGreaterThan(0) + + const cardContents = document.querySelectorAll('[data-slot="card-content"]') + expect(cardContents.length).toBeGreaterThan(0) + }) + + it('maintains consistent typography and spacing scales', () => { + render() + + // Verify heading uses consistent font classes + const mainHeading = screen.getByText('Tattoo Aftercare') + expect(mainHeading).toHaveClass('font-playfair') + + // Verify muted text uses consistent token + const mutedElements = document.querySelectorAll('.text-muted-foreground') + expect(mutedElements.length).toBeGreaterThan(0) + + // Verify consistent spacing classes are used + const htmlContent = document.documentElement.innerHTML + expect(htmlContent).toContain('space-y-') + expect(htmlContent).toContain('gap-') + expect(htmlContent).toContain('px-8') + expect(htmlContent).toContain('py-6') // Cards use py-6, not py-8 + }) + + it('applies motion classes with reduced-motion safeguard', () => { + render() + const html = document.documentElement.innerHTML + expect(html).toContain('animate-in') + expect(html).toContain('motion-reduce:animate-none') + }) +}) diff --git a/__tests__/components/artist-portfolio.test.tsx b/__tests__/components/artist-portfolio.test.tsx new file mode 100644 index 0000000..6b81698 --- /dev/null +++ b/__tests__/components/artist-portfolio.test.tsx @@ -0,0 +1,99 @@ +import React from 'react' +import { render } from '@testing-library/react' +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { ArtistPortfolio } from '@/components/artist-portfolio' + +// Mock requestAnimationFrame / cancel +global.requestAnimationFrame = vi.fn((cb) => setTimeout(cb, 0) as unknown as number) +global.cancelAnimationFrame = vi.fn((id) => clearTimeout(id as unknown as number)) + +// Default matchMedia mock (no reduced motion) +const createMatchMedia = (matches: boolean) => + vi.fn().mockImplementation((query) => ({ + matches, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + +// Basic getBoundingClientRect mock for panels +const defaultRect = { + top: 0, + bottom: 800, + left: 0, + right: 1200, + width: 1200, + height: 800, + x: 0, + y: 0, + toJSON: () => {}, +} + +describe('ArtistPortfolio Split Hero', () => { + beforeEach(() => { + vi.clearAllMocks() + + // default to no reduced-motion preference + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: createMatchMedia(false), + }) + + // Mock IntersectionObserver (class-like mock to satisfy TS typings) + class MockIntersectionObserver { + constructor(private cb?: IntersectionObserverCallback, private options?: IntersectionObserverInit) {} + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() + takeRecords() { return [] } + } + // Assign the mock class for the test environment + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(global as any).IntersectionObserver = MockIntersectionObserver + + // Mock getBoundingClientRect for all elements + Element.prototype.getBoundingClientRect = vi.fn(() => defaultRect) + }) + + it('initializes left/right panels with CSS var of 0 and transform style when motion allowed', () => { + const { getByTestId } = render() + + const left = getByTestId('artist-left-panel') + const right = getByTestId('artist-right-panel') + + expect(left).toBeInTheDocument() + expect(right).toBeInTheDocument() + + // CSS var should be initialized to 0px on mount + expect(left.style.getPropertyValue('--parallax-offset')).toBe('0px') + expect(right.style.getPropertyValue('--parallax-offset')).toBe('0px') + + // When motion is allowed, the element should expose the translateY style (uses CSS var) + expect(left).toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' }) + expect(right).toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' }) + }) + + it('does not apply parallax transform when prefers-reduced-motion is true', () => { + // Mock reduced motion preference + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: createMatchMedia(true), + }) + + const { getByTestId } = render() + + const left = getByTestId('artist-left-panel') + const right = getByTestId('artist-right-panel') + + // With reduced motion, the hook should not add transform/willChange styles + expect(left).not.toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' }) + expect(left).not.toHaveStyle({ willChange: 'transform' }) + + expect(right).not.toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' }) + expect(right).not.toHaveStyle({ willChange: 'transform' }) + }) +}) diff --git a/__tests__/components/artists-grid.test.tsx b/__tests__/components/artists-grid.test.tsx new file mode 100644 index 0000000..8c371b2 --- /dev/null +++ b/__tests__/components/artists-grid.test.tsx @@ -0,0 +1,202 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { ArtistsGrid } from '@/components/artists-grid' +import '@testing-library/jest-dom' + +// Mock the custom hook +vi.mock('@/hooks/use-artist-data', () => ({ + useArtists: vi.fn(), +})) + +import { useArtists } from '@/hooks/use-artist-data' + +describe('ArtistsGrid Component', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should display loading state', () => { + vi.mocked(useArtists).mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + } as any) + + render() + + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('should display artists when loaded', async () => { + const mockArtists = [ + { + id: '1', + slug: 'test-artist', + name: 'Test Artist', + bio: 'Test bio', + specialties: ['Traditional', 'Realism'], + instagramHandle: '@testartist', + portfolioImages: [ + { + id: '1', + artistId: '1', + url: 'https://example.com/image.jpg', + caption: 'Test image', + tags: ['Traditional'], + isPublic: true, + orderIndex: 0, + createdAt: new Date(), + }, + ], + isActive: true, + hourlyRate: 150, + }, + ] + + vi.mocked(useArtists).mockReturnValue({ + data: mockArtists, + isLoading: false, + error: null, + } as any) + + render() + + await waitFor(() => { + expect(screen.getByText('Test Artist')).toBeInTheDocument() + }) + + expect(screen.getByText(/Traditional, Realism/i)).toBeInTheDocument() + expect(screen.getByText('Available')).toBeInTheDocument() + }) + + it('should display error state', () => { + vi.mocked(useArtists).mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('Failed to fetch'), + } as any) + + render() + + expect(screen.getByText(/Failed to load artists/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument() + }) + + it('should display empty state when no artists match filter', async () => { + vi.mocked(useArtists).mockReturnValue({ + data: [], + isLoading: false, + error: null, + } as any) + + render() + + await waitFor(() => { + expect(screen.getByText(/No artists found/i)).toBeInTheDocument() + }) + }) + + it('should display artist cards with portfolio images', async () => { + const mockArtists = [ + { + id: '1', + slug: 'artist-one', + name: 'Artist One', + bio: 'Bio one', + specialties: ['Traditional'], + portfolioImages: [ + { + id: '1', + artistId: '1', + url: 'https://example.com/img1.jpg', + tags: ['profile'], + isPublic: true, + orderIndex: 0, + createdAt: new Date(), + }, + ], + isActive: true, + hourlyRate: 100, + }, + ] + + vi.mocked(useArtists).mockReturnValue({ + data: mockArtists, + isLoading: false, + error: null, + } as any) + + render() + + await waitFor(() => { + // Check for View Portfolio link + const portfolioLink = screen.getByRole('link', { name: /View Portfolio/i }) + expect(portfolioLink).toHaveAttribute('href', '/artists/artist-one') + + // Check for Book Now link + const bookLink = screen.getByRole('link', { name: /Book Now/i }) + expect(bookLink).toHaveAttribute('href', '/book?artist=artist-one') + + // Check for hourly rate display + expect(screen.getByText(/\$100\/hr/i)).toBeInTheDocument() + }) + }) + + it('should display specialties as badges', async () => { + const mockArtists = [ + { + id: '1', + slug: 'multi-specialty-artist', + name: 'Multi Specialty Artist', + bio: 'Expert in multiple styles', + specialties: ['Traditional', 'Realism', 'Fine Line', 'Japanese'], + portfolioImages: [], + isActive: true, + }, + ] + + vi.mocked(useArtists).mockReturnValue({ + data: mockArtists, + isLoading: false, + error: null, + } as any) + + render() + + await waitFor(() => { + // Should show first 3 specialties + expect(screen.getByText('Traditional')).toBeInTheDocument() + expect(screen.getByText('Realism')).toBeInTheDocument() + expect(screen.getByText('Fine Line')).toBeInTheDocument() + + // Should show "+1 more" badge for the 4th specialty + expect(screen.getByText('+1 more')).toBeInTheDocument() + }) + }) + + it('should show inactive badge for inactive artists', async () => { + const mockArtists = [ + { + id: '1', + slug: 'inactive-artist', + name: 'Inactive Artist', + bio: 'Currently unavailable', + specialties: ['Traditional'], + portfolioImages: [], + isActive: false, + }, + ] + + vi.mocked(useArtists).mockReturnValue({ + data: mockArtists, + isLoading: false, + error: null, + } as any) + + render() + + await waitFor(() => { + expect(screen.getByText('Unavailable')).toBeInTheDocument() + }) + }) +}) diff --git a/__tests__/components/hero-section.test.tsx b/__tests__/components/hero-section.test.tsx new file mode 100644 index 0000000..b968223 --- /dev/null +++ b/__tests__/components/hero-section.test.tsx @@ -0,0 +1,132 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { HeroSection } from '@/components/hero-section' + +// Mock the feature flags provider +vi.mock('@/components/feature-flags-provider', () => ({ + useFeatureFlag: vi.fn(() => true), +})) + +// Mock the parallax hooks +vi.mock('@/hooks/use-parallax', () => ({ + useMultiLayerParallax: vi.fn(() => ({ + background: { + ref: { current: null }, + style: { transform: 'translateY(0px)' }, + }, + midground: { + ref: { current: null }, + style: { transform: 'translateY(0px)' }, + }, + foreground: { + ref: { current: null }, + style: { transform: 'translateY(0px)' }, + }, + })), + useReducedMotion: vi.fn(() => false), +})) + +describe('HeroSection Parallax Implementation', () => { + beforeEach(() => { + // Reset mocks + vi.clearAllMocks() + }) + + it("renders hero section with all layers", () => { + render() + + // Check for main heading + expect(screen.getByRole("heading", { name: /united tattoo/i })).toBeInTheDocument() + + // Check for tagline + expect(screen.getByText(/where artistry meets precision/i)).toBeInTheDocument() + + // Check for CTA button + expect(screen.getByRole("button", { name: /book consultation/i })).toBeInTheDocument() + }) + + it('applies reduced motion data attribute when reduced motion is preferred', async () => { + const { useReducedMotion } = await import('@/hooks/use-parallax') + vi.mocked(useReducedMotion).mockReturnValue(true) + + render() + + const section = document.querySelector('section') + expect(section).toHaveAttribute('data-reduced-motion', 'true') + }) + + it("has proper accessibility attributes for decorative images", () => { + render() + + // Background and midground layers should be aria-hidden + const decorativeElements = document.querySelectorAll('[aria-hidden="true"]') + expect(decorativeElements.length).toBeGreaterThan(0) + }) + + it("uses proper semantic structure", () => { + render() + + // Should have proper heading hierarchy + const heading = screen.getByRole("heading", { name: /united tattoo/i }) + expect(heading.tagName).toBe("H1") + + // Should have proper section structure + const section = document.querySelector("section") + expect(section).toHaveAttribute("id", "home") + }) + + it("applies will-change-transform for performance optimization", () => { + render() + + const transformElements = document.querySelectorAll(".will-change-transform") + expect(transformElements.length).toBeGreaterThan(0) + }) + + it('respects feature flag for advanced animations', async () => { + const { useFeatureFlag } = await import('@/components/feature-flags-provider') + const { useMultiLayerParallax } = await import('@/hooks/use-parallax') + + // Test with feature flag disabled + vi.mocked(useFeatureFlag).mockReturnValue(false) + + render() + + // Should pass disabled=true to parallax hook when feature flag is off + expect(useMultiLayerParallax).toHaveBeenCalledWith(true) + }) + + it("has responsive design classes", () => { + render() + + const heading = screen.getByRole("heading", { name: /united tattoo/i }) + expect(heading).toHaveClass("text-5xl", "lg:text-7xl") + + const tagline = screen.getByText(/where artistry meets precision/i) + expect(tagline).toHaveClass("text-xl", "lg:text-2xl") + }) + + it("initializes parallax transforms to 0 at mount", () => { + render() + + // All parallax layers should initialize with 0px transform + const backgroundLayer = document.querySelector('[style*="translateY(0px)"]') + const midgroundLayer = document.querySelectorAll('[style*="translateY(0px)"]')[1] + const foregroundLayer = document.querySelectorAll('[style*="translateY(0px)"]')[2] + + expect(backgroundLayer).toBeInTheDocument() + expect(midgroundLayer).toBeInTheDocument() + expect(foregroundLayer).toBeInTheDocument() + }) + + it("disables parallax transforms when reduced motion is preferred", async () => { + const { useReducedMotion } = await import('@/hooks/use-parallax') + vi.mocked(useReducedMotion).mockReturnValue(true) + + render() + + // When reduced motion is preferred, parallax should be disabled + const section = document.querySelector('section') + expect(section).toHaveAttribute('data-reduced-motion', 'true') + }) +}) diff --git a/__tests__/components/privacy-page.test.tsx b/__tests__/components/privacy-page.test.tsx new file mode 100644 index 0000000..139c655 --- /dev/null +++ b/__tests__/components/privacy-page.test.tsx @@ -0,0 +1,109 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { PrivacyPage } from '@/components/privacy-page' + +describe('PrivacyPage ShadCN UI Consistency', () => { + it('uses standardized heading and body scales with ShadCN primitives', () => { + render() + + // Verify main container uses ShadCN background tokens + const mainContainer = document.querySelector('.min-h-screen') + expect(mainContainer).toHaveClass('bg-background', 'text-foreground') + + // Verify heading uses consistent font classes and scale + const mainHeading = screen.getByText('Privacy Policy') + expect(mainHeading).toHaveClass('font-playfair', 'text-5xl', 'lg:text-7xl') + + // Verify body text uses consistent muted foreground token + const bodyText = screen.getByText(/We respect your privacy/) + expect(bodyText).toHaveClass('text-muted-foreground') + + // Verify no ad-hoc color classes are used + const htmlContent = document.documentElement.innerHTML + expect(htmlContent).not.toContain('text-white') + expect(htmlContent).not.toContain('text-gray-300') + expect(htmlContent).not.toContain('bg-white/5') + expect(htmlContent).not.toContain('border-white/10') + + // Verify ShadCN design tokens are consistently used + expect(htmlContent).toContain('text-muted-foreground') + expect(htmlContent).toContain('bg-background') + expect(htmlContent).toContain('text-foreground') + }) + + it('uses ShadCN primitives correctly throughout the page', () => { + render() + + // Verify Alert primitive is present and properly structured + const alert = screen.getByRole('alert') + expect(alert).toHaveAttribute('data-slot', 'alert') + + // Verify Badge primitive is present + const badge = screen.getByText('Last updated: 2025-09-16') + expect(badge).toBeInTheDocument() + + // Verify Card primitives are present (multiple cards should exist) + const cards = document.querySelectorAll('[data-slot="card"]') + expect(cards.length).toBeGreaterThan(0) + + // Verify Card headers and content use proper ShadCN structure + const cardHeaders = document.querySelectorAll('[data-slot="card-header"]') + expect(cardHeaders.length).toBeGreaterThan(0) + + const cardContents = document.querySelectorAll('[data-slot="card-content"]') + expect(cardContents.length).toBeGreaterThan(0) + + // Verify all CardContent uses muted foreground token + const cardContentElements = document.querySelectorAll('[data-slot="card-content"]') + cardContentElements.forEach(element => { + expect(element).toHaveClass('text-muted-foreground') + }) + }) + + it('maintains consistent spacing and typography patterns', () => { + render() + + // Verify consistent spacing classes are used + const htmlContent = document.documentElement.innerHTML + expect(htmlContent).toContain('space-y-3') + expect(htmlContent).toContain('gap-6') + expect(htmlContent).toContain('px-8') + expect(htmlContent).toContain('lg:px-16') + + // Verify consistent text sizing + expect(htmlContent).toContain('text-xl') + expect(htmlContent).toContain('leading-relaxed') + + // Verify grid layout consistency + expect(htmlContent).toContain('grid-cols-1') + expect(htmlContent).toContain('lg:grid-cols-2') + + // Verify responsive design patterns + expect(htmlContent).toContain('max-w-4xl') + expect(htmlContent).toContain('max-w-6xl') + }) + + it('uses proper icon integration with ShadCN components', () => { + render() + + // Verify icons are properly integrated without ad-hoc color classes + const infoIcon = document.querySelector('.lucide-info') + expect(infoIcon).toBeInTheDocument() + + // Verify icons use consistent sizing + const htmlContent = document.documentElement.innerHTML + expect(htmlContent).toContain('w-5 h-5') + + // Verify icons don't have ad-hoc color overrides + expect(htmlContent).not.toContain('text-white') + }) + + it('applies motion classes with reduced-motion safeguard', () => { + render() + const html = document.documentElement.innerHTML + expect(html).toContain('animate-in') + expect(html).toContain('motion-reduce:animate-none') + }) +}) diff --git a/__tests__/flags/api-appointments-booking-disabled.test.ts b/__tests__/flags/api-appointments-booking-disabled.test.ts new file mode 100644 index 0000000..ab0300c --- /dev/null +++ b/__tests__/flags/api-appointments-booking-disabled.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/flags', () => ({ + Flags: { BOOKING_ENABLED: false }, +})) +vi.mock('@/lib/auth', () => ({ + authOptions: {}, +})) +vi.mock('next-auth', () => ({ + getServerSession: vi.fn(), +})) + +describe('Booking appointments mutations with BOOKING_ENABLED=false', () => { + it('POST returns 503 without invoking booking logic', async () => { + const { POST } = await import('../../app/api/appointments/route') + const response = await POST({} as any) + expect(response.status).toBe(503) + await expect(response.json()).resolves.toEqual({ error: 'Booking disabled' }) + }) + + it('PUT returns 503 without invoking booking logic', async () => { + const { PUT } = await import('../../app/api/appointments/route') + const response = await PUT({} as any) + expect(response.status).toBe(503) + await expect(response.json()).resolves.toEqual({ error: 'Booking disabled' }) + }) + + it('DELETE returns 503 without invoking booking logic', async () => { + const { DELETE } = await import('../../app/api/appointments/route') + const response = await DELETE({} as any) + expect(response.status).toBe(503) + await expect(response.json()).resolves.toEqual({ error: 'Booking disabled' }) + }) +}) diff --git a/__tests__/flags/api-uploads-disabled.test.ts b/__tests__/flags/api-uploads-disabled.test.ts new file mode 100644 index 0000000..0d9d24b --- /dev/null +++ b/__tests__/flags/api-uploads-disabled.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, vi } from 'vitest' + +vi.mock('@/lib/flags', () => ({ + Flags: { UPLOADS_ADMIN_ENABLED: false }, +})) +vi.mock('@/lib/auth', () => ({ + authOptions: {}, + requireAuth: vi.fn(), +})) +vi.mock('next-auth', () => ({ + getServerSession: vi.fn(async () => null), +})) + +describe('Uploads admin disabled', () => { + it('returns 503 for files bulk-delete when UPLOADS_ADMIN_ENABLED=false', async () => { + const { POST } = await import('../../app/api/files/bulk-delete/route') + const fakeReq: any = { json: async () => ({ fileIds: ['1'] }) } + const res = await POST(fakeReq as any) + const body = await res.json() + expect(res.status).toBe(503) + expect(body).toHaveProperty('error') + }) +}) diff --git a/__tests__/flags/artists-section.static.test.tsx b/__tests__/flags/artists-section.static.test.tsx new file mode 100644 index 0000000..1a5f853 --- /dev/null +++ b/__tests__/flags/artists-section.static.test.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import { renderToString } from 'react-dom/server' +import { describe, expect, it } from 'vitest' + +import { ArtistsSection } from '@/components/artists-section' +import { FeatureFlagsProvider } from '@/components/feature-flags-provider' +import { FLAG_DEFAULTS } from '@/lib/flags' + +const disabledAnimationFlags = { + ...FLAG_DEFAULTS, + ADVANCED_NAV_SCROLL_ANIMATIONS_ENABLED: false, +} as typeof FLAG_DEFAULTS + +describe('ArtistsSection static fallback when animations disabled', () => { + it('renders cards visible without animation classes', () => { + const html = renderToString( + + + , + ) + + expect(html).not.toContain('opacity-0 translate-y-8') + expect(html).toContain('opacity-100 translate-y-0') + }) +}) diff --git a/__tests__/flags/booking-form.disabled.test.ts b/__tests__/flags/booking-form.disabled.test.ts new file mode 100644 index 0000000..7049113 --- /dev/null +++ b/__tests__/flags/booking-form.disabled.test.ts @@ -0,0 +1,22 @@ +import React from "react" +import { renderToString } from "react-dom/server" +import { describe, expect, it } from "vitest" + +import { BookingForm } from "@/components/booking-form" +import { FeatureFlagsProvider } from "@/components/feature-flags-provider" +import { FLAG_DEFAULTS } from "@/lib/flags" + +const disabledFlags = { ...FLAG_DEFAULTS, BOOKING_ENABLED: false } as typeof FLAG_DEFAULTS + +describe("BookingForm disabled mode (SSR string)", () => { + it("includes disabled notice when BOOKING_ENABLED=false", () => { + const html = renderToString( + + + , + ) + + expect(html).toContain("Online booking is temporarily unavailable") + expect(html).toContain("contact the studio") + }) +}) diff --git a/__tests__/hooks/use-parallax.test.tsx b/__tests__/hooks/use-parallax.test.tsx new file mode 100644 index 0000000..7aa30eb --- /dev/null +++ b/__tests__/hooks/use-parallax.test.tsx @@ -0,0 +1,199 @@ +import React from 'react' +import { render, act } from '@testing-library/react' +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { useParallax, useReducedMotion } from '@/hooks/use-parallax' + +// Mock window methods +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}) + +// Mock window properties +Object.defineProperty(window, 'pageYOffset', { + writable: true, + value: 0, +}) + +Object.defineProperty(window, 'innerHeight', { + writable: true, + value: 800, +}) + +// Mock requestAnimationFrame +global.requestAnimationFrame = vi.fn(callback => setTimeout(callback, 0)) +global.cancelAnimationFrame = vi.fn(id => clearTimeout(id)) + +// Mock IntersectionObserver +global.IntersectionObserver = vi.fn(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})) + +// Mock getBoundingClientRect +Element.prototype.getBoundingClientRect = vi.fn(() => ({ + top: 0, + bottom: 100, + left: 0, + right: 100, + width: 100, + height: 100, + x: 0, + y: 0, + toJSON: () => {}, +})) + +// Test component that uses the parallax hook +const TestComponent = ({ depth = 0.1, disabled = false }: { depth?: number; disabled?: boolean }) => { + const parallax = useParallax({ depth, disabled }) + + return ( +
+ Test Element +
+ ) +} + +describe('useParallax Hook', () => { + beforeEach(() => { + // Reset mocks + vi.clearAllMocks() + + // Reset window properties + Object.defineProperty(window, 'pageYOffset', { + writable: true, + value: 0, + }) + + // Reset mock implementations + Element.prototype.getBoundingClientRect = vi.fn(() => ({ + top: 0, + bottom: 100, + left: 0, + right: 100, + width: 100, + height: 100, + x: 0, + y: 0, + toJSON: () => {}, + })) + }) + + it('initializes CSS transform to 0 at mount', () => { + render() + + const element = document.querySelector('[data-testid="parallax-element"]') + expect(element).toBeInTheDocument() + + // Initially should have 0px transform via CSS variable + expect(element).toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' }) + }) + + it('does not apply translation until scroll occurs', () => { + render() + + const element = document.querySelector('[data-testid="parallax-element"]') + expect(element).toBeInTheDocument() + + // Initially should have 0px transform via CSS variable + expect(element).toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' }) + + // Simulate scroll + act(() => { + Object.defineProperty(window, 'pageYOffset', { + writable: true, + value: 100, + }) + window.dispatchEvent(new Event('scroll')) + }) + + // After scroll, transform should still use CSS variable + expect(element).toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' }) + }) + + it('respects disabled prop and does not apply transforms', () => { + render() + + const element = document.querySelector('[data-testid="parallax-element"]') + expect(element).toBeInTheDocument() + + // With disabled=true, should have no transform styles + expect(element).not.toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' }) + expect(element).not.toHaveStyle({ willChange: 'transform' }) + }) +}) + +describe('useReducedMotion Hook', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('initializes with correct boolean value from prefersReducedMotion()', () => { + // Mock matchMedia to return true for reduced motion + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(query => ({ + matches: query === '(prefers-reduced-motion: reduce)', + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }) + + let reducedMotionValue: boolean + const TestReducedMotionComponent = () => { + reducedMotionValue = useReducedMotion() + return
Test
+ } + + render() + + // Should be a boolean value, not a function reference + expect(typeof reducedMotionValue).toBe('boolean') + expect(reducedMotionValue).toBe(true) + }) + + it('disables parallax transforms when reduced motion is preferred', () => { + // Mock matchMedia to return true for reduced motion + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(query => ({ + matches: query === '(prefers-reduced-motion: reduce)', + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }) + + render() + + const element = document.querySelector('[data-testid="parallax-element"]') + expect(element).toBeInTheDocument() + + // With reduced motion, should have no transform styles + expect(element).not.toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' }) + expect(element).not.toHaveStyle({ willChange: 'transform' }) + }) +}) \ No newline at end of file diff --git a/__tests__/lib/data-migration.test.ts b/__tests__/lib/data-migration.test.ts new file mode 100644 index 0000000..638ed0d --- /dev/null +++ b/__tests__/lib/data-migration.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' + +// Mock the database using proper Vitest patterns +const mockStmt = { + bind: vi.fn().mockReturnThis(), + run: vi.fn().mockResolvedValue({ success: true, changes: 1 }), + get: vi.fn(), + all: vi.fn().mockResolvedValue({ results: [] }), + first: vi.fn().mockResolvedValue(null), +} + +const mockDB = { + prepare: vi.fn().mockReturnValue(mockStmt), + exec: vi.fn(), +} + +// Mock the entire lib/db module +vi.mock('@/lib/db', () => ({ + getDB: vi.fn(() => mockDB), +})) + +// Mock the artists data with proper structure +vi.mock('@/data/artists', () => ({ + artists: [ + { + id: '1', + name: 'Test Artist', + bio: 'Test bio', + styles: ['Traditional', 'Realism'], + instagram: 'https://instagram.com/testartist', + experience: '5 years', + workImages: ['/test-image.jpg'], + faceImage: '/test-face.jpg', + }, + { + id: '2', + name: 'Another Artist', + bio: 'Another bio', + styles: ['Japanese', 'Blackwork'], + instagram: 'https://instagram.com/anotherartist', + experience: '8 years', + workImages: [], + faceImage: '/another-face.jpg', + }, + ], +})) + +describe('DataMigrator', () => { + let DataMigrator: any + let migrator: any + + beforeEach(async () => { + vi.clearAllMocks() + // Reset mock implementations + mockDB.prepare.mockReturnValue(mockStmt) + mockStmt.first.mockResolvedValue(null) + mockStmt.run.mockResolvedValue({ success: true, changes: 1 }) + + // Import the DataMigrator class after mocks are set up + const module = await import('@/lib/data-migration') + DataMigrator = module.DataMigrator + migrator = new DataMigrator() + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + describe('isMigrationCompleted', () => { + it('should return false when no artists exist', async () => { + mockStmt.first.mockResolvedValueOnce({ count: 0 }) + + const isCompleted = await migrator.isMigrationCompleted() + + expect(isCompleted).toBe(false) + }) + + it('should return true when artists exist', async () => { + mockStmt.first.mockResolvedValueOnce({ count: 2 }) + + const isCompleted = await migrator.isMigrationCompleted() + + expect(isCompleted).toBe(true) + }) + }) + + describe('migrateArtistData', () => { + it('should migrate all artists successfully', async () => { + await migrator.migrateArtistData() + + // Verify user creation calls + expect(mockDB.prepare).toHaveBeenCalledWith( + expect.stringContaining('INSERT OR IGNORE INTO users') + ) + + // Verify artist creation calls + expect(mockDB.prepare).toHaveBeenCalledWith( + expect.stringContaining('INSERT OR IGNORE INTO artists') + ) + + // Verify portfolio image creation calls + expect(mockDB.prepare).toHaveBeenCalledWith( + expect.stringContaining('INSERT OR IGNORE INTO portfolio_images') + ) + }) + + it('should handle errors gracefully', async () => { + mockStmt.run.mockRejectedValueOnce(new Error('Database error')) + + await expect(migrator.migrateArtistData()).rejects.toThrow('Database error') + }) + }) + + describe('clearMigratedData', () => { + it('should clear all data successfully', async () => { + await migrator.clearMigratedData() + + expect(mockDB.prepare).toHaveBeenCalledWith('DELETE FROM portfolio_images') + expect(mockDB.prepare).toHaveBeenCalledWith('DELETE FROM artists') + expect(mockDB.prepare).toHaveBeenCalledWith('DELETE FROM users WHERE role = "ARTIST"') + }) + + it('should handle clear data errors', async () => { + mockStmt.run.mockRejectedValueOnce(new Error('Clear error')) + + await expect(migrator.clearMigratedData()).rejects.toThrow('Clear error') + }) + }) + + describe('getMigrationStats', () => { + it('should return correct migration statistics', async () => { + mockStmt.first + .mockResolvedValueOnce({ count: 3 }) // total users + .mockResolvedValueOnce({ count: 2 }) // total artists + .mockResolvedValueOnce({ count: 1 }) // total portfolio images + + const stats = await migrator.getMigrationStats() + + expect(stats.totalUsers).toBe(3) + expect(stats.totalArtists).toBe(2) + expect(stats.totalPortfolioImages).toBe(1) + }) + }) +}) diff --git a/__tests__/lib/db.test.ts b/__tests__/lib/db.test.ts new file mode 100644 index 0000000..5d50890 --- /dev/null +++ b/__tests__/lib/db.test.ts @@ -0,0 +1,269 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + getArtists, + getArtistWithPortfolio, + getPublicArtists, + getArtistBySlug, + updateArtist, + addPortfolioImage, + updatePortfolioImage, + deletePortfolioImage, +} from '@/lib/db' + +// Mock D1 database +const createMockD1 = () => ({ + prepare: vi.fn().mockReturnThis(), + bind: vi.fn().mockReturnThis(), + first: vi.fn(), + all: vi.fn(), + run: vi.fn(), +}) + +describe('Database Functions', () => { + let mockEnv: { DB: ReturnType } + + beforeEach(() => { + mockEnv = { + DB: createMockD1(), + } + vi.clearAllMocks() + }) + + describe('getArtists', () => { + it('should fetch all artists and parse JSON fields', async () => { + const mockArtists = [ + { + id: '1', + name: 'Test Artist', + bio: 'Test bio', + specialties: '["Traditional","Realism"]', + isActive: 1, + }, + ] + + mockEnv.DB.all.mockResolvedValue({ + results: mockArtists, + success: true, + }) + + const result = await getArtists(mockEnv) + + expect(result).toHaveLength(1) + expect(result[0].specialties).toEqual(['Traditional', 'Realism']) + expect(result[0].isActive).toBe(true) + }) + + it('should handle empty results', async () => { + mockEnv.DB.all.mockResolvedValue({ + results: [], + success: true, + }) + + const result = await getArtists(mockEnv) + expect(result).toEqual([]) + }) + + it('should handle database errors', async () => { + mockEnv.DB.all.mockRejectedValue(new Error('Database error')) + + await expect(getArtists(mockEnv)).rejects.toThrow('Database error') + }) + }) + + describe('getArtistWithPortfolio', () => { + it('should fetch artist with portfolio images', async () => { + const mockArtist = { + id: '1', + name: 'Test Artist', + bio: 'Test bio', + specialties: '["Traditional"]', + isActive: 1, + } + + const mockImages = [ + { + id: '1', + artistId: '1', + url: 'https://example.com/image.jpg', + caption: 'Test image', + tags: '["Traditional","Portrait"]', + isPublic: 1, + orderIndex: 0, + }, + ] + + mockEnv.DB.first.mockResolvedValueOnce(mockArtist) + mockEnv.DB.all.mockResolvedValueOnce({ + results: mockImages, + success: true, + }) + + const result = await getArtistWithPortfolio('1', mockEnv) + + expect(result).toBeDefined() + expect(result?.name).toBe('Test Artist') + expect(result?.portfolioImages).toHaveLength(1) + expect(result?.portfolioImages[0].tags).toEqual(['Traditional', 'Portrait']) + }) + + it('should return null for non-existent artist', async () => { + mockEnv.DB.first.mockResolvedValue(null) + + const result = await getArtistWithPortfolio('999', mockEnv) + expect(result).toBeNull() + }) + }) + + describe('getPublicArtists', () => { + it('should return only active artists with public images', async () => { + const mockArtists = [ + { + id: '1', + name: 'Active Artist', + specialties: '["Traditional"]', + isActive: 1, + }, + { + id: '2', + name: 'Inactive Artist', + specialties: '["Realism"]', + isActive: 0, + }, + ] + + mockEnv.DB.all.mockResolvedValue({ + results: mockArtists.filter(a => a.isActive), + success: true, + }) + + const result = await getPublicArtists({}, mockEnv) + + expect(result).toHaveLength(1) + expect(result[0].name).toBe('Active Artist') + }) + + it('should filter by specialty', async () => { + const mockArtists = [ + { + id: '1', + name: 'Traditional Artist', + specialties: '["Traditional"]', + isActive: 1, + }, + ] + + mockEnv.DB.all.mockResolvedValue({ + results: mockArtists, + success: true, + }) + + await getPublicArtists({ specialty: 'Traditional' }, mockEnv) + + // Verify the bind was called (specialty filter applied) + expect(mockEnv.DB.bind).toHaveBeenCalled() + }) + }) + + describe('getArtistBySlug', () => { + it('should fetch artist by slug', async () => { + const mockArtist = { + id: '1', + slug: 'test-artist', + name: 'Test Artist', + specialties: '["Traditional"]', + } + + mockEnv.DB.first.mockResolvedValue(mockArtist) + mockEnv.DB.all.mockResolvedValue({ + results: [], + success: true, + }) + + const result = await getArtistBySlug('test-artist', mockEnv) + + expect(result).toBeDefined() + expect(result?.slug).toBe('test-artist') + expect(mockEnv.DB.bind).toHaveBeenCalledWith('test-artist') + }) + }) + + describe('updateArtist', () => { + it('should update artist and stringify JSON fields', async () => { + const updateData = { + id: '1', + name: 'Updated Name', + bio: 'Updated bio', + specialties: ['Traditional', 'Realism'], + hourlyRate: 150, + } + + mockEnv.DB.run.mockResolvedValue({ + success: true, + meta: { changes: 1 }, + }) + + await updateArtist('1', updateData, mockEnv) + + // Verify the update was called + expect(mockEnv.DB.run).toHaveBeenCalled() + expect(mockEnv.DB.bind).toHaveBeenCalled() + }) + }) + + describe('Portfolio Image Operations', () => { + it('should add portfolio image', async () => { + const imageData = { + url: 'https://example.com/image.jpg', + caption: 'Test caption', + tags: ['Traditional'], + isPublic: true, + orderIndex: 0, + } + + mockEnv.DB.run.mockResolvedValue({ + success: true, + meta: { last_row_id: 1 }, + }) + + mockEnv.DB.first.mockResolvedValue({ + id: '1', + ...imageData, + artistId: '1', + tags: JSON.stringify(imageData.tags), + }) + + const result = await addPortfolioImage('1', imageData, mockEnv) + + expect(result).toBeDefined() + expect(result.caption).toBe('Test caption') + }) + + it('should update portfolio image', async () => { + const updateData = { + caption: 'Updated caption', + tags: ['Traditional', 'Portrait'], + isPublic: false, + } + + mockEnv.DB.run.mockResolvedValue({ + success: true, + meta: { changes: 1 }, + }) + + await updatePortfolioImage('1', updateData, mockEnv) + + expect(mockEnv.DB.run).toHaveBeenCalled() + }) + + it('should delete portfolio image', async () => { + mockEnv.DB.run.mockResolvedValue({ + success: true, + meta: { changes: 1 }, + }) + + await deletePortfolioImage('1', mockEnv) + + expect(mockEnv.DB.run).toHaveBeenCalled() + }) + }) +}) diff --git a/__tests__/lib/flags.test.ts b/__tests__/lib/flags.test.ts new file mode 100644 index 0000000..63e9c67 --- /dev/null +++ b/__tests__/lib/flags.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest" + +import { + FLAG_DEFAULTS, + Flags, + getFlags, + registerRuntimeFlags, + resetFlagsCache, + parseBool, +} from "@/lib/flags" + +type FlagName = keyof typeof FLAG_DEFAULTS +const flagKeys = Object.keys(FLAG_DEFAULTS) as FlagName[] + +const originalEnv: Partial> = {} + +beforeEach(() => { + resetFlagsCache() + for (const key of flagKeys) { + if (!(key in originalEnv)) { + originalEnv[key] = process.env[key] + } + delete process.env[key] + } + delete (globalThis as Record).__UNITED_TATTOO_RUNTIME_FLAGS__ +}) + +afterEach(() => { + resetFlagsCache() + for (const key of flagKeys) { + const value = originalEnv[key] + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + delete (globalThis as Record).__UNITED_TATTOO_RUNTIME_FLAGS__ +}) + +describe("parseBool", () => { + it("handles string coercion and defaults", () => { + expect(parseBool("true", false)).toBe(true) + expect(parseBool(" FALSE ", true)).toBe(false) + expect(parseBool("1", false)).toBe(true) + expect(parseBool(undefined, true)).toBe(true) + }) +}) + +describe("getFlags", () => { + it("falls back to defaults and logs missing keys", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + const snapshot = getFlags({ refresh: true }) + + expect(snapshot).toMatchObject(FLAG_DEFAULTS) + expect(warnSpy).toHaveBeenCalled() + + warnSpy.mockRestore() + }) + + it("honours environment overrides", () => { + process.env.BOOKING_ENABLED = "false" + process.env.PUBLIC_APPOINTMENT_REQUESTS_ENABLED = "true" + + const snapshot = getFlags({ refresh: true }) + + expect(snapshot.BOOKING_ENABLED).toBe(false) + expect(snapshot.PUBLIC_APPOINTMENT_REQUESTS_ENABLED).toBe(true) + }) +}) + +describe("registerRuntimeFlags", () => { + it("allows runtime overrides to take precedence", () => { + process.env.BOOKING_ENABLED = "true" + const override = { ...FLAG_DEFAULTS, BOOKING_ENABLED: false } as typeof FLAG_DEFAULTS + + registerRuntimeFlags(override) + const snapshot = getFlags() + + expect(snapshot.BOOKING_ENABLED).toBe(false) + }) +}) + +describe("Flags proxy", () => { + it("reflects current snapshot values", () => { + process.env.ADMIN_ENABLED = "false" + const snapshot = getFlags({ refresh: true }) + expect(snapshot.ADMIN_ENABLED).toBe(false) + expect(Flags.ADMIN_ENABLED).toBe(false) + }) +}) diff --git a/__tests__/lib/validations.test.ts b/__tests__/lib/validations.test.ts new file mode 100644 index 0000000..53a2a8e --- /dev/null +++ b/__tests__/lib/validations.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest' +import { createArtistSchema, createAppointmentSchema } from '@/lib/validations' + +describe('Validation Schemas', () => { + describe('createArtistSchema', () => { + it('should validate a valid artist object', () => { + const validArtist = { + name: 'John Doe', + bio: 'Experienced tattoo artist', + specialties: ['Traditional', 'Realism'], + instagramHandle: 'johndoe', + hourlyRate: 150, + isActive: true, + } + + const result = createArtistSchema.safeParse(validArtist) + expect(result.success).toBe(true) + }) + + it('should reject artist with invalid data', () => { + const invalidArtist = { + name: '', // Empty name should fail + bio: 'Bio', + specialties: [], + hourlyRate: -50, // Negative rate should fail + } + + const result = createArtistSchema.safeParse(invalidArtist) + expect(result.success).toBe(false) + }) + + it('should require name field', () => { + const artistWithoutName = { + bio: 'Bio', + specialties: ['Traditional'], + hourlyRate: 150, + } + + const result = createArtistSchema.safeParse(artistWithoutName) + expect(result.success).toBe(false) + }) + }) + + describe('createAppointmentSchema', () => { + it('should validate a valid appointment object', () => { + const validAppointment = { + clientName: 'Jane Smith', + clientEmail: 'jane@example.com', + clientPhone: '+1234567890', + artistId: 'artist-123', + startTime: new Date('2024-12-01T10:00:00Z'), + endTime: new Date('2024-12-01T12:00:00Z'), + description: 'Traditional rose tattoo', + estimatedPrice: 300, + status: 'PENDING' as const, + } + + const result = createAppointmentSchema.safeParse(validAppointment) + expect(result.success).toBe(true) + }) + + it('should reject appointment with invalid email', () => { + const invalidAppointment = { + clientName: 'Jane Smith', + clientEmail: 'invalid-email', // Invalid email format + artistId: 'artist-123', + startTime: new Date('2024-12-01T10:00:00Z'), + endTime: new Date('2024-12-01T12:00:00Z'), + description: 'Tattoo description', + status: 'PENDING' as const, + } + + const result = createAppointmentSchema.safeParse(invalidAppointment) + expect(result.success).toBe(false) + }) + + it('should reject appointment with end time before start time', () => { + const invalidAppointment = { + clientName: 'Jane Smith', + clientEmail: 'jane@example.com', + artistId: 'artist-123', + startTime: new Date('2024-12-01T12:00:00Z'), + endTime: new Date('2024-12-01T10:00:00Z'), // End before start + description: 'Tattoo description', + status: 'PENDING' as const, + } + + const result = createAppointmentSchema.safeParse(invalidAppointment) + expect(result.success).toBe(false) + }) + }) +}) diff --git a/docs/bootstrapping.md b/docs/bootstrapping.md new file mode 100644 index 0000000..34f9a67 --- /dev/null +++ b/docs/bootstrapping.md @@ -0,0 +1,46 @@ +# Bootstrapping Checklist + +This checklist walks you from cloning the template to having a runnable project with confident defaults. Keep it open while you initialise a new repo. + +## 1. Template hygiene +- [ ] Create a fresh repository (local or Gitea) and copy the template into it. +- [ ] Run `./scripts/bootstrap-template.sh` to update the package name, git remotes, and README badges. +- [ ] Remove example images or assets you do not plan to ship (`public/`, `docs/img/`, etc.). +- [ ] Delete unused test suites so the CI noise floor stays low. + +## 2. Runtime setup +- [ ] Review `.env.example` and duplicate it to `.env` for local development. +- [ ] Fill only the sections that match the integrations you intend to use (auth, storage, calendar, analytics). +- [ ] Create secrets in your chosen manager (doppler, sops, 1Password CLI, environment repository) and document where they live. +- [ ] Configure feature flags or toggles that gate optional modules; default to safe fallbacks. + +## 3. Dependencies & tooling +- [ ] Decide on a package manager (pnpm, npm, yarn) and lock it in the README + CI. +- [ ] Install linting and formatting tools (`eslint`, `prettier`, `biome`, etc.) and wire them into `package.json` scripts. +- [ ] Add base Git hooks (Husky, Lefthook, or pre-commit) if you rely on pre-push validation. +- [ ] Configure TypeScript paths/aliases so the example tests resolve once you create real modules. + +## 4. Infrastructure & services +- [ ] Document deployment tooling (Wrangler, Vercel, Fly.io, Docker) in `docs/stack-decisions.md`. +- [ ] Provision staging and production environments or capture the manual steps. +- [ ] Outline database migration flow (Prisma, Drizzle, Kysely) and how to run it locally. +- [ ] For third-party integrations (OAuth, storage, calendar), confirm rate limits and timeout behaviour. + +## 5. CI/CD wiring +- [ ] Choose a pipeline runner (Gitea Actions, Woodpecker, GitHub Actions, etc.). +- [ ] Add jobs for lint, typecheck, unit tests, and build (if applicable). +- [ ] Cache dependencies to keep pipelines fast. +- [ ] Gate deployments on green checks and review status. + +## 6. Documentation & knowledge +- [ ] Update `README.md` with product-specific copy, screenshots, and deployment commands. +- [ ] Record architectural decisions in `docs/stack-decisions.md` (lean ADRs are ideal). +- [ ] Extend `docs/edge-cases.md` with anything unique to this project. +- [ ] Share workflow instructions (branching, PR labels, release cadence) in `CONTRIBUTING.md`. + +## 7. Launch readiness +- [ ] Smoke-test the bootstrap by running `pnpm test` (or your equivalent) and ensuring the example specs fail because modules are missing—this keeps you honest. +- [ ] Create a tracking issue or project board with tasks generated from the checklist. +- [ ] Archive or export the checklist with completed items for future reference. + +Repeat this ritual for every new project so you ship with fewer unknowns and more confidence. diff --git a/docs/edge-cases.md b/docs/edge-cases.md new file mode 100644 index 0000000..ec2068c --- /dev/null +++ b/docs/edge-cases.md @@ -0,0 +1,50 @@ +# Edge Case Catalogue + +Capture every non-happy-path scenario that has bitten you in past projects. Use these prompts when planning features, writing tests, and updating documentation. + +## Authentication & Authorization +- What happens when the identity provider is unavailable or rate-limited? +- Can users authenticate with multiple providers? How do you reconcile identities? +- Do access tokens expire mid-session? Plan silent refresh and forced logout flows. +- Are admin-only routes guarded on the server, not just the client? +- How do you roll keys or secrets without booting everyone? + +## Feature Flags & Configuration +- Can new features be disabled quickly without redeploying? +- Are default values safe when the config service is unreachable? +- What is logged when a flag evaluation fails? + +## Data & Persistence +- Are migrations idempotent? Can you roll them back? +- Do background jobs tolerate partial failure or duplicate delivery? +- What size assumptions exist for JSON payloads, binary blobs, or text fields? +- How do you seed development data without leaking production secrets? + +## Scheduling & Calendars +- Do you store timestamps in UTC and render them with the user's offset? +- How do you handle daylight saving transitions and leap seconds? +- Can overlapping events be created? If not, validate and surface clear errors. +- What is the source of truth when multiple calendars sync into one timeline? + +## File & Asset Management +- Maximum file size? Enforce both client and server-side. +- Are uploads scanned, transcoded, or resized? Where is the queue? +- How do you serve private files? Signed URLs, download proxies, expiring tokens? +- What is the retention policy and deletion workflow? + +## External Services +- Plan for timeouts, retries, and rate limits on each integration. +- If a vendor returns partial data, does your UI still render something helpful? +- Document SLAs and fallbacks in `docs/stack-decisions.md`. + +## Observability & Recovery +- Which metrics, logs, and traces are mandatory before launch? +- Do alerts route to a real person with enough context to act? +- After an incident, what automated reports or scripts help recreate the scenario? + +## Compliance & Privacy +- How do you handle data export, erasure, and consent? +- What environments carry production data? Are they encrypted at rest? +- Which audit logs must be preserved, and where? + +When a new surprise occurs, write the story here, then open a PR to harden the template so the next project benefits immediately. diff --git a/docs/stack-decisions.md b/docs/stack-decisions.md new file mode 100644 index 0000000..9f5f1a9 --- /dev/null +++ b/docs/stack-decisions.md @@ -0,0 +1,32 @@ +# Stack Decisions Log + +Use this document to capture the "why" behind platform and tooling choices. Treat each entry as a lightweight ADR (Architecture Decision Record). + +## Template baseline +- **Framework**: Next.js + TypeScript (edge-friendly, hybrid rendering, great DX) +- **Testing**: Vitest (fast unit/integration runner with React ecosystem support) +- **Styling**: Tailwind CSS or CSS Modules (choose one per project) +- **Deployment**: Cloudflare Pages + Workers (immutable deploys, global edge) +- **Database**: PostgreSQL (Supabase/Neon friendly), accessed via ORM of choice +- **Storage**: S3-compatible buckets (AWS S3, Cloudflare R2) + +## Recording a decision +1. Title — short phrase (`Adopt Drizzle ORM`, `Switch CI to Woodpecker`) +2. Context — what problem are you solving? Mention constraints, stakeholders, and trade-offs. +3. Decision — what did you pick and why? +4. Status — proposed, accepted, deprecated, superseded. +5. Consequences — positive and negative effects, migrations required, follow-up work. + +## Example entry +``` +## Adopt Drizzle ORM +Status: Accepted (2024-02-12) +Context: Need a type-safe query builder that works in serverless environments without generating heavyweight clients. +Decision: Replace Prisma with Drizzle ORM because it offers SQL-first migrations, small runtime footprint, and better edge compatibility. +Consequences: +- Rewrite existing Prisma migrations → Drizzle SQL migrations. +- Update CI to run `drizzle-kit push` instead of `prisma migrate deploy`. +- Developers need to learn the new query builder API. +``` + +Keep this log close to the code. When you revisit a project months later, these notes will save hours of rediscovery. diff --git a/docs/testing-blueprints.md b/docs/testing-blueprints.md new file mode 100644 index 0000000..e020ac1 --- /dev/null +++ b/docs/testing-blueprints.md @@ -0,0 +1,41 @@ +# Testing Blueprints + +The template ships with full Vitest suites under `__tests__/`. They currently reference the `@/` module alias from a Next.js project. Replace those imports with your actual modules as you build the app. Use this guide to adapt the patterns. + +## API routes +- File: `__tests__/api/*` +- Focus: HTTP status codes, query parsing, pagination, error handling. +- Mocks: database adapters, third-party SDKs, feature flags. +- Tips: assert on both response body and the parameters passed into mocked dependencies. + +## Components +- File: `__tests__/components/*` +- Focus: accessibility, copy, conditional rendering, navigation flows. +- Mocks: Next.js router, contexts, external hooks. +- Tips: render minimal UI tree, interact with `@testing-library/react` utilities, assert on semantics not implementation. + +## Hooks +- File: `__tests__/hooks/*` +- Focus: lifecycle, browser APIs (scroll, resize), async behaviour. +- Mocks: `window`, `document`, timers, intersection observers. +- Tips: wrap `act` around updates, reset mocks between tests, include teardown coverage. + +## Flags & configuration +- File: `__tests__/flags/*` +- Focus: toggling features on/off, server-side overrides, fallbacks. +- Mocks: flag evaluation client, configuration store. +- Tips: include “flag service offline” scenarios to enforce safe defaults. + +## Libraries +- File: `__tests__/lib/*` +- Focus: data migration guards, validation, persistence abstractions. +- Mocks: filesystem, database clients, clock. +- Tips: write table-driven tests so new edge cases are easy to add. + +### Making them yours +1. Rename folders to match real domains (`users`, `billing`, `cms`). +2. Swap module imports from `@/lib/...` to wherever your implementation lives. +3. Keep the error-handling tests, even if you simplify the happy path—they are cheap insurance. +4. Run `pnpm test` (or your equivalent) often; treat failures as documentation gaps. + +These suites double as onboarding material. New contributors can read the tests to understand intent before diving into production code. diff --git a/scripts/bootstrap-template.sh b/scripts/bootstrap-template.sh new file mode 100755 index 0000000..2bcc37b --- /dev/null +++ b/scripts/bootstrap-template.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash + +# Bootstrap a fresh project from this template. +# - Updates README heading with the new project name. +# - Creates an initial commit if none exists. +# - Optionally rewires git remotes. +# - Touches package.json if you want to set the name field (only when present). + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/.." && pwd)" + +echo "🚀 Template bootstrap" + +read -r -p "Project name (e.g. \"Atlas Console\"): " project_name +if [[ -z "${project_name}" ]]; then + echo "Project name cannot be empty. Aborting." + exit 1 +fi + +default_slug="$(echo "${project_name}" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd '[:alnum:]-')" +read -r -p "Project slug [${default_slug}]: " project_slug +project_slug="${project_slug:-$default_slug}" + +# Update README heading if it still contains the template title. +readme="${repo_root}/README.md" +if grep -q "^Development Project Template" "${readme}"; then + echo "Updating README title..." + tmp_readme="$(mktemp)" + { + echo "${project_name}" + echo "${project_name//?/=}" + tail -n +3 "${readme}" + } > "${tmp_readme}" + mv "${tmp_readme}" "${readme}" +else + echo "README already customised; skipping title update." +fi + +# Update package.json name if present. +package_json="${repo_root}/package.json" +if [[ -f "${package_json}" ]]; then + if command -v jq >/dev/null 2>&1; then + echo "Updating package.json name → ${project_slug}" + tmp_package="$(mktemp)" + jq --arg name "${project_slug}" '.name = $name' "${package_json}" > "${tmp_package}" + mv "${tmp_package}" "${package_json}" + else + echo "jq is not installed; skipping package.json update. Install jq and rerun if needed." + fi +else + echo "No package.json found; skipping package rename." +fi + +# Offer to update git origin remote. +if git -C "${repo_root}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + current_remote="$(git -C "${repo_root}" remote get-url origin 2>/dev/null || true)" + echo "Current git remote: ${current_remote:-}" + read -r -p "Update git remote? (y/N): " change_remote + if [[ "${change_remote,,}" == "y" ]]; then + read -r -p "New remote URL: " remote_url + if git -C "${repo_root}" remote | grep -q "^origin$"; then + git -C "${repo_root}" remote set-url origin "${remote_url}" + else + git -C "${repo_root}" remote add origin "${remote_url}" + fi + echo "Origin remote updated." + else + echo "Skipping remote update." + fi +fi + +# Stamp a .project-name file so scripts/tools can read the canonical name. +echo "${project_name}" > "${repo_root}/.project-name" + +cat <