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.
432 lines
17 KiB
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();
|