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
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>
158 lines
5.8 KiB
TypeScript
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);
|
|
});
|
|
});
|