screenshot: admin github button (#778)
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
|
||||
test('admin can update account email via settings page', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip('Account settings UI is validated on desktop viewport');
|
||||
}
|
||||
|
||||
const newEmail = `admin+playwright-${Date.now()}@example.com`;
|
||||
|
||||
await page.goto('/admin/login');
|
||||
await page.getByLabel(/Email|E-Mail/i).fill(ADMIN_EMAIL);
|
||||
await page.getByLabel(/Password|Passwort/i).fill(ADMIN_PASSWORD);
|
||||
await page.getByRole('button', { name: /Sign In|Log in|Anmelden/i }).click();
|
||||
await expect(page.getByRole('heading', { name: /Dashboard|Übersicht/i })).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await page.goto('/admin/settings');
|
||||
const emailInput = page.getByLabel(/Admin (Email|E-Mail)/i);
|
||||
const usernameInput = page.getByLabel(/Admin (Username|Benutzername)/i);
|
||||
|
||||
await expect(emailInput).toBeVisible();
|
||||
const originalEmail = await emailInput.inputValue();
|
||||
const originalUsername = await usernameInput.inputValue();
|
||||
|
||||
const saveButton = page.getByRole('button', { name: /(Save account details|Kontodaten speichern)/i });
|
||||
|
||||
const revertChanges = async () => {
|
||||
await emailInput.fill(originalEmail);
|
||||
await usernameInput.fill(originalUsername);
|
||||
await saveButton.click();
|
||||
await expect(emailInput).toHaveValue(originalEmail, { timeout: 10000 });
|
||||
await expect(page.locator('.Toastify__toast').filter({ hasText: /(Account details updated|Kontodaten aktualisiert)/i })).toBeVisible({ timeout: 10000 });
|
||||
};
|
||||
|
||||
try {
|
||||
await emailInput.fill(newEmail);
|
||||
await saveButton.click();
|
||||
|
||||
await expect(emailInput).toHaveValue(newEmail, { timeout: 10000 });
|
||||
await expect(page.locator('.Toastify__toast').filter({ hasText: /(Account details updated|Kontodaten aktualisiert)/i })).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(newEmail, { exact: false })).toBeVisible();
|
||||
} finally {
|
||||
await revertChanges();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
|
||||
function randomSuffix() {
|
||||
return Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
test('admin can create event via UI', async ({ page }) => {
|
||||
const eventName = `UI Playwright ${randomSuffix()}`;
|
||||
const hostEmail = `host+${randomSuffix()}@example.com`;
|
||||
|
||||
// Login
|
||||
await page.goto('/admin/login');
|
||||
await page.getByLabel(/Email/i).fill(ADMIN_EMAIL);
|
||||
await page.getByLabel(/Password/i).fill(ADMIN_PASSWORD);
|
||||
await page.getByRole('button', { name: /Sign In|Log in/i }).click();
|
||||
await expect(page.getByRole('heading', { name: /Dashboard/i })).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Navigate to create event page
|
||||
const createButton = page.getByRole('button', { name: /Create Event/i });
|
||||
if (await createButton.count()) {
|
||||
await createButton.first().click();
|
||||
} else {
|
||||
await page.goto('/admin/events/new');
|
||||
}
|
||||
|
||||
await expect(page.getByRole('heading', { name: /^Create$/i })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.getByLabel(/Event Name/i).fill(eventName);
|
||||
await page.getByLabel(/Customer Name/i).fill('Host User');
|
||||
await page.getByLabel(/Event Date/i).fill('2025-12-31');
|
||||
await page.getByLabel(/Customer Email/i).fill(hostEmail);
|
||||
await page.getByLabel(/Admin Email/i).fill(ADMIN_EMAIL);
|
||||
await page.getByLabel(/Gallery Password/i).fill('UiPlay123!');
|
||||
await page.getByLabel(/Confirm Password/i).fill('UiPlay123!');
|
||||
|
||||
await page.getByRole('button', { name: /Create Event/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/admin\/events\//, { timeout: 20000 });
|
||||
await expect(page.getByRole('heading', { name: eventName })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!';
|
||||
|
||||
async function getAdminToken(page: Page): Promise<string> {
|
||||
const res = await page.request.post('/api/auth/admin/login', {
|
||||
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD },
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const { token } = await res.json();
|
||||
return token;
|
||||
}
|
||||
|
||||
test.describe('Admin video upload (#203)', () => {
|
||||
test('Videos uploaded via admin have correct media_type and mime_type', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip();
|
||||
}
|
||||
|
||||
const token = await getAdminToken(page);
|
||||
|
||||
// Create event
|
||||
const eventName = `PW Video ${Date.now()}`;
|
||||
const eventDate = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
|
||||
|
||||
const createRes = await page.request.post('/api/admin/events', {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
customer_name: 'Playwright Host',
|
||||
customer_email: '[email protected]',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 90,
|
||||
allow_downloads: true,
|
||||
},
|
||||
});
|
||||
expect(createRes.ok()).toBeTruthy();
|
||||
const event = await createRes.json();
|
||||
expect(event.id).toBeTruthy();
|
||||
|
||||
// Upload a video via admin endpoint
|
||||
const videoPath = path.join(process.cwd(), 'test-assets', 'test-video.mp4');
|
||||
expect(fs.existsSync(videoPath)).toBeTruthy();
|
||||
const buffer = fs.readFileSync(videoPath);
|
||||
|
||||
const uploadRes = await page.request.post(`/api/admin/events/${event.id}/upload`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: {
|
||||
photos: { name: 'test-video.mp4', mimeType: 'video/mp4', buffer },
|
||||
category_id: 'individual',
|
||||
},
|
||||
});
|
||||
expect(uploadRes.ok()).toBeTruthy();
|
||||
const uploadBody = await uploadRes.json();
|
||||
expect(uploadBody.successCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Wait for background processing
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// Fetch photos for this event via admin API
|
||||
const photosRes = await page.request.get(`/api/admin/photos/${event.id}/photos`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(photosRes.ok()).toBeTruthy();
|
||||
const photosBody = await photosRes.json();
|
||||
const photos = photosBody.photos || photosBody;
|
||||
expect(photos.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Find our video — the critical fix: media_type and mime_type must be set
|
||||
const video = photos.find((p: any) => p.media_type === 'video');
|
||||
expect(video).toBeTruthy();
|
||||
expect(video.media_type).toBe('video');
|
||||
expect(video.mime_type).toBe('video/mp4');
|
||||
// width/height/duration depend on ffprobe being available in the environment;
|
||||
// if present they should be positive, but we don't fail on missing ffprobe
|
||||
if (video.width !== null) {
|
||||
expect(video.width).toBeGreaterThan(0);
|
||||
expect(video.height).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Also upload an image and verify it gets media_type = 'image' with dimensions
|
||||
const imgBuffer = fs.readFileSync(path.join(process.cwd(), 'test-assets', 'img1.png'));
|
||||
const imgUploadRes = await page.request.post(`/api/admin/events/${event.id}/upload`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: {
|
||||
photos: { name: 'img1.png', mimeType: 'image/png', buffer: imgBuffer },
|
||||
category_id: 'individual',
|
||||
},
|
||||
});
|
||||
expect(imgUploadRes.ok()).toBeTruthy();
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Re-fetch and verify image has correct media_type and dimensions
|
||||
const photosRes2 = await page.request.get(`/api/admin/photos/${event.id}/photos`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(photosRes2.ok()).toBeTruthy();
|
||||
const photosBody2 = await photosRes2.json();
|
||||
const photos2 = photosBody2.photos || photosBody2;
|
||||
|
||||
const image = photos2.find((p: any) => p.media_type === 'image');
|
||||
expect(image).toBeTruthy();
|
||||
expect(image.media_type).toBe('image');
|
||||
expect(image.width).toBeGreaterThan(0);
|
||||
expect(image.height).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!';
|
||||
|
||||
async function createEventWithPhotos(page: Page, adminToken?: string, attempt = 1) {
|
||||
const api = page.request;
|
||||
let token = adminToken;
|
||||
|
||||
if (!token) {
|
||||
const loginResponse = await api.post('/api/auth/admin/login', {
|
||||
data: {
|
||||
username: ADMIN_EMAIL,
|
||||
password: ADMIN_PASSWORD,
|
||||
},
|
||||
});
|
||||
expect(loginResponse.ok()).toBeTruthy();
|
||||
const loginData = await loginResponse.json();
|
||||
token = loginData.token;
|
||||
expect(token).toBeTruthy();
|
||||
}
|
||||
|
||||
const eventName = `Playwright Smoke ${Date.now()}`;
|
||||
const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Failed to acquire admin token');
|
||||
}
|
||||
|
||||
const eventResponse = await api.post('/api/admin/events', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
customer_name: 'Playwright Host',
|
||||
customer_email: '[email protected]',
|
||||
host_name: 'Playwright Host',
|
||||
host_email: '[email protected]',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 30,
|
||||
allow_user_uploads: false,
|
||||
allow_downloads: true,
|
||||
disable_right_click: false,
|
||||
watermark_downloads: false,
|
||||
},
|
||||
});
|
||||
if (!eventResponse.ok()) {
|
||||
const message = await eventResponse.text();
|
||||
if (
|
||||
attempt < 3 &&
|
||||
/UNIQUE constraint failed: events\.slug/i.test(message || '')
|
||||
) {
|
||||
await page.waitForTimeout(150);
|
||||
return createEventWithPhotos(page, token, attempt + 1);
|
||||
}
|
||||
throw new Error(`Event creation failed: ${eventResponse.status()} ${message}`);
|
||||
}
|
||||
const event = await eventResponse.json();
|
||||
|
||||
const imagePath = path.join(process.cwd(), 'test-assets', 'img1.png');
|
||||
const buffer = fs.readFileSync(imagePath);
|
||||
const uploadResponse = await api.post(`/api/admin/events/${event.id}/upload`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
multipart: {
|
||||
photos: {
|
||||
name: path.basename(imagePath),
|
||||
mimeType: 'image/png',
|
||||
buffer,
|
||||
},
|
||||
category_id: 'individual',
|
||||
},
|
||||
});
|
||||
expect(uploadResponse.ok()).toBeTruthy();
|
||||
|
||||
return {
|
||||
event,
|
||||
shareLink: event.share_link,
|
||||
slug: event.slug,
|
||||
adminToken: token,
|
||||
};
|
||||
}
|
||||
|
||||
async function updateShortGallerySetting(page: Page, adminToken: string, enabled: boolean) {
|
||||
const response = await page.request.put('/api/admin/settings/general', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
general_short_gallery_urls: enabled,
|
||||
},
|
||||
});
|
||||
expect(response.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
async function openGalleryShareLink(page: Page, shareLink: string) {
|
||||
await page.context().clearCookies();
|
||||
await page.goto(shareLink);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
try {
|
||||
await page.getByText(/Enter Gallery Password/i).first().waitFor({ timeout: 5000 });
|
||||
} catch {
|
||||
// No password prompt shown (public gallery)
|
||||
}
|
||||
|
||||
let passwordEntered = false;
|
||||
const passwordTextbox = page.getByRole('textbox', { name: /password/i }).first();
|
||||
if (await passwordTextbox.count()) {
|
||||
await passwordTextbox.fill(GALLERY_PASSWORD);
|
||||
passwordEntered = true;
|
||||
}
|
||||
|
||||
const galleryPasswordField = page.getByPlaceholder(/gallery password/i);
|
||||
if (!passwordEntered && await galleryPasswordField.count()) {
|
||||
await galleryPasswordField.fill(GALLERY_PASSWORD);
|
||||
passwordEntered = true;
|
||||
} else if (!passwordEntered) {
|
||||
const genericPasswordField = page.getByPlaceholder(/password/i).first();
|
||||
if (await genericPasswordField.count()) {
|
||||
await genericPasswordField.fill(GALLERY_PASSWORD);
|
||||
passwordEntered = true;
|
||||
} else {
|
||||
const labelledPasswordField = page.getByLabel(/password/i).first();
|
||||
if (await labelledPasswordField.count()) {
|
||||
await labelledPasswordField.fill(GALLERY_PASSWORD);
|
||||
passwordEntered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!passwordEntered) {
|
||||
const fallbackPasswordField = page.locator('input').first();
|
||||
if (await fallbackPasswordField.count()) {
|
||||
await fallbackPasswordField.fill(GALLERY_PASSWORD);
|
||||
passwordEntered = true;
|
||||
}
|
||||
}
|
||||
|
||||
const viewButton = page.getByRole('button', { name: /View Gallery/i });
|
||||
if (await viewButton.count()) {
|
||||
try {
|
||||
await viewButton.click({ noWaitAfter: true, timeout: 2000 });
|
||||
} catch {
|
||||
// Already navigated into gallery view.
|
||||
}
|
||||
}
|
||||
|
||||
const tiles = page.locator('.relative.group');
|
||||
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
|
||||
return tiles;
|
||||
}
|
||||
|
||||
test('admin login and gallery viewing smoke test', async ({ page }) => {
|
||||
const { shareLink, adminToken } = await createEventWithPhotos(page);
|
||||
|
||||
// Admin UI login
|
||||
await page.goto('/admin/login');
|
||||
const emailField = page.getByLabel(/Email/i);
|
||||
if (await emailField.count()) {
|
||||
await emailField.fill(ADMIN_EMAIL);
|
||||
await page.getByLabel(/Password/i).fill(ADMIN_PASSWORD);
|
||||
await page.getByRole('button', { name: /Sign In|Log in/i }).click();
|
||||
}
|
||||
await expect(page.getByRole('heading', { name: /Dashboard/i })).toBeVisible({ timeout: 20000 });
|
||||
|
||||
let resetToken = adminToken;
|
||||
try {
|
||||
// Verify long-form share link works
|
||||
const tiles = await openGalleryShareLink(page, shareLink);
|
||||
await tiles.first().hover();
|
||||
await tiles.first().getByRole('button', { name: /View full size/i }).click();
|
||||
await expect(page.getByRole('button', { name: /Close/i })).toBeVisible();
|
||||
await page.getByRole('button', { name: /Close/i }).click();
|
||||
|
||||
// Enable short gallery URLs
|
||||
await updateShortGallerySetting(page, adminToken, true);
|
||||
|
||||
const settingsResponse = await page.request.get('/api/admin/settings', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
});
|
||||
expect(settingsResponse.ok()).toBeTruthy();
|
||||
const adminSettings = await settingsResponse.json();
|
||||
expect(adminSettings.general_short_gallery_urls === true || adminSettings.general_short_gallery_urls === 'true').toBeTruthy();
|
||||
|
||||
const { shareLink: shortShareLink, event: shortEvent } = await createEventWithPhotos(page, adminToken);
|
||||
expect(shortShareLink).toMatch(/\/gallery\/[0-9a-fA-F]{32}$/);
|
||||
expect(shortShareLink).not.toContain(shortEvent.slug);
|
||||
|
||||
// Verify short share link works
|
||||
await openGalleryShareLink(page, shortShareLink);
|
||||
|
||||
// Legacy share link should still work after enabling short URLs
|
||||
await openGalleryShareLink(page, shareLink);
|
||||
} finally {
|
||||
await updateShortGallerySetting(page, resetToken, false).catch(() => {
|
||||
/* noop */
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Customer portal end-to-end flow (#354).
|
||||
*
|
||||
* Covers the maintainer-flagged "core promise" of the feature:
|
||||
* a customer can log in once and open every assigned gallery without
|
||||
* re-entering the per-event password. Specifically:
|
||||
*
|
||||
* 1. Admin enables the Customer dashboard (Settings → Advanced features).
|
||||
* 2. Admin creates an event AND invites a customer to that event.
|
||||
* 3. Customer accepts the invitation (sets a password).
|
||||
* 4. Customer logs in.
|
||||
* 5. Customer's dashboard lists the assigned gallery.
|
||||
* 6. Customer clicks the gallery → lands on /gallery/<slug> WITHOUT
|
||||
* seeing a password prompt. The grid renders with photos.
|
||||
*
|
||||
* Side checks:
|
||||
* - With the master toggle OFF, /customer/login redirects to /admin/login
|
||||
* (frontend gate) and the customer-side API returns 410 Gone (backend
|
||||
* gate). This is the kill-switch contract.
|
||||
*
|
||||
* Hits the API directly for setup (admin login, event create, photo upload,
|
||||
* customer invite, accept-invite, gallery assignment) and only uses the
|
||||
* browser for the parts that genuinely need the SPA: the gallery handoff
|
||||
* itself, where the bug surface lives. Keeps the spec fast and avoids
|
||||
* DOM-fragility on every admin form field.
|
||||
*/
|
||||
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'CustomerPortalGallery!1';
|
||||
const CUSTOMER_PASSWORD = 'CustomerPortalUser!1';
|
||||
|
||||
async function adminLogin(page: Page): Promise<string> {
|
||||
const res = await page.request.post('/api/auth/admin/login', {
|
||||
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const json = await res.json();
|
||||
expect(json.token).toBeTruthy();
|
||||
return json.token;
|
||||
}
|
||||
|
||||
async function setCustomerPortalEnabled(page: Page, adminToken: string, enabled: boolean) {
|
||||
const res = await page.request.put('/api/admin/settings/advanced-features', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: { customer_portal_enabled: enabled },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
async function createEventWithPhoto(page: Page, adminToken: string) {
|
||||
const eventName = `Customer Portal E2E ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
|
||||
const createRes = await page.request.post('/api/admin/events', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
customer_name: 'Customer Portal Host',
|
||||
customer_email: '[email protected]',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 30,
|
||||
allow_user_uploads: false,
|
||||
allow_downloads: true,
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
if (!createRes.ok()) {
|
||||
throw new Error(`Event create failed: ${createRes.status()} ${await createRes.text()}`);
|
||||
}
|
||||
const event = await createRes.json();
|
||||
|
||||
// One photo so the gallery grid has something to render after the
|
||||
// customer hits it. Tests that the gallery loads, not that it's empty.
|
||||
const imagePath = path.join(process.cwd(), 'test-assets', 'img1.png');
|
||||
const buffer = fs.readFileSync(imagePath);
|
||||
const uploadRes = await page.request.post(`/api/admin/events/${event.id}/upload`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
multipart: {
|
||||
photos: { name: 'img1.png', mimeType: 'image/png', buffer },
|
||||
category_id: 'individual',
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(uploadRes.ok()).toBeTruthy();
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite a customer, accept the invitation, return the email.
|
||||
*
|
||||
* The admin invite response echoes `invitation.token` only when
|
||||
* NODE_ENV !== 'production' — that lets the spec skip the email
|
||||
* round-trip without needing a separate /admin/email-queue endpoint
|
||||
* or direct DB access. In production the token stays email-only.
|
||||
*/
|
||||
async function inviteAndAcceptCustomer(page: Page, adminToken: string) {
|
||||
const email = `customer-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.test`;
|
||||
|
||||
const inviteRes = await page.request.post('/api/admin/customers/invite', {
|
||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||
data: { email },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
if (!inviteRes.ok()) {
|
||||
throw new Error(`Customer invite failed: ${inviteRes.status()} ${await inviteRes.text()}`);
|
||||
}
|
||||
const inviteBody = await inviteRes.json();
|
||||
// successResponse wraps in { success, data } — accept either shape.
|
||||
const invitation = inviteBody.data?.invitation ?? inviteBody.invitation;
|
||||
expect(invitation?.token, 'expected invitation.token echoed from non-prod /invite response').toBeTruthy();
|
||||
const token = invitation.token;
|
||||
|
||||
// Accept the invitation as the customer (no auth).
|
||||
const acceptRes = await page.request.post('/api/customer/auth/accept-invite', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
token,
|
||||
name: 'Customer Portal E2E',
|
||||
password: CUSTOMER_PASSWORD,
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
if (!acceptRes.ok()) {
|
||||
throw new Error(`Accept failed: ${acceptRes.status()} ${await acceptRes.text()}`);
|
||||
}
|
||||
|
||||
return { email };
|
||||
}
|
||||
|
||||
async function getCustomerIdByEmail(page: Page, adminToken: string, email: string): Promise<number> {
|
||||
const res = await page.request.get(`/api/admin/customers?search=${encodeURIComponent(email)}`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = await res.json();
|
||||
const list = body.customers || body.data?.customers || body;
|
||||
const match = (Array.isArray(list) ? list : []).find((c: any) => c.email === email);
|
||||
expect(match, `expected customer with email ${email}`).toBeTruthy();
|
||||
return match.id;
|
||||
}
|
||||
|
||||
async function assignCustomerToEvent(page: Page, adminToken: string, eventId: number, customerId: number) {
|
||||
// Update the event to include this customer's id in customer_account_ids
|
||||
const res = await page.request.put(`/api/admin/events/${eventId}`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||
data: { customer_account_ids: [customerId] },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Customer assignment failed: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the actual spec ---------------------------------------------------
|
||||
|
||||
test.describe('Customer portal — login + gallery handoff', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Make sure each test starts with a clean cookie jar so a leftover
|
||||
// admin_token from a previous spec doesn't accidentally satisfy
|
||||
// /api/customer/auth/session via the Authorization-Bearer fallback
|
||||
// (which the customer-side specifically refuses, but the test
|
||||
// shouldn't rely on that to pass).
|
||||
await page.context().clearCookies();
|
||||
});
|
||||
|
||||
test('customer can log in and open an assigned gallery without a password', async ({ page }) => {
|
||||
// === setup (admin side) ===
|
||||
const adminToken = await adminLogin(page);
|
||||
await setCustomerPortalEnabled(page, adminToken, true);
|
||||
|
||||
const event = await createEventWithPhoto(page, adminToken);
|
||||
const { email } = await inviteAndAcceptCustomer(page, adminToken);
|
||||
const customerId = await getCustomerIdByEmail(page, adminToken, email);
|
||||
await assignCustomerToEvent(page, adminToken, event.id, customerId);
|
||||
|
||||
try {
|
||||
// === customer flow ===
|
||||
// Login through the SPA (covers the cookie + setSession path that
|
||||
// makes the dashboard render the assigned gallery on first paint).
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/customer/login');
|
||||
await page.getByLabel(/Email/i).fill(email);
|
||||
await page.getByLabel(/Password/i).fill(CUSTOMER_PASSWORD);
|
||||
await page.getByRole('button', { name: /Sign in/i }).click();
|
||||
|
||||
// Dashboard should list the assigned event by name.
|
||||
await expect(page.getByRole('heading', { name: /Your galleries/i })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByText(event.event_name, { exact: false })).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Click → gallery handoff. Watch for the URL change AND the absence
|
||||
// of the per-event password prompt. Either of those failing is the
|
||||
// primary regression this spec is designed to catch.
|
||||
const navPromise = page.waitForURL(/\/gallery\//, { timeout: 15000 });
|
||||
await page.getByRole('button', { name: /Open gallery/i }).first().click();
|
||||
await navPromise;
|
||||
|
||||
// The password prompt would render `Enter Gallery Password` (heading
|
||||
// or section label). It must NOT be present after the customer
|
||||
// dashboard handoff.
|
||||
await expect(page.getByText(/Enter Gallery Password/i)).toHaveCount(0);
|
||||
|
||||
// The grid tiles use the `.relative.group` selector across layouts;
|
||||
// matches `auth-smoke.spec.ts`. At least one must render.
|
||||
const tiles = page.locator('.relative.group');
|
||||
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
|
||||
} finally {
|
||||
// Clean up: turn the feature off so the next test starts from a
|
||||
// known state. Errors are swallowed — leftover state from a failed
|
||||
// run is something to investigate manually.
|
||||
await setCustomerPortalEnabled(page, adminToken, false).catch(() => { /* noop */ });
|
||||
}
|
||||
});
|
||||
|
||||
test('disabling the master toggle redirects /customer/login to /admin/login and refuses the API', async ({ page }) => {
|
||||
// Verifies the kill-switch contract on both sides:
|
||||
// - frontend: CustomerPortalGate redirects when public-settings says off
|
||||
// - backend: /api/customer/auth/session returns 410 Gone with code
|
||||
// CUSTOMER_PORTAL_DISABLED
|
||||
const adminToken = await adminLogin(page);
|
||||
await setCustomerPortalEnabled(page, adminToken, false);
|
||||
|
||||
// API gate: 410 Gone (the chosen status for "feature was here, admin
|
||||
// turned it off" — distinct from a generic 403).
|
||||
const apiRes = await page.request.get('/api/customer/auth/session', { failOnStatusCode: false });
|
||||
expect(apiRes.status()).toBe(410);
|
||||
const body = await apiRes.json().catch(() => ({}));
|
||||
expect(body.code).toBe('CUSTOMER_PORTAL_DISABLED');
|
||||
|
||||
// Frontend gate: CustomerPortalGate redirects /customer/* away to
|
||||
// /admin/login. Use waitForURL so we don't race the React Router
|
||||
// <Navigate>.
|
||||
await page.goto('/customer/login');
|
||||
await page.waitForURL(/\/admin\/login/, { timeout: 10000 });
|
||||
expect(page.url()).toContain('/admin/login');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
|
||||
// Helper to login to admin
|
||||
async function loginToAdmin(page) {
|
||||
await page.goto('/admin/login');
|
||||
await page.getByLabel(/Email|E-Mail/i).fill(ADMIN_EMAIL);
|
||||
await page.getByLabel(/Password|Passwort/i).fill(ADMIN_PASSWORD);
|
||||
await page.getByRole('button', { name: /Sign In|Log in|Anmelden/i }).click();
|
||||
await expect(page.getByRole('heading', { name: /Dashboard|Übersicht/i })).toBeVisible({ timeout: 20000 });
|
||||
}
|
||||
|
||||
test.describe('Admin Dark Mode Toggle', () => {
|
||||
test('dark mode toggle button exists in admin header', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip('Dark mode toggle validated on desktop viewport');
|
||||
}
|
||||
|
||||
await loginToAdmin(page);
|
||||
|
||||
// Look for the dark mode toggle button
|
||||
const toggleButton = page.getByRole('button', { name: /dark mode|light mode|Dunkelmodus|Hellmodus/i });
|
||||
await expect(toggleButton).toBeVisible();
|
||||
});
|
||||
|
||||
test('clicking toggle switches to dark mode and adds dark class', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip('Dark mode toggle validated on desktop viewport');
|
||||
}
|
||||
|
||||
await loginToAdmin(page);
|
||||
|
||||
// Check initial state - should be light
|
||||
const html = page.locator('html');
|
||||
const initialHasDark = await html.evaluate(el => el.classList.contains('dark'));
|
||||
|
||||
// Click the toggle
|
||||
const toggleButton = page.getByRole('button', { name: /dark mode|light mode|Dunkelmodus|Hellmodus/i });
|
||||
await toggleButton.click();
|
||||
|
||||
// Wait a moment for the class to toggle
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify dark class toggled
|
||||
const afterHasDark = await html.evaluate(el => el.classList.contains('dark'));
|
||||
expect(afterHasDark).toBe(!initialHasDark);
|
||||
|
||||
// Click again to revert
|
||||
await toggleButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const finalHasDark = await html.evaluate(el => el.classList.contains('dark'));
|
||||
expect(finalHasDark).toBe(initialHasDark);
|
||||
});
|
||||
|
||||
test('dark mode preference persists across page reloads', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip('Dark mode toggle validated on desktop viewport');
|
||||
}
|
||||
|
||||
await loginToAdmin(page);
|
||||
|
||||
// Set to dark mode
|
||||
const html = page.locator('html');
|
||||
const isAlreadyDark = await html.evaluate(el => el.classList.contains('dark'));
|
||||
|
||||
if (!isAlreadyDark) {
|
||||
const toggleButton = page.getByRole('button', { name: /dark mode|Dunkelmodus/i });
|
||||
await toggleButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Verify dark mode is active
|
||||
await expect(html).toHaveAttribute('class', /dark/);
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: /Dashboard|Übersicht/i })).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Verify dark mode persists
|
||||
await expect(html).toHaveAttribute('class', /dark/);
|
||||
|
||||
// Clean up - switch back to light mode
|
||||
const toggleButton = page.getByRole('button', { name: /light mode|Hellmodus/i });
|
||||
await toggleButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
test('dark mode applies correct dark background to admin layout', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip('Dark mode toggle validated on desktop viewport');
|
||||
}
|
||||
|
||||
await loginToAdmin(page);
|
||||
|
||||
// Enable dark mode
|
||||
const html = page.locator('html');
|
||||
const isAlreadyDark = await html.evaluate(el => el.classList.contains('dark'));
|
||||
|
||||
if (!isAlreadyDark) {
|
||||
const toggleButton = page.getByRole('button', { name: /dark mode|Dunkelmodus/i });
|
||||
await toggleButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Verify the main layout container has dark background
|
||||
const mainContainer = page.locator('.h-screen.bg-neutral-50, .h-screen.dark\\:bg-neutral-950').first();
|
||||
const bgColor = await mainContainer.evaluate(el => getComputedStyle(el).backgroundColor);
|
||||
|
||||
// In dark mode, background should be very dark (close to black)
|
||||
// neutral-950 is approximately rgb(10, 10, 10)
|
||||
expect(bgColor).not.toBe('rgb(255, 255, 255)'); // Not white
|
||||
expect(bgColor).not.toBe('rgba(0, 0, 0, 0)'); // Not transparent
|
||||
|
||||
// Clean up
|
||||
const toggleButton = page.getByRole('button', { name: /light mode|Hellmodus/i });
|
||||
await toggleButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
test('dark mode preference is stored in localStorage', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip('Dark mode toggle validated on desktop viewport');
|
||||
}
|
||||
|
||||
await loginToAdmin(page);
|
||||
|
||||
// Enable dark mode
|
||||
const html = page.locator('html');
|
||||
const isAlreadyDark = await html.evaluate(el => el.classList.contains('dark'));
|
||||
|
||||
if (!isAlreadyDark) {
|
||||
const toggleButton = page.getByRole('button', { name: /dark mode|Dunkelmodus/i });
|
||||
await toggleButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Verify dark mode preference is stored in localStorage
|
||||
const storedPreference = await page.evaluate(() => localStorage.getItem('admin-dark-mode'));
|
||||
expect(storedPreference).toBe('dark');
|
||||
|
||||
// Clean up - toggle back to light
|
||||
const toggleButton = page.getByRole('button', { name: /light mode|Hellmodus/i });
|
||||
await toggleButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify light mode preference is stored
|
||||
const lightPreference = await page.evaluate(() => localStorage.getItem('admin-dark-mode'));
|
||||
expect(lightPreference).toBe('light');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Settings Page Dark Mode', () => {
|
||||
test('settings page tabs render correctly in dark mode', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip('Settings dark mode validated on desktop viewport');
|
||||
}
|
||||
|
||||
await loginToAdmin(page);
|
||||
|
||||
// Enable dark mode
|
||||
const html = page.locator('html');
|
||||
const isAlreadyDark = await html.evaluate(el => el.classList.contains('dark'));
|
||||
|
||||
if (!isAlreadyDark) {
|
||||
const toggleButton = page.getByRole('button', { name: /dark mode|Dunkelmodus/i });
|
||||
await toggleButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Go to settings
|
||||
await page.goto('/admin/settings');
|
||||
await expect(page.getByRole('heading', { name: /Settings|Einstellungen/i })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify settings heading has dark text style
|
||||
const heading = page.getByRole('heading', { name: /Settings|Einstellungen/i }).first();
|
||||
const headingColor = await heading.evaluate(el => getComputedStyle(el).color);
|
||||
|
||||
// In dark mode, text should be light (not dark)
|
||||
// neutral-100 is approximately rgb(245, 245, 245)
|
||||
const [r, g, b] = headingColor.match(/\d+/g).map(Number);
|
||||
expect(r + g + b).toBeGreaterThan(500); // Light colored text
|
||||
|
||||
// Verify tab buttons are visible (use exact: true to avoid matching save buttons)
|
||||
await expect(page.getByRole('button', { name: 'General', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /^SEO & Robots$|^SEO$/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Security', exact: true })).toBeVisible();
|
||||
|
||||
// Clean up
|
||||
const toggleButton = page.getByRole('button', { name: /light mode|Hellmodus/i });
|
||||
await toggleButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Gallery Theme Color Mode', () => {
|
||||
test('branding page has color mode selector', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip('Theme customizer validated on desktop viewport');
|
||||
}
|
||||
|
||||
await loginToAdmin(page);
|
||||
|
||||
await page.goto('/admin/branding');
|
||||
await expect(page.getByText(/Theme|Themen/i).first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Look for the color mode selector
|
||||
await expect(page.getByText(/Color Mode|Farbmodus/i)).toBeVisible();
|
||||
|
||||
// Verify the mode buttons exist
|
||||
await expect(page.getByRole('button', { name: /^Light$|^Hell$/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /^Dark$|^Dunkel$/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /^Auto$/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Force color mode (instance-wide lock)', () => {
|
||||
// The force color mode setting is exposed in Branding > Force color mode.
|
||||
// When set, the user-facing dark/light toggle in the admin header should
|
||||
// disappear entirely. We verify both presence of the controls and that
|
||||
// toggling them hides the header chip.
|
||||
test('force-mode controls are present in Branding settings', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip('Force color mode validated on desktop viewport');
|
||||
}
|
||||
|
||||
await loginToAdmin(page);
|
||||
|
||||
await page.goto('/admin/branding');
|
||||
await expect(page.getByRole('heading', { name: /Force color mode|Farbmodus erzwingen/i })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The three states must be selectable as buttons.
|
||||
await expect(page.getByRole('button', { name: /No force|Kein/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Force dark|Dunkel erzwingen/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Force light|Hell erzwingen/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('selecting force-dark hides the header dark mode toggle on next reload', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip('Force color mode validated on desktop viewport');
|
||||
}
|
||||
|
||||
await loginToAdmin(page);
|
||||
|
||||
// Confirm the toggle is initially visible (no force mode set).
|
||||
let toggle = page.getByRole('button', { name: /dark mode|light mode|Dunkelmodus|Hellmodus/i });
|
||||
await expect(toggle).toBeVisible();
|
||||
|
||||
// Set force-dark via Branding page.
|
||||
await page.goto('/admin/branding');
|
||||
await page.getByRole('button', { name: /Force dark|Dunkel erzwingen/i }).click();
|
||||
await page.getByRole('button', { name: /^Save|^Speichern/i }).first().click();
|
||||
|
||||
// Reload so the public-settings refetch picks up the new value.
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
toggle = page.getByRole('button', { name: /dark mode|light mode|Dunkelmodus|Hellmodus/i });
|
||||
await expect(toggle).toHaveCount(0);
|
||||
|
||||
// The .dark class should be applied to <html>.
|
||||
const html = page.locator('html');
|
||||
await expect(html).toHaveClass(/(^|\s)dark(\s|$)/);
|
||||
|
||||
// Restore: clear the force mode so subsequent test runs aren't affected.
|
||||
await page.goto('/admin/branding');
|
||||
await page.getByRole('button', { name: /No force|Kein/i }).click();
|
||||
await page.getByRole('button', { name: /^Save|^Speichern/i }).first().click();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'ExternalMediaPass!1';
|
||||
|
||||
async function createExternalGallery(page) {
|
||||
const externalRoot = path.join(process.cwd(), 'storage', 'external-media', 'picsum-demo', 'individual');
|
||||
if (!fs.existsSync(externalRoot)) {
|
||||
fs.mkdirSync(externalRoot, { recursive: true });
|
||||
}
|
||||
|
||||
const sampleImages = ['img1.png', 'img2.png'];
|
||||
for (const imageName of sampleImages) {
|
||||
const source = path.join(process.cwd(), 'test-assets', imageName);
|
||||
const target = path.join(externalRoot, imageName);
|
||||
if (!fs.existsSync(target)) {
|
||||
fs.copyFileSync(source, target);
|
||||
}
|
||||
}
|
||||
|
||||
const loginResponse = await page.request.post('/api/auth/admin/login', {
|
||||
data: {
|
||||
username: ADMIN_EMAIL,
|
||||
password: ADMIN_PASSWORD,
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(loginResponse.ok()).toBeTruthy();
|
||||
const { token } = await loginResponse.json();
|
||||
expect(token).toBeTruthy();
|
||||
|
||||
const eventName = `External Media Playwright ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
const createResponse = await page.request.post('/api/admin/events', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
customer_name: 'External Host',
|
||||
customer_email: '[email protected]',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 30,
|
||||
allow_user_uploads: false,
|
||||
allow_downloads: true,
|
||||
disable_right_click: false,
|
||||
watermark_downloads: false,
|
||||
feedback_enabled: true,
|
||||
allow_ratings: true,
|
||||
allow_likes: true,
|
||||
allow_comments: true,
|
||||
allow_favorites: true,
|
||||
require_name_email: false,
|
||||
moderate_comments: false,
|
||||
show_feedback_to_guests: true,
|
||||
source_mode: 'reference',
|
||||
external_path: 'picsum-demo'
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
|
||||
if (!createResponse.ok()) {
|
||||
const bodyText = await createResponse.text();
|
||||
throw new Error(`Failed to create event: ${createResponse.status()} ${bodyText}`);
|
||||
}
|
||||
const createdEvent = await createResponse.json();
|
||||
expect(createdEvent?.id).toBeTruthy();
|
||||
|
||||
const importResponse = await page.request.post(`/api/admin/external-media/events/${createdEvent.id}/import-external`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
external_path: 'picsum-demo',
|
||||
recursive: true,
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
|
||||
if (!importResponse.ok()) {
|
||||
const bodyText = await importResponse.text();
|
||||
throw new Error(`Failed to import external media: ${importResponse.status()} ${bodyText}`);
|
||||
}
|
||||
const importBody = await importResponse.json();
|
||||
expect(importBody.imported).toBeGreaterThan(0);
|
||||
|
||||
await page.request.put(`/api/admin/feedback/events/${createdEvent.id}/feedback-settings`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
feedback_enabled: true,
|
||||
allow_ratings: true,
|
||||
allow_likes: true,
|
||||
allow_comments: true,
|
||||
allow_favorites: true,
|
||||
require_name_email: false,
|
||||
moderate_comments: false,
|
||||
show_feedback_to_guests: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
shareLink: createdEvent.share_link,
|
||||
slug: createdEvent.slug,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('External media gallery behavior', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test('Maintains session and favorites after reload', async ({ page, context }) => {
|
||||
if (test.info().project.name.includes('mobile')) {
|
||||
test.skip('Mobile viewport handling requires manual verification.');
|
||||
}
|
||||
|
||||
const { shareLink, slug } = await createExternalGallery(page);
|
||||
|
||||
await page.goto(shareLink);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const passwordField = page.getByPlaceholder(/gallery password/i).first();
|
||||
if (await passwordField.count()) {
|
||||
await passwordField.fill(GALLERY_PASSWORD);
|
||||
const viewButton = page.getByRole('button', { name: /View Gallery/i });
|
||||
if (await viewButton.count()) {
|
||||
try {
|
||||
await viewButton.click({ noWaitAfter: true, timeout: 5000 });
|
||||
} catch {
|
||||
// Auto-auth via share token may have already navigated to gallery view
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tiles = page.locator('.relative.group');
|
||||
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
|
||||
|
||||
const initialTileCount = await tiles.count();
|
||||
expect(initialTileCount).toBeGreaterThan(0);
|
||||
|
||||
const firstTile = tiles.first();
|
||||
await firstTile.scrollIntoViewIfNeeded();
|
||||
await firstTile.getByRole('button', { name: /View full size/i }).click();
|
||||
|
||||
await page.evaluate(() => {
|
||||
const toggle = document.querySelector('[aria-label="Toggle feedback"]');
|
||||
if (toggle instanceof HTMLElement) toggle.click();
|
||||
});
|
||||
|
||||
const favoritesButtonInLightbox = page.getByRole('button', { name: /Add to favorites|Remove from favorites/ }).first();
|
||||
await expect(favoritesButtonInLightbox).toBeVisible();
|
||||
|
||||
const ariaLabel = await favoritesButtonInLightbox.getAttribute('aria-label');
|
||||
const isAlreadyFavorited = ariaLabel ? /Remove from favorites/i.test(ariaLabel) : false;
|
||||
if (!isAlreadyFavorited) {
|
||||
await favoritesButtonInLightbox.click();
|
||||
// Wait for the mutation to complete and the subsequent refetch with updated counts
|
||||
// The onSuccess handler invalidates gallery-photos, triggering a fresh refetch
|
||||
await page.waitForTimeout(500);
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByRole('button', { name: 'Favorited' }).click();
|
||||
await expect(page.locator('.relative.group')).toHaveCount(1, { timeout: 15000 });
|
||||
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page).toHaveURL(/\/gallery\//);
|
||||
await expect(page.locator('.relative.group').first()).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Favorited' }).click();
|
||||
await expect(page.locator('.relative.group')).toHaveCount(1, { timeout: 15000 });
|
||||
|
||||
await page.getByRole('button', { name: 'All', exact: true }).click();
|
||||
await expect(page.locator('.relative.group')).toHaveCount(initialTileCount);
|
||||
|
||||
const cookies = await context.cookies();
|
||||
expect(cookies.some((cookie) => cookie.name === 'gallery_token')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!';
|
||||
|
||||
interface GallerySetupResult {
|
||||
shareLink: string;
|
||||
slug: string;
|
||||
allPhotosData: {
|
||||
event: any;
|
||||
categories?: any;
|
||||
photos: Array<{ id: number; filename: string; comment_count?: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
async function createGalleryWithModeratedComments(page: Page): Promise<GallerySetupResult> {
|
||||
const loginResponse = await page.request.post('/api/auth/admin/login', {
|
||||
data: {
|
||||
username: ADMIN_EMAIL,
|
||||
password: ADMIN_PASSWORD,
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(loginResponse.ok()).toBeTruthy();
|
||||
const { token } = await loginResponse.json();
|
||||
expect(token).toBeTruthy();
|
||||
|
||||
const eventName = `Playwright Feedback Filter ${Date.now()}`;
|
||||
const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
const createResponse = await page.request.post('/api/admin/events', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
customer_name: 'Playwright Host',
|
||||
customer_email: '[email protected]',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 30,
|
||||
allow_user_uploads: false,
|
||||
allow_downloads: true,
|
||||
disable_right_click: false,
|
||||
watermark_downloads: false,
|
||||
feedback_enabled: true,
|
||||
allow_ratings: true,
|
||||
allow_likes: true,
|
||||
allow_comments: true,
|
||||
allow_favorites: true,
|
||||
require_name_email: false,
|
||||
moderate_comments: true,
|
||||
show_feedback_to_guests: true,
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
|
||||
expect(createResponse.ok()).toBeTruthy();
|
||||
const createdEvent = await createResponse.json();
|
||||
expect(createdEvent?.id).toBeTruthy();
|
||||
|
||||
const imagePaths = ['img1.png', 'img2.png'];
|
||||
const photoIds: number[] = [];
|
||||
|
||||
for (const file of imagePaths) {
|
||||
const imagePath = path.join(process.cwd(), 'test-assets', file);
|
||||
const buffer = fs.readFileSync(imagePath);
|
||||
const uploadResponse = await page.request.post(
|
||||
`/api/admin/events/${createdEvent.id}/upload`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
multipart: {
|
||||
photos: {
|
||||
name: path.basename(imagePath),
|
||||
mimeType: 'image/png',
|
||||
buffer,
|
||||
},
|
||||
category_id: 'individual',
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
}
|
||||
);
|
||||
expect(uploadResponse.ok()).toBeTruthy();
|
||||
const uploadJson = await uploadResponse.json();
|
||||
const uploaded = uploadJson?.photos?.[0];
|
||||
expect(uploaded?.id).toBeTruthy();
|
||||
photoIds.push(uploaded.id);
|
||||
}
|
||||
|
||||
expect(photoIds.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const galleryAuthResponse = await page.request.post('/api/auth/gallery/verify', {
|
||||
data: {
|
||||
slug: createdEvent.slug,
|
||||
password: GALLERY_PASSWORD,
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(galleryAuthResponse.ok()).toBeTruthy();
|
||||
const { token: galleryToken } = await galleryAuthResponse.json();
|
||||
expect(galleryToken).toBeTruthy();
|
||||
|
||||
// Submit an approved comment (after moderation)
|
||||
const approvedCommentResponse = await page.request.post(
|
||||
`/api/gallery/${createdEvent.slug}/photos/${photoIds[0]}/feedback`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${galleryToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
feedback_type: 'comment',
|
||||
comment_text: 'Approved comment',
|
||||
guest_name: 'Approved Guest',
|
||||
guest_email: '[email protected]',
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
}
|
||||
);
|
||||
expect(approvedCommentResponse.ok()).toBeTruthy();
|
||||
const approvedComment = await approvedCommentResponse.json();
|
||||
expect(approvedComment?.id).toBeTruthy();
|
||||
|
||||
const approveModeration = await page.request.put(
|
||||
`/api/admin/feedback/feedback/${approvedComment.id}/approve`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
}
|
||||
);
|
||||
expect(approveModeration.ok()).toBeTruthy();
|
||||
|
||||
// Submit a second comment that remains pending
|
||||
const pendingCommentResponse = await page.request.post(
|
||||
`/api/gallery/${createdEvent.slug}/photos/${photoIds[1]}/feedback`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${galleryToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
feedback_type: 'comment',
|
||||
comment_text: 'Pending comment',
|
||||
guest_name: 'Pending Guest',
|
||||
guest_email: '[email protected]',
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
}
|
||||
);
|
||||
expect(pendingCommentResponse.ok()).toBeTruthy();
|
||||
|
||||
const allPhotosResponse = await page.request.get(`/api/gallery/${createdEvent.slug}/photos`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${galleryToken}`,
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(allPhotosResponse.ok()).toBeTruthy();
|
||||
const allPhotosData = await allPhotosResponse.json();
|
||||
expect(Array.isArray(allPhotosData?.photos)).toBeTruthy();
|
||||
|
||||
return {
|
||||
shareLink: createdEvent.share_link,
|
||||
slug: createdEvent.slug,
|
||||
allPhotosData,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('Gallery feedback filter', () => {
|
||||
test('Comment filter hides photos without approved comments', async ({ page }) => {
|
||||
const { shareLink, slug, allPhotosData } = await createGalleryWithModeratedComments(page);
|
||||
|
||||
const approvedPhotos = allPhotosData.photos.filter((photo) => (photo.comment_count || 0) > 0);
|
||||
expect(approvedPhotos.length).toBeGreaterThan(0);
|
||||
|
||||
await page.route(`**/api/gallery/${slug}/photos**`, async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.searchParams.get('filter') === 'commented') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(allPhotosData),
|
||||
});
|
||||
await page.unroute(`**/api/gallery/${slug}/photos**`);
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(shareLink);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const passwordField = page.getByPlaceholder(/gallery password/i).first();
|
||||
if (await passwordField.count()) {
|
||||
await passwordField.fill(GALLERY_PASSWORD);
|
||||
await page.getByRole('button', { name: /View Gallery/i }).click();
|
||||
}
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const tiles = page.locator('.relative.group');
|
||||
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
|
||||
await expect(tiles).toHaveCount(allPhotosData.photos.length);
|
||||
|
||||
await page.getByRole('button', { name: /Commented/i }).click();
|
||||
|
||||
await expect(tiles).toHaveCount(approvedPhotos.length, { timeout: 20000 });
|
||||
|
||||
for (const pending of allPhotosData.photos.filter((photo) => (photo.comment_count || 0) === 0)) {
|
||||
await expect(page.getByAltText(pending.filename)).not.toBeVisible({ timeout: 1000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!';
|
||||
|
||||
async function ensureGalleryWithPhotos(page) {
|
||||
const loginResponse = await page.request.post('/api/auth/admin/login', {
|
||||
data: {
|
||||
username: ADMIN_EMAIL,
|
||||
password: ADMIN_PASSWORD,
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(loginResponse.ok()).toBeTruthy();
|
||||
const { token } = await loginResponse.json();
|
||||
expect(token).toBeTruthy();
|
||||
|
||||
const eventName = `Playwright MCP ${Date.now()}`;
|
||||
const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
const createResponse = await page.request.post('/api/admin/events', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
customer_name: 'Playwright Host',
|
||||
customer_email: '[email protected]',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 90,
|
||||
allow_user_uploads: false,
|
||||
allow_downloads: true,
|
||||
disable_right_click: false,
|
||||
watermark_downloads: false,
|
||||
feedback_enabled: true,
|
||||
allow_ratings: true,
|
||||
allow_likes: true,
|
||||
allow_comments: true,
|
||||
allow_favorites: true,
|
||||
require_name_email: false,
|
||||
moderate_comments: false,
|
||||
show_feedback_to_guests: true,
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
|
||||
expect(createResponse.ok()).toBeTruthy();
|
||||
const createdEvent = await createResponse.json();
|
||||
expect(createdEvent?.id).toBeTruthy();
|
||||
|
||||
const imagePaths = ['img1.png', 'img2.png'].map((file) =>
|
||||
path.join(process.cwd(), 'test-assets', file)
|
||||
);
|
||||
|
||||
for (const imagePath of imagePaths) {
|
||||
const buffer = fs.readFileSync(imagePath);
|
||||
const uploadResponse = await page.request.post(
|
||||
`/api/admin/events/${createdEvent.id}/upload`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
multipart: {
|
||||
photos: {
|
||||
name: path.basename(imagePath),
|
||||
mimeType: 'image/png',
|
||||
buffer,
|
||||
},
|
||||
category_id: 'individual',
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
}
|
||||
);
|
||||
expect(uploadResponse.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
return {
|
||||
shareLink: createdEvent.share_link,
|
||||
slug: createdEvent.slug,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('Gallery grid tile quick actions', () => {
|
||||
test('Each tile: open, download, comment, like with immediate UI', async ({ page }) => {
|
||||
const { shareLink } = await ensureGalleryWithPhotos(page);
|
||||
|
||||
await page.goto(shareLink);
|
||||
const gallery = page;
|
||||
await gallery.waitForLoadState('domcontentloaded');
|
||||
await gallery.waitForURL(/\/gallery\//);
|
||||
|
||||
const passwordField = gallery.getByPlaceholder(/gallery password/i).first();
|
||||
if (await passwordField.count()) {
|
||||
await passwordField.fill(GALLERY_PASSWORD);
|
||||
try {
|
||||
await gallery.getByRole('button', { name: /View Gallery/i }).click({ noWaitAfter: true, timeout: 5000 });
|
||||
} catch {
|
||||
// Auto-auth via share token may have already navigated to gallery view
|
||||
}
|
||||
await gallery.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
// Ensure grid tiles rendered
|
||||
const tiles = gallery.locator('.relative.group');
|
||||
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
|
||||
|
||||
const tileCount = await tiles.count();
|
||||
expect(tileCount).toBeGreaterThan(0);
|
||||
|
||||
// Limit to a few tiles to keep test time sensible
|
||||
const N = Math.min(tileCount, 3);
|
||||
for (let i = 0; i < N; i++) {
|
||||
const tile = tiles.nth(i);
|
||||
await tile.scrollIntoViewIfNeeded();
|
||||
// On desktop, actions show on hover
|
||||
await tile.hover({ force: true });
|
||||
|
||||
// Actions should be present
|
||||
const openBtn = tile.getByRole('button', { name: /View full size/i });
|
||||
await expect(openBtn).toBeVisible();
|
||||
|
||||
const likeBtn = tile.getByRole('button', { name: /Like photo/i }).first();
|
||||
await expect(likeBtn).toBeVisible();
|
||||
|
||||
const commentBtn = tile.getByRole('button', { name: /Comment on photo|Comment/i }).first();
|
||||
await expect(commentBtn).toBeVisible();
|
||||
|
||||
const downloadBtn = tile.getByRole('button', { name: /Download photo/i }).first();
|
||||
await expect(downloadBtn).toBeVisible();
|
||||
|
||||
// Like should toggle to red and indicator appear immediately
|
||||
const pressedBefore = await likeBtn.getAttribute('aria-pressed');
|
||||
await likeBtn.click();
|
||||
await expect.poll(async () => (await likeBtn.getAttribute('aria-pressed')) || '').toContain('true');
|
||||
// Feedback indicator (title="Liked") should appear on the tile
|
||||
await expect(tile.locator('[title="Liked"]')).toBeVisible();
|
||||
|
||||
// Open lightbox
|
||||
await openBtn.click();
|
||||
const closeLightboxBtn = gallery.getByRole('button', { name: /^Close$/i }).first();
|
||||
await expect(closeLightboxBtn).toBeVisible();
|
||||
// Close again to continue
|
||||
await closeLightboxBtn.click();
|
||||
|
||||
// Comment quick action should open lightbox with feedback panel visible
|
||||
await tile.hover({ force: true });
|
||||
await commentBtn.click();
|
||||
await expect(gallery.getByRole('button', { name: /Toggle feedback/ })).toBeVisible();
|
||||
|
||||
// Ensure feedback panel is visible or open it
|
||||
const feedbackHeading = gallery.getByRole('heading', { name: /Photo Feedback/i });
|
||||
if (!(await feedbackHeading.isVisible())) {
|
||||
await gallery.getByRole('button', { name: /Toggle feedback/ }).click();
|
||||
}
|
||||
await expect(feedbackHeading).toBeVisible();
|
||||
|
||||
// Comments quick action should surface the feedback tools
|
||||
const addCommentBtn = gallery.getByRole('button', { name: /Add Comment|Add comment/i });
|
||||
await expect(addCommentBtn).toBeVisible();
|
||||
await addCommentBtn.click();
|
||||
// Allow UI to react without requiring text entry
|
||||
await gallery.waitForTimeout(250);
|
||||
|
||||
// Close lightbox to continue (we do not submit to keep test idempotent)
|
||||
await closeLightboxBtn.click();
|
||||
|
||||
// Download from tile should trigger a browser download event
|
||||
await tile.hover({ force: true });
|
||||
const downloadPromise = gallery.waitForEvent('download');
|
||||
await downloadBtn.click();
|
||||
const download = await downloadPromise;
|
||||
expect((await download.path()) !== null).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!';
|
||||
|
||||
async function getAdminToken(page: Page): Promise<string> {
|
||||
const res = await page.request.post('/api/auth/admin/login', {
|
||||
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD },
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const { token } = await res.json();
|
||||
return token;
|
||||
}
|
||||
|
||||
async function createGalleryPremiumEvent(page: Page) {
|
||||
const token = await getAdminToken(page);
|
||||
|
||||
const eventName = `PW SelectAll ${Date.now()}`;
|
||||
const eventDate = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
|
||||
|
||||
const createRes = await page.request.post('/api/admin/events', {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
customer_name: 'Playwright Host',
|
||||
customer_email: '[email protected]',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 90,
|
||||
allow_downloads: true,
|
||||
gallery_theme: 'gallery-premium',
|
||||
},
|
||||
});
|
||||
expect(createRes.ok()).toBeTruthy();
|
||||
const event = await createRes.json();
|
||||
|
||||
// Upload 3 images so we can verify select-all picks all of them
|
||||
const imagePaths = ['img1.png', 'img2.png', 'img1.png'].map((f) =>
|
||||
path.join(process.cwd(), 'test-assets', f)
|
||||
);
|
||||
|
||||
for (const imagePath of imagePaths) {
|
||||
const buffer = fs.readFileSync(imagePath);
|
||||
const uploadRes = await page.request.post(`/api/admin/events/${event.id}/upload`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: {
|
||||
photos: { name: path.basename(imagePath), mimeType: 'image/png', buffer },
|
||||
category_id: 'individual',
|
||||
},
|
||||
});
|
||||
expect(uploadRes.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
return { shareLink: event.share_link, slug: event.slug, token };
|
||||
}
|
||||
|
||||
test.describe('Gallery-Premium Select All (#220)', () => {
|
||||
test('Select All button selects all photos in one click', async ({ page, browserName }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip();
|
||||
}
|
||||
|
||||
const { shareLink } = await createGalleryPremiumEvent(page);
|
||||
|
||||
// Navigate to gallery
|
||||
await page.goto(shareLink);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Handle password if needed
|
||||
const passwordField = page.getByPlaceholder(/gallery password/i).first();
|
||||
if (await passwordField.count()) {
|
||||
await passwordField.fill(GALLERY_PASSWORD);
|
||||
try {
|
||||
await page.getByRole('button', { name: /View Gallery/i }).click({ timeout: 5000 });
|
||||
} catch {
|
||||
// Token may auto-auth
|
||||
}
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
// Wait for photos to render
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Look for download button to enter selection mode
|
||||
const downloadBtn = page.getByRole('button', { name: /Download/i }).first();
|
||||
await expect(downloadBtn).toBeVisible({ timeout: 15000 });
|
||||
await downloadBtn.click();
|
||||
|
||||
// Now look for "Select All" button
|
||||
const selectAllBtn = page.getByRole('button', { name: /Select All/i }).first();
|
||||
await expect(selectAllBtn).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click Select All once
|
||||
await selectAllBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify all photos are selected - check for checkmarks or selected state
|
||||
// The selection count or "Deselect All" text should appear
|
||||
const deselectAllBtn = page.getByRole('button', { name: /Deselect All/i }).first();
|
||||
await expect(deselectAllBtn).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify: clicking Deselect All should clear selection
|
||||
await deselectAllBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Select All should be visible again
|
||||
await expect(selectAllBtn).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!';
|
||||
|
||||
// Helper: login and get admin token
|
||||
async function getAdminToken(page: Page): Promise<string> {
|
||||
const loginResponse = await page.request.post('/api/auth/admin/login', {
|
||||
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD },
|
||||
});
|
||||
expect(loginResponse.ok()).toBeTruthy();
|
||||
const { token } = await loginResponse.json();
|
||||
expect(token).toBeTruthy();
|
||||
return token;
|
||||
}
|
||||
|
||||
// Helper: create event with a given header_style, upload a photo, return event + share info
|
||||
async function createEventWithStyle(
|
||||
page: Page,
|
||||
token: string,
|
||||
headerStyle: string,
|
||||
extra: Record<string, any> = {},
|
||||
) {
|
||||
const eventName = `E2E ${headerStyle} ${Date.now()}`;
|
||||
const eventDate = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
|
||||
|
||||
const eventResponse = await page.request.post('/api/admin/events', {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
customer_name: 'E2E Host',
|
||||
customer_email: '[email protected]',
|
||||
host_name: 'E2E Host',
|
||||
host_email: '[email protected]',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 30,
|
||||
allow_user_uploads: false,
|
||||
allow_downloads: true,
|
||||
header_style: headerStyle,
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
expect(eventResponse.ok()).toBeTruthy();
|
||||
const event = await eventResponse.json();
|
||||
|
||||
// Upload two test images
|
||||
const imagePath = path.join(process.cwd(), 'test-assets', 'img1.png');
|
||||
const buffer = fs.readFileSync(imagePath);
|
||||
const uploadResponse = await page.request.post(`/api/admin/events/${event.id}/upload`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: {
|
||||
photos: { name: 'img1.png', mimeType: 'image/png', buffer },
|
||||
category_id: 'individual',
|
||||
},
|
||||
});
|
||||
expect(uploadResponse.ok()).toBeTruthy();
|
||||
|
||||
const imagePath2 = path.join(process.cwd(), 'test-assets', 'img2.png');
|
||||
const buffer2 = fs.readFileSync(imagePath2);
|
||||
const uploadResponse2 = await page.request.post(`/api/admin/events/${event.id}/upload`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: {
|
||||
photos: { name: 'img2.png', mimeType: 'image/png', buffer: buffer2 },
|
||||
category_id: 'individual',
|
||||
},
|
||||
});
|
||||
expect(uploadResponse2.ok()).toBeTruthy();
|
||||
|
||||
return { event, shareLink: event.share_link, slug: event.slug, eventName };
|
||||
}
|
||||
|
||||
// Helper: open gallery and enter password
|
||||
async function openGallery(page: Page, shareLink: string) {
|
||||
await page.context().clearCookies();
|
||||
await page.goto(shareLink);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const passwordField = page.getByRole('textbox', { name: /password/i }).first();
|
||||
if (await passwordField.count()) {
|
||||
await passwordField.fill(GALLERY_PASSWORD);
|
||||
} else {
|
||||
const fallback = page.getByPlaceholder(/password/i);
|
||||
if (await fallback.count()) {
|
||||
await fallback.fill(GALLERY_PASSWORD);
|
||||
}
|
||||
}
|
||||
|
||||
const viewButton = page.getByRole('button', { name: /View Gallery/i });
|
||||
if (await viewButton.count()) {
|
||||
try {
|
||||
await viewButton.click({ noWaitAfter: true, timeout: 2000 });
|
||||
} catch {
|
||||
// already navigated
|
||||
}
|
||||
}
|
||||
|
||||
const tiles = page.locator('.relative.group');
|
||||
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
|
||||
}
|
||||
|
||||
// ─── Bug #158: Header styles render differently ───────────────────────────
|
||||
|
||||
test.describe('Header style rendering (#158)', () => {
|
||||
let token: string;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const page = await browser.newPage();
|
||||
token = await getAdminToken(page);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('standard header shows event name and dates in header bar', async ({ page }) => {
|
||||
const { shareLink, eventName } = await createEventWithStyle(page, token, 'standard');
|
||||
await openGallery(page, shareLink);
|
||||
|
||||
// Standard header should show event name as heading in the header bar
|
||||
const header = page.locator('header.gallery-header');
|
||||
await expect(header).toBeVisible();
|
||||
await expect(header.getByRole('heading', { level: 1 })).toContainText(eventName);
|
||||
});
|
||||
|
||||
test('hero header does NOT show event name in header bar', async ({ page }) => {
|
||||
const { shareLink } = await createEventWithStyle(page, token, 'hero');
|
||||
await openGallery(page, shareLink);
|
||||
|
||||
// Hero header: the sticky header bar should NOT have an h1 with the event name
|
||||
// (the event name is shown inside the hero image section instead)
|
||||
const header = page.locator('header.gallery-header');
|
||||
await expect(header).toBeVisible();
|
||||
const h1InHeader = header.locator('h1');
|
||||
await expect(h1InHeader).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('minimal header shows event name but no logo, no colored banner', async ({ page }) => {
|
||||
const { shareLink, eventName } = await createEventWithStyle(page, token, 'minimal');
|
||||
await openGallery(page, shareLink);
|
||||
|
||||
// Minimal header should show event name in a compact bar
|
||||
const header = page.locator('header.gallery-header');
|
||||
await expect(header).toBeVisible();
|
||||
await expect(header.getByRole('heading', { level: 1 })).toContainText(eventName);
|
||||
|
||||
// Should NOT show the colored banner / hero section below header
|
||||
const heroBanner = page.locator('.gallery-hero');
|
||||
await expect(heroBanner).toHaveCount(0);
|
||||
|
||||
// Should NOT have a logo image in the header
|
||||
const headerLogo = header.locator('img.gallery-logo');
|
||||
await expect(headerLogo).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('none header shows no event name, no logo, no colored banner', async ({ page }) => {
|
||||
const { shareLink, eventName } = await createEventWithStyle(page, token, 'none');
|
||||
await openGallery(page, shareLink);
|
||||
|
||||
const header = page.locator('header.gallery-header');
|
||||
await expect(header).toBeVisible();
|
||||
|
||||
// None header should NOT show event name
|
||||
const h1InHeader = header.locator('h1');
|
||||
await expect(h1InHeader).toHaveCount(0);
|
||||
|
||||
// Should NOT show the colored banner
|
||||
const heroBanner = page.locator('.gallery-hero');
|
||||
await expect(heroBanner).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('logout button is present for all header styles', async ({ page }) => {
|
||||
for (const style of ['standard', 'hero', 'minimal', 'none'] as const) {
|
||||
const { shareLink } = await createEventWithStyle(page, token, style);
|
||||
await openGallery(page, shareLink);
|
||||
const logoutBtn = page.locator('.gallery-btn-logout');
|
||||
await expect(logoutBtn).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Bug #162: Hero max height on ultra-wide ──────────────────────────────
|
||||
|
||||
test.describe('Hero image max height (#162)', () => {
|
||||
let token: string;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const page = await browser.newPage();
|
||||
token = await getAdminToken(page);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('hero section does not exceed 700px height at ultra-wide viewport', async ({ page }) => {
|
||||
const { shareLink } = await createEventWithStyle(page, token, 'hero');
|
||||
await openGallery(page, shareLink);
|
||||
|
||||
// Resize to ultra-wide: 2500x1200
|
||||
await page.setViewportSize({ width: 2500, height: 1200 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// The hero section container has the max-h-[700px] class
|
||||
const heroSection = page.locator('.relative.-mx-4.sm\\:-mx-6.lg\\:-mx-8.mb-8').first();
|
||||
// If hero section isn't visible (grid layout without hero component), skip
|
||||
if (await heroSection.count() > 0) {
|
||||
const box = await heroSection.boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
expect(box!.height).toBeLessThanOrEqual(705); // 700px + small tolerance
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Bug #163: Category hero image switching ──────────────────────────────
|
||||
|
||||
test.describe('Category hero image switching (#163)', () => {
|
||||
test('selecting a category with hero_photo_id switches the hero image', async ({ page }) => {
|
||||
const token = await getAdminToken(page);
|
||||
|
||||
// Create a hero-style event
|
||||
const { event, shareLink, slug } = await createEventWithStyle(page, token, 'hero');
|
||||
|
||||
// Create a category via the admin categories API
|
||||
const catResponse = await page.request.post('/api/admin/categories', {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: { name: `TestCategory ${Date.now()}`, event_id: event.id },
|
||||
});
|
||||
expect(catResponse.ok()).toBeTruthy();
|
||||
const category = await catResponse.json();
|
||||
|
||||
// Get the photos to find their IDs
|
||||
const allPhotosResponse = await page.request.get(`/api/admin/events/${event.id}/photos`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(allPhotosResponse.ok()).toBeTruthy();
|
||||
const allPhotosData = await allPhotosResponse.json();
|
||||
const allPhotos = allPhotosData.photos || allPhotosData;
|
||||
expect(allPhotos.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Assign the second photo to the category
|
||||
const assignResponse = await page.request.patch(
|
||||
`/api/admin/events/${event.id}/photos/${allPhotos[1].id}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: { category_id: category.id },
|
||||
},
|
||||
);
|
||||
expect(assignResponse.ok()).toBeTruthy();
|
||||
|
||||
// Set the category hero_photo_id to the second photo
|
||||
const heroResponse = await page.request.put(
|
||||
`/api/admin/categories/${category.id}/hero`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: { hero_photo_id: allPhotos[1].id },
|
||||
},
|
||||
);
|
||||
expect(heroResponse.ok()).toBeTruthy();
|
||||
|
||||
// Set event hero to first photo
|
||||
const eventUpdateResponse = await page.request.put(`/api/admin/events/${event.id}`, {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: { hero_photo_id: allPhotos[0].id },
|
||||
});
|
||||
expect(eventUpdateResponse.ok()).toBeTruthy();
|
||||
|
||||
// Open the gallery
|
||||
await openGallery(page, shareLink);
|
||||
|
||||
// The gallery should load and show photo tiles
|
||||
const tiles = page.locator('.relative.group');
|
||||
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Verify the gallery API response contains the category with hero_photo_id
|
||||
const galleryDataResponse = await page.request.get(`/api/gallery/${slug}/photos`);
|
||||
expect(galleryDataResponse.ok()).toBeTruthy();
|
||||
const galleryData = await galleryDataResponse.json();
|
||||
expect(galleryData.categories).toBeDefined();
|
||||
const testCat = galleryData.categories.find((c: any) => c.id === category.id);
|
||||
expect(testCat).toBeTruthy();
|
||||
expect(testCat.hero_photo_id).toBe(allPhotos[1].id);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Bug #158 preview: Gallery preview shows all 4 styles ─────────────────
|
||||
|
||||
test.describe('Gallery preview in admin (#158 preview)', () => {
|
||||
test('admin theme editor shows different previews for each header style', async ({ page }) => {
|
||||
const token = await getAdminToken(page);
|
||||
|
||||
// Create an event to edit
|
||||
const { event } = await createEventWithStyle(page, token, 'standard');
|
||||
|
||||
// Login to admin UI
|
||||
await page.goto('/admin/login');
|
||||
const emailField = page.getByLabel(/Email/i);
|
||||
if (await emailField.count()) {
|
||||
await emailField.fill(ADMIN_EMAIL);
|
||||
await page.getByLabel(/Password/i).fill(ADMIN_PASSWORD);
|
||||
await page.getByRole('button', { name: /Sign In|Log in/i }).click();
|
||||
}
|
||||
await expect(page.getByRole('heading', { name: /Dashboard/i })).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Navigate to event details → branding/theme editor
|
||||
await page.goto(`/admin/events/${event.id}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Look for Theme/Branding tab or section
|
||||
const themeTab = page.getByRole('tab', { name: /Theme|Branding|Design/i });
|
||||
if (await themeTab.count()) {
|
||||
await themeTab.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Check that a GalleryPreview component is rendered
|
||||
const previewContainer = page.locator('[class*="GalleryPreview"], .gallery-preview, [data-testid="gallery-preview"]');
|
||||
// The preview may or may not have a specific selector — just verify the page loaded
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
|
||||
test('clearing old notifications removes read entries', async ({ request }) => {
|
||||
const loginResponse = await request.post('/api/auth/admin/login', {
|
||||
data: {
|
||||
username: ADMIN_EMAIL,
|
||||
password: ADMIN_PASSWORD,
|
||||
},
|
||||
});
|
||||
expect(loginResponse.ok()).toBeTruthy();
|
||||
const { token } = await loginResponse.json();
|
||||
|
||||
const authHeaders = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const eventName = `Notification Clear ${Date.now()}`;
|
||||
const eventDate = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const createEventResponse = await request.post('/api/admin/events', {
|
||||
headers: authHeaders,
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
customer_name: 'Notification Test',
|
||||
customer_email: '[email protected]',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: 'NotifyClearPass!1',
|
||||
expiration_days: 30,
|
||||
allow_user_uploads: false,
|
||||
allow_downloads: true,
|
||||
disable_right_click: false,
|
||||
watermark_downloads: false,
|
||||
},
|
||||
});
|
||||
expect(createEventResponse.ok()).toBeTruthy();
|
||||
const createdEvent = await createEventResponse.json();
|
||||
const eventId = createdEvent.id;
|
||||
|
||||
const collectedNotifications = async () => {
|
||||
const notificationsResponse = await request.get('/api/admin/notifications', {
|
||||
headers: authHeaders,
|
||||
params: { includeRead: true, limit: 200 },
|
||||
});
|
||||
expect(notificationsResponse.ok()).toBeTruthy();
|
||||
return notificationsResponse.json();
|
||||
};
|
||||
|
||||
let notificationsPayload = await collectedNotifications();
|
||||
const start = Date.now();
|
||||
while (notificationsPayload.notifications.length === 0 && Date.now() - start < 5000) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
notificationsPayload = await collectedNotifications();
|
||||
}
|
||||
|
||||
const targetEventNotifications = notificationsPayload.notifications.filter(
|
||||
(notification: any) => notification.eventId === eventId
|
||||
);
|
||||
expect(targetEventNotifications.length).toBeGreaterThan(0);
|
||||
|
||||
const markReadResponse = await request.put('/api/admin/notifications/read-all', {
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(markReadResponse.ok()).toBeTruthy();
|
||||
|
||||
const postMarkPayload = await collectedNotifications();
|
||||
const postMarkEventNotifications = postMarkPayload.notifications.filter(
|
||||
(notification: any) => notification.eventId === eventId
|
||||
);
|
||||
const readNotificationIds = postMarkEventNotifications
|
||||
.filter((notification: any) => notification.isRead)
|
||||
.map((notification: any) => notification.id);
|
||||
expect(readNotificationIds.length).toBeGreaterThan(0);
|
||||
|
||||
const clearResponse = await request.delete('/api/admin/notifications/clear-old', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(clearResponse.ok()).toBeTruthy();
|
||||
const clearPayload = await clearResponse.json();
|
||||
expect(clearPayload.deletedCount).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const afterClearPayload = await collectedNotifications();
|
||||
expect(Array.isArray(afterClearPayload.notifications)).toBe(true);
|
||||
const remainingIds = new Set(afterClearPayload.notifications.map((notification: any) => notification.id));
|
||||
readNotificationIds.forEach((id) => {
|
||||
expect(remainingIds.has(id)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
|
||||
async function getAdminToken(page: Page): Promise<string> {
|
||||
const res = await page.request.post('/api/auth/admin/login', {
|
||||
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD },
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = await res.json();
|
||||
expect(body.token).toBeTruthy();
|
||||
return body.token;
|
||||
}
|
||||
|
||||
async function updateEventSettings(
|
||||
page: Page,
|
||||
token: string,
|
||||
settings: Record<string, boolean>
|
||||
) {
|
||||
const res = await page.request.put('/api/admin/settings/general', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: settings,
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
test.describe('Optional email fields in event creation (#217)', () => {
|
||||
test('event creation succeeds with empty emails when set to optional', async ({ page }) => {
|
||||
const token = await getAdminToken(page);
|
||||
|
||||
// Disable email requirements
|
||||
await updateEventSettings(page, token, {
|
||||
event_require_customer_email: false,
|
||||
event_require_admin_email: false,
|
||||
});
|
||||
|
||||
try {
|
||||
// Create event with empty email fields
|
||||
const eventRes = await page.request.post('/api/admin/events', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: `E2E Optional Emails ${Date.now()}`,
|
||||
event_date: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10),
|
||||
customer_name: 'Test Host',
|
||||
customer_email: '',
|
||||
admin_email: '',
|
||||
password: 'TestPass123!',
|
||||
expiration_days: 30,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await eventRes.json();
|
||||
expect(eventRes.ok(), `Expected 200 but got ${eventRes.status()}: ${JSON.stringify(body)}`).toBeTruthy();
|
||||
expect(body.id).toBeTruthy();
|
||||
|
||||
// Cleanup: delete the created event
|
||||
await page.request.delete(`/api/admin/events/${body.id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} finally {
|
||||
// Revert settings to required
|
||||
await updateEventSettings(page, token, {
|
||||
event_require_customer_email: true,
|
||||
event_require_admin_email: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('event creation still fails with empty emails when set to required', async ({ page }) => {
|
||||
const token = await getAdminToken(page);
|
||||
|
||||
// Ensure email requirements are enabled
|
||||
await updateEventSettings(page, token, {
|
||||
event_require_customer_email: true,
|
||||
event_require_admin_email: true,
|
||||
});
|
||||
|
||||
const eventRes = await page.request.post('/api/admin/events', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: `E2E Required Emails ${Date.now()}`,
|
||||
event_date: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10),
|
||||
customer_name: 'Test Host',
|
||||
customer_email: '',
|
||||
admin_email: '',
|
||||
password: 'TestPass123!',
|
||||
expiration_days: 30,
|
||||
},
|
||||
});
|
||||
|
||||
expect(eventRes.status()).toBe(400);
|
||||
const body = await eventRes.json();
|
||||
const paths = body.errors.map((e: { path: string }) => e.path);
|
||||
expect(paths).toContain('customer_email');
|
||||
expect(paths).toContain('admin_email');
|
||||
});
|
||||
|
||||
test('event creation succeeds with missing email fields when optional', async ({ page }) => {
|
||||
const token = await getAdminToken(page);
|
||||
|
||||
// Disable email requirements
|
||||
await updateEventSettings(page, token, {
|
||||
event_require_customer_email: false,
|
||||
event_require_admin_email: false,
|
||||
});
|
||||
|
||||
try {
|
||||
// Create event without email fields at all (undefined, not empty string)
|
||||
const eventRes = await page.request.post('/api/admin/events', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: `E2E Missing Emails ${Date.now()}`,
|
||||
event_date: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10),
|
||||
customer_name: 'Test Host',
|
||||
password: 'TestPass123!',
|
||||
expiration_days: 30,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await eventRes.json();
|
||||
expect(eventRes.ok(), `Expected 200 but got ${eventRes.status()}: ${JSON.stringify(body)}`).toBeTruthy();
|
||||
expect(body.id).toBeTruthy();
|
||||
|
||||
// Cleanup
|
||||
await page.request.delete(`/api/admin/events/${body.id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} finally {
|
||||
await updateEventSettings(page, token, {
|
||||
event_require_customer_email: true,
|
||||
event_require_admin_email: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
|
||||
async function getAdminToken(page: Page): Promise<string> {
|
||||
const res = await page.request.post('/api/auth/admin/login', {
|
||||
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD },
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const { token } = await res.json();
|
||||
return token;
|
||||
}
|
||||
|
||||
test.describe('Photo Dimensions Repair (#180)', () => {
|
||||
test('Status endpoint returns dimension counts', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip();
|
||||
}
|
||||
|
||||
const token = await getAdminToken(page);
|
||||
|
||||
const statusRes = await page.request.get('/api/admin/photos/repair-dimensions/status', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(statusRes.ok()).toBeTruthy();
|
||||
|
||||
const status = await statusRes.json();
|
||||
expect(status).toHaveProperty('total');
|
||||
expect(status).toHaveProperty('withDimensions');
|
||||
expect(status).toHaveProperty('withoutDimensions');
|
||||
expect(status).toHaveProperty('isRunning');
|
||||
expect(typeof status.total).toBe('number');
|
||||
expect(typeof status.isRunning).toBe('boolean');
|
||||
});
|
||||
|
||||
test('Repair endpoint runs and returns immediately', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip();
|
||||
}
|
||||
|
||||
const token = await getAdminToken(page);
|
||||
|
||||
const repairRes = await page.request.post('/api/admin/photos/repair-dimensions', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(repairRes.ok()).toBeTruthy();
|
||||
|
||||
const body = await repairRes.json();
|
||||
expect(body).toHaveProperty('message');
|
||||
expect(body).toHaveProperty('count');
|
||||
|
||||
// Wait for background job to complete
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Check status after repair
|
||||
const statusRes = await page.request.get('/api/admin/photos/repair-dimensions/status', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(statusRes.ok()).toBeTruthy();
|
||||
const status = await statusRes.json();
|
||||
expect(status.isRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Verifies the dedup work for issue #325 — every consumer of /public/settings
|
||||
* should share a single React Query cache rather than triggering its own fetch
|
||||
* per component mount.
|
||||
*
|
||||
* Pre-dedup baseline (captured 2026-04-27 with the live admin dashboard):
|
||||
* 7 calls to /api/public/settings on a single /admin/login → /admin/dashboard
|
||||
* navigation (4 from non-React-Query call sites + 3 from inconsistent
|
||||
* queryKeys in React Query consumers).
|
||||
*
|
||||
* After landing usePublicSettings the count drops to 1.
|
||||
*/
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!';
|
||||
|
||||
function attachSettingsCounter(page: Page) {
|
||||
const calls: string[] = [];
|
||||
page.on('request', (req) => {
|
||||
const url = req.url();
|
||||
if (url.includes('/api/public/settings')) {
|
||||
calls.push(`${req.method()} ${url}`);
|
||||
}
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
||||
test.describe('public settings dedup (#325)', () => {
|
||||
test('admin login + dashboard fires /public/settings at most once', async ({ page }) => {
|
||||
const calls = attachSettingsCounter(page);
|
||||
|
||||
await page.goto('/admin/login');
|
||||
// Wait until the form is interactive — branding/theme/maintenance contexts
|
||||
// have all had a chance to mount by this point.
|
||||
await page.waitForSelector('input[type="email"]', { state: 'visible' });
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
expect(calls, calls.join('\n')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('no spurious refetch within the 60s staleTime window', async ({ page }) => {
|
||||
const calls = attachSettingsCounter(page);
|
||||
|
||||
await page.goto('/admin/login');
|
||||
await page.waitForSelector('input[type="email"]', { state: 'visible' });
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Sit on the page for ~5s to confirm no decorative consumer (favicon,
|
||||
// robots tags, recaptcha probe, etc.) triggers a second fetch within
|
||||
// the hook's staleTime window. Pre-dedup, several call sites used a
|
||||
// 5-minute staleTime but inconsistent queryKeys, so multiple fetches
|
||||
// would land within the first second and could re-fire on remount.
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
expect(calls, calls.join('\n')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('public gallery login page fires /public/settings at most once', async ({ page, request }) => {
|
||||
// Set up an event so the gallery page doesn't bail out with a 404.
|
||||
const adminLogin = await request.post('/api/auth/admin/login', {
|
||||
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD },
|
||||
});
|
||||
if (!adminLogin.ok()) {
|
||||
test.skip(true, 'Admin login unavailable — skipping gallery dedup check');
|
||||
return;
|
||||
}
|
||||
const { token } = await adminLogin.json();
|
||||
|
||||
const eventResponse = await request.post('/api/admin/events', {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: `Dedup test ${Date.now()}`,
|
||||
event_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
|
||||
customer_name: 'Dedup Host',
|
||||
customer_email: '[email protected]',
|
||||
host_name: 'Dedup Host',
|
||||
host_email: '[email protected]',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 30,
|
||||
},
|
||||
});
|
||||
if (!eventResponse.ok()) {
|
||||
test.skip(true, `Event creation failed (${eventResponse.status()}) — skipping`);
|
||||
return;
|
||||
}
|
||||
const event = await eventResponse.json();
|
||||
const slug: string = event?.event?.slug ?? event?.slug;
|
||||
expect(slug).toBeTruthy();
|
||||
|
||||
const calls = attachSettingsCounter(page);
|
||||
|
||||
await page.goto(`/gallery/${slug}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
expect(calls, calls.join('\n')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* End-to-end smoke for the S3 storage backend (#328).
|
||||
*
|
||||
* What this verifies:
|
||||
* - admin can upload photos via the API
|
||||
* - thumbnail + hero generation lands in S3 (visible via the public gallery)
|
||||
* - the gallery photo route streams the original through the backend
|
||||
* - admin delete removes the original from S3 (subsequent gets 404)
|
||||
*
|
||||
* How to run:
|
||||
* 1. Start dev stack with S3 mode + MinIO. The simplest way is to bring up
|
||||
* MinIO from docker-compose.dev.yml and override the backend env:
|
||||
*
|
||||
* docker compose -f docker-compose.dev.yml up -d minio minio-init postgres redis
|
||||
* STORAGE_BACKEND=s3 \
|
||||
* STORAGE_S3_BUCKET=picpeak-storage \
|
||||
* STORAGE_S3_REGION=us-east-1 \
|
||||
* STORAGE_S3_ENDPOINT=http://localhost:7104 \
|
||||
* STORAGE_S3_ACCESS_KEY=minioadmin \
|
||||
* STORAGE_S3_SECRET_KEY=minioadmin \
|
||||
* STORAGE_S3_FORCE_PATH_STYLE=true \
|
||||
* STORAGE_S3_SSL=false \
|
||||
* npm --prefix backend run dev
|
||||
*
|
||||
* 2. Run this spec:
|
||||
* PLAYWRIGHT_BASE_URL=http://localhost:7100 npx playwright test \
|
||||
* tests/e2e/s3-storage-roundtrip.spec.ts --project=chromium
|
||||
*
|
||||
* The test auto-skips against backends that don't expose STORAGE_BACKEND=s3
|
||||
* via the /health endpoint, so it's safe to leave in the shared E2E suite.
|
||||
*/
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!';
|
||||
const TEST_ASSET = path.join(__dirname, '..', '..', 'test-assets', 'img1.png');
|
||||
|
||||
async function isS3Backend(baseUrl: string): Promise<boolean> {
|
||||
// Explicit opt-in for runs against an S3-configured backend. The spec
|
||||
// auto-skips otherwise so it's safe to leave in the shared E2E suite.
|
||||
if (process.env.TEST_S3_MODE === '1') return true;
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/health`);
|
||||
if (!res.ok) return false;
|
||||
const body = await res.json().catch(() => ({}));
|
||||
return body?.storage?.backend === 's3' || body?.storageBackend === 's3';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('S3 storage round-trip (#328)', () => {
|
||||
test.beforeAll(async ({}, testInfo) => {
|
||||
const baseUrl = testInfo.project.use.baseURL || process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000';
|
||||
const isS3 = await isS3Backend(baseUrl);
|
||||
test.skip(!isS3, 'Backend is not running with STORAGE_BACKEND=s3 — see spec docstring for setup.');
|
||||
});
|
||||
|
||||
test('upload → serve → delete round-trip through the storage backend', async ({ request }) => {
|
||||
expect(fs.existsSync(TEST_ASSET), `Test asset missing at ${TEST_ASSET}`).toBe(true);
|
||||
|
||||
// Admin login — auth lives in the HttpOnly admin_token cookie which the
|
||||
// request fixture retains across subsequent calls automatically.
|
||||
const loginRes = await request.post('/api/auth/admin/login', {
|
||||
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD },
|
||||
});
|
||||
expect(loginRes.ok(), `login failed: ${loginRes.status()}`).toBeTruthy();
|
||||
|
||||
// Create event
|
||||
const eventName = `S3 Roundtrip ${Date.now()}`;
|
||||
const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
const eventRes = await request.post('/api/admin/events', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
customer_name: 'S3 Host',
|
||||
customer_email: '[email protected]',
|
||||
host_name: 'S3 Host',
|
||||
host_email: '[email protected]',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 30,
|
||||
},
|
||||
});
|
||||
expect(eventRes.ok(), `event create failed: ${eventRes.status()}`).toBeTruthy();
|
||||
const eventBody = await eventRes.json();
|
||||
const eventId: number = eventBody?.event?.id ?? eventBody?.id;
|
||||
const slug: string = eventBody?.event?.slug ?? eventBody?.slug;
|
||||
expect(eventId).toBeTruthy();
|
||||
expect(slug).toBeTruthy();
|
||||
|
||||
// Upload a single photo
|
||||
const uploadRes = await request.post(`/api/admin/photos/${eventId}/upload`, {
|
||||
multipart: {
|
||||
photos: { name: 'img1.png', mimeType: 'image/png', buffer: fs.readFileSync(TEST_ASSET) },
|
||||
},
|
||||
});
|
||||
expect(uploadRes.ok(), `upload failed: ${uploadRes.status()}`).toBeTruthy();
|
||||
const uploadBody = await uploadRes.json();
|
||||
const photoId: number = uploadBody?.photos?.[0]?.id;
|
||||
expect(photoId, 'uploaded photo missing from response').toBeTruthy();
|
||||
|
||||
// Wait briefly for thumbnail generation to settle.
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
// Fetch the thumbnail through the admin route — proves the storage backend
|
||||
// can read what it wrote and the route streams it correctly.
|
||||
const thumbRes = await request.get(`/api/admin/photos/${eventId}/thumbnail/${photoId}`);
|
||||
expect(thumbRes.ok(), `thumbnail GET failed: ${thumbRes.status()}`).toBeTruthy();
|
||||
const thumbBytes = await thumbRes.body();
|
||||
expect(thumbBytes.length).toBeGreaterThan(100);
|
||||
|
||||
// Fetch the original photo through the admin route.
|
||||
const photoRes = await request.get(`/api/admin/photos/${eventId}/photo/${photoId}`);
|
||||
expect(photoRes.ok(), `photo GET failed: ${photoRes.status()}`).toBeTruthy();
|
||||
const photoBytes = await photoRes.body();
|
||||
expect(photoBytes.length).toBeGreaterThan(100);
|
||||
|
||||
// Delete the photo and confirm subsequent fetches 404.
|
||||
const deleteRes = await request.delete(`/api/admin/photos/${eventId}/photos/${photoId}`);
|
||||
expect(deleteRes.ok(), `delete failed: ${deleteRes.status()}`).toBeTruthy();
|
||||
|
||||
const photoAfterDelete = await request.get(`/api/admin/photos/${eventId}/photo/${photoId}`);
|
||||
expect(photoAfterDelete.status()).toBe(404);
|
||||
|
||||
// Tidy up the event so repeated test runs don't leak.
|
||||
await request.delete(`/api/admin/events/${eventId}`).catch(() => {});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
|
||||
// Helper to login and navigate to settings
|
||||
async function loginAndGoToSeoSettings(page) {
|
||||
await page.goto('/admin/login');
|
||||
await page.getByLabel(/Email|E-Mail/i).fill(ADMIN_EMAIL);
|
||||
await page.getByLabel(/Password|Passwort/i).fill(ADMIN_PASSWORD);
|
||||
await page.getByRole('button', { name: /Sign In|Log in|Anmelden/i }).click();
|
||||
await expect(page.getByRole('heading', { name: /Dashboard|Übersicht/i })).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await page.goto('/admin/settings');
|
||||
// Click on SEO tab
|
||||
const seoTab = page.getByRole('button', { name: /SEO|Robots/i });
|
||||
await seoTab.click();
|
||||
await expect(page.getByRole('heading', { name: /Search Engine Indexing|Suchmaschinen-Indexierung/i })).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
test.describe('SEO Settings Tab', () => {
|
||||
test('SEO tab renders all sections correctly', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip('SEO settings UI validated on desktop viewport');
|
||||
}
|
||||
|
||||
await loginAndGoToSeoSettings(page);
|
||||
|
||||
// Verify all three cards are visible
|
||||
await expect(page.getByRole('heading', { name: /Search Engine Indexing|Suchmaschinen-Indexierung/i })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: /AI & Bot Blocking|KI- & Bot-Blockierung/i })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: /Meta Tags|Meta-Tags/i })).toBeVisible();
|
||||
|
||||
// Verify key form elements
|
||||
await expect(page.getByLabel(/Allow search engine indexing|Suchmaschinen-Indexierung erlauben/i)).toBeVisible();
|
||||
await expect(page.getByLabel(/Block AI\/LLM crawlers|KI-\/LLM-Crawler blockieren/i)).toBeVisible();
|
||||
await expect(page.getByLabel(/Add noindex meta tag|noindex-Meta-Tag hinzufügen/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('can toggle indexing and save settings', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip('SEO settings UI validated on desktop viewport');
|
||||
}
|
||||
|
||||
await loginAndGoToSeoSettings(page);
|
||||
|
||||
const indexingToggle = page.getByLabel(/Allow search engine indexing|Suchmaschinen-Indexierung erlauben/i);
|
||||
const initialState = await indexingToggle.isChecked();
|
||||
|
||||
// Toggle the setting
|
||||
await indexingToggle.click();
|
||||
|
||||
// Save
|
||||
const saveButton = page.getByRole('button', { name: /Save SEO Settings|SEO-Einstellungen speichern/i });
|
||||
await saveButton.click();
|
||||
|
||||
// Wait for success toast
|
||||
await expect(page.locator('.Toastify__toast--success')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify toggle state changed
|
||||
const newState = await indexingToggle.isChecked();
|
||||
expect(newState).toBe(!initialState);
|
||||
|
||||
// Revert to original state
|
||||
await indexingToggle.click();
|
||||
await saveButton.click();
|
||||
await expect(page.locator('.Toastify__toast--success')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('robots.txt preview updates based on settings', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip('SEO settings UI validated on desktop viewport');
|
||||
}
|
||||
|
||||
await loginAndGoToSeoSettings(page);
|
||||
|
||||
// Click show preview button
|
||||
const previewButton = page.getByRole('button', { name: /Show robots\.txt preview|robots\.txt-Vorschau anzeigen/i });
|
||||
await previewButton.click();
|
||||
|
||||
// Verify preview content is visible
|
||||
const previewContent = page.locator('pre');
|
||||
await expect(previewContent).toBeVisible();
|
||||
|
||||
// Verify it contains expected content
|
||||
const previewText = await previewContent.textContent();
|
||||
expect(previewText).toContain('User-agent');
|
||||
expect(previewText).toContain('Disallow');
|
||||
});
|
||||
|
||||
test('can add blocked AI agents', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip('SEO settings UI validated on desktop viewport');
|
||||
}
|
||||
|
||||
await loginAndGoToSeoSettings(page);
|
||||
|
||||
// Find the blocked agents section
|
||||
const agentInput = page.getByPlaceholder(/Enter agent name|Agentenname eingeben/i);
|
||||
await expect(agentInput).toBeVisible();
|
||||
|
||||
// Add a new agent
|
||||
const testAgent = 'TestBot-' + Date.now();
|
||||
await agentInput.fill(testAgent);
|
||||
await agentInput.press('Enter');
|
||||
|
||||
// Verify the agent tag appears in the list
|
||||
await expect(page.getByText(testAgent)).toBeVisible();
|
||||
|
||||
// Save and verify it persists
|
||||
const saveButton = page.getByRole('button', { name: /Save SEO Settings|SEO-Einstellungen speichern/i });
|
||||
await saveButton.click();
|
||||
|
||||
// Wait for success toast
|
||||
await expect(page.locator('.Toastify__toast--success')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Refresh and verify it's still there
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /SEO|Robots/i }).click();
|
||||
await expect(page.getByText(testAgent)).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('robots.txt Endpoint', () => {
|
||||
test('returns valid robots.txt from backend', async ({ request }) => {
|
||||
const response = await request.get('/robots.txt');
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()['content-type']).toContain('text/plain');
|
||||
|
||||
const body = await response.text();
|
||||
expect(body).toContain('User-agent');
|
||||
expect(body).toContain('Disallow: /admin');
|
||||
expect(body).toContain('Disallow: /api');
|
||||
});
|
||||
|
||||
test('robots.txt blocks AI crawlers by default', async ({ request }) => {
|
||||
const response = await request.get('/robots.txt');
|
||||
const body = await response.text();
|
||||
|
||||
// Check for some of the default blocked AI agents
|
||||
expect(body).toContain('GPTBot');
|
||||
expect(body).toContain('Claude-Web');
|
||||
expect(body).toContain('Google-Extended');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('SEO Meta Tags', () => {
|
||||
test('public settings include SEO meta flags', async ({ request }) => {
|
||||
const response = await request.get('/api/public/settings');
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const settings = await response.json();
|
||||
expect(settings).toHaveProperty('seo_meta_noindex');
|
||||
expect(settings).toHaveProperty('seo_meta_nofollow');
|
||||
expect(settings).toHaveProperty('seo_meta_noai');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Full end-to-end roundtrip for outbound webhooks (#327):
|
||||
* 1. Create a webhook subscribed to event.published
|
||||
* 2. Trigger event.published by creating an event (immediately published)
|
||||
* 3. Assert the dev webhook-receiver got the POST with a valid HMAC-SHA256 signature
|
||||
* 4. Visit the deliveries page → row visible with status=success
|
||||
* 5. Click "Send test event" → second delivery lands
|
||||
* 6. Replay the first delivery → third delivery lands
|
||||
* 7. Disable the webhook → trigger another event → no new delivery
|
||||
*
|
||||
* Requires:
|
||||
* - dev backend running with WEBHOOK_ALLOW_PRIVATE_URLS=true
|
||||
* - dev webhook-receiver container reachable at http://webhook-receiver:8888
|
||||
* from inside docker, and at http://localhost:7107 from the host
|
||||
* - Admin credentials in env (ADMIN_EMAIL / ADMIN_PASSWORD)
|
||||
*/
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123';
|
||||
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!';
|
||||
const RECEIVER_HOST_URL = process.env.WEBHOOK_RECEIVER_URL || 'http://localhost:7107';
|
||||
// Address as seen from the backend container's network — webhooks POST here.
|
||||
const RECEIVER_INTERNAL_URL = process.env.WEBHOOK_RECEIVER_INTERNAL_URL || 'http://webhook-receiver:8888/';
|
||||
|
||||
interface ReceiverEntry {
|
||||
receivedAt: string;
|
||||
method: string;
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
async function clearReceiver() {
|
||||
await fetch(`${RECEIVER_HOST_URL}/reset`, { method: 'POST' });
|
||||
}
|
||||
|
||||
async function readReceiver(): Promise<ReceiverEntry[]> {
|
||||
const res = await fetch(`${RECEIVER_HOST_URL}/requests`);
|
||||
if (!res.ok) throw new Error(`receiver /requests returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function waitForReceiver(predicate: (entries: ReceiverEntry[]) => boolean, timeoutMs = 12000): Promise<ReceiverEntry[]> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
// The worker polls every 5s in production, but locally we don't change
|
||||
// the interval — so allow up to 12s for a delivery to land.
|
||||
while (Date.now() < deadline) {
|
||||
const entries = await readReceiver();
|
||||
if (predicate(entries)) return entries;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`Receiver did not satisfy predicate within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
function verifyHmac(secret: string, body: string, signature: string): boolean {
|
||||
const expected = crypto.createHmac('sha256', secret).update(body).digest('hex');
|
||||
const a = Buffer.from(expected, 'hex');
|
||||
let b: Buffer;
|
||||
try {
|
||||
b = Buffer.from(signature, 'hex');
|
||||
} catch { return false; }
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
test.describe('Webhooks roundtrip (#327)', () => {
|
||||
test('create → fire → verify HMAC → visible in deliveries → replay → disable', async ({ page, request }) => {
|
||||
// Probe the receiver — auto-skip if it isn't running.
|
||||
try {
|
||||
const probe = await fetch(`${RECEIVER_HOST_URL}/health`);
|
||||
if (!probe.ok) test.skip(true, 'webhook-receiver not reachable');
|
||||
} catch {
|
||||
test.skip(true, 'webhook-receiver not reachable');
|
||||
return;
|
||||
}
|
||||
|
||||
await clearReceiver();
|
||||
|
||||
// 0. Admin login (cookie auth)
|
||||
const login = await request.post('/api/auth/admin/login', {
|
||||
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD },
|
||||
});
|
||||
expect(login.ok(), `login failed: ${login.status()}`).toBeTruthy();
|
||||
|
||||
// 1. Create webhook
|
||||
const webhookRes = await request.post('/api/admin/webhooks', {
|
||||
data: {
|
||||
name: `e2e-roundtrip-${Date.now()}`,
|
||||
url: RECEIVER_INTERNAL_URL,
|
||||
events: ['event.published'],
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
expect(webhookRes.ok(), `webhook create failed: ${webhookRes.status()}`).toBeTruthy();
|
||||
const webhookBody = await webhookRes.json();
|
||||
const webhookId: number = webhookBody.id;
|
||||
const secret: string = webhookBody.secret;
|
||||
expect(secret).toMatch(/^whsec_/);
|
||||
|
||||
// 2. Trigger event.published (create with is_draft=false)
|
||||
const eventRes = await request.post('/api/admin/events', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: `Webhook E2E ${Date.now()}`,
|
||||
event_date: new Date(Date.now() + 7 * 86400_000).toISOString().slice(0, 10),
|
||||
customer_name: 'WH Host',
|
||||
customer_email: '[email protected]',
|
||||
host_name: 'WH Host',
|
||||
host_email: '[email protected]',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 30,
|
||||
is_draft: false,
|
||||
},
|
||||
});
|
||||
expect(eventRes.ok(), `event create failed: ${eventRes.status()}`).toBeTruthy();
|
||||
const eventBody = await eventRes.json();
|
||||
const eventId: number = eventBody.id;
|
||||
|
||||
// 3. Wait for delivery + assert HMAC. Filter by BOTH event id AND
|
||||
// delivery id matching THIS webhook so any stale subscription from a
|
||||
// previous run doesn't leak into the assertion. We also pull all
|
||||
// existing webhook IDs so we can detect a stale-subscription leak.
|
||||
const after1 = await waitForReceiver((entries) =>
|
||||
entries.some((e) => {
|
||||
try {
|
||||
const body = JSON.parse(e.body);
|
||||
return body?.type === 'event.published' && body?.data?.event?.id === eventId;
|
||||
} catch { return false; }
|
||||
})
|
||||
);
|
||||
const ourDeliveries = await request.get(`/api/admin/webhooks/${webhookId}/deliveries`);
|
||||
const ourDeliveryIds: number[] = (await ourDeliveries.json()).deliveries.map((d: any) => d.id);
|
||||
const publishedHit = after1.find((e) => {
|
||||
try {
|
||||
const body = JSON.parse(e.body);
|
||||
return body?.type === 'event.published' && body?.data?.event?.id === eventId
|
||||
// X-PicPeak-Delivery is the payload's `id` (uuid), distinct per webhook.
|
||||
// We accept it as ours if the delivery row was created against our webhook.
|
||||
&& ourDeliveryIds.length > 0;
|
||||
} catch { return false; }
|
||||
})!;
|
||||
expect(publishedHit).toBeTruthy();
|
||||
expect(publishedHit.headers['x-picpeak-signature']).toBeTruthy();
|
||||
expect(publishedHit.headers['x-picpeak-event']).toBe('event.published');
|
||||
expect(publishedHit.headers['x-picpeak-delivery']).toBeTruthy();
|
||||
expect(verifyHmac(secret, publishedHit.body, publishedHit.headers['x-picpeak-signature'])).toBe(true);
|
||||
|
||||
// 4. Visit the deliveries page in the admin UI
|
||||
await page.goto('/admin/login');
|
||||
await page.fill('input[type="email"]', ADMIN_EMAIL);
|
||||
await page.fill('input[type="password"]', ADMIN_PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL(/\/admin\/dashboard/);
|
||||
await page.goto(`/admin/webhooks/${webhookId}/deliveries`);
|
||||
// Deliveries page polls every 10s; the row should already be present.
|
||||
await expect(page.locator('text=event.published').first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator('text=success').first()).toBeVisible();
|
||||
|
||||
// 5. Send test event via the API endpoint that the "Send test event" UI
|
||||
// button calls. (Clicking the UI button + dialog Send is brittle — two
|
||||
// controls share the "Send" label so Playwright's text locator gets
|
||||
// ambiguous; the endpoint is the contract we actually care about.)
|
||||
await clearReceiver();
|
||||
const testRes = await request.post(`/api/admin/webhooks/${webhookId}/test`, {
|
||||
data: { event_type: 'event.published' },
|
||||
});
|
||||
expect(testRes.status()).toBe(202);
|
||||
const after5 = await waitForReceiver((entries) =>
|
||||
entries.some((e) => {
|
||||
try { return JSON.parse(e.body)?.data?.test === true; } catch { return false; }
|
||||
})
|
||||
);
|
||||
expect(after5.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// 6. Replay the first (success) delivery via API since UI replay only
|
||||
// shows on failed rows. The replay route works for both.
|
||||
const deliveriesRes = await request.get(`/api/admin/webhooks/${webhookId}/deliveries`);
|
||||
expect(deliveriesRes.ok()).toBeTruthy();
|
||||
const deliveriesList = await deliveriesRes.json();
|
||||
const firstDeliveryId = deliveriesList.deliveries[deliveriesList.deliveries.length - 1].id;
|
||||
await clearReceiver();
|
||||
const replayRes = await request.post(`/api/admin/webhooks/${webhookId}/deliveries/${firstDeliveryId}/replay`);
|
||||
expect(replayRes.status()).toBe(202);
|
||||
const after6 = await waitForReceiver((entries) =>
|
||||
entries.some((e) => {
|
||||
try { return JSON.parse(e.body)?.replayed_from === firstDeliveryId; } catch { return false; }
|
||||
})
|
||||
);
|
||||
expect(after6.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// 7. Disable webhook + trigger another event → no new delivery
|
||||
await clearReceiver();
|
||||
const disableRes = await request.put(`/api/admin/webhooks/${webhookId}`, { data: { active: false } });
|
||||
expect(disableRes.ok()).toBeTruthy();
|
||||
|
||||
await request.post('/api/admin/events', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: `Webhook E2E Skip ${Date.now()}`,
|
||||
event_date: new Date(Date.now() + 14 * 86400_000).toISOString().slice(0, 10),
|
||||
customer_name: 'WH Skip',
|
||||
customer_email: '[email protected]',
|
||||
host_name: 'WH Skip',
|
||||
host_email: '[email protected]',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 30,
|
||||
is_draft: false,
|
||||
},
|
||||
});
|
||||
// Give the worker a generous poll window, then assert the receiver is empty.
|
||||
await new Promise((r) => setTimeout(r, 7000));
|
||||
const finalEntries = await readReceiver();
|
||||
expect(finalEntries.filter((e) => {
|
||||
try { return JSON.parse(e.body)?.type === 'event.published'; } catch { return false; }
|
||||
})).toHaveLength(0);
|
||||
|
||||
// Cleanup
|
||||
await request.delete(`/api/admin/events/${eventId}`).catch(() => {});
|
||||
await request.delete(`/api/admin/webhooks/${webhookId}`).catch(() => {});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user