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
+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
});
});
});