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 f64b5e4..5591fb8 100644 --- a/src/modules/commerce/deliveries/delivery.routes.js +++ b/src/modules/commerce/deliveries/delivery.routes.js @@ -2,57 +2,119 @@ 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(); +// GET /api/deliveries/search?q= +deliveryRouter.get("/search", authenticate, requireRole(ROLES.SELLER), searchDeliveries); + +// POST /api/stores/:id/deliveries — Vincular delivery al comercio +storeDeliveryRouter.post("/:id/deliveries", authenticate, requireRole(ROLES.SELLER), createDelivery); + /** * @swagger - * /api/deliveries/search: + * /api/stores/{id}/deliveries: * get: - * summary: Buscar deliveries disponibles para vincular a un comercio - * description: Busca usuarios activos con rol DELIVERY por email o telefono y retorna candidatos que aun no tienen un registro de delivery vinculado. + * 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: - * - cookieAuth: [] + * - bearerAuth: [] * parameters: - * - in: query - * name: q + * - in: path + * name: id + * required: true * schema: - * type: string - * description: Texto a buscar en email o telefono + * type: integer + * description: ID del comercio * responses: * 200: - * description: Candidatos encontrados + * description: Lista de repartidores y estadísticas * content: * application/json: * schema: - * $ref: '#/components/schemas/DeliverySearchCandidatesResponse' + * 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 - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/AuthErrorResponse' * 403: - * description: Solo comercios pueden buscar deliveries - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' + * description: No tiene permisos para acceder a este comercio + * 404: + * description: Comercio no encontrado */ -deliveryRouter.get("/search", authenticate, requireRole(ROLES.SELLER), searchDeliveries); +storeDeliveryRouter.get("/:id/deliveries", authenticate, requireRole(ROLES.SELLER), getStoreDeliveries); /** * @swagger - * /api/stores/{id}/deliveries: - * post: - * summary: Vincular un delivery a un comercio - * description: Crea un registro en Deliveries para asociar un usuario DELIVERY al comercio del vendedor autenticado. El delivery se crea con estado INACTIVE por defecto. + * /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: - * - cookieAuth: [] + * - bearerAuth: [] * parameters: * - in: path * name: id @@ -60,51 +122,25 @@ deliveryRouter.get("/search", authenticate, requireRole(ROLES.SELLER), searchDel * schema: * type: integer * description: ID del comercio - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/LinkStoreDeliveryRequest' + * - in: path + * name: deliveryId + * required: true + * schema: + * type: integer + * description: ID del delivery a desvincular * responses: - * 201: - * description: Delivery vinculado al comercio - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/StoreDeliveryResponse' + * 204: + * description: Repartidor desvinculado correctamente * 400: - * description: Datos invalidos o fk_user faltante - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' + * description: El repartidor tiene entregas activas * 401: * description: No autenticado - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/AuthErrorResponse' * 403: - * description: No tiene permisos para vincular deliveries a este comercio - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' + * description: No tiene permisos para acceder a este comercio * 404: - * description: Comercio o candidato a delivery no encontrado - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 409: - * description: El delivery ya esta vinculado a este comercio o a otro comercio - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' + * description: Delivery no encontrado para este comercio */ -storeDeliveryRouter.post("/:id/deliveries", authenticate, requireRole(ROLES.SELLER), createDelivery); +storeDeliveryRouter.delete("/:id/deliveries/:deliveryId", authenticate, requireRole(ROLES.SELLER), deleteStoreDelivery); /** * @swagger @@ -195,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 -); + "/:storeId/deliveries/:deliveryId/reviews", + authenticate, + requireRole(ROLES.SELLER), + getStoreDeliveryReviews +); \ No newline at end of file diff --git a/src/modules/commerce/deliveries/delivery.service.js b/src/modules/commerce/deliveries/delivery.service.js index 1101fb7..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, @@ -30,7 +37,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 +65,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 +75,126 @@ 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"); + + 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.delivery_assignments.length > 0) { + throw new ValidationError("No se puede desvincular un delivery con entregas activas"); + } + + await tx.deliveries.update({ + where: { id_delivery: deliveryId }, + data: { fk_store: null, delivery_status: "INACTIVE" }, + }); + }); +}; + export const getStoreDeliveryReviewsService = async ( authenticatedUserId, storeIdStr, diff --git a/tests/unit/commerce/delivery.test.js b/tests/unit/commerce/delivery.test.js index bef63dd..50436a7 100644 --- a/tests/unit/commerce/delivery.test.js +++ b/tests/unit/commerce/delivery.test.js @@ -3,242 +3,485 @@ import request from "supertest"; import app from "../../../src/app.js"; import { prisma } from "../../../src/lib/prisma.js"; -vi.mock("../../../src/lib/prisma.js", () => ({ +// ─── MOCK DE PRISMA ────────────────────────────────────────────────────────── + +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: { - findMany: vi.fn(), - findUnique: vi.fn(), - findFirst: vi.fn(), - }, - stores: { - findUnique: vi.fn(), - }, - deliveries: { - findUnique: vi.fn(), - create: vi.fn(), - }, - deliveryReviews: { - findMany: vi.fn(), - } - } -})); + users, + stores, + deliveries, + deliveryReviews, + $transaction: vi.fn((fn) => fn({ users, stores, deliveries, deliveryReviews })), + }, + }; +}); 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, }); + }); - 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); - }); + 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); + }); - 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 - }); - }); + 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); + + 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); + + expect(res.status).toBe(204); + expect(prisma.deliveries.update).toHaveBeenCalledWith({ + where: { id_delivery: 10 }, + data: { fk_store: null, delivery_status: "INACTIVE" }, + }); + }); + + 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 y delivery_status, no toca al usuario + expect(prisma.deliveries.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { fk_store: null, delivery_status: "INACTIVE" }, + }) + ); + expect(prisma.users.delete).not.toHaveBeenCalled(); + }); +}); + +// ─── 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