La reserva pública no ofrecía horarios en producción. La ventana laboral se
construía con `new Date(y, m, d, hh, mm)`, que resuelve el reloj de pared en la
tz del proceso. El Dockerfile no fijaba TZ y node:22-slim arranca en UTC,
mientras que la máquina de desarrollo está en America/Mexico_City: por eso solo
fallaba desplegado. Un negocio de 09:00-20:00 se publicaba como 09:00-20:00 UTC
(03:00-14:00 de México), y como el generador descarta lo anterior a ahora+30min,
a partir de la 1 PM la lista quedaba vacía.
Toda la API de scheduling.ts lleva ahora `tz` explícita y resuelve el reloj de
pared con wallToUtcDate/bizDateISO de time.ts, que ya existían para esto.
Arrastraba cinco defectos más en la misma ruta:
- getExistingBusy acotaba el día concatenando `${fecha}T00:00:00`. Como start_at
se guarda en UTC, una cita de las 19:00 de México vive en el día UTC siguiente
y quedaba fuera del rango: el guard anti doble-reserva no veía la tarde entera.
Ahora usa bizDayBoundsIsoFor.
- Un negocio recién sembrado nacía con working_hours y slug en NULL, o sea con
cero franjas agendables y /b/:slug en 404: el backfill vivía solo dentro de las
migraciones, que corren antes de que exista la fila. Los defaults se fijan en el
INSERT (server/lib/businessDefaults.ts) en los tres sitios que crean negocios, y
migrateV4ToV5 repara los ya rotos. El demo usa slug fijo `mi-negocio-demo`
porque es la URL ya publicada y el volumen se recrea en cada despliegue.
- El chip mostraba la hora formateada por el servidor y el resumen la del
navegador: dos horas distintas para el mismo slot. Ambas salen ahora del
instante resuelto en la tz del negocio.
- La separación mañana/tarde usaba /PM/i sobre un texto ya localizado, y es-MX
rinde "05:00 p.m." con puntos: nunca casaba, así que el grupo "Tarde"
desaparecía y toda la tarde se agrupaba bajo "Mañana".
- MonthCalendar comparaba canPrev contra el día 1 del mes visible en vez de
contra minDate, de modo que la flecha de mes anterior nunca se podía pulsar.
Las guardas de migración comparaban la versión como texto ("10" >= "2" es false),
lo que habría reejecutado migrateV1ToV2 y su DROP TABLE users al llegar a dos
dígitos; ahora comparan números.
Verificación: scheduling.test.ts fija TZ=UTC y usa negocios en America/Mexico_City
para que la tz del proceso y la del negocio nunca coincidan; el Dockerfile fija
ENV TZ=UTC por lo mismo. 43 unitarias + 33 e2e + 12 booking + 17 admin en verde
con el servidor en UTC y base recién sembrada; typecheck limpio. booking-e2e.mjs
busca el próximo día abierto en vez de asumir "mañana", que lo hacía fallar cada
viernes y sábado por calendario.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
209 lines
11 KiB
TypeScript
209 lines
11 KiB
TypeScript
// server/lib/scheduling.test.ts
|
|
//
|
|
// La suite corre con TZ=UTC A PROPÓSITO, y los casos usan negocios en
|
|
// America/Mexico_City: la tz del proceso y la del negocio nunca coinciden, así que
|
|
// cualquier cálculo que se ancle a la del proceso falla aquí y no en producción.
|
|
// (Se fija en runtime en vez de en el script de npm porque `TZ=x cmd` no es
|
|
// portable a cmd.exe en Windows.)
|
|
process.env.TZ = "UTC";
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
parseWorkingHours, isoDayOfWeek, getWorkingHoursForDate,
|
|
normalizeText, tokens, specialtyMatch, overlaps, hasConflict, scoreCandidate,
|
|
pickBestSlotEmployee, BUSY_STATUSES,
|
|
} from "./scheduling.ts";
|
|
import type { CandidateInfo, BusyWindow } from "./scheduling.ts";
|
|
|
|
const MX = "America/Mexico_City";
|
|
/** Instante UTC de un reloj de pared mexicano. Los tests no deben depender de la tz del proceso. */
|
|
function mx(dateIso: string, hh: number, mm = 0): number {
|
|
const [y, mo, d] = dateIso.split("-").map(Number);
|
|
return Date.UTC(y, mo - 1, d, hh + 6, mm); // México = UTC-6 todo el año desde 2022
|
|
}
|
|
|
|
test("parseWorkingHours: json válido → mapa 1..7", () => {
|
|
const m = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" }, 6: null, 7: null }));
|
|
assert.equal(m?.[1]?.start, "09:00");
|
|
assert.equal(m?.[6], null);
|
|
assert.equal(m?.[7], null);
|
|
assert.equal(m?.[2], null); // ausente → null
|
|
});
|
|
|
|
test("parseWorkingHours: null/invalid → null", () => {
|
|
assert.equal(parseWorkingHours(null), null);
|
|
assert.equal(parseWorkingHours("no-json"), null);
|
|
});
|
|
|
|
test("isoDayOfWeek: lunes=1, domingo=7", () => {
|
|
assert.equal(isoDayOfWeek(new Date("2026-07-27T12:00:00")), 1); // lunes
|
|
assert.equal(isoDayOfWeek(new Date("2026-07-26T12:00:00")), 7); // domingo
|
|
});
|
|
|
|
test("getWorkingHoursForDate: día cerrado → null", () => {
|
|
const biz = parseWorkingHours(JSON.stringify({ 6: null, 7: null })); // fin de semana cerrado
|
|
assert.equal(getWorkingHoursForDate(null, biz, "2026-07-25", MX), null); // sábado
|
|
});
|
|
|
|
test("normalizeText/tokens: quita acentos y lowercase", () => {
|
|
assert.equal(normalizeText("Coloración"), "coloracion");
|
|
assert.deepEqual(tokens("Corte de cabello"), ["corte", "de", "cabello"]);
|
|
});
|
|
|
|
test("specialtyMatch: coincidencia exacta de etiqueta → 1", () => {
|
|
assert.equal(specialtyMatch(["corte"], { name: "Corte de cabello", category: "Cabello" }), 1);
|
|
});
|
|
|
|
test("specialtyMatch: etiqueta con acento/case → 1", () => {
|
|
assert.equal(specialtyMatch(["Coloración"], { name: "Tinte", category: "Coloración" }), 1);
|
|
});
|
|
|
|
test("specialtyMatch: sin etiquetas → 0.5", () => {
|
|
assert.equal(specialtyMatch([], { name: "Corte", category: "Cabello" }), 0.5);
|
|
});
|
|
|
|
test("specialtyMatch: etiqueta no relacionada → 0.5", () => {
|
|
assert.equal(specialtyMatch(["barba"], { name: "Manicura", category: "Uñas" }), 0.5);
|
|
});
|
|
|
|
test("overlaps: bordes inclusivos de no-traslape", () => {
|
|
// una cita termina exactamente cuando empieza otra → NO traslape
|
|
assert.equal(overlaps(100, 200, 200, 300), false);
|
|
// traslape real
|
|
assert.equal(overlaps(100, 250, 200, 300), true);
|
|
assert.equal(overlaps(200, 300, 100, 250), true);
|
|
});
|
|
|
|
test("hasConflict: lista vacía → false", () => {
|
|
assert.equal(hasConflict([], 100, 200), false);
|
|
});
|
|
|
|
test("scoreCandidate: pesos 40/20/40", () => {
|
|
// todo al máximo → 100
|
|
assert.equal(scoreCandidate({ specialtyMatch: 1, efficiency: 1, loadBalance: 0 }), 100);
|
|
// todo al mínimo → 0
|
|
assert.equal(scoreCandidate({ specialtyMatch: 0, efficiency: 0, loadBalance: 1 }), 0);
|
|
// sólo specialty (0.5) → 20 (40·0.5)
|
|
assert.equal(scoreCandidate({ specialtyMatch: 0.5, efficiency: 0, loadBalance: 1 }), 20);
|
|
});
|
|
|
|
test("scoreCandidate: mayor efficiency → mayor score", () => {
|
|
const low = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.2, loadBalance: 0.5 });
|
|
const high = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.9, loadBalance: 0.5 });
|
|
assert.ok(high > low);
|
|
});
|
|
|
|
test("scoreCandidate: menor load (más disponible) → mayor score", () => {
|
|
const busy = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.5, loadBalance: 0.9 });
|
|
const free = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.5, loadBalance: 0.1 });
|
|
assert.ok(free > busy);
|
|
});
|
|
|
|
test("scoreCandidate: fairness — libre+ineficiente vence a ocupado+eficiente (misma especialidad)", () => {
|
|
// Con los pesos viejos (50/30/20) el efficiency estático (30) aplastaba al load (20):
|
|
// un senior ocupado siempre ganaba. Con 40/20/40, el load (40) supera al efficiency (20).
|
|
// A: especialista ocupado (load 0.8) y muy eficiente (0.9)
|
|
const a = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.9, loadBalance: 0.8 });
|
|
// B: especialista libre (load 0.1) e ineficiente (0.2)
|
|
const b = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.2, loadBalance: 0.1 });
|
|
assert.equal(a, 40 * 0.5 + 20 * 0.9 + 40 * (1 - 0.8)); // 46
|
|
assert.equal(b, 40 * 0.5 + 20 * 0.2 + 40 * (1 - 0.1)); // 60
|
|
assert.ok(b > a, "el especialista libre debe vencer al ocupado+eficiente (anti-burnout)");
|
|
});
|
|
|
|
test("pickBestSlotEmployee: empate en score → gana el de menor loadBalance (no por id)", () => {
|
|
const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "17:00" } }));
|
|
const startMs = mx("2026-07-27", 13); // lunes, dentro de horario
|
|
const endMs = mx("2026-07-27", 14);
|
|
// A (id menor, senior): especialidad coincide (sm=1), ocupado media jornada (load=0.5)
|
|
// → 40·1 + 20·0.5 + 40·0.5 = 70
|
|
// B (id mayor, junior): sin especialidad (sm=0.5), libre (load=0)
|
|
// → 40·0.5 + 20·0.5 + 40·1 = 70 → empate exacto; menor load gana
|
|
const A: CandidateInfo = { id: 1, name: "Senior", color: "#fff", role: "stylist", specialties: ["corte"], efficiency_score: 50, empWh: null };
|
|
const B: CandidateInfo = { id: 2, name: "Junior", color: "#fff", role: "stylist", specialties: [], efficiency_score: 50, empWh: null };
|
|
const busy = new Map<number, BusyWindow[]>([
|
|
[1, [{ startMs: mx("2026-07-27", 9), endMs: mx("2026-07-27", 13) }]],
|
|
[2, []],
|
|
]);
|
|
const best = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs, MX);
|
|
assert.equal(best?.id, 2, "el junior libre gana el empate por menor loadBalance, NO por id menor");
|
|
});
|
|
|
|
test("pickBestSlotEmployee: empate total (score+load) → decisión determinista y reproducible", () => {
|
|
const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "17:00" } }));
|
|
const startMs = mx("2026-07-27", 9);
|
|
const endMs = mx("2026-07-27", 10);
|
|
// Dos candidatos idénticos salvo el id → mismo score y mismo loadBalance → hash decide.
|
|
const A: CandidateInfo = { id: 10, name: "Diez", color: "#fff", role: "s", specialties: ["corte"], efficiency_score: 50, empWh: null };
|
|
const B: CandidateInfo = { id: 20, name: "Veinte", color: "#fff", role: "s", specialties: ["corte"], efficiency_score: 50, empWh: null };
|
|
const busy = new Map<number, BusyWindow[]>([[10, []], [20, []]]);
|
|
const r1 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs, MX);
|
|
const r2 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs, MX);
|
|
assert.ok(r1 && (r1.id === 10 || r1.id === 20), "debe elegir uno de los dos candidatos");
|
|
assert.equal(r1?.id, r2?.id, "misma entrada → misma decisión (hash determinista, reproducible)");
|
|
});
|
|
|
|
test("BUSY_STATUSES: scheduled+completed sí cuentan; no_show NO (silla vacía)", () => {
|
|
const s: string[] = [...BUSY_STATUSES];
|
|
assert.ok(s.includes("scheduled"));
|
|
assert.ok(s.includes("completed"));
|
|
assert.equal(s.includes("no_show"), false);
|
|
assert.equal(s.length, 2);
|
|
});
|
|
|
|
// ── Regresión: la ventana laboral debe anclarse a la tz del NEGOCIO ────────────
|
|
// Estos tests fallaban en producción (contenedor node:22-slim = UTC) y pasaban en
|
|
// dev (Windows = America/Mexico_City), porque getWorkingHoursForDate construía la
|
|
// ventana con `new Date(y, m, d, hh, mm)`, es decir en la tz del *proceso*.
|
|
// Corren bajo `TZ=UTC` en test:unit precisamente para que el desfase no se esconda.
|
|
|
|
test("getWorkingHoursForDate: ancla la ventana a la tz del negocio, no a la del proceso", () => {
|
|
const biz = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } }));
|
|
const r = getWorkingHoursForDate(null, biz, "2026-08-31", "America/Mexico_City"); // lunes
|
|
assert.ok(r, "el lunes debe estar abierto");
|
|
// 09:00 CST = 15:00Z ; 20:00 CST = 02:00Z del día siguiente
|
|
assert.equal(new Date(r!.startMs).toISOString(), "2026-08-31T15:00:00.000Z");
|
|
assert.equal(new Date(r!.endMs).toISOString(), "2026-09-01T02:00:00.000Z");
|
|
});
|
|
|
|
test("getWorkingHoursForDate: la misma fecha en otra tz da otra ventana absoluta", () => {
|
|
const biz = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } }));
|
|
const mx = getWorkingHoursForDate(null, biz, "2026-08-31", "America/Mexico_City");
|
|
const utc = getWorkingHoursForDate(null, biz, "2026-08-31", "UTC");
|
|
assert.notEqual(mx!.startMs, utc!.startMs);
|
|
assert.equal(new Date(utc!.startMs).toISOString(), "2026-08-31T09:00:00.000Z");
|
|
});
|
|
|
|
test("getWorkingHoursForDate: el día de la semana sale de la fecha natural, no de un Date local", () => {
|
|
const biz = parseWorkingHours(JSON.stringify({ 6: null, 7: null, 1: { start: "09:00", end: "20:00" } }));
|
|
assert.equal(getWorkingHoursForDate(null, biz, "2026-08-29", "America/Mexico_City"), null); // sábado
|
|
assert.equal(getWorkingHoursForDate(null, biz, "2026-08-30", "America/Mexico_City"), null); // domingo
|
|
assert.ok(getWorkingHoursForDate(null, biz, "2026-08-31", "America/Mexico_City")); // lunes
|
|
});
|
|
|
|
test("getWorkingHoursForDate: empleado tiene prioridad sobre negocio (tz-explícito)", () => {
|
|
const emp = parseWorkingHours(JSON.stringify({ 1: { start: "10:00", end: "14:00" } }));
|
|
const biz = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } }));
|
|
const r = getWorkingHoursForDate(emp, biz, "2026-08-31", "America/Mexico_City");
|
|
assert.equal(new Date(r!.startMs).toISOString(), "2026-08-31T16:00:00.000Z"); // 10:00 CST
|
|
});
|
|
|
|
test("pickBestSlotEmployee: un slot de la tarde del negocio sigue dentro de la ventana", () => {
|
|
// 19:00 México = 01:00Z del día siguiente. Con la ventana mal anclada este slot
|
|
// quedaba fuera de rango y el endpoint devolvía la tarde vacía.
|
|
const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } }));
|
|
const cand: CandidateInfo = {
|
|
id: 1, name: "Ana", color: "#000", role: "Estilista",
|
|
specialties: ["Cabello"], efficiency_score: 80, empWh: null,
|
|
};
|
|
const startMs = Date.parse("2026-09-01T01:00:00Z"); // lunes 31/08 19:00 México
|
|
const endMs = startMs + 60 * 60000;
|
|
const busy = new Map<number, BusyWindow[]>();
|
|
const best = pickBestSlotEmployee(
|
|
[cand], busy, bizWh, { name: "Corte", category: "Cabello" },
|
|
startMs, endMs, "America/Mexico_City"
|
|
);
|
|
assert.equal(best?.id, 1);
|
|
});
|