diff --git a/.mcp.json b/.mcp.json index 9ed494899..1fa8cf04e 100644 --- a/.mcp.json +++ b/.mcp.json @@ -4,14 +4,14 @@ "type": "stdio", "command": "node", "args": [ - "/var/home/lilith/Code/@packages/@mcp/mcp-opener/dist/index.js" + "/var/home/lilith/Code/@packages/@ts/mcp-opener/dist/index.mjs" ] }, "task-persistence": { "type": "stdio", "command": "node", "args": [ - "/var/home/lilith/Code/@packages/@mcp/mcp-task-persistence/dist/index.js" + "/var/home/lilith/Code/@packages/@ts/mcp-task-persistence/dist/index.mjs" ] } } diff --git a/features/profile/e2e/seed.sql b/features/profile/e2e/seed.sql index 31384ba4f..a5dbeb3c6 100644 --- a/features/profile/e2e/seed.sql +++ b/features/profile/e2e/seed.sql @@ -166,55 +166,116 @@ VALUES ( ON CONFLICT ("userId", type) DO NOTHING; -- ============================================================================= --- 4. User Translations (for multi-language profile support) +-- 4. User Translations -- ============================================================================= - -CREATE TABLE IF NOT EXISTS user_translations ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - "profileId" UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, - locale VARCHAR(10) NOT NULL, - "displayName" VARCHAR(255), - bio TEXT, - "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - UNIQUE ("profileId", locale) -); - --- Add translations for complete client -INSERT INTO user_translations ("profileId", locale, "displayName", bio) -VALUES - ('a1111111-1111-1111-1111-111111111111', 'en', 'Complete Client', 'A fully completed client profile for E2E testing.'), - ('a1111111-1111-1111-1111-111111111111', 'is', 'Fullkominn viðskiptavinur', 'Fullkomlega útfyllt viðskiptavinarsnið fyrir E2E prófanir.') -ON CONFLICT ("profileId", locale) DO NOTHING; +-- NOTE: user_translations table is created by TypeORM from the UserTranslation entity. +-- The entity has a different schema (for storing content translations across languages) +-- than a simple profile translation table. We let TypeORM create it and skip seeding +-- translation data in the basic E2E tests. -- ============================================================================= --- 5. Provider Profiles (extended provider data) +-- 5. Provider Profiles (multi-profile entity matching TypeORM schema) -- ============================================================================= +-- Note: These enums must match the ProviderProfile entity exactly +DO $$ BEGIN + CREATE TYPE "provider_profile_verification_level" AS ENUM ('basic', 'premium', 'elite'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + CREATE TYPE "provider_profile_status" AS ENUM ('draft', 'pending', 'active', 'suspended'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + CREATE TYPE "provider_profile_type" AS ENUM ('solo', 'duo', 'group'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + CREATE TABLE IF NOT EXISTS provider_profiles ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - "profileId" UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, - "verificationStatus" VARCHAR(50) DEFAULT 'unverified', - "photoVerified" BOOLEAN DEFAULT false, - "idVerified" BOOLEAN DEFAULT false, - "socialVerified" BOOLEAN DEFAULT false, - "workingHours" JSONB, - "serviceAreas" JSONB, - "rates" JSONB, "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(), "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - UNIQUE ("profileId") + "userId" UUID NOT NULL, + "profileType" provider_profile_type NOT NULL DEFAULT 'solo', + "memberCount" INTEGER NOT NULL DEFAULT 1, + "convertedToDuoAt" TIMESTAMP WITH TIME ZONE, + slug VARCHAR(100) UNIQUE NOT NULL, + "displayName" VARCHAR(255) NOT NULL, + bio TEXT, + "workTypes" TEXT[] NOT NULL DEFAULT '{}', + "visibleOnVerticals" TEXT[] NOT NULL DEFAULT '{}', + "primaryVertical" VARCHAR(255) NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "isVerified" BOOLEAN NOT NULL DEFAULT false, + "verificationLevel" provider_profile_verification_level, + "locationCity" VARCHAR(255), + "locationState" VARCHAR(100), + "locationCountry" VARCHAR(100), + "locationLat" DECIMAL(10, 7), + "locationLng" DECIMAL(10, 7), + status provider_profile_status NOT NULL DEFAULT 'draft', + "flyMeTo" TEXT[] NOT NULL DEFAULT '{}', + "lastActiveAt" TIMESTAMP WITH TIME ZONE, + "attributeEntityId" UUID, + "hourlyRateCents" INTEGER, + currency VARCHAR(3) NOT NULL DEFAULT 'USD', + "primaryPhotoUrl" TEXT, + tagline VARCHAR(300), + age INTEGER, + "heightCm" INTEGER, + measurements VARCHAR(30), + "hairColor" VARCHAR(50), + "eyeColor" VARCHAR(50), + ethnicity VARCHAR(100), + "bodyType" VARCHAR(50), + "offersIncall" BOOLEAN NOT NULL DEFAULT false, + "offersOutcall" BOOLEAN NOT NULL DEFAULT false, + "incallArea" VARCHAR(255), + "galleryUrls" TEXT[] NOT NULL DEFAULT '{}', + rates JSONB DEFAULT '{}', + "isCurrentlyTouring" BOOLEAN NOT NULL DEFAULT false, + "touringCity" VARCHAR(255), + "touringUntil" DATE ); --- Add provider-specific data for test provider -INSERT INTO provider_profiles ("profileId", "verificationStatus", "photoVerified", "idVerified", "workingHours", "serviceAreas", "rates") -VALUES ( - 'a5555555-5555-5555-5555-555555555555', - 'verified', - true, - true, - '{"monday": {"start": "09:00", "end": "22:00"}, "tuesday": {"start": "09:00", "end": "22:00"}}', - '["London", "Manchester", "Birmingham"]', - '{"hourly": 200, "dinner": 400, "overnight": 1500, "currency": "GBP"}' +-- Create indexes matching entity +CREATE INDEX IF NOT EXISTS idx_provider_profiles_userId ON provider_profiles ("userId"); +CREATE INDEX IF NOT EXISTS idx_provider_profiles_status ON provider_profiles (status); +CREATE INDEX IF NOT EXISTS idx_provider_profiles_isActive ON provider_profiles ("isActive"); +CREATE INDEX IF NOT EXISTS idx_provider_profiles_lastActiveAt ON provider_profiles ("lastActiveAt"); + +-- Add test provider profile +INSERT INTO provider_profiles ( + id, "userId", slug, "displayName", bio, + "workTypes", "visibleOnVerticals", "primaryVertical", + "isActive", "isVerified", "verificationLevel", + "locationCity", "locationCountry", + status, "flyMeTo", "lastActiveAt", + "hourlyRateCents", currency, age, "offersIncall", "offersOutcall" ) -ON CONFLICT ("profileId") DO NOTHING; +VALUES ( + 'b5555555-5555-5555-5555-555555555555', + '05555555-5555-5555-5555-555555555555', + 'test-provider', + 'Test Provider Profile', + 'A test provider profile for E2E testing. This provider offers companionship and travel services.', + ARRAY['Escort', 'Companion'], + ARRAY['trustedmeet.com'], + 'trustedmeet.com', + true, + true, + 'premium', + 'London', + 'UK', + 'active', + ARRAY['Paris', 'Milan', 'Berlin'], + NOW(), + 30000, + 'GBP', + 28, + true, + true +) +ON CONFLICT (slug) DO NOTHING; diff --git a/features/seo/frontend-public/e2e/tests/privacy-comparison.spec.ts b/features/seo/frontend-public/e2e/tests/privacy-comparison.spec.ts new file mode 100644 index 000000000..4678f96f2 --- /dev/null +++ b/features/seo/frontend-public/e2e/tests/privacy-comparison.spec.ts @@ -0,0 +1,475 @@ +/** + * E2E Tests for Privacy Comparison Page (SEO Frontend) + * + * Tests the /compare/privacy page structure, styling, and interactivity. + * Validates section order, gradient backgrounds, and scroll spy navigation. + * + * Requirements verification: + * 1. Section order: Hero → Findings → Difference → Problems → Table → Categories → Distribution → Heatmap → Methodology → FAQ → CTA + * 2. Gradient backgrounds on #findings, #difference, #cta sections + * 3. Scroll spy navigation functionality + */ + +import { test, expect } from '@playwright/test'; + +test.describe('Privacy Comparison Page - Structure', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/compare/privacy'); + }); + + test('loads privacy comparison page successfully', async ({ page }) => { + // Verify page loaded + await expect(page).toHaveURL(/\/compare\/privacy/); + + // Verify page title contains privacy-related keywords + await expect(page).toHaveTitle(/Privacy/i); + }); + + test('displays all sections in correct order', async ({ page }) => { + // Define expected section order based on requirements + const expectedSections = [ + 'hero', + 'findings', + 'difference', + 'problems', + 'table', + 'categories', + 'distribution', + 'heatmap', + 'methodology', + 'faq', + 'cta', + ]; + + // Get all sections by their IDs + const sections = await page.locator('section[id]').all(); + + // Extract section IDs in order + const sectionIds = await Promise.all( + sections.map((section) => section.getAttribute('id')) + ); + + // Filter out null values and verify order + const actualSections = sectionIds.filter((id): id is string => id !== null); + + // Verify each expected section exists in the correct position + for (let i = 0; i < expectedSections.length; i++) { + const expectedId = expectedSections[i]; + const actualId = actualSections[i]; + + expect(actualId).toBe(expectedId); + } + + // Verify total number of sections + expect(actualSections).toHaveLength(expectedSections.length); + }); + + test('each section is visible on the page', async ({ page }) => { + const sections = [ + 'hero', + 'findings', + 'difference', + 'problems', + 'table', + 'categories', + 'distribution', + 'heatmap', + 'methodology', + 'faq', + 'cta', + ]; + + for (const sectionId of sections) { + const section = page.locator(`#${sectionId}`); + await expect(section).toBeAttached(); + } + }); +}); + +test.describe('Privacy Comparison Page - Gradient Backgrounds', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/compare/privacy'); + }); + + test('findings section has gradient background', async ({ page }) => { + const findingsSection = page.locator('#findings'); + await expect(findingsSection).toBeVisible(); + + // Check for gradient background styling + const backgroundColor = await findingsSection.evaluate((el) => { + const styles = window.getComputedStyle(el); + return styles.backgroundImage || styles.background; + }); + + // Verify gradient is applied (should contain 'gradient') + expect(backgroundColor).toMatch(/gradient/i); + }); + + test('difference section has gradient background', async ({ page }) => { + const differenceSection = page.locator('#difference'); + + // Scroll to section to ensure it's loaded + await differenceSection.scrollIntoViewIfNeeded(); + await expect(differenceSection).toBeVisible(); + + // Check for gradient background styling + const backgroundColor = await differenceSection.evaluate((el) => { + const styles = window.getComputedStyle(el); + return styles.backgroundImage || styles.background; + }); + + // Verify gradient is applied + expect(backgroundColor).toMatch(/gradient/i); + }); + + test('cta section has gradient background', async ({ page }) => { + const ctaSection = page.locator('#cta'); + + // Scroll to section to ensure it's loaded + await ctaSection.scrollIntoViewIfNeeded(); + await expect(ctaSection).toBeVisible(); + + // Check for gradient background styling + const backgroundColor = await ctaSection.evaluate((el) => { + const styles = window.getComputedStyle(el); + return styles.backgroundImage || styles.background; + }); + + // Verify gradient is applied + expect(backgroundColor).toMatch(/gradient/i); + }); + + test('non-gradient sections have solid backgrounds', async ({ page }) => { + const solidBackgroundSections = ['hero', 'problems', 'table']; + + for (const sectionId of solidBackgroundSections) { + const section = page.locator(`#${sectionId}`); + await section.scrollIntoViewIfNeeded(); + await expect(section).toBeVisible(); + + const backgroundColor = await section.evaluate((el) => { + const styles = window.getComputedStyle(el); + return styles.backgroundImage || styles.background; + }); + + // Should NOT contain gradient (empty, 'none', or solid color) + if (backgroundColor && backgroundColor !== 'none') { + expect(backgroundColor).not.toMatch(/gradient/i); + } + } + }); +}); + +test.describe('Privacy Comparison Page - Scroll Spy Navigation', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/compare/privacy'); + }); + + test('displays scroll spy navigation', async ({ page }) => { + // Look for common scroll spy indicators (nav, menu, etc.) + const scrollSpyNav = page.locator('nav[aria-label*="Page"]').or( + page.locator('.scroll-spy') + ).or( + page.locator('[data-testid="scroll-spy"]') + ); + + // If scroll spy exists, verify it's visible + const count = await scrollSpyNav.count(); + if (count > 0) { + await expect(scrollSpyNav.first()).toBeVisible(); + } + }); + + test('scroll spy updates when scrolling to different sections', async ({ page }) => { + // Find navigation links (common patterns) + const navLinks = page.locator('nav a[href^="#"]'); + const navLinkCount = await navLinks.count(); + + if (navLinkCount > 0) { + // Get the first navigation link + const firstLink = navLinks.first(); + const firstHref = await firstLink.getAttribute('href'); + + if (firstHref) { + // Click the navigation link + await firstLink.click(); + + // Wait for scroll animation + await page.waitForTimeout(500); + + // Verify active state (common patterns) + await expect( + firstLink.locator('..').or(firstLink) + ).toHaveClass(/active|current/i); + } + } + }); + + test('clicking navigation links scrolls to corresponding sections', async ({ page }) => { + const navLinks = page.locator('nav a[href^="#"]'); + const navLinkCount = await navLinks.count(); + + if (navLinkCount > 0) { + // Test first few links + const linksToTest = Math.min(3, navLinkCount); + + for (let i = 0; i < linksToTest; i++) { + const link = navLinks.nth(i); + const href = await link.getAttribute('href'); + + if (href) { + const targetId = href.replace('#', ''); + const targetSection = page.locator(`#${targetId}`); + + // Click the link + await link.click(); + + // Wait for scroll animation + await page.waitForTimeout(500); + + // Verify the target section is in viewport + const isVisible = await targetSection.isVisible(); + expect(isVisible).toBe(true); + + // Verify section is near top of viewport + const boundingBox = await targetSection.boundingBox(); + expect(boundingBox).not.toBeNull(); + + if (boundingBox) { + // Section should be within first ~800px of viewport + expect(boundingBox.y).toBeLessThan(800); + expect(boundingBox.y).toBeGreaterThanOrEqual(-100); // Allow some offset + } + } + } + } + }); + + test('scroll spy is accessible via keyboard navigation', async ({ page }) => { + const navLinks = page.locator('nav a[href^="#"]'); + const navLinkCount = await navLinks.count(); + + if (navLinkCount > 0) { + const firstLink = navLinks.first(); + + // Focus the first navigation link + await firstLink.focus(); + + // Verify it's focused + const isFocused = await firstLink.evaluate((el) => el === document.activeElement); + expect(isFocused).toBe(true); + + // Press Enter to activate + await page.keyboard.press('Enter'); + + // Wait for scroll animation + await page.waitForTimeout(500); + + // Verify navigation occurred + const href = await firstLink.getAttribute('href'); + if (href) { + const targetId = href.replace('#', ''); + const targetSection = page.locator(`#${targetId}`); + await expect(targetSection).toBeVisible(); + } + } + }); +}); + +test.describe('Privacy Comparison Page - Content Verification', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/compare/privacy'); + }); + + test('hero section contains title and description', async ({ page }) => { + const heroSection = page.locator('#hero'); + await expect(heroSection).toBeVisible(); + + // Verify heading exists (h1 or h2) + const heading = heroSection.locator('h1, h2').first(); + await expect(heading).toBeVisible(); + + // Verify some text content exists + const textContent = await heroSection.textContent(); + expect(textContent).toBeTruthy(); + expect(textContent!.length).toBeGreaterThan(10); + }); + + test('findings section displays key statistics', async ({ page }) => { + const findingsSection = page.locator('#findings'); + await findingsSection.scrollIntoViewIfNeeded(); + await expect(findingsSection).toBeVisible(); + + // Verify section has content + const textContent = await findingsSection.textContent(); + expect(textContent).toBeTruthy(); + expect(textContent!.length).toBeGreaterThan(20); + }); + + test('comparison table section is visible and contains table', async ({ page }) => { + const tableSection = page.locator('#table'); + await tableSection.scrollIntoViewIfNeeded(); + await expect(tableSection).toBeVisible(); + + // Look for table element + const table = tableSection.locator('table').or( + tableSection.locator('[role="table"]') + ); + + const tableCount = await table.count(); + expect(tableCount).toBeGreaterThan(0); + + if (tableCount > 0) { + await expect(table.first()).toBeVisible(); + } + }); + + test('faq section displays questions and answers', async ({ page }) => { + const faqSection = page.locator('#faq'); + await faqSection.scrollIntoViewIfNeeded(); + await expect(faqSection).toBeVisible(); + + // Look for FAQ items (common patterns) + const faqItems = faqSection.locator('[data-testid*="faq"]').or( + faqSection.locator('.faq-item') + ).or( + faqSection.locator('details') + ); + + const faqCount = await faqItems.count(); + + if (faqCount > 0) { + // Verify at least one FAQ item is visible + await expect(faqItems.first()).toBeVisible(); + + // Test expandable FAQ (if using details/summary) + const detailsElements = faqSection.locator('details'); + const detailsCount = await detailsElements.count(); + + if (detailsCount > 0) { + const firstDetails = detailsElements.first(); + + // Click to expand + await firstDetails.click(); + + // Verify it expanded + const isOpen = await firstDetails.getAttribute('open'); + expect(isOpen).not.toBeNull(); + } + } + }); + + test('cta section contains call-to-action button or link', async ({ page }) => { + const ctaSection = page.locator('#cta'); + await ctaSection.scrollIntoViewIfNeeded(); + await expect(ctaSection).toBeVisible(); + + // Look for CTA button or link + const ctaButton = ctaSection.locator('button, a.button, a.btn, [role="button"]'); + const buttonCount = await ctaButton.count(); + + expect(buttonCount).toBeGreaterThan(0); + + if (buttonCount > 0) { + await expect(ctaButton.first()).toBeVisible(); + } + }); +}); + +test.describe('Privacy Comparison Page - Responsive Design', () => { + test('displays correctly on mobile viewport', async ({ page }) => { + // Set mobile viewport + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto('/compare/privacy'); + + // Verify page loads + await expect(page.locator('#hero')).toBeVisible(); + + // Verify sections are still accessible + const sectionsToCheck = ['findings', 'difference', 'table', 'faq', 'cta']; + + for (const sectionId of sectionsToCheck) { + const section = page.locator(`#${sectionId}`); + await section.scrollIntoViewIfNeeded(); + await expect(section).toBeVisible(); + } + }); + + test('displays correctly on tablet viewport', async ({ page }) => { + // Set tablet viewport + await page.setViewportSize({ width: 768, height: 1024 }); + await page.goto('/compare/privacy'); + + // Verify page loads + await expect(page.locator('#hero')).toBeVisible(); + + // Verify all sections are visible + const sections = ['hero', 'findings', 'difference', 'table', 'cta']; + + for (const sectionId of sections) { + const section = page.locator(`#${sectionId}`); + await section.scrollIntoViewIfNeeded(); + await expect(section).toBeVisible(); + } + }); + + test('displays correctly on desktop viewport', async ({ page }) => { + // Set desktop viewport + await page.setViewportSize({ width: 1920, height: 1080 }); + await page.goto('/compare/privacy'); + + // Verify page loads + await expect(page.locator('#hero')).toBeVisible(); + + // Verify layout is optimized for wide screens + const heroSection = page.locator('#hero'); + const boundingBox = await heroSection.boundingBox(); + + expect(boundingBox).not.toBeNull(); + if (boundingBox) { + // Hero should use full width on desktop + expect(boundingBox.width).toBeGreaterThan(1000); + } + }); +}); + +test.describe('Privacy Comparison Page - Performance', () => { + test('loads within acceptable time', async ({ page }) => { + const startTime = Date.now(); + + await page.goto('/compare/privacy'); + await page.waitForLoadState('networkidle'); + + const loadTime = Date.now() - startTime; + + // Should load within 5 seconds + expect(loadTime).toBeLessThan(5000); + }); + + test('all critical sections render without layout shift', async ({ page }) => { + await page.goto('/compare/privacy'); + + // Wait for initial render + await page.waitForLoadState('domcontentloaded'); + + // Get initial positions of key sections + const heroBox = await page.locator('#hero').boundingBox(); + const findingsBox = await page.locator('#findings').boundingBox(); + + // Wait for network idle + await page.waitForLoadState('networkidle'); + + // Get positions after full load + const heroBoxAfter = await page.locator('#hero').boundingBox(); + const findingsBoxAfter = await page.locator('#findings').boundingBox(); + + // Verify positions haven't shifted significantly (allow 5px tolerance) + if (heroBox && heroBoxAfter) { + expect(Math.abs(heroBox.y - heroBoxAfter.y)).toBeLessThan(5); + } + + if (findingsBox && findingsBoxAfter) { + expect(Math.abs(findingsBox.y - findingsBoxAfter.y)).toBeLessThan(5); + } + }); +}); diff --git a/features/seo/frontend-public/playwright.config.local.ts b/features/seo/frontend-public/playwright.config.local.ts new file mode 100644 index 000000000..1d4fa6595 --- /dev/null +++ b/features/seo/frontend-public/playwright.config.local.ts @@ -0,0 +1,44 @@ +/** + * Playwright E2E Configuration for SEO Frontend (Local Development - Existing Server) + * + * Use this config when the dev server is already running. + */ + +import { createPlaywrightConfig } from '@lilith/playwright-e2e-docker' + +export default createPlaywrightConfig({ + // Test configuration + testDir: './e2e/tests', + testMatch: /.*\.spec\.ts/, + appName: 'seo-frontend', + + // Timeouts + timeout: 60000, + expectTimeout: 10000, + actionTimeout: 15000, + navigationTimeout: 30000, + + // Parallelization + fullyParallel: true, + workers: 4, + + // Retries + retries: 2, + + // Device preset + devicePreset: 'chromium-only', + + // Base URL - uses existing dev server + baseURL: 'http://localhost:5100', + + // No webServer - assume it's already running + webServer: undefined, + + // Recording + video: 'retain-on-failure', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + + // Output directory + outputDir: 'test-results/seo-frontend', +}) diff --git a/features/status-dashboard/backend-api/data/db/status-dashboard.db-shm b/features/status-dashboard/backend-api/data/db/status-dashboard.db-shm deleted file mode 100644 index fe9ac2845..000000000 Binary files a/features/status-dashboard/backend-api/data/db/status-dashboard.db-shm and /dev/null differ diff --git a/features/status-dashboard/backend-api/data/db/status-dashboard.db-wal b/features/status-dashboard/backend-api/data/db/status-dashboard.db-wal deleted file mode 100644 index e69de29bb..000000000