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/scripts/db/comprobar-guardas.mjs

432 lines
17 KiB

/**
* 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();

Powered by TurnKey Linux.