/** * Comprueba que las guardas del esquema hacen lo que dicen. * * Que una restricción exista en `pg_constraint` no significa que impida nada: * se prueba intentando lo que tiene que rechazar. Todo va dentro de una * transacción que se deshace, así que no deja rastro. */ import fs from 'node:fs'; import pg from 'pg'; const DIARIO = '.diario-guardas.txt'; fs.writeFileSync(DIARIO, ''); const apuntar = (l) => fs.appendFileSync(DIARIO, l + '\n'); const c = new pg.Client({ connectionString: process.env.DATABASE_URL, query_timeout: 15_000 }); await c.connect(); await c.query('BEGIN'); const resumen = await c.query(` select (select count(*)::int from information_schema.tables where table_schema = 'public' and table_type = 'BASE TABLE') as tablas, (select count(*)::int from information_schema.views where table_schema = 'public') as vistas, (select count(*)::int from information_schema.table_constraints where constraint_schema = 'public' and constraint_type = 'FOREIGN KEY') as fks, (select count(*)::int from pg_trigger where not tgisinternal) as disparadores`); const r = resumen.rows[0]; apuntar( `tablas ${r.tablas} · vistas ${r.vistas} · claves foráneas ${r.fks} · disparadores ${r.disparadores}\n` ); /** Prueba que algo se rechaza. Si pasa, es que la guarda no guarda. */ async function rechaza(nombre, sql, valores = []) { await c.query('SAVEPOINT prueba'); try { await c.query(sql, valores); await c.query('ROLLBACK TO SAVEPOINT prueba'); apuntar(` ✗ ${nombre} — SE PERMITIÓ, y no debería`); return false; } catch (fallo) { await c.query('ROLLBACK TO SAVEPOINT prueba'); const motivo = fallo.message.split('\n')[0].slice(0, 70); apuntar(` ✓ ${nombre} — rechazado: ${motivo}`); return true; } } /** * Como `rechaza`, pero forzando la comprobación de las restricciones * aplazadas. * * El disparador del reparto de autoría es `DEFERRABLE INITIALLY DEFERRED`, y * con razón: al insertar tres autores, los estados intermedios no suman cien y * comprobarlo fila a fila haría imposible cualquier inserción. Pero eso * significa que solo salta al cerrar la transacción, y una prueba que nunca * cierra no lo despierta: la primera versión de esto daba por buena una guarda * que no había llegado a ejecutarse. */ async function rechazaAlCerrar(nombre, preparar, sql) { /* * Conexión propia y transacción propia. Dentro de un savepoint, * `SET CONSTRAINTS ALL IMMEDIATE` no despertaba los eventos aplazados y la * prueba daba por bueno lo que el disparador sí rechaza —comprobado * interrogándolo aparte—. Una guarda que solo se puede comprobar al cerrar * necesita una transacción que se pueda cerrar. */ const otro = new pg.Client({ connectionString: process.env.DATABASE_URL, query_timeout: 15_000 }); await otro.connect(); await otro.query('BEGIN'); try { for (const linea of preparar) await otro.query(linea); await otro.query(sql); await otro.query('SET CONSTRAINTS ALL IMMEDIATE'); apuntar(` ✗ ${nombre} — SE PERMITIÓ, y no debería`); return false; } catch (fallo) { apuntar( ` ✓ ${nombre} — rechazado: ${fallo.message.split(String.fromCharCode(10))[0].slice(0, 70)}` ); return true; } finally { await otro.query('ROLLBACK').catch(() => {}); await otro.end(); } } /** Y que lo legítimo sí pasa: una guarda que lo bloquea todo tampoco sirve. */ async function permite(nombre, sql, valores = []) { await c.query('SAVEPOINT prueba'); try { await c.query(sql, valores); await c.query('RELEASE SAVEPOINT prueba'); apuntar(` ✓ ${nombre} — permitido, como debe`); return true; } catch (fallo) { await c.query('ROLLBACK TO SAVEPOINT prueba'); apuntar(` ✗ ${nombre} — SE RECHAZÓ: ${fallo.message.split('\n')[0].slice(0, 70)}`); return false; } } /* --- Datos mínimos para poder probar ---------------------------------- * * Todo con el prefijo `zz-`, y el país `ZZ`, que ISO 3166 reserva para uso * privado. Antes usaba slugs de verdad —«baladas», «como-si-nada»— y funcionó * mientras la base estuvo vacía; en cuanto se volcó el catálogo, el script se * caía en la primera inserción por clave duplicada y dejaba de comprobar nada. * * `e1` tampoco es principal: ya hay un estilo principal de verdad, y el índice * único parcial —que es justo una de las guardas que esto prueba— no admite * dos. */ await c.query(`insert into pais (codigo, nombre) values ('ZZ', 'País de prueba')`); await c.query(`insert into estilo (id, slug, nombre) values ('e1', 'zz-baladas', 'Baladas')`); await c.query( `insert into cancion (id, slug, titulo, estilo_id, idioma) values ('c1', 'zz-como-si-nada', 'Como si nada', 'e1', 'es')` ); await c.query( `insert into cancion (id, slug, titulo, estilo_id, idioma, version_de_id, version_nombre, version_codigo) values ('c2', 'zz-como-si-nada-maqueta', 'Como si nada', 'e1', 'es', 'c1', 'Maqueta 2018', 'maqueta')` ); await c.query(`insert into persona (id, slug, nombre) values ('p1', 'zz-juan', 'Juan')`); await c.query(`insert into genero (id, slug, nombre) values ('g1', 'zz-bolero', 'Bolero')`); await c.query(`insert into genero (id, slug, nombre) values ('g2', 'zz-son', 'Son cubano')`); await c.query(`insert into genero (id, slug, nombre) values ('g3', 'zz-bolero-son', 'Bolero-son')`); await c.query(`insert into usuario (id, email) values ('u9', 'zz@example.com')`); await c.query(`insert into usuario (id, email) values ('u8', 'zz-otra@example.com')`); await c.query( `insert into pedido (id, usuario_id, sesion_pago_id, estado, packs, total_centimos) values ('o9', 'u9', 'cs_zz', 'pagado', 1, 500)` ); apuntar('LAS VERSIONES'); await permite( 'una canción puede tener versiones', `insert into cancion (id, slug, titulo, estilo_id, idioma, version_de_id, version_nombre, version_codigo) values ('c3', 'zz-orquestal', 'Como si nada', 'e1', 'es', 'c1', 'Orquestal', 'orquestal')` ); await rechaza( 'una versión no puede tener versiones', `insert into cancion (id, slug, titulo, estilo_id, idioma, version_de_id, version_nombre, version_codigo) values ('c4', 'zz-x', 'X', 'e1', 'es', 'c2', 'X', 'x')` ); await rechaza( 'una canción no es versión de sí misma', `update cancion set version_de_id = 'c1' where id = 'c1'` ); apuntar('\nLA VISTA DE PRINCIPALES'); const vista = await c.query('select count(*)::int n from cancion_principal'); const todas = await c.query('select count(*)::int n from cancion'); const principales = await c.query( 'select count(*)::int n from cancion where version_de_id is null' ); const bien = vista.rows[0].n === principales.rows[0].n && vista.rows[0].n < todas.rows[0].n; apuntar( ` ${bien ? '✓' : '✗'} cancion tiene ${todas.rows[0].n} filas y cancion_principal ${vista.rows[0].n}, que son las principales` ); apuntar('\nLOS ATRIBUTOS DE CANCIÓN'); await permite( 'idioma, formación vocal y letra se guardan por grabación', `update cancion set idioma = 'pt-BR', formacion_vocal = 'dueto-mixto', tiene_letra = false where id = 'c2'` ); await rechaza( 'el idioma tiene que ser una etiqueta BCP 47', `update cancion set idioma = 'francés' where id = 'c1'` ); await rechaza( 'no se puede inventar una formación vocal', `update cancion set formacion_vocal = 'trío' where id = 'c1'` ); await permite( 'el acceso se guarda por grabación', `update cancion set acceso = 'exclusiva' where id = 'c2'` ); await rechaza( 'no se puede inventar un nivel de acceso', `update cancion set acceso = 'suscriptores' where id = 'c1'` ); apuntar('\nLAS LISTAS DE REPRODUCCIÓN'); await permite( 'una cuenta puede tener varias listas y una canción estar en ambas', `insert into lista_reproduccion (id, usuario_id, nombre) values ('l1', 'u9', 'Para conducir'), ('l2', 'u9', 'Para escribir'); insert into lista_reproduccion_cancion (lista_id, cancion_id, orden) values ('l1', 'c1', 0), ('l2', 'c1', 0)` ); await rechaza( 'la misma canción no se duplica dentro de una lista', `insert into lista_reproduccion_cancion (lista_id, cancion_id, orden) values ('l1', 'c1', 1)` ); await rechaza( 'dos posiciones de una lista no pueden coincidir', `insert into lista_reproduccion_cancion (lista_id, cancion_id, orden) values ('l1', 'c2', 0)` ); await rechaza( 'el nombre se compara sin distinguir mayúsculas ni espacios laterales', `insert into lista_reproduccion (id, usuario_id, nombre) values ('l3', 'u9', ' PARA CONDUCIR ')` ); await permite( 'otra cuenta sí puede usar el mismo nombre', `insert into lista_reproduccion (id, usuario_id, nombre) values ('l4', 'u8', 'Para conducir')` ); apuntar('\nLOS PERMISOS DE ESCUCHA'); await permite( 'una cuenta puede recibir permiso expreso para una grabación privada', `insert into acceso_cancion_usuario (usuario_id, cancion_id) values ('u9', 'c1')` ); await rechaza( 'no se duplica la misma concesión', `insert into acceso_cancion_usuario (usuario_id, cancion_id) values ('u9', 'c1')` ); await permite( 'una suscripción activa puede tener fecha de finalización futura', `insert into suscripcion (id, usuario_id, estado, inicia_en, termina_en) values ('s1', 'u9', 'activa', now(), now() + interval '1 month')` ); await rechaza( 'una suscripción no puede terminar antes de empezar', `insert into suscripcion (id, usuario_id, inicia_en, termina_en) values ('s2', 'u9', now(), now() - interval '1 day')` ); await rechaza( 'no se puede inventar un estado de suscripción', `insert into suscripcion (id, usuario_id, estado) values ('s3', 'u9', 'gratis')` ); await rechaza( 'una referencia externa necesita decir su proveedor', `insert into suscripcion (id, usuario_id, referencia_externa) values ('s4', 'u9', 'sub_123')` ); apuntar('\nEL COMERCIO EXTENSIBLE'); await permite( 'un plan conserva nivel, precio, recurrencia y prestaciones', `insert into plan_suscripcion (id, codigo, nombre, nivel, precio_centimos, periodicidad, prestaciones, estado) values ('ps1', 'zz-apoyo', 'Apoyo', 1, 500, 'mes', '["catálogo exclusivo"]', 'disponible'); update suscripcion set plan_id = 'ps1' where id = 's1'` ); await rechaza( 'un plan no admite intervalos de renovación nulos', `insert into plan_suscripcion (id, codigo, nombre, nivel, precio_centimos, periodicidad, intervalos) values ('ps2', 'zz-roto', 'Roto', 2, 500, 'mes', 0)` ); await rechaza( 'un plan no admite un número negativo de descargas', `insert into plan_suscripcion (id, codigo, nombre, nivel, precio_centimos, periodicidad, descargas_incluidas) values ('ps3', 'zz-descargas', 'Roto', 2, 500, 'mes', -1)` ); await rechaza( 'un plan sin descargas no declara calidad de descarga', `insert into plan_suscripcion (id, codigo, nombre, nivel, precio_centimos, periodicidad, calidad_descarga) values ('ps4', 'zz-calidad', 'Roto', 2, 500, 'mes', 'media')` ); await rechaza( 'un plan del taller no mezcla capacidades de música', `insert into plan_suscripcion (id, codigo, nombre, nivel, precio_centimos, periodicidad, ambito, calidad_escucha, acceso_contenido_taller, descarga_material_taller) values ('ps5', 'zz-taller-mal', 'Roto', 1, 990, 'ano', 'taller_letras', 'alta', true, true)` ); await permite( 'un plan del taller conserva el acceso y la descarga de materiales', `insert into plan_suscripcion (id, codigo, nombre, nivel, precio_centimos, periodicidad, ambito, calidad_escucha, acceso_contenido_taller, descarga_material_taller) values ('ps6', 'zz-taller', 'Taller', 1, 990, 'ano', 'taller_letras', null, true, true)` ); await rechaza( 'el Juke-box no se puede mezclar con una suscripción del taller', `insert into plan_suscripcion (id, codigo, nombre, nivel, precio_centimos, periodicidad, ambito, calidad_escucha, acceso_jukebox, acceso_contenido_taller, descarga_material_taller) values ('ps7', 'zz-taller-jukebox', 'Roto', 2, 990, 'ano', 'taller_letras', null, true, true, true)` ); await permite( 'un libro disponible se puede añadir al carrito con cantidad', `insert into producto (id, codigo, tipo, nombre, estado, precio_centimos) values ('pr1', 'zz-libro', 'libro', 'Libro de prueba', 'disponible', 900); insert into carrito (id, usuario_id) values ('ca9', 'u9'); insert into carrito_producto (carrito_id, producto_id, cantidad) values ('ca9', 'pr1', 2)` ); await rechaza( 'un producto no puede tener precio negativo', `insert into producto (id, codigo, tipo, nombre, precio_centimos) values ('pr2', 'zz-negativo', 'midi', 'MIDI roto', -1)` ); await permite( 'un pedido congela una línea de cobro coherente', `insert into pedido_linea (pedido_id, codigo, tipo, nombre, cantidad, precio_unitario_centimos, total_centimos, moneda, modalidad) values ('o9', 'pack-canciones-10', 'pack_canciones', 'Pack', 1, 500, 500, 'eur', 'pago_unico')` ); await rechaza( 'una línea no admite un total distinto de cantidad por precio', `insert into pedido_linea (pedido_id, codigo, tipo, nombre, cantidad, precio_unitario_centimos, total_centimos, moneda, modalidad) values ('o9', 'zz-mal-total', 'servicio', 'Servicio', 2, 300, 500, 'eur', 'pago_unico')` ); apuntar('\nEL LINAJE DE GÉNEROS'); await permite( 'el bolero-son deriva de dos géneros', `insert into genero_origen (genero_id, origen_id, tipo, principal) values ('g3', 'g1', 'fusiona', true), ('g3', 'g2', 'fusiona', false)` ); await rechaza( 'un género no deriva de sí mismo', `insert into genero_origen (genero_id, origen_id, tipo) values ('g1', 'g1', 'deriva')` ); await rechaza( 'no se admiten ciclos en el linaje', `insert into genero_origen (genero_id, origen_id, tipo) values ('g1', 'g3', 'deriva')` ); await rechaza( 'solo una arista principal por género', `update genero_origen set principal = true where genero_id = 'g3' and origen_id = 'g2'` ); apuntar('\nEL REPARTO DE AUTORÍA'); await permite( 'un reparto que suma cien pasa', `insert into autoria (cancion_id, persona_id, rol, porcentaje) values ('c1', 'p1', 'letra', 60), ('c1', 'p1', 'musica', 40)` ); /* * Sobre otra canción y con un rol libre, para que el rechazo venga de la suma y * no de la clave primaria. La primera versión de esta prueba pasaba por el * motivo equivocado —clave duplicada— y dejaba el disparador sin comprobar, que * es peor que no tener prueba. */ await rechazaAlCerrar( 'un reparto que suma noventa, no', [ `insert into estilo (id, slug, nombre) values ('z1', 'zz-z', 'Z')`, `insert into cancion (id, slug, titulo, estilo_id, idioma) values ('z2', 'zz-z', 'Z', 'z1', 'es')`, `insert into persona (id, slug, nombre) values ('z3', 'zz-z', 'Z')` ], `insert into autoria (cancion_id, persona_id, rol, porcentaje) values ('z2', 'z3', 'letra', 90)` ); await permite( 'y el de c3 sí, si llega a cien', `insert into autoria (cancion_id, persona_id, rol, porcentaje) values ('c3', 'p1', 'letra', 90), ('c3', 'p1', 'musica', 10)` ); apuntar('\nEL ESTILO PRINCIPAL'); await rechaza( 'no puede haber dos estilos principales', `insert into estilo (id, slug, nombre, principal) values ('e2', 'zz-rock', 'Rock', true)` ); await permite( 'los no principales, los que hagan falta', `insert into estilo (id, slug, nombre) values ('e3', 'zz-clasica', 'Clásica')` ); apuntar('\nLAS FACTURAS'); await rechaza( 'una rectificativa tiene que decir a cuál rectifica', `insert into factura (id, pedido_id, serie, numero, tipo, base_centimos, iva_tipo, iva_cuota_centimos, total_centimos, datos_congelados, pais_codigo) values ('f1', 'inexistente', 'A', 1, 'rectificativa', 100, 21, 21, 121, '{}', 'ZZ')` ); apuntar('\nEL ROL DE LA CUENTA'); await rechaza('no se puede inventar un rol', `update usuario set rol = 'adminn' where id = 'u9'`); await permite('y quien administra, sí', `update usuario set rol = 'admin' where id = 'u9'`); apuntar('\nLA VALORACIÓN'); await rechaza( 'no se puede puntuar con un seis', `insert into valoracion (usuario_id, cancion_id, "value") values ('u9', 'c1', 6)` ); apuntar(''); apuntar('LAS COMPRAS APUNTAN A ALGO'); /* * Lo que la fase 3 vino a arreglar. Antes de ella estas tablas guardaban el * slug como texto suelto, así que las tres cosas que aquí se rechazan pasaban * sin que saltara nada: se podía marcar como favorito un tema inventado y * borrar del catálogo uno que alguien había pagado. * * La cuenta y el pedido se preparan antes de la prueba de la valoración * porque esta los necesita: un seis tiene que rechazarse por el rango, no por * una clave foránea que no encuentra al usuario. */ await permite( 'una compra apunta a una canción que existe', `insert into compra (usuario_id, cancion_id, pedido_id) values ('u9', 'c1', 'o9')` ); await rechaza( 'no se puede comprar un tema que no existe', `insert into compra (usuario_id, cancion_id, pedido_id) values ('u9', 'no-existe', 'o9')` ); await rechaza( 'ni borrar del catálogo un tema que alguien ha pagado', `delete from cancion where id = 'c1'` ); await rechaza( 'ni marcar como favorito un tema inventado', `insert into favorito (usuario_id, cancion_id) values ('u9', 'no-existe')` ); await permite( 'un tema sin comprar sí se retira del catálogo', `update cancion set estado = 'retirado' where id = 'c3'` ); await c.query('ROLLBACK'); apuntar('\n(todo deshecho: la base queda como estaba)'); await c.end();