veza/tests/e2e/30-embed-and-share.spec.ts
senke 806bd77d09
Some checks failed
Veza CI / Rust (Stream Server) (push) Successful in 5m26s
Security Scan / Secret Scanning (gitleaks) (push) Failing after 56s
Veza CI / Backend (Go) (push) Failing after 8m39s
Veza CI / Frontend (Web) (push) Failing after 16m22s
Veza CI / Notify on failure (push) Successful in 11s
E2E Playwright / e2e (full) (push) Successful in 20m30s
feat(embed): /embed/track/:id widget + /oembed envelope + per-track OG tags (W3 Day 15)
End-to-end embed pipeline. Standalone HTML widget for iframes, oEmbed
JSON for unfurlers (Twitter/Discord/Slack), runtime per-track OG +
Twitter player card on the SPA. Share-token storage + handlers were
already in place from earlier — Day 15 only adds the embed surface.

Backend (root router, no /api/v1 prefix — matches what scrapers expect)
- internal/handlers/embed_handler.go : EmbedTrack renders inline HTML
  with OG tags + <audio controls>. DMCA-blocked tracks 451, private
  tracks 404 (don't leak existence). X-Frame-Options=ALLOWALL +
  CSP frame-ancestors=* so the page can be iframed by third parties.
  OEmbed handler accepts ?url=&format=json, validates the URL points
  at /tracks/:id, returns a type=rich envelope with an iframe HTML
  string. ?maxwidth clamped to [240, 1280].
- internal/api/routes_embed.go : registers the two endpoints.
- internal/handlers/embed_handler_test.go : pure-function coverage
  for extractTrackIDFromURL (8 cases incl. trailing slash, query
  string, hash fragment, subpath) + parseSafeInt (overflow + non-digit
  rejection).

Frontend
- apps/web/src/features/tracks/hooks/useTrackOpenGraph.ts : runtime
  injection of og:* + twitter:player + <link rel=alternate>
  (oEmbed discovery) into document.head. Limitation noted inline —
  pure HTML scrapers don't see these ; the embed widget itself
  carries server-rendered OG tags so unfurlers always work.
- TrackDetailPage : wires useTrackOpenGraph(track) on render.

E2E (tests/e2e/30-embed-and-share.spec.ts)
- 30. /embed/track/:id renders HTML with OG tags + audio src.
- 31. /oembed returns valid JSON envelope (rich type, iframe HTML).
- 32. /oembed rejects non-track URLs (400).
- 33. share-token roundtrip — creator mints, anonymous resolves via
  /api/v1/tracks/shared/:token (re-uses existing share handler ;
  Day 15 didn't add new share infra, just covers it under the embed
  acceptance gate).

Acceptance (Day 15) : embed widget Twitter card preview ✓ (OG tags
present), oEmbed JSON valid ✓, share token roundtrip ✓.

W3 verification gate : Redis Sentinel ✓ · MinIO distribué ✓ ·
CDN signed URLs ✓ · DMCA E2E ✓ · embed + share token ✓ · all 5
W3 days shipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:49:54 +02:00

158 lines
5.8 KiB
TypeScript

import { test, expect } from '@chromatic-com/playwright';
import { CONFIG } from './helpers';
/**
* v1.0.9 W3 Day 15 — embed widget + oEmbed + share token roundtrip.
*
* Tests :
* 30. GET /embed/track/:id renders standalone HTML with OG tags + a
* playable <audio> source pointing at /api/v1/tracks/:id/stream.
* 31. GET /oembed?url=... returns a valid oEmbed JSON envelope
* (type=rich, html iframe).
* 32. Share-token roundtrip — creator generates a token for a private
* track, anonymous user fetches it via /api/v1/tracks/shared/:token.
*
* The share-token test relies on the existing /api/v1/tracks/:id/share
* endpoint (already wired pre-Day 15). Day 15's contribution is the
* embed widget + oEmbed pair on the public side.
*/
interface ApiEnvelope<T> {
success?: boolean;
data: T;
error?: { code?: string; message?: string };
}
async function loginAsCreator(
request: import('@playwright/test').APIRequestContext,
): Promise<{ accessToken: string }> {
const resp = await request.post(`${CONFIG.apiURL}/api/v1/auth/login`, {
data: {
email: CONFIG.users.creator.email,
password: CONFIG.users.creator.password,
remember_me: false,
},
});
expect(resp.status()).toBe(200);
const body = (await resp.json()) as ApiEnvelope<{
token: { access_token: string };
}>;
const data = body.data ?? (body as unknown as { token: { access_token: string } });
return { accessToken: data.token.access_token };
}
async function firstCreatorTrackID(
request: import('@playwright/test').APIRequestContext,
token: string,
): Promise<string> {
const resp = await request.get(`${CONFIG.apiURL}/api/v1/tracks`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(resp.status()).toBe(200);
const body = (await resp.json()) as ApiEnvelope<{ tracks: { id: string }[] }>;
const tracks = body.data?.tracks ?? [];
expect(tracks.length, 'seed creator account must have at least one track').toBeGreaterThan(0);
return tracks[0].id;
}
test.describe('EMBED — widget + oEmbed + share token (v1.0.9 W3 Day 15)', () => {
test('30. embed/track/:id renders standalone HTML with OG tags', async ({ request }) => {
test.setTimeout(20_000);
const { accessToken } = await loginAsCreator(request);
const trackID = await firstCreatorTrackID(request, accessToken);
const resp = await request.get(`${CONFIG.apiURL}/embed/track/${trackID}`);
expect(resp.status(), 'public embed must be reachable without auth').toBe(200);
const ct = resp.headers()['content-type'] || '';
expect(ct.includes('text/html')).toBeTruthy();
expect(resp.headers()['x-frame-options']).toBe('ALLOWALL');
const html = await resp.text();
// Open Graph + Twitter card.
expect(html).toContain('property="og:type"');
expect(html).toContain('property="og:title"');
expect(html).toContain('name="twitter:card"');
// Audio element pointing back at the stream API.
expect(html).toContain(`/api/v1/tracks/${trackID}/stream`);
// <audio> element present.
expect(html).toMatch(/<audio[^>]*controls/);
});
test('31. /oembed returns valid oEmbed JSON envelope', async ({ request }) => {
test.setTimeout(20_000);
const { accessToken } = await loginAsCreator(request);
const trackID = await firstCreatorTrackID(request, accessToken);
const trackPageURL = `${CONFIG.baseURL}/tracks/${trackID}`;
const resp = await request.get(
`${CONFIG.apiURL}/oembed?url=${encodeURIComponent(trackPageURL)}&format=json`,
);
expect(resp.status()).toBe(200);
const body = (await resp.json()) as {
version: string;
type: string;
provider_name: string;
html: string;
width: number;
height: number;
title: string;
};
expect(body.version).toBe('1.0');
expect(body.type).toBe('rich');
expect(body.provider_name).toBe('Veza');
expect(body.html).toMatch(/<iframe[^>]+src="[^"]+"/);
expect(body.html).toContain(`/embed/track/${trackID}`);
expect(body.width).toBeGreaterThanOrEqual(240);
expect(body.height).toBeGreaterThan(0);
expect(body.title.length).toBeGreaterThan(0);
});
test('32. /oembed rejects non-track URLs', async ({ request }) => {
const resp = await request.get(
`${CONFIG.apiURL}/oembed?url=${encodeURIComponent('https://veza.fr/playlists/abc')}&format=json`,
);
expect(resp.status()).toBe(400);
});
test('33. share token roundtrip — create + resolve via /tracks/shared/:token', async ({
request,
}) => {
test.setTimeout(30_000);
const { accessToken } = await loginAsCreator(request);
const trackID = await firstCreatorTrackID(request, accessToken);
// Creator issues a share token.
const createResp = await request.post(
`${CONFIG.apiURL}/api/v1/tracks/${trackID}/share`,
{
headers: { Authorization: `Bearer ${accessToken}` },
data: { permissions: 'read' },
},
);
expect(createResp.status(), 'creator must be able to mint a share token').toBe(200);
const createBody = (await createResp.json()) as ApiEnvelope<{
share: { share_token: string };
}>;
const shareToken =
createBody.data?.share?.share_token ??
(createBody as unknown as { share: { share_token: string } }).share.share_token;
expect(shareToken.length).toBeGreaterThan(0);
// Anonymous resolve via the public endpoint.
const resolveResp = await request.get(
`${CONFIG.apiURL}/api/v1/tracks/shared/${encodeURIComponent(shareToken)}`,
);
expect(resolveResp.status(), 'share token must resolve without auth').toBe(200);
const resolveBody = (await resolveResp.json()) as ApiEnvelope<{
track: { id: string };
share: { share_token: string };
}>;
const resolved = resolveBody.data ?? (resolveBody as unknown as { track: { id: string } });
expect(resolved.track.id).toBe(trackID);
});
});