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/tienda.e2e.ts

326 lines
13 KiB

import { expect, test, type Page } from '@playwright/test';
/** El tamaño del pack, tal como lo dice `$lib/tienda`. */
const CANCIONES_POR_PACK = 10;
/** 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();
}
/**
* 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++;
}
}
expect(puestas, 'no hay bastantes temas a la venta para llenar un pack').toBe(cuantas);
}
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()
).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 }) => {
await llenarUnPack(page);
await page.goto('/carrito');
await expect(page.getByText(`1 pack de ${CANCIONES_POR_PACK} canciones.`)).toBeVisible();
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();
});
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();
});
});
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');
});
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);
});
});

Powered by TurnKey Linux.