diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..027a4dac --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,103 @@ +name: Deploy pleno-vue to Hetzner (staging) + +on: + push: + branches: [master] + workflow_dispatch: + +concurrency: + group: deploy-pleno-vue + cancel-in-progress: false + +env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + +jobs: + test-and-deploy: + name: Build + Deploy + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install + Build + run: | + npm ci --ignore-scripts + npm run build + + - name: Setup SSH + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }} + + - name: Add host key + run: | + mkdir -p ~/.ssh + ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null + + - name: Pre-deploy snapshot + id: pre + run: | + ssh "$DEPLOY_USER@$DEPLOY_HOST" ' + set -e + cd /opt/pleno-vue + git rev-parse HEAD > /tmp/last_deploy_sha + echo "pre_sha=$(cat /tmp/last_deploy_sha)" >> $GITHUB_OUTPUT + ' + + - name: Deploy + id: deploy + run: | + ssh "$DEPLOY_USER@$DEPLOY_HOST" ' + set -e + cd /opt/pleno-vue + git fetch origin master + git reset --hard origin/master + npm ci --ignore-scripts + npm run build + sudo systemctl reload nginx || true + sudo systemctl reload pleno-vue || true + echo "Deploy complete: $(git rev-parse --short HEAD)" + ' + + - name: Smoke test + id: smoke + continue-on-error: true + env: + SMOKE_BASE_URL: ${{ secrets.SMOKE_BASE_URL }} + run: | + bash scripts/smoke-test.sh "$SMOKE_BASE_URL" + + - name: Auto-rollback on smoke failure + if: steps.smoke.outcome == 'failure' + run: | + echo "::error::Smoke test failed — rolling back to ${{ steps.pre.outputs.pre_sha }}" + ssh "$DEPLOY_USER@$DEPLOY_HOST" ' + set -e + cd /opt/pleno-vue + git reset --hard ${{ steps.pre.outputs.pre_sha }} + npm ci --ignore-scripts + npm run build + sudo systemctl reload nginx || true + ' + + - name: Post Slack status + if: always() + uses: slackapi/slack-github-action@v1.27.0 + with: + channel-id: ${{ secrets.AI_DAILY_CHANNEL }} + payload: | + { + "text": "${{ job.status == 'success' && '✅' || '❌' }} Deploy *pleno-vue@${{ github.sha[0:7] }}* — ${{ job.status }}\n${{ steps.smoke.outcome == 'failure' && '⚠️ Auto-rolled back' || '✓ Smoke passed' }}" + } + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100755 index 00000000..a0370a71 --- /dev/null +++ b/scripts/smoke-test.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Generic smoke test for any deployed app. +# +# Usage: ./scripts/smoke-test.sh [base_url] +# Default: https://staging.truckwash.io +# +# Required env vars (set by GitHub Action): +# SMOKE_BASE_URL - base URL to test (default: https://staging.truckwash.io) +# +# Optional env vars: +# SMOKE_TOKEN - bearer token for authenticated checks +# SMOKE_TIMEOUT - curl timeout in seconds (default: 10) +# +# Exits 0 on all-pass, 1 on any failure. + +set -euo pipefail + +BASE_URL="${SMOKE_BASE_URL:-${1:-https://staging.truckwash.io}}" +TIMEOUT="${SMOKE_TIMEOUT:-10}" + +# Color codes +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +FAIL=0 + +check() { + local name="$1" + local url="$2" + local expected="${3:-200}" + local method="${4:-GET}" + + local status + status=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" --max-time "$TIMEOUT" "$url" || echo "000") + + if [[ "$status" =~ ^($expected)$ ]] || [[ "$expected" == "2xx" && "$status" =~ ^2 ]]; then + echo -e " ${GREEN}✓${NC} $name ($status) — $url" + else + echo -e " ${RED}✗${NC} $name (expected $expected, got $status) — $url" + FAIL=1 + fi +} + +echo "Smoke test against $BASE_URL" +echo " (timeout ${TIMEOUT}s per check)" +echo + +# === Health endpoints (universal) === +check "health check" "$BASE_URL/healthz" "2xx" +check "ping" "$BASE_URL/api/ping" "2xx" + +# === Authentication (should NOT 500) === +check "login page" "$BASE_URL/login" "2xx" + +# === Public endpoints (api repo) === +check "customer list (public schema)" "$BASE_URL/api/customer" "2xx" +check "kundeoprettelse form" "$BASE_URL/kundeoprettelse" "2xx" + +# === Public endpoints (pleno-vue) === +check "self-serve program picker" "$BASE_URL/self-serve/program" "2xx" +check "vehicle step" "$BASE_URL/self-serve/vehicle" "2xx" + +# === Custom 404 should not 500 === +check "404 page" "$BASE_URL/this-route-does-not-exist" "404" + +# === Optional authenticated check === +if [ -n "${SMOKE_TOKEN:-}" ]; then + check "auth check" "$BASE_URL/api/me" "2xx" +fi + +echo +if [ "$FAIL" -eq 0 ]; then + echo -e "${GREEN}✓ All smoke tests passed${NC}" + exit 0 +else + echo -e "${RED}✗ Some smoke tests failed${NC}" + exit 1 +fi diff --git a/tests/unit/xlvask-usage-department-filter.spec.js b/tests/unit/xlvask-usage-department-filter.spec.js index 23825850..7ce36a5f 100644 --- a/tests/unit/xlvask-usage-department-filter.spec.js +++ b/tests/unit/xlvask-usage-department-filter.spec.js @@ -15,8 +15,13 @@ describe("xlvask usage pagination department selector propagation", () => { it("applies the HallId filter when the departmentId prop is provided", () => { const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue"); + // `effectiveDepartmentId` is now a computed (so the value is `.value`) + // and is wired into a watcher that re-applies the HallId filter + // whenever the department changes. The initial setup also seeds + // the filter from the computed. expect(source).toMatch(/effectiveDepartmentId\.value\s*>\s*0/); expect(source).toMatch(/setFilter\(\s*["']HallId["']\s*,\s*effectiveDepartmentId\.value\s*,\s*false\s*\)/); + expect(source).toMatch(/watch\(\s*effectiveDepartmentId\s*,/); }); it("falls back to the departmentId route param when the prop is not provided", () => { @@ -29,8 +34,11 @@ describe("xlvask usage pagination department selector propagation", () => { it("does not apply the HallId filter when no departmentId is provided", () => { const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue"); - expect(source).toContain( - "const effectiveDepartmentId = computed(() =>\n props.departmentId > 0\n ? props.departmentId\n : Number.isInteger(routeDepartmentId.value) && routeDepartmentId.value > 0\n ? routeDepartmentId.value\n : 0\n);" + // `effectiveDepartmentId` is a computed (not a plain const) so the + // value is `.value`, and it is read through `routeDepartmentId.value` + // for the route-param fallback. + expect(source).toMatch( + /const\s+effectiveDepartmentId\s*=\s*computed\(\s*\(\)\s*=>\s*[\s\S]*?props\.departmentId\s*>\s*0[\s\S]*?routeDepartmentId\.value\s*>\s*0[\s\S]*?:\s*0\s*\)/ ); expect(source).toMatch(/if\s*\(effectiveDepartmentId\.value\s*>\s*0\)\s*\{\s*setFilter\(\s*["']HallId["']/); });