import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import { pool } from "../db/pool.ts"; import { resetDb, seedMinimal, crearNegocio } from "./helpers.ts"; let ids: Awaited>; before(async () => { await resetDb(); ids = await seedMinimal(); }); after(async () => { await pool.end(); }); test("la base rechaza dos citas solapadas de la misma empleada", async () => { const ins = `INSERT INTO appointments (business_id, client_id, employee_id, service_id, start_at, end_at, price) VALUES ($1,$2,$3,$4,$5,$6,0) RETURNING id`; await pool.query(ins, [ ids.businessId, ids.clientId, ids.employeeId, ids.serviceId, "2026-09-01T16:00:00Z", "2026-09-01T17:00:00Z", ]); await assert.rejects( () => pool.query(ins, [ ids.businessId, ids.clientId, ids.employeeId, ids.serviceId, "2026-09-01T16:30:00Z", "2026-09-01T17:30:00Z", ]), (e: any) => e.code === "23P01", "debe ser una violación de exclusión (23P01), no un error cualquiera" ); }); test("una cita cancelada libera el hueco", async () => { await pool.query( `UPDATE appointments SET status = 'cancelled', cancelled_by = 'client' WHERE business_id = $1`, [ids.businessId] ); const { rows } = await pool.query( `INSERT INTO appointments (business_id, client_id, employee_id, service_id, start_at, end_at, price) VALUES ($1,$2,$3,$4,'2026-09-01T16:15:00Z','2026-09-01T17:15:00Z',0) RETURNING id`, [ids.businessId, ids.clientId, ids.employeeId, ids.serviceId] ); assert.ok(rows[0].id > 0); }); test("dos clientas del mismo negocio no pueden compartir teléfono normalizado", async () => { const ins = `INSERT INTO clients (business_id, name, phone, phone_e164) VALUES ($1,$2,$3,$4) RETURNING id`; await pool.query(ins, [ids.businessId, "Ana", "55 1111 2222", "+525511112222"]); await assert.rejects( () => pool.query(ins, [ids.businessId, "Ana (dup)", "5511112222", "+525511112222"]), (e: any) => e.code === "23505" ); }); test("dos clientas sin teléfono sí pueden coexistir", async () => { const ins = `INSERT INTO clients (business_id, name, phone, phone_e164) VALUES ($1,$2,NULL,NULL) RETURNING id, contactable`; const a = await pool.query(ins, [ids.businessId, "Sin teléfono 1"]); const b = await pool.query(ins, [ids.businessId, "Sin teléfono 2"]); assert.ok(a.rows[0].id !== b.rows[0].id); assert.equal(a.rows[0].contactable, false, "sin teléfono = no contactable"); }); test("crm_connections guarda la credencial cifrada y los ajustes por negocio", async () => { await resetDb(); const { rows } = await pool.query<{ column_name: string }>( `SELECT column_name FROM information_schema.columns WHERE table_name = 'crm_connections'` ); const nombres = rows.map((r) => r.column_name); for (const c of [ "token_cipher", "token_nonce", "token_tag", "token_fingerprint", "token_updated_at", "calendar_id", "test_email", "allow_real_sends", "label", ]) { assert.ok(nombres.includes(c), `falta la columna ${c}`); } }); test("allow_real_sends nace en false: los envíos reales se abren a propósito", async () => { await resetDb(); const b = await crearNegocio(); const { rows } = await pool.query( `INSERT INTO crm_connections (business_id, location_id) VALUES ($1, 'loc-1') RETURNING allow_real_sends`, [b.id] ); assert.equal(rows[0].allow_real_sends, false); }); test("media credencial se rechaza: va completa o no va", async () => { await resetDb(); const b = await crearNegocio(); await assert.rejects( () => pool.query( // El bytea va como PARÁMETRO, no como literal: escrito a mano dentro // de una plantilla, la secuencia de escape de un byte nulo se convierte // en ese byte de verdad y rompe el protocolo de Postgres antes de que // la restricción llegue a opinar. `INSERT INTO crm_connections (business_id, location_id, token_cipher) VALUES ($1, 'loc-1', $2)`, [b.id, Buffer.from([1, 2, 3])] ), /crm_connections_credencial_completa/ ); });