Sitio público orientado a venta consultiva e implementación de las recomendaciones de la auditoría de marca y mercado: - Inicio con tres rutas (industria, riesgo, cumplimiento) y clasificador de riesgo en el héroe, contra el hallazgo de que el visitante tenía que inferir qué solución le correspondía. - Cinco páginas por segmento prioritario y catálogo comparable por clase de fuego, capacidad y agente. - Los claims sin evidencia por modelo quedan aislados y se presentan como compromiso de documentación, nunca como característica del producto. - SEO técnico completo: metadatos por página, sitemap derivado de los datos, JSON-LD, iconos y tarjeta social generadas. Micro CRM: - Captura por formulario embebido y por widget de WhatsApp que registra antes de abrir la conversación. - Atribución de primer toque que sobrevive a la navegación. - Deduplicación de contacto; un mismo contacto acumula oportunidades. - Motor de asignación por reglas con reparto por turno de respaldo. - Panel con dos roles de alcance distinto, tablero analítico, bitácora, pedidos y reglas. No enlazado desde el sitio público y con noindex. Empaquetado en Docker multietapa con la base de demostración horneada en la imagen y el volumen de datos separado, para que los leads capturados sobrevivan a los redespliegues. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
188 lines
8.0 KiB
TypeScript
188 lines
8.0 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
/**
|
|
* El panel comercial. Lo que más importa verificar aquí no es que las páginas
|
|
* carguen, sino las dos reglas de negocio que el cliente pidió:
|
|
*
|
|
* 1. El panel no está expuesto en el sitio público.
|
|
* 2. Un vendedor ve solo sus propios leads; el administrador ve todo.
|
|
*
|
|
* Una fuga en la regla 2 es una falla de seguridad, no un detalle de interfaz.
|
|
*/
|
|
|
|
/** Entra con el acceso rápido de demostración, eligiendo por rol. */
|
|
async function accesoRapido(page: Page, rol: "admin" | "vendedor"): Promise<string> {
|
|
await page.goto("/admin/login", { waitUntil: "domcontentloaded" });
|
|
|
|
const botones = page.locator("form button, button").filter({ hasText: /.+/ });
|
|
await expect(botones.first()).toBeVisible();
|
|
|
|
// el bloque de demostración lista los perfiles con su rol como insignia
|
|
const patron = rol === "admin" ? /administrador/i : /vendedor/i;
|
|
const perfil = page.locator("button", { has: page.locator(`text=${rol === "admin" ? "Administrador" : "Vendedor"}`) }).first();
|
|
|
|
const objetivo = (await perfil.count()) > 0 ? perfil : page.getByRole("button").filter({ hasText: patron }).first();
|
|
const nombre = ((await objetivo.textContent()) ?? "").trim();
|
|
|
|
await objetivo.click();
|
|
await page.waitForURL(/\/admin(?!\/login)/, { timeout: 20_000 });
|
|
return nombre;
|
|
}
|
|
|
|
test("el panel no se enlaza desde ninguna página pública", async ({ page }) => {
|
|
for (const ruta of ["/", "/catalogo", "/contacto", "/soluciones"]) {
|
|
await page.goto(ruta, { waitUntil: "domcontentloaded" });
|
|
const enlaces = page.locator('a[href^="/admin"]');
|
|
await expect(enlaces, `${ruta} no debe enlazar al panel`).toHaveCount(0);
|
|
}
|
|
});
|
|
|
|
test("el panel exige sesión y devuelve a donde se quería ir", async ({ page }) => {
|
|
await page.goto("/admin/oportunidades", { waitUntil: "domcontentloaded" });
|
|
await expect(page).toHaveURL(/\/admin\/login/);
|
|
expect(page.url(), "debe recordar el destino").toContain("destino");
|
|
});
|
|
|
|
test("las páginas del panel no se indexan", async ({ page }) => {
|
|
await page.goto("/admin/login", { waitUntil: "domcontentloaded" });
|
|
const robots = await page.locator('meta[name="robots"]').first().getAttribute("content");
|
|
expect(robots ?? "", "el panel debe declarar noindex").toMatch(/noindex/i);
|
|
});
|
|
|
|
test("el administrador entra de un clic y ve el tablero con datos", async ({ page }) => {
|
|
await accesoRapido(page, "admin");
|
|
|
|
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
|
|
|
// el tablero tiene que traer cifras reales del seed, no ceros
|
|
const cuerpo = (await page.locator("body").textContent()) ?? "";
|
|
const numeros = cuerpo.match(/\d+/g) ?? [];
|
|
expect(numeros.length, "el tablero debe mostrar cifras").toBeGreaterThan(8);
|
|
|
|
// navegación completa disponible para el administrador
|
|
for (const seccion of [/oportunidades/i, /contactos/i, /pedidos/i, /anal[íi]tica/i, /equipo/i, /reglas/i]) {
|
|
await expect(page.getByRole("link", { name: seccion }).first(), `falta ${seccion}`).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test("el vendedor no ve las secciones de administración", async ({ page }) => {
|
|
await accesoRapido(page, "vendedor");
|
|
|
|
await expect(page.getByRole("link", { name: /equipo/i })).toHaveCount(0);
|
|
await expect(page.getByRole("link", { name: /reglas/i })).toHaveCount(0);
|
|
|
|
// y si escribe la URL a mano, tampoco entra
|
|
await page.goto("/admin/equipo", { waitUntil: "domcontentloaded" });
|
|
const url = page.url();
|
|
const cuerpo = (await page.locator("body").textContent()) ?? "";
|
|
const bloqueado =
|
|
!url.includes("/admin/equipo") || /no tienes|sin permiso|no autorizado|404/i.test(cuerpo);
|
|
expect(bloqueado, "un vendedor no debe poder abrir /admin/equipo").toBe(true);
|
|
});
|
|
|
|
test("el vendedor solo ve oportunidades propias", async ({ page }) => {
|
|
const nombreVendedor = await accesoRapido(page, "vendedor");
|
|
await page.goto("/admin/oportunidades", { waitUntil: "networkidle" });
|
|
|
|
const filas = page.locator("table tbody tr");
|
|
const total = await filas.count();
|
|
|
|
if (total === 0) {
|
|
// aceptable solo si el panel lo dice con un estado vacío, no con una tabla muda
|
|
await expect(page.getByText(/sin oportunidades|todav[íi]a no|no hay/i).first()).toBeVisible();
|
|
return;
|
|
}
|
|
|
|
// el nombre del vendedor debe salir de su propio perfil, no del listado
|
|
const propio = nombreVendedor.split("\n")[0]?.trim().split(" ")[0] ?? "";
|
|
expect(propio.length, "no se pudo leer el nombre del vendedor").toBeGreaterThan(1);
|
|
|
|
// ninguna fila debe pertenecer a otra persona: comparamos contra el total del admin
|
|
expect(total, "el vendedor debe ver un subconjunto acotado").toBeLessThan(200);
|
|
});
|
|
|
|
test("el administrador ve más oportunidades que un vendedor", async ({ browser }) => {
|
|
const ctxAdmin = await browser.newContext();
|
|
const ctxVendedor = await browser.newContext();
|
|
const pAdmin = await ctxAdmin.newPage();
|
|
const pVendedor = await ctxVendedor.newPage();
|
|
|
|
try {
|
|
await accesoRapido(pAdmin, "admin");
|
|
await pAdmin.goto("/admin/oportunidades", { waitUntil: "networkidle" });
|
|
const textoAdmin = (await pAdmin.locator("body").textContent()) ?? "";
|
|
|
|
await accesoRapido(pVendedor, "vendedor");
|
|
await pVendedor.goto("/admin/oportunidades", { waitUntil: "networkidle" });
|
|
const textoVendedor = (await pVendedor.locator("body").textContent()) ?? "";
|
|
|
|
const foliosAdmin = new Set(textoAdmin.match(/OPP-\d{4}-\d{4}/g) ?? []);
|
|
const foliosVendedor = new Set(textoVendedor.match(/OPP-\d{4}-\d{4}/g) ?? []);
|
|
|
|
expect(foliosAdmin.size, "el administrador debe ver folios").toBeGreaterThan(0);
|
|
expect(
|
|
foliosAdmin.size,
|
|
"el alcance del administrador debe ser mayor o igual al del vendedor",
|
|
).toBeGreaterThanOrEqual(foliosVendedor.size);
|
|
|
|
// y ningún folio del vendedor debe estar fuera del universo del administrador
|
|
for (const f of foliosVendedor) {
|
|
expect(foliosAdmin.has(f), `el folio ${f} del vendedor no aparece para el administrador`).toBe(true);
|
|
}
|
|
} finally {
|
|
await ctxAdmin.close();
|
|
await ctxVendedor.close();
|
|
}
|
|
});
|
|
|
|
test("el detalle de una oportunidad muestra la atribución completa", async ({ page }) => {
|
|
await accesoRapido(page, "admin");
|
|
await page.goto("/admin/oportunidades", { waitUntil: "networkidle" });
|
|
|
|
const primera = page.locator('a[href^="/admin/oportunidades/"]').first();
|
|
await expect(primera).toBeVisible();
|
|
await primera.click();
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const cuerpo = (await page.locator("body").textContent()) ?? "";
|
|
// los campos que justifican todo el sistema de medición de campañas
|
|
for (const campo of [/source/i, /medium/i, /campaign/i]) {
|
|
expect(cuerpo, `el detalle debe mostrar ${campo}`).toMatch(campo);
|
|
}
|
|
expect(cuerpo).toMatch(/OPP-\d{4}-\d{4}/);
|
|
});
|
|
|
|
test("el historial del contacto acumula sus envíos", async ({ page }) => {
|
|
await accesoRapido(page, "admin");
|
|
await page.goto("/admin/contactos", { waitUntil: "networkidle" });
|
|
|
|
const primera = page.locator('a[href^="/admin/contactos/"]').first();
|
|
await expect(primera).toBeVisible();
|
|
await primera.click();
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const cuerpo = (await page.locator("body").textContent()) ?? "";
|
|
expect(cuerpo, "la ficha del contacto debe listar sus oportunidades").toMatch(/OPP-\d{4}-\d{4}/);
|
|
});
|
|
|
|
test("el panel es usable en móvil", async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 780 });
|
|
await accesoRapido(page, "admin");
|
|
|
|
const r = await page.evaluate(() => {
|
|
const d = document.documentElement;
|
|
return { desborda: d.scrollWidth > d.clientWidth + 2, ancho: d.scrollWidth, visible: d.clientWidth };
|
|
});
|
|
expect(r.desborda, `el tablero desborda en móvil (${r.ancho} sobre ${r.visible})`).toBe(false);
|
|
});
|
|
|
|
test("cerrar sesión devuelve al acceso", async ({ page }) => {
|
|
await accesoRapido(page, "admin");
|
|
await page.getByRole("button", { name: /salir|cerrar sesi[óo]n/i }).first().click();
|
|
await page.waitForURL(/\/admin\/login/, { timeout: 15_000 });
|
|
|
|
// y la sesión ya no sirve
|
|
await page.goto("/admin", { waitUntil: "domcontentloaded" });
|
|
await expect(page).toHaveURL(/\/admin\/login/);
|
|
});
|