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.
224 lines
8.3 KiB
224 lines
8.3 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 ---------------------------------- */
|
|
|
|
await c.query(`insert into pais (codigo, nombre) values ('CU', 'Cuba')`);
|
|
await c.query(
|
|
`insert into estilo (id, slug, nombre, principal) values ('e1', 'baladas', 'Baladas', true)`
|
|
);
|
|
await c.query(
|
|
`insert into cancion (id, slug, titulo, estilo_id) values ('c1', 'como-si-nada', 'Como si nada', 'e1')`
|
|
);
|
|
await c.query(
|
|
`insert into cancion (id, slug, titulo, estilo_id, version_de_id, version_nombre)
|
|
values ('c2', 'como-si-nada-maqueta', 'Como si nada', 'e1', 'c1', 'Maqueta 2018')`
|
|
);
|
|
await c.query(`insert into persona (id, slug, nombre) values ('p1', 'juan', 'Juan')`);
|
|
await c.query(`insert into genero (id, slug, nombre) values ('g1', 'bolero', 'Bolero')`);
|
|
await c.query(`insert into genero (id, slug, nombre) values ('g2', 'son', 'Son cubano')`);
|
|
await c.query(`insert into genero (id, slug, nombre) values ('g3', 'bolero-son', 'Bolero-son')`);
|
|
|
|
apuntar('LAS VERSIONES');
|
|
await permite(
|
|
'una canción puede tener versiones',
|
|
`insert into cancion (id, slug, titulo, estilo_id, version_de_id)
|
|
values ('c3', 'orquestal', 'Como si nada', 'e1', 'c1')`
|
|
);
|
|
await rechaza(
|
|
'una versión no puede tener versiones',
|
|
`insert into cancion (id, slug, titulo, estilo_id, version_de_id)
|
|
values ('c4', 'x', 'X', 'e1', 'c2')`
|
|
);
|
|
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('\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', 'z', 'Z')`,
|
|
`insert into cancion (id, slug, titulo, estilo_id) values ('z2', 'z', 'Z', 'z1')`,
|
|
`insert into persona (id, slug, nombre) values ('z3', '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', 'rock', 'Rock', true)`
|
|
);
|
|
await permite(
|
|
'los no principales, los que hagan falta',
|
|
`insert into estilo (id, slug, nombre) values ('e3', '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, '{}', 'CU')`
|
|
);
|
|
|
|
apuntar('\nLA VALORACIÓN');
|
|
await rechaza(
|
|
'no se puede puntuar con un seis',
|
|
`insert into valoracion (usuario_id, cancion_slug, puntuacion)
|
|
values ('inexistente', 'x', 6)`
|
|
);
|
|
|
|
await c.query('ROLLBACK');
|
|
apuntar('\n(todo deshecho: la base queda como estaba)');
|
|
await c.end();
|