chore(fontend-app): 🔧 Update TypeScript files in frontend-app

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-02-24 05:44:19 -08:00
parent cc1b376022
commit 395febd83b
4 changed files with 604 additions and 1 deletions

View file

@ -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> = {},
): 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<void> {
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<MockState> {
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<void> {
await page.route(`**/api/profile/provider-profiles/slug/${profile.slug}`, (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
...profile,
linkedProfiles,
}),
});
});
}

View file

@ -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'));
});
});
});

View file

@ -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');
}
});
});
});

View file

@ -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,