AgendaPro v1.0 - multi-tenant SaaS, mobile calendar, public booking

Multi-tenant scheduling SaaS (AgendaPro-equivalent):
- Backend: Node + Express + node:sqlite, multi-tenant (admin/owner/employee),
  versioned migrations, 4 templates (estetica-spa, barberia, clinica, blank).
- Frontend: React 18 + TS + Vite + Tailwind, FullCalendar drag-and-drop,
  Recharts dashboard, mobile-first (day/list views on mobile).
- Features: calendar+services+employees+clients+tickets, dashboard with
  best employee/service, top tickets, top/frequent clients, commissions,
  cancellation policy + no-show tracking, public online booking (/b/:slug),
  cash register (cierre de caja), reminders/notifications center.
- Verification: 65/65 e2e API, 58/58 visual (0 overflow, 0 console errors),
  typecheck clean, vite build OK.
This commit is contained in:
AgendaPro Dev
2026-07-25 13:45:53 -06:00
commit e8d2435cc2
63 changed files with 16539 additions and 0 deletions
+214
View File
@@ -0,0 +1,214 @@
// Comprehensive e2e: ALL features (existing + new) must pass before declaring 100% functional.
const BASE = "http://localhost:5173";
let pass = 0, fail = 0;
const failures = [];
async function req(path, init = {}) {
const url = path.startsWith("http") ? path : `${BASE}${path}`;
const res = await fetch(url, init);
const text = await res.text();
let json; try { json = JSON.parse(text); } catch { json = text; }
return { status: res.status, json, headers: res.headers };
}
function check(name, cond, extra = "") {
if (cond) { pass++; console.log(`${name}`); }
else { fail++; failures.push(name + (extra ? " :: " + extra : "")); console.log(`${name} ${extra}`); }
}
async function login(email, password = "demo1234") {
const r = await req("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }) });
if (r.status !== 200) throw new Error(`login ${email} failed: ${r.status} ${JSON.stringify(r.json)}`);
return r.json;
}
const H = (token) => ({ authorization: `Bearer ${token}`, "Content-Type": "application/json" });
console.log("\n=== HEALTH & AUTH ===");
const h = await req("/api/health"); check("health", h.json?.ok === true);
const me = await req("/api/auth/demo-users"); check("demo-users >= 8", me.json.users.length >= 8);
const owner = await login("[email protected]"); check("owner login", !!owner.token && owner.user.role === "owner");
const empLogin = await login("[email protected]"); check("employee login", empLogin.user.role === "employee" && empLogin.user.employee_id);
const adminLogin = await login("[email protected]"); check("admin login", adminLogin.user.role === "admin");
const t = owner.token, eT = empLogin.token, aT = adminLogin.token;
// /me fix
const meR = await req("/api/auth/me", { headers: H(t) }); check("/me returns owner", meR.json.user.email === "[email protected]");
console.log("\n=== EXISTING: business / employees / services / clients / appointments / dashboard ===");
const biz = await req("/api/business", { headers: H(t) }); check("business loaded", biz.json.business?.name === "Lumière Estética & Spa");
const emps = await req("/api/employees", { headers: H(t) }); check("employees list", emps.json.employees.length === 6 && emps.json.employees[0].stats?.revenue_total >= 0);
const svcs = await req("/api/services", { headers: H(t) }); check("services list", svcs.json.services.length === 12 && svcs.json.services[0].employee_ids);
const cls = await req("/api/clients", { headers: H(t) }); check("clients list", cls.json.clients.length > 0 && cls.json.clients[0].stats?.visits >= 0);
const appts = await req("/api/appointments?limit=5", { headers: H(t) }); check("appointments joined", appts.json.appointments[0]?.service && appts.json.appointments[0]?.client);
const ov = await req("/api/dashboard/overview", { headers: H(t) }); check("overview KPIs", ov.json.revenue_month > 0 && ov.json.appts_upcoming > 0);
const be = await req("/api/dashboard/best-employees", { headers: H(t) }); check("best-employees ranked", be.json.employees.length === 6);
const bs = await req("/api/dashboard/best-services", { headers: H(t) }); check("best-services ranked", bs.json.services.length > 0);
const tt = await req("/api/dashboard/top-tickets", { headers: H(t) }); check("top-tickets", tt.json.tickets.length > 0);
const tc = await req("/api/dashboard/top-clients", { headers: H(t) }); check("top-clients", tc.json.clients.length > 0);
const fc = await req("/api/dashboard/frequent-clients", { headers: H(t) }); check("frequent-clients", fc.json.clients.length > 0);
const rt = await req("/api/dashboard/revenue-trend?days=30", { headers: H(t) }); check("revenue-trend 30 points", rt.json.points.length === 30);
const rb = await req("/api/dashboard/revenue-by-category", { headers: H(t) }); check("revenue-by-category", rb.json.slices.length > 0);
const tk = await req("/api/dashboard/tickets?limit=20", { headers: H(t) }); check("tickets list", tk.json.tickets.length > 0);
console.log("\n=== NEW: COMMISSIONS ===");
const com = await req("/api/dashboard/commissions?range=60", { headers: H(t) });
check("commissions has rows", com.json.rows?.length === 6);
check("commissions total > 0 (after backfill)", com.json.total > 0);
const topEmp = com.json.rows[0];
check("top employee has commission > 0", topEmp.commission > 0 && topEmp.sales > 0);
// Lifecycle creates commission on complete
const createR = await req("/api/appointments", {
method: "POST", headers: H(t),
body: JSON.stringify({ service_id: svcs.json.services[0].id, client_id: cls.json.clients[0].id, start_at: "2026-09-01T10:00:00Z" }),
});
check("create appointment", !!createR.json.appointment?.id);
const completed = await req(`/api/appointments/${createR.json.appointment.id}/complete`, {
method: "POST", headers: H(t), body: JSON.stringify({ tip: 50, payment_method: "card" }),
});
check("complete -> status completed", completed.json.appointment.status === "completed");
// ticket should have commission > 0
const tkAfter = await req(`/api/dashboard/tickets?limit=5`, { headers: H(t) });
const ourTicket = tkAfter.json.tickets.find((t2) => t2.service?.id === svcs.json.services[0].id && t2.amount > 0);
check("new ticket has commission > 0", ourTicket && ourTicket.commission > 0, `ticket=${JSON.stringify(ourTicket)}`);
console.log("\n=== NEW: CANCELLATION POLICY ===");
const settings = await req("/api/settings", { headers: H(t) });
check("settings loaded", settings.json.settings?.cancel_window_hours !== undefined);
check("booking slug set", settings.json.settings?.slug === "lumiere-estetica-spa");
// Update policy (24h, 50% penalty)
const upd = await req("/api/settings", {
method: "PATCH", headers: H(t),
body: JSON.stringify({ cancel_window_hours: 24, cancel_penalty_pct: 50 }),
});
check("update cancellation policy", upd.json.settings.cancel_penalty_pct === 50);
// Policy preview: appointment 1h from now should be within 24h window -> penalty
const nearAppt = await req("/api/appointments", {
method: "POST", headers: H(t),
body: JSON.stringify({ service_id: svcs.json.services[0].id, client_id: cls.json.clients[0].id, start_at: new Date(Date.now() + 60 * 60000).toISOString() }),
});
check("created near-future appt for policy test", !!nearAppt.json.appointment?.id);
const policy = await req(`/api/appointments/${nearAppt.json.appointment.id}/cancellation-policy`, { headers: H(t) });
check("policy preview within window", policy.json.within_window === true && policy.json.penalty_amount > 0);
// Far future appointment should be outside window
const farAppt = await req("/api/appointments", {
method: "POST", headers: H(t),
body: JSON.stringify({ service_id: svcs.json.services[0].id, client_id: cls.json.clients[0].id, start_at: new Date(Date.now() + 48 * 3600000).toISOString() }),
});
const policyFar = await req(`/api/appointments/${farAppt.json.appointment.id}/cancellation-policy`, { headers: H(t) });
check("policy preview outside window (no penalty)", policyFar.json.within_window === false && policyFar.json.penalty_amount === 0);
// Restore default 0% penalty for downstream tests
await req("/api/settings", { method: "PATCH", headers: H(t), body: JSON.stringify({ cancel_penalty_pct: 0 }) });
console.log("\n=== NEW: PUBLIC BOOKING ===");
const pubInfo = await req(`${BASE}/api/public/lumiere-estetica-spa`);
check("public business info", pubInfo.json.business?.name === "Lumière Estética & Spa" && pubInfo.json.services.length === 12 && pubInfo.json.employees.length === 6);
const pubSlots = await req(`${BASE}/api/public/lumiere-estetica-spa/slots?service_id=1&date=2026-08-12`);
check("public slots > 0", pubSlots.json.slots?.length > 0);
const slotIso = pubSlots.json.slots[0].iso;
// Unique phone per run so the test is idempotent across runs
const uniq = Date.now().toString().slice(-8);
// Public book
const book = await req(`${BASE}/api/public/lumiere-estetica-spa/book`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
service_id: 1, employee_id: pubSlots.json.slots[0].employee_id, start_at: slotIso,
client: { name: "Cliente Web Test " + uniq, phone: "+52 55 " + uniq + " 1111", email: `webtest${uniq}@demo.com`, notes: "Online booking test" },
}),
});
check("public booking 201 + client_created", book.status === 201 && book.json.client_created === true && !!book.json.appointment?.id, `status=${book.status} body=${JSON.stringify(book.json).slice(0,160)}`);
// Verify the booking shows up in owner appointments (via the new client's history)
const webClient = (await req("/api/clients?q=" + uniq, { headers: H(t) })).json.clients.find((c) => c.phone && c.phone.includes(uniq));
check("web client created & searchable", !!webClient);
// Booking-disabled gate
await req("/api/settings", { method: "PATCH", headers: H(t), body: JSON.stringify({ booking_enabled: 0 }) });
const pubDis = await req(`${BASE}/api/public/lumiere-estetica-spa`);
check("booking disabled -> 403", pubDis.status === 403);
await req("/api/settings", { method: "PATCH", headers: H(t), body: JSON.stringify({ booking_enabled: 1 }) });
// Invalid slug
const pubBad = await req(`${BASE}/api/public/no-such-slug`);
check("invalid slug -> 404", pubBad.status === 404);
console.log("\n=== NEW: CASH REGISTER ===");
// Defensive: close any leftover open session from previous runs
const sess0 = await req("/api/cash/session", { headers: H(t) });
if (sess0.json.session) {
await req("/api/cash/close", { method: "POST", headers: H(t), body: JSON.stringify({ closing_amount: 0 }) });
console.log(" (cleaned leftover open session)");
}
const sessCheck = await req("/api/cash/session", { headers: H(t) });
check("no open session initially", sessCheck.json.session === null);
const open = await req("/api/cash/open", { method: "POST", headers: H(t), body: JSON.stringify({ opening_amount: 500, note: "Apertura test" }) });
check("open session 201", open.status === 201 && open.json.session?.status === "open", `status=${open.status} body=${JSON.stringify(open.json).slice(0,160)}`);
const open2 = await req("/api/cash/open", { method: "POST", headers: H(t), body: JSON.stringify({ opening_amount: 100 }) });
check("cannot open second session (400)", open2.status === 400);
const entry1 = await req("/api/cash/entries", { method: "POST", headers: H(t), body: JSON.stringify({ type: "income", amount: 300, concept: "Venta producto", payment_method: "cash" }) });
check("add income entry", entry1.status === 201 && entry1.json.entry?.type === "income", `status=${entry1.status} body=${JSON.stringify(entry1.json).slice(0,160)}`);
const entry2 = await req("/api/cash/entries", { method: "POST", headers: H(t), body: JSON.stringify({ type: "expense", amount: 50, concept: "Insumos", payment_method: "cash" }) });
check("add expense entry", entry2.status === 201, `status=${entry2.status}`);
const sess = await req("/api/cash/session", { headers: H(t) });
check("session stats: income=300 expense=50 expected=750", sess.json.stats?.income === 300 && sess.json.stats?.expense === 50, `stats=${JSON.stringify(sess.json.stats)} session=${sess.json.session?.id}`);
const close = await req("/api/cash/close", { method: "POST", headers: H(t), body: JSON.stringify({ closing_amount: 750 }) });
check("close session (perfect cuadre)", close.status === 200 && close.json.expected === 750 && close.json.session.status === "closed", `status=${close.status}`);
const sessAfter = await req("/api/cash/session", { headers: H(t) });
check("no open session after close", sessAfter.json.session === null);
console.log("\n=== NEW: NOTIFICATIONS ===");
const nstats = await req("/api/notifications/stats", { headers: H(t) });
check("notifications stats", nstats.json.pending >= 0 && nstats.json.upcoming_reminders >= 0);
const regen = await req("/api/notifications/regenerate", { method: "POST", headers: H(t) });
check("regenerate notifications", regen.json.ok === true);
const list = await req("/api/notifications?status=pending", { headers: H(t) });
check("notifications list pending", list.json.notifications.length > 0);
const firstN = list.json.notifications[0];
const send = await req(`/api/notifications/${firstN.id}/send`, { method: "POST", headers: H(t) });
check("send notification (simulated)", send.json.ok === true);
const listSent = await req("/api/notifications?status=sent", { headers: H(t) });
check("notification now sent", listSent.json.notifications.some((n) => n.id === firstN.id));
console.log("\n=== MULTI-TENANT ISOLATION ===");
const newBiz = await req("/api/admin/businesses", {
method: "POST", headers: H(aT),
body: JSON.stringify({ name: "Audit Barbería", template: "barberia", ownerName: "Audit Owner", ownerEmail: "[email protected]" }),
});
check("admin creates new biz + template seed", newBiz.status === 201 && newBiz.json.seeded?.services === 8);
const newOwner = await login("[email protected]");
check("new owner login", newOwner.user.role === "owner" && newOwner.user.business_id === newBiz.json.business.id);
const isoEmps = await req("/api/employees", { headers: H(newOwner.token) });
check("isolation: new owner sees only 4 employees", isoEmps.json.employees.length === 4);
const isoBiz = await req("/api/business", { headers: H(newOwner.token) });
check("isolation: new owner sees only their business", isoBiz.json.business.name === "Audit Barbería");
// Owner1 (Lumière) still 6 employees
const o1Emps = await req("/api/employees", { headers: H(t) });
check("isolation: Lumière owner still 6 employees", o1Emps.json.employees.length === 6);
// Owner blocked from admin
const forb = await req("/api/admin/businesses", { headers: H(t) });
check("owner blocked from admin (403)", forb.status === 403);
// Employee auto-assign still works
const empAppt = await req("/api/appointments", {
method: "POST", headers: H(eT),
body: JSON.stringify({ service_id: 1, client_id: cls.json.clients[0].id, start_at: "2026-10-01T10:00:00Z" }),
});
check("employee auto-assign (matches emp_id)", empAppt.json.appointment?.employee_id === empLogin.user.employee_id);
// Cleanup test business
const del = await req(`/api/admin/businesses/${newBiz.json.business.id}`, { method: "DELETE", headers: H(aT) });
check("admin delete business (cascade)", del.status === 200);
const gone = await req("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "[email protected]", password: "demo1234" }) });
check("deleted business users removed", gone.status === 401);
console.log("\n=== AUTH GUARDS ===");
const badLogin = await req("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "[email protected]", password: "wrong" }) });
check("bad login 401", badLogin.status === 401);
const noAuth = await req("/api/employees"); check("no auth 401", noAuth.status === 401);
const empForb = await req("/api/dashboard/overview", { headers: H(eT) });
check("employee blocked from dashboard 403", empForb.status === 403);
const empDash = await req("/api/dashboard/commissions", { headers: H(eT) });
check("employee blocked from commissions 403", empDash.status === 403);
const empCash = await req("/api/cash/session", { headers: H(eT) });
check("employee blocked from cash 403", empCash.status === 403);
console.log(`\n${pass}/${pass + fail} passed`);
if (fail) {
console.log("\nFAILURES:");
failures.forEach((f) => console.log(" •", f));
process.exit(1);
}
process.exit(0);