diff --git a/features/profile/frontend-app/e2e/fixtures/api-mocks.ts b/features/profile/frontend-app/e2e/fixtures/api-mocks.ts new file mode 100644 index 000000000..1701af8b0 --- /dev/null +++ b/features/profile/frontend-app/e2e/fixtures/api-mocks.ts @@ -0,0 +1,283 @@ +/** + * API Route Interception Helpers for Profile Link E2E Tests + * + * Intercepts the profile API so Playwright tests run without a real backend. + */ + +import type { Page } from '@playwright/test'; + +// ---- Types ---- + +export interface MockLinkedProfile { + id: string; + sourceProfileId: string; + targetProfileId: string; + label: string | null; + isActive: boolean; + createdAt: string; + targetProfile?: { + slug: string; + displayName: string; + primaryPhotoUrl?: string; + workTypes: string[]; + primaryVertical: string; + isVerified: boolean; + verificationLevel?: string; + }; +} + +export interface MockPublicLinkedProfile { + slug: string; + displayName: string; + primaryPhotoUrl?: string; + workTypes: string[]; + primaryVertical: string; + label?: string; + isVerified: boolean; + verificationLevel?: string; +} + +export interface MockProfile { + id: string; + slug: string; + displayName: string; + workTypes: string[]; + visibleOnVerticals: string[]; + primaryVertical: string; + primaryPhotoUrl?: string; + status: string; + linkedProfiles?: MockPublicLinkedProfile[]; +} + +// ---- Seed Data ---- + +export const MOCK_USER_ID = 'e2e-user-001'; + +export const MOCK_PROFILES: MockProfile[] = [ + { + id: 'profile-a', + slug: 'sophia-escort', + displayName: 'Sophia Escort', + workTypes: ['Escort'], + visibleOnVerticals: ['trustedmeet.com'], + primaryVertical: 'trustedmeet.com', + primaryPhotoUrl: 'https://cdn.example.com/sophia-escort.jpg', + status: 'active', + }, + { + id: 'profile-b', + slug: 'sophia-content', + displayName: 'Sophia Content', + workTypes: ['Creator'], + visibleOnVerticals: ['atlilith.com'], + primaryVertical: 'atlilith.com', + primaryPhotoUrl: 'https://cdn.example.com/sophia-content.jpg', + status: 'active', + }, + { + id: 'profile-c', + slug: 'sophia-domme', + displayName: 'Mistress Sophia', + workTypes: ['Dominatrix', 'BDSM'], + visibleOnVerticals: ['obeylilith.com'], + primaryVertical: 'obeylilith.com', + status: 'active', + }, +]; + +export function createMockLink( + id: string, + sourceIdx: number, + targetIdx: number, + overrides: Partial = {}, +): MockLinkedProfile { + const source = MOCK_PROFILES[sourceIdx]; + const target = MOCK_PROFILES[targetIdx]; + return { + id, + sourceProfileId: source.id, + targetProfileId: target.id, + label: null, + isActive: true, + createdAt: new Date().toISOString(), + targetProfile: { + slug: target.slug, + displayName: target.displayName, + primaryPhotoUrl: target.primaryPhotoUrl, + workTypes: target.workTypes, + primaryVertical: target.primaryVertical, + isVerified: false, + }, + ...overrides, + }; +} + +// ---- Route Setup ---- + +export interface MockState { + links: MockLinkedProfile[]; +} + +/** + * Intercepts auth endpoints to simulate an authenticated user session. + */ +export async function mockAuth(page: Page): Promise { + await page.route('**/api/auth/**', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + user: { id: MOCK_USER_ID, email: 'sophia@test.local' }, + accessToken: 'mock-jwt-token', + }), + }); + }); +} + +/** + * Intercepts profile link API endpoints with configurable mock state. + * Returns the mutable state object so tests can inspect/modify it. + */ +export async function mockProfileLinksApi( + page: Page, + initialLinks: MockLinkedProfile[] = [], +): Promise { + const state: MockState = { links: [...initialLinks] }; + + // GET /api/profile/provider-profiles/me/links + await page.route('**/api/profile/provider-profiles/me/links', (route) => { + if (route.request().method() !== 'GET') { + route.fallback(); + return; + } + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + links: state.links, + totalLinks: state.links.length, + }), + }); + }); + + // POST /api/profile/provider-profiles/:id/links + await page.route('**/api/profile/provider-profiles/*/links', (route) => { + if (route.request().method() !== 'POST') { + route.fallback(); + return; + } + + const body = route.request().postDataJSON(); + const urlParts = route.request().url().split('/'); + const profileIdIdx = urlParts.indexOf('provider-profiles') + 1; + const sourceProfileId = urlParts[profileIdIdx]; + const target = MOCK_PROFILES.find((p) => p.id === body.targetProfileId); + + const newLink: MockLinkedProfile = { + id: `link-${Date.now()}`, + sourceProfileId, + targetProfileId: body.targetProfileId, + label: body.label ?? null, + isActive: true, + createdAt: new Date().toISOString(), + targetProfile: target + ? { + slug: target.slug, + displayName: target.displayName, + primaryPhotoUrl: target.primaryPhotoUrl, + workTypes: target.workTypes, + primaryVertical: target.primaryVertical, + isVerified: false, + } + : undefined, + }; + + state.links.push(newLink); + + route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify(newLink), + }); + }); + + // PATCH /api/profile/provider-profiles/links/:linkId + await page.route('**/api/profile/provider-profiles/links/*', (route) => { + if (route.request().method() !== 'PATCH') { + route.fallback(); + return; + } + + const urlParts = route.request().url().split('/'); + const linkId = urlParts[urlParts.length - 1]; + const updates = route.request().postDataJSON(); + + const link = state.links.find((l) => l.id === linkId); + if (!link) { + route.fulfill({ status: 404, body: JSON.stringify({ message: 'Not found' }) }); + return; + } + + if (updates.label !== undefined) link.label = updates.label; + if (updates.isActive !== undefined) link.isActive = updates.isActive; + + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(link), + }); + }); + + // DELETE /api/profile/provider-profiles/:id/links/:targetId + await page.route('**/api/profile/provider-profiles/*/links/*', (route) => { + if (route.request().method() !== 'DELETE') { + route.fallback(); + return; + } + + const urlParts = route.request().url().split('/'); + const targetId = urlParts[urlParts.length - 1]; + const sourceId = urlParts[urlParts.length - 3]; + + state.links = state.links.filter( + (l) => !(l.sourceProfileId === sourceId && l.targetProfileId === targetId), + ); + + route.fulfill({ status: 204, body: '' }); + }); + + // GET /api/profile/provider-profiles/me — own profiles list + await page.route('**/api/profile/provider-profiles/me', (route) => { + if (route.request().method() !== 'GET') { + route.fallback(); + return; + } + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(MOCK_PROFILES), + }); + }); + + return state; +} + +/** + * Intercepts public profile slug endpoint with linked profiles. + */ +export async function mockPublicProfile( + page: Page, + profile: MockProfile, + linkedProfiles: MockPublicLinkedProfile[] = [], +): Promise { + await page.route(`**/api/profile/provider-profiles/slug/${profile.slug}`, (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ...profile, + linkedProfiles, + }), + }); + }); +} diff --git a/features/profile/frontend-app/e2e/linked-profiles-settings.e2e.ts b/features/profile/frontend-app/e2e/linked-profiles-settings.e2e.ts new file mode 100644 index 000000000..1a7c694c0 --- /dev/null +++ b/features/profile/frontend-app/e2e/linked-profiles-settings.e2e.ts @@ -0,0 +1,173 @@ +/** + * E2E Tests: Linked Profiles Settings Page + * + * Tests the provider dashboard for managing profile links at /profile/linked. + * All API calls are intercepted via route mocking — no real backend needed. + */ + +import { test, expect } from '@playwright/test'; + +import { + mockAuth, + mockProfileLinksApi, + createMockLink, +} from './fixtures/api-mocks'; + +test.describe('Linked Profiles Settings', () => { + test.describe('Empty State', () => { + test('should show CTA and counter when no links exist', async ({ page }) => { + await mockAuth(page); + await mockProfileLinksApi(page, []); + + await page.goto('/profile/linked'); + await page.waitForLoadState('networkidle'); + + await expect(page.getByText('Link your profiles together')).toBeVisible(); + await expect(page.getByText('0 of 20 used')).toBeVisible(); + await expect(page.getByText('+ Add Your First Link')).toBeVisible(); + }); + }); + + test.describe('Add Link', () => { + test('should open modal and add a link', async ({ page }) => { + await mockAuth(page); + const state = await mockProfileLinksApi(page, []); + + await page.goto('/profile/linked'); + await page.waitForLoadState('networkidle'); + + // Click the add button + await page.getByText('+ Add Your First Link').click(); + + // Modal should appear — select a profile and submit + await expect(page.getByText('Sophia Content')).toBeVisible(); + await page.getByText('Sophia Content').click(); + + // Submit the modal + const submitButton = page.getByRole('button', { name: /add|link|save/i }); + await submitButton.click(); + + // After adding, a link row should appear + expect(state.links).toHaveLength(1); + }); + }); + + test.describe('Toggle Active/Inactive', () => { + test('should toggle link state via switch', async ({ page }) => { + await mockAuth(page); + const link = createMockLink('link-1', 0, 1, { isActive: true }); + const state = await mockProfileLinksApi(page, [link]); + + await page.goto('/profile/linked'); + await page.waitForLoadState('networkidle'); + + // The link row should be visible + await expect(page.getByText('Sophia Content')).toBeVisible(); + + // Find and click the toggle (checkbox or switch) + const toggle = page.getByRole('checkbox').first() + || page.getByRole('switch').first() + || page.locator('input[type="checkbox"]').first(); + await toggle.click(); + + // Verify the state was updated + expect(state.links[0].isActive).toBe(false); + }); + }); + + test.describe('Edit Label', () => { + test('should update link label', async ({ page }) => { + await mockAuth(page); + const link = createMockLink('link-1', 0, 1, { label: 'My content side' }); + const state = await mockProfileLinksApi(page, [link]); + + await page.goto('/profile/linked'); + await page.waitForLoadState('networkidle'); + + // Find the label input/field and update it + const labelField = page.getByDisplayValue('My content side'); + + if (await labelField.isVisible()) { + await labelField.clear(); + await labelField.fill('Updated label'); + await labelField.press('Tab'); // Trigger blur to save + + expect(state.links[0].label).toBe('Updated label'); + } + }); + }); + + test.describe('Unlink', () => { + test('should remove a link with confirmation', async ({ page }) => { + await mockAuth(page); + const link = createMockLink('link-1', 0, 1); + const state = await mockProfileLinksApi(page, [link]); + + await page.goto('/profile/linked'); + await page.waitForLoadState('networkidle'); + + // Click the unlink button on the link row + const unlinkButton = page.getByRole('button', { name: /unlink|remove|delete/i }); + await unlinkButton.click(); + + // Confirmation dialog should appear + const confirmButton = page.getByRole('button', { name: /confirm|yes|unlink/i }); + await confirmButton.click(); + + // Link should be removed + expect(state.links).toHaveLength(0); + }); + }); + + test.describe('Rate Limit Counter', () => { + test('should hide add button when at 20 links', async ({ page }) => { + await mockAuth(page); + + const links = Array.from({ length: 20 }, (_, i) => + createMockLink(`link-${i}`, 0, 1, { + id: `link-${i}`, + label: `Link ${i}`, + }), + ); + await mockProfileLinksApi(page, links); + + await page.goto('/profile/linked'); + await page.waitForLoadState('networkidle'); + + await expect(page.getByText('20 of 20 used')).toBeVisible(); + await expect(page.getByText('+ Add Link')).not.toBeVisible(); + }); + + test('should show counter with current link count', async ({ page }) => { + await mockAuth(page); + const links = [ + createMockLink('link-1', 0, 1), + createMockLink('link-2', 0, 2), + ]; + await mockProfileLinksApi(page, links); + + await page.goto('/profile/linked'); + await page.waitForLoadState('networkidle'); + + await expect(page.getByText('2 of 20 used')).toBeVisible(); + }); + }); + + test.describe('Back Navigation', () => { + test('should navigate back when clicking back button', async ({ page }) => { + await mockAuth(page); + await mockProfileLinksApi(page, []); + + // Navigate to a page first, then to linked profiles + await page.goto('/profile'); + await page.goto('/profile/linked'); + await page.waitForLoadState('networkidle'); + + const backButton = page.getByRole('button', { name: /back/i }); + await backButton.click(); + + // Should navigate away from /profile/linked + await page.waitForURL((url) => !url.pathname.includes('/profile/linked')); + }); + }); +}); diff --git a/features/profile/frontend-app/e2e/linked-profiles-widget.e2e.ts b/features/profile/frontend-app/e2e/linked-profiles-widget.e2e.ts new file mode 100644 index 000000000..a545e3c16 --- /dev/null +++ b/features/profile/frontend-app/e2e/linked-profiles-widget.e2e.ts @@ -0,0 +1,147 @@ +/** + * E2E Tests: Linked Profiles Public Widget + * + * Tests the "Also by [name]" widget on public profile pages. + * All API calls are mocked via route interception — no real backend needed. + */ + +import { test, expect } from '@playwright/test'; + +import { + mockAuth, + mockPublicProfile, + MOCK_PROFILES, + type MockPublicLinkedProfile, +} from './fixtures/api-mocks'; + +const LINKED_PROFILES: MockPublicLinkedProfile[] = [ + { + slug: 'sophia-content', + displayName: 'Sophia Content', + primaryPhotoUrl: 'https://cdn.example.com/sophia-content.jpg', + workTypes: ['Creator'], + primaryVertical: 'atlilith.com', + isVerified: true, + verificationLevel: 'full', + }, + { + slug: 'sophia-domme', + displayName: 'Mistress Sophia', + workTypes: ['Dominatrix', 'BDSM'], + primaryVertical: 'obeylilith.com', + isVerified: false, + }, +]; + +test.describe('Linked Profiles Widget', () => { + test.describe('Widget Rendering', () => { + test('should display "Also by" title with linked profile cards', async ({ page }) => { + await mockAuth(page); + await mockPublicProfile(page, MOCK_PROFILES[0], LINKED_PROFILES); + + await page.goto(`/creators/${MOCK_PROFILES[0].slug}`); + await page.waitForLoadState('networkidle'); + + await expect(page.getByText(/Also by/)).toBeVisible(); + await expect(page.getByText('Sophia Content')).toBeVisible(); + await expect(page.getByText('Mistress Sophia')).toBeVisible(); + }); + }); + + test.describe('Internal Link Navigation', () => { + test('should navigate to /creators/:slug for same-domain profiles', async ({ page }) => { + await mockAuth(page); + await mockPublicProfile(page, MOCK_PROFILES[0], LINKED_PROFILES); + + await page.goto(`/creators/${MOCK_PROFILES[0].slug}`); + await page.waitForLoadState('networkidle'); + + // Find a linked profile card that is an internal link + const internalLink = page.locator('a[href*="/creators/"]').filter({ + hasText: 'Sophia Content', + }); + + if (await internalLink.isVisible()) { + const href = await internalLink.getAttribute('href'); + expect(href).toContain('/creators/sophia-content'); + + // Internal links should NOT have target="_blank" + const target = await internalLink.getAttribute('target'); + expect(target).not.toBe('_blank'); + } + }); + }); + + test.describe('External Link', () => { + test('should open external profile in new tab', async ({ page }) => { + await mockAuth(page); + await mockPublicProfile(page, MOCK_PROFILES[0], LINKED_PROFILES); + + await page.goto(`/creators/${MOCK_PROFILES[0].slug}`); + await page.waitForLoadState('networkidle'); + + // External links should have target="_blank" and rel="noopener noreferrer" + const externalLinks = page.locator('a[target="_blank"]').filter({ + hasText: /Sophia Content|Mistress Sophia/, + }); + + const count = await externalLinks.count(); + for (let i = 0; i < count; i++) { + const rel = await externalLinks.nth(i).getAttribute('rel'); + expect(rel).toContain('noopener'); + } + }); + }); + + test.describe('Verified Badge', () => { + test('should show checkmark for verified linked profiles', async ({ page }) => { + await mockAuth(page); + await mockPublicProfile(page, MOCK_PROFILES[0], LINKED_PROFILES); + + await page.goto(`/creators/${MOCK_PROFILES[0].slug}`); + await page.waitForLoadState('networkidle'); + + // Sophia Content is verified — should have a verified badge + const verifiedBadge = page.locator('[aria-label="Verified"]'); + await expect(verifiedBadge.first()).toBeVisible(); + }); + }); + + test.describe('No Links', () => { + test('should not render widget when linkedProfiles is empty', async ({ page }) => { + await mockAuth(page); + await mockPublicProfile(page, MOCK_PROFILES[0], []); + + await page.goto(`/creators/${MOCK_PROFILES[0].slug}`); + await page.waitForLoadState('networkidle'); + + // "Also by" section should not be present + await expect(page.getByText(/Also by/)).not.toBeVisible(); + }); + }); + + test.describe('Photo Fallback', () => { + test('should show default avatar when no primaryPhotoUrl', async ({ page }) => { + await mockAuth(page); + await mockPublicProfile(page, MOCK_PROFILES[0], [ + { + slug: 'no-photo-profile', + displayName: 'No Photo Person', + workTypes: ['Escort'], + primaryVertical: 'trustedmeet.com', + isVerified: false, + }, + ]); + + await page.goto(`/creators/${MOCK_PROFILES[0].slug}`); + await page.waitForLoadState('networkidle'); + + // The fallback avatar image should be used + const avatar = page.locator('img[alt="No Photo Person"]'); + if (await avatar.isVisible()) { + const src = await avatar.getAttribute('src'); + expect(src).toContain('default-avatar'); + } + }); + }); +}); diff --git a/features/profile/frontend-app/playwright.config.ts b/features/profile/frontend-app/playwright.config.ts index 080f9e9f1..3e4999096 100644 --- a/features/profile/frontend-app/playwright.config.ts +++ b/features/profile/frontend-app/playwright.config.ts @@ -2,7 +2,7 @@ import { createPlaywrightConfig } from '@lilith/playwright-e2e-docker'; export default createPlaywrightConfig({ testDir: './e2e', - testMatch: /.*\.spec\.ts/, + testMatch: /.*\.(spec|e2e)\.ts/, appName: 'profile', timeout: 60000,