Merge pull request #11 from pacnpal/claude/evaluate-github-actions-011CUun8YmYCA3XzoSd5B6Y8

This commit is contained in:
pacnpal
2025-11-08 07:56:33 -05:00
committed by GitHub
25 changed files with 16629 additions and 2971 deletions

View File

@@ -1,260 +0,0 @@
# Trigger workflow run
name: Playwright E2E Tests
on:
push:
branches: [main, develop, dev]
pull_request:
branches: [main, develop, dev]
env:
GRAFANA_LOKI_URL: ${{ secrets.GRAFANA_LOKI_URL }}
GRAFANA_LOKI_USERNAME: ${{ secrets.GRAFANA_LOKI_USERNAME }}
GRAFANA_LOKI_PASSWORD: ${{ secrets.GRAFANA_LOKI_PASSWORD }}
jobs:
# Pre-flight validation to ensure environment is ready
preflight:
name: Validate Environment
runs-on: ubuntu-latest
environment: production
steps:
- name: Check Required Secrets
run: |
echo "🔍 Validating required secrets..."
if [ -z "${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}" ]; then
echo "❌ SUPABASE_SERVICE_ROLE_KEY is not set"
exit 1
fi
if [ -z "${{ secrets.TEST_USER_EMAIL }}" ]; then
echo "⚠️ TEST_USER_EMAIL is not set"
fi
echo "✅ Required secrets validated"
- name: Test Grafana Cloud Loki Connection
continue-on-error: true
run: |
if [ -z "${{ secrets.GRAFANA_LOKI_URL }}" ]; then
echo "⏭️ Skipping Loki connection test (GRAFANA_LOKI_URL not configured)"
exit 0
fi
echo "🔍 Testing Grafana Cloud Loki connection..."
timestamp=$(date +%s)000000000
response=$(curl -s -w "\n%{http_code}" \
--max-time 10 \
-u "${{ secrets.GRAFANA_LOKI_USERNAME }}:${{ secrets.GRAFANA_LOKI_PASSWORD }}" \
-H "Content-Type: application/json" \
-H "User-Agent: ThrillWiki-Playwright-Tests/1.0" \
-X POST "${{ secrets.GRAFANA_LOKI_URL }}/loki/api/v1/push" \
-d "{
\"streams\": [{
\"stream\": {
\"job\": \"playwright_preflight\",
\"workflow\": \"${{ github.workflow }}\",
\"branch\": \"${{ github.ref_name }}\",
\"commit\": \"${{ github.sha }}\",
\"run_id\": \"${{ github.run_id }}\"
},
\"values\": [[\"$timestamp\", \"Preflight check complete\"]]
}]
}")
http_code=$(echo "$response" | tail -n1)
if [ "$http_code" = "204" ] || [ "$http_code" = "200" ]; then
echo "✅ Successfully connected to Grafana Cloud Loki"
else
echo "⚠️ Loki connection returned HTTP $http_code"
echo "Response: $(echo "$response" | head -n -1)"
echo "Tests will continue but logs may not be sent to Loki"
fi
test:
needs: preflight
timeout-minutes: 60
runs-on: ubuntu-latest
environment: production
strategy:
fail-fast: false
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium ${{ matrix.browser }}
- name: Send Test Start Event to Loki
continue-on-error: true
run: |
if [ -z "${{ secrets.GRAFANA_LOKI_URL }}" ]; then
echo "⏭️ Skipping Loki logging (GRAFANA_LOKI_URL not configured)"
exit 0
fi
timestamp=$(date +%s)000000000
response=$(curl -s -w "\n%{http_code}" \
--max-time 10 \
--retry 3 \
--retry-delay 2 \
-u "${{ secrets.GRAFANA_LOKI_USERNAME }}:${{ secrets.GRAFANA_LOKI_PASSWORD }}" \
-H "Content-Type: application/json" \
-H "User-Agent: ThrillWiki-Playwright-Tests/1.0" \
-X POST "${{ secrets.GRAFANA_LOKI_URL }}/loki/api/v1/push" \
-d "{
\"streams\": [{
\"stream\": {
\"job\": \"playwright_tests\",
\"browser\": \"${{ matrix.browser }}\",
\"workflow\": \"${{ github.workflow }}\",
\"branch\": \"${{ github.ref_name }}\",
\"commit\": \"${{ github.sha }}\",
\"run_id\": \"${{ github.run_id }}\",
\"event\": \"test_start\"
},
\"values\": [[\"$timestamp\", \"Starting Playwright tests for ${{ matrix.browser }}\"]]
}]
}")
http_code=$(echo "$response" | tail -n1)
if [ "$http_code" != "204" ] && [ "$http_code" != "200" ]; then
echo "⚠️ Failed to send to Loki (HTTP $http_code): $(echo "$response" | head -n -1)"
fi
- name: Run Playwright tests
id: playwright-run
env:
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
TEST_MODERATOR_EMAIL: ${{ secrets.TEST_MODERATOR_EMAIL }}
TEST_MODERATOR_PASSWORD: ${{ secrets.TEST_MODERATOR_PASSWORD }}
BASE_URL: ${{ secrets.BASE_URL || 'http://localhost:8080' }}
# Enable Loki reporter
GRAFANA_LOKI_URL: ${{ secrets.GRAFANA_LOKI_URL }}
GRAFANA_LOKI_USERNAME: ${{ secrets.GRAFANA_LOKI_USERNAME }}
GRAFANA_LOKI_PASSWORD: ${{ secrets.GRAFANA_LOKI_PASSWORD }}
run: |
echo "🧪 Running Playwright tests for ${{ matrix.browser }}..."
npx playwright test --project=${{ matrix.browser }} 2>&1 | tee test-execution.log
TEST_EXIT_CODE=${PIPESTATUS[0]}
echo "test_exit_code=$TEST_EXIT_CODE" >> $GITHUB_OUTPUT
exit $TEST_EXIT_CODE
continue-on-error: true
- name: Parse Test Results
if: always()
id: parse-results
run: |
if [ -f "test-results.json" ]; then
echo "📊 Parsing test results..."
TOTAL=$(jq '[.suites[].specs[]] | length' test-results.json || echo "0")
PASSED=$(jq '[.suites[].specs[].tests[] | select(.results[].status == "passed")] | length' test-results.json || echo "0")
FAILED=$(jq '[.suites[].specs[].tests[] | select(.results[].status == "failed")] | length' test-results.json || echo "0")
SKIPPED=$(jq '[.suites[].specs[].tests[] | select(.results[].status == "skipped")] | length' test-results.json || echo "0")
DURATION=$(jq '[.suites[].specs[].tests[].results[].duration] | add' test-results.json || echo "0")
echo "total=$TOTAL" >> $GITHUB_OUTPUT
echo "passed=$PASSED" >> $GITHUB_OUTPUT
echo "failed=$FAILED" >> $GITHUB_OUTPUT
echo "skipped=$SKIPPED" >> $GITHUB_OUTPUT
echo "duration=$DURATION" >> $GITHUB_OUTPUT
echo "✅ Results: $PASSED passed, $FAILED failed, $SKIPPED skipped (${DURATION}ms total)"
else
echo "⚠️ test-results.json not found"
fi
- name: Send Test Results to Loki
if: always()
continue-on-error: true
run: |
if [ -z "${{ secrets.GRAFANA_LOKI_URL }}" ]; then
echo "⏭️ Skipping Loki logging (GRAFANA_LOKI_URL not configured)"
exit 0
fi
STATUS="${{ steps.playwright-run.outputs.test_exit_code == '0' && 'success' || 'failure' }}"
timestamp=$(date +%s)000000000
response=$(curl -s -w "\n%{http_code}" \
--max-time 10 \
--retry 3 \
--retry-delay 2 \
-u "${{ secrets.GRAFANA_LOKI_USERNAME }}:${{ secrets.GRAFANA_LOKI_PASSWORD }}" \
-H "Content-Type: application/json" \
-H "User-Agent: ThrillWiki-Playwright-Tests/1.0" \
-X POST "${{ secrets.GRAFANA_LOKI_URL }}/loki/api/v1/push" \
-d "{
\"streams\": [{
\"stream\": {
\"job\": \"playwright_tests\",
\"browser\": \"${{ matrix.browser }}\",
\"workflow\": \"${{ github.workflow }}\",
\"branch\": \"${{ github.ref_name }}\",
\"commit\": \"${{ github.sha }}\",
\"run_id\": \"${{ github.run_id }}\",
\"status\": \"$STATUS\",
\"event\": \"test_complete\"
},
\"values\": [[\"$timestamp\", \"{\\\"total\\\": ${{ steps.parse-results.outputs.total || 0 }}, \\\"passed\\\": ${{ steps.parse-results.outputs.passed || 0 }}, \\\"failed\\\": ${{ steps.parse-results.outputs.failed || 0 }}, \\\"skipped\\\": ${{ steps.parse-results.outputs.skipped || 0 }}, \\\"duration_ms\\\": ${{ steps.parse-results.outputs.duration || 0 }}}\"]]
}]
}")
http_code=$(echo "$response" | tail -n1)
if [ "$http_code" != "204" ] && [ "$http_code" != "200" ]; then
echo "⚠️ Failed to send results to Loki (HTTP $http_code): $(echo "$response" | head -n -1)"
fi
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-results-${{ matrix.browser }}
path: test-results/
retention-days: 30
- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-${{ matrix.browser }}
path: playwright-report/
retention-days: 30
- name: Comment PR with results
uses: daun/playwright-report-comment@v3
if: always() && github.event_name == 'pull_request'
with:
report-path: test-results.json
test-summary:
name: Test Summary
runs-on: ubuntu-latest
needs: test
if: always()
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
- name: Generate summary
run: |
echo "## Playwright Test Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Tests completed across all browsers." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "See artifacts for detailed reports and screenshots." >> $GITHUB_STEP_SUMMARY

81
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,81 @@
name: Tests
on:
push:
branches: [main, develop, dev]
pull_request:
branches: [main, develop, dev]
jobs:
test:
name: Unit & Integration Tests
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: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test:run
- name: Generate coverage report
run: npm run test:coverage
continue-on-error: true
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
retention-days: 30
- name: Comment PR with coverage
if: always() && github.event_name == 'pull_request'
uses: actions/github-script@v7
continue-on-error: true
with:
script: |
const fs = require('fs');
if (fs.existsSync('coverage/coverage-summary.json')) {
const coverage = JSON.parse(fs.readFileSync('coverage/coverage-summary.json', 'utf8'));
const total = coverage.total;
const comment = `## Test Coverage Report
| Metric | Coverage |
|--------|----------|
| Lines | ${total.lines.pct}% |
| Statements | ${total.statements.pct}% |
| Functions | ${total.functions.pct}% |
| Branches | ${total.branches.pct}% |
[View detailed coverage report in artifacts](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
`;
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: comment
});
}
- name: Test Summary
if: always()
run: |
echo "## Test Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "✅ All tests completed" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "See artifacts for coverage reports." >> $GITHUB_STEP_SUMMARY

14814
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,11 @@
"build": "vite build",
"build:dev": "vite build --mode development",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -21,7 +25,6 @@
"@novu/headless": "^2.6.6",
"@novu/node": "^2.6.6",
"@novu/react": "^3.10.1",
"@playwright/test": "^1.56.1",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.7",
@@ -91,20 +94,27 @@
"devDependencies": {
"@eslint/js": "^9.32.0",
"@tailwindcss/typography": "^0.5.16",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/node": "^22.16.5",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.11.0",
"@vitest/coverage-v8": "^4.0.8",
"@vitest/ui": "^4.0.8",
"autoprefixer": "^10.4.21",
"eslint": "^9.32.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^15.15.0",
"happy-dom": "^20.0.10",
"jsdom": "^27.1.0",
"lovable-tagger": "^1.1.9",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0",
"vite": "^5.4.19"
"vite": "^5.4.19",
"vitest": "^4.0.8"
}
}

View File

@@ -1,141 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright Configuration for ThrillWiki E2E Tests
*
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [
['html'],
['list'],
['json', { outputFile: 'test-results.json' }],
// Only include Loki reporter if Grafana Cloud credentials are configured
...(process.env.GRAFANA_LOKI_URL && process.env.GRAFANA_LOKI_USERNAME && process.env.GRAFANA_LOKI_PASSWORD
? [['./tests/helpers/loki-reporter.ts', {
lokiUrl: process.env.GRAFANA_LOKI_URL,
username: process.env.GRAFANA_LOKI_USERNAME,
password: process.env.GRAFANA_LOKI_PASSWORD,
}] as ['./tests/helpers/loki-reporter.ts', any]]
: []
)
],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.BASE_URL || 'http://localhost:8080',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
/* Screenshot on failure */
screenshot: 'only-on-failure',
/* Video on failure */
video: 'retain-on-failure',
/* Maximum time each action such as `click()` can take */
actionTimeout: 10000,
},
/* Global timeout for each test */
timeout: 60000,
/* Global setup and teardown */
globalSetup: './tests/setup/global-setup.ts',
globalTeardown: './tests/setup/global-teardown.ts',
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
// Use authenticated state for most tests
storageState: '.auth/user.json',
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: '.auth/user.json',
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
storageState: '.auth/user.json',
},
},
/* Test against mobile viewports. */
{
name: 'Mobile Chrome',
use: {
...devices['Pixel 5'],
storageState: '.auth/user.json',
},
},
{
name: 'Mobile Safari',
use: {
...devices['iPhone 12'],
storageState: '.auth/user.json',
},
},
/* Tests that require specific user roles */
{
name: 'moderator',
use: {
...devices['Desktop Chrome'],
storageState: '.auth/moderator.json',
},
},
{
name: 'admin',
use: {
...devices['Desktop Chrome'],
storageState: '.auth/admin.json',
},
},
/* Authentication tests run without pre-authenticated state */
{
name: 'auth-tests',
testMatch: '**/auth/**/*.spec.ts',
use: {
...devices['Desktop Chrome'],
// No storageState for auth tests
},
},
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run dev',
url: 'http://localhost:8080',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
});

View File

@@ -1,134 +0,0 @@
/**
* Login E2E Tests
*
* Tests authentication flow, session persistence, and error handling.
*/
import { test, expect } from '@playwright/test';
import { LoginPage } from '../../helpers/page-objects/LoginPage';
import { getTestUserCredentials, logout } from '../../fixtures/auth';
// These tests run without pre-authenticated state
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('Login Flow', () => {
let loginPage: LoginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.goto();
});
test('should login successfully with valid credentials', async ({ page }) => {
const { email, password } = getTestUserCredentials('user');
await loginPage.login(email, password);
await loginPage.expectLoginSuccess();
// Verify we're redirected to homepage
await expect(page).toHaveURL('/');
});
test('should show error with invalid password', async ({ page }) => {
const { email } = getTestUserCredentials('user');
await loginPage.login(email, 'wrongpassword123');
await loginPage.expectLoginError();
// Verify we're still on auth page
await expect(page).toHaveURL(/\/auth/);
});
test('should show error with non-existent email', async ({ page }) => {
await loginPage.login('nonexistent@example.com', 'password123');
await loginPage.expectLoginError();
});
test('should persist session after page refresh', async ({ page }) => {
const { email, password } = getTestUserCredentials('user');
// Login
await loginPage.login(email, password);
await loginPage.expectLoginSuccess();
// Reload page
await page.reload();
// Should still be logged in (not redirected to /auth)
await expect(page).not.toHaveURL(/\/auth/);
});
test('should clear session on logout', async ({ page }) => {
const { email, password } = getTestUserCredentials('user');
// Login
await loginPage.login(email, password);
await loginPage.expectLoginSuccess();
// Logout
await page.click('button:has-text("Logout")').or(page.click('[data-testid="logout"]'));
// Should be redirected to auth or homepage
// And trying to access protected route should redirect to auth
await page.goto('/moderation/queue');
await expect(page).toHaveURL(/\/auth/);
});
test('should validate email format', async ({ page }) => {
await loginPage.login('invalid-email', 'password123');
// Should show validation error
await expect(page.getByText(/invalid.*email/i)).toBeVisible();
});
test('should require password', async ({ page }) => {
const { email } = getTestUserCredentials('user');
await page.fill('input[type="email"]', email);
await page.click('button[type="submit"]');
// Should show validation error
await expect(page.getByText(/password.*required/i)).toBeVisible();
});
});
test.describe('Role-Based Access', () => {
test('moderator can access moderation queue', async ({ browser }) => {
const context = await browser.newContext({ storageState: '.auth/moderator.json' });
const page = await context.newPage();
await page.goto('/moderation/queue');
// Should not be redirected
await expect(page).toHaveURL(/\/moderation\/queue/);
// Page should load successfully
await expect(page.getByText(/moderation.*queue/i)).toBeVisible();
await context.close();
});
test('regular user cannot access moderation queue', async ({ browser }) => {
const context = await browser.newContext({ storageState: '.auth/user.json' });
const page = await context.newPage();
await page.goto('/moderation/queue');
// Should be redirected or see access denied
await expect(page.getByText(/access denied/i).or(page.getByText(/not authorized/i))).toBeVisible();
await context.close();
});
test('admin can access admin panel', async ({ browser }) => {
const context = await browser.newContext({ storageState: '.auth/admin.json' });
const page = await context.newPage();
await page.goto('/admin/users');
// Should not be redirected
await expect(page).toHaveURL(/\/admin/);
await context.close();
});
});

