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>
668 lines
30 KiB
TypeScript
668 lines
30 KiB
TypeScript
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);
|
||
});
|
||
});
|