feat(platform): multi-tenancy con credenciales por negocio y sincronización por id

El backend Postgres de `platform/` asumía un solo negocio con un solo token del
CRM. Este cambio lo convierte en una plataforma multi-cuenta y añade la
sincronización selectiva de las cinco entidades del encargo.

## Multi-tenancy

El `locationId` ya era por negocio, pero el token vivía en la variable de entorno
`CRM_TOKEN`, una sola para todo el proceso. Con dos negocios eso usaba el token
del primero contra la subcuenta del segundo: 401 en el mejor caso, escritura en
la subcuenta equivocada en el peor.

- `lib/crypto.ts` — AES-256-GCM para los tokens. Autenticado a propósito: una
  fila manipulada hace que el descifrado FALLE, en vez de devolver basura que
  acabaríamos mandando como credencial al CRM. La clave maestra vive en
  `CRM_MASTER_KEY`, fuera de la base.
- `crm/ctx.ts` — `CrmCtx { businessId, locationId, token }` sustituye al
  `locationId: string` suelto que viajaba por once firmas. Es un objeto y no dos
  parámetros porque dos `string` seguidos se cruzan sin que el compilador diga
  nada, y cruzarlos aquí manda el token de un cliente a la subcuenta de otro. Es
  el único sitio donde el token existe descifrado, y solo en memoria.
- `crm/client.ts` — `CrmOptions.token` pasa a ser OBLIGATORIO, sin valor por
  defecto: olvidarlo es ahora un error de compilación. El estrangulador pasa a
  ser por token y aprende la cuota de las cabeceras `x-ratelimit-*`, que declaran
  100 peticiones por 10 s — el cliente iba 6,5x por debajo con una estimación.
- Migración 003: credencial cifrada, calendario y la red de seguridad de mensajes
  POR NEGOCIO. Como variable global decidía por todas las cuentas a la vez.

Lo único de la credencial que sale del servidor es la huella de 6 caracteres.

## Consola de superadministración

`/api/admin`, solo para el rol `admin`: alta de cuentas con su dueña en una
transacción, vínculo, desvínculo y suspensión. Las credenciales se COMPRUEBAN
contra el CRM antes de guardarse — un token sin validar traslada el fallo al
primer intento de sincronizar, lejos de donde se cometió. El error distingue
«token inválido» de «subcuenta inexistente» de «token de otra subcuenta».

Pantalla en `/admin/cuentas`, verificada en navegador: el campo del token es de
contraseña y viene vacío, porque no hay valor que traer.

## Sincronización por identificador

`POST /api/crm/sync/:entidad/:id` para contacto, conversación, mensaje, cita y
servicio. La dirección la decide la entidad: las tres primeras se TRAEN porque el
CRM es su dueño; las dos últimas se EMPUJAN, porque el calendario del CRM tiene
una sola cita en dos años y su catálogo de servicios está vacío.

- `crm/conversations.ts` — lectura por id de conversaciones y mensajes sueltos.
- `crm/syncConversations.ts` — el espejo persistido. Las tablas existían desde
  002_crm.sql y nadie escribía en ellas: la bandeja consultaba el CRM en vivo.
- `crm/calendars.ts` — escritura de citas al calendario. `isoConDesplazamiento`
  escribe la hora de pared del negocio con su desplazamiento; `toISOString()`
  habría movido la hora que el CRM enseña en su interfaz.
- `crm/services.ts` — publicación de servicios al catálogo.

## Verificado contra la subcuenta real, no deducido

Las cinco entidades se ejercieron contra el CRM del cliente. Las escrituras van
en un ciclo crear → releer → borrar → confirmar borrado, con la limpieza en un
`finally`, y antes se comprobó que el borrado existe: preguntar si se puede
deshacer ANTES de escribir en el CRM de un cliente, no después. La subcuenta
quedó como estaba.

47 hallazgos medidos en `crm/HALLAZGOS.md`, y la referencia de endpoints en
`crm/API.md`, con la lista explícita de dónde la documentación oficial falla.

110 pruebas de plataforma en verde, typecheck limpio, build correcto. El backend
de demo de `server/` no se ha tocado y sigue con sus 43 pruebas.

## Deuda conocida, dicha sin rodeos

- La bandeja de mensajes todavía lee en vivo del CRM, no del espejo.
- La autenticación sigue siendo el id del usuario en texto plano, también para el
  rol admin. Esta consola crea cuentas y guarda credenciales de clientes encima
  de esa base: no debe quedar expuesta a internet hasta endurecerla.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
