veza/tests/e2e/47-social-deep.spec.ts
senke 775b320b42 feat(e2e): add 303 deep behavioral tests + fix WebSocket + lint-staged
9 deep E2E test files (303 tests total):
41-chat(33) 42-player(31) 43-upload(28) 44-auth(37) 45-playlists(35)
46-search(32) 47-social(30) 48-marketplace(30) 49-settings(37)

Fix WebSocket origin bug (Chat never worked):
GetAllowedWebSocketOrigins() excluded localhost/127.0.0.1 in dev.

Fix lint-staged gofmt: pass files as args not stdin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:35:26 +02:00

668 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { test, expect } from '@playwright/test';
import { loginViaAPI, CONFIG, navigateTo } from './helpers';
/**
* 47-social-deep.spec.ts
*
* Comprehensive E2E tests for Veza Social features:
* - Public user profile (/u/:username)
* - Own profile redirect (/profile)
* - Follow/unfollow interaction
* - Feed page (/feed)
* - Social hub (/social) with sidebar tabs
* - Privacy guarantees (per ORIGIN_UI_UX_SYSTEM §13 — no public popularity metrics)
* - Navigation between profiles/tracks/posts
*
* Seeded users (from veza-backend-api/cmd/tools/seed/seed_users.go):
* - listener: music_fan (follows creators)
* - creator : top_artist (has tracks / followers)
*/
const BASE = CONFIG.baseURL;
const LISTENER_USERNAME = CONFIG.users.listener.username; // music_fan
const CREATOR_USERNAME = CONFIG.users.creator.username; // top_artist
// Regex helpers for i18n-agnostic matching
const RX_FOLLOW = /\b(Follow|Suivre|Seguir|Abonnement)\b/i;
const RX_FOLLOWING = /\b(Following|Suivi|Abonné|Siguiendo|Désabonnement)\b/i;
const RX_FOLLOW_OR_ING = /\b(Follow|Following|Suivre|Suivi|Abonné|Seguir|Siguiendo|Abonnement|Désabonnement)\b/i;
const RX_TRACKS_LABEL = /\b(Tracks|Morceaux|Pistas)\b/i;
const RX_FOLLOWERS_LABEL = /\b(Followers|Abonnés|Seguidores)\b/i;
const RX_FOLLOWING_LABEL = /\b(Following|Abonnements|Siguiendo)\b/i;
const RX_PLAYLISTS_LABEL = /\b(Playlists|Listas)\b/i;
// ============================================================================
// 1. PUBLIC PROFILE PAGE (/u/:username) — 6 tests
// ============================================================================
test.describe('Social Deep — Public profile (/u/:username)', () => {
test.beforeEach(async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
});
test('01. /u/:username loads for any valid username', async ({ page }) => {
await page.goto(`${BASE}/u/${CREATOR_USERNAME}`, { waitUntil: 'domcontentloaded' });
// Must NOT redirect to /login — public route
await expect(page).toHaveURL(new RegExp(`/u/${CREATOR_USERNAME}`), { timeout: 15_000 });
// h1 contains the displayName (derived from username when no first/last name)
const h1 = page.getByRole('heading', { level: 1 }).first();
await expect(h1).toBeVisible({ timeout: 15_000 });
const h1Text = (await h1.textContent() ?? '').trim();
expect(h1Text.length).toBeGreaterThan(0);
expect(h1Text).not.toMatch(/not found|introuvable|something went wrong/i);
});
test('02. Avatar/initials fallback is rendered (seeded URL 404s → initials visible)', async ({ page }) => {
await page.goto(`${BASE}/u/${CREATOR_USERNAME}`, { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ timeout: 15_000 });
// Avatar is always rendered. Either an <img alt="{username}"> is present,
// OR (on error/no src) a span with initials is shown.
const avatarImg = page.locator(`img[alt="${CREATOR_USERNAME}"]`).first();
const initialsSpan = page
.locator('span.font-bold.text-muted-foreground')
.filter({ hasText: /^[A-Z?]{1,2}$/ })
.first();
const hasImg = await avatarImg.isVisible({ timeout: 3_000 }).catch(() => false);
const hasInitials = await initialsSpan.isVisible({ timeout: 3_000 }).catch(() => false);
expect(
hasImg || hasInitials,
'Avatar must render either an <img> or initials fallback span',
).toBeTruthy();
});
test('03. Username is displayed with @ prefix', async ({ page }) => {
await page.goto(`${BASE}/u/${CREATOR_USERNAME}`, { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ timeout: 15_000 });
// The header renders "@{username}" via a span that includes @ prefix
const handle = page.getByText(new RegExp(`@\\s*${CREATOR_USERNAME}`, 'i')).first();
await expect(handle).toBeVisible();
});
test('04. About / bio section is present', async ({ page }) => {
await page.goto(`${BASE}/u/${CREATOR_USERNAME}`, { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ timeout: 15_000 });
// The "About" section is a h2 with the About label (or translated equivalent)
const aboutHeading = page.getByRole('heading', { level: 2 }).filter({
hasText: /about|à propos|acerca/i,
}).first();
await expect(aboutHeading).toBeVisible();
// Bio paragraph follows immediately; either custom text or i18n "No bio" placeholder
const bioParagraph = aboutHeading.locator('xpath=following-sibling::p[1]');
await expect(bioParagraph).toBeVisible();
const bioText = (await bioParagraph.textContent() ?? '').trim();
expect(bioText.length).toBeGreaterThan(0);
});
test('05. Stats show Tracks, Playlists, Followers, Following counts', async ({ page }) => {
await page.goto(`${BASE}/u/${CREATOR_USERNAME}`, { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ timeout: 15_000 });
const body = (await page.textContent('body')) ?? '';
expect(body, 'Tracks stat label should be visible').toMatch(RX_TRACKS_LABEL);
expect(body, 'Playlists stat label should be visible').toMatch(RX_PLAYLISTS_LABEL);
expect(body, 'Followers stat label should be visible').toMatch(RX_FOLLOWERS_LABEL);
expect(body, 'Following stat label should be visible').toMatch(RX_FOLLOWING_LABEL);
});
test('06. Sensitive information (email, password) is NOT leaked in DOM', async ({ page }) => {
await page.goto(`${BASE}/u/${CREATOR_USERNAME}`, { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ timeout: 15_000 });
const html = await page.content();
// No email addresses for seeded users should appear on a public profile page
expect(html, 'Creator email must not leak').not.toContain('artist@veza.music');
expect(html, 'Listener email must not leak').not.toContain('user@veza.music');
expect(html, 'Admin email must not leak').not.toContain('admin@veza.music');
// No password / hash patterns
expect(html).not.toMatch(/password_hash/i);
expect(html).not.toMatch(/password["']?\s*[:=]\s*["'][^"']{3,}/i);
// No JWT tokens in DOM
expect(html).not.toMatch(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/);
});
});
// ============================================================================
// 2. OWN PROFILE (/profile) — 4 tests
// ============================================================================
test.describe('Social Deep — Own profile (/profile)', () => {
test.beforeEach(async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
});
test('07. /profile redirects to /u/<current_username>', async ({ page }) => {
await page.goto(`${BASE}/profile`, { waitUntil: 'domcontentloaded' });
// ProfileRedirect replaces history → final URL must be /u/<listener_username>
await page.waitForURL(new RegExp(`/u/${LISTENER_USERNAME}$`), { timeout: 15_000 });
expect(page.url()).toContain(`/u/${LISTENER_USERNAME}`);
});
test('08. Own profile shows NO Follow button (user cannot follow self)', async ({ page }) => {
await page.goto(`${BASE}/u/${LISTENER_USERNAME}`, { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ timeout: 15_000 });
// FollowButton component returns null when user.id === profile.id
const followBtn = page.getByRole('button', { name: RX_FOLLOW_OR_ING });
await expect(followBtn).toHaveCount(0);
});
test('09. Own profile stats match the logged-in user', async ({ page }) => {
await page.goto(`${BASE}/u/${LISTENER_USERNAME}`, { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ timeout: 15_000 });
// Username (listener) must appear with @ prefix as the handle
const handle = page.getByText(new RegExp(`@\\s*${LISTENER_USERNAME}`, 'i')).first();
await expect(handle).toBeVisible();
// Cross-check via API: GET /api/v1/users/by-username/:username should match the visible profile
const response = await page.request.get(
`${CONFIG.apiURL}/api/v1/users/by-username/${encodeURIComponent(LISTENER_USERNAME)}`,
);
expect(response.ok(), `API profile lookup failed: ${response.status()}`).toBeTruthy();
const payload = await response.json().catch(() => null);
expect(payload, 'Profile API must return JSON').toBeTruthy();
const profile = (payload?.profile ?? payload?.data?.profile ?? payload?.data ?? payload) as
| { username?: string }
| null;
expect(profile?.username).toBe(LISTENER_USERNAME);
});
test('10. Bio editing is available via settings or skipped if not routed', async ({ page }) => {
await navigateTo(page, '/settings');
// Bio/profile fields may be on a settings tab or elsewhere. We don't assert
// they're editable on /u/:username (public profile has no inline edit).
const bioField = page
.getByLabel(/bio/i)
.or(page.locator('textarea[name="bio"], textarea[id*="bio"]'))
.first();
const hasBio = await bioField.isVisible({ timeout: 3_000 }).catch(() => false);
test.skip(!hasBio, 'Bio edit form not exposed on /settings — feature not yet routed');
await expect(bioField).toBeVisible();
});
});
// ============================================================================
// 3. FOLLOW BUTTON — 5 tests
// ============================================================================
test.describe('Social Deep — Follow button', () => {
test.beforeEach(async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
});
test('11. Follow button visible on another user\'s profile', async ({ page }) => {
await page.goto(`${BASE}/u/${CREATOR_USERNAME}`, { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ timeout: 15_000 });
const followBtn = page.getByRole('button', { name: RX_FOLLOW_OR_ING }).first();
await expect(followBtn).toBeVisible({ timeout: 10_000 });
});
test('12. Clicking Follow → button text changes to Following', async ({ page }) => {
// Ensure we start in "not following" state via API (idempotent cleanup)
const profileResp = await page.request.get(
`${CONFIG.apiURL}/api/v1/users/by-username/${encodeURIComponent(CREATOR_USERNAME)}`,
);
expect(profileResp.ok()).toBeTruthy();
const creatorPayload = await profileResp.json();
const creator = creatorPayload?.profile ?? creatorPayload?.data?.profile ?? creatorPayload?.data ?? creatorPayload;
const creatorId = creator?.id;
expect(creatorId, 'Creator id must be present').toBeTruthy();
// Force unfollow first so the initial state is deterministic
await page.request.delete(`${CONFIG.apiURL}/api/v1/users/${creatorId}/follow`).catch(() => undefined);
await page.goto(`${BASE}/u/${CREATOR_USERNAME}`, { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ timeout: 15_000 });
const followBtn = page.getByRole('button', { name: RX_FOLLOW_OR_ING }).first();
await expect(followBtn).toBeVisible({ timeout: 10_000 });
await expect(followBtn).toHaveText(RX_FOLLOW, { timeout: 10_000 });
await followBtn.click();
// After click, the button text must flip to "Following" (or translated equivalent)
await expect(followBtn).toHaveText(RX_FOLLOWING, { timeout: 10_000 });
});
test('13. Clicking Following → button text changes back to Follow', async ({ page }) => {
// Ensure starting state: currently following
const profileResp = await page.request.get(
`${CONFIG.apiURL}/api/v1/users/by-username/${encodeURIComponent(CREATOR_USERNAME)}`,
);
expect(profileResp.ok()).toBeTruthy();
const creatorPayload = await profileResp.json();
const creator = creatorPayload?.profile ?? creatorPayload?.data?.profile ?? creatorPayload?.data ?? creatorPayload;
const creatorId = creator?.id;
expect(creatorId).toBeTruthy();
// Force follow so initial state is "Following"
await page.request.post(`${CONFIG.apiURL}/api/v1/users/${creatorId}/follow`).catch(() => undefined);
await page.goto(`${BASE}/u/${CREATOR_USERNAME}`, { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ timeout: 15_000 });
const followBtn = page.getByRole('button', { name: RX_FOLLOW_OR_ING }).first();
await expect(followBtn).toBeVisible({ timeout: 10_000 });
await expect(followBtn).toHaveText(RX_FOLLOWING, { timeout: 10_000 });
await followBtn.click();
await expect(followBtn).toHaveText(RX_FOLLOW, { timeout: 10_000 });
});
test('14. Follower count updates after follow action (verified via API)', async ({ page }) => {
// Get creator id
const profileResp = await page.request.get(
`${CONFIG.apiURL}/api/v1/users/by-username/${encodeURIComponent(CREATOR_USERNAME)}`,
);
expect(profileResp.ok()).toBeTruthy();
const creatorPayload = await profileResp.json();
const creator = creatorPayload?.profile ?? creatorPayload?.data?.profile ?? creatorPayload?.data ?? creatorPayload;
const creatorId = creator?.id;
expect(creatorId).toBeTruthy();
// Force unfollow first → baseline
await page.request.delete(`${CONFIG.apiURL}/api/v1/users/${creatorId}/follow`).catch(() => undefined);
// Follow via API so we can observe followers-count increment deterministically
const followResp = await page.request.post(`${CONFIG.apiURL}/api/v1/users/${creatorId}/follow`);
expect(followResp.ok(), `Follow API failed: ${followResp.status()}`).toBeTruthy();
// GET /api/v1/users/:id/followers must now list the listener
const followersResp = await page.request.get(
`${CONFIG.apiURL}/api/v1/users/${creatorId}/followers?page=1&limit=50`,
);
expect(followersResp.ok(), `Followers API failed: ${followersResp.status()}`).toBeTruthy();
const followersPayload = await followersResp.json();
const raw =
followersPayload?.followers ??
followersPayload?.data?.followers ??
followersPayload?.data ??
followersPayload;
const followers = Array.isArray(raw) ? raw : Array.isArray(raw?.followers) ? raw.followers : [];
expect(Array.isArray(followers), 'followers must be an array').toBeTruthy();
const usernames = followers.map((f: { username?: string }) => f?.username).filter(Boolean);
expect(
usernames,
`Listener (${LISTENER_USERNAME}) must appear in creator followers after follow`,
).toContain(LISTENER_USERNAME);
// Cleanup: unfollow so subsequent runs stay idempotent
await page.request.delete(`${CONFIG.apiURL}/api/v1/users/${creatorId}/follow`).catch(() => undefined);
});
test('15. Cannot follow self — no Follow button on own profile', async ({ page }) => {
await page.goto(`${BASE}/u/${LISTENER_USERNAME}`, { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ timeout: 15_000 });
// FollowButton renders null when current user id === profile id → zero buttons match
const followBtn = page.getByRole('button', { name: RX_FOLLOW_OR_ING });
await expect(followBtn).toHaveCount(0);
// Sanity check: attempting to follow self via API must fail (4xx)
const selfResp = await page.request.get(
`${CONFIG.apiURL}/api/v1/users/by-username/${encodeURIComponent(LISTENER_USERNAME)}`,
);
expect(selfResp.ok()).toBeTruthy();
const selfPayload = await selfResp.json();
const self = selfPayload?.profile ?? selfPayload?.data?.profile ?? selfPayload?.data ?? selfPayload;
const selfId = self?.id;
expect(selfId).toBeTruthy();
const followSelfResp = await page.request.post(`${CONFIG.apiURL}/api/v1/users/${selfId}/follow`);
expect(followSelfResp.status(), 'Following self must be rejected by backend').toBeGreaterThanOrEqual(400);
});
});
// ============================================================================
// 4. FEED PAGE (/feed) — 4 tests
// ============================================================================
test.describe('Social Deep — Feed page (/feed)', () => {
test.beforeEach(async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
});
test('16. /feed loads with heading and fetches tracks', async ({ page }) => {
let feedStatus = 0;
page.on('response', (response) => {
if (/\/api\/v1\/feed(\?|$)/.test(response.url())) {
feedStatus = response.status();
}
});
await navigateTo(page, '/feed');
// h1 "Feed" heading is visible
const h1 = page.getByRole('heading', { level: 1 }).first();
await expect(h1).toBeVisible({ timeout: 15_000 });
// API call must succeed (not 5xx)
await page.waitForTimeout(1_500);
expect(feedStatus, 'Feed API must be called with success status').toBeGreaterThanOrEqual(200);
expect(feedStatus).toBeLessThan(500);
});
test('17. Feed shows either track cards OR an empty-state message', async ({ page }) => {
await navigateTo(page, '/feed');
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ timeout: 15_000 });
await page.waitForTimeout(2_000); // let react-query settle
const articles = page.getByRole('article');
const articleCount = await articles.count();
if (articleCount === 0) {
// Empty state text from i18n (t('feed.emptyTitle') / t('feed.emptyDescription'))
const body = (await page.textContent('body')) ?? '';
expect(
body,
'Empty feed must display an empty-state message (title/description)',
).toMatch(/follow|suivre|seguir|empty|no tracks|no new tracks|aucun/i);
} else {
// Non-empty: first article must expose the expected accessible structure
const firstArticle = articles.first();
await expect(firstArticle).toBeVisible();
}
});
test('18. Infinite scroll loads more tracks OR the load-more sentinel exists', async ({ page }) => {
await navigateTo(page, '/feed');
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ timeout: 15_000 });
await page.waitForTimeout(2_000);
const initialCount = await page.getByRole('article').count();
if (initialCount < 5) {
test.skip(true, 'Not enough tracks to test infinite scroll (need at least 5)');
return;
}
// Scroll to bottom to trigger IntersectionObserver on loadMoreRef
let extraLoaded = false;
for (let i = 0; i < 4 && !extraLoaded; i++) {
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(1_500);
const newCount = await page.getByRole('article').count();
if (newCount > initialCount) {
extraLoaded = true;
}
}
// Either more loaded, OR there genuinely is no next page
if (!extraLoaded) {
// Verify via API that hasNextPage is false (next_cursor is null/undefined)
const resp = await page.request.get(`${CONFIG.apiURL}/api/v1/feed?limit=20`);
expect(resp.ok()).toBeTruthy();
const payload = await resp.json();
const data = payload?.data ?? payload;
const nextCursor = data?.next_cursor ?? null;
expect(
nextCursor,
'If nothing loaded on scroll, backend must confirm no next_cursor',
).toBeFalsy();
} else {
expect(extraLoaded).toBeTruthy();
}
});
test('19. Empty feed shows empty-state when user follows nobody (API contract)', async ({ page }) => {
// We assert the contract: feed endpoint returns a page object with items array
const resp = await page.request.get(`${CONFIG.apiURL}/api/v1/feed?limit=20`);
expect(resp.ok(), `Feed API status: ${resp.status()}`).toBeTruthy();
const payload = await resp.json();
const data = payload?.data ?? payload;
const items = data?.items ?? [];
expect(Array.isArray(items), 'feed response items must be an array').toBeTruthy();
// Navigate to /feed and check that page doesn't break when items is empty
await navigateTo(page, '/feed');
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ timeout: 15_000 });
const body = (await page.textContent('body')) ?? '';
expect(body, 'Feed page must not crash').not.toMatch(/500|Internal Server Error|unexpected error/i);
});
});
// ============================================================================
// 5. SOCIAL HUB (/social) — 5 tests
// ============================================================================
test.describe('Social Deep — Social hub (/social)', () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 }); // sidebar is lg+ only
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
});
test('20. /social loads with sidebar tabs (Fresh Tracks, Explore, Communities)', async ({ page }) => {
await navigateTo(page, '/social');
// Sidebar is `hidden lg:block` — viewport 1280 shows it
const freshTracks = page.getByRole('button', { name: /fresh tracks/i });
const explore = page.getByRole('button', { name: /^explore$/i });
const communities = page.getByRole('button', { name: /communities/i });
await expect(freshTracks).toBeVisible({ timeout: 10_000 });
await expect(explore).toBeVisible();
await expect(communities).toBeVisible();
});
test('21. Fresh Tracks tab is the default and loads community feed', async ({ page }) => {
await navigateTo(page, '/social');
// The "Community Feed" heading (h2) indicates feed tab is active
const communityFeedHeading = page
.getByRole('heading', { name: /community feed/i })
.first();
await expect(communityFeedHeading).toBeVisible({ timeout: 15_000 });
// Fresh Tracks button in sidebar must be in active (outline) variant — test its presence
const freshTracks = page.getByRole('button', { name: /fresh tracks/i });
await expect(freshTracks).toBeVisible();
});
test('22. Explore tab loads trending hashtags and suggested users', async ({ page }) => {
await navigateTo(page, '/social');
const exploreBtn = page.getByRole('button', { name: /^explore$/i });
await expect(exploreBtn).toBeVisible({ timeout: 10_000 });
await exploreBtn.click();
// Explore view renders an h2 "Explore" and sub-sections "Trending" + "Suggested Users"
const exploreHeading = page.getByRole('heading', { name: /^explore$/i }).first();
await expect(exploreHeading).toBeVisible({ timeout: 10_000 });
// The Trending and Suggested Users cards both use h3 labels
const trendingCard = page.getByRole('heading', { name: /^trending$/i, level: 3 }).first();
const suggestedCard = page.getByRole('heading', { name: /suggested users/i, level: 3 }).first();
await expect(trendingCard).toBeVisible();
await expect(suggestedCard).toBeVisible();
});
test('23. Communities tab changes active tab', async ({ page }) => {
await navigateTo(page, '/social');
const communitiesBtn = page.getByRole('button', { name: /communities/i });
await expect(communitiesBtn).toBeVisible({ timeout: 10_000 });
await communitiesBtn.click();
await page.waitForTimeout(800);
// Must not break: page body still has meaningful content and no error banner
const body = (await page.textContent('body')) ?? '';
expect(body).not.toMatch(/500|Internal Server Error/i);
expect(body.length).toBeGreaterThan(200);
});
test('24. Trending Tags section is visible on /social', async ({ page }) => {
await navigateTo(page, '/social');
// Right sidebar has an h3 "Trending Tags"
const trendingTags = page
.getByRole('heading', { name: /trending tags/i, level: 3 })
.first();
await expect(trendingTags).toBeVisible({ timeout: 10_000 });
// At least 1 tag chip must be rendered (fallback tags guarantee this)
const tagChips = trendingTags.locator('xpath=following::span[contains(@class,"bg-muted")]');
const chipCount = await tagChips.count();
expect(chipCount, 'At least one trending tag chip must render').toBeGreaterThan(0);
});
});
// ============================================================================
// 6. PRIVACY (ORIGIN_UI_UX_SYSTEM §13) — 3 tests
// ============================================================================
test.describe('Social Deep — Privacy guarantees', () => {
test.beforeEach(async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
});
test('25. Listen history is NOT shown on another user\'s public profile', async ({ page }) => {
await page.goto(`${BASE}/u/${CREATOR_USERNAME}`, { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ timeout: 15_000 });
const body = (await page.textContent('body')) ?? '';
// ORIGIN rule: listen history is private
expect(body).not.toMatch(/listening history|listen history|recently played|historique d[']écoute|último.*escuchado/i);
});
test('26. Private user info (email, birthdate, gender) is NOT exposed publicly', async ({ page }) => {
await page.goto(`${BASE}/u/${CREATOR_USERNAME}`, { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ timeout: 15_000 });
const html = await page.content();
// Email addresses for seeded accounts must never leak on public profile
expect(html).not.toContain('@veza.music');
// Phone numbers (common leakage pattern)
expect(html).not.toMatch(/\+\d{1,3}\s?\d{6,}/);
// Birthdate / gender specific labels (not usually rendered on public profile)
const body = (await page.textContent('body')) ?? '';
expect(body).not.toMatch(/birthdate|date of birth|date de naissance|fecha de nacimiento/i);
});
test('27. Public popularity metrics (play counts, likes) are NOT shown on public profile', async ({ page }) => {
// ORIGIN_UI_UX_SYSTEM §13 — métriques de popularité PRIVÉES (créateur seulement)
await page.goto(`${BASE}/u/${CREATOR_USERNAME}`, { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ timeout: 15_000 });
const body = (await page.textContent('body')) ?? '';
// No per-track play counts / plays label as a public metric. We tolerate
// the "Tracks" stat (number of uploaded tracks), but "plays" or "écoutes"
// as a displayed counter violates the spec.
// We specifically forbid labels like "12,345 plays" or "X likes" on public view.
expect(
body,
'Public profile must NOT display play-count metrics (ORIGIN_UI_UX_SYSTEM §13)',
).not.toMatch(/\d+\s*(plays|écoutes|reproducciones)\b/i);
// No "likes" or "hearts" aggregate counter as a public popularity signal
expect(
body,
'Public profile must NOT display global likes counter',
).not.toMatch(/\btotal likes\b|\btotal j[']aime\b|\btotal me gusta\b/i);
});
});
// ============================================================================
// 7. NAVIGATION — 3 tests
// ============================================================================
test.describe('Social Deep — Navigation', () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
});
test('28. Clicking a track card in feed navigates to /tracks/:id', async ({ page }) => {
await navigateTo(page, '/feed');
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ timeout: 15_000 });
await page.waitForTimeout(2_000);
const articles = page.getByRole('article');
const count = await articles.count();
if (count === 0) {
test.skip(true, 'No tracks in feed to click — seed may be empty');
return;
}
const firstArticle = articles.first();
await firstArticle.scrollIntoViewIfNeeded();
await firstArticle.click();
// Wait for navigation to /tracks/:id (the feed card onTrackClick navigates there)
await page.waitForURL(/\/tracks\/[\w-]+/, { timeout: 10_000 }).catch(async () => {
// Some cards may intercept clicks differently — retry clicking a link inside
const link = firstArticle.getByRole('link').first();
if (await link.isVisible({ timeout: 2_000 }).catch(() => false)) {
await link.click();
await page.waitForURL(/\/tracks\/[\w-]+/, { timeout: 10_000 });
}
});
expect(page.url()).toMatch(/\/tracks\/[\w-]+/);
});
test('29. Clicking a track on a profile navigates to the track detail page', async ({ page }) => {
await page.goto(`${BASE}/u/${CREATOR_USERNAME}`, { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ timeout: 15_000 });
await page.waitForTimeout(2_000);
// Profile Tracks tab renders <Link to="/tracks/{id}"> wrappers
const trackLink = page.locator('a[href^="/tracks/"]').first();
const hasTrack = await trackLink.isVisible({ timeout: 5_000 }).catch(() => false);
if (!hasTrack) {
test.skip(true, `${CREATOR_USERNAME} has no tracks on profile — seed may be empty`);
return;
}
const href = await trackLink.getAttribute('href');
expect(href).toMatch(/^\/tracks\/[\w-]+$/);
await trackLink.click();
await page.waitForURL(/\/tracks\/[\w-]+/, { timeout: 10_000 });
expect(page.url()).toMatch(/\/tracks\/[\w-]+/);
});
test('30. Browser back navigation restores previous page correctly', async ({ page }) => {
// Start on /social
await navigateTo(page, '/social');
await expect(page).toHaveURL(/\/social$/, { timeout: 15_000 });
// Navigate forward to a profile
await page.goto(`${BASE}/u/${CREATOR_USERNAME}`, { waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(new RegExp(`/u/${CREATOR_USERNAME}$`), { timeout: 15_000 });
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ timeout: 15_000 });
// Click browser back button
await page.goBack({ waitUntil: 'domcontentloaded' });
// Must land back on /social
await expect(page).toHaveURL(/\/social$/, { timeout: 10_000 });
const body = (await page.textContent('body')) ?? '';
expect(body).not.toMatch(/500|Internal Server Error/i);
});
});