From 26b6b37ceed77a86e24816c911f48440676daa3e Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Tue, 21 Apr 2026 00:47:40 +0700 Subject: [PATCH] feat(qa): add smoke test suite + post-deploy workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - e2e/api/smoke.spec.ts — 9 @smoke API tests covering health, auth roundtrip, token refresh, listings, search, payments, subscriptions, and inquiries - e2e/web/smoke.spec.ts — 7 @smoke Web tests covering homepage, login/register pages, listings, search, listing detail 404 handling, and console-error check - playwright.config.ts — smoke-api and smoke-web projects (grep: /@smoke/) allowing targeted post-deploy execution without the full suite - .github/workflows/smoke.yml — workflow_dispatch + workflow_call trigger for running only the @smoke subset against staging or production URLs Co-Authored-By: Paperclip --- .github/workflows/smoke.yml | 103 ++++++++++++++++++++++++++++++ e2e/api/smoke.spec.ts | 123 ++++++++++++++++++++++++++++++++++++ e2e/web/smoke.spec.ts | 111 ++++++++++++++++++++++++++++++++ playwright.config.ts | 18 ++++++ 4 files changed, 355 insertions(+) create mode 100644 .github/workflows/smoke.yml create mode 100644 e2e/api/smoke.spec.ts create mode 100644 e2e/web/smoke.spec.ts diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml new file mode 100644 index 0000000..7adbd85 --- /dev/null +++ b/.github/workflows/smoke.yml @@ -0,0 +1,103 @@ +name: Smoke Tests (Post-Deploy) + +on: + workflow_dispatch: + inputs: + environment: + description: 'Target environment' + required: true + default: 'staging' + type: choice + options: + - staging + - production + api_url: + description: 'API base URL (overrides default for env)' + required: false + type: string + web_url: + description: 'Web base URL (overrides default for env)' + required: false + type: string + workflow_call: + inputs: + environment: + required: false + type: string + default: 'staging' + api_url: + required: false + type: string + web_url: + required: false + type: string + +concurrency: + group: smoke-${{ inputs.environment || 'staging' }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + smoke: + name: Smoke — ${{ inputs.environment || 'staging' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + API_BASE_URL: ${{ inputs.api_url || (inputs.environment == 'production' && vars.PROD_API_URL) || vars.STAGING_API_URL || 'http://localhost:3001/api/v1/' }} + WEB_BASE_URL: ${{ inputs.web_url || (inputs.environment == 'production' && vars.PROD_WEB_URL) || vars.STAGING_WEB_URL || 'http://localhost:3000' }} + CI: true + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps chromium + + - name: Install Playwright system deps + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + + - name: Run smoke tests (API) + run: npx playwright test --project=smoke-api + env: + API_BASE_URL: ${{ env.API_BASE_URL }} + + - name: Run smoke tests (Web) + run: npx playwright test --project=smoke-web + env: + WEB_BASE_URL: ${{ env.WEB_BASE_URL }} + API_BASE_URL: ${{ env.API_BASE_URL }} + + - name: Upload smoke report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: smoke-report-${{ inputs.environment || 'staging' }}-${{ github.run_id }} + path: playwright-report/ + retention-days: 7 + + - name: Notify failure + if: failure() + run: | + echo "::error::Smoke tests FAILED on ${{ inputs.environment || 'staging' }}. Check the uploaded playwright-report artifact for details." diff --git a/e2e/api/smoke.spec.ts b/e2e/api/smoke.spec.ts new file mode 100644 index 0000000..07b718b --- /dev/null +++ b/e2e/api/smoke.spec.ts @@ -0,0 +1,123 @@ +/** + * Smoke suite — API + * + * Runs after every deploy to verify core API paths are healthy. + * Tests are tagged with @smoke so they can be isolated: + * + * npx playwright test --project=api --grep @smoke + * + * Intentionally lightweight: each test makes at most one API call and + * asserts only on status code + structural shape. No heavy fixtures, + * no side effects that require cleanup. + */ +import { test, expect } from '@playwright/test'; +import { createTestUser, registerUser } from '../fixtures'; + +// ── Health ──────────────────────────────────────────────────────────────────── + +test('@smoke API health check returns 200', async ({ request }) => { + // Playwright baseURL ends with /api/v1/ — health is at the root + const res = await request.get('../../health'); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.status).toBe('ok'); +}); + +// ── Auth ────────────────────────────────────────────────────────────────────── + +test('@smoke auth register + login roundtrip', async ({ request }) => { + const user = createTestUser(); + const registered = await registerUser(request, user); + + expect(registered).toHaveProperty('accessToken'); + expect(registered).toHaveProperty('refreshToken'); + expect(typeof registered.accessToken).toBe('string'); + + const loginRes = await request.post('auth/login', { + data: { phone: user.phone, password: user.password }, + }); + expect(loginRes.status()).toBe(201); + const tokens = await loginRes.json(); + expect(tokens).toHaveProperty('accessToken'); +}); + +test('@smoke auth token refresh', async ({ request }) => { + const user = createTestUser(); + const { refreshToken } = await registerUser(request, user); + + const res = await request.post('auth/refresh', { + data: { refreshToken }, + }); + expect(res.status()).toBe(201); + const body = await res.json(); + expect(body).toHaveProperty('accessToken'); +}); + +test('@smoke protected route rejects unauthenticated request', async ({ request }) => { + const res = await request.get('auth/profile'); + expect([401, 403]).toContain(res.status()); +}); + +// ── Listings ───────────────────────────────────────────────────────────────── + +test('@smoke listings list returns paginated results', async ({ request }) => { + const res = await request.get('listings', { + params: { page: 1, limit: 5 }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('data'); + expect(Array.isArray(body.data)).toBeTruthy(); + expect(body).toHaveProperty('meta'); +}); + +test('@smoke listing creation requires auth', async ({ request }) => { + const res = await request.post('listings', { + data: { title: 'Smoke test listing' }, + }); + expect([401, 403]).toContain(res.status()); +}); + +// ── Search ──────────────────────────────────────────────────────────────────── + +test('@smoke search endpoint is reachable', async ({ request }) => { + const res = await request.get('search', { + params: { q: 'apartment', limit: 5 }, + }); + // 200 = Typesense available; 500/503 = service unavailable (accepted in smoke) + expect([200, 500, 503]).toContain(res.status()); +}); + +test('@smoke geo search endpoint is reachable', async ({ request }) => { + const res = await request.get('search/geo', { + params: { lat: 10.7769, lng: 106.7009, radius: 5000, limit: 5 }, + }); + expect([200, 500, 503]).toContain(res.status()); +}); + +// ── Payments ────────────────────────────────────────────────────────────────── + +test('@smoke payment initiation requires auth', async ({ request }) => { + const res = await request.post('payments', { + data: { amount: 100000, gateway: 'VNPAY' }, + }); + expect([401, 403]).toContain(res.status()); +}); + +// ── Subscriptions ───────────────────────────────────────────────────────────── + +test('@smoke subscription plans are publicly readable', async ({ request }) => { + const res = await request.get('subscriptions/plans'); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBeTruthy(); +}); + +// ── Inquiries ───────────────────────────────────────────────────────────────── + +test('@smoke inquiry creation requires auth', async ({ request }) => { + const res = await request.post('inquiries', { + data: { listingId: 'smoke-listing-id', message: 'Smoke test inquiry' }, + }); + expect([401, 403]).toContain(res.status()); +}); diff --git a/e2e/web/smoke.spec.ts b/e2e/web/smoke.spec.ts new file mode 100644 index 0000000..b9d3353 --- /dev/null +++ b/e2e/web/smoke.spec.ts @@ -0,0 +1,111 @@ +/** + * Smoke suite — Web + * + * Runs after every deploy to verify critical frontend pages load and + * render key UI elements. Tagged with @smoke for targeted execution: + * + * npx playwright test --project=web --grep @smoke + * + * Tests avoid user interactions that require a live API/auth session so + * they remain fast and resilient in staging environments. + */ +import { test, expect } from '@playwright/test'; + +// ── Homepage ────────────────────────────────────────────────────────────────── + +test('@smoke homepage loads', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/.+/); + // Search bar or hero section must be visible + const searchInput = page.getByRole('searchbox').or(page.getByPlaceholder(/tìm kiếm|search/i)); + await expect(searchInput.first()).toBeVisible({ timeout: 10_000 }); +}); + +// ── Auth pages ──────────────────────────────────────────────────────────────── + +test('@smoke login page renders form', async ({ page }) => { + await page.goto('/login'); + await expect(page.getByRole('heading', { name: /đăng nhập/i })).toBeVisible(); + await expect(page.getByLabel(/số điện thoại/i)).toBeVisible(); + await expect(page.getByLabel(/mật khẩu/i)).toBeVisible(); + await expect(page.getByRole('button', { name: /đăng nhập/i })).toBeVisible(); +}); + +test('@smoke register page renders form', async ({ page }) => { + await page.goto('/register'); + await expect(page.getByRole('heading', { name: /đăng ký/i })).toBeVisible(); + await expect(page.getByLabel(/số điện thoại/i)).toBeVisible(); +}); + +// ── Listings ────────────────────────────────────────────────────────────────── + +test('@smoke listings page loads without JS errors', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (err) => errors.push(err.message)); + + await page.goto('/listings'); + // Allow time for async data loading + await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => { + // networkidle may not be reached if polling; continue + }); + + // Page must not show an unhandled error boundary + const errorHeading = page.getByRole('heading', { name: /500|something went wrong|lỗi/i }); + await expect(errorHeading).not.toBeVisible(); + + // Filter JS errors that are not related to missing env vars in test + const fatalErrors = errors.filter( + (e) => !e.includes('NEXT_PUBLIC') && !e.includes('mapbox'), + ); + expect(fatalErrors).toHaveLength(0); +}); + +// ── Search ──────────────────────────────────────────────────────────────────── + +test('@smoke search page is accessible', async ({ page }) => { + await page.goto('/search?q=apartment'); + await expect(page).not.toHaveTitle(/404|not found/i); + // Some results container or empty-state must be present + const container = page + .locator('[data-testid="search-results"]') + .or(page.locator('[data-testid="empty-state"]')) + .or(page.getByText(/kết quả|results|không tìm thấy/i)); + // Be lenient — search service may be unavailable in staging + await page.waitForTimeout(3000); + // Just confirm no crash + const errorBoundary = page.getByRole('heading', { name: /500|server error/i }); + await expect(errorBoundary).not.toBeVisible(); +}); + +// ── Listing Detail ──────────────────────────────────────────────────────────── + +test('@smoke listing detail page handles missing id gracefully', async ({ page }) => { + const res = await page.goto('/listings/nonexistent-smoke-test-id'); + // Should render 404 page, not crash with 500 + const status = res?.status(); + if (status && status >= 500) { + throw new Error(`Listing detail returned ${status} for unknown ID (expected 404)`); + } +}); + +// ── Static / infra ──────────────────────────────────────────────────────────── + +test('@smoke no console errors on login page', async ({ page }) => { + const consoleErrors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error') consoleErrors.push(msg.text()); + }); + + await page.goto('/login'); + await page.waitForLoadState('domcontentloaded'); + + // Filter noise from third-party scripts / mapbox in test env + const meaningful = consoleErrors.filter( + (e) => + !e.includes('mapbox') && + !e.includes('NEXT_PUBLIC') && + !e.includes('Failed to load resource') && + !e.includes('net::ERR'), + ); + expect(meaningful).toHaveLength(0); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 0369a2d..e0fe413 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -58,6 +58,24 @@ export default defineConfig({ baseURL: process.env.WEB_BASE_URL ?? `http://localhost:${WEB_PORT}`, }, }, + // Smoke projects — subsets of api/web tagged @smoke for post-deploy checks + { + name: 'smoke-api', + testDir: './e2e/api', + grep: /@smoke/, + use: { + baseURL: process.env.API_BASE_URL ?? `http://localhost:${API_PORT}/api/v1/`, + }, + }, + { + name: 'smoke-web', + testDir: './e2e/web', + grep: /@smoke/, + use: { + ...devices['Desktop Chrome'], + baseURL: process.env.WEB_BASE_URL ?? `http://localhost:${WEB_PORT}`, + }, + }, ], webServer: [