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, type Page } from '@playwright/test';
|
|
|
|
|
|
|
|
|
|
/** El tamaño del pack, tal como lo dice `$lib/tienda`. */
|
|
|
|
|
const CANCIONES_POR_PACK = 10;
|
|
|
|
|
|
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
|
|
|
/** Añade un tema desde el listado de un álbum, por su título. */
|
|
|
|
|
async function anadir(page: Page, titulo: string) {
|
Una sola tabla de canciones, y la cola se puede llenar
Había dos listados: una tabla ordenable para el catálogo y una lista
aparte para los discos. Cada arreglo había que hacerlo dos veces y el
resultado era que la portada estaba en una y el año en la otra. Ahora es
un solo componente, y lo que cambia son las columnas de contexto, que se
encienden por props: dentro de un disco, la columna «Álbum» diría diez
veces lo mismo.
Solo recibe canciones. El estilo, el disco y la portada los resuelve
contra el catálogo que ya baja en `page.data`, así que quien la usa no
tiene que armar filas a mano, que era lo que obligaba a repetir código.
Cada fila lleva su portada —con la ampliada al pasar el ratón—, y debajo
del título van los géneros y las etiquetas en vez del primer verso: la
letra está entera en la ficha, y aquí lo que sirve para elegir es de qué
está hecho el tema. Las etiquetas se cortan por número, con un «+2» al
final: recortarlas con un degradado dejaba «Autoengaño» en «Autoeng», que
no parece una elección sino algo roto.
La cola ya se puede llenar. Hasta ahora solo se podía sustituir —cada
play la vaciaba y la llenaba con su contexto—, así que no había forma de
armar una escucha cogiendo temas de discos distintos. `encolar()` es la
otra mitad, y con la cola vacía arranca, porque un botón que no hace nada
es peor que no tenerlo.
Los géneros de una canción entran en el modelo: `cancion_genero` existía
en la base y no llegaba a la web. Son cosa distinta del estilo —el estilo
es cómo se ordena la obra aquí y es uno solo; los géneros son de qué está
hecha la canción y pueden ser varios— y llevan al glosario.
De paso: las letras largas van a dos columnas de 37ch, que es el mínimo
que deja caber dos y a la vez el verso más largo del catálogo —importa
afinarlo, porque en una letra dónde acaba cada verso es parte del texto—;
la página baja de 2808 a 2023 píxeles. Contacto sale de la barra de menú
y se queda solo en el pie. Y el titular pasa a «Letrista y creador
musical».
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 weeks ago
|
|
|
// Dentro de la tabla de temas, no en cualquier sitio: el título del disco se
|
|
|
|
|
// repite en las migas de pan y en la cabecera.
|
|
|
|
|
const fila = page.getByRole('row').filter({ hasText: titulo }).first();
|
|
|
|
|
await fila.getByRole('button', { name: 'Añadir', exact: true }).click();
|
|
|
|
|
await expect(fila.getByRole('button', { name: 'Quitar', exact: true })).toBeVisible();
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Deja el carrito con un pack completo.
|
|
|
|
|
*
|
|
|
|
|
* Recorre discos añadiendo lo que haya a la venta hasta llegar al número, en
|
|
|
|
|
* vez de nombrar los temas uno a uno: así el tamaño del pack puede cambiar
|
|
|
|
|
* —pasó de cinco a diez— sin que haya que reescribir la lista, y añadir o
|
|
|
|
|
* quitar una canción del catálogo no rompe la prueba.
|
|
|
|
|
*/
|
|
|
|
|
async function llenarUnPack(page: Page, cuantas = CANCIONES_POR_PACK) {
|
|
|
|
|
let puestas = 0;
|
|
|
|
|
|
|
|
|
|
for (const album of ['como-si-nada', 'dos-orillas', 'ruido-blanco', 'preludios-para-piano']) {
|
|
|
|
|
if (puestas >= cuantas) break;
|
|
|
|
|
await page.goto(`/albumes/${album}`);
|
|
|
|
|
|
Una sola tabla de canciones, y la cola se puede llenar
Había dos listados: una tabla ordenable para el catálogo y una lista
aparte para los discos. Cada arreglo había que hacerlo dos veces y el
resultado era que la portada estaba en una y el año en la otra. Ahora es
un solo componente, y lo que cambia son las columnas de contexto, que se
encienden por props: dentro de un disco, la columna «Álbum» diría diez
veces lo mismo.
Solo recibe canciones. El estilo, el disco y la portada los resuelve
contra el catálogo que ya baja en `page.data`, así que quien la usa no
tiene que armar filas a mano, que era lo que obligaba a repetir código.
Cada fila lleva su portada —con la ampliada al pasar el ratón—, y debajo
del título van los géneros y las etiquetas en vez del primer verso: la
letra está entera en la ficha, y aquí lo que sirve para elegir es de qué
está hecho el tema. Las etiquetas se cortan por número, con un «+2» al
final: recortarlas con un degradado dejaba «Autoengaño» en «Autoeng», que
no parece una elección sino algo roto.
La cola ya se puede llenar. Hasta ahora solo se podía sustituir —cada
play la vaciaba y la llenaba con su contexto—, así que no había forma de
armar una escucha cogiendo temas de discos distintos. `encolar()` es la
otra mitad, y con la cola vacía arranca, porque un botón que no hace nada
es peor que no tenerlo.
Los géneros de una canción entran en el modelo: `cancion_genero` existía
en la base y no llegaba a la web. Son cosa distinta del estilo —el estilo
es cómo se ordena la obra aquí y es uno solo; los géneros son de qué está
hecha la canción y pueden ser varios— y llevan al glosario.
De paso: las letras largas van a dos columnas de 37ch, que es el mínimo
que deja caber dos y a la vez el verso más largo del catálogo —importa
afinarlo, porque en una letra dónde acaba cada verso es parte del texto—;
la página baja de 2808 a 2023 píxeles. Contacto sale de la barra de menú
y se queda solo en el pie. Y el titular pasa a «Letrista y creador
musical».
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 weeks ago
|
|
|
const botones = page.getByRole('table').getByRole('button', { name: 'Añadir', exact: true });
|
|
|
|
|
while (puestas < cuantas && (await botones.count()) > 0) {
|
|
|
|
|
await botones.first().click();
|
|
|
|
|
// El botón cambia a «Quitar», así que la cuenta de «Añadir» baja sola.
|
|
|
|
|
await expect(botones).toHaveCount((await botones.count()) - 1 + 1 - 1);
|
|
|
|
|
puestas++;
|
|
|
|
|
}
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect(puestas, 'no hay bastantes temas a la venta para llenar un pack').toBe(cuantas);
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test.describe('Carrito', () => {
|
|
|
|
|
test('se puede llenar sin haber entrado', async ({ page }) => {
|
|
|
|
|
await page.goto('/albumes/como-si-nada');
|
|
|
|
|
|
|
|
|
|
const carrito = page.getByRole('link', { name: /^Carrito/ });
|
|
|
|
|
await anadir(page, 'Media verdad');
|
|
|
|
|
|
|
|
|
|
await expect(carrito).toContainText('1');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('el pago se bloquea mientras el pack está a medias', async ({ page }) => {
|
|
|
|
|
await page.goto('/albumes/como-si-nada');
|
|
|
|
|
await anadir(page, 'Media verdad');
|
|
|
|
|
await page.goto('/carrito');
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
page.getByText(`Faltan ${CANCIONES_POR_PACK - 1} canciones para completar el pack.`).first()
|
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
|
|
|
).toBeVisible();
|
|
|
|
|
// Sin sesión el botón de pagar ni siquiera aparece: primero hay que entrar.
|
|
|
|
|
await expect(page.getByRole('link', { name: 'Entrar para comprar' })).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('propone canciones para cerrar el pack', async ({ page }) => {
|
|
|
|
|
await page.goto('/albumes/como-si-nada');
|
|
|
|
|
await anadir(page, 'Media verdad');
|
|
|
|
|
await page.goto('/carrito');
|
|
|
|
|
|
|
|
|
|
const sugerencias = page.getByRole('heading', { level: 2, name: 'Completa el pack' });
|
|
|
|
|
await expect(sugerencias).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('un pack completo cuenta y se puede pagar', async ({ page }) => {
|
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
|
|
|
await llenarUnPack(page);
|
|
|
|
|
await page.goto('/carrito');
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText(`1 pack de ${CANCIONES_POR_PACK} canciones.`)).toBeVisible();
|
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
|
|
|
await expect(page.getByText('1,50 €').first()).toBeVisible();
|
|
|
|
|
// Al estar completo, ya no hay nada que sugerir.
|
|
|
|
|
await expect(page.getByRole('heading', { level: 2, name: 'Completa el pack' })).toHaveCount(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('se pueden quitar canciones', async ({ page }) => {
|
|
|
|
|
await page.goto('/albumes/como-si-nada');
|
|
|
|
|
await anadir(page, 'Media verdad');
|
|
|
|
|
await page.goto('/carrito');
|
|
|
|
|
|
|
|
|
|
await page.getByRole('button', { name: 'Quitar' }).first().click();
|
|
|
|
|
await expect(page.getByText('No has añadido ninguna canción todavía.')).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test.describe('Modal de acceso', () => {
|
|
|
|
|
test('«Entrar» abre el diálogo sin salir de la página', async ({ page }) => {
|
|
|
|
|
await page.goto('/musica');
|
|
|
|
|
|
|
|
|
|
await page.locator('header').getByRole('link', { name: 'Entrar', exact: true }).click();
|
|
|
|
|
|
|
|
|
|
const dialogo = page.locator('dialog.acceso');
|
|
|
|
|
await expect(dialogo).toBeVisible();
|
|
|
|
|
// Sigue siendo /musica: entrar no debería sacarte de donde estabas.
|
|
|
|
|
await expect(page).toHaveURL(/\/musica$/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('tiene las dos mitades: imagen a un lado y formulario al otro', async ({ page }) => {
|
|
|
|
|
await page.setViewportSize({ width: 1280, height: 900 });
|
|
|
|
|
await page.goto('/musica');
|
|
|
|
|
await page.locator('header').getByRole('link', { name: 'Entrar', exact: true }).click();
|
|
|
|
|
|
|
|
|
|
const imagen = page.locator('dialog.acceso .ilustracion');
|
|
|
|
|
const formulario = page.locator('dialog.acceso .panel');
|
|
|
|
|
await expect(imagen).toBeVisible();
|
|
|
|
|
await expect(formulario).toBeVisible();
|
|
|
|
|
|
|
|
|
|
const izquierda = (await imagen.boundingBox())!;
|
|
|
|
|
const derecha = (await formulario.boundingBox())!;
|
|
|
|
|
// La imagen entera queda a la izquierda del formulario, sin solaparse.
|
|
|
|
|
expect(izquierda.x + izquierda.width).toBeLessThanOrEqual(derecha.x + 1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('el foco entra en el campo del correo, no en el campo trampa', async ({ page }) => {
|
|
|
|
|
// `showModal()` enfoca lo primero que encuentra alcanzable, y lo primero
|
|
|
|
|
// del formulario es la trampa contra envíos automáticos: está fuera de
|
|
|
|
|
// pantalla pero se puede enfocar.
|
|
|
|
|
await page.goto('/musica');
|
|
|
|
|
await page.locator('header').getByRole('link', { name: 'Entrar', exact: true }).click();
|
|
|
|
|
|
|
|
|
|
await expect(page.locator('dialog.acceso #email')).toBeFocused();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('se cierra con Escape y el foco vuelve a «Entrar»', async ({ page }) => {
|
|
|
|
|
await page.goto('/musica');
|
|
|
|
|
const abrir = page.locator('header').getByRole('link', { name: 'Entrar', exact: true });
|
|
|
|
|
await abrir.click();
|
|
|
|
|
await expect(page.locator('dialog.acceso')).toBeVisible();
|
|
|
|
|
|
|
|
|
|
await page.keyboard.press('Escape');
|
|
|
|
|
|
|
|
|
|
await expect(page.locator('dialog.acceso')).toBeHidden();
|
|
|
|
|
await expect(abrir).toBeFocused();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('cerrado no ocupa sitio en la página', async ({ page }) => {
|
|
|
|
|
/*
|
|
|
|
|
* El navegador oculta un `<dialog>` cerrado, pero cualquier regla nuestra
|
|
|
|
|
* de `display` le gana por venir de la hoja del autor. Con una suelta, el
|
|
|
|
|
* diálogo cerrado añadía una pantalla entera de vacío al final de todas
|
|
|
|
|
* las páginas del sitio.
|
|
|
|
|
*/
|
|
|
|
|
await page.goto('/taller');
|
|
|
|
|
|
|
|
|
|
const oculto = await page
|
|
|
|
|
.locator('dialog.acceso')
|
|
|
|
|
.evaluate((d) => getComputedStyle(d).display === 'none');
|
|
|
|
|
expect(oculto).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('desde el modal, la vuelta apunta a donde se estaba', async ({ page }) => {
|
|
|
|
|
await page.goto('/musica/baladas-romanticas');
|
|
|
|
|
await page.locator('header').getByRole('link', { name: 'Entrar', exact: true }).click();
|
|
|
|
|
|
|
|
|
|
await expect(page.locator('dialog.acceso input[name="volverA"]')).toHaveValue(
|
|
|
|
|
'/musica/baladas-romanticas'
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('los enlaces legales abren aparte y no se llevan el formulario', async ({
|
|
|
|
|
page,
|
|
|
|
|
context
|
|
|
|
|
}) => {
|
|
|
|
|
/*
|
|
|
|
|
* Desde un modal, un enlace normal navega por debajo: la página cambia sin
|
|
|
|
|
* que se vea y, al cerrar el diálogo, has aterrizado en otro sitio con lo
|
|
|
|
|
* escrito perdido.
|
|
|
|
|
*/
|
|
|
|
|
await page.goto('/musica');
|
|
|
|
|
await page.locator('header').getByRole('link', { name: 'Entrar', exact: true }).click();
|
|
|
|
|
await page.fill('dialog.acceso #email', 'sigue.aqui@example.com');
|
|
|
|
|
|
|
|
|
|
const [aparte] = await Promise.all([
|
|
|
|
|
context.waitForEvent('page'),
|
|
|
|
|
page.locator('dialog.acceso').getByRole('link', { name: 'condiciones de uso' }).click()
|
|
|
|
|
]);
|
|
|
|
|
await aparte.waitForLoadState();
|
|
|
|
|
|
|
|
|
|
expect(aparte.url()).toContain('/legal/condiciones-de-uso');
|
|
|
|
|
await expect(page).toHaveURL(/\/musica$/);
|
|
|
|
|
await expect(page.locator('dialog.acceso')).toBeVisible();
|
|
|
|
|
await expect(page.locator('dialog.acceso #email')).toHaveValue('sigue.aqui@example.com');
|
|
|
|
|
await aparte.close();
|
|
|
|
|
});
|
|
|
|
|
|
Entrar con Google o con Facebook
Detrás de la cuenta hay compras y facturación, y un código por correo apoya
toda esa puerta en el buzón: quien entre ahí, entra aquí. Delegar la
identidad en un proveedor que ya tiene doble factor la sostiene mejor.
Es el flujo de código de autorización con PKCE, escrito a mano porque son
cien líneas y las partes que importan son tres, mejor a la vista que
detrás de una dependencia: comparar el `state`, mandar el `code_verifier`
y exigir que el correo venga verificado antes de enlazar nada.
Lo que NO hace, a propósito: verificar la firma del `id_token`. La
identidad no se saca de lo que trae el navegador, sino de una llamada
nuestra al proveedor por TLS, así que no hay firma ajena que comprobar.
Y no guarda ningún token: se usa una vez para preguntar quién es y se
tira.
El enlazado por correo verificado es lo delicado, y por eso está separado
en `usuarioParaPerfil` con su explicación: sin exigir la verificación,
cualquiera podría poner la dirección de otro en un perfil suyo y quedarse
con su cuenta y sus compras.
El código por correo se queda debajo, como alternativa. Sin credenciales
puestas, los botones no se pintan y la ruta contesta 503: un botón que
solo lleva a un error es peor que no tenerlo.
Y los textos legales al día, que ahora sí hay terceros: qué se manda a
Google y a Facebook, cuándo, qué se guarda y cómo revocarlo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 weeks ago
|
|
|
test('un proveedor sin configurar no se ofrece ni deja empezar', async ({ page, request }) => {
|
|
|
|
|
/*
|
|
|
|
|
* Un botón de «Continuar con Google» sin credenciales solo sirve para
|
|
|
|
|
* llevar a un error, así que no se pinta. Y la ruta tampoco arranca el
|
|
|
|
|
* baile: contesta que aquí eso no está montado.
|
|
|
|
|
*/
|
|
|
|
|
await page.goto('/entrar');
|
|
|
|
|
await expect(page.locator('.proveedor')).toHaveCount(0);
|
|
|
|
|
|
|
|
|
|
const respuesta = await request.get('/acceso/google', { maxRedirects: 0 });
|
|
|
|
|
expect(respuesta.status()).toBe(503);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('una vuelta de proveedor inventada no abre sesión', async ({ request }) => {
|
|
|
|
|
/*
|
|
|
|
|
* Es la defensa del `state`: sin la cookie que se guardó al empezar, una
|
|
|
|
|
* vuelta fabricada por cualquiera no vale. Si valiera, bastaría con hacerte
|
|
|
|
|
* visitar una URL para meterte en la cuenta de otro.
|
|
|
|
|
*/
|
|
|
|
|
const respuesta = await request.get('/acceso/google/retorno?state=inventado&code=x', {
|
|
|
|
|
maxRedirects: 0
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(respuesta.status()).toBe(303);
|
|
|
|
|
expect(respuesta.headers()['location']).toContain('/entrar?fallo=');
|
|
|
|
|
expect(respuesta.headers()['set-cookie'] ?? '').not.toContain('sesion=');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('sin JavaScript, «Entrar» sigue llevando a la página', async ({ browser }) => {
|
|
|
|
|
const contexto = await browser.newContext({ javaScriptEnabled: false });
|
|
|
|
|
const pagina = await contexto.newPage();
|
|
|
|
|
await pagina.goto('/musica');
|
|
|
|
|
|
|
|
|
|
await pagina.locator('header').getByRole('link', { name: 'Entrar', exact: true }).click();
|
|
|
|
|
|
|
|
|
|
await expect(pagina).toHaveURL(/\/entrar/);
|
|
|
|
|
await expect(pagina.getByLabel('Correo electrónico')).toBeVisible();
|
|
|
|
|
await contexto.close();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
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
|
|
|
test.describe('Cuenta', () => {
|
|
|
|
|
test('la cuenta exige entrar', async ({ page }) => {
|
|
|
|
|
await page.goto('/cuenta');
|
|
|
|
|
|
|
|
|
|
await expect(page).toHaveURL(/\/entrar/);
|
|
|
|
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Entrar o crear cuenta');
|
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
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pedir un código deja el alta en marcha', async ({ page }) => {
|
|
|
|
|
/*
|
|
|
|
|
* Correo distinto en cada ejecución: los límites del formulario de acceso
|
|
|
|
|
* viven en la base de datos y sobreviven entre pasadas, así que repetir la
|
|
|
|
|
* suite con una dirección fija acabaría chocando con el tope por correo.
|
|
|
|
|
*/
|
|
|
|
|
const correo = `prueba.e2e.${Date.now()}@example.com`;
|
|
|
|
|
|
|
|
|
|
await page.goto('/entrar');
|
|
|
|
|
await page.getByLabel('Correo electrónico').fill(correo);
|
|
|
|
|
await page.getByRole('button', { name: 'Enviarme el código' }).click();
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* Lo que se comprueba es que la acción se ejecuta y responde algo
|
|
|
|
|
* coherente: o pasa al paso del código, o dice por qué no. Cuál de las dos
|
|
|
|
|
* toca depende del entorno —si hay proveedor de correo, si se han pedido
|
|
|
|
|
* muchos códigos desde esta IP—, así que se acepta cualquiera en vez de
|
|
|
|
|
* enumerar mensajes, que es lo que hacía frágil este test.
|
|
|
|
|
*/
|
|
|
|
|
await expect(page.getByLabel('Código').or(page.getByRole('alert'))).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('el paso del código se puede retomar sin pedir otro', async ({ page }) => {
|
|
|
|
|
// Recargar la página, o volver a ella desde el correo, no debería obligar
|
|
|
|
|
// a gastar un código nuevo.
|
|
|
|
|
await page.goto('/entrar?paso=codigo&email=prueba.e2e%40example.com');
|
|
|
|
|
|
|
|
|
|
await expect(page.getByLabel('Código')).toBeVisible();
|
|
|
|
|
await expect(page.getByText('prueba.e2e@example.com')).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('el navegador no deja enviar un código incompleto', async ({ page }) => {
|
|
|
|
|
await page.goto('/entrar?paso=codigo&email=prueba.e2e%40example.com');
|
|
|
|
|
|
|
|
|
|
const campo = page.getByLabel('Código');
|
|
|
|
|
await campo.fill('12');
|
|
|
|
|
await page.getByRole('button', { name: 'Entrar' }).click();
|
|
|
|
|
|
|
|
|
|
// El `pattern` del campo corta el envío sin salir del navegador, así que
|
|
|
|
|
// no llega a verse el mensaje del servidor: seguimos en el mismo paso.
|
|
|
|
|
await expect(campo).toBeVisible();
|
|
|
|
|
expect(await campo.evaluate((el) => (el as HTMLInputElement).validity.valid)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('rechaza un código que no existe', async ({ page }) => {
|
|
|
|
|
await page.goto('/entrar?paso=codigo&email=nadie.e2e%40example.com');
|
|
|
|
|
|
|
|
|
|
await page.getByLabel('Código').fill('000000');
|
|
|
|
|
await page.getByRole('button', { name: 'Entrar' }).click();
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText(/El código ha caducado o ya se ha usado/)).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('rechaza un correo mal formado', async ({ page }) => {
|
|
|
|
|
await page.goto('/entrar');
|
|
|
|
|
// `type="email"` ya filtra lo obvio; esto pasa el filtro del navegador y
|
|
|
|
|
// lo tiene que rechazar el servidor.
|
|
|
|
|
await page.getByLabel('Correo electrónico').fill('alguien@example');
|
|
|
|
|
await page.getByRole('button', { name: 'Enviarme el código' }).click();
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText('Ese correo no parece válido.')).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test.describe('Descargas', () => {
|
|
|
|
|
test('el audio completo no se sirve sin sesión', async ({ request }) => {
|
|
|
|
|
const respuesta = await request.get('/descargas/como-si-nada');
|
|
|
|
|
expect(respuesta.status()).toBe(401);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('el fragmento de muestra sí es público', async ({ request }) => {
|
|
|
|
|
const respuesta = await request.get('/muestras/como-si-nada/01-como-si-nada.wav');
|
|
|
|
|
|
|
|
|
|
expect(respuesta.ok()).toBe(true);
|
|
|
|
|
expect(respuesta.headers()['content-type']).toContain('audio');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('no hay ruta pública al audio completo', async ({ request }) => {
|
|
|
|
|
// La carpeta /audio existía antes de que el catálogo se pusiera a la
|
|
|
|
|
// venta; que siga muerta es parte de lo que protege los masters.
|
|
|
|
|
const respuesta = await request.get('/audio/como-si-nada/01-como-si-nada.wav');
|
|
|
|
|
expect(respuesta.status()).toBe(404);
|
|
|
|
|
});
|
|
|
|
|
});
|