4ac34ca943
Deploy to Dev / Deploy & Smoke Test (push) Successful in 23s
Lets the photographer edit config/site.json from /dashboard instead of SSHing in and hand-editing the file. Two tiers: - Quick-edit form for the fields actually touched day to day (site title/tagline/hero, photographer bio, contact/social, Venmo username, session pricing, theme colors) - Raw JSON textarea for everything else (portfolio categories, locations, session types, Immich settings) - the form's fields are a subset of this, not a separate source of truth Backend: GET/PUT /api/admin/config, gated by the existing requireAdmin middleware (OIDC admin session or legacy ADMIN_SECRET). PUT validates the body is an object with the required top-level sections, writes atomically (temp file + rename), and keeps one prior version as site.json.bak before overwriting. Required a docker-compose.yml change: the api service had no volume mount for config/ at all before this (only portfolio/nginx did, and read-only) - added a read-write mount so the API can actually write the file the live site reads.
167 lines
7.3 KiB
JavaScript
167 lines
7.3 KiB
JavaScript
// @ts-check
|
|
const { test, expect } = require('@playwright/test');
|
|
|
|
const ADMIN_SECRET = process.env.ADMIN_SECRET || '9yPuNfYjy5kisRKrowhmH42PTeEgH';
|
|
|
|
test.describe('Admin dashboard — auth', () => {
|
|
test('loads the login screen at /dashboard', async ({ page }) => {
|
|
await page.goto('/dashboard');
|
|
await expect(page.locator('#login-screen')).toBeVisible();
|
|
await expect(page.locator('#login-screen')).toContainText(/Admin/i);
|
|
});
|
|
|
|
test('wrong passphrase shows error message', async ({ page }) => {
|
|
await page.goto('/dashboard');
|
|
await page.locator('#login-input').fill('wrongpassphrase');
|
|
await page.locator('[id=login-btn]').click();
|
|
await expect(page.locator('#login-error')).toBeVisible({ timeout: 6_000 });
|
|
});
|
|
|
|
test('correct passphrase shows the app', async ({ page }) => {
|
|
await page.goto('/dashboard');
|
|
await page.locator('#login-input').fill(ADMIN_SECRET);
|
|
await page.locator('[id=login-btn]').click();
|
|
await expect(page.locator('#app')).toBeVisible({ timeout: 8_000 });
|
|
await expect(page.locator('#login-screen')).not.toBeVisible();
|
|
});
|
|
|
|
test('Enter key submits login form', async ({ page }) => {
|
|
await page.goto('/dashboard');
|
|
await page.locator('#login-input').fill(ADMIN_SECRET);
|
|
await page.locator('#login-input').press('Enter');
|
|
await expect(page.locator('#app')).toBeVisible({ timeout: 8_000 });
|
|
});
|
|
|
|
test('Sign Out returns to login screen', async ({ page }) => {
|
|
await page.goto('/dashboard');
|
|
await page.locator('#login-input').fill(ADMIN_SECRET);
|
|
await page.locator('[id=login-btn]').click();
|
|
await expect(page.locator('#app')).toBeVisible({ timeout: 8_000 });
|
|
await page.locator('button:has-text("Sign Out")').click();
|
|
await expect(page.locator('#login-screen')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe('Admin dashboard — panels', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.goto('/dashboard');
|
|
await page.locator('#login-input').fill(ADMIN_SECRET);
|
|
await page.locator('[id=login-btn]').click();
|
|
await expect(page.locator('#app')).toBeVisible({ timeout: 8_000 });
|
|
});
|
|
|
|
test('Overview panel shows stat cards', async ({ page }) => {
|
|
await expect(page.locator('.stat-card')).toHaveCount(4);
|
|
// At least one stat value should be rendered (not just —)
|
|
const values = page.locator('.stat-card .value');
|
|
await expect(values.first()).toBeVisible();
|
|
});
|
|
|
|
test('Overview shows next upcoming sessions list', async ({ page }) => {
|
|
const list = page.locator('#next-up-list');
|
|
await expect(list).toBeVisible();
|
|
// Should load (not show loading spinner indefinitely)
|
|
await expect(list.locator('.loading')).not.toBeVisible({ timeout: 8_000 });
|
|
});
|
|
|
|
test('Calendar panel renders a month grid', async ({ page }) => {
|
|
await page.locator('#nav-calendar').click();
|
|
await expect(page.locator('#panel-calendar')).toBeVisible();
|
|
await expect(page.locator('#cal-month-label')).toBeVisible();
|
|
// Grid should have at least 28 day cells + 7 header cells
|
|
const days = page.locator('.cal-day');
|
|
const count = await days.count();
|
|
expect(count).toBeGreaterThanOrEqual(28);
|
|
});
|
|
|
|
test('Calendar prev/next navigation changes month', async ({ page }) => {
|
|
await page.locator('#nav-calendar').click();
|
|
const labelBefore = await page.locator('#cal-month-label').textContent();
|
|
await page.locator('button:has-text("Next")').click();
|
|
const labelAfter = await page.locator('#cal-month-label').textContent();
|
|
expect(labelAfter).not.toBe(labelBefore);
|
|
});
|
|
|
|
test('Bookings panel renders table with results', async ({ page }) => {
|
|
await page.locator('#nav-bookings').click();
|
|
await expect(page.locator('#panel-bookings')).toBeVisible();
|
|
// Table or empty state should appear
|
|
const wrap = page.locator('#bookings-table-wrap');
|
|
await expect(wrap).not.toBeEmpty({ timeout: 8_000 });
|
|
});
|
|
|
|
test('Bookings panel search filter narrows results', async ({ page }) => {
|
|
await page.locator('#nav-bookings').click();
|
|
await page.locator('#b-search').fill('Smoke Test');
|
|
await page.waitForTimeout(500); // debounce
|
|
const wrap = page.locator('#bookings-table-wrap');
|
|
await expect(wrap).not.toBeEmpty({ timeout: 6_000 });
|
|
// All visible names should include Smoke Test (or empty state)
|
|
const cells = page.locator('table tbody td strong');
|
|
const count = await cells.count();
|
|
for (let i = 0; i < count; i++) {
|
|
const text = await cells.nth(i).textContent();
|
|
expect(text).toContain('Smoke Test');
|
|
}
|
|
});
|
|
|
|
test('Bookings panel clear filter button works', async ({ page }) => {
|
|
await page.locator('#nav-bookings').click();
|
|
await page.locator('#b-search').fill('xyz-nobody');
|
|
await page.waitForTimeout(400);
|
|
await page.locator('button:has-text("Clear")').click();
|
|
await expect(page.locator('#b-search')).toHaveValue('');
|
|
await expect(page.locator('#bookings-table-wrap')).not.toBeEmpty({ timeout: 6_000 });
|
|
});
|
|
|
|
test('Expanding a booking row shows client and payment details', async ({ page }) => {
|
|
await page.locator('#nav-bookings').click();
|
|
const firstRow = page.locator('table tbody tr.expandable').first();
|
|
await expect(firstRow).toBeVisible({ timeout: 8_000 });
|
|
await firstRow.click();
|
|
// Expand row with detail should appear
|
|
const expandRow = page.locator('.expand-row').first();
|
|
await expect(expandRow).toBeVisible({ timeout: 5_000 });
|
|
await expect(expandRow).toContainText(/Client|Payment|Session/i);
|
|
});
|
|
|
|
test('Payments panel shows revenue summary', async ({ page }) => {
|
|
await page.locator('#nav-payments').click();
|
|
await expect(page.locator('#panel-payments')).toBeVisible();
|
|
await expect(page.locator('#revenue-summary')).not.toBeEmpty({ timeout: 8_000 });
|
|
await expect(page.locator('.revenue-card')).toHaveCount(3);
|
|
});
|
|
|
|
test('Payments panel CSV export triggers download', async ({ page }) => {
|
|
await page.locator('#nav-payments').click();
|
|
|
|
const [download] = await Promise.all([
|
|
page.waitForEvent('download', { timeout: 10_000 }),
|
|
page.locator('button:has-text("Export CSV")').click(),
|
|
]);
|
|
|
|
expect(download.suggestedFilename()).toMatch(/lisilou-bookings.*\.csv/);
|
|
});
|
|
|
|
test('Config panel loads current site.json into the form', async ({ page }) => {
|
|
await page.locator('#nav-config').click();
|
|
await expect(page.locator('#panel-config')).toBeVisible();
|
|
await expect(page.locator('#cfg-content')).toBeVisible({ timeout: 8_000 });
|
|
// Title field should be populated from real config, not left blank
|
|
await expect(page.locator('#cfg-site-title')).not.toHaveValue('');
|
|
// Raw JSON textarea should contain valid, non-trivial JSON
|
|
const raw = await page.locator('#cfg-raw-json').inputValue();
|
|
expect(() => JSON.parse(raw)).not.toThrow();
|
|
expect(JSON.parse(raw)).toHaveProperty('site');
|
|
});
|
|
|
|
test('Config panel round-trips a no-op save without error', async ({ page }) => {
|
|
// Saves the exact same content back - proves the PUT path works without
|
|
// mutating the real config the live site depends on.
|
|
await page.locator('#nav-config').click();
|
|
await expect(page.locator('#cfg-content')).toBeVisible({ timeout: 8_000 });
|
|
await page.locator('button:has-text("Save Full Config")').click();
|
|
await expect(page.locator('#cfg-raw-status')).toHaveClass(/ok/, { timeout: 8_000 });
|
|
});
|
|
});
|