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) { // 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 ['cupido-sin-flechas']) { if (puestas >= cuantas) break; await page.goto(`/albumes/${album}`); 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/cupido-sin-flechas'); const carrito = page.getByRole('link', { name: /^Carrito/ }); await anadir(page, 'Prometiste'); await expect(carrito).toContainText('1'); }); test('una selección parcial se resuelve en un pack y puede continuar', async ({ page }) => { await page.goto('/albumes/cupido-sin-flechas'); await anadir(page, 'Prometiste'); await page.goto('/carrito'); await expect(page.getByText(/1 canción se cobrará como 1 pack/).first()).toBeVisible(); await expect(page.getByText(/Puedes añadir 9 más sin cambiar el precio/).first()).toBeVisible(); await expect(page.getByText('1,50 €').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('el acceso explica que se pide para completar la compra', async ({ page }) => { await page.goto('/albumes/cupido-sin-flechas'); await anadir(page, 'Prometiste'); await page.goto('/carrito'); await page.getByRole('link', { name: 'Entrar para comprar' }).click(); await expect( page .getByRole('dialog') .getByText( 'Necesitas entrar para completar la compra y guardar tus descargas en tu cuenta.' ) ).toBeVisible(); await expect(page).toHaveURL(/\/carrito$/); }); test('propone canciones para aprovechar el precio ya incluido', async ({ page }) => { await page.goto('/albumes/cupido-sin-flechas'); await anadir(page, 'Prometiste'); await page.goto('/carrito'); const sugerencias = page.getByRole('heading', { level: 2, name: 'Aprovecha el espacio incluido' }); 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(`${CANCIONES_POR_PACK} canciones se cobrarán como 1 pack.`) ).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: 'Aprovecha el espacio incluido' }) ).toHaveCount(0); }); test('se pueden quitar canciones', async ({ page }) => { await page.goto('/albumes/cupido-sin-flechas'); await anadir(page, 'Prometiste'); await page.goto('/carrito'); await page.getByRole('button', { name: 'Quitar' }).first().click(); await expect(page.getByText('No has añadido nada 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(); await expect( dialogo.getByText('Entra para acceder a tus compras, favoritos, listas y likes.') ).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 input[name="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 `` 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 input[name="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 input[name="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|Continuar con este correo)$/ }) .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|de prueba no coincide)/) ).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|Continuar con este correo)$/ }) .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/prometiste'); expect(respuesta.status()).toBe(401); }); test('la escucha completa de calidad reducida es pública cuando la canción lo es', async ({ request }) => { const respuesta = await request.get('/escucha/prometiste'); expect(respuesta.ok()).toBe(true); expect(respuesta.headers()['content-type']).toContain('audio'); }); test('ya no queda una ruta pública de fragmentos recortados', async ({ request }) => { const respuesta = await request.get('/muestras/cupido-sin-flechas/07-prometiste.mp3'); expect(respuesta.status()).toBe(404); }); 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/cupido-sin-flechas/07-prometiste.mp3'); expect(respuesta.status()).toBe(404); }); });