AgendaPro Dev
2026-08-30 15:07:20 -06:00
co-authored by Claude Opus 5
parent dcbf750c09
commit 6d67b23e55
95 changed files with 16132 additions and 41 deletions
+153
View File
@@ -0,0 +1,153 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { pool } from "../db/pool.ts";
import { createApp } from "../index.ts";
import { resetDb, seedMinimal } from "./helpers.ts";
import { guardarCredencial } from "../crm/ctx.ts";
import type { Server } from "node:http";
process.env.CRM_MASTER_KEY = Buffer.alloc(32, 5).toString("base64");
let ids: Awaited<ReturnType<typeof seedMinimal>>;
let adminId: number;
let server: Server;
let base: string;
before(async () => {
await resetDb();
ids = await seedMinimal();
const { rows } = await pool.query(
`INSERT INTO users (business_id, email, password, name, role)
VALUES (NULL,'[email protected]','demo1234','Plataforma','admin') RETURNING id`
);
adminId = rows[0].id;
server = createApp().listen(0);
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
});
after(async () => {
server.close();
await pool.end();
});
function req(path: string, init: RequestInit = {}, userId: number = adminId) {
return fetch(`${base}${path}`, {
...init,
headers: {
"content-type": "application/json",
authorization: `Bearer ${userId}`,
...(init.headers || {}),
},
});
}
test("una dueña de negocio no puede entrar a la consola de plataforma", async () => {
const r = await req("/api/admin/businesses", {}, ids.ownerUserId);
assert.equal(r.status, 403);
});
test("una empleada tampoco", async () => {
const r = await req("/api/admin/businesses", {}, ids.employeeUserId);
assert.equal(r.status, 403);
});
test("el superadministrador da de alta una cuenta con su dueña", async () => {
const r = await req("/api/admin/businesses", {
method: "POST",
body: JSON.stringify({
name: "Spa Nuevo",
owner_email: "[email protected]",
owner_name: "Dueña",
owner_password: "demo1234",
}),
});
assert.equal(r.status, 201);
const b = await r.json();
assert.equal(b.business.name, "Spa Nuevo");
assert.equal(b.business.slug, "spa-nuevo", "el negocio nace con slug");
assert.equal(b.owner.email, "[email protected]", "el correo se normaliza a minúsculas");
// Sin horario, un negocio nuevo no tiene ninguna franja agendable.
const { rows } = await pool.query(
`SELECT working_hours FROM businesses WHERE id = $1`, [b.business.id]
);
assert.ok(rows[0].working_hours?.["1"], "el negocio nace con horario laboral");
});
test("dos cuentas con el mismo nombre no chocan de slug", async () => {
const r = await req("/api/admin/businesses", {
method: "POST",
body: JSON.stringify({
name: "Spa Nuevo", owner_email: "[email protected]",
owner_name: "Otra", owner_password: "x",
}),
});
assert.equal(r.status, 201);
assert.equal((await r.json()).business.slug, "spa-nuevo-2");
});
test("un correo repetido se rechaza con 409, no con un 500 de la base", async () => {
const r = await req("/api/admin/businesses", {
method: "POST",
body: JSON.stringify({
name: "Tercero", owner_email: "[email protected]",
owner_name: "X", owner_password: "x",
}),
});
assert.equal(r.status, 409);
});
test("faltar datos de la dueña da 400 y lo dice", async () => {
const r = await req("/api/admin/businesses", {
method: "POST",
body: JSON.stringify({ name: "Sin dueña" }),
});
assert.equal(r.status, 400);
assert.match((await r.json()).error, /dueña/i);
});
test("el listado nunca devuelve el token, solo su huella", async () => {
await guardarCredencial(ids.businessId, "loc-1", "token-secretisimo", "Yola Franco Spa");
const r = await req("/api/admin/businesses");
assert.equal(r.status, 200);
const texto = JSON.stringify(await r.json());
assert.ok(!texto.includes("token-secretisimo"), "el token no puede salir por la API");
assert.ok(texto.includes("etisimo".slice(-6)), "sí debe salir la huella");
assert.ok(texto.includes("Yola Franco Spa"), "y la etiqueta de la subcuenta");
});
test("suspender una cuenta la deja suspendida", async () => {
const r = await req(`/api/admin/businesses/${ids.businessId}`, {
method: "PATCH",
body: JSON.stringify({ status: "suspended" }),
});
assert.equal(r.status, 200);
assert.equal((await r.json()).business.status, "suspended");
});
test("un estado inventado se rechaza", async () => {
const r = await req(`/api/admin/businesses/${ids.businessId}`, {
method: "PATCH",
body: JSON.stringify({ status: "lo-que-sea" }),
});
assert.equal(r.status, 400);
});
test("desvincular borra la credencial y conserva la subcuenta", async () => {
await guardarCredencial(ids.businessId, "loc-9", "token-x");
const r = await req(`/api/admin/businesses/${ids.businessId}/crm`, { method: "DELETE" });
assert.equal(r.status, 200);
const { rows } = await pool.query(
`SELECT location_id, token_cipher FROM crm_connections WHERE business_id = $1`,
[ids.businessId]
);
assert.equal(rows[0].location_id, "loc-9");
assert.equal(rows[0].token_cipher, null);
});
test("vincular sin token o sin subcuenta da 400", async () => {
const r = await req(`/api/admin/businesses/${ids.businessId}/crm`, {
method: "PUT",
body: JSON.stringify({ location_id: "loc-1" }),
});
assert.equal(r.status, 400);
});
+136
View File
@@ -0,0 +1,136 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { pool } from "../db/pool.ts";
import { createApp } from "../index.ts";
import { resetDb, seedMinimal } from "./helpers.ts";
import type { Server } from "node:http";
let ids: Awaited<ReturnType<typeof seedMinimal>>;
let server: Server;
let base: string;
before(async () => {
await resetDb();
ids = await seedMinimal();
server = createApp().listen(0);
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
});
after(async () => {
server.close();
await pool.end();
});
function req(path: string, init: RequestInit = {}, userId = ids.ownerUserId) {
return fetch(`${base}${path}`, {
...init,
headers: {
"content-type": "application/json",
authorization: `Bearer ${userId}`,
...(init.headers || {}),
},
});
}
const nueva = (start: string) => ({
client_id: ids.clientId,
employee_id: ids.employeeId,
service_id: ids.serviceId,
start_at: start,
});
test("crea una cita y calcula el fin con la duración del servicio", async () => {
const r = await req("/api/appointments", {
method: "POST",
body: JSON.stringify(nueva("2026-09-02T16:00:00Z")),
});
assert.equal(r.status, 201);
const { appointment } = await r.json();
// El servicio sembrado dura 90 min.
assert.equal(appointment.end_at, "2026-09-02T17:30:00Z");
assert.equal(appointment.price, 850);
assert.equal(appointment.status, "scheduled");
});
test("un solape devuelve 409 en español, no un 500", async () => {
const r = await req("/api/appointments", {
method: "POST",
body: JSON.stringify(nueva("2026-09-02T17:00:00Z")),
});
assert.equal(r.status, 409);
const body = await r.json();
assert.match(body.error, /ocupad/i);
});
test("reprogramar a un hueco libre funciona", async () => {
const { rows } = await pool.query(`SELECT id FROM appointments ORDER BY id LIMIT 1`);
const r = await req(`/api/appointments/${rows[0].id}`, {
method: "PATCH",
body: JSON.stringify({ start_at: "2026-09-02T19:00:00Z" }),
});
assert.equal(r.status, 200);
const { appointment } = await r.json();
assert.equal(appointment.start_at, "2026-09-02T19:00:00Z");
assert.equal(appointment.end_at, "2026-09-02T20:30:00Z");
});
test("reprogramar encima de otra cita devuelve 409", async () => {
await req("/api/appointments", {
method: "POST",
body: JSON.stringify(nueva("2026-09-02T12:00:00Z")),
});
const { rows } = await pool.query(
`SELECT id FROM appointments WHERE start_at = '2026-09-02T12:00:00Z'`
);
const r = await req(`/api/appointments/${rows[0].id}`, {
method: "PATCH",
body: JSON.stringify({ start_at: "2026-09-02T19:30:00Z" }),
});
assert.equal(r.status, 409);
});
test("cancelar libera el hueco y registra quién canceló", async () => {
const { rows } = await pool.query(
`SELECT id FROM appointments WHERE start_at = '2026-09-02T19:00:00Z'`
);
const r = await req(`/api/appointments/${rows[0].id}/cancel`, {
method: "POST",
body: JSON.stringify({ cancelled_by: "client", reason: "Se enfermó" }),
});
assert.equal(r.status, 200);
const { appointment } = await r.json();
assert.equal(appointment.status, "cancelled");
assert.equal(appointment.cancelled_by, "client");
const libre = await req("/api/appointments", {
method: "POST",
body: JSON.stringify(nueva("2026-09-02T19:00:00Z")),
});
assert.equal(libre.status, 201, "el hueco de una cancelada vuelve a estar libre");
});
test("cada cambio deja un evento en appointment_events", async () => {
const { rows } = await pool.query(`SELECT action FROM appointment_events ORDER BY id`);
const acciones = rows.map((r) => r.action);
assert.ok(acciones.includes("created"));
assert.ok(acciones.includes("rescheduled"));
assert.ok(acciones.includes("cancelled"));
});
test("no se puede tocar una cita de otro negocio", async () => {
const other = await pool.query(
`INSERT INTO businesses (name, slug, working_hours)
VALUES ('Otro Spa 2','otro-2','{}'::jsonb) RETURNING id`
);
const otherUser = await pool.query(
`INSERT INTO users (business_id, email, password, name, role)
VALUES ($1,'[email protected]','x','Otra','owner') RETURNING id`,
[other.rows[0].id]
);
const { rows } = await pool.query(`SELECT id FROM appointments ORDER BY id LIMIT 1`);
const r = await req(
`/api/appointments/${rows[0].id}`,
{ method: "PATCH", body: JSON.stringify({ notes: "intruso" }) },
otherUser.rows[0].id
);
assert.equal(r.status, 404);
});
+143
View File
@@ -0,0 +1,143 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { pool } from "../db/pool.ts";
import { createApp } from "../index.ts";
import { resetDb, seedMinimal } from "./helpers.ts";
import type { Server } from "node:http";
let ids: Awaited<ReturnType<typeof seedMinimal>>;
let server: Server;
let base: string;
before(async () => {
await resetDb();
ids = await seedMinimal();
server = createApp().listen(0);
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
});
after(async () => {
server.close();
await pool.end();
});
function req(path: string, init: RequestInit = {}, userId = ids.employeeUserId) {
return fetch(`${base}${path}`, {
...init,
headers: {
"content-type": "application/json",
authorization: `Bearer ${userId}`,
...(init.headers || {}),
},
});
}
async function crearCita(start: string): Promise<number> {
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,$5::timestamptz,$5::timestamptz + interval '90 minutes',850)
RETURNING id`,
[ids.businessId, ids.clientId, ids.employeeId, ids.serviceId, start]
);
return rows[0].id;
}
test("marcar Vino crea la visita y completa la cita", async () => {
const id = await crearCita("2026-09-03T16:00:00Z");
const r = await req(`/api/appointments/${id}/attendance`, {
method: "POST",
body: JSON.stringify({ attended: true, total_charged: 900, payment_method: "cash" }),
});
assert.equal(r.status, 200);
const { appointment, visit } = await r.json();
assert.equal(appointment.status, "completed");
assert.equal(visit.total_charged, 900);
assert.equal(visit.payment_method, "cash");
assert.equal(visit.recorded_by_user_id, ids.employeeUserId);
});
test("marcar No vino no crea visita", async () => {
const id = await crearCita("2026-09-03T18:00:00Z");
const r = await req(`/api/appointments/${id}/attendance`, {
method: "POST",
body: JSON.stringify({ attended: false }),
});
assert.equal(r.status, 200);
const { appointment, visit } = await r.json();
assert.equal(appointment.status, "no_show");
assert.equal(visit, null);
const { rows } = await pool.query(
`SELECT count(*)::int c FROM visits WHERE appointment_id = $1`,
[id]
);
assert.equal(rows[0].c, 0);
});
test("marcar dos veces la misma cita devuelve 409", async () => {
const id = await crearCita("2026-09-03T20:00:00Z");
await req(`/api/appointments/${id}/attendance`, {
method: "POST",
body: JSON.stringify({ attended: true }),
});
const r = await req(`/api/appointments/${id}/attendance`, {
method: "POST",
body: JSON.stringify({ attended: false }),
});
assert.equal(r.status, 409);
const body = await r.json();
assert.match(body.error, /ya se resolvió/i);
});
test("no se puede marcar asistencia en una cita cancelada", async () => {
const id = await crearCita("2026-09-04T16:00:00Z");
await pool.query(
`UPDATE appointments SET status='cancelled', cancelled_by='client' WHERE id=$1`,
[id]
);
const r = await req(`/api/appointments/${id}/attendance`, {
method: "POST",
body: JSON.stringify({ attended: true }),
});
assert.equal(r.status, 409);
});
test("una empleada no puede resolver la cita de otra", async () => {
const otra = await pool.query(
`INSERT INTO employees (business_id, name) VALUES ($1,'Otra especialista') RETURNING id`,
[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-05T16:00:00Z','2026-09-05T17:00:00Z',0)
RETURNING id`,
[ids.businessId, ids.clientId, otra.rows[0].id, ids.serviceId]
);
const r = await req(`/api/appointments/${rows[0].id}/attendance`, {
method: "POST",
body: JSON.stringify({ attended: true }),
});
assert.equal(r.status, 403);
// La administradora sí puede.
const rOwner = await req(
`/api/appointments/${rows[0].id}/attendance`,
{ method: "POST", body: JSON.stringify({ attended: true }) },
ids.ownerUserId
);
assert.equal(rOwner.status, 200);
});
test("el toque deja evento y auditoría", async () => {
const { rows } = await pool.query(
`SELECT action FROM appointment_events WHERE action IN ('attended','no_show')`
);
assert.ok(rows.some((r) => r.action === "attended"));
assert.ok(rows.some((r) => r.action === "no_show"));
const audit = await pool.query(
`SELECT count(*)::int c FROM audit_log WHERE action = 'attendance'`
);
assert.ok(audit.rows[0].c >= 2);
});
+59
View File
@@ -0,0 +1,59 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { pool, withTx } from "../db/pool.ts";
import { writeAudit } from "../lib/audit.ts";
import { resetDb, seedMinimal } from "./helpers.ts";
let ids: Awaited<ReturnType<typeof seedMinimal>>;
before(async () => {
await resetDb();
ids = await seedMinimal();
});
after(async () => {
await pool.end();
});
test("writeAudit guarda el antes y el después como jsonb", async () => {
await withTx(async (c) => {
await writeAudit(c, {
businessId: ids.businessId,
actorUserId: ids.ownerUserId,
entity: "clients",
entityId: ids.clientId,
action: "update",
before: { name: "Mariana" },
after: { name: "Mariana López" },
ip: "127.0.0.1",
});
});
const { rows } = await pool.query(
`SELECT entity, action, before, after, ip FROM audit_log
WHERE entity_id = $1 ORDER BY id DESC LIMIT 1`,
[ids.clientId]
);
assert.equal(rows[0].entity, "clients");
assert.equal(rows[0].action, "update");
assert.deepEqual(rows[0].before, { name: "Mariana" });
assert.deepEqual(rows[0].after, { name: "Mariana López" });
assert.equal(rows[0].ip, "127.0.0.1");
});
test("writeAudit se apunta a la transacción que lo llama", async () => {
await assert.rejects(
withTx(async (c) => {
await writeAudit(c, {
businessId: ids.businessId,
actorUserId: ids.ownerUserId,
entity: "clients",
entityId: ids.clientId,
action: "delete",
});
throw new Error("boom");
})
);
const { rows } = await pool.query(
`SELECT count(*)::int c FROM audit_log WHERE action = 'delete'`
);
assert.equal(rows[0].c, 0, "el rollback debe llevarse también la auditoría");
});
+99
View File
@@ -0,0 +1,99 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { pool } from "../db/pool.ts";
import { createApp } from "../index.ts";
import { resetDb, seedMinimal } from "./helpers.ts";
import type { Server } from "node:http";
let ids: Awaited<ReturnType<typeof seedMinimal>>;
let server: Server;
let base: string;
before(async () => {
await resetDb();
ids = await seedMinimal();
server = createApp().listen(0);
const addr = server.address() as { port: number };
base = `http://127.0.0.1:${addr.port}`;
});
after(async () => {
server.close();
await pool.end();
});
function req(path: string, init: RequestInit = {}, userId = ids.ownerUserId) {
return fetch(`${base}${path}`, {
...init,
headers: {
"content-type": "application/json",
authorization: `Bearer ${userId}`,
...(init.headers || {}),
},
});
}
test("crea una clienta y guarda el teléfono normalizado", async () => {
const r = await req("/api/clients", {
method: "POST",
body: JSON.stringify({ name: "Sofía Ramírez", phone: "(55) 4444-3333" }),
});
assert.equal(r.status, 201);
const { client } = await r.json();
assert.equal(client.phone, "(55) 4444-3333", "conserva lo que tecleó la persona");
assert.equal(client.phone_e164, "+525544443333");
assert.equal(client.contactable, true);
});
test("el segundo alta con el mismo número devuelve 409 con la ficha existente", async () => {
const r = await req("/api/clients", {
method: "POST",
body: JSON.stringify({ name: "Sofia R.", phone: "+52 55 4444 3333" }),
});
assert.equal(r.status, 409);
const body = await r.json();
assert.match(body.error, /ya existe/i);
assert.equal(body.existing.name, "Sofía Ramírez");
assert.equal(body.existing.phone_e164, "+525544443333");
});
test("permite dar de alta sin teléfono, marcada como no contactable", async () => {
const r = await req("/api/clients", {
method: "POST",
body: JSON.stringify({ name: "Clienta de mostrador" }),
});
assert.equal(r.status, 201);
const { client } = await r.json();
assert.equal(client.phone_e164, null);
assert.equal(client.contactable, false);
});
test("la búsqueda encuentra por teléfono sin formato", async () => {
const r = await req("/api/clients?q=5544443333");
const { clients } = await r.json();
assert.equal(clients.length, 1);
assert.equal(clients[0].name, "Sofía Ramírez");
});
test("el alta deja rastro en audit_log", async () => {
const { rows } = await pool.query(
`SELECT action, actor_user_id FROM audit_log
WHERE entity = 'clients' AND action = 'create' ORDER BY id DESC LIMIT 1`
);
assert.equal(rows[0].action, "create");
assert.equal(rows[0].actor_user_id, ids.ownerUserId);
});
test("no se ven clientas de otro negocio", async () => {
const other = await pool.query(
`INSERT INTO businesses (name, slug, working_hours)
VALUES ('Otro Spa','otro','{}'::jsonb) RETURNING id`
);
const otherUser = await pool.query(
`INSERT INTO users (business_id, email, password, name, role)
VALUES ($1,'[email protected]','x','Otro','owner') RETURNING id`,
[other.rows[0].id]
);
const r = await req("/api/clients", {}, otherUser.rows[0].id);
const { clients } = await r.json();
assert.equal(clients.length, 0);
});
+121
View File
@@ -0,0 +1,121 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { pool } from "../db/pool.ts";
import { resetDb, crearNegocio } from "./helpers.ts";
import { ctxDe, guardarCredencial, olvidarCredencial } from "../crm/ctx.ts";
process.env.CRM_MASTER_KEY = Buffer.alloc(32, 3).toString("base64");
test("dos negocios tienen credenciales distintas y no se cruzan", async () => {
await resetDb();
const a = await crearNegocio({ name: "Spa A", slug: "spa-a" });
const b = await crearNegocio({ name: "Spa B", slug: "spa-b" });
await guardarCredencial(a.id, "loc-AAA", "token-de-A", "Spa A");
await guardarCredencial(b.id, "loc-BBB", "token-de-B", "Spa B");
const ctxA = await ctxDe(a.id);
const ctxB = await ctxDe(b.id);
assert.equal(ctxA.locationId, "loc-AAA");
assert.equal(ctxA.token, "token-de-A");
assert.equal(ctxB.locationId, "loc-BBB");
assert.equal(ctxB.token, "token-de-B");
assert.equal(ctxA.businessId, a.id);
});
test("el token no queda en claro en la base", async () => {
await resetDb();
const a = await crearNegocio();
await guardarCredencial(a.id, "loc-1", "token-secretisimo");
const { rows } = await pool.query<{ token_cipher: Buffer; token_fingerprint: string }>(
`SELECT token_cipher, token_fingerprint FROM crm_connections WHERE business_id = $1`,
[a.id]
);
assert.ok(
!rows[0].token_cipher.toString("utf8").includes("token-secretisimo"),
"el cifrado no puede contener el token legible"
);
assert.equal(rows[0].token_fingerprint, "etisimo".slice(-6));
});
test("volver a guardar rota la credencial sin duplicar la fila", async () => {
await resetDb();
const a = await crearNegocio();
await guardarCredencial(a.id, "loc-1", "token-viejo");
await guardarCredencial(a.id, "loc-1", "token-nuevo");
assert.equal((await ctxDe(a.id)).token, "token-nuevo");
const { rows } = await pool.query<{ n: number }>(
`SELECT count(*)::int AS n FROM crm_connections WHERE business_id = $1`,
[a.id]
);
assert.equal(rows[0].n, 1);
});
test("rotar la credencial conserva la etiqueta anterior si no se manda otra", async () => {
await resetDb();
const a = await crearNegocio();
await guardarCredencial(a.id, "loc-1", "t1", "Yola Franco Spa");
await guardarCredencial(a.id, "loc-1", "t2");
const { rows } = await pool.query<{ label: string }>(
`SELECT label FROM crm_connections WHERE business_id = $1`,
[a.id]
);
assert.equal(rows[0].label, "Yola Franco Spa");
});
test("un negocio sin conexión da un 409 que dice qué hacer", async () => {
await resetDb();
const a = await crearNegocio();
await assert.rejects(
() => ctxDe(a.id),
(e: any) => {
assert.equal(e.status, 409);
assert.match(e.error, /no está vinculado/i);
return true;
}
);
});
test("una conexión sin token da un 409 distinto del de sin conexión", async () => {
await resetDb();
const a = await crearNegocio();
await pool.query(
`INSERT INTO crm_connections (business_id, location_id) VALUES ($1, 'loc-1')`,
[a.id]
);
await assert.rejects(
() => ctxDe(a.id),
(e: any) => {
assert.equal(e.status, 409);
assert.match(e.error, /token/i);
return true;
}
);
});
test("olvidarCredencial borra el token pero conserva la conexión y lo sincronizado", async () => {
await resetDb();
const a = await crearNegocio();
await guardarCredencial(a.id, "loc-1", "token-x", "Etiqueta");
await olvidarCredencial(a.id);
const { rows } = await pool.query(
`SELECT location_id, label, token_cipher, token_fingerprint
FROM crm_connections WHERE business_id = $1`,
[a.id]
);
assert.equal(rows[0].location_id, "loc-1", "la subcuenta se recuerda");
assert.equal(rows[0].label, "Etiqueta");
assert.equal(rows[0].token_cipher, null);
assert.equal(rows[0].token_fingerprint, null);
// `ctxDe` lanza `{ status, error }`, no un Error: la forma con expresión
// regular compara contra `message`, que un objeto plano no tiene.
await assert.rejects(
() => ctxDe(a.id),
(e: any) => {
assert.match(e.error, /token/i);
return true;
}
);
});
+101
View File
@@ -0,0 +1,101 @@
// La zona del proceso es UTC y la del negocio America/Mexico_City: nunca
// coinciden, así que una recaída de zona horaria falla aquí y no en producción.
process.env.TZ = "UTC";
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { pool } from "../db/pool.ts";
import { createApp } from "../index.ts";
import { resetDb, seedMinimal } from "./helpers.ts";
import type { Server } from "node:http";
let ids: Awaited<ReturnType<typeof seedMinimal>>;
let server: Server;
let base: string;
before(async () => {
await resetDb();
ids = await seedMinimal();
server = createApp().listen(0);
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
});
after(async () => {
server.close();
await pool.end();
});
function req(path: string, init: RequestInit = {}, userId = ids.ownerUserId) {
return fetch(`${base}${path}`, {
...init,
headers: {
"content-type": "application/json",
authorization: `Bearer ${userId}`,
...(init.headers || {}),
},
});
}
async function crearCita(startUtc: string): Promise<number> {
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,$5::timestamptz,$5::timestamptz + interval '60 minutes',850)
RETURNING id`,
[ids.businessId, ids.clientId, ids.employeeId, ids.serviceId, startUtc]
);
return rows[0].id;
}
test("una cita de las 19:00 de México cuenta en su día local, no en el UTC", async () => {
// 2026-09-07 19:00 en México (UTC-6) = 2026-09-08 01:00 UTC.
await crearCita("2026-09-08T01:00:00Z");
const r = await req("/api/day-close?date=2026-09-07");
const body = await r.json();
assert.equal(body.unresolved.length, 1, "debe contarse en el 7, no en el 8");
});
test("no deja cerrar el día con citas sin resolver", async () => {
const r = await req("/api/day-close", {
method: "POST",
body: JSON.stringify({ date: "2026-09-07" }),
});
assert.equal(r.status, 409);
const body = await r.json();
assert.equal(body.unresolved.length, 1);
assert.match(body.error, /sin resolver/i);
});
test("cierra el día cuando todas están resueltas y guarda el conteo", async () => {
const { rows } = await pool.query(`SELECT id FROM appointments WHERE status = 'scheduled'`);
await req(`/api/appointments/${rows[0].id}/attendance`, {
method: "POST",
body: JSON.stringify({ attended: true, total_charged: 850 }),
});
const r = await req("/api/day-close", {
method: "POST",
body: JSON.stringify({ date: "2026-09-07" }),
});
assert.equal(r.status, 200);
const { closure } = await r.json();
assert.equal(closure.attended_count, 1);
assert.equal(closure.no_show_count, 0);
assert.equal(closure.closed_by_user_id, ids.ownerUserId);
});
test("cerrar dos veces el mismo día devuelve 409", async () => {
const r = await req("/api/day-close", {
method: "POST",
body: JSON.stringify({ date: "2026-09-07" }),
});
assert.equal(r.status, 409);
const body = await r.json();
assert.match(body.error, /ya está cerrado/i);
});
test("el resumen del día muestra la fecha de cierre", async () => {
const r = await req("/api/day-close?date=2026-09-07");
const body = await r.json();
assert.ok(body.closed_at, "un día cerrado reporta cuándo se cerró");
assert.equal(body.attended, 1);
});
+112
View File
@@ -0,0 +1,112 @@
import { pool } from "../db/pool.ts";
import { runMigrations } from "../db/migrate.ts";
export interface SeedIds {
businessId: number;
ownerUserId: number;
employeeUserId: number;
employeeId: number;
serviceId: number;
clientId: number;
}
const WORKING_HOURS = JSON.stringify({
1: { start: "09:00", end: "20:00" },
2: { start: "09:00", end: "20:00" },
3: { start: "09:00", end: "20:00" },
4: { start: "09:00", end: "20:00" },
5: { start: "09:00", end: "20:00" },
6: { start: "10:00", end: "18:00" },
7: null,
});
/**
* Vacía el esquema. La guarda del nombre no es decorativa: este DROP SCHEMA
* contra la base de desarrollo se llevaría los datos del spa por delante.
*/
export async function dropSchema(): Promise<void> {
if (!/yola_test/.test(process.env.DATABASE_URL || "")) {
throw new Error("dropSchema solo corre contra yola_test — revisa DATABASE_URL");
}
await pool.query(`DROP SCHEMA public CASCADE; CREATE SCHEMA public;`);
}
/** Deja la base vacía y con el esquema al día. */
export async function resetDb(): Promise<void> {
await dropSchema();
await runMigrations();
}
export async function seedMinimal(): Promise<SeedIds> {
const biz = await pool.query(
`INSERT INTO businesses (name, slug, working_hours)
VALUES ('Yola Franco Spa', 'yola-franco', $1::jsonb) RETURNING id`,
[WORKING_HOURS]
);
const businessId = biz.rows[0].id as number;
const emp = await pool.query(
`INSERT INTO employees (business_id, name, email)
VALUES ($1,'Karla Ruiz','[email protected]') RETURNING id`,
[businessId]
);
const employeeId = emp.rows[0].id as number;
const svc = await pool.query(
`INSERT INTO services (business_id, name, duration_min, price)
VALUES ($1,'Extensiones de pestañas',90,850) RETURNING id`,
[businessId]
);
const serviceId = svc.rows[0].id as number;
await pool.query(
`INSERT INTO employee_services (employee_id, service_id) VALUES ($1,$2)`,
[employeeId, serviceId]
);
const owner = await pool.query(
`INSERT INTO users (business_id, email, password, name, role)
VALUES ($1,'[email protected]','demo1234','Yola Franco','owner') RETURNING id`,
[businessId]
);
const empUser = await pool.query(
`INSERT INTO users (business_id, email, password, name, role, employee_id)
VALUES ($1,'[email protected]','demo1234','Karla Ruiz','employee',$2) RETURNING id`,
[businessId, employeeId]
);
const cli = await pool.query(
`INSERT INTO clients (business_id, name, phone, phone_e164)
VALUES ($1,'Mariana López','55 8888 7777','+525588887777') RETURNING id`,
[businessId]
);
return {
businessId,
ownerUserId: owner.rows[0].id,
employeeUserId: empUser.rows[0].id,
employeeId,
serviceId,
clientId: cli.rows[0].id,
};
}
/**
* Crea un negocio suelto, sin catálogo ni personal.
*
* `seedMinimal` siembra UN negocio completo y sirve para casi todo; esto existe
* para las pruebas multi-negocio, donde lo que se comprueba es justamente que
* dos cuentas no se pisan y no hace falta el resto del inventario.
*/
export async function crearNegocio(
opts: { name?: string; slug?: string } = {}
): Promise<{ id: number; name: string }> {
const name = opts.name ?? `Negocio ${Math.random().toString(36).slice(2, 8)}`;
const slug = opts.slug ?? name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
const { rows } = await pool.query(
`INSERT INTO businesses (name, slug, working_hours)
VALUES ($1, $2, $3::jsonb) RETURNING id, name`,
[name, slug, WORKING_HOURS]
);
return rows[0];
}
+28
View File
@@ -0,0 +1,28 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { runMigrations } from "../db/migrate.ts";
import { pool } from "../db/pool.ts";
import { dropSchema } from "./helpers.ts";
// Parte de un esquema vacío a propósito: la aserción es que el bootstrap se
// aplica, y eso solo es cierto sobre una base sin migrar.
before(async () => {
await dropSchema();
});
after(async () => {
await pool.end();
});
test("runMigrations aplica los archivos pendientes y es idempotente", async () => {
const first = await runMigrations();
assert.ok(first.includes("000_bootstrap.sql"), "debe aplicar el bootstrap");
assert.ok(first.includes("001_core.sql"), "debe aplicar el núcleo");
const second = await runMigrations();
assert.deepEqual(second, [], "una segunda corrida no aplica nada");
const { rows } = await pool.query(
`SELECT count(*)::int AS c FROM schema_migrations WHERE filename = '000_bootstrap.sql'`
);
assert.equal(rows[0].c, 1, "no debe registrarse dos veces");
});
+121
View File
@@ -0,0 +1,121 @@
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<ReturnType<typeof seedMinimal>>;
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 () => {
+143
View File
@@ -0,0 +1,143 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { pool } from "../db/pool.ts";
import { resetDb, crearNegocio } from "./helpers.ts";
import { upsertConversacion, upsertMensaje } from "../crm/syncConversations.ts";
test("upsertConversacion es idempotente: dos veces no duplica", async () => {
await resetDb();
const b = await crearNegocio();
const conv = {
id: "conv-1",
contactId: "c-1",
fullName: "Ana",
lastMessageBody: "hola",
lastMessageType: "TYPE_SMS",
lastMessageDate: 1756000000000,
unreadCount: 2,
};
const id1 = await upsertConversacion(b.id, conv as any);
const id2 = await upsertConversacion(b.id, conv as any);
assert.equal(id1, id2);
const { rows } = await pool.query<{ n: number }>(
`SELECT count(*)::int AS n FROM conversations WHERE business_id = $1`,
[b.id]
);
assert.equal(rows[0].n, 1);
});
test("upsertConversacion enlaza con la clienta local por crm_contact_id", async () => {
await resetDb();
const b = await crearNegocio();
const { rows: cl } = await pool.query(
`INSERT INTO clients (business_id, name, crm_contact_id) VALUES ($1,'Ana','c-9') RETURNING id`,
[b.id]
);
await upsertConversacion(b.id, { id: "conv-9", contactId: "c-9", fullName: "Ana" } as any);
const { rows } = await pool.query(
`SELECT client_id FROM conversations WHERE business_id = $1 AND crm_conversation_id = 'conv-9'`,
[b.id]
);
assert.equal(rows[0].client_id, cl[0].id);
});
test("el enlace con la clienta se rellena después, y no se pierde al resincronizar", async () => {
await resetDb();
const b = await crearNegocio();
// Primero llega la conversación, cuando la clienta todavía no existe.
await upsertConversacion(b.id, { id: "conv-x", contactId: "c-x", fullName: "Ana" } as any);
const { rows: sin } = await pool.query(
`SELECT client_id FROM conversations WHERE crm_conversation_id = 'conv-x'`
);
assert.equal(sin[0].client_id, null);
// Luego la sincronización de contactos crea la clienta…
await pool.query(
`INSERT INTO clients (business_id, name, crm_contact_id) VALUES ($1,'Ana','c-x')`,
[b.id]
);
await upsertConversacion(b.id, { id: "conv-x", contactId: "c-x", fullName: "Ana" } as any);
const { rows: con } = await pool.query(
`SELECT client_id FROM conversations WHERE crm_conversation_id = 'conv-x'`
);
assert.ok(con[0].client_id, "al resincronizar debe quedar enlazada");
// …y una tercera pasada NO puede desenlazarla.
await pool.query(`UPDATE clients SET crm_contact_id = NULL WHERE business_id = $1`, [b.id]);
await upsertConversacion(b.id, { id: "conv-x", contactId: "c-x", fullName: "Ana" } as any);
const { rows: sigue } = await pool.query(
`SELECT client_id FROM conversations WHERE crm_conversation_id = 'conv-x'`
);
assert.equal(sigue[0].client_id, con[0].client_id, "un enlace resuelto no se borra");
});
test("upsertMensaje no duplica el mismo crm_message_id", async () => {
await resetDb();
const b = await crearNegocio();
const convId = await upsertConversacion(b.id, { id: "conv-2", contactId: "c-2" } as any);
const m = { id: "msg-1", body: "hola", direction: "inbound", messageType: "TYPE_SMS" };
await upsertMensaje(b.id, convId, m as any);
await upsertMensaje(b.id, convId, m as any);
const { rows } = await pool.query<{ n: number }>(
`SELECT count(*)::int AS n FROM messages WHERE business_id = $1`,
[b.id]
);
assert.equal(rows[0].n, 1);
});
test("upsertMensaje guarda el canal normalizado y el crudo", async () => {
await resetDb();
const b = await crearNegocio();
const convId = await upsertConversacion(b.id, { id: "conv-3" } as any);
await upsertMensaje(b.id, convId, {
id: "msg-2", body: "x", direction: "outbound", messageType: "TYPE_EMAIL",
} as any);
const { rows } = await pool.query(
`SELECT channel, channel_raw, direction FROM messages WHERE crm_message_id = 'msg-2'`
);
assert.equal(rows[0].channel, "Email");
assert.equal(rows[0].channel_raw, "TYPE_EMAIL", "el valor original se conserva");
assert.equal(rows[0].direction, "outbound");
});
test("un mensaje con dirección desconocida se guarda como entrante, no revienta", async () => {
await resetDb();
const b = await crearNegocio();
const convId = await upsertConversacion(b.id, { id: "conv-4" } as any);
await upsertMensaje(b.id, convId, { id: "msg-3", body: "x" } as any);
const { rows } = await pool.query(
`SELECT direction FROM messages WHERE crm_message_id = 'msg-3'`
);
assert.equal(rows[0].direction, "inbound");
});
test("sincronizar un hilo por id NO degrada el nombre ni el canal ya conocidos", async () => {
await resetDb();
const b = await crearNegocio();
// Primero llega desde el buscador, con nombre y canal buenos.
await upsertConversacion(b.id, {
id: "conv-deg", contactId: "c-d", fullName: "Carmen García",
lastMessageType: "TYPE_INSTAGRAM", lastMessageBody: "hola",
} as any);
// Luego llega por id, que no trae ni nombre ni canal reconocible.
await upsertConversacion(b.id, { id: "conv-deg", contactId: "c-d" } as any);
const { rows } = await pool.query(
`SELECT contact_name, last_message_type, last_message_body
FROM conversations WHERE crm_conversation_id = 'conv-deg'`
);
assert.equal(rows[0].contact_name, "Carmen García", "el nombre bueno se conserva");
assert.equal(rows[0].last_message_type, "Instagram", "el canal bueno se conserva");
assert.equal(rows[0].last_message_body, "hola", "y el último mensaje también");
});
test("un nombre nuevo y bueno SÍ reemplaza al anterior", async () => {
await resetDb();
const b = await crearNegocio();
await upsertConversacion(b.id, { id: "conv-n", fullName: "Nombre Viejo" } as any);
await upsertConversacion(b.id, { id: "conv-n", fullName: "Nombre Nuevo" } as any);
const { rows } = await pool.query(
`SELECT contact_name FROM conversations WHERE crm_conversation_id = 'conv-n'`
);
assert.equal(rows[0].contact_name, "Nombre Nuevo");
});
+84
View File
@@ -0,0 +1,84 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { pool } from "../db/pool.ts";
import { createApp } from "../index.ts";
import { resetDb, seedMinimal } from "./helpers.ts";
import { esEntidad, ENTIDADES } from "../crm/syncOne.ts";
import type { Server } from "node:http";
process.env.CRM_MASTER_KEY = Buffer.alloc(32, 11).toString("base64");
let ids: Awaited<ReturnType<typeof seedMinimal>>;
let server: Server;
let base: string;
before(async () => {
await resetDb();
ids = await seedMinimal();
server = createApp().listen(0);
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
});
after(async () => {
server.close();
await pool.end();
});
const req = (path: string, init: RequestInit = {}) =>
fetch(`${base}${path}`, {
...init,
headers: {
"content-type": "application/json",
authorization: `Bearer ${ids.ownerUserId}`,
...(init.headers || {}),
},
});
test("esEntidad acepta solo las cinco entidades del encargo", () => {
for (const e of ENTIDADES) assert.ok(esEntidad(e));
assert.equal(esEntidad("cliente"), false);
assert.equal(esEntidad(""), false);
assert.equal(esEntidad("../../etc/passwd"), false);
assert.equal(esEntidad("CONTACTO"), false);
});
test("ENTIDADES son exactamente las cinco, ni una más", () => {
assert.deepEqual(
[...ENTIDADES].sort(),
["cita", "contacto", "conversacion", "mensaje", "servicio"]
);
});
test("una entidad inventada da 400 y dice cuáles valen", async () => {
const r = await req("/api/crm/sync/pedido/abc", { method: "POST" });
assert.equal(r.status, 400);
const b = await r.json();
assert.match(b.error, /contacto/);
assert.match(b.error, /servicio/);
});
test("un identificador desmesurado se rechaza antes de salir a la red", async () => {
const r = await req(`/api/crm/sync/contacto/${"x".repeat(200)}`, { method: "POST" });
assert.equal(r.status, 400);
assert.match((await r.json()).error, /demasiado largo/i);
});
test("sin vínculo con el CRM, sincronizar por id da 409, no 500", async () => {
const r = await req("/api/crm/sync/contacto/abc123", { method: "POST" });
assert.equal(r.status, 409);
assert.match((await r.json()).error, /no está vinculado/i);
});
test("el espejo de conversaciones sin vínculo también da 409", async () => {
const r = await req("/api/crm/sync/conversations", { method: "POST" });
assert.equal(r.status, 409);
});
test("la cita exige un identificador numérico de la plataforma", async () => {
// Se vincula con credencial falsa: basta para pasar de `ctxDe` y llegar a la
// validación del identificador, que es lo que se prueba aquí.
const { guardarCredencial } = await import("../crm/ctx.ts");
await guardarCredencial(ids.businessId, "loc-x", "tok-x");
const r = await req("/api/crm/sync/cita/no-es-un-numero", { method: "POST" });
assert.equal(r.status, 400);
assert.match((await r.json()).error, /numérico/i);
});