diff --git a/prisma/migrations/20260430025443_add_vehicle_type_to_delivery/migration.sql b/prisma/migrations/20260430025443_add_vehicle_type_to_delivery/migration.sql new file mode 100644 index 0000000..534d949 --- /dev/null +++ b/prisma/migrations/20260430025443_add_vehicle_type_to_delivery/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Deliveries" ADD COLUMN "vehicle_type" VARCHAR(50); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7ca3ab1..5c039f8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -353,6 +353,7 @@ model Deliveries { fk_store Int fk_user Int @unique delivery_status DeliveryStatus @default(ACTIVE) + vehicle_type String? @db.VarChar(50) status Boolean @default(true) created_at DateTime @default(now()) @db.Timestamptz updated_at DateTime @updatedAt @db.Timestamptz diff --git a/src/modules/delivery/delivery/delivery.controller.js b/src/modules/delivery/delivery/delivery.controller.js index 7137c60..be82169 100644 --- a/src/modules/delivery/delivery/delivery.controller.js +++ b/src/modules/delivery/delivery/delivery.controller.js @@ -2,13 +2,15 @@ import { ZodError } from 'zod'; import { registerDeliverySchema, - updateDeliveryStatusSchema + updateDeliveryStatusSchema, + updateDeliveryProfileSchema } from './delivery.validation.js'; import { registerDeliveryService, updateDeliveryStatusService, getPendingAssignmentsService, - getDeliveryByIdService + getDeliveryByIdService, + updateDeliveryProfileService } from './delivery.service.js'; // Registrar delivery @@ -112,3 +114,41 @@ export const getDeliveryById = async (req, res) => { } }; +// Actualizar perfil del delivery +export const updateDeliveryProfile = async (req, res) => { + try { + const { id } = req.params; + const deliveryId = Number.parseInt(id, 10); + if (Number.isNaN(deliveryId)) { + return res.status(400).json({ + error: { code: 400, message: "ID inválido" } + }); + } + + const validData = updateDeliveryProfileSchema.parse(req.body); + const file = req.file; + + const result = await updateDeliveryProfileService( + deliveryId, + req.user, + validData, + file + ); + + return res.status(200).json(result); + } catch (error) { + if (error instanceof ZodError) { + return res.status(400).json({ + error: { + code: 400, + message: error.issues[0].message, + details: error.issues + } + }); + } + return res.status(error.status || 500).json({ + error: { code: error.status || 500, message: error.message } + }); + } +}; + diff --git a/src/modules/delivery/delivery/delivery.routes.js b/src/modules/delivery/delivery/delivery.routes.js index d1c8b08..a7a6d4c 100644 --- a/src/modules/delivery/delivery/delivery.routes.js +++ b/src/modules/delivery/delivery/delivery.routes.js @@ -3,11 +3,13 @@ import { Router } from "express"; import authenticate from "../../../config/jwt.config.js"; import { ROLES } from "../../../utils/contants/roles.constant.js"; import { requireRole } from "../../../middlewares/auth.middleware.js"; +import { upload } from "../../../middlewares/upload.middleware.js"; import { registerDelivery, updateDeliveryStatus, getPendingAssignments, - getDeliveryById + getDeliveryById, + updateDeliveryProfile } from "./delivery.controller.js"; const router = Router(); @@ -20,6 +22,7 @@ router.post("/register", registerDelivery); router.patch("/:id/status", authenticate, requireRole(ROLES.DELIVERY), updateDeliveryStatus); router.get("/:id/assignments", authenticate, requireRole(ROLES.DELIVERY), getPendingAssignments); router.get("/:id", authenticate, requireRole(ROLES.DELIVERY), getDeliveryById); +router.put("/:id", authenticate, requireRole(ROLES.DELIVERY), upload.single("avatarUrl"), updateDeliveryProfile); diff --git a/src/modules/delivery/delivery/delivery.service.js b/src/modules/delivery/delivery/delivery.service.js index a6978c1..aeaae5b 100644 --- a/src/modules/delivery/delivery/delivery.service.js +++ b/src/modules/delivery/delivery/delivery.service.js @@ -1,6 +1,7 @@ //delivery.service.js import { prisma } from '../../../lib/prisma.js'; import bcrypt from 'bcrypt'; +import { upsertUserImage } from '../../images/services/user-image.service.js'; const SALT_ROUNDS = 10; @@ -124,3 +125,72 @@ export const getDeliveryByIdService = async (id_delivery) => { return delivery; }; + +// Actualizar perfil de delivery (nombre, teléfono, tipo de vehículo y avatar) +export const updateDeliveryProfileService = async (id_delivery, authUser, data, file) => { + const deliveryId = Number(id_delivery); + + const delivery = await prisma.deliveries.findUnique({ + where: { id_delivery: deliveryId }, + include: { user: true } + }); + + if (!delivery) { + throw { status: 404, message: "Delivery no encontrado" }; + } + + // Verificar que sea el dueño + if (delivery.fk_user !== authUser.id_user && authUser.role !== 'ADMIN') { + throw { status: 403, message: "No tienes permiso para actualizar este perfil de delivery" }; + } + + const { name, phone, vehicleType } = data; + let avatarUrl = delivery.user.avatar_url; + + // Si envían un archivo de imagen, usar upsertUserImage + if (file) { + try { + avatarUrl = await upsertUserImage(delivery.fk_user, file, authUser); + } catch (error) { + if (error.statusCode) throw { status: error.statusCode, message: error.message }; + throw { status: 500, message: `Error al subir imagen: ${error.message}` }; + } + } + + // Actualizar Users y Deliveries en una sola transacción para evitar estados parciales + const hasUserChanges = name || phone; + const hasDeliveryChanges = vehicleType !== undefined; + + if (hasUserChanges || hasDeliveryChanges) { + await prisma.$transaction(async (tx) => { + if (hasUserChanges) { + await tx.users.update({ + where: { id_user: delivery.fk_user }, + data: { + ...(name && { name }), + ...(phone && { phone }) + } + }); + } + + if (hasDeliveryChanges) { + await tx.deliveries.update({ + where: { id_delivery: deliveryId }, + data: { vehicle_type: vehicleType } + }); + } + }); + } + + // Obtener delivery actualizado para devolverlo + const updatedDelivery = await prisma.deliveries.findUnique({ + where: { id_delivery: deliveryId }, + include: { + user: { + select: { name: true, phone: true, avatar_url: true, email: true } + } + } + }); + + return updatedDelivery; +}; diff --git a/src/modules/delivery/delivery/delivery.validation.js b/src/modules/delivery/delivery/delivery.validation.js index c3bb327..80df909 100644 --- a/src/modules/delivery/delivery/delivery.validation.js +++ b/src/modules/delivery/delivery/delivery.validation.js @@ -9,6 +9,12 @@ export const registerDeliverySchema = z.object({ }); +export const updateDeliveryProfileSchema = z.object({ + name: z.string().min(2, "Nombre debe tener mínimo 2 caracteres").optional(), + phone: z.string().regex(/^\d{10,}$/, "Teléfono debe tener mínimo 10 dígitos").optional(), + vehicleType: z.string().min(1, "Tipo de vehículo no puede estar vacío").max(50, "Tipo de vehículo no puede exceder 50 caracteres").optional() +}); + export const updateDeliveryStatusSchema = z.object({ diff --git a/tests/unit/delivery/delivery-profile.test.js b/tests/unit/delivery/delivery-profile.test.js new file mode 100644 index 0000000..c9f8325 --- /dev/null +++ b/tests/unit/delivery/delivery-profile.test.js @@ -0,0 +1,271 @@ +//delivery-profile.test.js +import { vi, describe, it, expect, beforeEach } from "vitest"; +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", () => { + const mockPrisma = { + deliveries: { + findUnique: vi.fn(), + update: vi.fn(), + }, + users: { + findUnique: vi.fn(), + update: vi.fn(), + }, + $transaction: vi.fn((callback) => callback(mockPrisma)), + }; + return { prisma: mockPrisma }; +}); + +// Mock de JWT: usuario autenticado con rol DELIVERY y id_user = 10 +vi.mock("jsonwebtoken", async () => { + const actual = await vi.importActual("jsonwebtoken"); + return { + default: { + ...actual.default, + verify: vi.fn((token, secret, callback) => { + callback(null, { id_user: 10, email: "delivery@test.com", role: "DELIVERY" }); + }), + }, + }; +}); + +// Mock del servicio de imagen para evitar llamadas reales a Supabase +vi.mock("../../../src/modules/images/services/user-image.service.js", () => ({ + upsertUserImage: vi.fn().mockResolvedValue("https://storage.example.com/avatar.jpg"), +})); + +const authCookie = "userToken=mock-token"; + +const mockUser = { + id_user: 10, + name: "Juan Delivery", + email: "delivery@test.com", + phone: "1234567890", + avatar_url: null, + role: "DELIVERY", +}; + +const mockDelivery = { + id_delivery: 1, + fk_user: 10, + fk_store: 1, + delivery_status: "ACTIVE", + vehicle_type: "MOTO", + status: true, + user: mockUser, +}; + +const mockUpdatedDelivery = { + id_delivery: 1, + fk_user: 10, + fk_store: 1, + delivery_status: "ACTIVE", + vehicle_type: "BICICLETA", + status: true, + user: { + name: "Juan Actualizado", + phone: "0987654321", + avatar_url: null, + email: "delivery@test.com", + }, +}; + +describe("PUT /api/deliveries/:id — Actualizar perfil de delivery", () => { + beforeEach(() => vi.clearAllMocks()); + + // Helper: ejecuta PUT /api/deliveries/:id con cookie de auth + const putDelivery = (id, body) => + request(app).put(`/api/deliveries/${id}`).set("Cookie", authCookie).send(body); + + // Helper: configura mocks de findUnique para el flujo de éxito (verificación + respuesta final) + const setupProfileMocks = (updatedOverride = {}) => { + prisma.deliveries.findUnique + .mockResolvedValueOnce(mockDelivery) + .mockResolvedValueOnce({ ...mockUpdatedDelivery, ...updatedOverride }); + prisma.users.update.mockResolvedValue({}); + prisma.deliveries.update.mockResolvedValue({}); + }; + + // --- Validación de parámetros --- + + it("devuelve 400 cuando el ID no es numérico", async () => { + const res = await request(app) + .put("/api/deliveries/abc") + .set("Cookie", authCookie) + .send({ name: "Nombre" }); + + expect(res.status).toBe(400); + expect(res.body.error.message).toMatch(/ID inválido/i); + }); + + // --- Autenticación y autorización --- + + it("devuelve 401 cuando no hay autenticación", async () => { + const res = await request(app).put("/api/deliveries/1").send({ name: "Nombre" }); + + expect(res.status).toBe(401); + }); + + it("devuelve 403 cuando el rol no es DELIVERY", async () => { + const jwt = await import("jsonwebtoken"); + jwt.default.verify.mockImplementationOnce((token, secret, callback) => { + callback(null, { id_user: 10, email: "customer@test.com", role: "CUSTOMER" }); + }); + + const res = await putDelivery(1, { name: "Nombre" }); + + expect(res.status).toBe(403); + expect(res.body.error.message).toMatch(/permisos/i); + }); + + // --- Delivery no encontrado --- + + it("devuelve 404 cuando el delivery no existe", async () => { + prisma.deliveries.findUnique.mockResolvedValue(null); + + const res = await putDelivery(999, { name: "Nombre" }); + + expect(res.status).toBe(404); + expect(res.body.error.message).toMatch(/delivery no encontrado/i); + }); + + // --- Propiedad (403 si no es dueño) --- + + it("devuelve 403 cuando intenta actualizar perfil de otro delivery", async () => { + prisma.deliveries.findUnique.mockResolvedValueOnce({ ...mockDelivery, fk_user: 99 }); + + const res = await putDelivery(1, { name: "Nombre Intruso" }); + + expect(res.status).toBe(403); + expect(res.body.error.message).toMatch(/no tienes permiso/i); + }); + + // --- Actualización exitosa: solo nombre --- + + it("devuelve 200 al actualizar solo el nombre", async () => { + setupProfileMocks({ user: { ...mockUpdatedDelivery.user, name: "Nuevo Nombre" } }); + + const res = await putDelivery(1, { name: "Nuevo Nombre" }); + + expect(res.status).toBe(200); + expect(prisma.users.update).toHaveBeenCalledWith({ + where: { id_user: 10 }, + data: { name: "Nuevo Nombre" }, + }); + }); + + // --- Actualización exitosa: solo teléfono --- + + it("devuelve 200 al actualizar solo el teléfono", async () => { + setupProfileMocks({ user: { ...mockUpdatedDelivery.user, phone: "0987654321" } }); + + const res = await putDelivery(1, { phone: "0987654321" }); + + expect(res.status).toBe(200); + expect(prisma.users.update).toHaveBeenCalledWith({ + where: { id_user: 10 }, + data: { phone: "0987654321" }, + }); + }); + + // --- Actualización exitosa: vehicleType --- + + it("devuelve 200 al actualizar vehicleType", async () => { + setupProfileMocks({ vehicle_type: "BICICLETA" }); + + const res = await putDelivery(1, { vehicleType: "BICICLETA" }); + + expect(res.status).toBe(200); + expect(prisma.deliveries.update).toHaveBeenCalledWith({ + where: { id_delivery: 1 }, + data: { vehicle_type: "BICICLETA" }, + }); + }); + + // --- Actualización exitosa: múltiples campos --- + + it("devuelve 200 al actualizar nombre, teléfono y vehicleType juntos", async () => { + setupProfileMocks(); + + const res = await putDelivery(1, { + name: "Juan Actualizado", + phone: "0987654321", + vehicleType: "BICICLETA", + }); + + expect(res.status).toBe(200); + expect(prisma.users.update).toHaveBeenCalledWith({ + where: { id_user: 10 }, + data: { name: "Juan Actualizado", phone: "0987654321" }, + }); + expect(prisma.deliveries.update).toHaveBeenCalledWith({ + where: { id_delivery: 1 }, + data: { vehicle_type: "BICICLETA" }, + }); + }); + + // --- Validación de campos --- + + it("devuelve 400 cuando el nombre es muy corto", async () => { + const res = await putDelivery(1, { name: "A" }); + expect(res.status).toBe(400); + }); + + it("devuelve 400 cuando el teléfono tiene formato inválido", async () => { + const res = await putDelivery(1, { phone: "123" }); + expect(res.status).toBe(400); + }); + + it("devuelve 400 cuando vehicleType es un string vacío", async () => { + const res = await putDelivery(1, { vehicleType: "" }); + expect(res.status).toBe(400); + }); + + // --- Upload de imagen --- + + it("devuelve 200 al subir imagen con perfil", async () => { + const { upsertUserImage } = await import("../../../src/modules/images/services/user-image.service.js"); + + setupProfileMocks({ + user: { ...mockUpdatedDelivery.user, avatar_url: "https://storage.example.com/avatar.jpg" }, + }); + + const res = await request(app) + .put("/api/deliveries/1") + .set("Cookie", authCookie) + .field("name", "Con Imagen") + .attach("avatarUrl", Buffer.from("fake-image-data"), { + filename: "avatar.png", + contentType: "image/png", + }); + + expect(res.status).toBe(200); + expect(upsertUserImage).toHaveBeenCalled(); + }); + + // --- Sin cambios (body vacío) --- + + it("devuelve 200 cuando se envía body vacío (sin cambios)", async () => { + setupProfileMocks(); + + const res = await putDelivery(1, {}); + + expect(res.status).toBe(200); + expect(prisma.users.update).not.toHaveBeenCalled(); + expect(prisma.deliveries.update).not.toHaveBeenCalled(); + }); + + // --- Error interno --- + + it("devuelve 500 cuando prisma falla inesperadamente", async () => { + prisma.deliveries.findUnique.mockRejectedValue(new Error("Database error")); + + const res = await putDelivery(1, { name: "Nombre" }); + + expect(res.status).toBe(500); + }); +});