`). El backend NO genera los gráficos.
+- Payload: `[{ product: {id, name, catalogue_images[]}, suppliers: [{ supplier: {...}, organization|null, municipality|null, department|null, country|null }] }]`.
+- **Genérico reutilizable:**
+ - `src/components/graph/VisChart.vue`: wrapper de vis-network (props `nodes`, `edges`, `height`, `options`; import `import { DataSet, Network } from 'vis-network/standalone'`; emite `select` con el nodo). **OJO**: nodo con `image: null` → TypeError de vis; omitir la clave `image` si no hay foto
+- **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:` (disyunción) con arista sólida hasta él y discontinua hacia cada proveedor; la duda se corta donde los proveedores coinciden (misma organización/municipio/departamento). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. `buildProvenanceGraph` acepta los niveles a graficar (`product`, `supplier`, `organization`, `municipality`, `department`, `country`); los niveles omitidos se saltan conectando el nivel previo con el siguiente. `ProvenanceGraph.vue` unifica los charts en uno con checkboxes de filtro (por defecto solo productos y proveedores), leyenda con el color de cada nivel (`KIND_COLORS` en `provenance-vis.js`), columnas por nivel (x fijo por tipo; la física ordena la y) y espaciado vertical mínimo (`minVerticalSpacing` en `VisChart`); muestra "próximamente estará disponible" cuando no hay relaciones. `ProvenanceSection.vue` muestra el título ("Origen de los productos") con un desplegable (clic en el título o botón chevron) que oculta el gráfico por defecto, y un segundo desplegable para el mapa ("Mapa de origen de los productos") — patrón reutilizable para futuros bloques. `ProvenanceMap.vue` muestra un recuadro informativo (lista) con **todos** los productos del payload —incluidos los sin proveedor o cuyo municipio no tiene coordenadas, marcados "Sin geolocalización"— como panel lateral izquierdo junto al mapa (en columna en pantallas < 900px) y, si hay al menos un municipio con coordenadas, el mapa leaflet al lado: un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload, sin desplazar posiciones aunque coincidan; los productos del mismo punto se agrupan en un único marcador con contador que al hacer clic despliega un popup con la lista de productos internos para abrir cada uno) y un ícono de persona en la posición de la tienda (settings store, endpoint público `getStoreSettings`); `fitBounds` abarca todos los marcadores, al hacer hover sobre un producto dibuja una línea discontinua hasta la tienda y al hacer hover sobre la tienda dibuja las de todos los productos; un botón flotante en el título del panel (`data-test="map-reset-zoom"`) re-ejecuta `fitBounds` para volver al zoom general; clic en un marcador individual abre un **popup de leaflet** anclado al ícono con el detalle (imagen + producto + proveedor + organización + territorio); clic en un producto del popup agrupado reemplaza su contenido por el detalle de ese producto. Clic en un producto del recuadro: si tiene ubicación hace `flyTo` al punto y abre el popup de detalle en el marcador; si no, abre `ProvenanceRelationModal.vue` (único caso donde se usa el modal web, pues no hay ícono en el mapa) que muestra el proveedor/organización disponibles y la nota "Aún sin geolocalización registrada." o "Aún no se ha vinculado un proveedor a este producto.". `ProvenanceDetailModal.vue`
+- **Admin CRUD** (`src/components/provenance/admin/`): `OrganizationsManagement.vue`, `SuppliersManagement.vue`, `GeographyManagement.vue` (tabs países/departamentos/municipios), `SupplierLinkDialog.vue` (vincula productos↔proveedores, abierto desde `ProductsManagement.vue`). Páginas en `src/pages/admin/{organizations,suppliers,geography}.vue`; rutas en `ADMIN_ROUTES` (`router/index.js`); ítems en `NavBar.vue`
+- **Endpoints provenance**: `/don_confiao/api/organizations/`, `/suppliers/`, `/countries/`, `/departments/`, `/municipalities/` (CRUD); vincular productos con `PATCH /don_confiao/api/products//` body `{"suppliers": [ids]}`; detalle de producto (con `suppliers`) via `GET /don_confiao/api/products//`
+- Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, y la API con `global.provide: { api }`
diff --git a/package-lock.json b/package-lock.json
index 2312abe..4a1a6e0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,7 @@
"leaflet": "^1.9.4",
"roboto-fontface": "*",
"vee-validate": "^4.14.6",
+ "vis-network": "^10.1.1",
"vue": "^3.4.31",
"vuetify": "^3.6.11"
},
@@ -392,6 +393,18 @@
"node": ">=18"
}
},
+ "node_modules/@egjs/hammerjs": {
+ "version": "2.0.17",
+ "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
+ "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==",
+ "peer": true,
+ "dependencies": {
+ "@types/hammerjs": "^2.0.36"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -1439,6 +1452,12 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/@types/hammerjs": {
+ "version": "2.0.46",
+ "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
+ "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
+ "peer": true
+ },
"node_modules/@types/json5": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@@ -2655,6 +2674,18 @@
"node": ">=14"
}
},
+ "node_modules/component-emitter": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-2.0.0.tgz",
+ "integrity": "sha512-4m5s3Me2xxlVKG9PkZpQqHQR7bgpnN7joDMJ4yvVkVXngjoITG76IaZmzmywSeRTeTpc6N6r3H3+KyUurV8OYw==",
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4982,6 +5013,12 @@
"json5": "lib/cli.js"
}
},
+ "node_modules/keycharm": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/keycharm/-/keycharm-0.4.0.tgz",
+ "integrity": "sha512-TyQTtsabOVv3MeOpR92sIKk/br9wxS+zGj4BG7CR8YbK4jM3tyIBaF0zhzeBUMx36/Q/iQLOKKOT+3jOQtemRQ==",
+ "peer": true
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -7036,6 +7073,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/uuid": {
+ "version": "14.0.1",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
+ "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "peer": true,
+ "bin": {
+ "uuid": "dist-node/bin/uuid"
+ }
+ },
"node_modules/vee-validate": {
"version": "4.14.6",
"resolved": "https://registry.npmjs.org/vee-validate/-/vee-validate-4.14.6.tgz",
@@ -7070,6 +7120,54 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/vis-data": {
+ "version": "8.0.5",
+ "resolved": "https://registry.npmjs.org/vis-data/-/vis-data-8.0.5.tgz",
+ "integrity": "sha512-tRQKcTGaclo60rTY1j4v7BgD9v5jfD3wl+JgR1q6I4AI7go3QH5OjMCLIuZPpL2sf4IRMlwyPlRLnK4m+Phjsw==",
+ "peer": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/visjs"
+ },
+ "peerDependencies": {
+ "uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^13.0.0 || ^14.0.0",
+ "vis-util": ">=6.0.0"
+ }
+ },
+ "node_modules/vis-network": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/vis-network/-/vis-network-10.1.1.tgz",
+ "integrity": "sha512-KCpQijl3DRasx5OSWaW+niW04ej6cczsjuNI+ZPrn1FItwwVHUnQXuiDoqj0H7EpvEd4JbbMKNLsFt1hhoQODQ==",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/visjs"
+ },
+ "peerDependencies": {
+ "@egjs/hammerjs": "^2.0.0",
+ "component-emitter": "^1.3.0 || ^2.0.0",
+ "keycharm": "^0.2.0 || ^0.3.0 || ^0.4.0",
+ "uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^13.0.0 || ^14.0.0",
+ "vis-data": ">=8.0.0",
+ "vis-util": ">=6.0.0"
+ }
+ },
+ "node_modules/vis-util": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/vis-util/-/vis-util-6.0.2.tgz",
+ "integrity": "sha512-sYawqWllCqwmTPLurRgPChmfRppbWI/XoBWdGNM6N2s4XuVR8VO8gtz/v9AvrAAOfZxel/U5UBNOwyhFn0qb7A==",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/visjs"
+ },
+ "peerDependencies": {
+ "@egjs/hammerjs": "^2.0.0",
+ "component-emitter": "^1.3.0 || ^2.0.0"
+ }
+ },
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
diff --git a/package.json b/package.json
index 8818240..e5ad862 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"leaflet": "^1.9.4",
"roboto-fontface": "*",
"vee-validate": "^4.14.6",
+ "vis-network": "^10.1.1",
"vue": "^3.4.31",
"vuetify": "^3.6.11"
},
diff --git a/src/components/NavBar.vue b/src/components/NavBar.vue
index 16b566d..7a55b5d 100644
--- a/src/components/NavBar.vue
+++ b/src/components/NavBar.vue
@@ -111,6 +111,9 @@
{ title: 'Imágenes de Catálogo', route: '/admin/catalogue-images', icon: 'mdi-image-multiple'},
{ title: 'Datos de la Tienda', route: '/admin/store-settings', icon: 'mdi-map-marker'},
{ title: 'Ver Ventas por Catálogo', route: '/admin/catalog-sales', icon: 'mdi-cart-arrow-down'},
+ { title: 'Organizaciones', route: '/admin/organizations', icon: 'mdi-domain'},
+ { title: 'Proveedores', route: '/admin/suppliers', icon: 'mdi-truck'},
+ { title: 'Geografía', route: '/admin/geography', icon: 'mdi-earth'},
{ divider: true },
{ header: 'Sincronización Tryton' },
{ title: 'Importar Productos', route: '/sincronizar_productos_tryton', icon: 'mdi-download'},
diff --git a/src/components/ProductsManagement.vue b/src/components/ProductsManagement.vue
index da45e57..10da31c 100644
--- a/src/components/ProductsManagement.vue
+++ b/src/components/ProductsManagement.vue
@@ -122,6 +122,19 @@
+
+
+
+ Proveedores
+
+
+
@@ -138,6 +151,13 @@
+
+
+
import { ref, watch, inject, onMounted, computed } from "vue";
+import SupplierLinkDialog from "@/components/provenance/admin/SupplierLinkDialog.vue";
// Estado
const api = inject("api");
@@ -164,6 +185,8 @@ const selected = ref([]);
const loading = ref(false);
const snackbar = ref({ show: false, message: "", color: "success" });
const searchQuery = ref("");
+const linkDialog = ref(false);
+const linkProduct = ref(null);
// Headers de la tabla
const headers = [
@@ -171,6 +194,7 @@ const headers = [
{ title: "Nombre", key: "name", sortable: true },
{ title: "Precio", key: "price", sortable: true },
{ title: "Estado", key: "active", sortable: true },
+ { title: "Proveedores", key: "actions", sortable: false },
];
// Computed - Productos filtrados por búsqueda
@@ -236,6 +260,11 @@ function showSnackbar(message, color) {
snackbar.value = { show: true, message, color };
}
+function openSupplierLink(product) {
+ linkProduct.value = product;
+ linkDialog.value = true;
+}
+
// Watchers
watch(activeFilter, () => {
selected.value = [];
diff --git a/src/components/PublicOrderSummary.vue b/src/components/PublicOrderSummary.vue
index 484b2a8..c45f4e2 100644
--- a/src/components/PublicOrderSummary.vue
+++ b/src/components/PublicOrderSummary.vue
@@ -40,6 +40,10 @@
+
@@ -51,6 +55,7 @@
import OrderLines from '@/components/order/OrderLines.vue'
import OrderPayment from '@/components/order/OrderPayment.vue'
import OrderTotal from '@/components/order/OrderTotal.vue'
+ import ProvenanceSection from '@/components/provenance/ProvenanceSection.vue'
defineProps({
purchase: {
diff --git a/src/components/graph/VisChart.vue b/src/components/graph/VisChart.vue
new file mode 100644
index 0000000..35a4853
--- /dev/null
+++ b/src/components/graph/VisChart.vue
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+
diff --git a/src/components/provenance/ProvenanceDetailModal.vue b/src/components/provenance/ProvenanceDetailModal.vue
new file mode 100644
index 0000000..f013836
--- /dev/null
+++ b/src/components/provenance/ProvenanceDetailModal.vue
@@ -0,0 +1,108 @@
+
+
+
+
+
+ {{ kindLabel }}
+
+
+
+
+ {{ selected.label }}
+
+
+ Descripción
+ {{ description }}
+
+
+ Sitio web
+
+ {{ website }}
+
+
+
+ Correo de contacto
+ {{ contactEmail }}
+
+
+ Teléfono de contacto
+ {{ contactPhone }}
+
+
+
+
+
+ Cerrar
+
+
+
+
+
+
+
+
diff --git a/src/components/provenance/ProvenanceGraph.vue b/src/components/provenance/ProvenanceGraph.vue
new file mode 100644
index 0000000..1014ce0
--- /dev/null
+++ b/src/components/provenance/ProvenanceGraph.vue
@@ -0,0 +1,114 @@
+
+
+
+ La información de origen de los productos de este pedido estará disponible
+ próximamente.
+
+
+
+
+ Elementos a graficar:
+
+
+
+
+ {{ option.label }}
+
+
+
+
+
+
+ Selecciona al menos un elemento para graficar.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/provenance/ProvenanceMap.vue b/src/components/provenance/ProvenanceMap.vue
new file mode 100644
index 0000000..719047c
--- /dev/null
+++ b/src/components/provenance/ProvenanceMap.vue
@@ -0,0 +1,569 @@
+
+
+
+
+
+
+
+ Productos ({{ productRows.length }})
+
+
+
+
+
+
+
+
+ mdi-map-marker
+
+
+ mdi-map-marker-off
+
+
+
+
+
+
+
+
+
+
+ La ubicación de la tienda no está configurada, por lo que no se mostrará su recorrido.
+
+
+ Pasa el cursor sobre un producto para ver su recorrido hasta la tienda. Haz clic en un marcador para ver el detalle.
+
+
+
+ Las coordenadas geográficas de los municipios de origen aún no están disponibles.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/provenance/ProvenanceRelationModal.vue b/src/components/provenance/ProvenanceRelationModal.vue
new file mode 100644
index 0000000..2e570ae
--- /dev/null
+++ b/src/components/provenance/ProvenanceRelationModal.vue
@@ -0,0 +1,145 @@
+
+
+
+
+
+ {{ productName }}
+
+
+
+
+
+
+
+
+ Geolocalización
+
+
+ {{ locationNote }}
+
+
+
+
+
+
+
+ {{ row.label }}
+
+
+ {{ row.name }}
+
+
+ {{ detail.label }}: {{ detail.value }}
+
+
+
+
+
+
+ Cerrar
+
+
+
+
+
+
+
+
diff --git a/src/components/provenance/ProvenanceSection.vue b/src/components/provenance/ProvenanceSection.vue
new file mode 100644
index 0000000..bbbf351
--- /dev/null
+++ b/src/components/provenance/ProvenanceSection.vue
@@ -0,0 +1,77 @@
+
+
+
+
+
+ Origen de los productos
+
+
+
+
+
+
+
+ Conoce quiénes producen los productos que compras y de qué territorios provienen.
+
+
+
+
+
+
+
+
+
+ Mapa de origen de los productos
+
+
+
+
+
+
+
+ Recorrido de cada producto desde el municipio donde se produce hasta nuestra tienda.
+
+
+
+
+
+
+
+
+
diff --git a/src/components/provenance/admin/GeographyManagement.vue b/src/components/provenance/admin/GeographyManagement.vue
new file mode 100644
index 0000000..1e5cc0d
--- /dev/null
+++ b/src/components/provenance/admin/GeographyManagement.vue
@@ -0,0 +1,687 @@
+
+
+ Gestión de Geografía
+
+
+
+ Países
+
+
+ Departamentos
+
+
+ Municipios
+
+
+
+
+
+
+
+ Nuevo país
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No hay países para mostrar
+
+
+
+
+
+
+
+
+
+
+
+ Nuevo departamento
+
+
+
+
+
+
+ {{ item.country_detail?.name }}
+
+
+
+
+
+
+
+
+
+
+ No hay departamentos para mostrar
+
+
+
+
+
+
+
+
+
+
+
+ Nuevo municipio
+
+
+
+
+
+
+ {{ item.department_detail?.name }}
+
+
+ {{ item.country_detail?.name }}
+
+
+
+
+
+
+
+
+
+
+ No hay municipios para mostrar
+
+
+
+
+
+
+
+
+
+
+
+ {{ countryDialog.isEdit ? 'Editar país' : 'Nuevo país' }}
+
+
+
+
+
+
+
+
+
+ Cancelar
+
+ Guardar
+
+
+
+
+
+
+
+
+
+ {{ departmentDialog.isEdit ? 'Editar departamento' : 'Nuevo departamento' }}
+
+
+
+
+
+
+
+
+
+ Cancelar
+
+ Guardar
+
+
+
+
+
+
+
+
+
+ {{ municipalityDialog.isEdit ? 'Editar municipio' : 'Nuevo municipio' }}
+
+
+
+
+
+
+
+
+
+
+ Cancelar
+
+ Guardar
+
+
+
+
+
+
+
+ Eliminar país
+
+ ¿Está seguro de eliminar "{{ deleteCountryTarget?.name }}"?
+
+
+
+ Cancelar
+
+ Eliminar
+
+
+
+
+
+
+
+ Eliminar departamento
+
+ ¿Está seguro de eliminar "{{ deleteDepartmentTarget?.name }}"?
+
+
+
+ Cancelar
+
+ Eliminar
+
+
+
+
+
+
+
+ Eliminar municipio
+
+ ¿Está seguro de eliminar "{{ deleteMunicipalityTarget?.name }}"?
+
+
+
+ Cancelar
+
+ Eliminar
+
+
+
+
+
+
+ {{ snackbar.message }}
+
+ Cerrar
+
+
+
+
+
+
diff --git a/src/components/provenance/admin/OrganizationsManagement.vue b/src/components/provenance/admin/OrganizationsManagement.vue
new file mode 100644
index 0000000..7e9b085
--- /dev/null
+++ b/src/components/provenance/admin/OrganizationsManagement.vue
@@ -0,0 +1,299 @@
+
+
+
+
+ Gestión de Organizaciones
+
+
+
+ Nueva organización
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.website }}
+
+
+
+
+
+
+
+
+
+
+
+ No hay organizaciones para mostrar
+
+
+
+
+
+
+
+
+
+
+ {{ dialog.isEdit ? 'Editar organización' : 'Nueva organización' }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancelar
+
+ Guardar
+
+
+
+
+
+
+
+ Eliminar organización
+
+ ¿Está seguro de eliminar "{{ deleteDialog.item?.name }}"?
+
+
+
+ Cancelar
+
+ Eliminar
+
+
+
+
+
+
+ {{ snackbar.message }}
+
+ Cerrar
+
+
+
+
+
+
+
+
diff --git a/src/components/provenance/admin/SupplierLinkDialog.vue b/src/components/provenance/admin/SupplierLinkDialog.vue
new file mode 100644
index 0000000..5a3a969
--- /dev/null
+++ b/src/components/provenance/admin/SupplierLinkDialog.vue
@@ -0,0 +1,126 @@
+
+
+
+ Proveedores del producto
+
+
+ {{ product.name }}
+
+
+
+ Seleccionados ({{ selectedIds.length }})
+
+
+
+
+ Cancelar
+
+ Guardar
+
+
+
+
+
+
+
diff --git a/src/components/provenance/admin/SuppliersManagement.vue b/src/components/provenance/admin/SuppliersManagement.vue
new file mode 100644
index 0000000..cbb8cfd
--- /dev/null
+++ b/src/components/provenance/admin/SuppliersManagement.vue
@@ -0,0 +1,347 @@
+
+
+
+
+ Gestión de Proveedores
+
+
+
+ Nuevo proveedor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.organization_detail?.name }}
+
+
+ {{ item.municipality_detail?.name }}
+
+
+
+
+
+
+
+
+
+
+ No hay proveedores para mostrar
+
+
+
+
+
+
+
+
+
+
+ {{ dialog.isEdit ? 'Editar proveedor' : 'Nuevo proveedor' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancelar
+
+ Guardar
+
+
+
+
+
+
+
+ Eliminar proveedor
+
+ ¿Está seguro de eliminar "{{ deleteDialog.item?.name }}"?
+
+
+
+ Cancelar
+
+ Eliminar
+
+
+
+
+
+
+ {{ snackbar.message }}
+
+ Cerrar
+
+
+
+
+
+
+
+
diff --git a/src/components/provenance/provenance-graph.js b/src/components/provenance/provenance-graph.js
new file mode 100644
index 0000000..f00a4fc
--- /dev/null
+++ b/src/components/provenance/provenance-graph.js
@@ -0,0 +1,145 @@
+/**
+ * Builders de grafos de provenance (específicos del dominio).
+ *
+ * Convierten el payload `product_provenance` del resumen de compra/pedido en
+ * un grafo con la semántica de certeza:
+ * - Arista cierta (continua): el destino es inequívoco.
+ * - Arista dudosa (discontinua): el destino es una posibilidad entre varias.
+ * - Con varios proveedores para un producto se inserta un nodo de disyunción
+ * (junction): sólida hasta él y discontinua hacia cada proveedor.
+ *
+ * `buildProvenanceGraph` permite elegir los niveles a graficar
+ * (product, supplier, organization, municipality, department, country);
+ * los niveles no solicitados se omiten conectando el nivel previo con el
+ * siguiente.
+ *
+ * El modelo devuelto es agnóstico de la herramienta de renderizado:
+ * node: { id, kind, label, image, entity }
+ * edge: { from, to, certain }
+ */
+
+const DEFAULT_KINDS = ['product', 'supplier']
+const TERRITORY_ORDER = ['municipality', 'department', 'country']
+
+function makeCollector () {
+ const nodes = []
+ const edges = []
+ const seen = new Map()
+ const edgeSeen = new Map()
+ return {
+ nodes,
+ edges,
+ addNode (kind, entity) {
+ const id = `${kind}:${entity.id}`
+ if (!seen.has(id)) {
+ const node = {
+ id,
+ kind,
+ label: entity.name || '',
+ image: entity.catalogue_images?.[0] || null,
+ entity: { ...entity, kind },
+ }
+ seen.set(id, node)
+ nodes.push(node)
+ }
+ return seen.get(id)
+ },
+ addEdge (from, to, certain) {
+ const key = `${from}\u0000${to}`
+ if (edgeSeen.has(key)) {
+ edgeSeen.get(key).certain = edgeSeen.get(key).certain && Boolean(certain)
+ return
+ }
+ const edge = { from, to, certain: Boolean(certain) }
+ edgeSeen.set(key, edge)
+ edges.push(edge)
+ },
+ }
+}
+
+function distinctValues (items) {
+ const seen = new Map()
+ for (const item of items) {
+ if (item && !seen.has(item.id)) seen.set(item.id, item)
+ }
+ return [...seen.values()]
+}
+
+function isCertain (values) {
+ return values.length <= 1
+}
+
+function wirePath (collector, path, certainties) {
+ for (let index = 0; index < path.length - 1; index++) {
+ const to = path[index + 1]
+ const kind = to.split(':')[0]
+ collector.addEdge(path[index], to, certainties[kind])
+ }
+}
+
+export function buildProvenanceGraph (provenance, kinds = DEFAULT_KINDS) {
+ const active = new Set(kinds)
+ const collector = makeCollector()
+
+ for (const entry of provenance || []) {
+ if (!entry.product) continue
+ const relations = (entry.suppliers || []).filter(rel => rel.supplier)
+ if (relations.length === 0) continue
+
+ const product = active.has('product') ? collector.addNode('product', entry.product) : null
+ const suppliersActive = active.has('supplier')
+
+ const certainties = {
+ municipality: isCertain(distinctValues(relations.map(rel => rel.municipality))),
+ department: isCertain(distinctValues(relations.map(rel => rel.department))),
+ country: isCertain(distinctValues(relations.map(rel => rel.country))),
+ organization: isCertain(distinctValues(relations.map(rel => rel.organization))),
+ }
+
+ for (const rel of relations) {
+ if (suppliersActive) collector.addNode('supplier', rel.supplier)
+ if (active.has('municipality') && rel.municipality) collector.addNode('municipality', rel.municipality)
+ if (active.has('department') && rel.department) collector.addNode('department', rel.department)
+ if (active.has('country') && rel.country) collector.addNode('country', rel.country)
+ if (active.has('organization') && rel.organization) collector.addNode('organization', rel.organization)
+ }
+
+ if (suppliersActive && product) {
+ if (relations.length === 1) {
+ collector.addEdge(product.id, `supplier:${relations[0].supplier.id}`, true)
+ } else {
+ const junction = collector.addNode('junction', { id: entry.product.id, name: '' })
+ collector.addEdge(product.id, junction.id, true)
+ for (const rel of relations) {
+ collector.addEdge(junction.id, `supplier:${rel.supplier.id}`, false)
+ }
+ }
+ }
+
+ for (const rel of relations) {
+ const path = []
+ if (suppliersActive) {
+ path.push(`supplier:${rel.supplier.id}`)
+ } else if (product) {
+ path.push(product.id)
+ }
+ for (const level of TERRITORY_ORDER) {
+ if (active.has(level) && rel[level]) path.push(`${level}:${rel[level].id}`)
+ }
+ wirePath(collector, path, certainties)
+ }
+
+ if (active.has('organization')) {
+ for (const rel of relations) {
+ if (!rel.organization) continue
+ const source = suppliersActive ? `supplier:${rel.supplier.id}` : (product ? product.id : null)
+ if (source) collector.addEdge(source, `organization:${rel.organization.id}`, certainties.organization)
+ }
+ }
+ }
+ return { nodes: collector.nodes, edges: collector.edges }
+}
+
+export function hasAnySupplier (provenance) {
+ return (provenance || []).some(entry => (entry.suppliers || []).length > 0)
+}
diff --git a/src/components/provenance/provenance-vis.js b/src/components/provenance/provenance-vis.js
new file mode 100644
index 0000000..c83560d
--- /dev/null
+++ b/src/components/provenance/provenance-vis.js
@@ -0,0 +1,80 @@
+/**
+ * Adapta el modelo de grafo de provenance ({ id, kind, label, image, entity }
+ * y { from, to, certain }) al formato de vis-network, incluyendo la semántica
+ * de certeza: arista cierta = sólida, arista dudosa = discontinua.
+ */
+
+const COLORS = {
+ product: '#26a69a',
+ supplier: '#42a5f5',
+ organization: '#ffb74d',
+ municipality: '#66bb6a',
+ department: '#5c6bc0',
+ country: '#ab47bc',
+ junction: '#78909c',
+}
+
+const PRODUCT_SPACING = 110
+const COLUMN_X = {
+ product: -240,
+ junction: -160,
+ supplier: -80,
+ organization: 80,
+ municipality: 240,
+ department: 400,
+ country: 560,
+}
+
+export const KIND_COLORS = COLORS
+
+export function toVisNodes (nodes) {
+ const products = nodes.filter(node => node.kind === 'product')
+ const productY = new Map()
+ products.forEach((node, index) => {
+ productY.set(node.entity.id, (index - (products.length - 1) / 2) * PRODUCT_SPACING)
+ })
+ return nodes.map(node => {
+ const color = COLORS[node.kind] || '#cfd8dc'
+ const { image, ...rest } = node
+ if (node.kind === 'junction') {
+ return {
+ ...rest,
+ shape: 'dot',
+ color,
+ size: 10,
+ label: '',
+ x: COLUMN_X.junction,
+ y: productY.get(node.entity.id),
+ fixed: { x: true, y: true },
+ }
+ }
+ const base = image
+ ? { ...rest, image, shape: 'circularImage', borderWidth: 2 }
+ : { ...rest, shape: 'dot', color, borderWidth: 2 }
+ if (node.kind === 'product') {
+ return {
+ ...base,
+ x: COLUMN_X.product,
+ y: productY.get(node.entity.id),
+ fixed: { x: true, y: true },
+ }
+ }
+ return { ...base, x: COLUMN_X[node.kind], fixed: { x: true, y: false } }
+ })
+}
+
+export function toVisEdges (edges) {
+ return edges.map(edge => ({
+ from: edge.from,
+ to: edge.to,
+ dashes: !edge.certain,
+ }))
+}
+
+export const chartOptions = {
+ nodes: { font: { size: 13, color: '#263238' } },
+ edges: { color: { color: '#546e7a' } },
+ physics: {
+ barnesHut: { gravitationalConstant: -4000, springLength: 120, springConstant: 0.02 },
+ },
+}
diff --git a/src/pages/admin/geography.vue b/src/pages/admin/geography.vue
new file mode 100644
index 0000000..a7582d4
--- /dev/null
+++ b/src/pages/admin/geography.vue
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/src/pages/admin/organizations.vue b/src/pages/admin/organizations.vue
new file mode 100644
index 0000000..b6d2741
--- /dev/null
+++ b/src/pages/admin/organizations.vue
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/src/pages/admin/suppliers.vue b/src/pages/admin/suppliers.vue
new file mode 100644
index 0000000..c4e7780
--- /dev/null
+++ b/src/pages/admin/suppliers.vue
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/src/router/index.js b/src/router/index.js
index 3f712f0..d42774d 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -25,6 +25,9 @@ const ADMIN_ROUTES = [
'/admin/catalog-sales',
'/admin/catalogue-images',
'/admin/store-settings',
+ '/admin/organizations',
+ '/admin/suppliers',
+ '/admin/geography',
]
const router = createRouter({
diff --git a/src/services/api.js b/src/services/api.js
index 20363e0..7423dca 100644
--- a/src/services/api.js
+++ b/src/services/api.js
@@ -15,6 +15,90 @@ class Api {
return this.apiImplementation.updateProduct(productId, data);
}
+ getProduct(productId) {
+ return this.apiImplementation.getProduct(productId);
+ }
+
+ getOrganizations() {
+ return this.apiImplementation.getOrganizations();
+ }
+
+ createOrganization(data) {
+ return this.apiImplementation.createOrganization(data);
+ }
+
+ updateOrganization(id, data) {
+ return this.apiImplementation.updateOrganization(id, data);
+ }
+
+ deleteOrganization(id) {
+ return this.apiImplementation.deleteOrganization(id);
+ }
+
+ getSuppliers() {
+ return this.apiImplementation.getSuppliers();
+ }
+
+ createSupplier(data) {
+ return this.apiImplementation.createSupplier(data);
+ }
+
+ updateSupplier(id, data) {
+ return this.apiImplementation.updateSupplier(id, data);
+ }
+
+ deleteSupplier(id) {
+ return this.apiImplementation.deleteSupplier(id);
+ }
+
+ getCountries() {
+ return this.apiImplementation.getCountries();
+ }
+
+ createCountry(data) {
+ return this.apiImplementation.createCountry(data);
+ }
+
+ updateCountry(id, data) {
+ return this.apiImplementation.updateCountry(id, data);
+ }
+
+ deleteCountry(id) {
+ return this.apiImplementation.deleteCountry(id);
+ }
+
+ getDepartments() {
+ return this.apiImplementation.getDepartments();
+ }
+
+ createDepartment(data) {
+ return this.apiImplementation.createDepartment(data);
+ }
+
+ updateDepartment(id, data) {
+ return this.apiImplementation.updateDepartment(id, data);
+ }
+
+ deleteDepartment(id) {
+ return this.apiImplementation.deleteDepartment(id);
+ }
+
+ getMunicipalities() {
+ return this.apiImplementation.getMunicipalities();
+ }
+
+ createMunicipality(data) {
+ return this.apiImplementation.createMunicipality(data);
+ }
+
+ updateMunicipality(id, data) {
+ return this.apiImplementation.updateMunicipality(id, data);
+ }
+
+ deleteMunicipality(id) {
+ return this.apiImplementation.deleteMunicipality(id);
+ }
+
getPaymentMethods() {
return this.apiImplementation.getPaymentMethods();
}
diff --git a/src/services/django-api.js b/src/services/django-api.js
index 11ab232..ec29b46 100644
--- a/src/services/django-api.js
+++ b/src/services/django-api.js
@@ -18,6 +18,10 @@ class DjangoApi {
return http.patch(url, payload).then((r) => r.data);
}
+ deleteRequest(url) {
+ return http.delete(url).then((r) => r.data);
+ }
+
getCustomers() {
const url = this.base + "/don_confiao/api/customers/";
return this.getRequest(url);
@@ -39,6 +43,111 @@ class DjangoApi {
return this.patchRequest(url, data);
}
+ getProduct(productId) {
+ const url = this.base + `/don_confiao/api/products/${productId}/`;
+ return this.getRequest(url);
+ }
+
+ getOrganizations() {
+ const url = this.base + "/don_confiao/api/organizations/";
+ return this.getRequest(url);
+ }
+
+ createOrganization(data) {
+ const url = this.base + "/don_confiao/api/organizations/";
+ return this.postRequest(url, data);
+ }
+
+ updateOrganization(id, data) {
+ const url = this.base + `/don_confiao/api/organizations/${id}/`;
+ return this.patchRequest(url, data);
+ }
+
+ deleteOrganization(id) {
+ const url = this.base + `/don_confiao/api/organizations/${id}/`;
+ return this.deleteRequest(url);
+ }
+
+ getSuppliers() {
+ const url = this.base + "/don_confiao/api/suppliers/";
+ return this.getRequest(url);
+ }
+
+ createSupplier(data) {
+ const url = this.base + "/don_confiao/api/suppliers/";
+ return this.postRequest(url, data);
+ }
+
+ updateSupplier(id, data) {
+ const url = this.base + `/don_confiao/api/suppliers/${id}/`;
+ return this.patchRequest(url, data);
+ }
+
+ deleteSupplier(id) {
+ const url = this.base + `/don_confiao/api/suppliers/${id}/`;
+ return this.deleteRequest(url);
+ }
+
+ getCountries() {
+ const url = this.base + "/don_confiao/api/countries/";
+ return this.getRequest(url);
+ }
+
+ createCountry(data) {
+ const url = this.base + "/don_confiao/api/countries/";
+ return this.postRequest(url, data);
+ }
+
+ updateCountry(id, data) {
+ const url = this.base + `/don_confiao/api/countries/${id}/`;
+ return this.patchRequest(url, data);
+ }
+
+ deleteCountry(id) {
+ const url = this.base + `/don_confiao/api/countries/${id}/`;
+ return this.deleteRequest(url);
+ }
+
+ getDepartments() {
+ const url = this.base + "/don_confiao/api/departments/";
+ return this.getRequest(url);
+ }
+
+ createDepartment(data) {
+ const url = this.base + "/don_confiao/api/departments/";
+ return this.postRequest(url, data);
+ }
+
+ updateDepartment(id, data) {
+ const url = this.base + `/don_confiao/api/departments/${id}/`;
+ return this.patchRequest(url, data);
+ }
+
+ deleteDepartment(id) {
+ const url = this.base + `/don_confiao/api/departments/${id}/`;
+ return this.deleteRequest(url);
+ }
+
+ getMunicipalities() {
+ const url = this.base + "/don_confiao/api/municipalities/";
+ return this.getRequest(url);
+ }
+
+ createMunicipality(data) {
+ const url = this.base + "/don_confiao/api/municipalities/";
+ return this.postRequest(url, data);
+ }
+
+ updateMunicipality(id, data) {
+ const url = this.base + `/don_confiao/api/municipalities/${id}/`;
+ return this.patchRequest(url, data);
+ }
+
+ deleteMunicipality(id) {
+ const url = this.base + `/don_confiao/api/municipalities/${id}/`;
+ return this.deleteRequest(url);
+ }
+
getPaymentMethods() {
const url =
this.base + "/don_confiao/payment_methods/all/select_format";
diff --git a/tests/unit/components/NavBar.spec.js b/tests/unit/components/NavBar.spec.js
new file mode 100644
index 0000000..a31230c
--- /dev/null
+++ b/tests/unit/components/NavBar.spec.js
@@ -0,0 +1,80 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+import NavBar from '@/components/NavBar.vue'
+import vuetify from '@/plugins/vuetify'
+
+import AuthService from '@/services/auth'
+
+vi.mock('@/services/auth', () => ({
+ default: {
+ isAuthenticated: vi.fn(),
+ logout: vi.fn(),
+ },
+}))
+
+function mountNavBar (api) {
+ const pinia = createPinia()
+ setActivePinia(pinia)
+ const wrapper = mount(NavBar, {
+ global: {
+ plugins: [pinia, vuetify],
+ provide: { api },
+ mocks: { $router: { push: vi.fn() } },
+ stubs: {
+ VAppBar: { template: ' ' },
+ VNavigationDrawer: { template: ' ' },
+ },
+ },
+ })
+ return { wrapper }
+}
+
+describe('NavBar', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ localStorage.clear()
+ })
+
+ it('sin sesión muestra el botón de login y no el menú de administración', async () => {
+ AuthService.isAuthenticated.mockReturnValue(false)
+ const { wrapper } = mountNavBar({ getCurrentUser: vi.fn() })
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Login')
+ expect(wrapper.text()).not.toContain('Administracion')
+ })
+
+ it('el administrador ve los ítems de administración de provenance', async () => {
+ AuthService.isAuthenticated.mockReturnValue(true)
+ const api = {
+ getCurrentUser: vi.fn().mockResolvedValue({ username: 'admin', role: 'administrator' }),
+ }
+ const { wrapper } = mountNavBar(api)
+ await flushPromises()
+
+ expect(api.getCurrentUser).toHaveBeenCalled()
+ expect(wrapper.text()).toContain('Administracion')
+
+ const adminItem = wrapper.findAll('.v-list-item')
+ .find(item => item.text().includes('Administracion'))
+ await adminItem.trigger('click')
+
+ expect(wrapper.text()).toContain('Organizaciones')
+ expect(wrapper.text()).toContain('Proveedores')
+ expect(wrapper.text()).toContain('Geografía')
+ })
+
+ it('el usuario público no ve el menú de administración y no ve "Comprar"', async () => {
+ AuthService.isAuthenticated.mockReturnValue(true)
+ const api = {
+ getCurrentUser: vi.fn().mockResolvedValue({ username: 'cliente', role: 'publico' }),
+ }
+ const { wrapper } = mountNavBar(api)
+ await flushPromises()
+
+ expect(wrapper.text()).not.toContain('Administracion')
+ expect(wrapper.text()).not.toContain('Comprar')
+ expect(wrapper.text()).toContain('Ver Catálogo')
+ })
+})
diff --git a/tests/unit/components/PublicOrderSummary.spec.js b/tests/unit/components/PublicOrderSummary.spec.js
index d4bb791..45ea149 100644
--- a/tests/unit/components/PublicOrderSummary.spec.js
+++ b/tests/unit/components/PublicOrderSummary.spec.js
@@ -6,8 +6,15 @@ import OrderCustomer from '@/components/order/OrderCustomer.vue'
import OrderLines from '@/components/order/OrderLines.vue'
import OrderTotal from '@/components/order/OrderTotal.vue'
import OrderPayment from '@/components/order/OrderPayment.vue'
+import ProvenanceSection from '@/components/provenance/ProvenanceSection.vue'
import vuetify from '@/plugins/vuetify'
+const ProvenanceSectionStub = {
+ name: 'ProvenanceSection',
+ props: ['provenance'],
+ template: '',
+}
+
const saleData = {
id: 5,
code: 'abc123',
@@ -33,7 +40,10 @@ const catalogData = {
function mountSummary (props) {
return mount(PublicOrderSummary, {
props,
- global: { plugins: [vuetify] },
+ global: {
+ plugins: [vuetify],
+ stubs: { ProvenanceSection: ProvenanceSectionStub },
+ },
})
}
@@ -87,4 +97,28 @@ describe('PublicOrderSummary', () => {
expect(wrapper.text()).not.toContain('Camilo')
expect(wrapper.findComponent(OrderLines).exists()).toBe(false)
})
+
+ it('no renderiza la sección de provenance cuando la respuesta no la incluye', () => {
+ const wrapper = mountSummary({ purchase: saleData })
+
+ expect(wrapper.findComponent(ProvenanceSection).exists()).toBe(false)
+ })
+
+ it('renderiza la sección de provenance cuando el resumen la incluye', () => {
+ const withProvenance = {
+ ...saleData,
+ product_provenance: [
+ {
+ product: { id: 10, name: 'Panela' },
+ suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, organization: { id: 3, name: 'Red' }, municipality: { id: 7, name: 'La Mesa' }, department: { id: 2, name: 'Cundinamarca' }, country: { id: 1, name: 'Colombia', code: 'CO' } }],
+ },
+ ],
+ }
+ const wrapper = mountSummary({ purchase: withProvenance })
+
+ expect(wrapper.findComponent(ProvenanceSection).exists()).toBe(true)
+ expect(wrapper.findComponent(ProvenanceSection).props('provenance')).toEqual(
+ withProvenance.product_provenance
+ )
+ })
})
diff --git a/tests/unit/components/graph/VisChart.spec.js b/tests/unit/components/graph/VisChart.spec.js
new file mode 100644
index 0000000..38b6a02
--- /dev/null
+++ b/tests/unit/components/graph/VisChart.spec.js
@@ -0,0 +1,157 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import VisChart from '@/components/graph/VisChart.vue'
+
+const vis = vi.hoisted(() => {
+ const networkInstance = {
+ on: vi.fn(),
+ once: vi.fn(),
+ setData: vi.fn(),
+ setOptions: vi.fn(),
+ fit: vi.fn(),
+ destroy: vi.fn(),
+ getPositions: vi.fn(() => ({})),
+ moveNode: vi.fn(),
+ }
+ const dataSetInstance = {
+ get: vi.fn(),
+ }
+ return {
+ networkInstance,
+ dataSetInstance,
+ Network: vi.fn(() => networkInstance),
+ DataSet: vi.fn(() => dataSetInstance),
+ }
+})
+
+vi.mock('vis-network/standalone', () => ({
+ Network: vis.Network,
+ DataSet: vis.DataSet,
+}))
+
+function handlerFor (mock, eventName) {
+ const call = mock.mock.calls.find(args => args[0] === eventName)
+ return call ? call[1] : null
+}
+
+describe('VisChart', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('crea la red con los nodos y aristas recibidos', () => {
+ const nodes = [{ id: 'product:1', label: 'Panela' }]
+ const edges = [{ from: 'product:1', to: 'supplier:5', dashes: true }]
+ mount(VisChart, { props: { nodes, edges } })
+
+ expect(vis.Network).toHaveBeenCalledTimes(1)
+ const [container, , options] = vis.Network.mock.calls[0]
+ expect(container).toBeInstanceOf(HTMLElement)
+ expect(options.physics.stabilization.enabled).toBe(true)
+ expect(vis.dataSetInstance.get).toBeDefined()
+ const data = vis.networkInstance.setData.mock.calls[0][0]
+ expect(data.nodes).toBe(vis.dataSetInstance)
+ expect(data.edges).toBe(vis.dataSetInstance)
+ })
+
+ it('mezcla las opciones base con las recibidas', () => {
+ mount(VisChart, {
+ props: {
+ nodes: [],
+ edges: [],
+ options: { nodes: { font: { size: 16 } }, physics: { stabilization: { iterations: 100 } } },
+ },
+ })
+
+ const options = vis.Network.mock.calls[0][2]
+ expect(options.nodes.font.size).toBe(16)
+ expect(options.nodes.shape).toBe('dot')
+ expect(options.physics.stabilization.iterations).toBe(100)
+ expect(options.physics.barnesHut.springLength).toBe(140)
+ })
+
+ it('emite select con el nodo al hacer click', () => {
+ const wrapper = mount(VisChart, { props: { nodes: [{ id: 'supplier:5', kind: 'supplier' }], edges: [] } })
+
+ vis.dataSetInstance.get.mockReturnValue({ id: 'supplier:5', kind: 'supplier', label: 'La Mesa' })
+ handlerFor(vis.networkInstance.on, 'click')({ nodes: ['supplier:5'] })
+
+ expect(wrapper.emitted('select')[0][0]).toEqual({ id: 'supplier:5', kind: 'supplier', label: 'La Mesa' })
+ })
+
+ it('no emite select cuando el click no cae sobre un nodo', () => {
+ const wrapper = mount(VisChart, { props: { nodes: [], edges: [] } })
+
+ handlerFor(vis.networkInstance.on, 'click')({ nodes: [] })
+
+ expect(wrapper.emitted('select')).toBeUndefined()
+ })
+
+ it('desactiva la física y ajusta la vista cuando la estabilización termina', () => {
+ mount(VisChart, { props: { nodes: [], edges: [] } })
+
+ const onStabilized = handlerFor(vis.networkInstance.once, 'stabilizationIterationsDone')
+ expect(onStabilized).toBeDefined()
+ onStabilized()
+
+ expect(vis.networkInstance.setOptions).toHaveBeenCalledWith({ physics: { enabled: false } })
+ expect(vis.networkInstance.fit).toHaveBeenCalled()
+ })
+
+ it('separa verticalmente los nodos de una misma columna tras estabilizar', () => {
+ vis.networkInstance.getPositions.mockReturnValue({
+ 'supplier:5': { x: 0, y: 100 },
+ 'supplier:6': { x: 0, y: 112 },
+ 'municipality:7': { x: 240, y: 100 },
+ 'product:1': { x: -240, y: 60 },
+ })
+ mount(VisChart, { props: { nodes: [], edges: [], minVerticalSpacing: 70 } })
+
+ const onStabilized = handlerFor(vis.networkInstance.once, 'stabilizationIterationsDone')
+ onStabilized()
+
+ expect(vis.networkInstance.moveNode).toHaveBeenCalledWith('supplier:6', 0, 170)
+ expect(vis.networkInstance.moveNode).not.toHaveBeenCalledWith('supplier:5', 0, expect.anything())
+ expect(vis.networkInstance.moveNode).not.toHaveBeenCalledWith('municipality:7', 240, expect.anything())
+ expect(vis.networkInstance.moveNode).not.toHaveBeenCalledWith('product:1', -240, expect.anything())
+ })
+
+ it('no separa nodos cuando no se indica espaciado vertical mínimo', () => {
+ vis.networkInstance.getPositions.mockReturnValue({
+ 'supplier:5': { x: 0, y: 100 },
+ 'supplier:6': { x: 0, y: 112 },
+ })
+ mount(VisChart, { props: { nodes: [], edges: [] } })
+
+ const onStabilized = handlerFor(vis.networkInstance.once, 'stabilizationIterationsDone')
+ onStabilized()
+
+ expect(vis.networkInstance.moveNode).not.toHaveBeenCalled()
+ })
+
+ it('vuelve a estabilizar el grafo cuando cambian los nodos o aristas', async () => {
+ const wrapper = mount(VisChart, { props: { nodes: [{ id: 'product:1' }], edges: [] } })
+ expect(vis.Network).toHaveBeenCalledTimes(1)
+ expect(vis.networkInstance.destroy).not.toHaveBeenCalled()
+
+ await wrapper.setProps({ nodes: [{ id: 'product:2' }], edges: [{ from: 'product:2', to: 'supplier:5' }] })
+
+ expect(vis.networkInstance.destroy).toHaveBeenCalled()
+ expect(vis.Network).toHaveBeenCalledTimes(2)
+ expect(vis.networkInstance.setData).toHaveBeenCalledTimes(2)
+ })
+
+ it('aplica la altura recibida al contenedor', () => {
+ const wrapper = mount(VisChart, { props: { height: 340 } })
+
+ expect(wrapper.element.style.height).toBe('340px')
+ })
+
+ it('destruye la red al desmontar', () => {
+ const wrapper = mount(VisChart, { props: { nodes: [], edges: [] } })
+
+ wrapper.unmount()
+
+ expect(vis.networkInstance.destroy).toHaveBeenCalled()
+ })
+})
diff --git a/tests/unit/components/provenance/ProvenanceDetailModal.spec.js b/tests/unit/components/provenance/ProvenanceDetailModal.spec.js
new file mode 100644
index 0000000..ecbf0ea
--- /dev/null
+++ b/tests/unit/components/provenance/ProvenanceDetailModal.spec.js
@@ -0,0 +1,88 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import ProvenanceDetailModal from '@/components/provenance/ProvenanceDetailModal.vue'
+import vuetify from '@/plugins/vuetify'
+
+const supplier = {
+ id: 'supplier:5',
+ kind: 'supplier',
+ label: 'Asociación Agropecuaria La Mesa',
+ image: null,
+ entity: {
+ id: 5,
+ name: 'Asociación Agropecuaria La Mesa',
+ description: 'Cooperativa de campesinos',
+ website: 'https://agro.example.org',
+ contact_email: 'contacto@agro.example.org',
+ contact_phone: '300 123 4567',
+ kind: 'supplier',
+ },
+}
+
+const product = {
+ id: 'product:1',
+ kind: 'product',
+ label: 'Panela regional por Kg',
+ image: 'http://localhost/media/panela.jpg',
+ entity: { id: 1, name: 'Panela regional por Kg', kind: 'product' },
+}
+
+function mountModal (props) {
+ return mount(ProvenanceDetailModal, {
+ props,
+ global: { plugins: [vuetify] },
+ })
+}
+
+function bodyText () {
+ return document.body.textContent
+}
+
+describe('ProvenanceDetailModal', () => {
+ it('muestra la información detallada del proveedor seleccionado', () => {
+ mountModal({ visible: true, selected: supplier })
+
+ expect(bodyText()).toContain('Proveedor')
+ expect(bodyText()).toContain('Asociación Agropecuaria La Mesa')
+ expect(bodyText()).toContain('Cooperativa de campesinos')
+ expect(bodyText()).toContain('https://agro.example.org')
+ expect(bodyText()).toContain('contacto@agro.example.org')
+ expect(bodyText()).toContain('300 123 4567')
+ })
+
+ it('muestra la imagen del producto cuando existe', () => {
+ mountModal({ visible: true, selected: product })
+
+ expect(document.querySelector('img').getAttribute('src')).toBe(
+ 'http://localhost/media/panela.jpg'
+ )
+ })
+
+ it('no muestra contenido cuando no hay entidad seleccionada', () => {
+ mountModal({ visible: true, selected: null })
+
+ expect(bodyText()).not.toContain('Proveedor')
+ expect(bodyText()).not.toContain('Asociación')
+ })
+
+ it('no muestra el diálogo cuando visible es false', () => {
+ mountModal({ visible: false, selected: supplier })
+
+ expect(bodyText()).not.toContain('Asociación Agropecuaria La Mesa')
+ })
+
+ it('omite los campos vacíos', () => {
+ const minimal = {
+ id: 'country:1',
+ kind: 'country',
+ label: 'Colombia',
+ image: null,
+ entity: { id: 1, name: 'Colombia', kind: 'country' },
+ }
+ mountModal({ visible: true, selected: minimal })
+
+ expect(bodyText()).toContain('Colombia')
+ expect(bodyText()).not.toContain('Descripción')
+ expect(bodyText()).not.toContain('Sitio web')
+ })
+})
diff --git a/tests/unit/components/provenance/ProvenanceGraph.spec.js b/tests/unit/components/provenance/ProvenanceGraph.spec.js
new file mode 100644
index 0000000..b533934
--- /dev/null
+++ b/tests/unit/components/provenance/ProvenanceGraph.spec.js
@@ -0,0 +1,164 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+import ProvenanceDetailModal from '@/components/provenance/ProvenanceDetailModal.vue'
+import ProvenanceGraph from '@/components/provenance/ProvenanceGraph.vue'
+import vuetify from '@/plugins/vuetify'
+
+vi.mock('@/components/graph/VisChart.vue', () => ({
+ default: {
+ name: 'VisChart',
+ template: '',
+ props: ['nodes', 'edges', 'height', 'options', 'minVerticalSpacing'],
+ },
+}))
+
+const provenance = [
+ {
+ product: {
+ id: 1,
+ name: 'Panela regional por Kg',
+ catalogue_images: ['http://localhost/media/panela.jpg'],
+ },
+ suppliers: [
+ {
+ supplier: { id: 5, name: 'Asociación Agropecuaria La Mesa' },
+ organization: { id: 3, name: 'Red de Economía Solidaria' },
+ municipality: { id: 7, name: 'La Mesa' },
+ department: { id: 2, name: 'Cundinamarca' },
+ country: { id: 1, name: 'Colombia' },
+ },
+ ],
+ },
+]
+
+function mountGraph (props = {}) {
+ return mount(ProvenanceGraph, {
+ props: { provenance, ...props },
+ global: { plugins: [vuetify] },
+ })
+}
+
+function visChart (wrapper) {
+ return wrapper.findComponent({ name: 'VisChart' })
+}
+
+function checkbox (wrapper, label) {
+ return wrapper.findAllComponents({ name: 'VCheckbox' }).find(cb => cb.text().trim() === label)
+}
+
+async function setKinds (wrapper, kinds) {
+ checkbox(wrapper, 'Productos').vm.$emit('update:modelValue', kinds)
+ await nextTick()
+}
+
+describe('ProvenanceGraph', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('por defecto grafica solo productos y proveedores', () => {
+ const wrapper = mountGraph()
+
+ const nodeKinds = visChart(wrapper).props('nodes').map(node => node.kind)
+ expect(nodeKinds).toEqual(['product', 'supplier'])
+ const edges = visChart(wrapper).props('edges')
+ expect(edges.find(edge => edge.from === 'product:1' && edge.to === 'supplier:5').dashes).toBe(false)
+ })
+
+ it('pide separación vertical mínima equivalente al círculo más una línea de label', () => {
+ const wrapper = mountGraph()
+
+ expect(visChart(wrapper).props('minVerticalSpacing')).toBe(70)
+ })
+
+ it('muestra un checkbox por cada nivel con productos y proveedores activos', () => {
+ const wrapper = mountGraph()
+
+ const labels = wrapper.findAllComponents({ name: 'VCheckbox' }).map(cb => cb.text().trim())
+ expect(labels).toEqual(['Productos', 'Proveedores', 'Organizaciones', 'Municipios', 'Departamentos', 'País'])
+ })
+
+ it('dibuja un círculo de color junto a cada checkbox como leyenda', () => {
+ const wrapper = mountGraph()
+
+ const dots = wrapper.findAll('.legend-dot')
+ expect(dots).toHaveLength(6)
+ expect(dots[0].element.style.backgroundColor).toBe('rgb(38, 166, 154)')
+ expect(dots[1].element.style.backgroundColor).toBe('rgb(66, 165, 245)')
+ expect(dots[2].element.style.backgroundColor).toBe('rgb(255, 183, 77)')
+ })
+
+ it('al activar municipios agrega nodos de municipio y la arista proveedor→municipio', async () => {
+ const wrapper = mountGraph()
+
+ await setKinds(wrapper, ['product', 'supplier', 'municipality'])
+
+ const nodeIds = visChart(wrapper).props('nodes').map(node => node.id)
+ expect(nodeIds).toContain('municipality:7')
+ const edges = visChart(wrapper).props('edges')
+ expect(edges.find(edge => edge.from === 'supplier:5' && edge.to === 'municipality:7').dashes).toBe(false)
+ })
+
+ it('con país activo crea el nodo país', async () => {
+ const wrapper = mountGraph()
+
+ await setKinds(wrapper, ['product', 'supplier', 'country'])
+
+ expect(visChart(wrapper).props('nodes').map(node => node.id)).toContain('country:1')
+ })
+
+ it('al desactivar productos no se crean nodos de producto', async () => {
+ const wrapper = mountGraph()
+
+ await setKinds(wrapper, ['supplier', 'municipality', 'department'])
+
+ const nodeKinds = visChart(wrapper).props('nodes').map(node => node.kind)
+ expect(nodeKinds).not.toContain('product')
+ expect(nodeKinds).not.toContain('junction')
+ })
+
+ it('avisa cuando no queda ningún nivel seleccionado', async () => {
+ const wrapper = mountGraph()
+
+ await setKinds(wrapper, [])
+
+ expect(visChart(wrapper).exists()).toBe(false)
+ expect(wrapper.text()).toContain('Selecciona al menos un elemento')
+ })
+
+ it('muestra mensaje de próximamente cuando ningún producto tiene proveedores', () => {
+ const wrapper = mountGraph({
+ provenance: [{ product: { id: 1, name: 'Panela' }, suppliers: [] }],
+ })
+
+ expect(visChart(wrapper).exists()).toBe(false)
+ expect(wrapper.text()).toContain('próximamente')
+ })
+
+ it('abre el modal de detalle al seleccionar un proveedor', async () => {
+ const wrapper = mountGraph()
+
+ await visChart(wrapper).vm.$emit('select', { id: 'supplier:5', kind: 'supplier', label: 'Asociación Agropecuaria La Mesa', entity: { kind: 'supplier' } })
+
+ expect(wrapper.findComponent(ProvenanceDetailModal).props('visible')).toBe(true)
+ expect(document.body.textContent).toContain('Asociación Agropecuaria La Mesa')
+ })
+
+ it('ignora la selección del punto de disyunción', async () => {
+ const provenanceWithTwoSuppliers = [
+ {
+ product: { id: 1, name: 'Panela' },
+ suppliers: [
+ { supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'La Mesa' } },
+ { supplier: { id: 6, name: 'B' }, municipality: { id: 8, name: 'San Antonio' } },
+ ],
+ },
+ ]
+ const wrapper = mountGraph({ provenance: provenanceWithTwoSuppliers })
+
+ await visChart(wrapper).vm.$emit('select', { id: 'junction:1', kind: 'junction', label: '' })
+
+ expect(wrapper.findComponent(ProvenanceDetailModal).props('visible')).toBe(false)
+ })
+})
diff --git a/tests/unit/components/provenance/ProvenanceMap.spec.js b/tests/unit/components/provenance/ProvenanceMap.spec.js
new file mode 100644
index 0000000..b6e6d76
--- /dev/null
+++ b/tests/unit/components/provenance/ProvenanceMap.spec.js
@@ -0,0 +1,402 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+import { createPinia, setActivePinia } from 'pinia'
+import ProvenanceMap from '@/components/provenance/ProvenanceMap.vue'
+import vuetify from '@/plugins/vuetify'
+
+const leaflet = vi.hoisted(() => {
+ const markers = []
+ const polylines = []
+ const layers = []
+ function makeLayer (type) {
+ const layer = {
+ type,
+ addTo: vi.fn(function () { return this }),
+ on: vi.fn(),
+ bindTooltip: vi.fn(function () { return this }),
+ bindPopup: vi.fn(function () { return this }),
+ setPopupContent: vi.fn(),
+ openPopup: vi.fn(),
+ closePopup: vi.fn(),
+ remove: vi.fn(),
+ getLatLng: vi.fn(function () { return this.pos }),
+ setLatLng: vi.fn(),
+ }
+ layers.push(layer)
+ return layer
+ }
+ const map = {
+ setView: vi.fn(function () { return this }),
+ fitBounds: vi.fn(),
+ flyTo: vi.fn(),
+ remove: vi.fn(),
+ invalidateSize: vi.fn(),
+ on: vi.fn(),
+ getZoom: vi.fn(() => 8),
+ }
+ const bounds = { extend: vi.fn(), isValid: vi.fn(() => true) }
+ return {
+ markers,
+ polylines,
+ layers,
+ map,
+ bounds,
+ L: {
+ map: vi.fn(() => map),
+ tileLayer: vi.fn(() => makeLayer('tileLayer')),
+ marker: vi.fn((pos, opts) => {
+ const marker = makeLayer('marker')
+ marker.pos = pos
+ marker.opts = opts
+ markers.push(marker)
+ return marker
+ }),
+ divIcon: vi.fn(opts => opts),
+ polyline: vi.fn((points, opts) => {
+ const polyline = makeLayer('polyline')
+ polyline.points = points
+ polyline.opts = opts
+ polylines.push(polyline)
+ return polyline
+ }),
+ latLngBounds: vi.fn(() => bounds),
+ },
+ }
+})
+
+vi.mock('leaflet', () => ({ default: leaflet.L }))
+
+const settings = { latitude: 4.6, longitude: -74.08, address: 'Cra 1 #2-3' }
+
+const provenance = [
+ {
+ product: { id: 1, name: 'Panela regional', catalogue_images: ['http://localhost/media/panela.jpg'] },
+ suppliers: [
+ {
+ supplier: { id: 5, name: 'Asociación La Mesa' },
+ organization: { id: 3, name: 'Red de Economía Solidaria' },
+ municipality: { id: 7, name: 'Santa Bárbara', latitude: 6.23, longitude: -75.56 },
+ },
+ ],
+ },
+ {
+ product: { id: 2, name: 'Arroz blanco', catalogue_images: [] },
+ suppliers: [
+ { supplier: { id: 6, name: 'Campesinos del Oriente' }, municipality: { id: 8, name: 'La Mesa', latitude: 4.86, longitude: -74.63 } },
+ { supplier: { id: 7, name: 'Finca El Paraíso' }, municipality: { id: 9, name: 'San Antonio', latitude: 6.22, longitude: -75.57 } },
+ ],
+ },
+]
+
+const sameMuni = [
+ {
+ product: { id: 1, name: 'Panela', catalogue_images: [] },
+ suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
+ },
+ {
+ product: { id: 2, name: 'Arroz', catalogue_images: [] },
+ suppliers: [{ supplier: { id: 6, name: 'B' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
+ },
+]
+
+function mountMap (props = {}) {
+ const api = { getStoreSettings: vi.fn().mockResolvedValue(settings) }
+ const pinia = createPinia()
+ setActivePinia(pinia)
+ const wrapper = mount(ProvenanceMap, {
+ props: { provenance, ...props },
+ global: {
+ plugins: [pinia, vuetify],
+ provide: { api },
+ },
+ })
+ return { api, wrapper }
+}
+
+function handlerOf (marker, event) {
+ return marker.on.mock.calls.find(args => args[0] === event)[1]
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ leaflet.markers.length = 0
+ leaflet.polylines.length = 0
+ leaflet.layers.length = 0
+ document.body.innerHTML = ''
+})
+
+describe('ProvenanceMap', () => {
+ it('carga la configuración de la tienda al montarse', async () => {
+ const { api } = mountMap()
+ await flushPromises()
+
+ expect(api.getStoreSettings).toHaveBeenCalled()
+ })
+
+ it('crea el mapa con la tienda y un marcador por municipio de origen', async () => {
+ mountMap()
+ await flushPromises()
+
+ expect(leaflet.L.map).toHaveBeenCalledTimes(1)
+ expect(leaflet.L.tileLayer).toHaveBeenCalledTimes(1)
+ expect(leaflet.markers).toHaveLength(4)
+ expect(leaflet.map.fitBounds).toHaveBeenCalled()
+ expect(leaflet.markers[0].bindTooltip).toHaveBeenCalled()
+ })
+
+ it('al pasar sobre un producto dibuja una línea hasta la tienda', async () => {
+ mountMap()
+ await flushPromises()
+
+ handlerOf(leaflet.markers[1], 'mouseover')()
+
+ expect(leaflet.L.polyline).toHaveBeenCalledTimes(1)
+ expect(leaflet.L.polyline.mock.calls[0][0]).toEqual([[6.23, -75.56], [4.6, -74.08]])
+ expect(leaflet.polylines[0].addTo).toHaveBeenCalled()
+ })
+
+ it('al pasar sobre la tienda dibuja las líneas de todos los productos', async () => {
+ mountMap()
+ await flushPromises()
+
+ handlerOf(leaflet.markers[0], 'mouseover')()
+
+ expect(leaflet.L.polyline).toHaveBeenCalledTimes(3)
+ })
+
+ it('al salir del marcador se limpian las líneas', async () => {
+ mountMap()
+ await flushPromises()
+
+ handlerOf(leaflet.markers[1], 'mouseover')()
+ expect(leaflet.polylines).toHaveLength(1)
+ handlerOf(leaflet.markers[1], 'mouseout')()
+
+ expect(leaflet.polylines[0].remove).toHaveBeenCalled()
+ })
+
+ it('muestra el detalle del producto en un popup sobre el marcador', async () => {
+ mountMap()
+ await flushPromises()
+
+ const marker = leaflet.markers[1]
+ expect(marker.bindPopup).toHaveBeenCalledTimes(1)
+ const popupEl = marker.bindPopup.mock.calls[0][0]
+ expect(popupEl.textContent).toContain('Panela regional')
+ expect(popupEl.textContent).toContain('Asociación La Mesa')
+ expect(popupEl.textContent).toContain('Santa Bárbara')
+ })
+
+ it('no abre el modal web para productos con ubicación', async () => {
+ mountMap()
+ await flushPromises()
+
+ expect(document.body.textContent).not.toContain('Proveedor')
+ })
+
+ it('no dibuja la línea de la tienda cuando la tienda no tiene coordenadas', async () => {
+ const api = { getStoreSettings: vi.fn().mockResolvedValue({ address: 'Cra 1' }) }
+ const pinia = createPinia()
+ setActivePinia(pinia)
+ mount(ProvenanceMap, {
+ props: { provenance },
+ global: {
+ plugins: [pinia, vuetify],
+ provide: { api },
+ },
+ })
+ await flushPromises()
+
+ expect(leaflet.markers).toHaveLength(3)
+ handlerOf(leaflet.markers[0], 'mouseover')()
+ expect(leaflet.L.polyline).not.toHaveBeenCalled()
+ })
+
+ it('muestra un aviso cuando ningún municipio tiene coordenadas', async () => {
+ const noCoords = [
+ { product: { id: 1, name: 'Panela' }, suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'M' } }] },
+ ]
+ const { wrapper } = mountMap({ provenance: noCoords })
+ await flushPromises()
+
+ expect(leaflet.L.map).not.toHaveBeenCalled()
+ expect(wrapper.text()).toContain('coordenadas')
+ })
+
+ it('mantiene la posición exacta del municipio aunque varios productos coincidan', async () => {
+ mountMap({ provenance: sameMuni })
+ await flushPromises()
+
+ expect(leaflet.markers).toHaveLength(2)
+ expect(leaflet.markers[1].pos).toEqual([4.631028, -74.461588])
+ expect(leaflet.map.on).not.toHaveBeenCalledWith('zoomend', expect.anything())
+ })
+
+ it('agrupa los productos del mismo punto en un marcador con contador', async () => {
+ mountMap({ provenance: sameMuni })
+ await flushPromises()
+
+ const groupIcon = leaflet.L.divIcon.mock.calls.find(args => args[0].className === 'provenance-group-pin')
+ expect(groupIcon).toBeDefined()
+ expect(groupIcon[0].html).toContain('2')
+ expect(groupIcon[0].html).toContain('mdi-package-variant')
+ const groupMarker = leaflet.markers[1]
+ expect(groupMarker.bindPopup).toHaveBeenCalled()
+ expect(groupMarker.bindTooltip.mock.calls[0][0]).toContain('2 productos')
+ })
+
+ it('al hacer clic en el marcador agrupado despliega los productos internos', async () => {
+ mountMap({ provenance: sameMuni })
+ await flushPromises()
+
+ const popupEl = leaflet.markers[1].bindPopup.mock.calls[0][0]
+ expect(popupEl.textContent).toContain('Panela')
+ expect(popupEl.textContent).toContain('Arroz')
+ })
+
+ it('al hacer clic en un producto del popup agrupado muestra su detalle sin cerrar el popup ni propagar el clic al mapa', async () => {
+ mountMap({ provenance: sameMuni })
+ await flushPromises()
+
+ const groupMarker = leaflet.markers[1]
+ const popupEl = groupMarker.bindPopup.mock.calls[0][0]
+ let bubbled = false
+ popupEl.addEventListener('click', () => { bubbled = true })
+ const item = popupEl.querySelector('[data-test="map-popup-item-1"]')
+ item.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+
+ expect(bubbled).toBe(false)
+ expect(groupMarker.closePopup).not.toHaveBeenCalled()
+ expect(groupMarker.setPopupContent).toHaveBeenCalledTimes(1)
+ const detailEl = groupMarker.setPopupContent.mock.calls[0][0]
+ expect(detailEl.textContent).toContain('Arroz')
+ expect(detailEl.textContent).toContain('B')
+ expect(groupMarker.openPopup).toHaveBeenCalled()
+ })
+
+ it('al pasar sobre la tienda dibuja una línea por cada producto agrupado', async () => {
+ mountMap({ provenance: sameMuni })
+ await flushPromises()
+
+ handlerOf(leaflet.markers[0], 'mouseover')()
+
+ expect(leaflet.L.polyline).toHaveBeenCalledTimes(2)
+ })
+
+ it('al pasar sobre el marcador agrupado dibuja las líneas de sus productos', async () => {
+ mountMap({ provenance: sameMuni })
+ await flushPromises()
+
+ handlerOf(leaflet.markers[1], 'mouseover')()
+
+ expect(leaflet.L.polyline).toHaveBeenCalledTimes(2)
+ expect(leaflet.polylines[0].points[0]).toEqual([4.631028, -74.461588])
+ })
+
+ it('muestra un recuadro con todos los productos, incluidos los sin geolocalización', async () => {
+ const withNoGeo = [
+ {
+ product: { id: 1, name: 'Panela regional', catalogue_images: ['http://localhost/media/panela.jpg'] },
+ suppliers: [{ supplier: { id: 5, name: 'Asociación La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara', latitude: 6.23, longitude: -75.56 } }],
+ },
+ {
+ product: { id: 2, name: 'Café de montaña', catalogue_images: [] },
+ suppliers: [{ supplier: { id: 8, name: 'Caficultores del Valle' }, municipality: null }],
+ },
+ {
+ product: { id: 3, name: 'Miel pura', catalogue_images: [] },
+ suppliers: [],
+ },
+ ]
+ const { wrapper } = mountMap({ provenance: withNoGeo })
+ await flushPromises()
+
+ const rows = wrapper.findAll('[data-test="map-product-row"]')
+ expect(rows).toHaveLength(3)
+ expect(rows.map(row => row.text().replace(/\s+/g, ' ').trim())).toEqual([
+ expect.stringContaining('Panela regional'),
+ expect.stringContaining('Café de montaña'),
+ expect.stringContaining('Miel pura'),
+ ])
+ expect(rows[1].text()).toContain('Sin geolocalización')
+ expect(rows[2].text()).toContain('Sin geolocalización')
+ expect(rows[0].text()).toContain('Santa Bárbara')
+ })
+
+ it('al hacer clic en un producto con ubicación centra el mapa y abre su detalle en el marcador', async () => {
+ const { wrapper } = mountMap()
+ await flushPromises()
+
+ await wrapper.find('[data-test="map-product-row"]').trigger('click')
+ await nextTick()
+
+ expect(leaflet.map.flyTo).toHaveBeenCalledTimes(1)
+ expect(leaflet.map.flyTo.mock.calls[0][0]).toEqual([6.23, -75.56])
+ const marker = leaflet.markers[1]
+ expect(marker.setPopupContent).toHaveBeenCalled()
+ expect(marker.setPopupContent.mock.calls[0][0].textContent).toContain('Panela regional')
+ expect(marker.setPopupContent.mock.calls[0][0].textContent).toContain('Asociación La Mesa')
+ expect(marker.openPopup).toHaveBeenCalled()
+ expect(document.body.textContent).not.toContain('Proveedor')
+ })
+
+ it('al hacer clic en un producto sin geolocalización abre el diálogo con la información disponible sin centrar el mapa', async () => {
+ const withNoGeo = [
+ {
+ product: { id: 1, name: 'Panela regional', catalogue_images: [] },
+ suppliers: [{ supplier: { id: 5, name: 'Asociación La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara', latitude: 6.23, longitude: -75.56 } }],
+ },
+ {
+ product: { id: 2, name: 'Café de montaña', catalogue_images: [] },
+ suppliers: [{ supplier: { id: 8, name: 'Caficultores del Valle' }, municipality: null }],
+ },
+ ]
+ const { wrapper } = mountMap({ provenance: withNoGeo })
+ await flushPromises()
+
+ const noGeoRow = wrapper.findAll('[data-test="map-product-row"]').find(row => row.text().includes('Café de montaña'))
+ await noGeoRow.trigger('click')
+ await nextTick()
+
+ expect(leaflet.map.flyTo).not.toHaveBeenCalled()
+ expect(document.body.textContent).toContain('Café de montaña')
+ expect(document.body.textContent).toContain('Caficultores del Valle')
+ expect(document.body.textContent).toContain('sin geolocalización')
+ })
+
+ it('al hacer clic en el botón de zoom general vuelve a encuadrar todos los marcadores', async () => {
+ const { wrapper } = mountMap()
+ await flushPromises()
+ leaflet.map.fitBounds.mockClear()
+
+ await wrapper.find('[data-test="map-reset-zoom"]').trigger('click')
+
+ expect(leaflet.map.fitBounds).toHaveBeenCalledTimes(1)
+ })
+
+ it('lee las coordenadas string del backend y da fondo visible al producto sin imagen', async () => {
+ const backendProvenance = [
+ {
+ product: { id: 110, name: 'Panela condimentada 150 grs', catalogue_images: [] },
+ suppliers: [
+ {
+ supplier: { id: 4, name: 'Asociación Agropecuaria La Mesa' },
+ organization: null,
+ municipality: { id: 2653, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' },
+ department: { id: 91, name: 'Cundinamarca' },
+ country: { id: 3, name: 'Colombia', code: 'CO' },
+ },
+ ],
+ },
+ ]
+ mountMap({ provenance: backendProvenance })
+ await flushPromises()
+
+ expect(leaflet.L.map).toHaveBeenCalledTimes(1)
+ expect(leaflet.markers).toHaveLength(2)
+ expect(leaflet.markers[1].pos).toEqual([4.631028, -74.461588])
+ const productIcon = leaflet.L.divIcon.mock.calls.find(args => args[0].html.includes('provenance-product-fallback'))
+ expect(productIcon).toBeDefined()
+ })
+})
diff --git a/tests/unit/components/provenance/ProvenanceRelationModal.spec.js b/tests/unit/components/provenance/ProvenanceRelationModal.spec.js
new file mode 100644
index 0000000..575ba3d
--- /dev/null
+++ b/tests/unit/components/provenance/ProvenanceRelationModal.spec.js
@@ -0,0 +1,96 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import ProvenanceRelationModal from '@/components/provenance/ProvenanceRelationModal.vue'
+import vuetify from '@/plugins/vuetify'
+
+const relation = {
+ supplier: { id: 5, name: 'Asociación La Mesa', contact_email: 'info@mesa.co' },
+ organization: { id: 3, name: 'Red de Economía Solidaria' },
+ municipality: { id: 7, name: 'Santa Bárbara' },
+ department: { id: 2, name: 'Antioquia' },
+ country: { id: 1, name: 'Colombia', code: 'CO' },
+}
+const product = {
+ id: 1,
+ name: 'Panela regional',
+ catalogue_images: ['http://localhost/media/panela.jpg'],
+}
+
+function mountModal (props = {}) {
+ return mount(ProvenanceRelationModal, {
+ props: { product, relation, visible: true, ...props },
+ global: { plugins: [vuetify] },
+ })
+}
+
+function bodyText () {
+ return document.body.textContent
+}
+
+describe('ProvenanceRelationModal', () => {
+ it('muestra el producto y todas las entidades de la relación', () => {
+ mountModal()
+
+ expect(bodyText()).toContain('Panela regional')
+ expect(bodyText()).toContain('Proveedor')
+ expect(bodyText()).toContain('Asociación La Mesa')
+ expect(bodyText()).toContain('Organización')
+ expect(bodyText()).toContain('Red de Economía Solidaria')
+ expect(bodyText()).toContain('Municipio')
+ expect(bodyText()).toContain('Santa Bárbara')
+ expect(bodyText()).toContain('Departamento')
+ expect(bodyText()).toContain('Antioquia')
+ expect(bodyText()).toContain('País')
+ expect(bodyText()).toContain('Colombia')
+ })
+
+ it('incluye los detalles de contacto del proveedor', () => {
+ mountModal()
+
+ expect(bodyText()).toContain('info@mesa.co')
+ })
+
+ it('muestra la imagen del producto cuando existe', () => {
+ mountModal()
+
+ expect(document.querySelector('img').getAttribute('src')).toBe(
+ 'http://localhost/media/panela.jpg'
+ )
+ })
+
+ it('no muestra las entidades ausentes', () => {
+ mountModal({ relation: { supplier: { id: 5, name: 'Asociación La Mesa' } } })
+
+ expect(bodyText()).not.toContain('Organización')
+ expect(bodyText()).not.toContain('Municipio')
+ expect(bodyText()).not.toContain('Departamento')
+ expect(bodyText()).not.toContain('País')
+ })
+
+ it('muestra el producto y un aviso cuando no hay proveedor vinculado', () => {
+ mountModal({ relation: null })
+
+ expect(bodyText()).toContain('Panela regional')
+ expect(bodyText()).toContain('no se ha vinculado un proveedor')
+ })
+
+ it('no muestra contenido cuando no hay producto ni relación', () => {
+ mountModal({ relation: null, product: null })
+
+ expect(bodyText()).not.toContain('Proveedor')
+ expect(bodyText()).not.toContain('Producto')
+ })
+
+ it('indica que aún no tiene geolocalización cuando la relación no tiene municipio', () => {
+ mountModal({ relation: { supplier: { id: 5, name: 'Asociación La Mesa' } } })
+
+ expect(bodyText()).toContain('Asociación La Mesa')
+ expect(bodyText()).toContain('sin geolocalización')
+ })
+
+ it('no muestra el diálogo cuando visible es false', () => {
+ mountModal({ visible: false })
+
+ expect(bodyText()).not.toContain('Asociación La Mesa')
+ })
+})
diff --git a/tests/unit/components/provenance/ProvenanceSection.spec.js b/tests/unit/components/provenance/ProvenanceSection.spec.js
new file mode 100644
index 0000000..adc5892
--- /dev/null
+++ b/tests/unit/components/provenance/ProvenanceSection.spec.js
@@ -0,0 +1,127 @@
+import { describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import ProvenanceGraph from '@/components/provenance/ProvenanceGraph.vue'
+import ProvenanceSection from '@/components/provenance/ProvenanceSection.vue'
+import vuetify from '@/plugins/vuetify'
+
+vi.mock('@/components/graph/VisChart.vue', () => ({
+ default: {
+ name: 'VisChart',
+ template: '',
+ props: ['nodes', 'edges', 'height', 'options', 'minVerticalSpacing'],
+ },
+}))
+
+const provenance = [
+ {
+ product: { id: 1, name: 'Panela regional por Kg' },
+ suppliers: [
+ {
+ supplier: { id: 5, name: 'Asociación La Mesa' },
+ organization: { id: 3, name: 'Red de Economía Solidaria' },
+ municipality: { id: 7, name: 'La Mesa' },
+ department: { id: 2, name: 'Cundinamarca' },
+ country: { id: 1, name: 'Colombia', code: 'CO' },
+ },
+ ],
+ },
+]
+
+function mountSection (props = {}) {
+ return mount(ProvenanceSection, {
+ props: { provenance, ...props },
+ global: { plugins: [vuetify], stubs: { ProvenanceMap: true } },
+ })
+}
+
+function mapWrapper (wrapper) {
+ return wrapper.findComponent({ name: 'ProvenanceMap' })
+}
+
+describe('ProvenanceSection', () => {
+ it('no renderiza nada cuando no hay provenance', () => {
+ const wrapper = mountSection({ provenance: null })
+
+ expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false)
+ })
+
+ it('no renderiza nada cuando provenance es una lista vacía', () => {
+ const wrapper = mountSection({ provenance: [] })
+
+ expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false)
+ })
+
+ it('muestra el título y oculta el gráfico por defecto', () => {
+ const wrapper = mountSection()
+
+ expect(wrapper.text()).toContain('Origen de los productos')
+ expect(wrapper.text()).not.toContain('historia')
+ expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false)
+ })
+
+ it('muestra el gráfico al hacer clic en el título y lo oculta al volver a hacer clic', async () => {
+ const wrapper = mountSection()
+
+ await wrapper.find('[data-test="provenance-toggle"]').trigger('click')
+ expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(true)
+
+ await wrapper.find('[data-test="provenance-toggle"]').trigger('click')
+ expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false)
+ })
+
+ it('también despliega y repliega con el botón de chevron', async () => {
+ const wrapper = mountSection()
+
+ const button = wrapper.find('[data-test="provenance-toggle-button"]')
+ expect(button.exists()).toBe(true)
+ await button.trigger('click')
+ expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(true)
+
+ await button.trigger('click')
+ expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false)
+ })
+
+ it('pasa el provenance al gráfico al desplegarlo', async () => {
+ const wrapper = mountSection()
+
+ await wrapper.find('[data-test="provenance-toggle"]').trigger('click')
+
+ expect(wrapper.findComponent(ProvenanceGraph).props('provenance')).toStrictEqual(provenance)
+ })
+
+ it('oculta el mapa por defecto', () => {
+ const wrapper = mountSection()
+
+ expect(mapWrapper(wrapper).exists()).toBe(false)
+ })
+
+ it('muestra el mapa al hacer clic en su título y lo oculta al volver a hacer clic', async () => {
+ const wrapper = mountSection()
+
+ await wrapper.find('[data-test="map-toggle"]').trigger('click')
+ expect(mapWrapper(wrapper).exists()).toBe(true)
+
+ await wrapper.find('[data-test="map-toggle"]').trigger('click')
+ expect(mapWrapper(wrapper).exists()).toBe(false)
+ })
+
+ it('también despliega y repliega el mapa con el botón de chevron', async () => {
+ const wrapper = mountSection()
+
+ const button = wrapper.find('[data-test="map-toggle-button"]')
+ expect(button.exists()).toBe(true)
+ await button.trigger('click')
+ expect(mapWrapper(wrapper).exists()).toBe(true)
+
+ await button.trigger('click')
+ expect(mapWrapper(wrapper).exists()).toBe(false)
+ })
+
+ it('pasa el provenance al mapa al desplegarlo', async () => {
+ const wrapper = mountSection()
+
+ await wrapper.find('[data-test="map-toggle"]').trigger('click')
+
+ expect(mapWrapper(wrapper).props('provenance')).toStrictEqual(provenance)
+ })
+})
diff --git a/tests/unit/components/provenance/admin/GeographyManagement.spec.js b/tests/unit/components/provenance/admin/GeographyManagement.spec.js
new file mode 100644
index 0000000..efb8bb9
--- /dev/null
+++ b/tests/unit/components/provenance/admin/GeographyManagement.spec.js
@@ -0,0 +1,114 @@
+import { describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import GeographyManagement from '@/components/provenance/admin/GeographyManagement.vue'
+import vuetify from '@/plugins/vuetify'
+import { clickBody, setBodyInput } from './helpers'
+
+const countries = [
+ { id: 1, name: 'Colombia', code: 'CO' },
+ { id: 2, name: 'Venezuela', code: 'VE' },
+]
+
+const departments = [
+ { id: 2, name: 'Cundinamarca', country: 1, country_detail: { id: 1, name: 'Colombia', code: 'CO' } },
+ { id: 3, name: 'Antioquia', country: 1, country_detail: { id: 1, name: 'Colombia', code: 'CO' } },
+]
+
+const municipalities = [
+ { id: 7, name: 'La Mesa', department: 2, country: 1, department_detail: { id: 2, name: 'Cundinamarca' } },
+ { id: 8, name: 'San Antonio', department: 3, country: 1, department_detail: { id: 3, name: 'Antioquia' } },
+]
+
+function mockApi () {
+ return {
+ getCountries: vi.fn().mockResolvedValue(countries),
+ createCountry: vi.fn().mockResolvedValue({}),
+ updateCountry: vi.fn().mockResolvedValue({}),
+ deleteCountry: vi.fn().mockResolvedValue({}),
+ getDepartments: vi.fn().mockResolvedValue(departments),
+ createDepartment: vi.fn().mockResolvedValue({}),
+ updateDepartment: vi.fn().mockResolvedValue({}),
+ deleteDepartment: vi.fn().mockResolvedValue({}),
+ getMunicipalities: vi.fn().mockResolvedValue(municipalities),
+ createMunicipality: vi.fn().mockResolvedValue({}),
+ updateMunicipality: vi.fn().mockResolvedValue({}),
+ deleteMunicipality: vi.fn().mockResolvedValue({}),
+ }
+}
+
+function mountComponent (api) {
+ return mount(GeographyManagement, {
+ global: { plugins: [vuetify], provide: { api } },
+ })
+}
+
+describe('GeographyManagement', () => {
+ it('muestra los países por defecto y permite crear uno', async () => {
+ const api = mockApi()
+ const wrapper = mountComponent(api)
+ await flushPromises()
+
+ expect(api.getCountries).toHaveBeenCalled()
+ expect(wrapper.text()).toContain('Colombia')
+
+ await wrapper.find('[data-testid="geo-country-create"]').trigger('click')
+ setBodyInput('[data-testid="geo-country-form-name"] input', 'Ecuador')
+ setBodyInput('[data-testid="geo-country-form-code"] input', 'EC')
+ clickBody('[data-testid="geo-country-save"]')
+ await flushPromises()
+
+ expect(api.createCountry).toHaveBeenCalledWith({ name: 'Ecuador', code: 'EC' })
+ expect(api.getCountries).toHaveBeenCalledTimes(2)
+ })
+
+ it('cambia a la pestaña de departamentos y crea uno', async () => {
+ const api = mockApi()
+ const wrapper = mountComponent(api)
+ await flushPromises()
+
+ await wrapper.find('[data-testid="geo-tab-departments"]').trigger('click')
+ await flushPromises()
+
+ expect(api.getDepartments).toHaveBeenCalled()
+ expect(wrapper.text()).toContain('Cundinamarca')
+
+ await wrapper.find('[data-testid="geo-department-create"]').trigger('click')
+ setBodyInput('[data-testid="geo-department-form-name"] input', 'Risaralda')
+ const countrySelect = wrapper.findAllComponents({ name: 'VSelect' })
+ .find(select => select.props('items').some(item => item.id === 2))
+ await countrySelect.vm.$emit('update:modelValue', 2)
+ clickBody('[data-testid="geo-department-save"]')
+ await flushPromises()
+
+ expect(api.createDepartment).toHaveBeenCalledWith(expect.objectContaining({
+ name: 'Risaralda',
+ country: 2,
+ }))
+ expect(api.getDepartments).toHaveBeenCalledTimes(2)
+ })
+
+ it('cambia a la pestaña de municipios y crea uno con departamento y país', async () => {
+ const api = mockApi()
+ const wrapper = mountComponent(api)
+ await flushPromises()
+
+ await wrapper.find('[data-testid="geo-tab-municipalities"]').trigger('click')
+ await flushPromises()
+
+ expect(api.getMunicipalities).toHaveBeenCalled()
+ expect(wrapper.text()).toContain('San Antonio')
+
+ await wrapper.find('[data-testid="geo-municipality-create"]').trigger('click')
+ setBodyInput('[data-testid="geo-municipality-form-name"] input', 'Pereira')
+ await wrapper.findAllComponents({ name: 'VAutocomplete' })[0].vm.$emit('update:modelValue', 3)
+ clickBody('[data-testid="geo-municipality-save"]')
+ await flushPromises()
+
+ expect(api.createMunicipality).toHaveBeenCalledWith(expect.objectContaining({
+ name: 'Pereira',
+ department: 3,
+ country: 1,
+ }))
+ expect(api.getMunicipalities).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/tests/unit/components/provenance/admin/OrganizationsManagement.spec.js b/tests/unit/components/provenance/admin/OrganizationsManagement.spec.js
new file mode 100644
index 0000000..eb9307c
--- /dev/null
+++ b/tests/unit/components/provenance/admin/OrganizationsManagement.spec.js
@@ -0,0 +1,117 @@
+import { describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import OrganizationsManagement from '@/components/provenance/admin/OrganizationsManagement.vue'
+import vuetify from '@/plugins/vuetify'
+import { clickBody, setBodyInput } from './helpers'
+
+const organizations = [
+ {
+ id: 1,
+ name: 'Red de Economía Solidaria',
+ description: 'Red de organizaciones',
+ website: 'https://red.example.org',
+ contact_email: 'contacto@red.example.org',
+ contact_phone: '3001112233',
+ },
+ {
+ id: 2,
+ name: 'Cooperativa El Campo',
+ description: null,
+ website: null,
+ contact_email: null,
+ contact_phone: null,
+ },
+]
+
+function mockApi () {
+ return {
+ getOrganizations: vi.fn().mockResolvedValue(organizations),
+ createOrganization: vi.fn().mockResolvedValue({}),
+ updateOrganization: vi.fn().mockResolvedValue({}),
+ deleteOrganization: vi.fn().mockResolvedValue({}),
+ }
+}
+
+function mountComponent (api) {
+ return mount(OrganizationsManagement, {
+ global: { plugins: [vuetify], provide: { api } },
+ })
+}
+
+function fillForm () {
+ setBodyInput('[data-testid="org-form-name"] input', 'Fundación Montaña')
+ setBodyInput('[data-testid="org-form-description"] textarea', 'Fundación')
+ setBodyInput('[data-testid="org-form-website"] input', 'https://montana.example.org')
+ setBodyInput('[data-testid="org-form-email"] input', 'info@montana.example.org')
+ setBodyInput('[data-testid="org-form-phone"] input', '3109876543')
+}
+
+describe('OrganizationsManagement', () => {
+ it('carga y muestra las organizaciones', async () => {
+ const api = mockApi()
+ const wrapper = mountComponent(api)
+ await flushPromises()
+
+ expect(api.getOrganizations).toHaveBeenCalled()
+ expect(wrapper.text()).toContain('Red de Economía Solidaria')
+ expect(wrapper.text()).toContain('Cooperativa El Campo')
+ })
+
+ it('filtra las organizaciones por búsqueda', async () => {
+ const wrapper = mountComponent(mockApi())
+ await flushPromises()
+
+ await wrapper.find('[data-testid="org-search"] input').setValue('Cooperativa')
+
+ expect(wrapper.text()).toContain('Cooperativa El Campo')
+ expect(wrapper.text()).not.toContain('Red de Economía Solidaria')
+ })
+
+ it('crea una organización', async () => {
+ const api = mockApi()
+ const wrapper = mountComponent(api)
+ await flushPromises()
+
+ await wrapper.find('[data-testid="org-create"]').trigger('click')
+ fillForm()
+ clickBody('[data-testid="org-save"]')
+ await flushPromises()
+
+ expect(api.createOrganization).toHaveBeenCalledWith({
+ name: 'Fundación Montaña',
+ description: 'Fundación',
+ website: 'https://montana.example.org',
+ contact_email: 'info@montana.example.org',
+ contact_phone: '3109876543',
+ })
+ expect(api.getOrganizations).toHaveBeenCalledTimes(2)
+ })
+
+ it('edita una organización', async () => {
+ const api = mockApi()
+ const wrapper = mountComponent(api)
+ await flushPromises()
+
+ await wrapper.find('[data-testid="org-edit-1"]').trigger('click')
+ setBodyInput('[data-testid="org-form-name"] input', 'Red de Economía Solidaria Colombia')
+ clickBody('[data-testid="org-save"]')
+ await flushPromises()
+
+ expect(api.updateOrganization).toHaveBeenCalledWith(1, expect.objectContaining({
+ name: 'Red de Economía Solidaria Colombia',
+ }))
+ })
+
+ it('elimina una organización', async () => {
+ const api = mockApi()
+ const wrapper = mountComponent(api)
+ await flushPromises()
+
+ await wrapper.find('[data-testid="org-delete-2"]').trigger('click')
+ clickBody('[data-testid="org-confirm-delete"]')
+ await flushPromises()
+
+ expect(api.deleteOrganization).toHaveBeenCalledWith(2)
+ expect(api.getOrganizations).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/tests/unit/components/provenance/admin/SupplierLinkDialog.spec.js b/tests/unit/components/provenance/admin/SupplierLinkDialog.spec.js
new file mode 100644
index 0000000..195b2c7
--- /dev/null
+++ b/tests/unit/components/provenance/admin/SupplierLinkDialog.spec.js
@@ -0,0 +1,72 @@
+import { describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import SupplierLinkDialog from '@/components/provenance/admin/SupplierLinkDialog.vue'
+import vuetify from '@/plugins/vuetify'
+import { clickBody } from './helpers'
+
+const suppliers = [
+ { id: 5, name: 'Asociación Agropecuaria La Mesa' },
+ { id: 9, name: 'Finca El Paraíso' },
+ { id: 12, name: 'Cooperativa del Valle' },
+]
+
+function mockApi () {
+ return {
+ getSuppliers: vi.fn().mockResolvedValue(suppliers),
+ getProduct: vi.fn().mockResolvedValue({ id: 1, name: 'Panela regional por Kg', suppliers: [5] }),
+ updateProduct: vi.fn().mockResolvedValue({}),
+ }
+}
+
+function mountDialog (api, props = {}) {
+ return mount(SupplierLinkDialog, {
+ props: { visible: true, product: { id: 1, name: 'Panela regional por Kg' }, ...props },
+ global: { plugins: [vuetify], provide: { api } },
+ })
+}
+
+describe('SupplierLinkDialog', () => {
+ it('carga los proveedores y los suppliers actuales del producto al abrir', async () => {
+ const api = mockApi()
+ mountDialog(api)
+ await flushPromises()
+
+ expect(api.getSuppliers).toHaveBeenCalled()
+ expect(api.getProduct).toHaveBeenCalledWith(1)
+ expect(document.body.textContent).toContain('Panela regional por Kg')
+ })
+
+ it('permite cambiar la selección y guarda los proveedores', async () => {
+ const api = mockApi()
+ const wrapper = mountDialog(api)
+ await flushPromises()
+
+ const autocomplete = wrapper.findAllComponents({ name: 'VAutocomplete' })
+ .find(component => component.props('items').some(item => item.id === 5))
+ await autocomplete.vm.$emit('update:modelValue', [5, 9])
+
+ expect(document.body.textContent).toContain('Seleccionados (2)')
+
+ clickBody('[data-testid="supplier-link-save"]')
+ await flushPromises()
+
+ expect(api.updateProduct).toHaveBeenCalledWith(1, { suppliers: [5, 9] })
+ })
+
+ it('mantiene los proveedores seleccionados aunque el filtro no los incluya', async () => {
+ const api = mockApi()
+ const wrapper = mountDialog(api)
+ await flushPromises()
+
+ const autocomplete = wrapper.findAllComponents({ name: 'VAutocomplete' })
+ .find(component => component.props('items').some(item => item.id === 5))
+ await autocomplete.vm.$emit('update:modelValue', [5, 12])
+ await autocomplete.vm.$emit('update:search', 'Finca')
+
+ const items = autocomplete.props('items')
+ expect(items.some(item => item.id === 5)).toBe(true)
+ expect(items.some(item => item.id === 12)).toBe(true)
+ expect(items.some(item => item.id === 9)).toBe(true)
+ expect(items.some(item => item.id === 7)).toBe(false)
+ })
+})
diff --git a/tests/unit/components/provenance/admin/SuppliersManagement.spec.js b/tests/unit/components/provenance/admin/SuppliersManagement.spec.js
new file mode 100644
index 0000000..d2315e5
--- /dev/null
+++ b/tests/unit/components/provenance/admin/SuppliersManagement.spec.js
@@ -0,0 +1,143 @@
+import { describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import SuppliersManagement from '@/components/provenance/admin/SuppliersManagement.vue'
+import vuetify from '@/plugins/vuetify'
+import { clickBody, setBodyInput } from './helpers'
+
+const organizations = [
+ { id: 3, name: 'Red de Economía Solidaria' },
+ { id: 4, name: 'Cooperativa El Campo' },
+]
+
+const municipalities = [
+ { id: 7, name: 'La Mesa', department: 2 },
+ { id: 8, name: 'San Antonio', department: 3 },
+]
+
+const departments = [
+ { id: 2, name: 'Cundinamarca' },
+ { id: 3, name: 'Antioquia' },
+]
+
+const suppliers = [
+ {
+ id: 5,
+ name: 'Asociación Agropecuaria La Mesa',
+ description: 'Cooperativa de campesinos',
+ organization: 3,
+ organization_detail: { id: 3, name: 'Red de Economía Solidaria' },
+ municipality: 7,
+ municipality_detail: { id: 7, name: 'La Mesa' },
+ contact_email: 'contacto@agro.example.org',
+ contact_phone: '300 123 4567',
+ },
+ {
+ id: 6,
+ name: 'Finca El Paraíso',
+ description: null,
+ organization: null,
+ organization_detail: null,
+ municipality: null,
+ municipality_detail: null,
+ contact_email: null,
+ contact_phone: null,
+ },
+]
+
+function mockApi () {
+ return {
+ getSuppliers: vi.fn().mockResolvedValue(suppliers),
+ getOrganizations: vi.fn().mockResolvedValue(organizations),
+ getMunicipalities: vi.fn().mockResolvedValue(municipalities),
+ getDepartments: vi.fn().mockResolvedValue(departments),
+ createSupplier: vi.fn().mockResolvedValue({}),
+ updateSupplier: vi.fn().mockResolvedValue({}),
+ deleteSupplier: vi.fn().mockResolvedValue({}),
+ }
+}
+
+function mountComponent (api) {
+ return mount(SuppliersManagement, {
+ global: { plugins: [vuetify], provide: { api } },
+ })
+}
+
+describe('SuppliersManagement', () => {
+ it('carga y muestra los proveedores con su organización y municipio', async () => {
+ const api = mockApi()
+ const wrapper = mountComponent(api)
+ await flushPromises()
+
+ expect(api.getSuppliers).toHaveBeenCalled()
+ expect(wrapper.text()).toContain('Asociación Agropecuaria La Mesa')
+ expect(wrapper.text()).toContain('Red de Economía Solidaria')
+ expect(wrapper.text()).toContain('La Mesa')
+ })
+
+ it('muestra el departamento en el título del municipio', async () => {
+ const api = mockApi()
+ const wrapper = mountComponent(api)
+ await flushPromises()
+
+ await wrapper.find('[data-testid="supplier-create"]').trigger('click')
+ const autocomplete = wrapper.findAllComponents({ name: 'VAutocomplete' })[0]
+ const itemTitle = autocomplete.props('itemTitle')
+
+ expect(itemTitle({ id: 7, name: 'La Mesa', department: 2 })).toBe('La Mesa (Cundinamarca)')
+ })
+
+ it('crea un proveedor con organización y municipio', async () => {
+ const api = mockApi()
+ const wrapper = mountComponent(api)
+ await flushPromises()
+
+ await wrapper.find('[data-testid="supplier-create"]').trigger('click')
+
+ setBodyInput('[data-testid="supplier-form-name"] input', 'Asociación El Cedro')
+ setBodyInput('[data-testid="supplier-form-description"] textarea', 'Caficultores')
+ const orgSelect = wrapper.findAllComponents({ name: 'VSelect' })
+ .find(select => select.props('items').some(item => item.id === 4))
+ await orgSelect.vm.$emit('update:modelValue', 4)
+ await wrapper.findAllComponents({ name: 'VAutocomplete' })[0].vm.$emit('update:modelValue', 8)
+ clickBody('[data-testid="supplier-save"]')
+ await flushPromises()
+
+ expect(api.createSupplier).toHaveBeenCalledWith(expect.objectContaining({
+ name: 'Asociación El Cedro',
+ description: 'Caficultores',
+ organization: 4,
+ municipality: 8,
+ }))
+ expect(api.getSuppliers).toHaveBeenCalledTimes(2)
+ })
+
+ it('edita un proveedor conservando la organización seleccionada', async () => {
+ const api = mockApi()
+ const wrapper = mountComponent(api)
+ await flushPromises()
+
+ await wrapper.find('[data-testid="supplier-edit-5"]').trigger('click')
+ setBodyInput('[data-testid="supplier-form-name"] input', 'Asociación Agropecuaria La Mesa Renovada')
+ clickBody('[data-testid="supplier-save"]')
+ await flushPromises()
+
+ expect(api.updateSupplier).toHaveBeenCalledWith(5, expect.objectContaining({
+ name: 'Asociación Agropecuaria La Mesa Renovada',
+ organization: 3,
+ municipality: 7,
+ }))
+ })
+
+ it('elimina un proveedor', async () => {
+ const api = mockApi()
+ const wrapper = mountComponent(api)
+ await flushPromises()
+
+ await wrapper.find('[data-testid="supplier-delete-6"]').trigger('click')
+ clickBody('[data-testid="supplier-confirm-delete"]')
+ await flushPromises()
+
+ expect(api.deleteSupplier).toHaveBeenCalledWith(6)
+ expect(api.getSuppliers).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/tests/unit/components/provenance/admin/helpers.js b/tests/unit/components/provenance/admin/helpers.js
new file mode 100644
index 0000000..81a9b70
--- /dev/null
+++ b/tests/unit/components/provenance/admin/helpers.js
@@ -0,0 +1,23 @@
+// Helpers para interactuar con el contenido de diálogos, que Vuetify
+// teleporta a document.body fuera del DOM del wrapper.
+
+function setNativeValue (el, value) {
+ const proto = el.tagName === 'TEXTAREA'
+ ? window.HTMLTextAreaElement.prototype
+ : window.HTMLInputElement.prototype
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value').set
+ setter.call(el, value)
+ el.dispatchEvent(new Event('input', { bubbles: true }))
+}
+
+export function setBodyInput (selector, value) {
+ const el = document.body.querySelector(selector)
+ if (!el) throw new Error(`Input no encontrado en body: ${selector}`)
+ setNativeValue(el, value)
+}
+
+export function clickBody (selector) {
+ const el = document.body.querySelector(selector)
+ if (!el) throw new Error(`Elemento no encontrado en body: ${selector}`)
+ el.click()
+}
diff --git a/tests/unit/components/provenance/provenance-graph.spec.js b/tests/unit/components/provenance/provenance-graph.spec.js
new file mode 100644
index 0000000..4e82223
--- /dev/null
+++ b/tests/unit/components/provenance/provenance-graph.spec.js
@@ -0,0 +1,176 @@
+import { describe, expect, it } from 'vitest'
+import {
+ buildProvenanceGraph,
+ hasAnySupplier,
+} from '@/components/provenance/provenance-graph'
+
+const ALL_KINDS = ['product', 'supplier', 'organization', 'municipality', 'department', 'country']
+
+const singleRelation = [
+ {
+ product: {
+ id: 1,
+ name: 'Panela regional por Kg',
+ catalogue_images: ['http://localhost/media/panela.jpg'],
+ },
+ suppliers: [
+ {
+ supplier: { id: 5, name: 'Asociación Agropecuaria La Mesa' },
+ organization: { id: 3, name: 'Red de Economía Solidaria' },
+ municipality: { id: 7, name: 'La Mesa' },
+ department: { id: 2, name: 'Cundinamarca' },
+ country: { id: 1, name: 'Colombia' },
+ },
+ ],
+ },
+]
+
+function twoSuppliers (overrides = {}) {
+ return [
+ {
+ product: { id: 1, name: 'Panela' },
+ suppliers: [
+ {
+ supplier: { id: 5, name: 'Asociación A' },
+ organization: { id: 3, name: 'Red' },
+ municipality: { id: 7, name: 'La Mesa' },
+ department: { id: 2, name: 'Cundinamarca' },
+ country: { id: 1, name: 'Colombia' },
+ ...(overrides.first || {}),
+ },
+ {
+ supplier: { id: 6, name: 'Asociación B' },
+ organization: { id: 3, name: 'Red' },
+ municipality: { id: 8, name: 'San Antonio' },
+ department: { id: 3, name: 'Antioquia' },
+ country: { id: 1, name: 'Colombia' },
+ ...(overrides.second || {}),
+ },
+ ],
+ },
+ ]
+}
+
+function edgeOf (graph, from, to) {
+ return graph.edges.find(edge => edge.from === from && edge.to === to)
+}
+
+describe('hasAnySupplier', () => {
+ it('es true cuando algún producto tiene proveedores', () => {
+ expect(hasAnySupplier(singleRelation)).toBe(true)
+ })
+
+ it('es false cuando todos los productos están sin proveedores', () => {
+ const empty = [{ product: { id: 1, name: 'Panela' }, suppliers: [] }]
+ expect(hasAnySupplier(empty)).toBe(false)
+ })
+
+ it('es false cuando no hay provenance', () => {
+ expect(hasAnySupplier([])).toBe(false)
+ })
+})
+
+describe('buildProvenanceGraph', () => {
+ it('por defecto solo grafica productos y proveedores', () => {
+ const graph = buildProvenanceGraph(singleRelation)
+
+ expect(graph.nodes.map(n => n.kind)).toEqual(['product', 'supplier'])
+ expect(graph.edges).toEqual([{ from: 'product:1', to: 'supplier:5', certain: true }])
+ })
+
+ it('con todos los niveles grafica la cadena completa y la organización', () => {
+ const graph = buildProvenanceGraph(singleRelation, ALL_KINDS)
+
+ expect(edgeOf(graph, 'product:1', 'supplier:5')).toEqual({ from: 'product:1', to: 'supplier:5', certain: true })
+ expect(edgeOf(graph, 'supplier:5', 'organization:3').certain).toBe(true)
+ expect(edgeOf(graph, 'supplier:5', 'municipality:7')).toEqual({ from: 'supplier:5', to: 'municipality:7', certain: true })
+ expect(edgeOf(graph, 'municipality:7', 'department:2').certain).toBe(true)
+ expect(edgeOf(graph, 'department:2', 'country:1').certain).toBe(true)
+ })
+
+ it('con varios proveedores crea el punto de disyunción y la duda se propaga', () => {
+ const graph = buildProvenanceGraph(twoSuppliers(), ALL_KINDS)
+
+ expect(graph.nodes.map(n => n.id)).toContain('junction:1')
+ expect(edgeOf(graph, 'product:1', 'junction:1')).toEqual({ from: 'product:1', to: 'junction:1', certain: true })
+ expect(edgeOf(graph, 'junction:1', 'supplier:5').certain).toBe(false)
+ expect(edgeOf(graph, 'junction:1', 'supplier:6').certain).toBe(false)
+ expect(edgeOf(graph, 'supplier:5', 'municipality:7').certain).toBe(false)
+ expect(edgeOf(graph, 'municipality:7', 'department:2').certain).toBe(false)
+ expect(edgeOf(graph, 'department:2', 'country:1').certain).toBe(true)
+ expect(edgeOf(graph, 'supplier:5', 'organization:3').certain).toBe(true)
+ })
+
+ it('omite los niveles no solicitados conectando el nivel previo con el siguiente', () => {
+ const graph = buildProvenanceGraph(singleRelation, ['product', 'supplier', 'department'])
+
+ expect(graph.nodes.map(n => n.kind)).toEqual(['product', 'supplier', 'department'])
+ expect(edgeOf(graph, 'supplier:5', 'department:2')).toEqual({ from: 'supplier:5', to: 'department:2', certain: true })
+ expect(edgeOf(graph, 'supplier:5', 'municipality:7')).toBeUndefined()
+ })
+
+ it('sin proveedores activos conecta el producto con el siguiente nivel', () => {
+ const graph = buildProvenanceGraph(singleRelation, ['product', 'municipality', 'department'])
+
+ expect(edgeOf(graph, 'product:1', 'municipality:7')).toEqual({ from: 'product:1', to: 'municipality:7', certain: true })
+ expect(edgeOf(graph, 'municipality:7', 'department:2').certain).toBe(true)
+ })
+
+ it('sin productos activos los proveedores quedan como raíces sin junction', () => {
+ const graph = buildProvenanceGraph(twoSuppliers(), ['supplier', 'municipality'])
+
+ expect(graph.nodes.some(n => n.kind === 'product')).toBe(false)
+ expect(graph.nodes.some(n => n.kind === 'junction')).toBe(false)
+ expect(edgeOf(graph, 'supplier:5', 'municipality:7').certain).toBe(false)
+ })
+
+ it('con país activo crea el nodo país aunque se omitan niveles intermedios', () => {
+ const graph = buildProvenanceGraph(singleRelation, ['product', 'supplier', 'country'])
+
+ expect(graph.nodes.map(n => n.id)).toContain('country:1')
+ expect(edgeOf(graph, 'supplier:5', 'country:1').certain).toBe(true)
+ })
+
+ it('no crea nodos de país si no se solicita', () => {
+ const graph = buildProvenanceGraph(singleRelation, ['product', 'supplier', 'municipality'])
+
+ expect(graph.nodes.some(n => n.kind === 'country')).toBe(false)
+ })
+
+ it('no duplica aristas cuando varios productos comparten proveedor y municipio', () => {
+ const data = [
+ { product: { id: 1, name: 'Panela' }, suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'La Mesa' }, department: { id: 2, name: 'Cundinamarca' } }] },
+ { product: { id: 2, name: 'Arroz' }, suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'La Mesa' }, department: { id: 2, name: 'Cundinamarca' } }] },
+ ]
+ const graph = buildProvenanceGraph(data, ['product', 'supplier', 'municipality'])
+
+ expect(graph.nodes.filter(n => n.id === 'municipality:7')).toHaveLength(1)
+ expect(graph.edges.filter(e => e.from === 'supplier:5' && e.to === 'municipality:7')).toHaveLength(1)
+ })
+
+ it('si el mismo par de nodos repite con distinta certeza, gana la duda', () => {
+ const data = [
+ { product: { id: 1, name: 'Panela' }, suppliers: [{ supplier: { id: 5, name: 'A' }, organization: { id: 3, name: 'Red' } }] },
+ { product: { id: 2, name: 'Arroz' }, suppliers: [{ supplier: { id: 5, name: 'A' }, organization: { id: 3, name: 'Red' } }, { supplier: { id: 6, name: 'B' }, organization: { id: 4, name: 'Coop' } }] },
+ ]
+ const graph = buildProvenanceGraph(data, ['product', 'supplier', 'organization'])
+
+ expect(graph.edges.filter(e => e.from === 'supplier:5' && e.to === 'organization:3')).toHaveLength(1)
+ expect(edgeOf(graph, 'supplier:5', 'organization:3').certain).toBe(false)
+ })
+
+ it('incluye la imagen y el detalle de la entidad del producto', () => {
+ const graph = buildProvenanceGraph(singleRelation)
+ const product = graph.nodes.find(n => n.id === 'product:1')
+
+ expect(product.image).toBe('http://localhost/media/panela.jpg')
+ expect(product.entity.kind).toBe('product')
+ })
+
+ it('devuelve un grafo vacío cuando no hay provenance', () => {
+ const graph = buildProvenanceGraph([])
+
+ expect(graph.nodes).toEqual([])
+ expect(graph.edges).toEqual([])
+ })
+})
diff --git a/tests/unit/pages/admin/admin-pages.spec.js b/tests/unit/pages/admin/admin-pages.spec.js
new file mode 100644
index 0000000..3541dd3
--- /dev/null
+++ b/tests/unit/pages/admin/admin-pages.spec.js
@@ -0,0 +1,83 @@
+import { describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+import { useAuthStore } from '@/stores/auth'
+import OrganizationsManagement from '@/components/provenance/admin/OrganizationsManagement.vue'
+import SuppliersManagement from '@/components/provenance/admin/SuppliersManagement.vue'
+import GeographyManagement from '@/components/provenance/admin/GeographyManagement.vue'
+import OrganizationsPage from '@/pages/admin/organizations.vue'
+import SuppliersPage from '@/pages/admin/suppliers.vue'
+import GeographyPage from '@/pages/admin/geography.vue'
+import vuetify from '@/plugins/vuetify'
+
+function mockApi () {
+ return {
+ getOrganizations: vi.fn().mockResolvedValue([]),
+ getSuppliers: vi.fn().mockResolvedValue([]),
+ getMunicipalities: vi.fn().mockResolvedValue([]),
+ getCountries: vi.fn().mockResolvedValue([]),
+ getDepartments: vi.fn().mockResolvedValue([]),
+ }
+}
+
+function mountAdmin (page, api, role) {
+ const pinia = createPinia()
+ setActivePinia(pinia)
+ const authStore = useAuthStore()
+ authStore.setUser({ role, username: 'admin' })
+ const wrapper = mount(page, {
+ global: { plugins: [pinia, vuetify], provide: { api } },
+ })
+ return { wrapper }
+}
+
+async function flushAll (wrapper) {
+ await flushPromises()
+ await wrapper.vm.$nextTick()
+}
+
+describe('página admin/organizations', () => {
+ it('renderiza OrganizationsManagement cuando el usuario es administrador', async () => {
+ const { wrapper } = mountAdmin(OrganizationsPage, mockApi(), 'administrator')
+ await flushAll(wrapper)
+
+ expect(wrapper.findComponent(OrganizationsManagement).exists()).toBe(true)
+ })
+
+ it('no renderiza el componente cuando el usuario no es administrador', async () => {
+ const { wrapper } = mountAdmin(OrganizationsPage, mockApi(), 'publico')
+
+ expect(wrapper.findComponent(OrganizationsManagement).exists()).toBe(false)
+ expect(wrapper.text()).toBe('')
+ })
+})
+
+describe('página admin/suppliers', () => {
+ it('renderiza SuppliersManagement cuando el usuario es administrador', async () => {
+ const { wrapper } = mountAdmin(SuppliersPage, mockApi(), 'administrator')
+ await flushAll(wrapper)
+
+ expect(wrapper.findComponent(SuppliersManagement).exists()).toBe(true)
+ })
+
+ it('no renderiza el componente cuando el usuario no es administrador', async () => {
+ const { wrapper } = mountAdmin(SuppliersPage, mockApi(), 'publico')
+
+ expect(wrapper.findComponent(SuppliersManagement).exists()).toBe(false)
+ })
+})
+
+describe('página admin/geography', () => {
+ it('renderiza GeographyManagement cuando el usuario es administrador', async () => {
+ const { wrapper } = mountAdmin(GeographyPage, mockApi(), 'administrator')
+ await flushAll(wrapper)
+
+ expect(wrapper.findComponent(GeographyManagement).exists()).toBe(true)
+ })
+
+ it('no renderiza el componente cuando el usuario no es administrador', async () => {
+ const { wrapper } = mountAdmin(GeographyPage, mockApi(), 'publico')
+
+ expect(wrapper.findComponent(GeographyManagement).exists()).toBe(false)
+ })
+})
diff --git a/tests/unit/services/django-api.spec.js b/tests/unit/services/django-api.spec.js
index 2124742..fe9cf17 100644
--- a/tests/unit/services/django-api.spec.js
+++ b/tests/unit/services/django-api.spec.js
@@ -32,3 +32,186 @@ describe('DjangoApi.getPublicOrderSummary', () => {
expect(result).toEqual({ code: 'abc123', type: 'sale' })
})
})
+
+describe('DjangoApi provenance CRUD', () => {
+ beforeEach(() => {
+ vi.stubEnv('VITE_DJANGO_BASE_URL', 'http://backend.test')
+ http.get.mockReset()
+ http.post.mockReset()
+ http.patch.mockReset()
+ http.delete.mockReset()
+ http.get.mockResolvedValue({ data: [] })
+ http.post.mockResolvedValue({ data: { id: 1 } })
+ http.patch.mockResolvedValue({ data: { id: 1 } })
+ http.delete.mockResolvedValue({ data: {} })
+ })
+
+ it('getProduct consulta el detalle autenticado del producto', async () => {
+ const api = new DjangoApi()
+
+ await api.getProduct(3)
+
+ expect(http.get).toHaveBeenCalledWith(
+ 'http://backend.test/don_confiao/api/products/3/'
+ )
+ })
+
+ it('getOrganizations consulta la lista de organizaciones', async () => {
+ const api = new DjangoApi()
+
+ await api.getOrganizations()
+
+ expect(http.get).toHaveBeenCalledWith(
+ 'http://backend.test/don_confiao/api/organizations/'
+ )
+ })
+
+ it('createOrganization hace POST con los datos', async () => {
+ const api = new DjangoApi()
+ const payload = { name: 'Red de Economía Solidaria' }
+
+ await api.createOrganization(payload)
+
+ expect(http.post).toHaveBeenCalledWith(
+ 'http://backend.test/don_confiao/api/organizations/',
+ payload
+ )
+ })
+
+ it('updateOrganization hace PATCH con los datos', async () => {
+ const api = new DjangoApi()
+ const payload = { description: 'Nueva descripción' }
+
+ await api.updateOrganization(3, payload)
+
+ expect(http.patch).toHaveBeenCalledWith(
+ 'http://backend.test/don_confiao/api/organizations/3/',
+ payload
+ )
+ })
+
+ it('deleteOrganization hace DELETE', async () => {
+ const api = new DjangoApi()
+
+ await api.deleteOrganization(3)
+
+ expect(http.delete).toHaveBeenCalledWith(
+ 'http://backend.test/don_confiao/api/organizations/3/'
+ )
+ })
+
+ it('getSuppliers consulta la lista de proveedores', async () => {
+ const api = new DjangoApi()
+
+ await api.getSuppliers()
+
+ expect(http.get).toHaveBeenCalledWith(
+ 'http://backend.test/don_confiao/api/suppliers/'
+ )
+ })
+
+ it('createSupplier hace POST con datos de proveedor', async () => {
+ const api = new DjangoApi()
+ const payload = { name: 'Asociación La Mesa', organization: 3, municipality: 7 }
+
+ await api.createSupplier(payload)
+
+ expect(http.post).toHaveBeenCalledWith(
+ 'http://backend.test/don_confiao/api/suppliers/',
+ payload
+ )
+ })
+
+ it('updateSupplier hace PATCH para desvincular organización con null', async () => {
+ const api = new DjangoApi()
+
+ await api.updateSupplier(5, { organization: null })
+
+ expect(http.patch).toHaveBeenCalledWith(
+ 'http://backend.test/don_confiao/api/suppliers/5/',
+ { organization: null }
+ )
+ })
+
+ it('deleteSupplier hace DELETE', async () => {
+ const api = new DjangoApi()
+
+ await api.deleteSupplier(5)
+
+ expect(http.delete).toHaveBeenCalledWith(
+ 'http://backend.test/don_confiao/api/suppliers/5/'
+ )
+ })
+
+ it('CRUD de países usa el endpoint countries', async () => {
+ const api = new DjangoApi()
+
+ await api.getCountries()
+ await api.createCountry({ name: 'Colombia', code: 'CO' })
+ await api.updateCountry(1, { code: 'CO' })
+ await api.deleteCountry(1)
+
+ expect(http.get).toHaveBeenCalledWith(
+ 'http://backend.test/don_confiao/api/countries/'
+ )
+ expect(http.post).toHaveBeenCalledWith(
+ 'http://backend.test/don_confiao/api/countries/',
+ { name: 'Colombia', code: 'CO' }
+ )
+ expect(http.patch).toHaveBeenCalledWith(
+ 'http://backend.test/don_confiao/api/countries/1/',
+ { code: 'CO' }
+ )
+ expect(http.delete).toHaveBeenCalledWith(
+ 'http://backend.test/don_confiao/api/countries/1/'
+ )
+ })
+
+ it('CRUD de departamentos usa el endpoint departments', async () => {
+ const api = new DjangoApi()
+
+ await api.getDepartments()
+ await api.createDepartment({ name: 'Cundinamarca', country: 1 })
+ await api.updateDepartment(2, { name: 'Antioquia' })
+ await api.deleteDepartment(2)
+
+ expect(http.get).toHaveBeenCalledWith(
+ 'http://backend.test/don_confiao/api/departments/'
+ )
+ expect(http.post).toHaveBeenCalledWith(
+ 'http://backend.test/don_confiao/api/departments/',
+ { name: 'Cundinamarca', country: 1 }
+ )
+ expect(http.patch).toHaveBeenCalledWith(
+ 'http://backend.test/don_confiao/api/departments/2/',
+ { name: 'Antioquia' }
+ )
+ expect(http.delete).toHaveBeenCalledWith(
+ 'http://backend.test/don_confiao/api/departments/2/'
+ )
+ })
+
+ it('CRUD de municipios usa el endpoint municipalities', async () => {
+ const api = new DjangoApi()
+
+ await api.getMunicipalities()
+ await api.createMunicipality({ name: 'La Mesa', department: 2, country: 1 })
+ await api.updateMunicipality(7, { name: 'LA MESA' })
+ await api.deleteMunicipality(7)
+
+ expect(http.get).toHaveBeenCalledWith(
+ 'http://backend.test/don_confiao/api/municipalities/'
+ )
+ expect(http.post).toHaveBeenCalledWith(
+ 'http://backend.test/don_confiao/api/municipalities/',
+ { name: 'La Mesa', department: 2, country: 1 }
+ )
+ expect(http.patch).toHaveBeenCalledWith(
+ 'http://backend.test/don_confiao/api/municipalities/7/',
+ { name: 'LA MESA' }
+ )
+ expect(http.delete).toHaveBeenCalledWith(
+ 'http://backend.test/don_confiao/api/municipalities/7/'
+ )
+ })
+})