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]>
93 lines
3.7 KiB
TypeScript
93 lines
3.7 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import { RUTAS_MUESTRA, TODAS_LAS_RUTAS } from "./rutas";
|
|
|
|
/**
|
|
* Humo: cada ruta pública responde, tiene una sola cabecera principal, título
|
|
* propio y los metadatos que la auditoría de marca señaló como ausentes en el
|
|
* sitio original.
|
|
*/
|
|
|
|
test.describe("rutas públicas responden", () => {
|
|
for (const ruta of TODAS_LAS_RUTAS) {
|
|
test(`200 y contenido en ${ruta}`, async ({ page }) => {
|
|
const res = await page.goto(ruta, { waitUntil: "domcontentloaded" });
|
|
expect(res?.status(), `${ruta} debe responder 200`).toBe(200);
|
|
|
|
// exactamente un h1: si hay dos, la jerarquía del documento está mal
|
|
const h1 = page.locator("h1");
|
|
await expect(h1, `${ruta} debe tener un solo h1`).toHaveCount(1);
|
|
await expect(h1).not.toBeEmpty();
|
|
|
|
const titulo = await page.title();
|
|
expect(titulo.length, `${ruta} necesita un <title> con sustancia`).toBeGreaterThan(12);
|
|
});
|
|
}
|
|
});
|
|
|
|
test.describe("metadatos que faltaban en el sitio original", () => {
|
|
for (const ruta of RUTAS_MUESTRA) {
|
|
test(`description y open graph en ${ruta}`, async ({ page }) => {
|
|
await page.goto(ruta, { waitUntil: "domcontentloaded" });
|
|
|
|
const desc = await page.locator('meta[name="description"]').getAttribute("content");
|
|
expect(desc, `${ruta} sin meta description`).toBeTruthy();
|
|
expect(desc!.length, `${ruta}: description demasiado corta`).toBeGreaterThan(50);
|
|
expect(desc!.length, `${ruta}: description demasiado larga para el snippet`).toBeLessThan(300);
|
|
|
|
const ogTitulo = await page.locator('meta[property="og:title"]').getAttribute("content");
|
|
expect(ogTitulo, `${ruta} sin og:title`).toBeTruthy();
|
|
|
|
const ogImagen = await page.locator('meta[property="og:image"]').first().getAttribute("content");
|
|
expect(ogImagen, `${ruta} sin og:image`).toBeTruthy();
|
|
|
|
const canonica = await page.locator('link[rel="canonical"]').getAttribute("href");
|
|
expect(canonica, `${ruta} sin canónica`).toBeTruthy();
|
|
});
|
|
}
|
|
});
|
|
|
|
test("idioma declarado en español de México", async ({ page }) => {
|
|
await page.goto("/");
|
|
await expect(page.locator("html")).toHaveAttribute("lang", /^es/);
|
|
});
|
|
|
|
test("sitemap lista rutas reales y excluye el panel", async ({ page }) => {
|
|
const res = await page.goto("/sitemap.xml");
|
|
expect(res?.status()).toBe(200);
|
|
const xml = await page.content();
|
|
|
|
expect(xml).toContain("/catalogo");
|
|
expect(xml).toContain("/soluciones/");
|
|
expect(xml, "el panel no debe aparecer en el sitemap").not.toContain("/admin");
|
|
});
|
|
|
|
test("robots prohíbe el panel y la API", async ({ request }) => {
|
|
const res = await request.get("/robots.txt");
|
|
expect(res.status()).toBe(200);
|
|
const txt = await res.text();
|
|
expect(txt).toMatch(/Disallow:\s*\/admin/i);
|
|
expect(txt).toMatch(/Sitemap:/i);
|
|
});
|
|
|
|
test("iconos y tarjeta social se generan", async ({ request }) => {
|
|
for (const ruta of ["/icon", "/opengraph-image"]) {
|
|
const res = await request.get(ruta);
|
|
expect(res.status(), `${ruta} debe responder`).toBeLessThan(400);
|
|
expect(res.headers()["content-type"], `${ruta} debe ser imagen`).toContain("image");
|
|
}
|
|
});
|
|
|
|
test("sonda de salud responde", async ({ request }) => {
|
|
const res = await request.get("/api/salud");
|
|
expect(res.status()).toBe(200);
|
|
const json = (await res.json()) as { ok: boolean };
|
|
expect(json.ok).toBe(true);
|
|
});
|
|
|
|
test("una ruta inexistente devuelve 404 con salidas útiles", async ({ page }) => {
|
|
const res = await page.goto("/producto/esto-no-existe-jamas");
|
|
expect(res?.status()).toBe(404);
|
|
// un 404 sin camino de regreso es un callejón
|
|
await expect(page.getByRole("link", { name: /cat[áa]logo|soluciones|inicio|contacto/i }).first()).toBeVisible();
|
|
});
|