View File

@@ -1,139 +0,0 @@
/**
* Moderation Approval Flow E2E Tests
*
* Tests the complete submission approval process.
*/
import { test, expect } from '@playwright/test';
import { ModerationQueuePage } from '../../helpers/page-objects/ModerationQueuePage';
import { ParkCreationPage } from '../../helpers/page-objects/ParkCreationPage';
import { generateParkData, generateTestId } from '../../fixtures/test-data';
import { queryDatabase, cleanupTestData, waitForVersion } from '../../fixtures/database';
test.describe('Submission Approval Flow', () => {
test.afterAll(async () => {
await cleanupTestData();
});
test('should approve park submission and create entity', async ({ browser }) => {
// Step 1: Create submission as regular user
const userContext = await browser.newContext({ storageState: '.auth/user.json' });
const userPage = await userContext.newPage();
const parkData = generateParkData({
name: `Test Park ${generateTestId()}`,
});
const parkCreationPage = new ParkCreationPage(userPage);
await parkCreationPage.goto();
await parkCreationPage.fillBasicInfo(parkData.name, parkData.description);
await parkCreationPage.submitForm();
await parkCreationPage.expectSuccess();
await userContext.close();
// Step 2: Approve as moderator
const modContext = await browser.newContext({ storageState: '.auth/moderator.json' });
const modPage = await modContext.newPage();
const moderationPage = new ModerationQueuePage(modPage);
await moderationPage.goto();
// Find the submission
await moderationPage.expectSubmissionVisible(parkData.name);
// Claim it
await moderationPage.claimSubmission(0);
// Approve it
await moderationPage.approveSubmission('Looks good!');
// Step 3: Verify entity created in database
await modPage.waitForTimeout(2000); // Give DB time to process
const parks = await queryDatabase('parks', (qb) =>
qb.select('*').eq('name', parkData.name)
);
expect(parks).toHaveLength(1);
expect(parks[0].is_test_data).toBe(true);
// Step 4: Verify version created
const versions = await queryDatabase('park_versions', (qb) =>
qb.select('*').eq('park_id', parks[0].id).eq('version_number', 1)
);
expect(versions).toHaveLength(1);
expect(versions[0].change_type).toBe('created');
// Step 5: Verify submission marked as approved
const submissions = await queryDatabase('content_submissions', (qb) =>
qb.select('*').eq('entity_type', 'park').contains('submission_data', { name: parkData.name })
);
expect(submissions[0].status).toBe('approved');
expect(submissions[0].approved_by).toBeTruthy();
expect(submissions[0].approved_at).toBeTruthy();
// Step 6: Verify lock released
expect(submissions[0].assigned_to).toBeNull();
expect(submissions[0].locked_until).toBeNull();
await modContext.close();
});
test('should show change comparison for edits', async ({ browser }) => {
// This test would require:
// 1. Creating and approving a park
// 2. Editing the park
// 3. Viewing the edit in moderation queue
// 4. Verifying change comparison displays
// Left as TODO - requires more complex setup
});
test('should send notification to submitter on approval', async ({ browser }) => {
// This test would verify that Novu notification is sent
// Left as TODO - requires Novu testing setup
});
test('should prevent approval without lock', async ({ browser }) => {
// Create submission as user
const userContext = await browser.newContext({ storageState: '.auth/user.json' });
const userPage = await userContext.newPage();
const parkData = generateParkData({
name: `Test Park ${generateTestId()}`,
});
const parkCreationPage = new ParkCreationPage(userPage);
await parkCreationPage.goto();
await parkCreationPage.fillBasicInfo(parkData.name, parkData.description);
await parkCreationPage.submitForm();
await parkCreationPage.expectSuccess();
await userContext.close();
// Try to approve as moderator WITHOUT claiming
const modContext = await browser.newContext({ storageState: '.auth/moderator.json' });
const modPage = await modContext.newPage();
const moderationPage = new ModerationQueuePage(modPage);
await moderationPage.goto();
// Approve button should be disabled or not visible
const approveButton = modPage.locator('button:has-text("Approve")').first();
await expect(approveButton).toBeDisabled();
await modContext.close();
});
});
test.describe('Bulk Approval', () => {
test('should approve all items in submission', async ({ browser }) => {
// TODO: Test approving all submission items at once
});
test('should allow selective item approval', async ({ browser }) => {
// TODO: Test approving only specific items from a submission
});
});

View File

