You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
senzapaura_es/e2e/sindicacion.e2e.ts

207 lines
7.2 KiB

import { expect, test } from '@playwright/test';
test.describe('Feed del blog', () => {
test('se sirve como RSS y lo anuncia toda la web', async ({ page, request }) => {
const respuesta = await request.get('/blog/rss.xml');
expect(respuesta.status()).toBe(200);
expect(respuesta.headers()['content-type']).toContain('application/rss+xml');
// Sin el `<link rel="alternate">` un lector no lo encuentra solo.
await page.goto('/');
await expect(page.locator('link[type="application/rss+xml"]')).toHaveAttribute(
'href',
'/blog/rss.xml'
);
});
test('lleva todas las entradas, de la más reciente primero', async ({ request }) => {
const xml = await (await request.get('/blog/rss.xml')).text();
const fechas = [...xml.matchAll(/<pubDate>(.*?)<\/pubDate>/g)].map((m) =>
new Date(m[1]).getTime()
);
expect(fechas.length).toBeGreaterThan(1);
expect(fechas).toEqual([...fechas].sort((a, b) => b - a));
});
test('las URLs son absolutas', async ({ request }) => {
// Un feed se lee fuera del sitio: una ruta relativa no lleva a ninguna
// parte.
const xml = await (await request.get('/blog/rss.xml')).text();
const enlaces = [...xml.matchAll(/<link>(.*?)<\/link>/g)].map((m) => m[1]);
expect(enlaces.length).toBeGreaterThan(0);
expect(enlaces.every((e) => e.startsWith('https://'))).toBe(true);
});
});
test.describe('Mapa del sitio', () => {
test('se sirve como XML y lo anuncia robots.txt', async ({ request }) => {
const respuesta = await request.get('/sitemap.xml');
expect(respuesta.status()).toBe(200);
expect(respuesta.headers()['content-type']).toContain('xml');
const robots = await (await request.get('/robots.txt')).text();
expect(robots).toContain('Sitemap: https://senzapaura.es/sitemap.xml');
// Y lo que no es contenido, fuera del rastreo.
for (const privada of ['/cuenta', '/carrito', '/descargas', '/acceso']) {
expect(robots).toContain(`Disallow: ${privada}`);
}
});
test('incluye el catálogo entero', async ({ request }) => {
const xml = await (await request.get('/sitemap.xml')).text();
// Una muestra de cada tipo: si algo se cayera del mapa, sería por tipo.
expect(xml).toContain('/canciones/dorina');
expect(xml).toContain('/musica/baladas-romanticas');
expect(xml).toContain('/albumes/singles-baladas-romanticas');
Las fichas de género salen del taller y tienen su propia sección Con lo que pediste: portada con mapa de Iberoamérica, buscador y un aside que empieza por Inicio, Los principios, Glosario y Letristas antes de la lista de géneros. El mapa es interactivo de verdad: cada país se pinta según lo que tenga —marcado si es cuna de algún género, tenue si solo lo adoptó, apagado si no hay nada—, y al pulsarlo la lista de abajo se parte en «nació aquí» y «también se canta ahí». Esa diferencia es lo que un mapa cuenta mejor que una lista. Los contornos son de `BlankMap-Americas.svg` de Wikimedia, en dominio público, recortados a los veinte países de Iberoamérica en América. De 640 KB a 36 KB comprimidos, y solo en esta ruta: coordenadas a un decimal y fuera los 134 islotes que a este tamaño miden menos de un píxel. Va por script y no pegado a mano, para poder rehacerlo. Cada país lleva además un punto de agarre calculado —el promedio de los vértices de su costa, no el centro de su rectángulo, que en Cuba cae en el mar—: sin él, Puerto Rico mide cuatro píxeles y una isla alargada no hay quien la acierte. «Letristas» es nueva: las mismas personas que salen repartidas en nueve fichas, aquí juntas, con su papel en cada género y sus obras. La página del capítulo pasa a `$lib/components/Capitulo.svelte` y la usan las dos secciones: son setecientas líneas, y copiarlas habría sido dos sitios donde arreglar cada cosa. Las direcciones viejas redirigen con 301 —`/taller/bolero` a `/generos/bolero`— resolviendo contra el catálogo de capítulos, no contra una lista escrita a mano que alguien tenga que ampliar. Un enlace compartido no puede empezar a dar 404 porque hayamos reordenado el menú. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 weeks ago
expect(xml).toContain('/generos/bachata');
expect(xml).toContain('/blog/lo-que-no-cabe-en-una-cancion');
expect(xml).toContain('/etiquetas/desamor');
});
test('no publica lo que depende de quién mira', async ({ request }) => {
const xml = await (await request.get('/sitemap.xml')).text();
for (const privada of [
'/cuenta',
'/carrito',
'/favoritos',
'/entrar',
'/descargas',
'/admin'
]) {
expect(xml).not.toContain(`<loc>https://senzapaura.es${privada}`);
}
});
});
test.describe('Imagen al compartir', () => {
test('una página sin portada propia usa la de reserva', async ({ page }) => {
await page.goto('/taller');
await expect(page.locator('meta[property="og:image"]')).toHaveAttribute(
'content',
'https://senzapaura.es/og.jpg'
);
});
test('un tema comparte su propia portada', async ({ page }) => {
await page.goto('/canciones/como-si-nada');
const contenido = await page.locator('meta[property="og:image"]').getAttribute('content');
expect(contenido).not.toContain('/og.jpg');
expect(contenido).toContain('/images/');
});
test('la imagen de reserva existe y tiene la proporción de una tarjeta', async ({ request }) => {
const respuesta = await request.get('/og.jpg');
expect(respuesta.status()).toBe(200);
expect(respuesta.headers()['content-type']).toContain('image/jpeg');
});
});
Textos legales, fuentes propias y ajustes de botones Cinco páginas legales en /legal, enlazadas desde el pie de todas las páginas: información legal, condiciones de uso, privacidad, cookies y condiciones de venta. Son borradores con los datos del titular marcados entre corchetes y un aviso de que hace falta revisión de un abogado antes de vender: cubren lo que pide la ley española, no son un documento validado. La política de cookies y la de privacidad están escritas mirando el código, no copiadas de una plantilla: dos cookies, las dos necesarias, y dos terceros —Stripe y Resend— que reciben lo imprescindible. Y eran tres, porque las tipografías venían de Google: cada visita mandaba la IP del visitante a un tercero sin pedirlo y sin que aportara nada. Ahora se sirven desde el propio dominio. Cargar la web ya no contacta con ningún dominio ajeno, y hay una prueba que lo comprueba. Aparte: el símbolo de marca registrada en el aviso de derechos, los botones de reproducir de las tarjetas pasan de 48 a 36 píxeles —eran casi una quinta parte de la portada, y cambiaban de proporción según la tarjeta— y el de ver el carrito solo aparece cuando hay algo dentro. De paso se arregla un fallo de tipos que estaba latente: `resolve()` tiene una sobrecarga por ruta y no acepta una unión, así que la navegación dejaba de compilar en cuanto se añadían rutas. Ahora se resuelve en `site.ts`, una a una. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
test.describe('Legal', () => {
test('las cinco páginas están y se enlazan desde el pie', async ({ page }) => {
await page.goto('/');
const pie = page.getByRole('navigation', { name: 'Legal' });
for (const titulo of [
'Información legal',
'Condiciones de uso',
'Política de privacidad',
'Política de cookies',
'Condiciones de venta'
]) {
await expect(pie.getByRole('link', { name: titulo })).toBeVisible();
}
});
test('cada texto legal dice cuándo se revisó', async ({ page }) => {
// Un aviso legal sin fecha no sirve: quien lo lee necesita saber a qué
// versión se está acogiendo.
await page.goto('/legal/privacidad');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Política de privacidad');
await expect(page.getByText(/Última revisión/)).toBeVisible();
});
test('desde un texto legal se llega a los demás', async ({ page }) => {
await page.goto('/legal/cookies');
const lateral = page.getByRole('navigation', { name: 'Textos legales' });
await expect(lateral.getByRole('link', { name: 'Política de cookies' })).toHaveAttribute(
'aria-current',
'page'
);
await lateral.getByRole('link', { name: 'Condiciones de venta' }).click();
await expect(page).toHaveURL('/legal/condiciones-de-venta');
});
/*
* El índice de apartados sale del Markdown en bruto y las anclas las pone un
* plugin al compilarlo: son dos caminos distintos que tienen que dar la misma
* cadena. Si se separan, los enlaces siguen pintándose y no llevan a ningún
* sitio, que es la clase de avería que no da error y no ve nadie.
*/
for (const slug of [
'aviso-legal',
'condiciones-de-uso',
'privacidad',
'cookies',
'condiciones-de-venta'
]) {
test(`el índice de /legal/${slug} apunta a apartados que existen`, async ({ page }) => {
await page.goto(`/legal/${slug}`);
const indice = page.getByRole('navigation', { name: 'Apartados de esta página' });
const destinos = await indice
.getByRole('link')
.evaluateAll((enlaces) => enlaces.map((e) => e.getAttribute('href') ?? ''));
expect(destinos.length).toBeGreaterThan(2);
for (const destino of destinos) {
await expect(page.locator(`h2${destino}`)).toHaveCount(1);
}
});
}
test('pinchar un apartado lo deja visible bajo la cabecera', async ({ page }) => {
// La cabecera es pegajosa: sin `scroll-margin-top` el salto deja el título
// justo debajo de ella y parece que no ha pasado nada.
await page.goto('/legal/condiciones-de-venta');
const indice = page.getByRole('navigation', { name: 'Apartados de esta página' });
await indice.getByRole('link', { name: 'Derecho de desistimiento' }).click();
const apartado = page.locator('h2#derecho-de-desistimiento');
await expect(apartado).toBeInViewport();
const debajo = await apartado.evaluate((h) => {
const cabecera = document.querySelector('header')!.getBoundingClientRect().bottom;
return h.getBoundingClientRect().top >= cabecera;
});
expect(debajo).toBe(true);
});
Textos legales, fuentes propias y ajustes de botones Cinco páginas legales en /legal, enlazadas desde el pie de todas las páginas: información legal, condiciones de uso, privacidad, cookies y condiciones de venta. Son borradores con los datos del titular marcados entre corchetes y un aviso de que hace falta revisión de un abogado antes de vender: cubren lo que pide la ley española, no son un documento validado. La política de cookies y la de privacidad están escritas mirando el código, no copiadas de una plantilla: dos cookies, las dos necesarias, y dos terceros —Stripe y Resend— que reciben lo imprescindible. Y eran tres, porque las tipografías venían de Google: cada visita mandaba la IP del visitante a un tercero sin pedirlo y sin que aportara nada. Ahora se sirven desde el propio dominio. Cargar la web ya no contacta con ningún dominio ajeno, y hay una prueba que lo comprueba. Aparte: el símbolo de marca registrada en el aviso de derechos, los botones de reproducir de las tarjetas pasan de 48 a 36 píxeles —eran casi una quinta parte de la portada, y cambiaban de proporción según la tarjeta— y el de ver el carrito solo aparece cuando hay algo dentro. De paso se arregla un fallo de tipos que estaba latente: `resolve()` tiene una sobrecarga por ruta y no acepta una unión, así que la navegación dejaba de compilar en cuanto se añadían rutas. Ahora se resuelve en `site.ts`, una a una. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
});
test.describe('Sin terceros', () => {
test('cargar la web no contacta con ningún dominio ajeno', async ({ page }) => {
// Las tipografías se sirven desde aquí. Cargarlas de Google mandaba la IP
// de cada visitante a un tercero en cada página, sin pedirlo.
const ajenos = new Set<string>();
page.on('request', (r) => {
const host = new URL(r.url()).host;
if (!host.startsWith('localhost') && !host.startsWith('127.0.0.1')) ajenos.add(host);
});
for (const ruta of ['/', '/musica', '/canciones/dorina', '/entrar']) {
await page.goto(ruta);
}
expect([...ajenos]).toEqual([]);
});
});

Powered by TurnKey Linux.