diff --git a/src/modules/commerce/commerces/store.controller.js b/src/modules/commerce/commerces/store.controller.js index 0956aa9..a29eb51 100644 --- a/src/modules/commerce/commerces/store.controller.js +++ b/src/modules/commerce/commerces/store.controller.js @@ -5,6 +5,7 @@ import { getStoresService, getAllProductsByStoreService, filterStoreProductsService, + updateStoreStatusService, deleteStoreService } from "./store.service.js"; import jwt from "jsonwebtoken"; @@ -60,13 +61,34 @@ export const updateStore = async (req, res) => { } }; +// Ruta pública — clientes ven solo comercios ACTIVE export const getStoreById = async (req, res) => { try { const { id } = req.params; const store = await getStoreByIdService(id); - if (!store || !store.status) { - throw { status: 404, message: "Comercio no encontrado" }; + //if (!store || !store.status) { + //throw { status: 404, message: "Comercio no encontrado" }; + //} + return res.status(200).json(store); + } catch (error) { + return res.status(error.status || 500).json({ + message: error.message || "Error interno" + }); + } +}; + +// Ruta autenticada — SELLER puede ver su comercio aunque esté INACTIVE +export const getMyStore = async (req, res) => { + try { + const { id } = req.params; + + // Verificar que el comercio pertenece al usuario autenticado + const store = await getStoreByIdService(id, { ignoreStoreStatus: true }); + + if (store.user?.id_user !== req.user?.id_user) { + return res.status(403).json({ message: "No tenés permisos para ver este comercio" }); } + return res.status(200).json(store); } catch (error) { return res.status(error.status || 500).json({ @@ -123,6 +145,33 @@ export const filterStoreProducts = async (req, res) => { } }; +export const updateStoreStatus = async (req, res) => { + try { + const { id } = req.params; + const { store_status } = req.body; + + if (!store_status) { + return res.status(400).json({ message: "store_status es requerido" }); + } + + const store = await updateStoreStatusService( + req.user?.id_user, + id, + store_status + ); + + return res.status(200).json({ + success: true, + message: `Comercio ${store_status === "ACTIVE" ? "habilitado" : "deshabilitado"} exitosamente`, + data: store + }); + } catch (error) { + return res.status(error.status || 500).json({ + message: error.message || "Error interno del servidor" + }); + } +}; + /** * * @param {*} req diff --git a/src/modules/commerce/commerces/store.routes.js b/src/modules/commerce/commerces/store.routes.js index d0f139d..cc6ddd8 100644 --- a/src/modules/commerce/commerces/store.routes.js +++ b/src/modules/commerce/commerces/store.routes.js @@ -4,9 +4,11 @@ import { createStore, updateStore, getStoreById, + getMyStore, getStores, getAllProductsByStore, filterStoreProducts, + updateStoreStatus, deleteStore } from "./store.controller.js"; import { parsePagination } from "../../../middlewares/pagination.middleware.js"; @@ -156,6 +158,9 @@ router.put("/:id", authenticate, updateStore); */ router.get("/", getStores); +// Ruta autenticada para el SELLER — va ANTES de /:id para no colisionar +router.get("/my/:id", authenticate, getMyStore); // ← agregar + /** * @swagger * /api/commerces/{id}: @@ -307,6 +312,9 @@ router.get( filterStoreProducts ); +// PATCH /api/commerces/:id/status — habilitar/deshabilitar comercio +router.patch("/:id/status", authenticate, updateStoreStatus); + /** * @swagger * /api/commerces/{id}: diff --git a/src/modules/commerce/commerces/store.service.js b/src/modules/commerce/commerces/store.service.js index e2469c8..30db67c 100644 --- a/src/modules/commerce/commerces/store.service.js +++ b/src/modules/commerce/commerces/store.service.js @@ -755,7 +755,7 @@ export const updateStoreService = async ( } }; -export const getStoreByIdService = async (id) => { +export const getStoreByIdService = async (id, { ignoreStoreStatus = false } = {}) => { try { // validaciones básicas if (!id) { @@ -779,6 +779,7 @@ export const getStoreByIdService = async (id) => { instagram_url: true, tiktok_url: true, status: true, + store_status: true, created_at: true, user: { select: { id_user: true, name: true, email: true } @@ -826,6 +827,11 @@ export const getStoreByIdService = async (id) => { throw { status: 404, message: "Comercio no encontrado" }; } + // DESPUÉS — null se trata como ACTIVE (comportamiento por defecto del schema) + if (!ignoreStoreStatus && store.store_status && store.store_status !== "ACTIVE") { + throw { status: 404, message: "Comercio no disponible" }; + } + return mapStoreWithPricedProducts(store); } catch (error) { @@ -1059,6 +1065,33 @@ export const filterStoreProductsService = async (id, filters, pagination) => { } }; +export const updateStoreStatusService = async (authenticatedUserId, storeId, store_status) => { + const store = await getAuthorizedStoreOwnerService(authenticatedUserId, storeId); + + const ALLOWED_STATUSES = ["ACTIVE", "INACTIVE"]; + if (!ALLOWED_STATUSES.includes(store_status)) { + throw { status: 400, message: "store_status debe ser ACTIVE o INACTIVE" }; + } + + await prisma.$transaction([ + prisma.stores.update({ + where: { id_store: store.id_store }, + data: { store_status } + }), + // Al desactivar → ocultar productos. Al activar → hacerlos visibles nuevamente. + prisma.products.updateMany({ + where: { fk_store: store.id_store, status: true }, + data: { visible: store_status === "ACTIVE" } + }) + ]); + + const updated = await prisma.stores.findUnique({ + where: { id_store: store.id_store }, + select: { id_store: true, name: true, store_status: true, status: true } + }); + + return updated; +}; /** * Esta funcion se utiliza para el borrado logico de un comercio y sus respectivos productos en base al usuario autenticado que debe ser el dueño. @@ -1091,7 +1124,10 @@ export const getStoresService = async (filters = {}) => { const categoryIdRaw = filters.storeCategoryId ?? filters.categoryId ?? filters.fk_store_category; - const where = { status: true }; + const where = { + status: true, + store_status: "ACTIVE" + }; if (search) { where.OR = [ diff --git a/tests/unit/commerce/store-status.test.js b/tests/unit/commerce/store-status.test.js new file mode 100644 index 0000000..33aad4e --- /dev/null +++ b/tests/unit/commerce/store-status.test.js @@ -0,0 +1,225 @@ +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"; + +vi.mock("../../../src/lib/prisma.js", () => ({ + prisma: { + stores: { + findUnique: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + }, + products: { + updateMany: vi.fn(), + }, + $transaction: 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" }); + }), + } + }; +}); + +// ─── Datos de prueba ────────────────────────────────────────────────────────── +const mockActiveStore = { + id_store: 1, + fk_user: 1, + name: "Comercio Test", + store_status: "ACTIVE", + status: true, + logo: null, + user: { id_user: 1, status: true }, +}; + +const mockInactiveStore = { + ...mockActiveStore, + store_status: "INACTIVE", +}; + +const authCookie = "userToken=mock-token"; + +// ─── Tests: GET /api/commerces/:id (ruta pública) ──────────────────────────── +describe("GET /api/commerces/:id — ruta pública", () => { + beforeEach(() => vi.clearAllMocks()); + + it("devuelve 404 cuando el comercio está INACTIVE", async () => { + prisma.stores.findUnique.mockResolvedValue({ + ...mockInactiveStore, + user: { id_user: 1, name: "Test", email: "test@test.com" }, + store_category: { id_store_category: 1, name: "Tecnología" }, + products: [], + addresses: [], + }); + + const res = await request(app).get("/api/commerces/1"); + + expect(res.status).toBe(404); + expect(res.body.message).toMatch(/comercio no disponible/i); + }); + + it("devuelve 200 cuando el comercio está ACTIVE", async () => { + prisma.stores.findUnique.mockResolvedValue({ + ...mockActiveStore, + user: { id_user: 1, name: "Test", email: "test@test.com" }, + store_category: { id_store_category: 1, name: "Tecnología" }, + products: [], + addresses: [], + }); + + const res = await request(app).get("/api/commerces/1"); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("id_store", 1); + }); + + it("devuelve 404 cuando el comercio no existe", async () => { + prisma.stores.findUnique.mockResolvedValue(null); + + const res = await request(app).get("/api/commerces/999"); + + expect(res.status).toBe(404); + expect(res.body.message).toMatch(/comercio no encontrado/i); + }); +}); + +// ─── Tests: GET /api/commerces/my/:id (ruta autenticada SELLER) ────────────── +describe("GET /api/commerces/my/:id — ruta autenticada", () => { + beforeEach(() => vi.clearAllMocks()); + + it("devuelve 200 cuando el comercio está INACTIVE para el SELLER dueño", async () => { + prisma.stores.findUnique.mockResolvedValue({ + ...mockInactiveStore, + user: { id_user: 1, name: "Test", email: "test@test.com" }, + store_category: { id_store_category: 1, name: "Tecnología" }, + products: [], + addresses: [], + }); + + const res = await request(app) + .get("/api/commerces/my/1") + .set("Cookie", authCookie); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("store_status", "INACTIVE"); + }); + + it("devuelve 403 cuando el SELLER no es dueño del comercio", async () => { + prisma.stores.findUnique.mockResolvedValue({ + ...mockActiveStore, + fk_user: 99, + user: { id_user: 99, name: "Otro", email: "otro@test.com" }, + store_category: { id_store_category: 1, name: "Tecnología" }, + products: [], + addresses: [], + }); + + const res = await request(app) + .get("/api/commerces/my/1") + .set("Cookie", authCookie); + + expect(res.status).toBe(403); + expect(res.body.message).toMatch(/permisos/i); + }); + + it("devuelve 401 cuando no hay cookie de autenticación", async () => { + const res = await request(app).get("/api/commerces/my/1"); + + expect(res.status).toBe(401); + }); +}); + +// ─── Tests: PATCH /api/commerces/:id/status ────────────────────────────────── +describe("PATCH /api/commerces/:id/status", () => { + beforeEach(() => vi.clearAllMocks()); + + it("devuelve 400 cuando store_status no se envía", async () => { + const res = await request(app) + .patch("/api/commerces/1/status") + .set("Cookie", authCookie) + .send({}); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/store_status es requerido/i); + }); + + it("devuelve 400 cuando store_status tiene un valor inválido", async () => { + prisma.stores.findUnique.mockResolvedValue(mockActiveStore); + + const res = await request(app) + .patch("/api/commerces/1/status") + .set("Cookie", authCookie) + .send({ store_status: "SUSPENDIDO" }); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/ACTIVE o INACTIVE/i); + }); + + it("devuelve 200 y mensaje correcto al deshabilitar el comercio", async () => { + prisma.stores.findUnique + .mockResolvedValueOnce(mockActiveStore) + .mockResolvedValueOnce({ + id_store: 1, + name: "Comercio Test", + store_status: "INACTIVE", + status: true, + }); + prisma.$transaction.mockResolvedValue([{}, {}]); + + const res = await request(app) + .patch("/api/commerces/1/status") + .set("Cookie", authCookie) + .send({ store_status: "INACTIVE" }); + + expect(res.status).toBe(200); + expect(res.body.message).toMatch(/deshabilitado/i); + expect(res.body.success).toBe(true); + }); + + it("devuelve 200 y mensaje correcto al habilitar el comercio", async () => { + prisma.stores.findUnique + .mockResolvedValueOnce(mockInactiveStore) + .mockResolvedValueOnce({ + id_store: 1, + name: "Comercio Test", + store_status: "ACTIVE", + status: true, + }); + prisma.$transaction.mockResolvedValue([{}, {}]); + + const res = await request(app) + .patch("/api/commerces/1/status") + .set("Cookie", authCookie) + .send({ store_status: "ACTIVE" }); + + expect(res.status).toBe(200); + expect(res.body.message).toMatch(/habilitado/i); + }); + + it("devuelve 401 cuando no hay cookie de autenticación", async () => { + const res = await request(app) + .patch("/api/commerces/1/status") + .send({ store_status: "INACTIVE" }); + + expect(res.status).toBe(401); + }); + + it("devuelve 404 cuando el comercio no existe", async () => { + prisma.stores.findUnique.mockResolvedValue(null); + + const res = await request(app) + .patch("/api/commerces/1/status") + .set("Cookie", authCookie) + .send({ store_status: "INACTIVE" }); + + expect(res.status).toBe(404); + }); +}); \ No newline at end of file