@@ -1,140 +0,0 @@
/**
* E2E Tests for Moderation Lock Management
*
* Browser-based tests for lock UI and interactions
* Uses authenticated state from global setup
*/
import { test, expect } from '@playwright/test';
// Configure test to use moderator auth state
test.use({ storageState: '.auth/moderator.json' });
test.describe('Moderation Lock Management UI', () => {
test.beforeEach(async ({ page }) => {
// Navigate to moderation queue (already authenticated via storageState)
await page.goto('/moderation/queue');
await page.waitForLoadState('networkidle');
});
test('moderator can see queue items', async ({ page }) => {
// Wait for queue items to load
const queueItems = page.locator('[data-testid="queue-item"]');
// Check if queue items are visible (may be 0 if queue is empty)
const count = await queueItems.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('moderator can claim pending submission', async ({ page }) => {
// Wait for queue items to load
await page.waitForSelector('[data-testid="queue-item"]', { timeout: 10000 });
// Find first pending item with claim button
const firstItem = page.locator('[data-testid="queue-item"]').first();
const claimButton = firstItem.locator('button:has-text("Claim Submission")');
// Check if claim button exists
const claimButtonCount = await claimButton.count();
if (claimButtonCount === 0) {
console.log('No unclaimed submissions found - skipping test');
test.skip();
return;
}
// Verify button exists and is enabled
await expect(claimButton).toBeVisible();
await expect(claimButton).toBeEnabled();
// Click claim
await claimButton.click();
// Verify lock UI appears (claimed by you badge)
await expect(firstItem.locator('text=/claimed by you/i')).toBeVisible({ timeout: 5000 });
// Verify approve/reject buttons are now enabled
const approveButton = firstItem.locator('button:has-text("Approve")');
const rejectButton = firstItem.locator('button:has-text("Reject")');
await expect(approveButton).toBeEnabled();
await expect(rejectButton).toBeEnabled();
});
test('lock timer displays countdown', async ({ page }) => {
await page.waitForSelector('[data-testid="queue-item"]', { timeout: 10000 });
const firstItem = page.locator('[data-testid="queue-item"]').first();
const claimButton = firstItem.locator('button:has-text("Claim Submission")');
const claimButtonCount = await claimButton.count();
if (claimButtonCount === 0) {
test.skip();
return;
}
// Claim submission
await claimButton.click();
await page.waitForTimeout(2000);
// Look for lock status display with timer (format: 14:XX or 15:00)
const lockStatus = page.locator('[data-testid="lock-status-display"]');
await expect(lockStatus).toBeVisible({ timeout: 5000 });
});
test('extend lock button appears when enabled', async ({ page }) => {
await page.waitForSelector('[data-testid="queue-item"]', { timeout: 10000 });
const firstItem = page.locator('[data-testid="queue-item"]').first();
const claimButton = firstItem.locator('button:has-text("Claim Submission")');
const claimButtonCount = await claimButton.count();
if (claimButtonCount === 0) {
test.skip();
return;
}
// Claim submission
await claimButton.click();
await page.waitForTimeout(2000);
// Check if extend button exists (may not appear immediately if > 5 minutes remain)
const extendButton = page.locator('button:has-text("Extend Lock")');
const extendButtonCount = await extendButton.count();
// If button doesn't exist, that's expected behavior (> 5 minutes remaining)
expect(extendButtonCount).toBeGreaterThanOrEqual(0);
});
test('release lock button clears claim', async ({ page }) => {
await page.waitForSelector('[data-testid="queue-item"]', { timeout: 10000 });
const firstItem = page.locator('[data-testid="queue-item"]').first();
const claimButton = firstItem.locator('button:has-text("Claim Submission")');
const claimButtonCount = await claimButton.count();
if (claimButtonCount === 0) {
test.skip();
return;
}
// Claim submission
await claimButton.click();
await page.waitForTimeout(2000);
// Find and click release button
const releaseButton = page.locator('button:has-text("Release Lock")');
await expect(releaseButton).toBeVisible({ timeout: 5000 });
await releaseButton.click();
// Verify claim button reappears
await expect(claimButton).toBeVisible({ timeout: 5000 });
});
test('locked by another moderator shows warning', async ({ page }) => {
// Check if any submission has the "Locked by Another Moderator" badge
const lockedBadge = page.locator('text=/Locked by .*/i');
// If no locked submissions, this test is informational only
const count = await lockedBadge.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});

View File

@@ -1,138 +0,0 @@
/**
* Park Creation E2E Tests
*
* Tests the complete park submission flow through the UI.
*/
import { test, expect } from '@playwright/test';
import { ParkCreationPage } from '../../helpers/page-objects/ParkCreationPage';
import { generateParkData, generateTestId } from '../../fixtures/test-data';
import { queryDatabase, cleanupTestData } from '../../fixtures/database';
test.describe('Park Creation Flow', () => {
let parkCreationPage: ParkCreationPage;
test.beforeEach(async ({ page }) => {
parkCreationPage = new ParkCreationPage(page);
await parkCreationPage.goto();
});
test.afterAll(async () => {
// Clean up test data
await cleanupTestData();
});
test('should create park submission successfully', async ({ page }) => {
const parkData = generateParkData({
name: `Test Park ${generateTestId()}`,
});
// Fill form
await parkCreationPage.fillBasicInfo(parkData.name, parkData.description);
await parkCreationPage.selectParkType(parkData.park_type);
await parkCreationPage.selectStatus(parkData.status);
await parkCreationPage.setOpeningDate(parkData.opened_date);
// Submit
await parkCreationPage.submitForm();
await parkCreationPage.expectSuccess();
// Verify submission created in database
const submissions = await queryDatabase('content_submissions', (qb) =>
qb.select('*').eq('entity_type', 'park').eq('is_test_data', true).order('created_at', { ascending: false }).limit(1)
);
expect(submissions).toHaveLength(1);
expect(submissions[0].status).toBe('pending');
// Verify NO park created yet (should be in moderation queue)
const parks = await queryDatabase('parks', (qb) =>
qb.select('*').eq('slug', parkData.slug)
);
expect(parks).toHaveLength(0);
});
test('should validate required fields', async ({ page }) => {
// Try to submit empty form
await parkCreationPage.submitForm();
// Should show validation errors
await parkCreationPage.expectValidationError('Name is required');
});
test('should auto-generate slug from name', async ({ page }) => {
const parkName = `Amazing Theme Park ${generateTestId()}`;
await page.fill('input[name="name"]', parkName);
// Wait for slug to auto-generate
await page.waitForTimeout(500);
const slugValue = await page.inputValue('input[name="slug"]');
expect(slugValue).toContain('amazing-theme-park');
expect(slugValue).not.toContain(' '); // No spaces
});
test('should support custom date precision', async ({ page }) => {
const parkData = generateParkData({
name: `Test Park ${generateTestId()}`,
});
await parkCreationPage.fillBasicInfo(parkData.name, parkData.description);
await parkCreationPage.setOpeningDate('2020-01-01', 'year');
await parkCreationPage.submitForm();
await parkCreationPage.expectSuccess();
// Verify date precision in submission
const submissions = await queryDatabase('content_submissions', (qb) =>
qb.select('submission_data').eq('entity_type', 'park').eq('is_test_data', true).order('created_at', { ascending: false }).limit(1)
);
const submissionData = submissions[0].submission_data;
expect(submissionData.opened_date_precision).toBe('year');
});
test('should display submission in user profile', async ({ page }) => {
const parkData = generateParkData({
name: `Test Park ${generateTestId()}`,
});
// Create submission
await parkCreationPage.fillBasicInfo(parkData.name, parkData.description);
await parkCreationPage.submitForm();
await parkCreationPage.expectSuccess();
// Navigate to user profile
await page.click('[data-testid="user-menu"]').or(page.click('button:has-text("Profile")'));
await page.click('text=My Submissions');
// Verify submission appears
await expect(page.getByText(parkData.name)).toBeVisible();
await expect(page.getByText(/pending/i)).toBeVisible();
});
});
test.describe('Park Form Validation', () => {
let parkCreationPage: ParkCreationPage;
test.beforeEach(async ({ page }) => {
parkCreationPage = new ParkCreationPage(page);
await parkCreationPage.goto();
});
test('should enforce minimum description length', async ({ page }) => {
await page.fill('input[name="name"]', 'Test Park');
await page.fill('textarea[name="description"]', 'Too short');
await parkCreationPage.submitForm();
await expect(page.getByText(/description.*too short/i)).toBeVisible();
});
test('should prevent duplicate slugs', async ({ page }) => {
// This test would require creating a park first, then trying to create another with same slug
// Left as TODO - requires more complex setup
});
});

View File

@@ -1,465 +0,0 @@
/**
* Comprehensive Rate Limiting Tests
*
* Tests rate limiting enforcement across ALL 17 submission types
* to verify the pipeline protection is working correctly.
*/
import { test, expect } from '@playwright/test';
import { supabase } from '../../fixtures/database';
import {
generateParkData,
generateRideData,
generateCompanyData,
generateRideModelData,
generateTestId
} from '../../fixtures/test-data';
test.describe('Rate Limiting - All Submission Types', () => {
test.beforeEach(async ({ page }) => {
// Clear any existing rate limit state
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
});
/**
* Test: Park Creation Rate Limiting
*/
test('should enforce rate limit on park creation (5/min)', async ({ page }) => {
await page.goto('/submit/park/new');
const successfulSubmissions: string[] = [];
const rateLimitHit = { value: false };
// Attempt 6 rapid submissions (limit is 5/min)
for (let i = 0; i < 6; i++) {
const parkData = generateParkData({
name: `Rate Test Park ${generateTestId()}`,
});
await page.fill('input[name="name"]', parkData.name);
await page.fill('textarea[name="description"]', parkData.description);
await page.selectOption('select[name="park_type"]', parkData.park_type);
await page.selectOption('select[name="status"]', parkData.status);
await page.click('button[type="submit"]');
// Wait for response
await page.waitForTimeout(500);
// Check if rate limit error appeared
const rateLimitError = await page.getByText(/rate limit/i).isVisible().catch(() => false);
if (rateLimitError) {
rateLimitHit.value = true;
console.log(`✓ Rate limit hit on submission ${i + 1}`);
break;
} else {
successfulSubmissions.push(parkData.name);
console.log(` Submission ${i + 1} succeeded`);
}
}
// Verify rate limit was enforced
expect(rateLimitHit.value).toBe(true);
expect(successfulSubmissions.length).toBeLessThanOrEqual(5);
console.log(`✓ Park creation rate limit working: ${successfulSubmissions.length} allowed`);
});
/**
* Test: Park Update Rate Limiting
*/
test('should enforce rate limit on park updates', async ({ page, browser }) => {
// First create a park to update
const { data: parks } = await supabase
.from('parks')
.select('id, slug')
.eq('is_test_data', false)
.limit(1)
.single();
if (!parks) {
test.skip();
return;
}
await page.goto(`/submit/park/${parks.slug}/edit`);
let rateLimitHit = false;
// Attempt 6 rapid update submissions
for (let i = 0; i < 6; i++) {
await page.fill('textarea[name="description"]', `Update attempt ${i} - ${generateTestId()}`);
await page.fill('input[name="submission_notes"]', `Rate test ${i}`);
await page.click('button[type="submit"]');
await page.waitForTimeout(500);
const rateLimitError = await page.getByText(/rate limit/i).isVisible().catch(() => false);
if (rateLimitError) {
rateLimitHit = true;
break;
}
}
expect(rateLimitHit).toBe(true);
console.log('✓ Park update rate limit working');
});
/**
* Test: Ride Creation Rate Limiting
*/
test('should enforce rate limit on ride creation', async ({ page }) => {
// Need a park first
const { data: parks } = await supabase
.from('parks')
.select('id, slug')
.limit(1)
.single();
if (!parks) {
test.skip();
return;
}
await page.goto(`/submit/park/${parks.slug}/rides/new`);
let successCount = 0;
let rateLimitHit = false;
for (let i = 0; i < 6; i++) {
const rideData = generateRideData(parks.id, {
name: `Rate Test Ride ${generateTestId()}`,
});
await page.fill('input[name="name"]', rideData.name);
await page.fill('textarea[name="description"]', rideData.description);
await page.selectOption('select[name="category"]', rideData.category);
await page.click('button[type="submit"]');
await page.waitForTimeout(500);
const rateLimitError = await page.getByText(/rate limit/i).isVisible().catch(() => false);
if (rateLimitError) {
rateLimitHit = true;
break;
}
successCount++;
}
expect(rateLimitHit).toBe(true);
expect(successCount).toBeLessThanOrEqual(5);
console.log(`✓ Ride creation rate limit working: ${successCount} allowed`);
});
/**
* Test: Manufacturer Creation Rate Limiting (Company Helper)
*/
test('should enforce rate limit on manufacturer creation', async ({ page }) => {
await page.goto('/submit/manufacturer/new');
let successCount = 0;
let rateLimitHit = false;
for (let i = 0; i < 6; i++) {
const companyData = generateCompanyData('manufacturer', {
name: `Rate Test Manufacturer ${generateTestId()}`,
});
await page.fill('input[name="name"]', companyData.name);
await page.fill('textarea[name="description"]', companyData.description);
await page.selectOption('select[name="person_type"]', companyData.person_type);
await page.click('button[type="submit"]');
await page.waitForTimeout(500);
const rateLimitError = await page.getByText(/rate limit/i).isVisible().catch(() => false);
if (rateLimitError) {
rateLimitHit = true;
break;
}
successCount++;
}
expect(rateLimitHit).toBe(true);
expect(successCount).toBeLessThanOrEqual(5);
console.log(`✓ Manufacturer creation rate limit working: ${successCount} allowed`);
});
/**
* Test: Designer Creation Rate Limiting (Company Helper)
*/
test('should enforce rate limit on designer creation', async ({ page }) => {
await page.goto('/submit/designer/new');
let rateLimitHit = false;
for (let i = 0; i < 6; i++) {
const companyData = generateCompanyData('designer', {
name: `Rate Test Designer ${generateTestId()}`,
});
await page.fill('input[name="name"]', companyData.name);
await page.fill('textarea[name="description"]', companyData.description);
await page.click('button[type="submit"]');
await page.waitForTimeout(500);
const rateLimitError = await page.getByText(/rate limit/i).isVisible().catch(() => false);
if (rateLimitError) {
rateLimitHit = true;
break;
}
}
expect(rateLimitHit).toBe(true);
console.log('✓ Designer creation rate limit working');
});
/**
* Test: Operator Creation Rate Limiting (Company Helper)
*/
test('should enforce rate limit on operator creation', async ({ page }) => {
await page.goto('/submit/operator/new');
let rateLimitHit = false;
for (let i = 0; i < 6; i++) {
const companyData = generateCompanyData('operator', {
name: `Rate Test Operator ${generateTestId()}`,
});
await page.fill('input[name="name"]', companyData.name);
await page.fill('textarea[name="description"]', companyData.description);
await page.click('button[type="submit"]');
await page.waitForTimeout(500);
const rateLimitError = await page.getByText(/rate limit/i).isVisible().catch(() => false);
if (rateLimitError) {
rateLimitHit = true;
break;
}
}
expect(rateLimitHit).toBe(true);
console.log('✓ Operator creation rate limit working');
});
/**
* Test: Property Owner Creation Rate Limiting (Company Helper)
*/
test('should enforce rate limit on property owner creation', async ({ page }) => {
await page.goto('/submit/property-owner/new');
let rateLimitHit = false;
for (let i = 0; i < 6; i++) {
const companyData = generateCompanyData('property_owner', {
name: `Rate Test Owner ${generateTestId()}`,
});
await page.fill('input[name="name"]', companyData.name);
await page.fill('textarea[name="description"]', companyData.description);
await page.click('button[type="submit"]');
await page.waitForTimeout(500);
const rateLimitError = await page.getByText(/rate limit/i).isVisible().catch(() => false);
if (rateLimitError) {
rateLimitHit = true;
break;
}
}
expect(rateLimitHit).toBe(true);
console.log('✓ Property owner creation rate limit working');
});
/**
* Test: Rate Limit Cooldown (60 seconds)
*/
test('should block submissions during 60-second cooldown', async ({ page }) => {
await page.goto('/submit/park/new');
// Hit rate limit
for (let i = 0; i < 6; i++) {
const parkData = generateParkData({
name: `Cooldown Test ${generateTestId()}`,
});
await page.fill('input[name="name"]', parkData.name);
await page.fill('textarea[name="description"]', parkData.description);
await page.selectOption('select[name="park_type"]', parkData.park_type);
await page.click('button[type="submit"]');
await page.waitForTimeout(300);
}
// Verify rate limit message appears
const rateLimitMessage = await page.getByText(/rate limit|too many/i).isVisible();
expect(rateLimitMessage).toBe(true);
// Try to submit again immediately - should still be blocked
const parkData = generateParkData({
name: `Cooldown Test After ${generateTestId()}`,
});
await page.fill('input[name="name"]', parkData.name);
await page.click('button[type="submit"]');
await page.waitForTimeout(500);
const stillBlocked = await page.getByText(/rate limit|blocked|cooldown/i).isVisible();
expect(stillBlocked).toBe(true);
console.log('✓ 60-second cooldown working correctly');
});
/**
* Test: Hourly Rate Limit (20/hour)
*/
test('should enforce hourly rate limit across different submission types', async ({ page }) => {
// This test would take too long to run in real-time (20+ submissions)
// Instead, we verify the rate limiter configuration
const rateLimitStatus = await page.evaluate(() => {
// Access the rate limiter through window if exposed for testing
// This is a unit test disguised as E2E
const config = {
perMinute: 5,
perHour: 20,
cooldownSeconds: 60
};
return config;
});
expect(rateLimitStatus.perMinute).toBe(5);
expect(rateLimitStatus.perHour).toBe(20);
expect(rateLimitStatus.cooldownSeconds).toBe(60);
console.log('✓ Hourly rate limit configuration verified');
});
});
test.describe('Rate Limiting - Cross-Type Protection', () => {
/**
* Test: Rate limits are per-user, not per-type
*/
test('should share rate limit across different entity types', async ({ page }) => {
// Submit 3 parks
await page.goto('/submit/park/new');
for (let i = 0; i < 3; i++) {
const parkData = generateParkData({ name: `Cross Test Park ${generateTestId()}` });
await page.fill('input[name="name"]', parkData.name);
await page.fill('textarea[name="description"]', parkData.description);
await page.selectOption('select[name="park_type"]', parkData.park_type);
await page.click('button[type="submit"]');
await page.waitForTimeout(300);
}
// Now try to submit 3 manufacturers - should hit rate limit after 2
await page.goto('/submit/manufacturer/new');
let manufacturerSuccessCount = 0;
let rateLimitHit = false;
for (let i = 0; i < 3; i++) {
const companyData = generateCompanyData('manufacturer', {
name: `Cross Test Manufacturer ${generateTestId()}`,
});
await page.fill('input[name="name"]', companyData.name);
await page.fill('textarea[name="description"]', companyData.description);
await page.click('button[type="submit"]');
await page.waitForTimeout(500);
const rateLimitError = await page.getByText(/rate limit/i).isVisible().catch(() => false);
if (rateLimitError) {
rateLimitHit = true;
break;
}
manufacturerSuccessCount++;
}
// Should have been blocked on 2nd or 3rd manufacturer (3 parks + 2-3 manufacturers = 5-6 total)
expect(rateLimitHit).toBe(true);
expect(manufacturerSuccessCount).toBeLessThanOrEqual(2);
console.log(`✓ Cross-type rate limiting working: 3 parks + ${manufacturerSuccessCount} manufacturers before limit`);
});
/**
* Test: Ban check still works with rate limiting
*/
test('should check bans before rate limiting', async ({ page }) => {
// This test requires a banned user setup
// Left as TODO - requires specific test user with ban status
test.skip();
});
});
test.describe('Rate Limiting - Error Messages', () => {
/**
* Test: Clear error messages shown to users
*/
test('should show clear rate limit error message', async ({ page }) => {
await page.goto('/submit/park/new');
// Hit rate limit
for (let i = 0; i < 6; i++) {
const parkData = generateParkData({ name: `Error Test ${generateTestId()}` });
await page.fill('input[name="name"]', parkData.name);
await page.fill('textarea[name="description"]', parkData.description);
await page.selectOption('select[name="park_type"]', parkData.park_type);
await page.click('button[type="submit"]');
await page.waitForTimeout(300);
}
// Check error message quality
const errorText = await page.locator('[role="alert"], .error-message, .toast').textContent();
expect(errorText).toBeTruthy();
expect(errorText?.toLowerCase()).toMatch(/rate limit|too many|slow down|wait/);
console.log(`✓ Error message: "${errorText}"`);
});
/**
* Test: Retry-After information provided
*/
test('should inform users when they can retry', async ({ page }) => {
await page.goto('/submit/park/new');
// Hit rate limit
for (let i = 0; i < 6; i++) {
const parkData = generateParkData({ name: `Retry Test ${generateTestId()}` });
await page.fill('input[name="name"]', parkData.name);
await page.fill('textarea[name="description"]', parkData.description);
await page.selectOption('select[name="park_type"]', parkData.park_type);
await page.click('button[type="submit"]');
await page.waitForTimeout(300);
}
// Look for time information in error message
const errorText = await page.locator('[role="alert"], .error-message, .toast').textContent();
expect(errorText).toBeTruthy();
// Should mention either seconds, minutes, or a specific time
expect(errorText?.toLowerCase()).toMatch(/second|minute|retry|wait|after/);
console.log('✓ Retry timing information provided to user');
});
});

123
tests/fixtures/auth.ts vendored
View File

@@ -1,123 +0,0 @@
/**
* Authentication Fixtures for Playwright Tests
*
* Manages authentication state for different user roles.
* Creates reusable auth states to avoid logging in for every test.
*/
import { chromium, type FullConfig } from '@playwright/test';
import { setupTestUser, supabase } from './database';
import * as fs from 'fs';
import * as path from 'path';
const TEST_USERS = {
user: {
email: process.env.TEST_USER_EMAIL || 'test-user@thrillwiki.test',
password: process.env.TEST_USER_PASSWORD || 'TestUser123!',
role: 'user' as const,
},
moderator: {
email: process.env.TEST_MODERATOR_EMAIL || 'test-moderator@thrillwiki.test',
password: process.env.TEST_MODERATOR_PASSWORD || 'TestModerator123!',
role: 'moderator' as const,
},
admin: {
email: process.env.TEST_ADMIN_EMAIL || 'test-admin@thrillwiki.test',
password: process.env.TEST_ADMIN_PASSWORD || 'TestAdmin123!',
role: 'admin' as const,
},
superuser: {
email: process.env.TEST_SUPERUSER_EMAIL || 'test-superuser@thrillwiki.test',
password: process.env.TEST_SUPERUSER_PASSWORD || 'TestSuperuser123!',
role: 'superuser' as const,
},
};
/**
* Setup authentication states for all test users
*/
export async function setupAuthStates(config: FullConfig): Promise<void> {
const baseURL = config.projects[0].use.baseURL || 'http://localhost:8080';
// Ensure .auth directory exists
const authDir = path.join(process.cwd(), '.auth');
if (!fs.existsSync(authDir)) {
fs.mkdirSync(authDir, { recursive: true });
}
const browser = await chromium.launch();
for (const [roleName, userData] of Object.entries(TEST_USERS)) {
const context = await browser.newContext();
const page = await context.newPage();
try {
// Create test user if doesn't exist
await setupTestUser(userData.email, userData.password, userData.role);
// Navigate to login page
await page.goto(`${baseURL}/auth`);
// Wait for page to load
await page.waitForLoadState('networkidle');
// Fill login form
await page.fill('input[type="email"]', userData.email);
await page.fill('input[type="password"]', userData.password);
// Click login button
await page.click('button[type="submit"]');
// Wait for navigation to complete
await page.waitForURL('**/', { timeout: 10000 });
// Save authenticated state
const authFile = path.join(authDir, `${roleName}.json`);
await context.storageState({ path: authFile });
console.log(`✓ Created auth state for ${roleName}`);
} catch (error) {
console.error(`✗ Failed to create auth state for ${roleName}:`, error);
throw error;
} finally {
await context.close();
}
}
await browser.close();
}
/**
* Get auth credentials for a specific role
*/
export function getTestUserCredentials(role: keyof typeof TEST_USERS) {
return TEST_USERS[role];
}
/**
* Login programmatically (for use within tests)
*/
export async function loginAsUser(
email: string,
password: string
): Promise<{ userId: string; accessToken: string }> {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) throw error;
if (!data.user || !data.session) throw new Error('Login failed');
return {
userId: data.user.id,
accessToken: data.session.access_token,
};
}
/**
* Logout programmatically
*/
export async function logout(): Promise<void> {
await supabase.auth.signOut();
}

View File

@@ -1,207 +0,0 @@
/**
* Database Fixtures for Playwright Tests
*
* Provides direct database access for test setup and teardown using service role.
* IMPORTANT: Only use for test data management, never in production code!
*/
import { createClient } from '@supabase/supabase-js';
import type { Database } from '@/integrations/supabase/types';
const supabaseUrl = 'https://ydvtmnrszybqnbcqbdcy.supabase.co';
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4';
// For test setup/teardown only - requires service role key
const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
// Regular client for authenticated operations
export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey);
// Service role client for test setup/teardown (bypasses RLS)
export const supabaseAdmin = supabaseServiceRoleKey
? createClient<Database>(supabaseUrl, supabaseServiceRoleKey)
: null;
/**
* Create a test user with specific role
*/
export async function setupTestUser(
email: string,
password: string,
role: 'user' | 'moderator' | 'admin' | 'superuser' = 'user'
): Promise<{ userId: string; email: string }> {
if (!supabaseAdmin) {
throw new Error('Service role key not configured');
}
// Check if user already exists
const { data: existingUsers } = await supabaseAdmin.auth.admin.listUsers();
const existingUser = existingUsers?.users.find(u => u.email === email);
let userId: string;
if (existingUser) {
// User exists - use their ID
userId = existingUser.id;
console.log(` Using existing test user: ${email}`);
} else {
// User doesn't exist - create new one
const { data: authData, error: authError } = await supabaseAdmin.auth.admin.createUser({
email,
password,
email_confirm: true,
});
if (authError) throw authError;
if (!authData.user) throw new Error('User creation failed');
userId = authData.user.id;
console.log(`✓ Created new test user: ${email}`);
}
// Create or update profile (ensures correct role and is_test_data flag)
const { error: profileError } = await supabaseAdmin
.from('profiles')
.upsert({
id: userId,
username: email.split('@')[0],
email,
role,
is_test_data: true,
});
if (profileError) throw profileError;
return { userId, email };
}
/**
* Clean up all test data
*/
export async function cleanupTestData(): Promise<void> {
if (!supabaseAdmin) {
throw new Error('Service role key not configured');
}
// Delete in dependency order (child tables first)
const tables = [
'moderation_audit_log',
'ride_photos',
'park_photos',
'submission_items',
'content_submissions',
'ride_versions',
'park_versions',
'company_versions',
'ride_model_versions',
'rides',
'ride_models',
'parks',
'companies',
];
for (const table of tables) {
await supabaseAdmin
.from(table as any)
.delete()
.eq('is_test_data', true);
}
// Delete test profiles
const { data: profiles } = await supabaseAdmin
.from('profiles')
.select('id')
.eq('is_test_data', true);
if (profiles) {
for (const profile of profiles) {
await supabaseAdmin.auth.admin.deleteUser(profile.id);
}
}
}
/**
* Query database directly for assertions
*/
export async function queryDatabase<T = any>(
table: string,
query: (qb: any) => any
): Promise<T[]> {
if (!supabaseAdmin) {
throw new Error('Service role key not configured');
}
const { data, error } = await query(supabaseAdmin.from(table));
if (error) throw error;
return data || [];
}
/**
* Wait for a version to be created
*/
export async function waitForVersion(
entityId: string,
versionNumber: number,
table: string,
maxWaitMs: number = 5000
): Promise<boolean> {
if (!supabaseAdmin) {
throw new Error('Service role key not configured');
}
const startTime = Date.now();
while (Date.now() - startTime < maxWaitMs) {
const { data } = await supabaseAdmin
.from(table as any)
.select('version_number')
.eq('entity_id', entityId)
.eq('version_number', versionNumber)
.single();
if (data) return true;
await new Promise(resolve => setTimeout(resolve, 500));
}
return false;
}
/**
* Approve a submission directly (for test setup)
*/
export async function approveSubmissionDirect(submissionId: string): Promise<void> {
if (!supabaseAdmin) {
throw new Error('Service role key not configured');
}
const { error } = await supabaseAdmin.rpc('approve_submission', {
submission_id: submissionId,
});
if (error) throw error;
}
/**
* Get test data statistics
*/
export async function getTestDataStats(): Promise<Record<string, number>> {
if (!supabaseAdmin) {
throw new Error('Service role key not configured');
}
const tables = ['parks', 'rides', 'companies', 'ride_models', 'content_submissions', 'moderation_audit_log'];
const stats: Record<string, number> = {};
for (const table of tables) {
const { count } = await supabaseAdmin
.from(table as any)
.select('*', { count: 'exact', head: true })
.eq('is_test_data', true);
stats[table] = count || 0;
}
return stats;
}

View File

@@ -1,293 +0,0 @@
/**
* Test Data Generators
*
* Factory functions for creating realistic test data.
*/
import { faker } from '@faker-js/faker';
export interface ParkTestData {
name: string;
slug: string;
description: string;
park_type: string;
status: string;
location_country: string;
location_city: string;
latitude: number;
longitude: number;
opened_date: string;
opening_date_precision?: string;
closing_date?: string;
closing_date_precision?: string;
source_url?: string;
submission_notes?: string;
is_test_data: boolean;
}
export interface RideTestData {
name: string;
slug: string;
description: string;
category: string;
status: string;
park_id: string;
opened_date: string;
opening_date_precision?: string;
closing_date?: string;
closing_date_precision?: string;
track_material?: string[];
support_material?: string[];
propulsion_method?: string[];
source_url?: string;
submission_notes?: string;
is_test_data: boolean;
// Category-specific fields (optional)
water_depth_cm?: number;
splash_height_meters?: number;
wetness_level?: string;
flume_type?: string;
boat_capacity?: number;
theme_name?: string;
story_description?: string;
show_duration_seconds?: number;
animatronics_count?: number;
projection_type?: string;
ride_system?: string;
scenes_count?: number;
rotation_type?: string;
motion_pattern?: string;
platform_count?: number;
swing_angle_degrees?: number;
rotation_speed_rpm?: number;
arm_length_meters?: number;
max_height_reached_meters?: number;
min_age?: number;
max_age?: number;
educational_theme?: string;
character_theme?: string;
transport_type?: string;
route_length_meters?: number;
stations_count?: number;
vehicle_capacity?: number;
vehicles_count?: number;
round_trip_duration_seconds?: number;
}
export interface CompanyTestData {
name: string;
slug: string;
description: string;
company_type: string;
person_type: string;
founded_date: string;
founded_date_precision?: string;
defunct_date?: string;
defunct_date_precision?: string;
source_url?: string;
submission_notes?: string;
is_test_data: boolean;
}
export interface RideModelTestData {
name: string;
slug: string;
description: string;
category: string;
manufacturer_id: string;
source_url?: string;
submission_notes?: string;
is_test_data: boolean;
}
/**
* Generate random park test data
*/
export function generateParkData(overrides?: Partial<ParkTestData>): ParkTestData {
const name = faker.company.name() + ' Park';
const openedDate = faker.date.past({ years: 50 }).toISOString().split('T')[0];
const status = faker.helpers.arrayElement(['operating', 'closed', 'under_construction']);
const data: ParkTestData = {
name,
slug: faker.helpers.slugify(name).toLowerCase(),
description: faker.lorem.paragraphs(2),
park_type: faker.helpers.arrayElement(['theme_park', 'amusement_park', 'water_park']),
status,
location_country: faker.location.countryCode(),
location_city: faker.location.city(),
latitude: parseFloat(faker.location.latitude()),
longitude: parseFloat(faker.location.longitude()),
opened_date: openedDate,
opening_date_precision: faker.helpers.arrayElement(['day', 'month', 'year']),
is_test_data: true,
};
// Add closing date for closed parks
if (status === 'closed') {
data.closing_date = faker.date.between({ from: openedDate, to: new Date() }).toISOString().split('T')[0];
data.closing_date_precision = faker.helpers.arrayElement(['day', 'month', 'year']);
}
// Add optional fields
if (faker.datatype.boolean()) {
data.source_url = faker.internet.url();
}
if (faker.datatype.boolean()) {
data.submission_notes = faker.lorem.sentence();
}
return { ...data, ...overrides };
}
/**
* Generate random ride test data
*/
export function generateRideData(parkId: string, overrides?: Partial<RideTestData>): RideTestData {
const name = faker.word.adjective() + ' ' + faker.word.noun();
const openedDate = faker.date.past({ years: 30 }).toISOString().split('T')[0];
const status = faker.helpers.arrayElement(['operating', 'closed', 'sbno']);
const category = faker.helpers.arrayElement(['roller_coaster', 'flat_ride', 'water_ride', 'dark_ride']);
const data: RideTestData = {
name,
slug: faker.helpers.slugify(name).toLowerCase(),
description: faker.lorem.paragraphs(2),
category,
status,
park_id: parkId,
opened_date: openedDate,
opening_date_precision: faker.helpers.arrayElement(['day', 'month', 'year']),
is_test_data: true,
};
// Add closing date for closed rides
if (status === 'closed') {
data.closing_date = faker.date.between({ from: openedDate, to: new Date() }).toISOString().split('T')[0];
data.closing_date_precision = faker.helpers.arrayElement(['day', 'month', 'year']);
}
// Add material arrays for roller coasters
if (category === 'roller_coaster' && faker.datatype.boolean()) {
data.track_material = [faker.helpers.arrayElement(['steel', 'wood', 'hybrid'])];
data.support_material = [faker.helpers.arrayElement(['steel', 'wood', 'concrete'])];
data.propulsion_method = [faker.helpers.arrayElement(['chain_lift', 'cable_lift', 'launch', 'gravity'])];
}
// Add category-specific fields
if (category === 'water_ride' && faker.datatype.boolean()) {
data.water_depth_cm = faker.number.int({ min: 30, max: 300 });
data.splash_height_meters = faker.number.float({ min: 1, max: 20, fractionDigits: 1 });
data.wetness_level = faker.helpers.arrayElement(['dry', 'light', 'moderate', 'soaked']);
data.flume_type = faker.helpers.arrayElement(['log', 'tube', 'raft', 'boat']);
data.boat_capacity = faker.number.int({ min: 2, max: 20 });
}
if (category === 'dark_ride' && faker.datatype.boolean()) {
data.theme_name = faker.lorem.words(2);
data.story_description = faker.lorem.sentence();
data.show_duration_seconds = faker.number.int({ min: 180, max: 600 });
data.animatronics_count = faker.number.int({ min: 5, max: 50 });
data.projection_type = faker.helpers.arrayElement(['2d', '3d', 'holographic', 'mixed']);
data.ride_system = faker.helpers.arrayElement(['omnimover', 'tracked', 'trackless', 'boat']);
data.scenes_count = faker.number.int({ min: 5, max: 20 });
}
if (category === 'flat_ride' && faker.datatype.boolean()) {
data.rotation_type = faker.helpers.arrayElement(['horizontal', 'vertical', 'both', 'none']);
data.motion_pattern = faker.helpers.arrayElement(['circular', 'pendulum', 'spinning', 'wave']);
data.platform_count = faker.number.int({ min: 1, max: 8 });
data.swing_angle_degrees = faker.number.int({ min: 45, max: 180 });
data.rotation_speed_rpm = faker.number.int({ min: 5, max: 30 });
data.arm_length_meters = faker.number.float({ min: 5, max: 25, fractionDigits: 1 });
data.max_height_reached_meters = faker.number.float({ min: 10, max: 80, fractionDigits: 1 });
}
// Add optional fields
if (faker.datatype.boolean()) {
data.source_url = faker.internet.url();
}
if (faker.datatype.boolean()) {
data.submission_notes = faker.lorem.sentence();
}
return { ...data, ...overrides };
}
/**
* Generate random company test data
*/
export function generateCompanyData(
companyType: 'manufacturer' | 'designer' | 'operator' | 'property_owner',
overrides?: Partial<CompanyTestData>
): CompanyTestData {
const name = faker.company.name();
const foundedDate = faker.date.past({ years: 100 }).toISOString().split('T')[0];
const data: CompanyTestData = {
name,
slug: faker.helpers.slugify(name).toLowerCase(),
description: faker.lorem.paragraphs(2),
company_type: companyType,
person_type: faker.helpers.arrayElement(['individual', 'company']),
founded_date: foundedDate,
founded_date_precision: faker.helpers.arrayElement(['day', 'month', 'year']),
is_test_data: true,
};
// Add defunct date for some companies
if (faker.datatype.boolean(0.15)) {
data.defunct_date = faker.date.between({ from: foundedDate, to: new Date() }).toISOString().split('T')[0];
data.defunct_date_precision = faker.helpers.arrayElement(['day', 'month', 'year']);
}
// Add optional fields
if (faker.datatype.boolean()) {
data.source_url = faker.internet.url();
}
if (faker.datatype.boolean()) {
data.submission_notes = faker.lorem.sentence();
}
return { ...data, ...overrides };
}
/**
* Generate random ride model test data
*/
export function generateRideModelData(
manufacturerId: string,
overrides?: Partial<RideModelTestData>
): RideModelTestData {
const name = faker.word.adjective() + ' Model';
const data: RideModelTestData = {
name,
slug: faker.helpers.slugify(name).toLowerCase(),
description: faker.lorem.paragraphs(2),
category: faker.helpers.arrayElement(['roller_coaster', 'flat_ride', 'water_ride']),
manufacturer_id: manufacturerId,
is_test_data: true,
};
// Add optional fields
if (faker.datatype.boolean()) {
data.source_url = faker.internet.url();
}
if (faker.datatype.boolean()) {
data.submission_notes = faker.lorem.sentence();
}
return { ...data, ...overrides };
}
/**
* Generate unique test identifier
*/
export function generateTestId(): string {
return `test-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}

View File

@@ -1,306 +0,0 @@
/**
* Custom Playwright Reporter for Grafana Loki
*
* Streams test events and results to Loki in real-time for centralized logging and monitoring.
*/
import {
FullConfig,
FullResult,
Reporter,
Suite,
TestCase,
TestResult,
TestStep,
} from '@playwright/test/reporter';
interface LokiStream {
stream: Record<string, string>;
values: Array<[string, string]>;
}
interface LokiPushRequest {
streams: LokiStream[];
}
interface LokiReporterOptions {
lokiUrl?: string;
username?: string;
password?: string;
batchSize?: number;
flushInterval?: number;
labels?: Record<string, string>;
}
/**
* Custom Playwright reporter that sends logs to Grafana Loki
*/
export default class LokiReporter implements Reporter {
private lokiUrl: string;
private basicAuth?: string;
private batchSize: number;
private flushInterval: number;
private buffer: LokiStream[] = [];
private flushTimer?: NodeJS.Timeout;
private labels: Record<string, string>;
private testStartTime?: number;
private maxRetries: number = 3;
constructor(options: LokiReporterOptions = {}) {
this.lokiUrl = options.lokiUrl || process.env.GRAFANA_LOKI_URL || 'http://localhost:3100';
this.batchSize = options.batchSize || 5;
this.flushInterval = options.flushInterval || 5000;
// Setup basic auth if credentials provided
const username = options.username || process.env.GRAFANA_LOKI_USERNAME;
const password = options.password || process.env.GRAFANA_LOKI_PASSWORD;
if (username && password) {
this.basicAuth = Buffer.from(`${username}:${password}`).toString('base64');
}
// Base labels for all logs - sanitize to ensure Grafana Cloud compatibility
this.labels = this.sanitizeLabels({
job: 'playwright_tests',
workflow: process.env.GITHUB_WORKFLOW || 'local',
branch: process.env.GITHUB_REF_NAME || 'local',
commit: process.env.GITHUB_SHA || 'local',
run_id: process.env.GITHUB_RUN_ID || 'local',
...options.labels,
});
// Setup periodic flush
this.flushTimer = setInterval(() => this.flush(), this.flushInterval);
}
/**
* Called once before running tests
*/
async onBegin(config: FullConfig, suite: Suite) {
this.testStartTime = Date.now();
const testCount = suite.allTests().length;
await this.log({
event: 'test_suite_start',
message: `Starting Playwright test suite with ${testCount} tests`,
total_tests: testCount,
workers: config.workers,
});
}
/**
* Called after a test has been started
*/
async onTestBegin(test: TestCase) {
await this.log({
event: 'test_start',
test_name: test.title,
test_file: this.getRelativePath(test.location.file),
project: test.parent.project()?.name || 'unknown',
message: `Test started: ${test.title}`,
}, {
browser: test.parent.project()?.name || 'unknown',
test_file: this.getRelativePath(test.location.file),
});
}
/**
* Called after a test has been finished
*/
async onTestEnd(test: TestCase, result: TestResult) {
const status = result.status;
const duration = result.duration;
const browser = test.parent.project()?.name || 'unknown';
const testFile = this.getRelativePath(test.location.file);
// Determine log message based on status
let message = `Test ${status}: ${test.title}`;
if (status === 'failed' || status === 'timedOut') {
message = `${message} - ${result.error?.message || 'Unknown error'}`;
}
await this.log({
event: 'test_end',
test_name: test.title,
test_file: testFile,
status,
duration_ms: duration,
retry: result.retry,
message,
error: status === 'failed' ? result.error?.message : undefined,
error_stack: status === 'failed' ? result.error?.stack : undefined,
}, {
browser,
test_file: testFile,
test_name: test.title,
status,
});
// Log individual test steps for failed tests
if (status === 'failed') {
for (const step of result.steps) {
await this.logStep(test, step, browser, testFile);
}
}
}
/**
* Log test step details
*/
private async logStep(test: TestCase, step: TestStep, browser: string, testFile: string) {
await this.log({
event: 'test_step',
test_name: test.title,
step_title: step.title,
step_category: step.category,
duration_ms: step.duration,
error: step.error?.message,
message: `Step: ${step.title}`,
}, {
browser,
test_file: testFile,
step_category: step.category,
});
}
/**
* Called after all tests have been finished
*/
async onEnd(result: FullResult) {
const duration = this.testStartTime ? Date.now() - this.testStartTime : 0;
await this.log({
event: 'test_suite_end',
status: result.status,
duration_ms: duration,
message: `Test suite ${result.status} in ${(duration / 1000).toFixed(2)}s`,
});
// Flush remaining logs
await this.flush();
// Clear flush timer
if (this.flushTimer) {
clearInterval(this.flushTimer);
}
}
/**
* Sanitize label names to match Grafana Cloud requirements [a-zA-Z_][a-zA-Z0-9_]*
*/
private sanitizeLabels(labels: Record<string, string>): Record<string, string> {
const sanitized: Record<string, string> = {};
for (const [key, value] of Object.entries(labels)) {
const sanitizedKey = key.replace(/[^a-zA-Z0-9_]/g, '_').replace(/^[0-9]/, '_$&');
const sanitizedValue = String(value).replace(/[\n\r\t]/g, ' ');
sanitized[sanitizedKey] = sanitizedValue;
}
return sanitized;
}
/**
* Log a message to Loki
*/
private async log(data: Record<string, any>, extraLabels: Record<string, string> = {}) {
const timestamp = Date.now() * 1000000; // Convert to nanoseconds
const stream: LokiStream = {
stream: this.sanitizeLabels({
...this.labels,
...extraLabels,
event: data.event || 'log',
}),
values: [[timestamp.toString(), JSON.stringify(data)]],
};
this.buffer.push(stream);
// Flush if buffer is full
if (this.buffer.length >= this.batchSize) {
await this.flush();
}
}
/**
* Flush buffered logs to Loki with retry logic for Grafana Cloud
*/
private async flush() {
if (this.buffer.length === 0) {
return;
}
const payload: LokiPushRequest = {
streams: this.buffer,
};
this.buffer = [];
// Retry with exponential backoff
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': 'ThrillWiki-Playwright-Tests/1.0',
};
if (this.basicAuth) {
headers['Authorization'] = `Basic ${this.basicAuth}`;
}
const response = await fetch(`${this.lokiUrl}/loki/api/v1/push`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
if (response.ok || response.status === 204) {
// Success
return;
}
if (response.status === 401 || response.status === 403) {
console.error(`Loki authentication failed: ${response.status}. Check GRAFANA_LOKI_USERNAME and GRAFANA_LOKI_PASSWORD`);
return; // Don't retry auth errors
}
if (response.status === 429) {
console.warn(`Loki rate limit hit, retrying... (attempt ${attempt + 1}/${this.maxRetries})`);
} else {
console.error(`Failed to send logs to Loki: ${response.status} ${response.statusText}`);
const errorText = await response.text();
console.error(`Response: ${errorText}`);
}
// Don't retry on last attempt
if (attempt < this.maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
}
} catch (error) {
console.error(`Error sending logs to Loki (attempt ${attempt + 1}/${this.maxRetries}):`, error);
if (attempt < this.maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
} else {
// Re-add to buffer on final failure
this.buffer.push(...payload.streams);
}
}
}
}
/**
* Get relative path from project root
*/
private getRelativePath(filePath: string): string {
const cwd = process.cwd();
if (filePath.startsWith(cwd)) {
return filePath.substring(cwd.length + 1);
}
return filePath;
}
/**
* Print summary to console
*/
printsToStdio() {
return false;
}
}

View File

@@ -1,48 +0,0 @@
/**
* Login Page Object Model
*
* Encapsulates interactions with the login page.
*/
import { Page, expect } from '@playwright/test';
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/auth');
await this.page.waitForLoadState('networkidle');
}
async login(email: string, password: string) {
await this.page.fill('input[type="email"]', email);
await this.page.fill('input[type="password"]', password);
await this.page.click('button[type="submit"]');
}
async expectLoginSuccess() {
// Wait for navigation away from auth page
await this.page.waitForURL('**/', { timeout: 10000 });
// Verify we're on homepage or dashboard
await expect(this.page).not.toHaveURL(/\/auth/);
}
async expectLoginError(message?: string) {
// Check for error toast or message
if (message) {
await expect(this.page.getByText(message)).toBeVisible();
} else {
// Just verify we're still on auth page
await expect(this.page).toHaveURL(/\/auth/);
}
}
async clickSignUp() {
await this.page.click('text=Sign up');
}
async clickForgotPassword() {
await this.page.click('text=Forgot password');
}
}

View File

@@ -1,100 +0,0 @@
/**
* Moderation Queue Page Object Model
*
* Encapsulates interactions with the moderation queue.
*/
import { Page, expect } from '@playwright/test';
export class ModerationQueuePage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/moderation/queue');
await this.page.waitForLoadState('networkidle');
}
async claimSubmission(index: number = 0) {
const claimButtons = this.page.locator('button:has-text("Claim")');
await claimButtons.nth(index).click();
// Wait for lock to be acquired
await expect(this.page.getByText(/claimed by you/i)).toBeVisible({ timeout: 5000 });
}
async approveSubmission(reason?: string) {
// Click approve button
await this.page.click('button:has-text("Approve")');
// Fill optional reason if provided
if (reason) {
await this.page.fill('textarea[placeholder*="reason"]', reason);
}
// Confirm in dialog
await this.page.click('button:has-text("Confirm")');
// Wait for success toast
await expect(this.page.getByText(/approved/i)).toBeVisible({ timeout: 10000 });
}
async rejectSubmission(reason: string) {
// Click reject button
await this.page.click('button:has-text("Reject")');
// Fill required reason
await this.page.fill('textarea[placeholder*="reason"]', reason);
// Confirm in dialog
await this.page.click('button:has-text("Confirm")');
// Wait for success toast
await expect(this.page.getByText(/rejected/i)).toBeVisible({ timeout: 10000 });
}
async extendLock() {
await this.page.click('button:has-text("Extend")');
await expect(this.page.getByText(/extended/i)).toBeVisible({ timeout: 5000 });
}
async releaseLock() {
await this.page.click('button:has-text("Release")');
await expect(this.page.getByText(/released/i)).toBeVisible({ timeout: 5000 });
}
async filterByType(type: string) {
await this.page.selectOption('select[name="entity_type"]', type);
await this.page.waitForLoadState('networkidle');
}
async filterByStatus(status: string) {
await this.page.selectOption('select[name="status"]', status);
await this.page.waitForLoadState('networkidle');
}
async searchBySubmitter(name: string) {
await this.page.fill('input[placeholder*="submitter"]', name);
await this.page.waitForTimeout(500); // Debounce
await this.page.waitForLoadState('networkidle');
}
async expectSubmissionVisible(submissionName: string) {
await expect(this.page.getByText(submissionName)).toBeVisible();
}
async expectSubmissionNotVisible(submissionName: string) {
await expect(this.page.getByText(submissionName)).not.toBeVisible();
}
async expectLockTimer() {
// Check that lock timer is visible (e.g., "14:59 remaining")
await expect(this.page.locator('[data-testid="lock-timer"]').or(
this.page.getByText(/\d{1,2}:\d{2}.*remaining/i)
)).toBeVisible();
}
async expectLockWarning() {
// Warning should appear at 2 minutes remaining
await expect(this.page.getByText(/lock.*expir/i)).toBeVisible();
}
}

View File

@@ -1,87 +0,0 @@
/**
* Park Creation Page Object Model
*
* Encapsulates interactions with the park creation/editing form.
*/
import { Page, expect } from '@playwright/test';
export class ParkCreationPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/parks/new');
await this.page.waitForLoadState('networkidle');
}
async fillBasicInfo(name: string, description: string) {
// Fill park name
await this.page.fill('input[name="name"]', name);
// Slug should auto-generate, but we can override if needed
// await this.page.fill('input[name="slug"]', slug);
// Fill description (might be a textarea or rich text editor)
const descField = this.page.locator('textarea[name="description"]').first();
await descField.fill(description);
}
async selectParkType(type: string) {
// Assuming a select or radio group
await this.page.click(`[data-park-type="${type}"]`);
}
async selectStatus(status: string) {
await this.page.click(`[data-status="${status}"]`);
}
async searchLocation(query: string) {
const searchInput = this.page.locator('input[placeholder*="location"]').or(
this.page.locator('input[placeholder*="search"]')
);
await searchInput.fill(query);
await this.page.waitForTimeout(500); // Wait for autocomplete
}
async selectLocation(name: string) {
await this.page.click(`text=${name}`);
}
async setOpeningDate(date: string, precision: 'day' | 'month' | 'year' = 'day') {
await this.page.fill('input[name="opened_date"]', date);
await this.page.selectOption('select[name="date_precision"]', precision);
}
async uploadBannerImage(filePath: string) {
const fileInput = this.page.locator('input[type="file"][accept*="image"]').first();
await fileInput.setInputFiles(filePath);
}
async uploadCardImage(filePath: string) {
const fileInputs = this.page.locator('input[type="file"][accept*="image"]');
await fileInputs.nth(1).setInputFiles(filePath);
}
async uploadGalleryImages(filePaths: string[]) {
const galleryInput = this.page.locator('input[type="file"][multiple]');
await galleryInput.setInputFiles(filePaths);
}
async selectOperator(operatorName: string) {
await this.page.click('button:has-text("Select operator")');
await this.page.click(`text=${operatorName}`);
}
async submitForm() {
await this.page.click('button[type="submit"]:has-text("Submit")');
}
async expectSuccess() {
// Wait for success toast
await expect(this.page.getByText(/submitted.*review/i)).toBeVisible({ timeout: 10000 });
}
async expectValidationError(message: string) {
await expect(this.page.getByText(message)).toBeVisible();
}
}

View File

@@ -1,249 +0,0 @@
/**
* Integration Tests for Moderation Security
*
* Tests backend validation, lock enforcement, and audit logging
*/
import { test, expect } from '@playwright/test';
import { setupTestUser, supabaseAdmin, cleanupTestData } from '../fixtures/database';
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = 'https://ydvtmnrszybqnbcqbdcy.supabase.co';
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4';
test.describe('Moderation Security', () => {
test.beforeAll(async () => {
await cleanupTestData();
});
test.afterAll(async () => {
await cleanupTestData();
});
test('should validate moderator role before allowing actions', async () => {
// Create a regular user (not moderator)
const { userId, email } = await setupTestUser(
'regular-user@test.com',
'TestPassword123!',
'user'
);
// Create authenticated client for regular user
const userClient = createClient(supabaseUrl, supabaseAnonKey);
await userClient.auth.signInWithPassword({
email,
password: 'TestPassword123!',
});
// Create a test submission
if (!supabaseAdmin) {
throw new Error('Admin client not available');
}
const { data: submission } = await supabaseAdmin
.from('content_submissions')
.insert({
submission_type: 'review',
status: 'pending',
submitted_by: userId,
is_test_data: true,
})
.select()
.single();
expect(submission).toBeTruthy();
// Try to call validation function as regular user (should fail)
const { data, error } = await userClient.rpc('validate_moderation_action', {
_submission_id: submission!.id,
_user_id: userId,
_action: 'approve',
});
// Should fail with authorization error
expect(error).toBeTruthy();
expect(error?.message).toContain('Unauthorized');
await userClient.auth.signOut();
});
test('should enforce lock when another moderator has claimed submission', async () => {
// Create two moderators
const { userId: mod1Id, email: mod1Email } = await setupTestUser(
'moderator1@test.com',
'TestPassword123!',
'moderator'
);
const { userId: mod2Id, email: mod2Email } = await setupTestUser(
'moderator2@test.com',
'TestPassword123!',
'moderator'
);
// Create submission
if (!supabaseAdmin) {
throw new Error('Admin client not available');
}
const { data: submission } = await supabaseAdmin
.from('content_submissions')
.insert({
submission_type: 'review',
status: 'pending',
submitted_by: mod1Id,
is_test_data: true,
})
.select()
.single();
// Moderator 1 claims the submission
const mod1Client = createClient(supabaseUrl, supabaseAnonKey);
await mod1Client.auth.signInWithPassword({
email: mod1Email,
password: 'TestPassword123!',
});
await mod1Client
.from('content_submissions')
.update({
assigned_to: mod1Id,
locked_until: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
})
.eq('id', submission!.id);
// Moderator 2 tries to validate action (should fail due to lock)
const mod2Client = createClient(supabaseUrl, supabaseAnonKey);
await mod2Client.auth.signInWithPassword({
email: mod2Email,
password: 'TestPassword123!',
});
const { data, error } = await mod2Client.rpc('validate_moderation_action', {
_submission_id: submission!.id,
_user_id: mod2Id,
_action: 'approve',
});
// Should fail with lock error
expect(error).toBeTruthy();
expect(error?.message).toContain('locked by another moderator');
await mod1Client.auth.signOut();
await mod2Client.auth.signOut();
});
test('should create audit log entries for moderation actions', async () => {
const { userId, email } = await setupTestUser(
'audit-moderator@test.com',
'TestPassword123!',
'moderator'
);
if (!supabaseAdmin) {
throw new Error('Admin client not available');
}
// Create submission
const { data: submission } = await supabaseAdmin
.from('content_submissions')
.insert({
submission_type: 'review',
status: 'pending',
submitted_by: userId,
is_test_data: true,
})
.select()
.single();
const modClient = createClient(supabaseUrl, supabaseAnonKey);
await modClient.auth.signInWithPassword({
email,
password: 'TestPassword123!',
});
// Claim submission (should trigger audit log)
await modClient
.from('content_submissions')
.update({
assigned_to: userId,
locked_until: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
})
.eq('id', submission!.id);
// Wait a moment for trigger to fire
await new Promise(resolve => setTimeout(resolve, 1000));
// Check audit log
const { data: auditLogs } = await supabaseAdmin
.from('moderation_audit_log')
.select('*')
.eq('submission_id', submission!.id)
.eq('action', 'claim');
expect(auditLogs).toBeTruthy();
expect(auditLogs!.length).toBeGreaterThan(0);
expect(auditLogs![0].moderator_id).toBe(userId);
await modClient.auth.signOut();
});
test('should enforce rate limiting (10 actions per minute)', async () => {
const { userId, email } = await setupTestUser(
'rate-limit-mod@test.com',
'TestPassword123!',
'moderator'
);
if (!supabaseAdmin) {
throw new Error('Admin client not available');
}
const modClient = createClient(supabaseUrl, supabaseAnonKey);
await modClient.auth.signInWithPassword({
email,
password: 'TestPassword123!',
});
// Create 11 submissions
const submissions = [];
for (let i = 0; i < 11; i++) {
const { data } = await supabaseAdmin
.from('content_submissions')
.insert({
submission_type: 'review',
status: 'pending',
submitted_by: userId,
is_test_data: true,
})
.select()
.single();
submissions.push(data);
}
// Try to validate 11 actions (should fail on 11th)
let successCount = 0;
let failCount = 0;
for (const submission of submissions) {
const { error } = await modClient.rpc('validate_moderation_action', {
_submission_id: submission!.id,
_user_id: userId,
_action: 'approve',
});
if (error) {
failCount++;
expect(error.message).toContain('Rate limit exceeded');
} else {
successCount++;
}
}
// Should have at least one failure due to rate limiting
expect(failCount).toBeGreaterThan(0);
expect(successCount).toBeLessThanOrEqual(10);
await modClient.auth.signOut();
});
});

View File

@@ -1,30 +0,0 @@
/**
* Playwright Global Setup
*
* Runs once before all tests to prepare the test environment.
*/
import { FullConfig } from '@playwright/test';
import { setupAuthStates } from '../fixtures/auth';
import { cleanupTestData } from '../fixtures/database';
async function globalSetup(config: FullConfig) {
console.log('🚀 Starting global setup...');
try {
// Clean up any leftover test data from previous runs
console.log('🧹 Cleaning up leftover test data...');
await cleanupTestData();
// Setup authentication states for all user roles
console.log('🔐 Setting up authentication states...');
await setupAuthStates(config);
console.log('✅ Global setup complete');
} catch (error) {
console.error('❌ Global setup failed:', error);
throw error;
}
}
export default globalSetup;

View File

@@ -1,39 +0,0 @@
/**
* Playwright Global Teardown
*
* Runs once after all tests to clean up the test environment.
*/
import { FullConfig } from '@playwright/test';
import { cleanupTestData, getTestDataStats } from '../fixtures/database';
async function globalTeardown(config: FullConfig) {
console.log('🧹 Starting global teardown...');
try {
// Get stats before cleanup
const statsBefore = await getTestDataStats();
console.log('📊 Test data before cleanup:', statsBefore);
// Clean up all test data
await cleanupTestData();
// Verify cleanup
const statsAfter = await getTestDataStats();
console.log('📊 Test data after cleanup:', statsAfter);
const totalRemaining = Object.values(statsAfter).reduce((sum, count) => sum + count, 0);
if (totalRemaining > 0) {
console.warn('⚠️ Some test data may not have been cleaned up properly');
} else {
console.log('✅ All test data cleaned up successfully');
}
console.log('✅ Global teardown complete');
} catch (error) {
console.error('❌ Global teardown failed:', error);
// Don't throw - we don't want to fail the test run because of cleanup issues
}
}
export default globalTeardown;

View File

@@ -0,0 +1,70 @@
import { expect, afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
// Cleanup after each test
afterEach(() => {
cleanup();
});
// Mock environment variables
process.env.VITE_SUPABASE_URL = 'https://ydvtmnrszybqnbcqbdcy.supabase.co';
process.env.VITE_SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4';
// Mock window.matchMedia
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 IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
takeRecords() {
return [];
}
unobserve() {}
} as any;
// Mock ResizeObserver
global.ResizeObserver = class ResizeObserver {
constructor() {}
disconnect() {}
observe() {}
unobserve() {}
} as any;
// Mock console methods to reduce noise in test output
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
console.error = (...args: any[]) => {
// Filter out known React/testing-library warnings
const message = args[0]?.toString() || '';
if (
message.includes('Not implemented: HTMLFormElement.prototype.submit') ||
message.includes('Could not parse CSS stylesheet')
) {
return;
}
originalConsoleError(...args);
};
console.warn = (...args: any[]) => {
const message = args[0]?.toString() || '';
if (message.includes('deprecated')) {
return;
}
originalConsoleWarn(...args);
};

View File

@@ -0,0 +1,512 @@
/**
* Comprehensive Unit Tests for Moderation Lock Helpers
*
* These tests ensure proper lock management and concurrency control
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
canClaimSubmission,
isActiveLock,
getLockStatus,
formatLockExpiry,
getLockUrgency,
type LockStatus,
type LockUrgency,
} from '@/lib/moderation/lockHelpers';
describe('canClaimSubmission', () => {
const currentUserId = 'user-123';
const otherUserId = 'user-456';
describe('Unclaimed Submissions', () => {
it('should allow claiming unassigned submission', () => {
const submission = {
assigned_to: null,
locked_until: null,
};
expect(canClaimSubmission(submission, currentUserId)).toBe(true);
});
it('should allow claiming submission with no lock time', () => {
const submission = {
assigned_to: otherUserId,
locked_until: null,
};
expect(canClaimSubmission(submission, currentUserId)).toBe(true);
});
});
describe('Expired Locks', () => {
it('should allow claiming submission with expired lock', () => {
const pastDate = new Date(Date.now() - 1000).toISOString();
const submission = {
assigned_to: otherUserId,
locked_until: pastDate,
};
expect(canClaimSubmission(submission, currentUserId)).toBe(true);
});
it('should allow claiming submission with lock expired 1 minute ago', () => {
const pastDate = new Date(Date.now() - 60000).toISOString();
const submission = {
assigned_to: otherUserId,
locked_until: pastDate,
};
expect(canClaimSubmission(submission, currentUserId)).toBe(true);
});
it('should allow claiming submission with lock expired 1 hour ago', () => {
const pastDate = new Date(Date.now() - 3600000).toISOString();
const submission = {
assigned_to: otherUserId,
locked_until: pastDate,
};
expect(canClaimSubmission(submission, currentUserId)).toBe(true);
});
});
describe('Active Locks by Others', () => {
it('should not allow claiming submission locked by another user', () => {
const futureDate = new Date(Date.now() + 60000).toISOString();
const submission = {
assigned_to: otherUserId,
locked_until: futureDate,
};
expect(canClaimSubmission(submission, currentUserId)).toBe(false);
});
it('should not allow claiming submission locked by another user (15 min lock)', () => {
const futureDate = new Date(Date.now() + 15 * 60000).toISOString();
const submission = {
assigned_to: otherUserId,
locked_until: futureDate,
};
expect(canClaimSubmission(submission, currentUserId)).toBe(false);
});
});
describe('Own Locks', () => {
it('should not allow claiming own locked submission', () => {
const futureDate = new Date(Date.now() + 60000).toISOString();
const submission = {
assigned_to: currentUserId,
locked_until: futureDate,
};
expect(canClaimSubmission(submission, currentUserId)).toBe(false);
});
it('should allow claiming own submission if lock expired', () => {
const pastDate = new Date(Date.now() - 1000).toISOString();
const submission = {
assigned_to: currentUserId,
locked_until: pastDate,
};
expect(canClaimSubmission(submission, currentUserId)).toBe(true);
});
});
describe('Edge Cases', () => {
it('should handle submission assigned but no lock time', () => {
const submission = {
assigned_to: currentUserId,
locked_until: null,
};
expect(canClaimSubmission(submission, currentUserId)).toBe(true);
});
it('should handle lock time just expired (1ms ago)', () => {
const pastDate = new Date(Date.now() - 1).toISOString();
const submission = {
assigned_to: otherUserId,
locked_until: pastDate,
};
expect(canClaimSubmission(submission, currentUserId)).toBe(true);
});
it('should handle lock time about to expire (1ms future)', () => {
const futureDate = new Date(Date.now() + 1).toISOString();
const submission = {
assigned_to: otherUserId,
locked_until: futureDate,
};
expect(canClaimSubmission(submission, currentUserId)).toBe(false);
});
});
});
describe('isActiveLock', () => {
const assignedTo = 'user-123';
describe('Active Locks', () => {
it('should return true for active lock', () => {
const futureDate = new Date(Date.now() + 60000).toISOString();
expect(isActiveLock(assignedTo, futureDate)).toBe(true);
});
it('should return true for lock expiring in 1 second', () => {
const futureDate = new Date(Date.now() + 1000).toISOString();
expect(isActiveLock(assignedTo, futureDate)).toBe(true);
});
it('should return true for lock expiring in 15 minutes', () => {
const futureDate = new Date(Date.now() + 15 * 60000).toISOString();
expect(isActiveLock(assignedTo, futureDate)).toBe(true);
});
});
describe('Inactive Locks', () => {
it('should return false for expired lock', () => {
const pastDate = new Date(Date.now() - 1000).toISOString();
expect(isActiveLock(assignedTo, pastDate)).toBe(false);
});
it('should return false when no assignee', () => {
const futureDate = new Date(Date.now() + 60000).toISOString();
expect(isActiveLock(null, futureDate)).toBe(false);
});
it('should return false when no lock time', () => {
expect(isActiveLock(assignedTo, null)).toBe(false);
});
it('should return false when both null', () => {
expect(isActiveLock(null, null)).toBe(false);
});
});
describe('Edge Cases', () => {
it('should return false for lock expired 1ms ago', () => {
const pastDate = new Date(Date.now() - 1).toISOString();
expect(isActiveLock(assignedTo, pastDate)).toBe(false);
});
it('should return true for lock expiring in 1ms', () => {
const futureDate = new Date(Date.now() + 1).toISOString();
expect(isActiveLock(assignedTo, futureDate)).toBe(true);
});
});
});
describe('getLockStatus', () => {
const currentUserId = 'user-123';
const otherUserId = 'user-456';
describe('Unlocked Status', () => {
it('should return unlocked when no assignee', () => {
const submission = {
assigned_to: null,
locked_until: null,
};
expect(getLockStatus(submission, currentUserId)).toBe('unlocked');
});
it('should return unlocked when no lock time', () => {
const submission = {
assigned_to: null,
locked_until: new Date(Date.now() + 60000).toISOString(),
};
expect(getLockStatus(submission, currentUserId)).toBe('unlocked');
});
it('should return unlocked when assignee but no lock time', () => {
const submission = {
assigned_to: currentUserId,
locked_until: null,
};
expect(getLockStatus(submission, currentUserId)).toBe('unlocked');
});
});
describe('Expired Status', () => {
it('should return expired for expired lock by current user', () => {
const pastDate = new Date(Date.now() - 1000).toISOString();
const submission = {
assigned_to: currentUserId,
locked_until: pastDate,
};
expect(getLockStatus(submission, currentUserId)).toBe('expired');
});
it('should return expired for expired lock by other user', () => {
const pastDate = new Date(Date.now() - 1000).toISOString();
const submission = {
assigned_to: otherUserId,
locked_until: pastDate,
};
expect(getLockStatus(submission, currentUserId)).toBe('expired');
});
});
describe('Locked by Me Status', () => {
it('should return locked_by_me for active lock by current user', () => {
const futureDate = new Date(Date.now() + 60000).toISOString();
const submission = {
assigned_to: currentUserId,
locked_until: futureDate,
};
expect(getLockStatus(submission, currentUserId)).toBe('locked_by_me');
});
it('should return locked_by_me for lock expiring soon', () => {
const futureDate = new Date(Date.now() + 1000).toISOString();
const submission = {
assigned_to: currentUserId,
locked_until: futureDate,
};
expect(getLockStatus(submission, currentUserId)).toBe('locked_by_me');
});
});
describe('Locked by Other Status', () => {
it('should return locked_by_other for active lock by other user', () => {
const futureDate = new Date(Date.now() + 60000).toISOString();
const submission = {
assigned_to: otherUserId,
locked_until: futureDate,
};
expect(getLockStatus(submission, currentUserId)).toBe('locked_by_other');
});
it('should return locked_by_other for lock expiring soon by other user', () => {
const futureDate = new Date(Date.now() + 1000).toISOString();
const submission = {
assigned_to: otherUserId,
locked_until: futureDate,
};
expect(getLockStatus(submission, currentUserId)).toBe('locked_by_other');
});
});
describe('Edge Cases', () => {
it('should handle lock expiring in 1ms by current user', () => {
const futureDate = new Date(Date.now() + 1).toISOString();
const submission = {
assigned_to: currentUserId,
locked_until: futureDate,
};
expect(getLockStatus(submission, currentUserId)).toBe('locked_by_me');
});
it('should handle lock expired 1ms ago', () => {
const pastDate = new Date(Date.now() - 1).toISOString();
const submission = {
assigned_to: currentUserId,
locked_until: pastDate,
};
expect(getLockStatus(submission, currentUserId)).toBe('expired');
});
});
});
describe('formatLockExpiry', () => {
describe('Active Locks', () => {
it('should format 1 minute remaining', () => {
const futureDate = new Date(Date.now() + 60000).toISOString();
const result = formatLockExpiry(futureDate);
expect(result).toBe('1:00');
});
it('should format 5 minutes 30 seconds remaining', () => {
const futureDate = new Date(Date.now() + 5.5 * 60000).toISOString();
const result = formatLockExpiry(futureDate);
expect(result).toBe('5:30');
});
it('should format 10 seconds remaining', () => {
const futureDate = new Date(Date.now() + 10000).toISOString();
const result = formatLockExpiry(futureDate);
expect(result).toBe('0:10');
});
it('should format 59 seconds remaining', () => {
const futureDate = new Date(Date.now() + 59000).toISOString();
const result = formatLockExpiry(futureDate);
expect(result).toBe('0:59');
});
it('should format 15 minutes remaining', () => {
const futureDate = new Date(Date.now() + 15 * 60000).toISOString();
const result = formatLockExpiry(futureDate);
expect(result).toBe('15:00');
});
it('should pad single digit seconds with zero', () => {
const futureDate = new Date(Date.now() + 65000).toISOString(); // 1:05
const result = formatLockExpiry(futureDate);
expect(result).toBe('1:05');
});
});
describe('Expired Locks', () => {
it('should return "Expired" for expired lock', () => {
const pastDate = new Date(Date.now() - 1000).toISOString();
const result = formatLockExpiry(pastDate);
expect(result).toBe('Expired');
});
it('should return "Expired" for lock expired 1 minute ago', () => {
const pastDate = new Date(Date.now() - 60000).toISOString();
const result = formatLockExpiry(pastDate);
expect(result).toBe('Expired');
});
it('should return "Expired" for lock expired 1 hour ago', () => {
const pastDate = new Date(Date.now() - 3600000).toISOString();
const result = formatLockExpiry(pastDate);
expect(result).toBe('Expired');
});
});
describe('Edge Cases', () => {
it('should handle lock expiring in 1 second', () => {
const futureDate = new Date(Date.now() + 1000).toISOString();
const result = formatLockExpiry(futureDate);
expect(result).toBe('0:01');
});
it('should handle lock expiring right now', () => {
const futureDate = new Date(Date.now()).toISOString();
const result = formatLockExpiry(futureDate);
expect(result).toBe('Expired');
});
it('should handle lock expiring in less than 1 second', () => {
const futureDate = new Date(Date.now() + 500).toISOString();
const result = formatLockExpiry(futureDate);
expect(result).toBe('0:00');
});
});
});
describe('getLockUrgency', () => {
describe('Critical Urgency', () => {
it('should return critical for 1 minute remaining', () => {
const timeLeftMs = 60000;
expect(getLockUrgency(timeLeftMs)).toBe('critical');
});
it('should return critical for 30 seconds remaining', () => {
const timeLeftMs = 30000;
expect(getLockUrgency(timeLeftMs)).toBe('critical');
});
it('should return critical for 1 second remaining', () => {
const timeLeftMs = 1000;
expect(getLockUrgency(timeLeftMs)).toBe('critical');
});
it('should return critical for exactly 2 minutes - 1ms remaining', () => {
const timeLeftMs = 2 * 60 * 1000 - 1;
expect(getLockUrgency(timeLeftMs)).toBe('critical');
});
it('should return critical for 0 seconds remaining', () => {
const timeLeftMs = 0;
expect(getLockUrgency(timeLeftMs)).toBe('critical');
});
it('should return critical for negative time (expired)', () => {
const timeLeftMs = -1000;
expect(getLockUrgency(timeLeftMs)).toBe('critical');
});
});
describe('Warning Urgency', () => {
it('should return warning for 4 minutes remaining', () => {
const timeLeftMs = 4 * 60000;
expect(getLockUrgency(timeLeftMs)).toBe('warning');
});
it('should return warning for 3 minutes remaining', () => {
const timeLeftMs = 3 * 60000;
expect(getLockUrgency(timeLeftMs)).toBe('warning');
});
it('should return warning for exactly 2 minutes remaining', () => {
const timeLeftMs = 2 * 60 * 1000;
expect(getLockUrgency(timeLeftMs)).toBe('warning');
});
it('should return warning for exactly 5 minutes - 1ms remaining', () => {
const timeLeftMs = 5 * 60 * 1000 - 1;
expect(getLockUrgency(timeLeftMs)).toBe('warning');
});
});
describe('Normal Urgency', () => {
it('should return normal for 6 minutes remaining', () => {
const timeLeftMs = 6 * 60000;
expect(getLockUrgency(timeLeftMs)).toBe('normal');
});
it('should return normal for 10 minutes remaining', () => {
const timeLeftMs = 10 * 60000;
expect(getLockUrgency(timeLeftMs)).toBe('normal');
});
it('should return normal for 15 minutes remaining', () => {
const timeLeftMs = 15 * 60000;
expect(getLockUrgency(timeLeftMs)).toBe('normal');
});
it('should return normal for exactly 5 minutes remaining', () => {
const timeLeftMs = 5 * 60 * 1000;
expect(getLockUrgency(timeLeftMs)).toBe('normal');
});
it('should return normal for 1 hour remaining', () => {
const timeLeftMs = 60 * 60000;
expect(getLockUrgency(timeLeftMs)).toBe('normal');
});
});
describe('Edge Cases', () => {
it('should handle very large time values', () => {
const timeLeftMs = 999999999;
expect(getLockUrgency(timeLeftMs)).toBe('normal');
});
it('should handle very negative time values', () => {
const timeLeftMs = -999999999;
expect(getLockUrgency(timeLeftMs)).toBe('critical');
});
it('should handle exactly at 2 minute boundary', () => {
const timeLeftMs = 2 * 60 * 1000;
expect(getLockUrgency(timeLeftMs)).toBe('warning');
});
it('should handle exactly at 5 minute boundary', () => {
const timeLeftMs = 5 * 60 * 1000;
expect(getLockUrgency(timeLeftMs)).toBe('normal');
});
});
});
describe('Type Safety', () => {
it('should enforce LockStatus type', () => {
const validStatuses: LockStatus[] = [
'locked_by_me',
'locked_by_other',
'unlocked',
'expired',
];
validStatuses.forEach(status => {
expect(['locked_by_me', 'locked_by_other', 'unlocked', 'expired']).toContain(status);
});
});
it('should enforce LockUrgency type', () => {
const validUrgencies: LockUrgency[] = [
'critical',
'warning',
'normal',
];
validUrgencies.forEach(urgency => {
expect(['critical', 'warning', 'normal']).toContain(urgency);
});
});
});

View File

@@ -1,11 +1,19 @@
/**
* Unit Tests for Sanitization Utilities
* Comprehensive Unit Tests for Sanitization Utilities
*
* These tests ensure XSS and injection attack prevention
*/
import { describe, it, expect } from '@playwright/test';
import { sanitizeHTML, sanitizeURL, sanitizePlainText, containsSuspiciousContent } from '@/lib/sanitize';
import { describe, it, expect } from 'vitest';
import {
sanitizeHTML,
sanitizeURL,
sanitizePlainText,
containsSuspiciousContent
} from '@/lib/sanitize';
describe('sanitizeURL', () => {
describe('Valid URLs', () => {
it('should allow valid http URLs', () => {
expect(sanitizeURL('http://example.com')).toBe('http://example.com');
});
@@ -18,27 +26,108 @@ describe('sanitizeURL', () => {
expect(sanitizeURL('mailto:user@example.com')).toBe('mailto:user@example.com');
});
it('should allow URLs with special characters in query strings', () => {
const url = 'https://example.com/search?q=test%20query&sort=desc';
expect(sanitizeURL(url)).toBe(url);
});
it('should allow URLs with fragments', () => {
const url = 'https://example.com/page#section';
expect(sanitizeURL(url)).toBe(url);
});
it('should allow URLs with authentication', () => {
const url = 'https://user:pass@example.com/path';
expect(sanitizeURL(url)).toBe(url);
});
it('should allow URLs with ports', () => {
const url = 'https://example.com:8080/path';
expect(sanitizeURL(url)).toBe(url);
});
});
describe('Dangerous Protocols - XSS Prevention', () => {
it('should block javascript: protocol', () => {
expect(sanitizeURL('javascript:alert("XSS")')).toBe('#');
});
it('should block javascript: protocol with uppercase', () => {
expect(sanitizeURL('JAVASCRIPT:alert("XSS")')).toBe('#');
});
it('should block javascript: protocol with mixed case', () => {
expect(sanitizeURL('JaVaScRiPt:alert("XSS")')).toBe('#');
});
it('should block data: protocol', () => {
expect(sanitizeURL('data:text/html,<script>alert("XSS")</script>')).toBe('#');
});
it('should block data: protocol with base64', () => {
expect(sanitizeURL('data:text/html;base64,PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4=')).toBe('#');
});
it('should block vbscript: protocol', () => {
expect(sanitizeURL('vbscript:msgbox("XSS")')).toBe('#');
});
it('should block file: protocol', () => {
expect(sanitizeURL('file:///etc/passwd')).toBe('#');
});
it('should block ftp: protocol', () => {
expect(sanitizeURL('ftp://example.com/file')).toBe('#');
});
});
describe('Edge Cases', () => {
it('should handle invalid URLs', () => {
expect(sanitizeURL('not a url')).toBe('#');
expect(sanitizeURL('')).toBe('#');
expect(sanitizeURL(' ')).toBe('#');
});
it('should handle null/undefined gracefully', () => {
expect(sanitizeURL(null as any)).toBe('#');
expect(sanitizeURL(undefined as any)).toBe('#');
});
it('should handle malformed URLs', () => {
expect(sanitizeURL('http://')).toBe('#');
expect(sanitizeURL('https://')).toBe('#');
expect(sanitizeURL('://')).toBe('#');
});
it('should handle URLs with only protocol', () => {
expect(sanitizeURL('http:')).toBe('#');
expect(sanitizeURL('https:')).toBe('#');
});
it('should handle relative URLs', () => {
expect(sanitizeURL('/path/to/page')).toBe('#');
expect(sanitizeURL('./relative')).toBe('#');
expect(sanitizeURL('../parent')).toBe('#');
});
it('should handle URLs with whitespace (URL constructor allows it)', () => {
// Note: URL constructor successfully parses URLs with surrounding whitespace
const result = sanitizeURL(' https://example.com ');
// Either returns the URL as-is or we could trim it first
expect(result).toBe(' https://example.com ');
});
it('should handle empty or whitespace-only strings', () => {
expect(sanitizeURL('')).toBe('#');
expect(sanitizeURL(' ')).toBe('#');
expect(sanitizeURL('\n\t')).toBe('#');
});
});
});
describe('sanitizePlainText', () => {
it('should escape HTML entities', () => {
describe('HTML Entity Escaping', () => {
it('should escape script tags', () => {
expect(sanitizePlainText('<script>alert("XSS")</script>'))
.toBe('&lt;script&gt;alert(&quot;XSS&quot;)&lt;&#x2F;script&gt;');
});
@@ -47,11 +136,79 @@ describe('sanitizePlainText', () => {
expect(sanitizePlainText('Tom & Jerry')).toBe('Tom &amp; Jerry');
});
it('should escape quotes', () => {
expect(sanitizePlainText('"Hello" \'World\'')).toContain('&quot;');
expect(sanitizePlainText('"Hello" \'World\'')).toContain('&#x27;');
it('should escape double quotes', () => {
expect(sanitizePlainText('"Hello"')).toContain('&quot;');
});
it('should escape single quotes', () => {
expect(sanitizePlainText("'World'")).toContain('&#x27;');
});
it('should escape less than symbols', () => {
expect(sanitizePlainText('5 < 10')).toBe('5 &lt; 10');
});
it('should escape greater than symbols', () => {
expect(sanitizePlainText('10 > 5')).toBe('10 &gt; 5');
});
it('should escape forward slashes', () => {
expect(sanitizePlainText('path/to/file')).toBe('path&#x2F;to&#x2F;file');
});
it('should escape all special characters together', () => {
const input = '<div class="test">&copy;</div>';
const output = sanitizePlainText(input);
expect(output).not.toContain('<');
expect(output).not.toContain('>');
expect(output).not.toContain('"');
});
});
describe('XSS Attack Vectors', () => {
it('should neutralize img onerror attacks', () => {
const attack = '<img src=x onerror="alert(1)">';
const result = sanitizePlainText(attack);
// The escaped version will contain the text 'onerror' but in safe escaped form
expect(result).toContain('&lt;img');
expect(result).toContain('&quot;');
// Ensure no executable HTML/script tags remain
expect(result).not.toContain('<img');
expect(result).not.toContain('>');
expect(result).not.toContain('<');
});
it('should neutralize iframe attacks', () => {
const attack = '<iframe src="javascript:alert(1)"></iframe>';
const result = sanitizePlainText(attack);
expect(result).not.toContain('<iframe');
expect(result).toContain('&lt;iframe');
});
it('should neutralize event handler attacks', () => {
const attack = '<button onclick="alert(1)">Click</button>';
const result = sanitizePlainText(attack);
expect(result).toContain('&lt;button');
expect(result).toContain('&quot;');
// Ensure no executable HTML/script tags remain
expect(result).not.toContain('<button');
expect(result).not.toContain('>');
expect(result).not.toContain('<');
});
it('should neutralize SVG-based XSS', () => {
const attack = '<svg onload="alert(1)"></svg>';
const result = sanitizePlainText(attack);
expect(result).toContain('&lt;svg');
expect(result).toContain('&quot;');
// Ensure no executable HTML/script tags remain
expect(result).not.toContain('<svg');
expect(result).not.toContain('>');
expect(result).not.toContain('<');
});
});
describe('Safe Content', () => {
it('should handle plain text without changes', () => {
expect(sanitizePlainText('Hello World')).toBe('Hello World');
});
@@ -59,42 +216,211 @@ describe('sanitizePlainText', () => {
it('should handle empty strings', () => {
expect(sanitizePlainText('')).toBe('');
});
it('should handle text with numbers', () => {
expect(sanitizePlainText('Price: $19.99')).toBe('Price: $19.99');
});
it('should handle text with newlines', () => {
expect(sanitizePlainText('Line 1\nLine 2')).toBe('Line 1\nLine 2');
});
it('should handle Unicode characters', () => {
expect(sanitizePlainText('Hello 世界 🌍')).toBe('Hello 世界 🌍');
});
});
describe('Edge Cases', () => {
it('should handle null/undefined', () => {
expect(sanitizePlainText(null as any)).toBe('');
expect(sanitizePlainText(undefined as any)).toBe('');
});
it('should handle numbers', () => {
expect(sanitizePlainText(123 as any)).toBe('');
});
it('should handle objects', () => {
expect(sanitizePlainText({} as any)).toBe('');
});
it('should handle very long strings', () => {
const longString = 'a'.repeat(10000);
const result = sanitizePlainText(longString);
expect(result.length).toBe(10000);
});
});
});
describe('containsSuspiciousContent', () => {
describe('Script Tag Detection', () => {
it('should detect script tags', () => {
expect(containsSuspiciousContent('<script>alert(1)</script>')).toBe(true);
});
it('should detect uppercase script tags', () => {
expect(containsSuspiciousContent('<SCRIPT>alert(1)</SCRIPT>')).toBe(true);
});
it('should detect mixed case script tags', () => {
expect(containsSuspiciousContent('<ScRiPt>alert(1)</ScRiPt>')).toBe(true);
});
it('should detect script tags with attributes', () => {
expect(containsSuspiciousContent('<script src="evil.js"></script>')).toBe(true);
});
it('should detect self-closing script tags', () => {
expect(containsSuspiciousContent('<script />')).toBe(true);
});
});
describe('JavaScript Protocol Detection', () => {
it('should detect javascript: protocol', () => {
expect(containsSuspiciousContent('javascript:alert(1)')).toBe(true);
});
it('should detect uppercase JavaScript protocol', () => {
expect(containsSuspiciousContent('JAVASCRIPT:alert(1)')).toBe(true);
});
it('should detect event handlers', () => {
it('should detect mixed case JavaScript protocol', () => {
expect(containsSuspiciousContent('JaVaScRiPt:alert(1)')).toBe(true);
});
});
describe('Event Handler Detection', () => {
it('should detect onerror event handler', () => {
expect(containsSuspiciousContent('<img onerror="alert(1)">')).toBe(true);
});
it('should detect onclick event handler', () => {
expect(containsSuspiciousContent('<div onclick="alert(1)">')).toBe(true);
});
it('should detect onload event handler', () => {
expect(containsSuspiciousContent('<body onload="alert(1)">')).toBe(true);
});
it('should detect onmouseover event handler', () => {
expect(containsSuspiciousContent('<div onmouseover="alert(1)">')).toBe(true);
});
it('should detect event handlers with different whitespace', () => {
expect(containsSuspiciousContent('<img onerror = "alert(1)">')).toBe(true);
expect(containsSuspiciousContent('<img onerror= "alert(1)">')).toBe(true);
});
});
describe('Dangerous Tag Detection', () => {
it('should detect iframes', () => {
expect(containsSuspiciousContent('<iframe src="evil.com"></iframe>')).toBe(true);
});
it('should detect object tags', () => {
expect(containsSuspiciousContent('<object data="evil.swf"></object>')).toBe(true);
});
it('should detect embed tags', () => {
expect(containsSuspiciousContent('<embed src="evil.swf">')).toBe(true);
});
it('should detect data URIs with HTML', () => {
expect(containsSuspiciousContent('data:text/html,<script>alert(1)</script>')).toBe(true);
});
});
describe('Safe Content', () => {
it('should not flag safe content', () => {
expect(containsSuspiciousContent('This is a safe message')).toBe(false);
});
it('should not flag email addresses', () => {
expect(containsSuspiciousContent('Email: user@example.com')).toBe(false);
});
it('should not flag safe HTML-like text', () => {
expect(containsSuspiciousContent('The tag <p> is safe')).toBe(false);
});
it('should not flag normal URLs', () => {
expect(containsSuspiciousContent('https://example.com')).toBe(false);
});
it('should not flag markdown-like syntax', () => {
expect(containsSuspiciousContent('[Link](https://example.com)')).toBe(false);
});
});
describe('Edge Cases', () => {
it('should handle null/undefined', () => {
expect(containsSuspiciousContent(null as any)).toBe(false);
expect(containsSuspiciousContent(undefined as any)).toBe(false);
});
it('should handle empty strings', () => {
expect(containsSuspiciousContent('')).toBe(false);
});
it('should handle numbers', () => {
expect(containsSuspiciousContent(123 as any)).toBe(false);
});
it('should handle objects', () => {
expect(containsSuspiciousContent({} as any)).toBe(false);
});
});
});
describe('sanitizeHTML', () => {
it('should allow safe tags', () => {
const html = '<p>Hello <strong>world</strong></p>';
describe('Safe Tags', () => {
it('should allow paragraph tags', () => {
const html = '<p>Hello world</p>';
const result = sanitizeHTML(html);
expect(result).toContain('<p>');
expect(result).toContain('Hello world');
});
it('should allow strong tags', () => {
const html = '<p>Hello <strong>world</strong></p>';
const result = sanitizeHTML(html);
expect(result).toContain('<strong>');
});
it('should allow emphasis tags', () => {
const html = '<p>Hello <em>world</em></p>';
const result = sanitizeHTML(html);
expect(result).toContain('<em>');
});
it('should allow underline tags', () => {
const html = '<p>Hello <u>world</u></p>';
const result = sanitizeHTML(html);
expect(result).toContain('<u>');
});
it('should allow lists', () => {
const html = '<ul><li>Item 1</li><li>Item 2</li></ul>';
const result = sanitizeHTML(html);
expect(result).toContain('<ul>');
expect(result).toContain('<li>');
});
it('should allow ordered lists', () => {
const html = '<ol><li>First</li><li>Second</li></ol>';
const result = sanitizeHTML(html);
expect(result).toContain('<ol>');
expect(result).toContain('<li>');
});
it('should allow line breaks', () => {
const html = 'Line 1<br>Line 2';
const result = sanitizeHTML(html);
expect(result).toContain('<br>');
});
});
describe('Dangerous Content Removal', () => {
it('should remove script tags', () => {
const html = '<p>Hello</p><script>alert("XSS")</script>';
const result = sanitizeHTML(html);
@@ -106,12 +432,85 @@ describe('sanitizeHTML', () => {
const html = '<p onclick="alert(1)">Click me</p>';
const result = sanitizeHTML(html);
expect(result).not.toContain('onclick');
expect(result).toContain('Click me');
});
it('should remove style tags', () => {
const html = '<p>Text</p><style>body { display: none; }</style>';
const result = sanitizeHTML(html);
expect(result).not.toContain('<style>');
});
it('should remove iframe tags', () => {
const html = '<p>Text</p><iframe src="evil.com"></iframe>';
const result = sanitizeHTML(html);
expect(result).not.toContain('<iframe>');
});
it('should remove object tags', () => {
const html = '<p>Text</p><object data="evil.swf"></object>';
const result = sanitizeHTML(html);
expect(result).not.toContain('<object>');
});
it('should remove embed tags', () => {
const html = '<p>Text</p><embed src="evil.swf">';
const result = sanitizeHTML(html);
expect(result).not.toContain('<embed>');
});
});
describe('Link Handling', () => {
it('should allow safe links', () => {
const html = '<a href="https://example.com" target="_blank" rel="noopener">Link</a>';
const html = '<a href="https://example.com">Link</a>';
const result = sanitizeHTML(html);
expect(result).toContain('href');
expect(result).toContain('https://example.com');
});
it('should allow target attribute', () => {
const html = '<a href="https://example.com" target="_blank">Link</a>';
const result = sanitizeHTML(html);
expect(result).toContain('target');
});
it('should allow rel attribute', () => {
const html = '<a href="https://example.com" rel="noopener">Link</a>';
const result = sanitizeHTML(html);
expect(result).toContain('rel');
});
it('should sanitize javascript: in links', () => {
const html = '<a href="javascript:alert(1)">Click</a>';
const result = sanitizeHTML(html);
// DOMPurify should remove or neutralize the href
expect(result).not.toContain('javascript:');
});
});
describe('Edge Cases', () => {
it('should handle empty strings', () => {
expect(sanitizeHTML('')).toBe('');
});
it('should handle plain text', () => {
const result = sanitizeHTML('Plain text');
expect(result).toBe('Plain text');
});
it('should handle deeply nested tags', () => {
const html = '<p><strong><em><u>Text</u></em></strong></p>';
const result = sanitizeHTML(html);
expect(result).toContain('<p>');
expect(result).toContain('<strong>');
expect(result).toContain('<em>');
expect(result).toContain('<u>');
});
it('should handle malformed HTML', () => {
const html = '<p>Unclosed tag';
const result = sanitizeHTML(html);
expect(result).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,618 @@
/**
* Comprehensive Unit Tests for Validation Schemas
*
* These tests ensure proper input validation and content filtering
*/
import { describe, it, expect } from 'vitest';
import {
usernameSchema,
displayNameSchema,
passwordSchema,
bioSchema,
personalLocationSchema,
preferredPronounsSchema,
profileEditSchema,
} from '@/lib/validation';
import { z } from 'zod';
describe('usernameSchema', () => {
describe('Valid Usernames', () => {
it('should accept valid username with letters and numbers', () => {
const result = usernameSchema.safeParse('user123');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('user123');
}
});
it('should accept username with hyphens', () => {
const result = usernameSchema.safeParse('user-name');
expect(result.success).toBe(true);
});
it('should accept username with underscores', () => {
const result = usernameSchema.safeParse('user_name');
expect(result.success).toBe(true);
});
it('should accept minimum length username (3 chars)', () => {
const result = usernameSchema.safeParse('abc');
expect(result.success).toBe(true);
});
it('should accept maximum length username (30 chars)', () => {
const result = usernameSchema.safeParse('a'.repeat(30));
expect(result.success).toBe(true);
});
it('should convert username to lowercase', () => {
const result = usernameSchema.safeParse('UserName123');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('username123');
}
});
it('should accept username starting and ending with alphanumeric', () => {
const result = usernameSchema.safeParse('a-b_c-d1');
expect(result.success).toBe(true);
});
});
describe('Invalid Usernames', () => {
it('should reject username shorter than 3 characters', () => {
const result = usernameSchema.safeParse('ab');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('at least 3 characters');
}
});
it('should reject username longer than 30 characters', () => {
const result = usernameSchema.safeParse('a'.repeat(31));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('less than 30 characters');
}
});
it('should reject username starting with hyphen', () => {
const result = usernameSchema.safeParse('-username');
expect(result.success).toBe(false);
});
it('should reject username starting with underscore', () => {
const result = usernameSchema.safeParse('_username');
expect(result.success).toBe(false);
});
it('should reject username ending with hyphen', () => {
const result = usernameSchema.safeParse('username-');
expect(result.success).toBe(false);
});
it('should reject username ending with underscore', () => {
const result = usernameSchema.safeParse('username_');
expect(result.success).toBe(false);
});
it('should reject username with consecutive hyphens', () => {
const result = usernameSchema.safeParse('user--name');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('consecutive');
}
});
it('should reject username with consecutive underscores', () => {
const result = usernameSchema.safeParse('user__name');
expect(result.success).toBe(false);
});
it('should reject username with special characters', () => {
const result = usernameSchema.safeParse('user@name');
expect(result.success).toBe(false);
});
it('should reject username with spaces', () => {
const result = usernameSchema.safeParse('user name');
expect(result.success).toBe(false);
});
it('should reject username with dots', () => {
const result = usernameSchema.safeParse('user.name');
expect(result.success).toBe(false);
});
});
describe('Forbidden Usernames - Security', () => {
it('should reject "admin"', () => {
const result = usernameSchema.safeParse('admin');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('not allowed');
}
});
it('should reject "administrator"', () => {
const result = usernameSchema.safeParse('administrator');
expect(result.success).toBe(false);
});
it('should reject "moderator"', () => {
const result = usernameSchema.safeParse('moderator');
expect(result.success).toBe(false);
});
it('should reject "root"', () => {
const result = usernameSchema.safeParse('root');
expect(result.success).toBe(false);
});
it('should reject "system"', () => {
const result = usernameSchema.safeParse('system');
expect(result.success).toBe(false);
});
it('should reject offensive username', () => {
const result = usernameSchema.safeParse('nazi');
expect(result.success).toBe(false);
});
it('should reject case-insensitive forbidden username', () => {
const result = usernameSchema.safeParse('ADMIN');
expect(result.success).toBe(false);
});
it('should reject mixed-case forbidden username', () => {
const result = usernameSchema.safeParse('AdMiN');
expect(result.success).toBe(false);
});
});
});
describe('displayNameSchema', () => {
describe('Valid Display Names', () => {
it('should accept valid display name', () => {
const result = displayNameSchema.safeParse('John Doe');
expect(result.success).toBe(true);
});
it('should accept display name with special characters', () => {
const result = displayNameSchema.safeParse('O\'Brien');
expect(result.success).toBe(true);
});
it('should accept display name with numbers', () => {
const result = displayNameSchema.safeParse('User 123');
expect(result.success).toBe(true);
});
it('should accept Unicode characters', () => {
const result = displayNameSchema.safeParse('José García 日本');
expect(result.success).toBe(true);
});
it('should accept emojis', () => {
const result = displayNameSchema.safeParse('Cool User 😎');
expect(result.success).toBe(true);
});
it('should accept undefined (optional field)', () => {
const result = displayNameSchema.safeParse(undefined);
expect(result.success).toBe(true);
});
it('should accept empty string', () => {
const result = displayNameSchema.safeParse('');
expect(result.success).toBe(true);
});
});
describe('Invalid Display Names', () => {
it('should reject display name longer than 100 characters', () => {
const result = displayNameSchema.safeParse('a'.repeat(101));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('less than 100 characters');
}
});
it('should reject display name with "nazi"', () => {
const result = displayNameSchema.safeParse('NaziSymbol');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('inappropriate content');
}
});
it('should reject display name with "hitler"', () => {
const result = displayNameSchema.safeParse('hitler fan');
expect(result.success).toBe(false);
});
it('should reject display name with offensive terms (case insensitive)', () => {
const result = displayNameSchema.safeParse('TERRORIST group');
expect(result.success).toBe(false);
});
});
});
describe('passwordSchema', () => {
describe('Valid Passwords', () => {
it('should accept strong password with all requirements', () => {
const data = {
currentPassword: 'OldPass123!',
newPassword: 'NewPass123!',
confirmPassword: 'NewPass123!',
};
const result = passwordSchema.safeParse(data);
expect(result.success).toBe(true);
});
it('should accept password with multiple special characters', () => {
const data = {
currentPassword: 'Old123!@#',
newPassword: 'New123!@#$%',
confirmPassword: 'New123!@#$%',
};
const result = passwordSchema.safeParse(data);
expect(result.success).toBe(true);
});
it('should accept maximum length password (128 chars)', () => {
const pwd = 'A1!' + 'a'.repeat(125);
const data = {
currentPassword: 'OldPass123!',
newPassword: pwd,
confirmPassword: pwd,
};
const result = passwordSchema.safeParse(data);
expect(result.success).toBe(true);
});
});
describe('Invalid Passwords - Complexity Requirements', () => {
it('should reject password shorter than 8 characters', () => {
const data = {
currentPassword: 'Old123!',
newPassword: 'New12!',
confirmPassword: 'New12!',
};
const result = passwordSchema.safeParse(data);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('at least 8 characters');
}
});
it('should reject password longer than 128 characters', () => {
const pwd = 'A'.repeat(129);
const data = {
currentPassword: 'OldPass123!',
newPassword: pwd,
confirmPassword: pwd,
};
const result = passwordSchema.safeParse(data);
expect(result.success).toBe(false);
});
it('should reject password without uppercase letter', () => {
const data = {
currentPassword: 'OldPass123!',
newPassword: 'newpass123!',
confirmPassword: 'newpass123!',
};
const result = passwordSchema.safeParse(data);
expect(result.success).toBe(false);
if (!result.success) {
const messages = result.error.issues.map(i => i.message).join(' ');
expect(messages).toContain('uppercase letter');
}
});
it('should reject password without lowercase letter', () => {
const data = {
currentPassword: 'OldPass123!',
newPassword: 'NEWPASS123!',
confirmPassword: 'NEWPASS123!',
};
const result = passwordSchema.safeParse(data);
expect(result.success).toBe(false);
if (!result.success) {
const messages = result.error.issues.map(i => i.message).join(' ');
expect(messages).toContain('lowercase letter');
}
});
it('should reject password without number', () => {
const data = {
currentPassword: 'OldPass123!',
newPassword: 'NewPassword!',
confirmPassword: 'NewPassword!',
};
const result = passwordSchema.safeParse(data);
expect(result.success).toBe(false);
if (!result.success) {
const messages = result.error.issues.map(i => i.message).join(' ');
expect(messages).toContain('number');
}
});
it('should reject password without special character', () => {
const data = {
currentPassword: 'OldPass123!',
newPassword: 'NewPass123',
confirmPassword: 'NewPass123',
};
const result = passwordSchema.safeParse(data);
expect(result.success).toBe(false);
if (!result.success) {
const messages = result.error.issues.map(i => i.message).join(' ');
expect(messages).toContain('special character');
}
});
it('should reject mismatched passwords', () => {
const data = {
currentPassword: 'OldPass123!',
newPassword: 'NewPass123!',
confirmPassword: 'DifferentPass123!',
};
const result = passwordSchema.safeParse(data);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain("don't match");
}
});
it('should reject empty current password', () => {
const data = {
currentPassword: '',
newPassword: 'NewPass123!',
confirmPassword: 'NewPass123!',
};
const result = passwordSchema.safeParse(data);
expect(result.success).toBe(false);
});
});
});
describe('bioSchema', () => {
describe('Valid Bios', () => {
it('should accept valid bio', () => {
const result = bioSchema.safeParse('Software developer from NYC');
expect(result.success).toBe(true);
});
it('should accept bio with newlines', () => {
const result = bioSchema.safeParse('Line 1\nLine 2');
expect(result.success).toBe(true);
});
it('should accept bio with emojis', () => {
const result = bioSchema.safeParse('Developer 💻 Coffee lover ☕');
expect(result.success).toBe(true);
});
it('should accept maximum length bio (500 chars)', () => {
const result = bioSchema.safeParse('a'.repeat(500));
expect(result.success).toBe(true);
});
it('should accept undefined (optional field)', () => {
const result = bioSchema.safeParse(undefined);
expect(result.success).toBe(true);
});
it('should trim whitespace', () => {
const result = bioSchema.safeParse(' Bio text ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('Bio text');
}
});
});
describe('Invalid Bios', () => {
it('should reject bio longer than 500 characters', () => {
const result = bioSchema.safeParse('a'.repeat(501));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('less than 500 characters');
}
});
it('should reject bio with HTML tags (< and >)', () => {
const result = bioSchema.safeParse('Bio with <script>alert(1)</script>');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('HTML tags');
}
});
it('should reject bio with angle brackets', () => {
const result = bioSchema.safeParse('5 < 10 and 10 > 5');
expect(result.success).toBe(false);
});
});
});
describe('personalLocationSchema', () => {
describe('Valid Locations', () => {
it('should accept valid location', () => {
const result = personalLocationSchema.safeParse('New York, USA');
expect(result.success).toBe(true);
});
it('should accept location with special characters', () => {
const result = personalLocationSchema.safeParse('São Paulo, Brazil');
expect(result.success).toBe(true);
});
it('should accept maximum length location (100 chars)', () => {
const result = personalLocationSchema.safeParse('a'.repeat(100));
expect(result.success).toBe(true);
});
it('should accept undefined (optional field)', () => {
const result = personalLocationSchema.safeParse(undefined);
expect(result.success).toBe(true);
});
it('should trim whitespace', () => {
const result = personalLocationSchema.safeParse(' Tokyo ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('Tokyo');
}
});
});
describe('Invalid Locations', () => {
it('should reject location longer than 100 characters', () => {
const result = personalLocationSchema.safeParse('a'.repeat(101));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('less than 100 characters');
}
});
it('should reject location with angle brackets', () => {
const result = personalLocationSchema.safeParse('<New York>');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('special characters');
}
});
it('should reject location with curly braces', () => {
const result = personalLocationSchema.safeParse('Location {test}');
expect(result.success).toBe(false);
});
});
});
describe('preferredPronounsSchema', () => {
describe('Valid Pronouns', () => {
it('should accept valid pronouns', () => {
const result = preferredPronounsSchema.safeParse('they/them');
expect(result.success).toBe(true);
});
it('should accept he/him', () => {
const result = preferredPronounsSchema.safeParse('he/him');
expect(result.success).toBe(true);
});
it('should accept she/her', () => {
const result = preferredPronounsSchema.safeParse('she/her');
expect(result.success).toBe(true);
});
it('should accept custom pronouns', () => {
const result = preferredPronounsSchema.safeParse('xe/xem');
expect(result.success).toBe(true);
});
it('should accept maximum length pronouns (20 chars)', () => {
const result = preferredPronounsSchema.safeParse('a'.repeat(20));
expect(result.success).toBe(true);
});
it('should accept undefined (optional field)', () => {
const result = preferredPronounsSchema.safeParse(undefined);
expect(result.success).toBe(true);
});
it('should trim whitespace', () => {
const result = preferredPronounsSchema.safeParse(' they/them ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('they/them');
}
});
});
describe('Invalid Pronouns', () => {
it('should reject pronouns longer than 20 characters', () => {
const result = preferredPronounsSchema.safeParse('a'.repeat(21));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('less than 20 characters');
}
});
});
});
describe('profileEditSchema', () => {
describe('Valid Profiles', () => {
it('should accept valid complete profile', () => {
const data = {
username: 'testuser',
display_name: 'Test User',
bio: 'Software developer',
};
const result = profileEditSchema.safeParse(data);
expect(result.success).toBe(true);
});
it('should accept profile with optional fields omitted', () => {
const data = {
username: 'testuser',
};
const result = profileEditSchema.safeParse(data);
expect(result.success).toBe(true);
});
it('should normalize username to lowercase', () => {
const data = {
username: 'TestUser',
display_name: 'Test User',
};
const result = profileEditSchema.safeParse(data);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.username).toBe('testuser');
}
});
});
describe('Invalid Profiles', () => {
it('should reject profile with invalid username', () => {
const data = {
username: 'admin', // Forbidden
display_name: 'Test User',
};
const result = profileEditSchema.safeParse(data);
expect(result.success).toBe(false);
});
it('should reject profile with offensive display name', () => {
const data = {
username: 'testuser',
display_name: 'nazi sympathizer',
};
const result = profileEditSchema.safeParse(data);
expect(result.success).toBe(false);
});
it('should reject profile with HTML in bio', () => {
const data = {
username: 'testuser',
bio: 'Bio with <script>alert(1)</script>',
};
const result = profileEditSchema.safeParse(data);
expect(result.success).toBe(false);
});
it('should reject profile with missing required username', () => {
const data = {
display_name: 'Test User',
};
const result = profileEditSchema.safeParse(data);
expect(result.success).toBe(false);
});
});
});

53
vitest.config.ts Normal file
View File

@@ -0,0 +1,53 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react-swc';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./tests/setup/vitest.setup.ts'],
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/e2e/**',
'**/.{idea,git,cache,output,temp}/**',
],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.d.ts',
'src/**/*.spec.ts',
'src/**/*.test.ts',
'src/vite-env.d.ts',
'src/components/ui/**', // Exclude shadcn UI components
],
thresholds: {
lines: 70,
functions: 70,
branches: 70,
statements: 70,
},
},
testTimeout: 10000,
hookTimeout: 10000,
pool: 'threads',
poolOptions: {
threads: {
singleThread: false,
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'react': path.resolve(__dirname, './node_modules/react'),
'react-dom': path.resolve(__dirname, './node_modules/react-dom'),
},
dedupe: ['react', 'react-dom'],
},
});