Web de Senza Paura: catálogo, taller, blog y tienda
Sitio del letrista y compositor, con SvelteKit 2 y Svelte 5.
El catálogo es contenido, no base de datos: estilos, álbumes, canciones y
capítulos son Markdown en `src/content/`, validados al compilar y compilados
dentro del bundle, así que recorrer la música no hace ni una consulta. La base
de datos guarda solo lo que generan las personas: cuentas, carritos, pedidos,
compras, valoraciones y favoritos.
Lo que hay:
- Música agrupada por estilos, con página propia para cada estilo, álbum y
tema, buscador y tabla ordenable.
- Reproductor persistente que sobrevive a la navegación. Suena un fragmento;
el archivo completo solo se obtiene comprando.
- Taller de letras: los principios del oficio y una ficha de creación por
estilo hispanoamericano.
- Blog con feed RSS, y etiquetas que cruzan blog y catálogo.
- Tienda con cuentas por código de correo, carrito, Stripe y descargas
protegidas.
- Panel de contenido en `/admin` que escribe los mismos Markdown.
- Tema oscuro de marca y su inversión clara, aplicado antes de pintar.
Lo que falta y no es código: las claves de Stripe y de correo, el tratamiento
del IVA, las páginas de condiciones y privacidad, y el material real. Las
canciones de ejemplo son inventadas y están para sustituirse; el audio son
marcadores salvo un tema.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
|
|
|
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.com/sitemap.xml');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
expect(xml).toContain('/taller/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.com${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.com/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([]);
|
|
|
|
|
});
|
|
|
|
|
});
|