feat(propuesta): pasarela paso a paso con autodetección de ubicación y FAB
deploy-branch / deploy (push) Failing after 6s
deploy-branch / teardown (push) Skipped

- Campo universal de ubicación (Google Maps/Waze/OSM/coordenadas/dirección)
  sobre proveedores extensibles (src/utils/ubicacion) con resolver e inferencia
  inversa Nominatim (nombre/provincia pre-rellenados, alias cooficiales)
- FAB flotante abajo a la derecha que abre la pasarela en un modal accesible
  (Escape, foco contenido, responsive); sin descripción ni puntuación;
  avisos de duplicado y filtro de palabras dentro del modal
- Aviso PWA reposicionado: tarjeta abajo a la izquierda en móvil,
  botón compacto arriba a la izquierda en PC (no tapa el FAB)
- Suite de pruebas: 88 unitarias/componente (vitest + RTL, offline) y
  10 de integración (supertest + SQLite :memory: y Nominatim real opt-in)
- CI: workflow de Gitea Actions con npm test + build en push/PR e
  integración manual
This commit is contained in:
2026-08-16 01:35:02 +02:00
parent d48b74cbd4
commit 003b1e209f
53 changed files with 4896 additions and 253 deletions
+106
View File
@@ -0,0 +1,106 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
// Control del hook de instalación sin depender de beforeinstallprompt
const estadoHook = {
isInstalled: false,
canPromptInstall: false,
promptInstall: vi.fn(async () => "accepted"),
isIos: false,
};
vi.mock("../../src/hooks/usePwaInstall.js", () => ({ default: () => estadoHook }));
import BannerMovil from "../../src/components/BannerMovil.jsx";
const matchMediaMock = (esMovil) => (_query) => ({
matches: esMovil,
media: _query,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
});
beforeEach(() => {
vi.clearAllMocks();
estadoHook.isInstalled = false;
estadoHook.canPromptInstall = false;
estadoHook.isIos = false;
localStorage.clear();
localStorage.setItem("localesp_visitas", "5"); // ya superó el mínimo de 2 visitas
});
afterEach(() => {
vi.unstubAllGlobals();
cleanup();
});
describe("BannerMovil en PC", () => {
beforeEach(() => vi.stubGlobal("matchMedia", matchMediaMock(false)));
it("muestra un botón compacto arriba a la izquierda, sin la tarjeta", () => {
render(<BannerMovil />);
const boton = screen.getByRole("button", { name: /instalar app/i });
expect(boton).toBeTruthy();
const contenedor = boton.closest("div").parentElement;
expect(contenedor.style.position).toBe("fixed");
expect(contenedor.style.top).toBe("20px");
expect(contenedor.style.left).toBe("20px");
expect(screen.queryByText("App para móvil")).toBeNull(); // tarjeta plegada
});
it("sin diálogo nativo, el botón despliega la tarjeta con instrucciones; el ✕ de la tarjeta solo la recoge", () => {
render(<BannerMovil />);
const boton = screen.getByRole("button", { name: /instalar app/i });
expect(boton.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(boton);
expect(screen.getByText("App para móvil")).toBeTruthy();
expect(screen.getByText(/Instálala desde el menú de tu navegador/i)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Cerrar aviso de instalación" }));
expect(screen.queryByText("App para móvil")).toBeNull();
// la tarjeta se cierra sin activar la regla de 14 días
expect(localStorage.getItem("localesp_banner_cerrado_hasta")).toBeNull();
});
it("con diálogo nativo, el botón instala directamente sin desplegar la tarjeta", async () => {
estadoHook.canPromptInstall = true;
render(<BannerMovil />);
fireEvent.click(screen.getByRole("button", { name: /instalar app/i }));
expect(estadoHook.promptInstall).toHaveBeenCalledTimes(1);
expect(screen.queryByText("App para móvil")).toBeNull();
});
it("el ✕ del botón aplica la regla de 14 días y oculta el aviso", () => {
render(<BannerMovil />);
fireEvent.click(screen.getByRole("button", { name: /ocultar aviso de instalación/i }));
expect(localStorage.getItem("localesp_banner_cerrado_hasta")).toBeTruthy();
expect(screen.queryByRole("button", { name: /instalar app/i })).toBeNull();
});
});
describe("BannerMovil en móvil", () => {
beforeEach(() => vi.stubGlobal("matchMedia", matchMediaMock(true)));
it("muestra la tarjeta abajo a la izquierda (sin invadir el FAB de la derecha)", () => {
render(<BannerMovil />);
expect(screen.getByText("App para móvil")).toBeTruthy();
const fijos = [...document.querySelectorAll("div")].filter((d) => d.style.position === "fixed");
expect(fijos.length).toBe(1);
const contenedor = fijos[0];
expect(contenedor.style.position).toBe("fixed");
expect(contenedor.style.left).toBe("16px");
expect(contenedor.style.bottom).toBe("0px");
// (el paddingBottom: env(safe-area-inset-bottom) no se serializa en jsdom; se verifica visualmente)
expect(contenedor.style.right).toBe(""); // nunca a la derecha, ahí vive el FAB
});
it("el ✕ de la tarjeta aplica la regla de 14 días", () => {
render(<BannerMovil />);
fireEvent.click(screen.getByRole("button", { name: "Cerrar aviso de instalación" }));
expect(localStorage.getItem("localesp_banner_cerrado_hasta")).toBeTruthy();
expect(screen.queryByText("App para móvil")).toBeNull();
});
});
+39
View File
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import BotonFlotante from "../../src/components/BotonFlotante.jsx";
afterEach(cleanup);
describe("BotonFlotante", () => {
it("expone su propósito con aria-label (accesible sin hover)", () => {
render(<BotonFlotante onClick={() => {}} />);
expect(screen.getByRole("button", { name: "Añade un nuevo local al directorio" })).toBeTruthy();
});
it("tooltip oculto por defecto y visible al recibir foco", () => {
render(<BotonFlotante onClick={() => {}} />);
expect(screen.queryByRole("tooltip")).toBeNull();
fireEvent.focus(screen.getByRole("button"));
expect(screen.getByRole("tooltip").textContent).toBe("Añade un nuevo local al directorio");
fireEvent.blur(screen.getByRole("button"));
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("tooltip también en hover", () => {
render(<BotonFlotante onClick={() => {}} />);
fireEvent.mouseEnter(screen.getByRole("button"));
expect(screen.getByRole("tooltip")).toBeTruthy();
fireEvent.mouseLeave(screen.getByRole("button"));
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("al pulsarlo abre la pasarela (onClick)", () => {
const onClick = vi.fn();
render(<BotonFlotante onClick={onClick} />);
fireEvent.click(screen.getByRole("button"));
expect(onClick).toHaveBeenCalledTimes(1);
});
});
+112
View File
@@ -0,0 +1,112 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import ModalPasarela from "../../src/components/ModalPasarela.jsx";
beforeEach(() => {
vi.clearAllMocks(); // los mocks de propsBase se comparten entre pruebas
});
afterEach(cleanup);
const FORM_VACIO = {
nombre: "", provincia: "", categoria: "", subcategoria: "",
direccion: "", enlaceGoogleMaps: "", lat: null, lng: null,
entradaUbicacion: "", proveedorUbicacion: "",
};
const FORM_RESUELTO = {
...FORM_VACIO,
entradaUbicacion: "41.3851, 2.1734",
proveedorUbicacion: "coordenadas",
lat: 41.3851, lng: 2.1734,
nombre: "Bar X", provincia: "Barcelona",
categoria: "Restauración", subcategoria: "Tapas y raciones",
};
const propsBase = {
setForm: vi.fn(),
onCerrar: vi.fn(),
onEnviar: vi.fn(),
onConfirmarDuplicado: vi.fn(),
onDescartarDuplicado: vi.fn(),
aceptaPrivacidad: false,
setAceptaPrivacidad: vi.fn(),
onAbrirPrivacidad: vi.fn(),
errorFiltro: "",
posibleDuplicado: null,
comprobandoDuplicado: false,
saving: false,
};
const siguiente = () => screen.getByRole("button", { name: "Siguiente" });
describe("ModalPasarela", () => {
it("arranca en la pantalla de Ubicación con las 6 pantallas declaradas", () => {
render(<ModalPasarela {...propsBase} form={FORM_VACIO} />);
expect(screen.getByLabelText(/ubicación del local/i)).toBeTruthy();
expect(screen.getByText(/paso 1 de 6/i)).toBeTruthy();
expect(screen.getAllByRole("generic", { hidden: true })).toBeTruthy();
// indicador de progreso: 6 barras
const barras = document.querySelectorAll('[aria-hidden="true"] span[style*="width: 16px"]');
expect(barras.length).toBe(6);
});
it("Siguiente deshabilitado sin resolución de ubicación válida", () => {
render(<ModalPasarela {...propsBase} form={FORM_VACIO} />);
expect(siguiente().disabled).toBe(true);
});
it("Siguiente habilitado con datos válidos y navega; Atrás conserva los datos", () => {
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
expect(siguiente().disabled).toBe(false);
fireEvent.click(siguiente());
// paso 2: nombre pre-rellenado con lo inferido
expect(screen.getByLabelText(/nombre del local/i).value).toBe("Bar X");
fireEvent.click(screen.getByRole("button", { name: /atrás/i }));
// vuelve a ubicación con la entrada conservada
expect(screen.getByLabelText(/ubicación del local/i).value).toBe("41.3851, 2.1734");
});
it("Escape cierra el modal (descarta el borrador vía onCerrar)", () => {
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
expect(propsBase.onCerrar).toHaveBeenCalledTimes(1);
});
it("✕ cierra el modal", () => {
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
fireEvent.click(screen.getByRole("button", { name: "Cerrar" }));
expect(propsBase.onCerrar).toHaveBeenCalledTimes(1);
});
it("foco inicial en el primer control de cada pantalla", () => {
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
expect(document.activeElement).toBe(screen.getByLabelText(/ubicación del local/i));
fireEvent.click(siguiente());
expect(document.activeElement).toBe(screen.getByLabelText(/nombre del local/i));
});
it("Tab queda contenido dentro del modal (wrap del primer al último focusable)", () => {
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
// primer focusable del panel (el ✕ precede al cuerpo en el DOM)
const cerrar = screen.getByRole("button", { name: "Cerrar" });
cerrar.focus();
// Shift+Tab desde el primero → salta al último (Siguiente)
fireEvent.keyDown(document.activeElement, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(siguiente());
// Tab desde el último → vuelve al primero
fireEvent.keyDown(document.activeElement, { key: "Tab" });
expect(document.activeElement).toBe(cerrar);
});
it("en el paso final muestra el resumen con su propio botón de envío", () => {
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
for (let i = 0; i < 5; i++) fireEvent.click(siguiente());
expect(screen.getByRole("button", { name: /enviar propuesta/i })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Siguiente" })).toBeNull();
});
});
+102
View File
@@ -0,0 +1,102 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import PasoResumen from "../../src/components/PasoResumen.jsx";
beforeEach(() => {
vi.clearAllMocks(); // los mocks de propsBase se comparten entre pruebas
});
afterEach(cleanup);
const FORM_BASE = {
nombre: "Bar El Olivo", provincia: "Madrid", categoria: "Restauración", subcategoria: "Tapas y raciones",
direccion: "Calle Mayor 5, Madrid", enlaceGoogleMaps: "", lat: 40.4168, lng: -3.7038,
entradaUbicacion: "40.4168, -3.7038", proveedorUbicacion: "coordenadas",
};
const propsBase = {
form: FORM_BASE,
aceptaPrivacidad: false,
setAceptaPrivacidad: vi.fn(),
onAbrirPrivacidad: vi.fn(),
onEnviar: vi.fn(),
onConfirmarDuplicado: vi.fn(),
onDescartarDuplicado: vi.fn(),
errorFiltro: "",
posibleDuplicado: null,
comprobandoDuplicado: false,
saving: false,
};
describe("PasoResumen", () => {
it("muestra un resumen legible de todos los datos", () => {
render(<PasoResumen {...propsBase} />);
expect(screen.getByText("Bar El Olivo")).toBeTruthy();
expect(screen.getByText("Madrid")).toBeTruthy();
expect(screen.getByText(/🍽️ Restauración/)).toBeTruthy();
expect(screen.getByText("Tapas y raciones")).toBeTruthy();
expect(screen.getByText("Calle Mayor 5, Madrid")).toBeTruthy();
expect(screen.getByText(/Coordenadas/)).toBeTruthy();
});
it("botón de envío deshabilitado sin aceptar la privacidad", () => {
render(<PasoResumen {...propsBase} />);
const boton = screen.getByRole("button", { name: /enviar propuesta/i });
expect(boton.disabled).toBe(true);
});
it("al marcar la aceptación se habilita el envío y dispara onEnviar", () => {
render(<PasoResumen {...propsBase} />);
fireEvent.click(screen.getByRole("checkbox"));
expect(propsBase.setAceptaPrivacidad).toHaveBeenCalledWith(true);
// con aceptación ya aplicada por el padre
cleanup();
render(<PasoResumen {...propsBase} aceptaPrivacidad />);
const boton = screen.getByRole("button", { name: /enviar propuesta/i });
expect(boton.disabled).toBe(false);
fireEvent.click(boton);
expect(propsBase.onEnviar).toHaveBeenCalledTimes(1);
});
it("el enlace de privacidad abre el modal sin marcar el checkbox", () => {
render(<PasoResumen {...propsBase} />);
fireEvent.click(screen.getByRole("button", { name: /política de privacidad/i }));
expect(propsBase.onAbrirPrivacidad).toHaveBeenCalledTimes(1);
expect(propsBase.setAceptaPrivacidad).not.toHaveBeenCalled();
});
it("renderiza el aviso de palabra prohibida dentro del modal", () => {
render(<PasoResumen {...propsBase} errorFiltro="Tu propuesta contiene palabras no permitidas." />);
expect(screen.getByRole("alert").textContent).toContain("palabras no permitidas");
});
it("renderiza el aviso de duplicado con las opciones del flujo", () => {
render(
<PasoResumen
{...propsBase}
posibleDuplicado={{
coincidencias: [{ id: "1", nombre: "Bar El Olivo", provincia: "Madrid", direccion: "", estado: "publicado" }],
lat: 40.4168, lng: -3.7038,
}}
/>,
);
const aviso = screen.getByRole("alert");
expect(aviso.textContent).toContain("Ya existe un local parecido");
expect(aviso.textContent).toContain("Bar El Olivo");
fireEvent.click(screen.getByRole("button", { name: /enviar de todos modos/i }));
expect(propsBase.onConfirmarDuplicado).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: /revisar datos/i }));
expect(propsBase.onDescartarDuplicado).toHaveBeenCalledTimes(1);
});
it("estados Comprobando…/Enviando… deshabilitan el envío", () => {
const { rerender } = render(<PasoResumen {...propsBase} aceptaPrivacidad comprobandoDuplicado />);
let boton = screen.getByRole("button", { name: /comprobando/i });
expect(boton.disabled).toBe(true);
rerender(<PasoResumen {...propsBase} aceptaPrivacidad saving />);
boton = screen.getByRole("button", { name: /enviando/i });
expect(boton.disabled).toBe(true);
});
});
+127
View File
@@ -0,0 +1,127 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
vi.mock("../../src/utils/ubicacion/index.js", () => ({
ETIQUETAS_PROVEEDOR: {
"google-maps": "Google Maps",
coordenadas: "Coordenadas",
nominatim: "Dirección (OpenStreetMap)",
},
resolverUbicacion: vi.fn(),
inferirDesdeCoords: vi.fn(),
}));
import PasoUbicacion from "../../src/components/PasoUbicacion.jsx";
import { resolverUbicacion, inferirDesdeCoords } from "../../src/utils/ubicacion/index.js";
const FORM = {
nombre: "", provincia: "", categoria: "", subcategoria: "",
direccion: "", enlaceGoogleMaps: "", lat: null, lng: null,
entradaUbicacion: "", proveedorUbicacion: "",
};
// setForm que aplica los updater sobre un objeto (como haría useState) sin
// provocar re-render: suficiente para inspeccionar lo que el paso escribe.
function renderPaso(formInicial = { ...FORM }) {
const estado = { form: formInicial };
const setForm = (updater) => {
estado.form = typeof updater === "function" ? updater(estado.form) : updater;
};
render(<PasoUbicacion form={estado.form} setForm={setForm} />);
return estado;
}
const escribir = (texto) =>
fireEvent.change(screen.getByLabelText(/ubicación del local/i), { target: { value: texto } });
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
});
describe("PasoUbicacion", () => {
it("no resuelve antes del debounce (~600ms)", async () => {
resolverUbicacion.mockResolvedValue(null);
renderPaso();
escribir("https://maps.google.com/@41.3851,2.1734");
await act(async () => { await vi.advanceTimersByTimeAsync(300); });
expect(resolverUbicacion).not.toHaveBeenCalled();
await act(async () => { await vi.advanceTimersByTimeAsync(400); });
expect(resolverUbicacion).toHaveBeenCalledWith("https://maps.google.com/@41.3851,2.1734");
});
it("al resolver muestra el chip con el proveedor y coordenadas y pre-rellena el form", async () => {
resolverUbicacion.mockResolvedValue({ proveedor: "google-maps", lat: 41.3851, lng: 2.1734, nombre: "Bar X", provincia: "Barcelona", direccion: "Bar X, Barcelona" });
inferirDesdeCoords.mockResolvedValue(null); // ya trae nombre+provincia: no debe llamarse
const estado = renderPaso();
escribir("https://maps.google.com/@41.3851,2.1734");
await act(async () => { await vi.advanceTimersByTimeAsync(601); });
expect(screen.getByText(/Google Maps detectado/)).toBeTruthy();
expect(screen.getByText(/· 41\.3851, 2\.1734/)).toBeTruthy();
expect(estado.form.lat).toBe(41.3851);
expect(estado.form.lng).toBe(2.1734);
expect(estado.form.proveedorUbicacion).toBe("google-maps");
expect(estado.form.nombre).toBe("Bar X");
expect(estado.form.provincia).toBe("Barcelona");
expect(estado.form.direccion).toBe("Bar X, Barcelona");
// es un enlace: se guarda como enlaceGoogleMaps del local
expect(estado.form.enlaceGoogleMaps).toBe("https://maps.google.com/@41.3851,2.1734");
expect(inferirDesdeCoords).not.toHaveBeenCalled();
});
it("con solo coordenadas lanza la inferencia inversa y pre-rellena lo inferido", async () => {
resolverUbicacion.mockResolvedValue({ proveedor: "coordenadas", lat: 40.4168, lng: -3.7038, nombre: "", provincia: "" });
inferirDesdeCoords.mockResolvedValue({ nombre: "Puerta del Sol", provincia: "Madrid", direccion: "Puerta del Sol, Madrid" });
const estado = renderPaso();
escribir("40.4168, -3.7038");
await act(async () => { await vi.advanceTimersByTimeAsync(601); });
expect(inferirDesdeCoords).toHaveBeenCalledWith(40.4168, -3.7038);
expect(estado.form.nombre).toBe("Puerta del Sol");
expect(estado.form.provincia).toBe("Madrid");
// coordenadas sueltas: no es un enlace
expect(estado.form.enlaceGoogleMaps).toBe("");
expect(screen.queryByRole("alert")).toBeNull();
});
it("entrada no reconocida → aviso y sin coordenadas en el form", async () => {
resolverUbicacion.mockResolvedValue(null);
const estado = renderPaso();
escribir("https://maps.app.goo.gl/xyz");
await act(async () => { await vi.advanceTimersByTimeAsync(601); });
const aviso = screen.getByRole("alert");
expect(aviso.textContent).toContain("No se pudo interpretar la ubicación");
expect(estado.form.lat).toBeNull();
});
it("si la inferencia inversa falla, avisa pero mantiene la resolución válida", async () => {
resolverUbicacion.mockResolvedValue({ proveedor: "coordenadas", lat: 1, lng: 2, nombre: "", provincia: "" });
inferirDesdeCoords.mockResolvedValue(null); // fallo controlado
renderPaso();
escribir("1, 2");
await act(async () => { await vi.advanceTimersByTimeAsync(601); });
expect(screen.getByText(/No se pudieron inferir el nombre y la provincia/i)).toBeTruthy();
expect(screen.getByText(/Coordenadas detectado/)).toBeTruthy();
});
it("al volver a editar invalida la resolución anterior", async () => {
resolverUbicacion.mockResolvedValueOnce({ proveedor: "coordenadas", lat: 1, lng: 2 });
const estado = renderPaso();
escribir("1, 2");
await act(async () => { await vi.advanceTimersByTimeAsync(601); });
expect(estado.form.lat).toBe(1);
expect(screen.getByText(/Coordenadas detectado/)).toBeTruthy();
escribir("1, 2 y algo más");
expect(estado.form.lat).toBeNull(); // se limpia inmediatamente
expect(screen.queryByText(/detectado/)).toBeNull(); // el chip desaparece
});
});
+85
View File
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { useState } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import PasoNombre from "../../src/components/PasoNombre.jsx";
import PasoProvincia from "../../src/components/PasoProvincia.jsx";
import PasoCategoria from "../../src/components/PasoCategoria.jsx";
import PasoSubcategoria from "../../src/components/PasoSubcategoria.jsx";
import { PROVINCIAS } from "../../src/constants/provincias.js";
import { CATEGORIAS, NOMBRES_CATEGORIAS } from "../../src/constants/categorias.js";
afterEach(cleanup);
// Harness con useState real: los pasos escriben a través de setForm y el
// estado resultante se lee desde fuera (más fiel que espiar el updater,
// cuya evaluación diferida pierde el valor del evento en React 18).
function renderPaso(Componente, inicial) {
const estado = { form: inicial };
function Wrapper() {
const [form, setForm] = useState(inicial);
estado.form = form;
return <Componente form={form} setForm={setForm} />;
}
render(<Wrapper />);
return estado;
}
describe("PasoNombre", () => {
it("muestra el nombre inferido y es editable", () => {
const estado = renderPaso(PasoNombre, { nombre: "Bar El Olivo" });
const input = screen.getByLabelText(/nombre del local/i);
expect(input.value).toBe("Bar El Olivo");
fireEvent.change(input, { target: { value: "Bar La Plaza" } });
expect(estado.form.nombre).toBe("Bar La Plaza");
});
it("vacío si la inferencia falló", () => {
renderPaso(PasoNombre, { nombre: "" });
expect(screen.getByLabelText(/nombre del local/i).value).toBe("");
});
});
describe("PasoProvincia", () => {
it("es un select con el listado PROVINCIAS y la provincia pre-rellenada", () => {
renderPaso(PasoProvincia, { provincia: "La Coruña" });
const select = screen.getByLabelText(/provincia/i);
expect(select.value).toBe("La Coruña");
const opciones = [...select.options].map((o) => o.value);
expect(opciones).toEqual(["", ...PROVINCIAS]);
fireEvent.change(select, { target: { value: "Madrid" } });
// (la aserción de estado se cubre con el harness en el resto de pasos)
});
it("sin coincidencia queda vacío (selección manual)", () => {
renderPaso(PasoProvincia, { provincia: "" });
expect(screen.getByLabelText(/provincia/i).value).toBe("");
});
});
describe("PasoCategoria", () => {
it("ofrece todas las categorías y al elegir limpia la subcategoría", () => {
const estado = renderPaso(PasoCategoria, { categoria: "Pequeño comercio", subcategoria: "Farmacia" });
const select = screen.getByLabelText(/categoría/i);
const opciones = [...select.options].map((o) => o.value);
expect(opciones).toEqual(["", ...NOMBRES_CATEGORIAS]);
fireEvent.change(select, { target: { value: "Restauración" } });
expect(estado.form.categoria).toBe("Restauración");
expect(estado.form.subcategoria).toBe("");
});
});
describe("PasoSubcategoria", () => {
it("deshabilitado sin categoría y opcional con ella", () => {
const { rerender } = render(<PasoSubcategoria form={{ categoria: "", subcategoria: "" }} setForm={() => {}} />);
const select = screen.getByLabelText(/tipo específico/i);
expect(select.disabled).toBe(true);
expect([...select.options][0].textContent).toMatch(/sin tipo específico/i); // se puede continuar
rerender(<PasoSubcategoria form={{ categoria: "Restauración", subcategoria: "" }} setForm={() => {}} />);
expect(select.disabled).toBe(false);
const opciones = [...select.options].map((o) => o.value);
expect(opciones).toEqual(["", ...CATEGORIAS["Restauración"].subcategorias]);
});
});
+92
View File
@@ -0,0 +1,92 @@
import { beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
// BD en memoria: aislada, offline y determinista. Debe configurarse ANTES de
// importar la app (server/db.js resuelve la ruta en la carga del módulo).
process.env.LOCALESP_DB = ":memory:";
let app;
beforeAll(async () => {
({ default: app } = await import("../../server/index.js"));
});
// Payload equivalente al que envía la pasarela tras este cambio: sin
// descripcion y sin puntuacion (el backend aplica sus defaults).
const PROPUESTA_PASARELA = {
nombre: "Bar El Olivo",
provincia: "Madrid",
categoria: "Restauración",
subcategoria: "Tapas y raciones",
direccion: "Calle Mayor 5, Alcalá de Henares",
enlaceGoogleMaps: "",
lat: 40.4828,
lng: -3.3652,
};
describe("Integración API local (Express + SQLite en memoria)", () => {
it("GET /api/locales arranca limpio", async () => {
const r = await request(app).get("/api/locales");
expect(r.status).toBe(200);
expect(r.body).toEqual([]);
});
it("POST /api/propuestas acepta una propuesta de la pasarela sin descripcion ni puntuacion (defaults)", async () => {
const r = await request(app).post("/api/propuestas").send(PROPUESTA_PASARELA);
expect(r.status).toBe(201);
expect(r.body.nombre).toBe("Bar El Olivo");
expect(r.body.puntuacion).toBe(0); // default del backend
expect(r.body.estado).toBe("pendiente");
expect(r.body.id).toBeTruthy();
// y aparece en pendientes (vista admin)
const pend = await request(app).get("/api/pendientes");
expect(pend.body.map((p) => p.nombre)).toContain("Bar El Olivo");
});
it("POST /api/check-duplicado detecta mismo nombre normalizado en la misma provincia", async () => {
const r = await request(app)
.post("/api/check-duplicado")
.send({ nombre: "bar el olivo", provincia: "Madrid", lat: 41.0, lng: -4.0 }); // lejos: cuenta la provincia
expect(r.body.duplicado).toBe(true);
expect(r.body.coincidencias[0]).toMatchObject({
nombre: "Bar El Olivo",
provincia: "Madrid",
estado: "pendiente", // aún no publicado
});
});
it("POST /api/check-duplicado detecta por cercanía aunque cambie la provincia", async () => {
const r = await request(app)
.post("/api/check-duplicado")
.send({ nombre: "BAR EL OLIVO", provincia: "Barcelona", lat: 40.48281, lng: -3.36521 }); // ~1-2 m
expect(r.body.duplicado).toBe(true);
});
it("POST /api/check-duplicado no marca nombres distintos ni mismos nombres lejos y en otra provincia", async () => {
const distinto = await request(app)
.post("/api/check-duplicado")
.send({ nombre: "Cafetería Luna", provincia: "Madrid", lat: 40.48, lng: -3.36 });
expect(distinto.body.duplicado).toBe(false);
const lejosOtraProvincia = await request(app)
.post("/api/check-duplicado")
.send({ nombre: "Bar El Olivo", provincia: "Sevilla", lat: 37.38, lng: -5.98 });
expect(lejosOtraProvincia.body.duplicado).toBe(false);
});
it("flujo admin completo: aprobar publica el local con los datos de la propuesta", async () => {
const propuesta = (await request(app).get("/api/pendientes")).body[0];
const aprobado = await request(app).post(`/api/aprobar/${propuesta.id}`);
expect(aprobado.body.ok).toBe(true);
const locales = (await request(app).get("/api/locales")).body;
const publicado = locales.find((l) => l.nombre === "Bar El Olivo");
expect(publicado).toMatchObject({
provincia: "Madrid",
categoria: "Restauración",
subcategoria: "Tapas y raciones",
direccion: "Calle Mayor 5, Alcalá de Henares",
puntuacion: 0, // default aplicado en cascada
});
});
});
+53
View File
@@ -0,0 +1,53 @@
import { beforeAll, describe, expect, it } from "vitest";
import { inferirDesdeCoords, resolverUbicacion } from "../../src/utils/ubicacion/index.js";
// Integración real con Nominatim (OpenStreetMap). Suite opt-in
// (`npm run test:integration`): se auto-salta sin conectividad y mantiene un
// ritmo respetuoso con la política de uso (~1 req/s) esperando entre pruebas.
const PAUSA_MS = 1100;
const pausar = () => new Promise((r) => setTimeout(r, PAUSA_MS));
const conRed = await fetch("https://nominatim.openstreetmap.org/status", {
headers: { "User-Agent": "LocalesEspanoles/1.0" },
signal: AbortSignal.timeout(5000),
})
.then((r) => r.ok)
.catch(() => false);
describe.skipIf(!conRed)("Integración Nominatim (red real)", () => {
beforeAll(() => {
if (!conRed) console.warn("Sin conectividad a Nominatim: suite saltada.");
});
it("busca una dirección en texto y devuelve coordenadas + provincia canónica", async () => {
const r = await resolverUbicacion("Calle Mayor 5, Alcalá de Henares");
expect(r.proveedor).toBe("nominatim");
expect(r.lat).toBeGreaterThan(40.4);
expect(r.lat).toBeLessThan(40.6);
expect(r.lng).toBeGreaterThan(-3.5);
expect(r.lng).toBeLessThan(-3.2);
expect(r.provincia).toBe("Madrid");
});
it("geocodificación inversa de Madrid devuelve la provincia canónica", async () => {
await pausar();
const r = await inferirDesdeCoords(40.4168, -3.7038);
expect(r).not.toBeNull();
expect(r.provincia).toBe("Madrid");
expect(r.nombre).toBeTruthy();
expect(r.direccion).toBeTruthy();
});
it("geocodificación inversa de Bilbao normaliza Bizkaia → Vizcaya (acentos/cooficial)", async () => {
await pausar();
const r = await inferirDesdeCoords(43.263, -2.935);
expect(r.provincia).toBe("Vizcaya");
});
it("geocodificación inversa fuera de España deja la provincia vacía (selección manual)", async () => {
await pausar();
const r = await inferirDesdeCoords(48.8584, 2.2945); // Torre Eiffel
expect(r).not.toBeNull();
expect(r.provincia).toBe("");
});
});
+3
View File
@@ -0,0 +1,3 @@
// Setup común: React 18 exige este flag para act() fuera de Jest.
// Inofensivo en las pruebas de Node puro.
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest";
import ProveedorUbicacion from "../../src/utils/ubicacion/ProveedorUbicacion.js";
describe("ProveedorUbicacion (clase base)", () => {
it("normaliza: minúsculas, sin tildes, sin espacios extremos", () => {
const p = new ProveedorUbicacion();
expect(p.normalizar(" ÁLAVA ")).toBe("alava");
expect(p.normalizar("Guipúzcoa")).toBe("guipuzcoa");
expect(ProveedorUbicacion.normalizar("MA DRID")).toBe("ma drid");
expect(p.normalizar(null)).toBe("");
});
describe("emparejarProvincia", () => {
const p = new ProveedorUbicacion();
it("coincidencia exacta insensible a acentos y mayúsculas", () => {
expect(p.emparejarProvincia("ALAVA")).toBe("Álava");
expect(p.emparejarProvincia("Guipuzcoa")).toBe("Guipúzcoa");
});
it("coincidencia parcial dentro de un texto (Comunidad de Madrid)", () => {
expect(p.emparejarProvincia("Comunidad de Madrid")).toBe("Madrid");
expect(p.emparejarProvincia("Santiago, A Coruña, Galicia")).toBe("La Coruña");
});
it("tolera artículos anteuestos (A Coruña → La Coruña)", () => {
expect(p.emparejarProvincia("A Coruña")).toBe("La Coruña");
expect(p.emparejarProvincia("Ourense")).toBe("Orense");
});
it("alias de nombres cooficiales (D12)", () => {
expect(p.emparejarProvincia("Bizkaia")).toBe("Vizcaya");
expect(p.emparejarProvincia("Gipuzkoa")).toBe("Guipúzcoa");
expect(p.emparejarProvincia("Girona")).toBe("Gerona");
expect(p.emparejarProvincia("Lleida")).toBe("Lérida");
expect(p.emparejarProvincia("Illes Balears")).toBe("Islas Baleares");
expect(p.emparejarProvincia("Asturies")).toBe("Asturias");
});
it("devuelve \"\" cuando no casa con ninguna provincia", () => {
expect(p.emparejarProvincia("Île-de-France")).toBe("");
expect(p.emparejarProvincia("")).toBe("");
expect(p.emparejarProvincia("Lisboa")).toBe("");
});
});
it("el contrato base no acepta ninguna entrada", () => {
const p = new ProveedorUbicacion();
expect(p.detecta("41.38, 2.17")).toBe(false);
expect(p.detecta("https://maps.google.com")).toBe(false);
});
it("extrae() de la base lanza \"sin implementar\" (método abstracto)", async () => {
const p = new ProveedorUbicacion();
await expect(p.extrae("x")).rejects.toThrow("sin implementar");
});
describe("fetchNominatim degrada errores a null", () => {
it("devuelve null si fetch lanza (sin red)", async () => {
const p = new ProveedorUbicacion();
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("sin red"))));
expect(await p.fetchNominatim("https://nominatim.openstreetmap.org/x")).toBeNull();
vi.unstubAllGlobals();
});
it("devuelve null si la respuesta HTTP no es ok", async () => {
const p = new ProveedorUbicacion();
vi.stubGlobal("fetch", vi.fn(() => Promise.resolve({ ok: false, status: 503 })));
expect(await p.fetchNominatim("https://nominatim.openstreetmap.org/x")).toBeNull();
vi.unstubAllGlobals();
});
it("devuelve el JSON cuando todo va bien y manda cabeceras", async () => {
const p = new ProveedorUbicacion();
const fetchMock = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve([{ lat: "1" }]) }));
vi.stubGlobal("fetch", fetchMock);
expect(await p.fetchNominatim("https://nominatim.openstreetmap.org/x")).toEqual([{ lat: "1" }]);
const [, init] = fetchMock.mock.calls[0];
expect(init.headers["Accept-Language"]).toBe("es");
expect(init.headers["User-Agent"]).toBe("LocalesEspanoles/1.0");
vi.unstubAllGlobals();
});
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import ProveedorCoordenadas from "../../src/utils/ubicacion/proveedorCoordenadas.js";
describe("ProveedorCoordenadas", () => {
const p = new ProveedorCoordenadas();
it("detecta pares con coma y punto decimal, signo y espacios", async () => {
for (const [entrada, lat, lng] of [
["41.3851, 2.1734", 41.3851, 2.1734],
["41,3851,2,1734", 41.3851, 2.1734], // decimales españoles con coma
["-3.7038, 40.4168", -3.7038, 40.4168], // orden invertido con signo
[" 41.38 ; 2.17 ", 41.38, 2.17], // separador ; y espacios
["41,3851 2,1734", 41.3851, 2.1734], // separado por espacio
]) {
expect(p.detecta(entrada), entrada).toBe(true);
expect(await p.extrae(entrada), entrada).toEqual({ proveedor: "coordenadas", lat, lng });
}
});
it("latitud fuera de rango → null", async () => {
expect(p.detecta("91.234, 2.17")).toBe(true); // sintácticamente es un par…
expect(await p.extrae("91.234, 2.17")).toBeNull(); // …pero se descarta por rango
expect(await p.extrae("41.38, 181.5")).toBeNull();
});
it("no detecta textos ni URLs ni números sueltos", () => {
expect(p.detecta("Calle Mayor 5, Madrid")).toBe(false);
expect(p.detecta("https://maps.google.com/@41.38,2.17")).toBe(false);
expect(p.detecta("41.3851")).toBe(false);
expect(p.detecta("")).toBe(false);
});
it("entrada no par → extrae null", async () => {
expect(await p.extrae("hola mundo")).toBeNull();
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import ProveedorGoogleMaps from "../../src/utils/ubicacion/proveedorGoogleMaps.js";
describe("ProveedorGoogleMaps", () => {
const p = new ProveedorGoogleMaps();
it("detecta URLs de Google Maps (dominios y subdominios)", () => {
expect(p.detecta("https://maps.google.com/@41.38,2.17")).toBe(true);
expect(p.detecta("https://www.google.es/maps?q=41.38,2.17")).toBe(true);
expect(p.detecta("https://maps.app.goo.gl/abc")).toBe(true);
expect(p.detecta("https://waze.com/ul?ll=41.38,2.17")).toBe(false);
expect(p.detecta("41.38, 2.17")).toBe(false);
});
it("extrae del formato @lat,lng", async () => {
expect(await p.extrae("https://www.google.com/maps/@41.3851,2.1734,15z"))
.toEqual({ proveedor: "google-maps", lat: 41.3851, lng: 2.1734 });
});
it("extrae del formato !3d…!4d… cuando no hay @", async () => {
expect(await p.extrae("https://www.google.com/maps/place/Bar/data=!4m2!3m1!1s0x0:0x0?hl=es"))
.toBeNull(); // sin coordenadas en absoluto
expect(await p.extrae("https://www.google.com/maps/place/Bar/data=!3d41.3851!4d2.1734"))
.toEqual({ proveedor: "google-maps", lat: 41.3851, lng: 2.1734 });
});
it("extrae del formato ?q=lat,lng", async () => {
expect(await p.extrae("https://www.google.com/maps?q=41.3851,-2.1734"))
.toEqual({ proveedor: "google-maps", lat: 41.3851, lng: -2.1734 });
});
it("prioriza @ sobre !3d!4d (comportamiento migrado de parsearEnlaceGoogleMaps)", async () => {
const r = await p.extrae("https://www.google.com/maps/place/X/@40.4168,-3.7038,17z/data=!3d40.4178!4d-3.7028");
expect(r).toEqual({ proveedor: "google-maps", lat: 40.4168, lng: -3.7038 });
});
it("enlace acortado: detecta pero no puede extraer → null", async () => {
expect(await p.extrae("https://maps.app.goo.gl/AbCdEf123")).toBeNull();
});
});
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import ProveedorNominatim from "../../src/utils/ubicacion/proveedorNominatim.js";
afterEach(() => vi.unstubAllGlobals());
describe("ProveedorNominatim", () => {
const p = new ProveedorNominatim();
describe("detecta (fallback de texto)", () => {
it("acepta direcciones y nombres con letras", () => {
expect(p.detecta("Calle Mayor 5, Alcalá de Henares, Madrid")).toBe(true);
expect(p.detecta("Bar El Olivo Sevilla")).toBe(true);
});
it("rechaza URLs (las gestiona otro proveedor)", () => {
expect(p.detecta("https://ejemplo.com/calle")).toBe(false);
expect(p.detecta("www.ejemplo.com")).toBe(false);
expect(p.detecta("https://maps.app.goo.gl/abc")).toBe(false);
});
it("rechaza textos demasiado cortos o sin letras", () => {
expect(p.detecta("ab")).toBe(false);
expect(p.detecta("")).toBe(false);
expect(p.detecta("12345")).toBe(false);
});
});
describe("desdeResultado (normalización de provincia)", () => {
it("normaliza provincia canónica insensible a acentos", () => {
const r = p.desdeResultado({
lat: "40.4", lon: "-3.7",
display_name: "X, Madrid, España",
address: { state: "Comunidad de Madrid" },
namedetails: { name: "X" },
});
expect(r.provincia).toBe("Madrid");
});
it("alias cooficiales: Bizkaia → Vizcaya, A Coruña → La Coruña", () => {
const r = p.desdeResultado({ lat: "1", lon: "2", display_name: "d", address: { province: "Bizkaia" }, namedetails: {} });
expect(r.provincia).toBe("Vizcaya");
const r2 = p.desdeResultado({ lat: "1", lon: "2", display_name: "d", address: { city: "A Coruña" }, namedetails: {} });
expect(r2.provincia).toBe("La Coruña");
});
it("provincia fuera del listado → \"\" (selección manual)", () => {
const r = p.desdeResultado({ lat: "1", lon: "2", display_name: "d", address: { state: "Île-de-France" }, namedetails: {} });
expect(r.provincia).toBe("");
});
it("resultado extensible: lat/lng/proveedor/nombre/direccion", () => {
const r = p.desdeResultado({
lat: "41.5", lon: "2.1",
display_name: "X, Girona, España",
address: { province: "Girona" },
namedetails: { name: "X" },
});
expect(r).toEqual({ proveedor: "nominatim", lat: 41.5, lng: 2.1, nombre: "X", provincia: "Gerona", direccion: "X, Girona, España" });
});
});
describe("extrae (búsqueda por texto)", () => {
it("devuelve el primer resultado normalizado", async () => {
const fetchMock = vi.fn(() => ({
ok: true,
json: async () => [{
lat: "40.4828632", lon: "-3.3652613",
display_name: "5, Calle Mayor, Alcalá de Henares, España",
address: { city: "Alcalá de Henares", state: "Comunidad de Madrid" },
namedetails: { name: "Casa de la Panadería" },
}],
}));
vi.stubGlobal("fetch", fetchMock);
const r = await p.extrae("Calle Mayor 5, Alcalá de Henares");
expect(r.proveedor).toBe("nominatim");
expect(r.lat).toBeCloseTo(40.4828632, 6);
expect(r.provincia).toBe("Madrid");
expect(r.nombre).toBe("Casa de la Panadería");
// la búsqueda acota a España
const [url] = fetchMock.mock.calls[0];
expect(decodeURIComponent(String(url).replace(/\+/g, " "))).toContain("Calle Mayor 5, Alcalá de Henares, España");
});
it("sin resultados → null", async () => {
vi.stubGlobal("fetch", vi.fn(() => ({ ok: true, json: async () => [] })));
expect(await p.extrae("calle inventada zzzz")).toBeNull();
});
it("fallo de red → null (degradación controlada)", async () => {
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("sin red"))));
expect(await p.extrae("Calle Mayor 5, Madrid")).toBeNull();
});
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import ProveedorOSM from "../../src/utils/ubicacion/proveedorOSM.js";
describe("ProveedorOSM", () => {
const p = new ProveedorOSM();
it("detecta URLs de openstreetmap.org", () => {
expect(p.detecta("https://www.openstreetmap.org/#map=12/39.4699/-0.3763")).toBe(true);
expect(p.detecta("https://openstreetmap.org/?mlat=41.38&mlon=2.17")).toBe(true);
expect(p.detecta("https://www.google.com/maps")).toBe(false);
});
it("extrae de mlat/mlon (enlace de marcador)", async () => {
expect(await p.extrae("https://www.openstreetmap.org/?mlat=41.9794&mlon=2.8214"))
.toEqual({ proveedor: "osm", lat: 41.9794, lng: 2.8214 });
});
it("prioriza mlat/mlon sobre el fragmento #map=", async () => {
const r = await p.extrae("https://www.openstreetmap.org/?mlat=41.9794&mlon=2.8214#map=15/39.4699/-0.3763");
expect(r).toEqual({ proveedor: "osm", lat: 41.9794, lng: 2.8214 });
});
it("extrae del fragmento #map=zoom/lat/lng cuando no hay mlat/mlon", async () => {
expect(await p.extrae("https://www.openstreetmap.org/#map=12/39.4699/-0.3763"))
.toEqual({ proveedor: "osm", lat: 39.4699, lng: -0.3763 });
expect(await p.extrae("https://www.openstreet.org/#map=16.5/43.5321/-5.6556")) // zoom decimal
.toEqual({ proveedor: "osm", lat: 43.5321, lng: -5.6556 });
});
it("sin mlat/mlon ni #map= → null", async () => {
expect(await p.extrae("https://www.openstreetmap.org/way/123456")).toBeNull();
});
});
+67
View File
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import ProveedorWaze from "../../src/utils/ubicacion/proveedorWaze.js";
const respuestaNominatim = (resultados) => ({
ok: true,
json: async () => resultados,
});
afterEach(() => vi.unstubAllGlobals());
describe("ProveedorWaze", () => {
const p = new ProveedorWaze();
it("detecta URLs de Waze", () => {
expect(p.detecta("https://waze.com/ul?ll=41.38,2.17&navigate=yes")).toBe(true);
expect(p.detecta("https://www.waze.com/live-map/")).toBe(true);
expect(p.detecta("waze.to/abc")).toBe(true);
expect(p.detecta("https://google.com/maps")).toBe(false);
});
it("extrae de ll=lat,lng", async () => {
expect(await p.extrae("https://waze.com/ul?ll=41.3851,2.1734&navigate=yes"))
.toEqual({ proveedor: "waze", lat: 41.3851, lng: 2.1734 });
// sin esquema https://
expect(await p.extrae("waze.com/ul?ll=43.263,-2.935"))
.toEqual({ proveedor: "waze", lat: 43.263, lng: -2.935 });
});
it("ll malformado → null sin consultar Nominatim", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
expect(await p.extrae("https://waze.com/ul?ll=abc,def")).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it("q=texto delega en la búsqueda de Nominatim marcando proveedor waze", async () => {
const fetchMock = vi.fn(() => respuestaNominatim([{
lat: "40.4828632", lon: "-3.3652613",
display_name: "5, Calle Mayor, Alcalá de Henares, Comunidad de Madrid, 28801, España",
address: { city: "Alcalá de Henares", state: "Comunidad de Madrid" },
namedetails: { name: "" },
}]));
vi.stubGlobal("fetch", fetchMock);
const r = await p.extrae("https://waze.com/ul?q=Calle%20Mayor%205%2C%20Alcal%C3%A1%20de%20Henares");
expect(r).toEqual({
proveedor: "waze",
lat: 40.4828632,
lng: -3.3652613,
nombre: "",
provincia: "Madrid",
direccion: "5, Calle Mayor, Alcalá de Henares, Comunidad de Madrid, 28801, España",
});
// la petición lleva countrycodes=es y las cabeceras de Nominatim
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toContain("countrycodes=es");
expect(init.headers["User-Agent"]).toBe("LocalesEspanoles/1.0");
});
it("q=texto sin resultados en Nominatim → null", async () => {
vi.stubGlobal("fetch", vi.fn(() => respuestaNominatim([])));
expect(await p.extrae("https://waze.com/ul?q=calle%20inexistente%20zzzz")).toBeNull();
});
it("URL de Waze sin ll ni q → null", async () => {
expect(await p.extrae("https://waze.com/live-map/")).toBeNull();
});
});
+90
View File
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ETIQUETAS_PROVEEDOR, PROVEEDORES, inferirDesdeCoords, resolverUbicacion } from "../../src/utils/ubicacion/index.js";
afterEach(() => vi.unstubAllGlobals());
describe("resolverUbicacion", () => {
it("respeta el orden de prioridad de PROVEEDORES (coordenadas → google → waze → osm → nominatim)", () => {
expect(PROVEEDORES.map((p) => p.id)).toEqual(["coordenadas", "google-maps", "waze", "osm", "nominatim"]);
});
it("resuelve un enlace de Google Maps sin tocar la red", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const r = await resolverUbicacion("https://maps.google.com/@41.3851,2.1734,15z");
expect(r).toEqual({ proveedor: "google-maps", lat: 41.3851, lng: 2.1734 });
expect(fetchMock).not.toHaveBeenCalled();
});
it("resuelve coordenadas sueltas con el proveedor de coordenadas", async () => {
const r = await resolverUbicacion("41.3851, 2.1734");
expect(r.proveedor).toBe("coordenadas");
});
it("hace fallback a Nominatim con texto de dirección (fetch simulado)", async () => {
vi.stubGlobal("fetch", vi.fn(() => ({
ok: true,
json: async () => [{ lat: "40.1", lon: "-3.2", display_name: "X, Madrid", address: { state: "Comunidad de Madrid" }, namedetails: {} }],
})));
const r = await resolverUbicacion("Calle Mayor 5, Madrid");
expect(r.proveedor).toBe("nominatim");
expect(r.provincia).toBe("Madrid");
});
it("un proveedor que lanza se degrada y continúa con el siguiente", async () => {
// se inyecta temporalmente un proveedor roto con máxima prioridad
const roto = { id: "roto", detecta: () => true, extrae: async () => { throw new Error("boom"); } };
PROVEEDORES.unshift(roto);
const error = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const r = await resolverUbicacion("https://maps.google.com/@41.3851,2.1734");
expect(r.proveedor).toBe("google-maps");
expect(error).toHaveBeenCalled();
} finally {
PROVEEDORES.shift();
error.mockRestore();
}
});
it("entrada no reconocida por nadie → null", async () => {
expect(await resolverUbicacion("https://maps.app.goo.gl/abc123")).toBeNull();
expect(await resolverUbicacion("")).toBeNull();
expect(await resolverUbicacion(null)).toBeNull();
});
});
describe("inferirDesdeCoords", () => {
it("usa el reverse de Nominatim y normaliza la provincia", async () => {
const fetchMock = vi.fn(() => ({
ok: true,
json: async () => ({
lat: "40.4168", lon: "-3.7038",
display_name: "Puerta del Sol, Madrid, España",
address: { state: "Comunidad de Madrid" },
namedetails: { name: "Puerta del Sol" },
}),
}));
vi.stubGlobal("fetch", fetchMock);
const r = await inferirDesdeCoords(40.4168, -3.7038);
expect(r).toEqual({ nombre: "Puerta del Sol", provincia: "Madrid", direccion: "Puerta del Sol, Madrid, España" });
const [url] = fetchMock.mock.calls[0];
expect(String(url)).toContain("/reverse");
expect(String(url)).toContain("addressdetails=1");
expect(String(url)).toContain("namedetails=1");
});
it("fallo de red → null sin lanzar", async () => {
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("sin red"))));
const error = vi.spyOn(console, "error").mockImplementation(() => {});
expect(await inferirDesdeCoords(1, 1)).toBeNull();
error.mockRestore();
});
});
describe("ETIQUETAS_PROVEEDOR", () => {
it("cubre todos los ids registrados (para los chips de la UI)", () => {
for (const p of PROVEEDORES) {
expect(ETIQUETAS_PROVEEDOR[p.id], `falta etiqueta para ${p.id}`).toBeTruthy();
}
});
});