- Rebrand all user-facing text to AgendaMax - Fix sidebar scrolling: card container no longer overflows html (flex h-full min-h-0) - Fix calendar toolbar hover: active buttons keep readable contrast - Sticky calendar toolbar (month/week/day always visible on scroll) - Seed guarantees 2-4 appointments per day for current week (Mon-Sat)
118 lines
7.2 KiB
JavaScript
118 lines
7.2 KiB
JavaScript
const BASE = "http://localhost:5173/api";
|
|
let pass = 0, fail = 0;
|
|
async function req(method, path, body, token) {
|
|
const headers = { "Content-Type": "application/json" };
|
|
if (token) headers.authorization = `Bearer ${token}`;
|
|
const res = await fetch(`${BASE}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
|
const text = await res.text();
|
|
let json; try { json = JSON.parse(text); } catch { json = text; }
|
|
return { status: res.status, json };
|
|
}
|
|
function check(name, cond, extra = "") {
|
|
if (cond) { pass++; console.log(` ✓ ${name}`); }
|
|
else { fail++; console.log(` ✗ ${name} ${extra}`); }
|
|
}
|
|
|
|
// Fetch the first available slot (iso) for a service over the next `maxDays` days.
|
|
// Slots already filter out non-working days and double-booked specialists, so any
|
|
// returned iso is safe to use as start_at for either autoAssign or self-assign paths.
|
|
async function findSlot(slug, serviceId, maxDays = 30) {
|
|
for (let i = 1; i <= maxDays; i++) {
|
|
const d = new Date(Date.now() + i * 86400000);
|
|
const date = d.toISOString().slice(0, 10);
|
|
const { json } = await req("GET", `/public/${slug}/slots?service_id=${serviceId}&date=${date}`);
|
|
if (Array.isArray(json.slots) && json.slots.length > 0) return json.slots[0].iso;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// /me now works (the bug we fixed) — login first to get a real token
|
|
const { json: login } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
|
|
check("login owner", !!login.token && login.user?.role === "owner", JSON.stringify(login).slice(0, 100));
|
|
const t = login.token;
|
|
const { json: me } = await req("GET", "/auth/me", null, t);
|
|
check("/api/auth/me returns user", me.user?.email === "[email protected]", JSON.stringify(me).slice(0, 100));
|
|
|
|
// Slug del negocio (para consultar slots públicos)
|
|
const { json: settings } = await req("GET", "/settings", null, t);
|
|
const slug = settings.settings?.slug;
|
|
check("tiene slug", !!slug, String(slug));
|
|
|
|
const checks = [
|
|
["business", "/business", (j) => j.business?.name === "Lumière Estética & Spa"],
|
|
["employees+stats", "/employees", (j) => j.employees?.length === 6 && !!j.employees[0].stats],
|
|
["services", "/services", (j) => j.services?.length === 12 && Array.isArray(j.services[0].employee_ids)],
|
|
["clients+stats", "/clients", (j) => j.clients?.length > 0 && j.clients[0].stats?.visits !== undefined],
|
|
["appointments joined", "/appointments?limit=5", (j) => j.appointments?.[0]?.service && j.appointments[0].client],
|
|
["dashboard overview", "/dashboard/overview", (j) => j.revenue_range > 0],
|
|
["best-employees", "/dashboard/best-employees", (j) => j.employees?.length === 6],
|
|
["best-services", "/dashboard/best-services", (j) => j.services?.length > 0],
|
|
["top-tickets", "/dashboard/top-tickets", (j) => j.tickets?.length > 0],
|
|
["top-clients", "/dashboard/top-clients", (j) => j.clients?.length > 0],
|
|
["frequent-clients", "/dashboard/frequent-clients", (j) => j.clients?.length > 0],
|
|
["revenue-trend", "/dashboard/revenue-trend?days=30", (j) => j.points?.length === 30],
|
|
["revenue-by-category", "/dashboard/revenue-by-category", (j) => j.slices?.length > 0],
|
|
["tickets list", "/dashboard/tickets", (j) => j.tickets?.length > 0],
|
|
];
|
|
for (const [name, path, cond] of checks) {
|
|
const { json, status } = await req("GET", path, null, t);
|
|
check(name, status === 200 && cond(json), `status=${status}`);
|
|
}
|
|
|
|
// Lifecycle
|
|
const slotOwner = await findSlot(slug, 1);
|
|
check("found slot for service 1", !!slotOwner);
|
|
const { json: created } = await req("POST", "/appointments", { service_id: 1, client_id: 1, start_at: slotOwner }, t);
|
|
check("create appointment", !!created.appointment?.id);
|
|
const { json: completed } = await req("POST", `/appointments/${created.appointment.id}/complete`, { tip: 50, payment_method: "cash" }, t);
|
|
check("complete -> ticket", completed.appointment?.status === "completed");
|
|
const { json: empLogin } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
|
|
// Mateo offers services "Corte caballero" (5) and "Corte + arreglo de barba" (6).
|
|
// Try 5 first (keeps the original assertion intent); fall back to 6 if 5 has no slots.
|
|
let empSvcId = 5;
|
|
let slotEmp = await findSlot(slug, empSvcId);
|
|
if (!slotEmp) { empSvcId = 6; slotEmp = await findSlot(slug, empSvcId); }
|
|
check("found slot for employee service", !!slotEmp);
|
|
const { json: empAppt } = await req("POST", "/appointments", { service_id: empSvcId, client_id: 2, start_at: slotEmp }, empLogin.token);
|
|
check("employee auto-assign", empAppt.appointment?.employee_id === empLogin.user?.employee_id, JSON.stringify(empAppt).slice(0, 150));
|
|
const { status: badLogin } = await req("POST", "/auth/login", { email: "[email protected]", password: "wrong" });
|
|
check("bad login 401", badLogin === 401);
|
|
const { status: noAuth } = await req("GET", "/employees");
|
|
check("no auth 401", noAuth === 401);
|
|
const { status: empForb } = await req("GET", "/dashboard/overview", null, empLogin.token);
|
|
check("employee blocked dashboard 403", empForb === 403);
|
|
|
|
// ---- Permission boundary (server-side enforcement) ----
|
|
// F1: employees must NOT see commission_pct on any colleague row
|
|
const { json: empLeak } = await req("GET", "/employees", null, empLogin.token);
|
|
const leaked = (empLeak.employees || []).filter((e) => Object.prototype.hasOwnProperty.call(e, "commission_pct"));
|
|
check("F1: employee cannot read commission_pct", leaked.length === 0, `${leaked.length} rows leaked commission_pct`);
|
|
|
|
// F2: employees cannot mutate a colleague's appointment (PATCH / complete / DELETE)
|
|
const mateoId = empLogin.user?.employee_id;
|
|
const { json: allAppts } = await req("GET", "/appointments?limit=80", null, t);
|
|
const foreign = (allAppts.appointments || []).find((a) => a.employee_id !== mateoId);
|
|
check("F2: found a colleague's appointment", !!foreign, "no foreign appt in seed");
|
|
if (foreign) {
|
|
const { status: sPatch } = await req("PATCH", `/appointments/${foreign.id}`, { notes: "x" }, empLogin.token);
|
|
check("F2: employee PATCH foreign appt -> 403", sPatch === 403, `status=${sPatch}`);
|
|
const { status: sComplete } = await req("POST", `/appointments/${foreign.id}/complete`, { tip: 0 }, empLogin.token);
|
|
check("F2: employee complete foreign appt -> 403", sComplete === 403, `status=${sComplete}`);
|
|
const { status: sDel } = await req("DELETE", `/appointments/${foreign.id}`, null, empLogin.token);
|
|
check("F2: employee DELETE foreign appt -> 403", sDel === 403, `status=${sDel}`);
|
|
}
|
|
// positive control: employee can still mutate their OWN appointment
|
|
if (empAppt.appointment?.id) {
|
|
const { status: sOwn } = await req("PATCH", `/appointments/${empAppt.appointment.id}`, { notes: "nota propia" }, empLogin.token);
|
|
check("F2: employee PATCH own appt -> 200", sOwn === 200, `status=${sOwn}`);
|
|
}
|
|
|
|
// F3: employees cannot edit/delete clients (ownerOnly)
|
|
const { status: sClientPatch } = await req("PATCH", "/clients/1", { notes: "x" }, empLogin.token);
|
|
check("F3: employee PATCH client -> 403", sClientPatch === 403, `status=${sClientPatch}`);
|
|
const { status: sClientDel } = await req("DELETE", "/clients/1", null, empLogin.token);
|
|
check("F3: employee DELETE client -> 403", sClientDel === 403, `status=${sClientDel}`);
|
|
|
|
console.log(`\n${pass}/${pass + fail} passed${fail ? `, ${fail} FAILED` : ""}`);
|
|
process.exit(fail ? 1 : 0);
|