From 379e66f212616b50488ecd0383aa4e0d487cf125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20D=C3=ADaz?= Date: Mon, 4 May 2026 02:41:11 -0300 Subject: [PATCH 1/2] =?UTF-8?q?OM-322:=20listado=20y=20eliminaci=C3=B3n=20?= =?UTF-8?q?de=20repartidores=20vinculados=20al=20comercio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa los dos endpoints del ticket OM-322 para la gestión de repartidores desde el panel del comercio (rol SELLER). Endpoints agregados ─────────────────── GET /api/stores/:id/deliveries - Retorna { stats, deliveries } donde stats incluye total, available, inDelivery y avgRating global (para el header del frontend). - Cada delivery expone: id, user (id/name/email/phone), status derivado, vehicleType, completedDeliveries, successRate y avgRating individual. - El status se deriva en runtime: · AVAILABLE → delivery_status = ACTIVE sin asignaciones activas · IN_DELIVERY → tiene asignación PENDING o ACCEPTED · UNAVAILABLE → delivery_status = INACTIVE o SUSPENDED - successRate = DELIVERED / (DELIVERED + REJECTED) × 100 (null si aún no hay historial terminal). - Solo accesible por el dueño del comercio (403 en caso contrario). DELETE /api/stores/:id/deliveries/:deliveryId - Desvincula el repartidor del comercio (fk_store = null). - No elimina al usuario ni su historial de entregas. - Bloquea la operación si el delivery tiene entregas activas (assignment_status IN [PENDING, ACCEPTED]) → 400. - Retorna 204 No Content al completarse. --- .../deliveries/delivery.controller.js | 30 +- .../commerce/deliveries/delivery.routes.js | 143 +++- .../commerce/deliveries/delivery.service.js | 122 ++- tests/unit/commerce/delivery.test.js | 700 ++++++++++++------ 4 files changed, 759 insertions(+), 236 deletions(-) diff --git a/src/modules/commerce/deliveries/delivery.controller.js b/src/modules/commerce/deliveries/delivery.controller.js index c2cb75d..55fb4ff 100644 --- a/src/modules/commerce/deliveries/delivery.controller.js +++ b/src/modules/commerce/deliveries/delivery.controller.js @@ -1,7 +1,9 @@ import { searchDeliveryCandidatesService, createDeliveryService, - getStoreDeliveryReviewsService + getStoreDeliveriesService, + deleteStoreDeliveryService, + getStoreDeliveryReviewsService, } from "./delivery.service.js"; export const searchDeliveries = async (req, res) => { @@ -39,6 +41,30 @@ export const createDelivery = async (req, res) => { } }; +export const getStoreDeliveries = async (req, res) => { + try { + const { id } = req.params; + const result = await getStoreDeliveriesService(req.user?.id_user, id); + res.status(200).json(result); + } catch (error) { + return res.status(error.status || error.statusCode || 500).json({ + message: error.message || "Error interno del servidor" + }); + } +}; + +export const deleteStoreDelivery = async (req, res) => { + try { + const { id, deliveryId } = req.params; + await deleteStoreDeliveryService(req.user?.id_user, id, deliveryId); + res.status(204).send(); + } catch (error) { + return res.status(error.status || error.statusCode || 500).json({ + message: error.message || "Error interno del servidor" + }); + } +}; + export const getStoreDeliveryReviews = async (req, res) => { try { const { storeId, deliveryId } = req.params; @@ -54,4 +80,4 @@ export const getStoreDeliveryReviews = async (req, res) => { message: error.message || "Error interno del servidor" }); } -}; +}; \ No newline at end of file diff --git a/src/modules/commerce/deliveries/delivery.routes.js b/src/modules/commerce/deliveries/delivery.routes.js index a683fda..f64066a 100644 --- a/src/modules/commerce/deliveries/delivery.routes.js +++ b/src/modules/commerce/deliveries/delivery.routes.js @@ -2,7 +2,13 @@ import { Router } from "express"; import authenticate from "../../../config/jwt.config.js"; import { requireRole } from "../../../middlewares/auth.middleware.js"; import { ROLES } from "../../../utils/contants/roles.constant.js"; -import { searchDeliveries, createDelivery, getStoreDeliveryReviews } from "./delivery.controller.js"; +import { + searchDeliveries, + createDelivery, + getStoreDeliveries, + deleteStoreDelivery, + getStoreDeliveryReviews, +} from "./delivery.controller.js"; export const deliveryRouter = Router(); export const storeDeliveryRouter = Router(); @@ -10,9 +16,132 @@ export const storeDeliveryRouter = Router(); // GET /api/deliveries/search?q= deliveryRouter.get("/search", authenticate, requireRole(ROLES.SELLER), searchDeliveries); -// POST /api/stores/{id}/deliveries +// POST /api/stores/:id/deliveries — Vincular delivery al comercio storeDeliveryRouter.post("/:id/deliveries", authenticate, requireRole(ROLES.SELLER), createDelivery); +/** + * @swagger + * /api/stores/{id}/deliveries: + * get: + * summary: Listar repartidores del comercio + * description: > + * Retorna todos los repartidores vinculados al comercio junto con estadísticas + * agregadas (disponibles, en entrega, total, rating promedio). + * Solo accesible por el dueño del comercio. + * tags: [Deliveries] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: ID del comercio + * responses: + * 200: + * description: Lista de repartidores y estadísticas + * content: + * application/json: + * schema: + * type: object + * properties: + * stats: + * type: object + * properties: + * total: + * type: integer + * available: + * type: integer + * inDelivery: + * type: integer + * avgRating: + * type: number + * nullable: true + * deliveries: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * user: + * type: object + * properties: + * id: + * type: integer + * name: + * type: string + * email: + * type: string + * phone: + * type: string + * nullable: true + * status: + * type: string + * enum: [AVAILABLE, IN_DELIVERY, UNAVAILABLE] + * vehicleType: + * type: string + * enum: [CAR, MOTORCYCLE, BICYCLE, ON_FOOT] + * completedDeliveries: + * type: integer + * successRate: + * type: number + * nullable: true + * description: Porcentaje de entregas exitosas sobre el total de terminales (DELIVERED + REJECTED) + * avgRating: + * type: number + * nullable: true + * reviewCount: + * type: integer + * 401: + * description: No autenticado + * 403: + * description: No tiene permisos para acceder a este comercio + * 404: + * description: Comercio no encontrado + */ +storeDeliveryRouter.get("/:id/deliveries", authenticate, requireRole(ROLES.SELLER), getStoreDeliveries); + +/** + * @swagger + * /api/stores/{id}/deliveries/{deliveryId}: + * delete: + * summary: Desvincular repartidor del comercio + * description: > + * Desvincula un repartidor del comercio (fk_store = null). No elimina al usuario. + * No se permite desvincular si el repartidor tiene entregas activas (PENDING o ACCEPTED). + * Solo accesible por el dueño del comercio. + * tags: [Deliveries] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: ID del comercio + * - in: path + * name: deliveryId + * required: true + * schema: + * type: integer + * description: ID del delivery a desvincular + * responses: + * 204: + * description: Repartidor desvinculado correctamente + * 400: + * description: El repartidor tiene entregas activas + * 401: + * description: No autenticado + * 403: + * description: No tiene permisos para acceder a este comercio + * 404: + * description: Delivery no encontrado para este comercio + */ +storeDeliveryRouter.delete("/:id/deliveries/:deliveryId", authenticate, requireRole(ROLES.SELLER), deleteStoreDelivery); + /** * @swagger * /api/stores/{storeId}/deliveries/{deliveryId}/reviews: @@ -102,8 +231,8 @@ storeDeliveryRouter.post("/:id/deliveries", authenticate, requireRole(ROLES.SELL * message: "Delivery no encontrado para este comercio" */ storeDeliveryRouter.get( - "/:storeId/deliveries/:deliveryId/reviews", - authenticate, - requireRole(ROLES.SELLER), - getStoreDeliveryReviews -); \ No newline at end of file + "/:storeId/deliveries/:deliveryId/reviews", + authenticate, + requireRole(ROLES.SELLER), + getStoreDeliveryReviews +); diff --git a/src/modules/commerce/deliveries/delivery.service.js b/src/modules/commerce/deliveries/delivery.service.js index 1101fb7..7a113d7 100644 --- a/src/modules/commerce/deliveries/delivery.service.js +++ b/src/modules/commerce/deliveries/delivery.service.js @@ -30,7 +30,7 @@ export const searchDeliveryCandidatesService = async (query) => { export const createDeliveryService = async (authenticatedUserId, storeIdStr, deliveryUserIdStr) => { const store = await getAuthorizedStoreOwnerService(authenticatedUserId, storeIdStr); const deliveryUserId = Number(deliveryUserIdStr); - + if (!Number.isInteger(deliveryUserId) || deliveryUserId <= 0) { throw new ValidationError("El ID del candidato a delivery debe ser un número entero positivo"); } @@ -58,7 +58,7 @@ export const createDeliveryService = async (authenticatedUserId, storeIdStr, del const existing = await prisma.deliveries.findUnique({ where: { fk_user: deliveryUserId } }); - + if (existing && existing.fk_store === store.id_store) { throw new ConflictError("El delivery ya está vinculado a este comercio"); } @@ -68,6 +68,124 @@ export const createDeliveryService = async (authenticatedUserId, storeIdStr, del } }; +export const getStoreDeliveriesService = async (authenticatedUserId, storeIdStr) => { + const store = await getAuthorizedStoreOwnerService(authenticatedUserId, storeIdStr); + + const deliveries = await prisma.deliveries.findMany({ + where: { fk_store: store.id_store, status: true }, + select: { + id_delivery: true, + delivery_status: true, + vehicle_type: true, + user: { + select: { id_user: true, name: true, email: true, phone: true }, + }, + delivery_assignments: { + where: { status: true }, + select: { assignment_status: true }, + }, + delivery_reviews: { + where: { status: true }, + select: { rating: true }, + }, + }, + orderBy: { created_at: "desc" }, + }); + + let totalAvailable = 0; + let totalInDelivery = 0; + const allRatings = []; + + const mappedDeliveries = deliveries.map((d) => { + const assignments = d.delivery_assignments; + const hasActiveAssignment = assignments.some( + (a) => a.assignment_status === "PENDING" || a.assignment_status === "ACCEPTED" + ); + + const delivered = assignments.filter((a) => a.assignment_status === "DELIVERED").length; + const rejected = assignments.filter((a) => a.assignment_status === "REJECTED").length; + const terminal = delivered + rejected; + const successRate = terminal > 0 ? Math.round((delivered / terminal) * 1000) / 10 : null; + + const ratings = d.delivery_reviews.map((r) => r.rating); + allRatings.push(...ratings); + const avgRating = + ratings.length > 0 + ? Math.round((ratings.reduce((sum, r) => sum + r, 0) / ratings.length) * 10) / 10 + : null; + + let displayStatus; + if (hasActiveAssignment) { + displayStatus = "IN_DELIVERY"; + totalInDelivery++; + } else if (d.delivery_status === "ACTIVE") { + displayStatus = "AVAILABLE"; + totalAvailable++; + } else { + displayStatus = "UNAVAILABLE"; + } + + return { + id: d.id_delivery, + user: { + id: d.user.id_user, + name: d.user.name, + email: d.user.email, + phone: d.user.phone, + }, + status: displayStatus, + vehicleType: d.vehicle_type, + completedDeliveries: delivered, + successRate, + avgRating, + reviewCount: ratings.length, + }; + }); + + const globalAvgRating = + allRatings.length > 0 + ? Math.round((allRatings.reduce((sum, r) => sum + r, 0) / allRatings.length) * 10) / 10 + : null; + + return { + stats: { + total: deliveries.length, + available: totalAvailable, + inDelivery: totalInDelivery, + avgRating: globalAvgRating, + }, + deliveries: mappedDeliveries, + }; +}; + +export const deleteStoreDeliveryService = async (authenticatedUserId, storeIdStr, deliveryIdStr) => { + const store = await getAuthorizedStoreOwnerService(authenticatedUserId, storeIdStr); + const deliveryId = parsePositiveInteger(deliveryIdStr, "ID de delivery"); + + const delivery = await prisma.deliveries.findFirst({ + where: { id_delivery: deliveryId, fk_store: store.id_store, status: true }, + select: { + id_delivery: true, + delivery_assignments: { + where: { status: true, assignment_status: { in: ["PENDING", "ACCEPTED"] } }, + select: { id_delivery_assignment: true }, + take: 1, + }, + }, + }); + + if (!delivery) throw new NotFoundError("Delivery no encontrado para este comercio"); + + if (delivery.delivery_assignments.length > 0) { + throw new ValidationError("No se puede desvincular un delivery con entregas activas"); + } + + await prisma.deliveries.update({ + where: { id_delivery: deliveryId }, + data: { fk_store: null }, + }); +}; + export const getStoreDeliveryReviewsService = async ( authenticatedUserId, storeIdStr, diff --git a/tests/unit/commerce/delivery.test.js b/tests/unit/commerce/delivery.test.js index bef63dd..1d8d05a 100644 --- a/tests/unit/commerce/delivery.test.js +++ b/tests/unit/commerce/delivery.test.js @@ -3,242 +3,492 @@ import request from "supertest"; import app from "../../../src/app.js"; import { prisma } from "../../../src/lib/prisma.js"; +// ─── MOCK DE PRISMA ────────────────────────────────────────────────────────── + vi.mock("../../../src/lib/prisma.js", () => ({ - prisma: { - users: { - findMany: vi.fn(), - findUnique: vi.fn(), - findFirst: vi.fn(), - }, - stores: { - findUnique: vi.fn(), - }, - deliveries: { - findUnique: vi.fn(), - create: vi.fn(), - }, - deliveryReviews: { - findMany: vi.fn(), - } - } + prisma: { + users: { + findMany: vi.fn(), + findUnique: vi.fn(), + findFirst: vi.fn(), + }, + stores: { + findUnique: vi.fn(), + }, + deliveries: { + findMany: vi.fn(), + findUnique: vi.fn(), + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + deliveryReviews: { + findMany: vi.fn(), + }, + }, })); vi.mock("jsonwebtoken", async () => { - const actual = await vi.importActual("jsonwebtoken"); - return { - default: { - ...actual.default, - verify: vi.fn((token, secret, callback) => { - callback(null, { id_user: 1, email: "test@test.com", role: "SELLER" }); - }), - } - }; + const actual = await vi.importActual("jsonwebtoken"); + return { + default: { + ...actual.default, + verify: vi.fn((token, secret, callback) => { + callback(null, { id_user: 1, email: "test@test.com", role: "SELLER" }); + }), + }, + }; }); const authCookie = "userToken=mock-token"; -describe("Delivery Endpoints", () => { - beforeEach(() => vi.clearAllMocks()); - - describe("GET /api/deliveries/search", () => { - it("returns empty array if no query provided", async () => { - const res = await request(app) - .get("/api/deliveries/search") - .set("Cookie", authCookie); - expect(res.status).toBe(200); - expect(res.body).toEqual([]); - }); - - it("returns candidates correctly", async () => { - const mockCandidates = [ - { id_user: 2, name: "Delivery 1", email: "del@test.com", phone: "123" } - ]; - prisma.users.findMany.mockResolvedValue(mockCandidates); - - const res = await request(app) - .get("/api/deliveries/search?q=del") - .set("Cookie", authCookie); - - expect(res.status).toBe(200); - expect(res.body).toEqual(mockCandidates); - expect(prisma.users.findMany).toHaveBeenCalledTimes(1); - }); +// ─── DATOS DE PRUEBA ───────────────────────────────────────────────────────── + +const mockStore = { + id_store: 1, + fk_user: 1, + status: true, + user: { id_user: 1, status: true }, +}; + +const mockStoreOtherOwner = { + id_store: 1, + fk_user: 99, + status: true, + user: { id_user: 99, status: true }, +}; + +// Disponible: ACTIVE + sin asignaciones activas, 2 reseñas (5 y 4) +const mockDeliveryAvailable = { + id_delivery: 10, + delivery_status: "ACTIVE", + vehicle_type: "MOTORCYCLE", + user: { id_user: 2, name: "Juan Pérez", email: "juan@delivery.com", phone: "+56912345678" }, + delivery_assignments: [], + delivery_reviews: [{ rating: 5 }, { rating: 4 }], +}; + +// En entrega: tiene asignación ACCEPTED + 3 reseñas (5, 5, 4) + 2 DELIVERED, 0 REJECTED +const mockDeliveryInDelivery = { + id_delivery: 11, + delivery_status: "ACTIVE", + vehicle_type: "CAR", + user: { id_user: 3, name: "María González", email: "maria@delivery.com", phone: "+56923456789" }, + delivery_assignments: [ + { assignment_status: "ACCEPTED" }, + { assignment_status: "DELIVERED" }, + { assignment_status: "DELIVERED" }, + ], + delivery_reviews: [{ rating: 5 }, { rating: 5 }, { rating: 4 }], +}; + +// No disponible: INACTIVE + sin asignaciones activas, 1 DELIVERED 1 REJECTED, sin reseñas +const mockDeliveryUnavailable = { + id_delivery: 12, + delivery_status: "INACTIVE", + vehicle_type: "BICYCLE", + user: { id_user: 4, name: "Carlos Ramírez", email: "carlos@delivery.com", phone: "+56934567890" }, + delivery_assignments: [ + { assignment_status: "DELIVERED" }, + { assignment_status: "REJECTED" }, + ], + delivery_reviews: [], +}; + +// ─── GET /api/deliveries/search ─────────────────────────────────────────────── + +describe("GET /api/deliveries/search", () => { + beforeEach(() => vi.clearAllMocks()); + + it("retorna array vacío si no se provee query", async () => { + const res = await request(app) + .get("/api/deliveries/search") + .set("Cookie", authCookie); + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + + it("retorna candidatos correctamente", async () => { + const mockCandidates = [ + { id_user: 2, name: "Delivery 1", email: "del@test.com", phone: "123" }, + ]; + prisma.users.findMany.mockResolvedValue(mockCandidates); + + const res = await request(app) + .get("/api/deliveries/search?q=del") + .set("Cookie", authCookie); + + expect(res.status).toBe(200); + expect(res.body).toEqual(mockCandidates); + expect(prisma.users.findMany).toHaveBeenCalledTimes(1); + }); +}); + +// ─── POST /api/stores/:id/deliveries ───────────────────────────────────────── + +describe("POST /api/stores/:id/deliveries", () => { + beforeEach(() => vi.clearAllMocks()); + + it("retorna 400 si fk_user no se envía", async () => { + const res = await request(app) + .post("/api/stores/1/deliveries") + .set("Cookie", authCookie) + .send({}); + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/fk_user es requerido/); + }); + + it("retorna 403 si la tienda no pertenece al usuario", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStoreOtherOwner); + + const res = await request(app) + .post("/api/stores/1/deliveries") + .set("Cookie", authCookie) + .send({ fk_user: 2 }); + expect(res.status).toBe(403); + expect(res.body.message).toMatch(/permisos/i); + }); + + it("retorna 404 si el candidato a delivery no existe", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStore); + prisma.users.findFirst.mockResolvedValue(null); + + const res = await request(app) + .post("/api/stores/1/deliveries") + .set("Cookie", authCookie) + .send({ fk_user: 2 }); + expect(res.status).toBe(404); + expect(res.body.message).toMatch(/Candidato a delivery no encontrado/i); + }); + + it("retorna 409 si el delivery ya está vinculado", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStore); + prisma.users.findFirst.mockResolvedValue({ id_user: 2, role: "DELIVERY", status: true }); + const prismaError = new Error("Unique constraint failed"); + prismaError.code = "P2002"; + prisma.deliveries.create.mockRejectedValue(prismaError); + prisma.deliveries.findUnique.mockResolvedValue({ fk_store: 1, fk_user: 2 }); + + const res = await request(app) + .post("/api/stores/1/deliveries") + .set("Cookie", authCookie) + .send({ fk_user: 2 }); + expect(res.status).toBe(409); + expect(res.body.message).toMatch(/ya está vinculado/i); + }); + + it("retorna 201 y el delivery creado al vincular correctamente", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStore); + prisma.users.findFirst.mockResolvedValue({ id_user: 2, role: "DELIVERY", status: true }); + prisma.deliveries.findUnique.mockResolvedValue(null); + const mockDelivery = { id_delivery: 10, fk_store: 1, fk_user: 2, delivery_status: "INACTIVE" }; + prisma.deliveries.create.mockResolvedValue(mockDelivery); + + const res = await request(app) + .post("/api/stores/1/deliveries") + .set("Cookie", authCookie) + .send({ fk_user: 2 }); + expect(res.status).toBe(201); + expect(res.body).toEqual(mockDelivery); + }); +}); + +// ─── GET /api/stores/:id/deliveries ────────────────────────────────────────── + +describe("GET /api/stores/:id/deliveries", () => { + beforeEach(() => vi.clearAllMocks()); + + it("retorna 403 si la tienda no pertenece al usuario", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStoreOtherOwner); + + const res = await request(app) + .get("/api/stores/1/deliveries") + .set("Cookie", authCookie); + expect(res.status).toBe(403); + }); + + it("retorna 404 si la tienda no existe", async () => { + prisma.stores.findUnique.mockResolvedValue(null); + + const res = await request(app) + .get("/api/stores/1/deliveries") + .set("Cookie", authCookie); + expect(res.status).toBe(404); + }); + + it("retorna lista vacía y stats en cero cuando no hay repartidores", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStore); + prisma.deliveries.findMany.mockResolvedValue([]); + + const res = await request(app) + .get("/api/stores/1/deliveries") + .set("Cookie", authCookie); + + expect(res.status).toBe(200); + expect(res.body.deliveries).toEqual([]); + expect(res.body.stats).toMatchObject({ + total: 0, + available: 0, + inDelivery: 0, + avgRating: null, + }); + }); + + it("calcula stats correctamente con múltiples repartidores", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStore); + prisma.deliveries.findMany.mockResolvedValue([ + mockDeliveryAvailable, + mockDeliveryInDelivery, + mockDeliveryUnavailable, + ]); + + const res = await request(app) + .get("/api/stores/1/deliveries") + .set("Cookie", authCookie); + + expect(res.status).toBe(200); + expect(res.body.stats).toMatchObject({ + total: 3, + available: 1, + inDelivery: 1, + }); + // ratings: [5,4] + [5,5,4] + [] = [5,4,5,5,4] = 23/5 = 4.6 + expect(res.body.stats.avgRating).toBe(4.6); + }); + + it("deriva status AVAILABLE para repartidor activo sin asignaciones activas", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStore); + prisma.deliveries.findMany.mockResolvedValue([mockDeliveryAvailable]); + + const res = await request(app) + .get("/api/stores/1/deliveries") + .set("Cookie", authCookie); + + const d = res.body.deliveries[0]; + expect(d.status).toBe("AVAILABLE"); + expect(d.completedDeliveries).toBe(0); + expect(d.successRate).toBeNull(); + expect(d.avgRating).toBe(4.5); + expect(d.reviewCount).toBe(2); + }); + + it("deriva status IN_DELIVERY para repartidor con asignación activa", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStore); + prisma.deliveries.findMany.mockResolvedValue([mockDeliveryInDelivery]); + + const res = await request(app) + .get("/api/stores/1/deliveries") + .set("Cookie", authCookie); + + const d = res.body.deliveries[0]; + expect(d.status).toBe("IN_DELIVERY"); + expect(d.completedDeliveries).toBe(2); + expect(d.successRate).toBe(100); // 2 DELIVERED, 0 REJECTED + // avgRating: (5+5+4)/3 = 4.7 + expect(d.avgRating).toBe(4.7); + }); + + it("deriva status UNAVAILABLE para repartidor INACTIVE", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStore); + prisma.deliveries.findMany.mockResolvedValue([mockDeliveryUnavailable]); + + const res = await request(app) + .get("/api/stores/1/deliveries") + .set("Cookie", authCookie); + + const d = res.body.deliveries[0]; + expect(d.status).toBe("UNAVAILABLE"); + expect(d.completedDeliveries).toBe(1); + expect(d.successRate).toBe(50); // 1 DELIVERED, 1 REJECTED + expect(d.avgRating).toBeNull(); + expect(d.reviewCount).toBe(0); + }); + + it("retorna los datos del usuario correctamente", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStore); + prisma.deliveries.findMany.mockResolvedValue([mockDeliveryAvailable]); + + const res = await request(app) + .get("/api/stores/1/deliveries") + .set("Cookie", authCookie); + + expect(res.body.deliveries[0].user).toMatchObject({ + id: 2, + name: "Juan Pérez", + email: "juan@delivery.com", + phone: "+56912345678", }); + }); + + it("retorna vehicleType en el objeto de delivery", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStore); + prisma.deliveries.findMany.mockResolvedValue([mockDeliveryAvailable]); + + const res = await request(app) + .get("/api/stores/1/deliveries") + .set("Cookie", authCookie); + + expect(res.body.deliveries[0].vehicleType).toBe("MOTORCYCLE"); + }); + + it("status IN_DELIVERY también aplica cuando la asignación es PENDING", async () => { + const deliveryWithPending = { + ...mockDeliveryAvailable, + delivery_assignments: [{ assignment_status: "PENDING" }], + }; + prisma.stores.findUnique.mockResolvedValue(mockStore); + prisma.deliveries.findMany.mockResolvedValue([deliveryWithPending]); + + const res = await request(app) + .get("/api/stores/1/deliveries") + .set("Cookie", authCookie); + + expect(res.body.deliveries[0].status).toBe("IN_DELIVERY"); + }); +}); + +// ─── DELETE /api/stores/:id/deliveries/:deliveryId ──────────────────────────── + +describe("DELETE /api/stores/:id/deliveries/:deliveryId", () => { + beforeEach(() => vi.clearAllMocks()); + + it("retorna 403 si la tienda no pertenece al usuario", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStoreOtherOwner); + + const res = await request(app) + .delete("/api/stores/1/deliveries/10") + .set("Cookie", authCookie); + expect(res.status).toBe(403); + }); + + it("retorna 404 si el delivery no pertenece a la tienda", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStore); + prisma.deliveries.findFirst.mockResolvedValue(null); - describe("POST /api/stores/:id/deliveries", () => { - it("returns 400 if fk_user is missing", async () => { - const res = await request(app) - .post("/api/stores/1/deliveries") - .set("Cookie", authCookie) - .send({}); - - expect(res.status).toBe(400); - expect(res.body.message).toMatch(/fk_user es requerido/); - }); - - it("returns 403 if store not owned by user", async () => { - prisma.stores.findUnique.mockResolvedValue({ - id_store: 1, - fk_user: 99, // User 1 is trying to access store owned by user 99 - status: true, - user: { id_user: 99, status: true } - }); - - const res = await request(app) - .post("/api/stores/1/deliveries") - .set("Cookie", authCookie) - .send({ fk_user: 2 }); - - expect(res.status).toBe(403); - expect(res.body.message).toMatch(/permisos/i); - }); - - it("returns 404 if delivery candidate not found", async () => { - prisma.stores.findUnique.mockResolvedValue({ - id_store: 1, - fk_user: 1, - status: true, - user: { id_user: 1, status: true } - }); - prisma.users.findFirst.mockResolvedValue(null); - - const res = await request(app) - .post("/api/stores/1/deliveries") - .set("Cookie", authCookie) - .send({ fk_user: 2 }); - - expect(res.status).toBe(404); - expect(res.body.message).toMatch(/Candidato a delivery no encontrado o no válido/i); - }); - - it("returns 409 if delivery already linked", async () => { - prisma.stores.findUnique.mockResolvedValue({ - id_store: 1, - fk_user: 1, - status: true, - user: { id_user: 1, status: true } - }); - prisma.users.findFirst.mockResolvedValue({ id_user: 2, role: "DELIVERY", status: true }); - - const prismaError = new Error("Unique constraint failed"); - prismaError.code = "P2002"; - prisma.deliveries.create.mockRejectedValue(prismaError); - prisma.deliveries.findUnique.mockResolvedValue({ fk_store: 1, fk_user: 2 }); - - const res = await request(app) - .post("/api/stores/1/deliveries") - .set("Cookie", authCookie) - .send({ fk_user: 2 }); - - expect(res.status).toBe(409); - expect(res.body.message).toMatch(/ya está vinculado/i); - }); - - it("returns 201 on success", async () => { - prisma.stores.findUnique.mockResolvedValue({ - id_store: 1, - fk_user: 1, - status: true, - user: { id_user: 1, status: true } - }); - prisma.users.findFirst.mockResolvedValue({ id_user: 2, role: "DELIVERY", status: true }); - prisma.deliveries.findUnique.mockResolvedValue(null); - - const mockDelivery = { id_delivery: 10, fk_store: 1, fk_user: 2, delivery_status: "INACTIVE" }; - prisma.deliveries.create.mockResolvedValue(mockDelivery); - - const res = await request(app) - .post("/api/stores/1/deliveries") - .set("Cookie", authCookie) - .send({ fk_user: 2 }); - - expect(res.status).toBe(201); - expect(res.body).toEqual(mockDelivery); - }); + const res = await request(app) + .delete("/api/stores/1/deliveries/10") + .set("Cookie", authCookie); + expect(res.status).toBe(404); + expect(res.body.message).toMatch(/Delivery no encontrado/i); + }); + + it("retorna 400 si el delivery tiene entregas activas (PENDING)", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStore); + prisma.deliveries.findFirst.mockResolvedValue({ + id_delivery: 10, + delivery_assignments: [{ id_delivery_assignment: 99 }], + }); + + const res = await request(app) + .delete("/api/stores/1/deliveries/10") + .set("Cookie", authCookie); + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/entregas activas/i); + }); + + it("retorna 204 y desvincula el delivery correctamente", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStore); + prisma.deliveries.findFirst.mockResolvedValue({ + id_delivery: 10, + delivery_assignments: [], // sin activas }); + prisma.deliveries.update.mockResolvedValue({}); + + const res = await request(app) + .delete("/api/stores/1/deliveries/10") + .set("Cookie", authCookie); - describe("GET /api/stores/:storeId/deliveries/:deliveryId/reviews", () => { - it("returns 400 when search is not numeric", async () => { - prisma.stores.findUnique.mockResolvedValue({ - id_store: 1, - fk_user: 1, - status: true, - user: { id_user: 1, status: true } - }); - prisma.deliveries.findUnique.mockResolvedValue({ id_delivery: 2, fk_store: 1 }); - - const res = await request(app) - .get("/api/stores/1/deliveries/2/reviews?search=abc") - .set("Cookie", authCookie); - - expect(res.status).toBe(400); - expect(res.body.message).toMatch(/ID de pedido/i); - }); - - it("returns 400 when rating range is invalid", async () => { - prisma.stores.findUnique.mockResolvedValue({ - id_store: 1, - fk_user: 1, - status: true, - user: { id_user: 1, status: true } - }); - prisma.deliveries.findUnique.mockResolvedValue({ id_delivery: 2, fk_store: 1 }); - - const res = await request(app) - .get("/api/stores/1/deliveries/2/reviews?minRating=6") - .set("Cookie", authCookie); - - expect(res.status).toBe(400); - expect(res.body.message).toMatch(/minRating/i); - }); - - it("returns 404 when delivery is not linked to the store", async () => { - prisma.stores.findUnique.mockResolvedValue({ - id_store: 1, - fk_user: 1, - status: true, - user: { id_user: 1, status: true } - }); - prisma.deliveries.findUnique.mockResolvedValue(null); - - const res = await request(app) - .get("/api/stores/1/deliveries/2/reviews") - .set("Cookie", authCookie); - - expect(res.status).toBe(404); - expect(res.body.message).toMatch(/delivery no encontrado/i); - }); - - it("returns 200 with reviews", async () => { - prisma.stores.findUnique.mockResolvedValue({ - id_store: 1, - fk_user: 1, - status: true, - user: { id_user: 1, status: true } - }); - prisma.deliveries.findUnique.mockResolvedValue({ id_delivery: 2, fk_store: 1 }); - prisma.deliveryReviews.findMany.mockResolvedValue([ - { - id_delivery_review: 5, - fk_order: 120, - rating: 5, - comment: "Entrega rápida", - created_at: new Date("2026-04-28T00:00:00.000Z"), - user: { name: "Juan" } - } - ]); - - const res = await request(app) - .get("/api/stores/1/deliveries/2/reviews?minRating=4&maxRating=5") - .set("Cookie", authCookie); - - expect(res.status).toBe(200); - expect(res.body.total).toBe(1); - expect(res.body.reviews[0]).toMatchObject({ - orderId: 120, - customerName: "Juan", - rating: 5 - }); - }); + expect(res.status).toBe(204); + expect(prisma.deliveries.update).toHaveBeenCalledWith({ + where: { id_delivery: 10 }, + data: { fk_store: null }, }); + }); + + it("no elimina al usuario, solo desvincula (fk_store = null)", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStore); + prisma.deliveries.findFirst.mockResolvedValue({ + id_delivery: 10, + delivery_assignments: [], + }); + prisma.deliveries.update.mockResolvedValue({}); + + await request(app) + .delete("/api/stores/1/deliveries/10") + .set("Cookie", authCookie); + + // update solo cambia fk_store, no toca status ni el usuario + expect(prisma.deliveries.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { fk_store: null }, + }) + ); + expect(prisma.users).not.toHaveProperty("delete"); + }); }); + +// ─── GET /api/stores/:storeId/deliveries/:deliveryId/reviews ────────────────── + +describe("GET /api/stores/:storeId/deliveries/:deliveryId/reviews", () => { + beforeEach(() => vi.clearAllMocks()); + + it("retorna 400 cuando search no es numérico", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStore); + prisma.deliveries.findUnique.mockResolvedValue({ id_delivery: 2, fk_store: 1 }); + + const res = await request(app) + .get("/api/stores/1/deliveries/2/reviews?search=abc") + .set("Cookie", authCookie); + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/ID de pedido/i); + }); + + it("retorna 400 cuando minRating está fuera de rango", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStore); + prisma.deliveries.findUnique.mockResolvedValue({ id_delivery: 2, fk_store: 1 }); + + const res = await request(app) + .get("/api/stores/1/deliveries/2/reviews?minRating=6") + .set("Cookie", authCookie); + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/minRating/i); + }); + + it("retorna 404 cuando el delivery no pertenece a la tienda", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStore); + prisma.deliveries.findUnique.mockResolvedValue(null); + + const res = await request(app) + .get("/api/stores/1/deliveries/2/reviews") + .set("Cookie", authCookie); + expect(res.status).toBe(404); + expect(res.body.message).toMatch(/delivery no encontrado/i); + }); + + it("retorna 200 con reseñas mapeadas correctamente", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStore); + prisma.deliveries.findUnique.mockResolvedValue({ id_delivery: 2, fk_store: 1 }); + prisma.deliveryReviews.findMany.mockResolvedValue([ + { + id_delivery_review: 5, + fk_order: 120, + rating: 5, + comment: "Entrega rápida", + created_at: new Date("2026-04-28T00:00:00.000Z"), + user: { name: "Juan" }, + }, + ]); + + const res = await request(app) + .get("/api/stores/1/deliveries/2/reviews?minRating=4&maxRating=5") + .set("Cookie", authCookie); + + expect(res.status).toBe(200); + expect(res.body.total).toBe(1); + expect(res.body.reviews[0]).toMatchObject({ + orderId: 120, + customerName: "Juan", + rating: 5, + }); + }); +}); \ No newline at end of file From 1ea8e8243e4de2673cc6e47471f1a2dc9cf65354 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20D=C3=ADaz?= Date: Wed, 6 May 2026 19:29:01 -0300 Subject: [PATCH 2/2] =?UTF-8?q?Correcciones=20de=20code=20rabbit=20para=20?= =?UTF-8?q?listado=20y=20eliminaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../commerce/deliveries/delivery.service.js | 45 +++++++++++-------- tests/unit/commerce/delivery.test.js | 43 ++++++++---------- 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/src/modules/commerce/deliveries/delivery.service.js b/src/modules/commerce/deliveries/delivery.service.js index 7a113d7..3dc804e 100644 --- a/src/modules/commerce/deliveries/delivery.service.js +++ b/src/modules/commerce/deliveries/delivery.service.js @@ -14,7 +14,14 @@ export const searchDeliveryCandidatesService = async (query) => { { email: { contains: query, mode: 'insensitive' } }, { phone: { contains: query, mode: 'insensitive' } } ], - delivery: null + AND: [ + { + OR: [ + { delivery: { is: null } }, + { delivery: { fk_store: null } }, + ], + }, + ], }, select: { id_user: true, @@ -162,27 +169,29 @@ export const deleteStoreDeliveryService = async (authenticatedUserId, storeIdStr const store = await getAuthorizedStoreOwnerService(authenticatedUserId, storeIdStr); const deliveryId = parsePositiveInteger(deliveryIdStr, "ID de delivery"); - const delivery = await prisma.deliveries.findFirst({ - where: { id_delivery: deliveryId, fk_store: store.id_store, status: true }, - select: { - id_delivery: true, - delivery_assignments: { - where: { status: true, assignment_status: { in: ["PENDING", "ACCEPTED"] } }, - select: { id_delivery_assignment: true }, - take: 1, + await prisma.$transaction(async (tx) => { + const delivery = await tx.deliveries.findFirst({ + where: { id_delivery: deliveryId, fk_store: store.id_store, status: true }, + select: { + id_delivery: true, + delivery_assignments: { + where: { status: true, assignment_status: { in: ["PENDING", "ACCEPTED"] } }, + select: { id_delivery_assignment: true }, + take: 1, + }, }, - }, - }); + }); - if (!delivery) throw new NotFoundError("Delivery no encontrado para este comercio"); + if (!delivery) throw new NotFoundError("Delivery no encontrado para este comercio"); - if (delivery.delivery_assignments.length > 0) { - throw new ValidationError("No se puede desvincular un delivery con entregas activas"); - } + if (delivery.delivery_assignments.length > 0) { + throw new ValidationError("No se puede desvincular un delivery con entregas activas"); + } - await prisma.deliveries.update({ - where: { id_delivery: deliveryId }, - data: { fk_store: null }, + await tx.deliveries.update({ + where: { id_delivery: deliveryId }, + data: { fk_store: null, delivery_status: "INACTIVE" }, + }); }); }; diff --git a/tests/unit/commerce/delivery.test.js b/tests/unit/commerce/delivery.test.js index 1d8d05a..50436a7 100644 --- a/tests/unit/commerce/delivery.test.js +++ b/tests/unit/commerce/delivery.test.js @@ -5,28 +5,21 @@ import { prisma } from "../../../src/lib/prisma.js"; // ─── MOCK DE PRISMA ────────────────────────────────────────────────────────── -vi.mock("../../../src/lib/prisma.js", () => ({ - prisma: { - users: { - findMany: vi.fn(), - findUnique: vi.fn(), - findFirst: vi.fn(), - }, - stores: { - findUnique: vi.fn(), - }, - deliveries: { - findMany: vi.fn(), - findUnique: vi.fn(), - findFirst: vi.fn(), - create: vi.fn(), - update: vi.fn(), - }, - deliveryReviews: { - findMany: vi.fn(), +vi.mock("../../../src/lib/prisma.js", () => { + const users = { findMany: vi.fn(), findUnique: vi.fn(), findFirst: vi.fn(), delete: vi.fn() }; + const stores = { findUnique: vi.fn() }; + const deliveries = { findMany: vi.fn(), findUnique: vi.fn(), findFirst: vi.fn(), create: vi.fn(), update: vi.fn() }; + const deliveryReviews = { findMany: vi.fn() }; + return { + prisma: { + users, + stores, + deliveries, + deliveryReviews, + $transaction: vi.fn((fn) => fn({ users, stores, deliveries, deliveryReviews })), }, - }, -})); + }; +}); vi.mock("jsonwebtoken", async () => { const actual = await vi.importActual("jsonwebtoken"); @@ -401,7 +394,7 @@ describe("DELETE /api/stores/:id/deliveries/:deliveryId", () => { expect(res.status).toBe(204); expect(prisma.deliveries.update).toHaveBeenCalledWith({ where: { id_delivery: 10 }, - data: { fk_store: null }, + data: { fk_store: null, delivery_status: "INACTIVE" }, }); }); @@ -417,13 +410,13 @@ describe("DELETE /api/stores/:id/deliveries/:deliveryId", () => { .delete("/api/stores/1/deliveries/10") .set("Cookie", authCookie); - // update solo cambia fk_store, no toca status ni el usuario + // update solo cambia fk_store y delivery_status, no toca al usuario expect(prisma.deliveries.update).toHaveBeenCalledWith( expect.objectContaining({ - data: { fk_store: null }, + data: { fk_store: null, delivery_status: "INACTIVE" }, }) ); - expect(prisma.users).not.toHaveProperty("delete"); + expect(prisma.users.delete).not.toHaveBeenCalled(); }); });