/**
 * test/services/DuplicatedMenuService.test.js
 *
 * Suite unitaria del DuplicatedMenuService:
 *
 *   ▸ Helpers exportados:
 *       - stripMetaFields  (limpia id/createdAt/updatedAt/created_at/updated_at
 *                           y aplica overrides para FKs)
 *       - buildIdMap       (Map<oldId,newId> + error 500 si hay mismatch)
 *
 *   ▸ Validaciones de existencia (4 × 404):
 *       - userFrom no existe
 *       - userTo no existe
 *       - restaurantFrom no existe
 *       - restaurantTo no existe
 *
 *   ▸ Reglas de negocio:
 *       - 409 si el restaurante destino ya tiene menús activos
 *       - clonedCount: 0 si el restaurante origen no tiene menús
 *
 *   ▸ Clonado completo (4 niveles):
 *       - menus → items → extra_sections → extra_items
 *       - mapeo correcto oldId → newId en cada nivel
 *       - FKs reescritas con los IDs del destino
 *       - id/createdAt/updatedAt removidos antes del INSERT
 *
 *   ▸ Estructura de las queries que el servicio emite:
 *       - SELECT destino filtra por deleted=0 (salvaguarda)
 *       - SELECTs origen NO filtran por deleted (alineado a las queries
 *         acordadas con producto)
 *
 *   ▸ Edge cases por nivel vacío:
 *       - origen sin items → no se ejecutan niveles 3 y 4
 *       - origen sin secciones → no se ejecuta nivel 4
 *       - origen sin extras → idMap.extraItems queda vacío
 *
 *   ▸ Transacción / rollback:
 *       - toda mutación corre dentro de sails.getDatastore().transaction()
 *       - errores propagan hacia afuera (Sails los traduce a rollback real)
 *       - mismatch de longitudes en buildIdMap aborta la txn (DuplicatedMenuError 500)
 */

const path = require('path');
const {
  makeChainable,
  makeRejectedChainable,
  makeSailsMock,
  makeModelMocks
} = require('../helpers/waterlineMocks');

const SERVICE_PATH = path.resolve(
  __dirname, '..', '..', 'api', 'services', 'DuplicatedMenuService.js'
);
const UTILS_PATH = path.resolve(
  __dirname, '..', '..', 'api', 'utils', 'duplicatedMenuUtils.js'
);

// ---------------------------------------------------------------------------
// Fixtures realistas — modelados sobre los INSERTs y SELECTs acordados.
// ---------------------------------------------------------------------------

const FIXTURES = {
  emails: {
    from: 'origen@meep.com',
    to:   'destino@meep.com'
  },

  userFrom: { id: 100, email: 'origen@meep.com' },
  userTo:   { id: 200, email: 'destino@meep.com' },

  restaurantFrom: { id: 1000, user_id: 100 },
  restaurantTo:   { id: 2000, user_id: 200 },

  // Nivel 1 — restaurant_menu (2 menús origen)
  sourceMenus: [
    {
      id: 3, name: 'Alitas', description: 'Las mas grandes y jugosas',
      created: '2020-04-08 14:16:51', restaurant_id: 1000,
      image: 'app/webroot/uploads/1/5e8e5b636b1dc.png',
      active: 1, has_menu_item: 1, index: 0, deleted: 0,
      createdAt: '2020-04-08T14:16:51Z', updatedAt: '2020-04-08T14:16:51Z'
    },
    {
      id: 4, name: 'RIBS', description: 'Carnosas y Jugosas Costillas',
      created: '2020-04-08 14:16:42', restaurant_id: 1000,
      image: 'app/webroot/uploads/1/5e8e5b5a7a142.png',
      active: 1, has_menu_item: 1, index: 1, deleted: 0,
      createdAt: '2020-04-08T14:16:42Z', updatedAt: '2020-04-08T14:16:42Z'
    }
  ],
  insertedMenus: [
    { id: 90, name: 'Alitas', restaurant_id: 2000 },
    { id: 91, name: 'RIBS',   restaurant_id: 2000 }
  ],

  // Nivel 2 — restaurant_menu_item (un item por cada menú)
  sourceItems: [
    {
      id: 30, name: '7 Piezas', description: ' 1 dip de 59ml', price: 76,
      image: 'app/webroot/uploads/1/5e8e4acd8888e.png',
      created: '2020-04-08 13:06:05', active: 1,
      restaurant_menu_id: 3, out_of_order: 0, index: 0, deleted: 0
    },
    {
      id: 40, name: 'RIBS ARMY', description: '340g de Costillas', price: 134,
      image: 'app/webroot/uploads/1/5e8e5afaa2094.png',
      created: '2020-04-08 14:15:06', active: 1,
      restaurant_menu_id: 4, out_of_order: 0, index: 1, deleted: 0
    }
  ],
  insertedItems: [
    { id: 300, restaurant_menu_id: 90 },
    { id: 400, restaurant_menu_id: 91 }
  ],

  // Nivel 3 — restaurant_menu_extra_section
  sourceSections: [
    {
      id: 2, name: 'Salsa', restaurant_id: 1000, active: 1,
      restaurant_menu_item_id: 30, required: 1, out_of_order: 0, deleted: 0
    }
  ],
  insertedSections: [
    { id: 999, restaurant_menu_item_id: 300, restaurant_id: 2000 }
  ],

  // Nivel 4 — restaurant_menu_extra_item
  sourceExtras: [
    {
      id: 8, name: 'Picante HOT', price: 0,
      created: '2020-04-08 13:07:33', active: 1,
      restaurant_menu_extra_section_id: 2, out_of_order: 0, deleted: 0
    },
    {
      id: 9, name: 'BOMBA', price: 0,
      created: '2020-04-08 13:07:52', active: 1,
      restaurant_menu_extra_section_id: 2, out_of_order: 0, deleted: 0
    }
  ],
  insertedExtras: [
    { id: 8000, restaurant_menu_extra_section_id: 999 },
    { id: 9000, restaurant_menu_extra_section_id: 999 }
  ]
};

// ---------------------------------------------------------------------------
// Suite
// ---------------------------------------------------------------------------

describe('DuplicatedMenuService', () => {
  let svc;
  let DuplicatedMenuError;
  let mocks;
  let sailsMock;

  /**
   * Helper local: deja todos los mocks listos para un happy path completo.
   * Cada test puede sobre-escribir el comportamiento que necesite.
   */
  function setupHappyPath() {
    mocks.User.findOne
      .mockResolvedValueOnce(FIXTURES.userFrom)
      .mockResolvedValueOnce(FIXTURES.userTo);

    mocks.Restaurant.findOne
      .mockResolvedValueOnce(FIXTURES.restaurantFrom)
      .mockResolvedValueOnce(FIXTURES.restaurantTo);

    mocks.RestaurantMenu.find
      // 1ª llamada: destino vacío
      .mockReturnValueOnce(makeChainable([]))
      // 2ª llamada: menús origen
      .mockReturnValueOnce(makeChainable(FIXTURES.sourceMenus));
    mocks.RestaurantMenu.createEach
      .mockReturnValueOnce(makeChainable(FIXTURES.insertedMenus));

    mocks.RestaurantMenuItem.find
      .mockReturnValueOnce(makeChainable(FIXTURES.sourceItems));
    mocks.RestaurantMenuItem.createEach
      .mockReturnValueOnce(makeChainable(FIXTURES.insertedItems));

    mocks.Restaurantmenuextrasection.find
      .mockReturnValueOnce(makeChainable(FIXTURES.sourceSections));
    mocks.Restaurantmenuextrasection.createEach
      .mockReturnValueOnce(makeChainable(FIXTURES.insertedSections));

    mocks.RestaurantMenuExtraItem.find
      .mockReturnValueOnce(makeChainable(FIXTURES.sourceExtras));
    mocks.RestaurantMenuExtraItem.createEach
      .mockReturnValueOnce(makeChainable(FIXTURES.insertedExtras));
  }

  beforeEach(() => {
    jest.resetModules();

    mocks = makeModelMocks();
    sailsMock = makeSailsMock();

    Object.assign(global, mocks);
    global.sails = sailsMock.sails;

    svc = require(SERVICE_PATH);
    const utils = require(UTILS_PATH);
    svc.stripMetaFields = utils.stripMetaFields;
    svc.buildIdMap = utils.buildIdMap;
    DuplicatedMenuError = svc.DuplicatedMenuError;
  });

  afterEach(() => {
    delete global.User;
    delete global.Restaurant;
    delete global.RestaurantMenu;
    delete global.RestaurantMenuItem;
    delete global.Restaurantmenuextrasection;
    delete global.RestaurantMenuExtraItem;
    delete global.sails;
  });

  // -------------------------------------------------------------------------
  // Helpers exportados
  // -------------------------------------------------------------------------

  describe('stripMetaFields', () => {
    it('quita id, createdAt, updatedAt, created_at, updated_at', () => {
      const out = svc.stripMetaFields({
        id: 5,
        createdAt: 'a', updatedAt: 'b',
        created_at: 'c', updated_at: 'd',
        name: 'foo', price: 10
      });
      expect(out).toEqual({ name: 'foo', price: 10 });
      expect(out).not.toHaveProperty('id');
      expect(out).not.toHaveProperty('createdAt');
      expect(out).not.toHaveProperty('updatedAt');
      expect(out).not.toHaveProperty('created_at');
      expect(out).not.toHaveProperty('updated_at');
    });

    it('aplica overrides sobre las columnas restantes', () => {
      const out = svc.stripMetaFields(
        { id: 5, restaurant_id: 1, name: 'foo' },
        { restaurant_id: 99 }
      );
      expect(out).toEqual({ name: 'foo', restaurant_id: 99 });
    });

    it('no muta el objeto original', () => {
      const original = { id: 5, name: 'foo' };
      svc.stripMetaFields(original, { name: 'bar' });
      expect(original).toEqual({ id: 5, name: 'foo' });
    });
  });

  describe('buildIdMap', () => {
    it('devuelve Map<oldId, newId> emparejando por índice', () => {
      const map = svc.buildIdMap(
        [{ id: 1 }, { id: 2 }, { id: 3 }],
        [{ id: 100 }, { id: 200 }, { id: 300 }]
      );
      expect(map).toBeInstanceOf(Map);
      expect(map.get(1)).toBe(100);
      expect(map.get(2)).toBe(200);
      expect(map.get(3)).toBe(300);
      expect(map.size).toBe(3);
    });

    it('lanza DuplicatedMenuError 500 si los conteos no coinciden', () => {
      expect.assertions(3);
      try {
        svc.buildIdMap([{ id: 1 }, { id: 2 }], [{ id: 100 }]);
      } catch (e) {
        expect(e).toBeInstanceOf(DuplicatedMenuError);
        expect(e.statusCode).toBe(500);
        expect(e.message).toMatch(/Inconsistencia/i);
      }
    });

    it('soporta arreglos vacíos (Map vacío)', () => {
      const map = svc.buildIdMap([], []);
      expect(map.size).toBe(0);
    });
  });

  // -------------------------------------------------------------------------
  // Validaciones de existencia (404)
  // -------------------------------------------------------------------------

  describe('duplicateMenu — validaciones (404)', () => {
    const params = { duplicateFrom: FIXTURES.emails.from, duplicateTo: FIXTURES.emails.to };

    it('lanza 404 si el userFrom no existe y NO inicia transacción', async () => {
      mocks.User.findOne.mockResolvedValueOnce(undefined);

      await expect(svc.duplicateMenu(params)).rejects.toMatchObject({
        name: 'DuplicatedMenuError',
        statusCode: 404,
        message: expect.stringContaining('principal')
      });

      expect(sailsMock.transaction).not.toHaveBeenCalled();
      expect(mocks.RestaurantMenu.createEach).not.toHaveBeenCalled();
    });

    it('lanza 404 si el userTo no existe', async () => {
      mocks.User.findOne
        .mockResolvedValueOnce(FIXTURES.userFrom)
        .mockResolvedValueOnce(undefined);

      await expect(svc.duplicateMenu(params)).rejects.toMatchObject({
        statusCode: 404,
        message: expect.stringContaining('secundario')
      });
      expect(sailsMock.transaction).not.toHaveBeenCalled();
    });

    it('lanza 404 si el restaurantFrom no existe', async () => {
      mocks.User.findOne
        .mockResolvedValueOnce(FIXTURES.userFrom)
        .mockResolvedValueOnce(FIXTURES.userTo);
      mocks.Restaurant.findOne.mockResolvedValueOnce(undefined);

      await expect(svc.duplicateMenu(params)).rejects.toMatchObject({
        statusCode: 404,
        message: expect.stringContaining('restaurante principal')
      });
      expect(sailsMock.transaction).not.toHaveBeenCalled();
    });

    it('lanza 404 si el restaurantTo no existe', async () => {
      mocks.User.findOne
        .mockResolvedValueOnce(FIXTURES.userFrom)
        .mockResolvedValueOnce(FIXTURES.userTo);
      mocks.Restaurant.findOne
        .mockResolvedValueOnce(FIXTURES.restaurantFrom)
        .mockResolvedValueOnce(undefined);

      await expect(svc.duplicateMenu(params)).rejects.toMatchObject({
        statusCode: 404,
        message: expect.stringContaining('restaurante secundario')
      });
      expect(sailsMock.transaction).not.toHaveBeenCalled();
    });
  });

  // -------------------------------------------------------------------------
  // Reglas de negocio dentro de la transacción
  // -------------------------------------------------------------------------

  describe('duplicateMenu — reglas de negocio dentro de la txn', () => {
    const params = { duplicateFrom: FIXTURES.emails.from, duplicateTo: FIXTURES.emails.to };

    it('lanza 409 si el restaurante destino ya tiene menús activos', async () => {
      mocks.User.findOne
        .mockResolvedValueOnce(FIXTURES.userFrom)
        .mockResolvedValueOnce(FIXTURES.userTo);
      mocks.Restaurant.findOne
        .mockResolvedValueOnce(FIXTURES.restaurantFrom)
        .mockResolvedValueOnce(FIXTURES.restaurantTo);

      mocks.RestaurantMenu.find
        .mockReturnValueOnce(makeChainable([{ id: 5000 }])); // destino con datos

      await expect(svc.duplicateMenu(params)).rejects.toMatchObject({
        name: 'DuplicatedMenuError',
        statusCode: 409,
        message: expect.stringContaining('destino')
      });

      // La txn sí se inicia (la validación vive dentro de ella) pero no
      // alcanza a insertar nada.
      expect(sailsMock.transaction).toHaveBeenCalledTimes(1);
      expect(mocks.RestaurantMenu.createEach).not.toHaveBeenCalled();
      expect(mocks.RestaurantMenuItem.find).not.toHaveBeenCalled();
    });

    it('retorna clonedCount con ceros cuando el origen no tiene menús', async () => {
      mocks.User.findOne
        .mockResolvedValueOnce(FIXTURES.userFrom)
        .mockResolvedValueOnce(FIXTURES.userTo);
      mocks.Restaurant.findOne
        .mockResolvedValueOnce(FIXTURES.restaurantFrom)
        .mockResolvedValueOnce(FIXTURES.restaurantTo);

      mocks.RestaurantMenu.find
        .mockReturnValueOnce(makeChainable([])) // destino vacío
        .mockReturnValueOnce(makeChainable([])); // origen vacío

      const result = await svc.duplicateMenu(params);

      expect(result.success).toBe(true);
      expect(result.dryRun).toBe(false);
      expect(result.clonedCount).toEqual({
        menus: 0, items: 0, extraSections: 0, extraItems: 0
      });
      expect(result.idMap).toEqual({
        menus: [], items: [], extraSections: [], extraItems: []
      });
      expect(result.from.email).toBe(FIXTURES.emails.from);
      expect(result.to.email).toBe(FIXTURES.emails.to);

      expect(mocks.RestaurantMenu.createEach).not.toHaveBeenCalled();
      expect(mocks.RestaurantMenuItem.find).not.toHaveBeenCalled();
    });
  });

  // -------------------------------------------------------------------------
  // Estructura de las queries (lo más sensible: confiamos en los SELECTs)
  // -------------------------------------------------------------------------

  describe('duplicateMenu — estructura de las queries', () => {
    const params = { duplicateFrom: FIXTURES.emails.from, duplicateTo: FIXTURES.emails.to };

    it('verifica destino con `deleted: 0` (salvaguarda) y origen sin filtro de deleted', async () => {
      setupHappyPath();
      await svc.duplicateMenu(params);

      // RestaurantMenu.find — 1ª: destino (con deleted), 2ª: origen (sin deleted)
      expect(mocks.RestaurantMenu.find).toHaveBeenNthCalledWith(1, {
        restaurant_id: FIXTURES.restaurantTo.id,
        deleted: 0
      });
      expect(mocks.RestaurantMenu.find).toHaveBeenNthCalledWith(2, {
        restaurant_id: FIXTURES.restaurantFrom.id
      });
    });

    it('SELECT items: WHERE restaurant_menu_id IN (<ids menús origen>)', async () => {
      setupHappyPath();
      await svc.duplicateMenu(params);

      expect(mocks.RestaurantMenuItem.find).toHaveBeenCalledWith({
        restaurant_menu_id: [3, 4]
      });
    });

    it('SELECT secciones: WHERE restaurant_menu_item_id IN (<ids items origen>)', async () => {
      setupHappyPath();
      await svc.duplicateMenu(params);

      expect(mocks.Restaurantmenuextrasection.find).toHaveBeenCalledWith({
        restaurant_menu_item_id: [30, 40]
      });
    });

    it('SELECT extras: WHERE restaurant_menu_extra_section_id IN (<ids secciones origen>)', async () => {
      setupHappyPath();
      await svc.duplicateMenu(params);

      expect(mocks.RestaurantMenuExtraItem.find).toHaveBeenCalledWith({
        restaurant_menu_extra_section_id: [2]
      });
    });

    it('User.findOne se llama con cada email', async () => {
      setupHappyPath();
      await svc.duplicateMenu(params);

      expect(mocks.User.findOne).toHaveBeenNthCalledWith(1, { email: FIXTURES.emails.from });
      expect(mocks.User.findOne).toHaveBeenNthCalledWith(2, { email: FIXTURES.emails.to });
    });

    it('Restaurant.findOne usa el user_id correspondiente', async () => {
      setupHappyPath();
      await svc.duplicateMenu(params);

      expect(mocks.Restaurant.findOne).toHaveBeenNthCalledWith(1, { user_id: FIXTURES.userFrom.id });
      expect(mocks.Restaurant.findOne).toHaveBeenNthCalledWith(2, { user_id: FIXTURES.userTo.id });
    });
  });

  // -------------------------------------------------------------------------
  // Mapeo de IDs y reescritura de FKs en los INSERTs
  // -------------------------------------------------------------------------

  describe('duplicateMenu — payloads de INSERT', () => {
    const params = { duplicateFrom: FIXTURES.emails.from, duplicateTo: FIXTURES.emails.to };

    it('INSERT menus: cada fila trae restaurant_id del destino y NO trae id', async () => {
      setupHappyPath();
      await svc.duplicateMenu(params);

      const rows = mocks.RestaurantMenu.createEach.mock.calls[0][0];
      expect(rows).toHaveLength(2);
      rows.forEach(row => {
        expect(row).not.toHaveProperty('id');
        expect(row).not.toHaveProperty('createdAt');
        expect(row).not.toHaveProperty('updatedAt');
        expect(row.restaurant_id).toBe(FIXTURES.restaurantTo.id);
      });
      // Nombres preservados del origen
      expect(rows[0].name).toBe('Alitas');
      expect(rows[1].name).toBe('RIBS');
    });

    it('INSERT items: restaurant_menu_id reescrito vía menuIdMap (no por nombre/imagen)', async () => {
      setupHappyPath();
      await svc.duplicateMenu(params);

      const rows = mocks.RestaurantMenuItem.createEach.mock.calls[0][0];
      expect(rows).toEqual([
        expect.objectContaining({ name: '7 Piezas',  restaurant_menu_id: 90 }),
        expect.objectContaining({ name: 'RIBS ARMY', restaurant_menu_id: 91 })
      ]);
      rows.forEach(row => expect(row).not.toHaveProperty('id'));
    });

    it('INSERT secciones: restaurant_menu_item_id reescrito + restaurant_id forzado al destino', async () => {
      setupHappyPath();
      await svc.duplicateMenu(params);

      const rows = mocks.Restaurantmenuextrasection.createEach.mock.calls[0][0];
      expect(rows).toEqual([
        expect.objectContaining({
          name: 'Salsa',
          restaurant_menu_item_id: 300,
          restaurant_id: FIXTURES.restaurantTo.id
        })
      ]);
      rows.forEach(row => expect(row).not.toHaveProperty('id'));
    });

    it('INSERT extras: restaurant_menu_extra_section_id reescrito vía sectionIdMap', async () => {
      setupHappyPath();
      await svc.duplicateMenu(params);

      const rows = mocks.RestaurantMenuExtraItem.createEach.mock.calls[0][0];
      expect(rows).toEqual([
        expect.objectContaining({ name: 'Picante HOT', restaurant_menu_extra_section_id: 999 }),
        expect.objectContaining({ name: 'BOMBA',       restaurant_menu_extra_section_id: 999 })
      ]);
      rows.forEach(row => expect(row).not.toHaveProperty('id'));
    });
  });

  // -------------------------------------------------------------------------
  // Response final — clonedCount + idMap
  // -------------------------------------------------------------------------

  describe('duplicateMenu — response final', () => {
    const params = { duplicateFrom: FIXTURES.emails.from, duplicateTo: FIXTURES.emails.to };

    it('retorna clonedCount con conteos por nivel', async () => {
      setupHappyPath();
      const result = await svc.duplicateMenu(params);

      expect(result.clonedCount).toEqual({
        menus: 2,
        items: 2,
        extraSections: 1,
        extraItems: 2
      });
    });

    it('retorna idMap completo con los 4 niveles', async () => {
      setupHappyPath();
      const result = await svc.duplicateMenu(params);

      expect(result.idMap).toEqual({
        menus:         [[3, 90], [4, 91]],
        items:         [[30, 300], [40, 400]],
        extraSections: [[2, 999]],
        extraItems:    [[8, 8000], [9, 9000]]
      });
    });

    it('retorna metadata from/to con email + user_id + restaurant_id', async () => {
      setupHappyPath();
      const result = await svc.duplicateMenu(params);

      expect(result.from).toEqual({
        email: FIXTURES.emails.from,
        user_id: FIXTURES.userFrom.id,
        restaurant_id: FIXTURES.restaurantFrom.id
      });
      expect(result.to).toEqual({
        email: FIXTURES.emails.to,
        user_id: FIXTURES.userTo.id,
        restaurant_id: FIXTURES.restaurantTo.id
      });
    });
  });

  // -------------------------------------------------------------------------
  // Edge cases — niveles intermedios vacíos
  // -------------------------------------------------------------------------

  describe('duplicateMenu — niveles vacíos', () => {
    const params = { duplicateFrom: FIXTURES.emails.from, duplicateTo: FIXTURES.emails.to };

    function setupBaseValidations() {
      mocks.User.findOne
        .mockResolvedValueOnce(FIXTURES.userFrom)
        .mockResolvedValueOnce(FIXTURES.userTo);
      mocks.Restaurant.findOne
        .mockResolvedValueOnce(FIXTURES.restaurantFrom)
        .mockResolvedValueOnce(FIXTURES.restaurantTo);
      mocks.RestaurantMenu.find
        .mockReturnValueOnce(makeChainable([])) // destino vacío
        .mockReturnValueOnce(makeChainable(FIXTURES.sourceMenus));
      mocks.RestaurantMenu.createEach
        .mockReturnValueOnce(makeChainable(FIXTURES.insertedMenus));
    }

    it('si origen NO tiene items, no se ejecutan niveles 3 ni 4', async () => {
      setupBaseValidations();
      mocks.RestaurantMenuItem.find.mockReturnValueOnce(makeChainable([]));

      const result = await svc.duplicateMenu(params);

      expect(result.clonedCount).toEqual({
        menus: 2, items: 0, extraSections: 0, extraItems: 0
      });
      expect(mocks.RestaurantMenuItem.createEach).not.toHaveBeenCalled();
      expect(mocks.Restaurantmenuextrasection.find).not.toHaveBeenCalled();
      expect(mocks.RestaurantMenuExtraItem.find).not.toHaveBeenCalled();
    });

    it('si origen tiene items pero NO secciones, no se ejecuta nivel 4', async () => {
      setupBaseValidations();
      mocks.RestaurantMenuItem.find.mockReturnValueOnce(makeChainable(FIXTURES.sourceItems));
      mocks.RestaurantMenuItem.createEach.mockReturnValueOnce(makeChainable(FIXTURES.insertedItems));
      mocks.Restaurantmenuextrasection.find.mockReturnValueOnce(makeChainable([]));

      const result = await svc.duplicateMenu(params);

      expect(result.clonedCount).toEqual({
        menus: 2, items: 2, extraSections: 0, extraItems: 0
      });
      expect(mocks.Restaurantmenuextrasection.createEach).not.toHaveBeenCalled();
      expect(mocks.RestaurantMenuExtraItem.find).not.toHaveBeenCalled();
    });

    it('si origen tiene secciones pero NO extras, idMap.extraItems queda vacío', async () => {
      setupBaseValidations();
      mocks.RestaurantMenuItem.find.mockReturnValueOnce(makeChainable(FIXTURES.sourceItems));
      mocks.RestaurantMenuItem.createEach.mockReturnValueOnce(makeChainable(FIXTURES.insertedItems));
      mocks.Restaurantmenuextrasection.find.mockReturnValueOnce(makeChainable(FIXTURES.sourceSections));
      mocks.Restaurantmenuextrasection.createEach.mockReturnValueOnce(makeChainable(FIXTURES.insertedSections));
      mocks.RestaurantMenuExtraItem.find.mockReturnValueOnce(makeChainable([]));

      const result = await svc.duplicateMenu(params);

      expect(result.clonedCount).toEqual({
        menus: 2, items: 2, extraSections: 1, extraItems: 0
      });
      expect(result.idMap.extraItems).toEqual([]);
      expect(mocks.RestaurantMenuExtraItem.createEach).not.toHaveBeenCalled();
    });
  });

  // -------------------------------------------------------------------------
  // Transacción / rollback
  // -------------------------------------------------------------------------

  describe('duplicateMenu — transacción / rollback', () => {
    const params = { duplicateFrom: FIXTURES.emails.from, duplicateTo: FIXTURES.emails.to };

    it('toda mutación corre dentro de sails.getDatastore().transaction()', async () => {
      setupHappyPath();
      await svc.duplicateMenu(params);

      expect(sailsMock.sails.getDatastore).toHaveBeenCalled();
      expect(sailsMock.transaction).toHaveBeenCalledTimes(1);
      expect(typeof sailsMock.transaction.mock.calls[0][0]).toBe('function');
    });

    it('un error en createEach propaga hacia afuera y aborta los siguientes niveles', async () => {
      mocks.User.findOne
        .mockResolvedValueOnce(FIXTURES.userFrom)
        .mockResolvedValueOnce(FIXTURES.userTo);
      mocks.Restaurant.findOne
        .mockResolvedValueOnce(FIXTURES.restaurantFrom)
        .mockResolvedValueOnce(FIXTURES.restaurantTo);
      mocks.RestaurantMenu.find
        .mockReturnValueOnce(makeChainable([]))
        .mockReturnValueOnce(makeChainable(FIXTURES.sourceMenus));

      const dbError = new Error('Lost connection to MySQL');
      mocks.RestaurantMenu.createEach.mockReturnValueOnce(makeRejectedChainable(dbError));

      await expect(svc.duplicateMenu(params)).rejects.toThrow('Lost connection to MySQL');

      // Niveles posteriores no se intentaron — la txn aborta toda la cadena.
      expect(mocks.RestaurantMenuItem.find).not.toHaveBeenCalled();
      expect(mocks.Restaurantmenuextrasection.find).not.toHaveBeenCalled();
      expect(mocks.RestaurantMenuExtraItem.find).not.toHaveBeenCalled();
    });

    it('si la BD inserta menos filas de las esperadas, lanza DuplicatedMenuError 500', async () => {
      mocks.User.findOne
        .mockResolvedValueOnce(FIXTURES.userFrom)
        .mockResolvedValueOnce(FIXTURES.userTo);
      mocks.Restaurant.findOne
        .mockResolvedValueOnce(FIXTURES.restaurantFrom)
        .mockResolvedValueOnce(FIXTURES.restaurantTo);
      mocks.RestaurantMenu.find
        .mockReturnValueOnce(makeChainable([]))
        .mockReturnValueOnce(makeChainable(FIXTURES.sourceMenus));
      // 2 enviados pero solo 1 retornado → buildIdMap arroja DuplicatedMenuError 500
      mocks.RestaurantMenu.createEach
        .mockReturnValueOnce(makeChainable([FIXTURES.insertedMenus[0]]));

      await expect(svc.duplicateMenu(params)).rejects.toMatchObject({
        name: 'DuplicatedMenuError',
        statusCode: 500
      });

      // No se intentaron los siguientes niveles
      expect(mocks.RestaurantMenuItem.find).not.toHaveBeenCalled();
    });

    it('un fallo durante la inserción de extras (nivel 4) tampoco deja datos parciales reportados', async () => {
      setupHappyPath();
      // Reemplazamos el último mock con uno que falla
      mocks.RestaurantMenuExtraItem.createEach
        .mockReset()
        .mockReturnValueOnce(makeRejectedChainable(new Error('FK constraint')));

      await expect(svc.duplicateMenu(params)).rejects.toThrow('FK constraint');
    });
  });

  // -------------------------------------------------------------------------
  // Dry-run
  // -------------------------------------------------------------------------

  describe('duplicateMenu — dry-run', () => {
    const dryRunParams = {
      duplicateFrom: FIXTURES.emails.from,
      duplicateTo: FIXTURES.emails.to,
      dryRun: true
    };

    /**
     * En dry-run el service NO debe llamar a `createEach` en ningún nivel,
     * por lo que NO necesitamos mockear esas funciones. Solo los SELECTs.
     */
    function setupDryRunReads() {
      mocks.User.findOne
        .mockResolvedValueOnce(FIXTURES.userFrom)
        .mockResolvedValueOnce(FIXTURES.userTo);
      mocks.Restaurant.findOne
        .mockResolvedValueOnce(FIXTURES.restaurantFrom)
        .mockResolvedValueOnce(FIXTURES.restaurantTo);
      mocks.RestaurantMenu.find
        .mockReturnValueOnce(makeChainable([]))                       // destino vacío
        .mockReturnValueOnce(makeChainable(FIXTURES.sourceMenus));    // origen
      mocks.RestaurantMenuItem.find
        .mockReturnValueOnce(makeChainable(FIXTURES.sourceItems));
      mocks.Restaurantmenuextrasection.find
        .mockReturnValueOnce(makeChainable(FIXTURES.sourceSections));
      mocks.RestaurantMenuExtraItem.find
        .mockReturnValueOnce(makeChainable(FIXTURES.sourceExtras));
    }

    it('dryRun=true NO llama createEach en ningún nivel', async () => {
      setupDryRunReads();
      await svc.duplicateMenu(dryRunParams);

      expect(mocks.RestaurantMenu.createEach).not.toHaveBeenCalled();
      expect(mocks.RestaurantMenuItem.createEach).not.toHaveBeenCalled();
      expect(mocks.Restaurantmenuextrasection.createEach).not.toHaveBeenCalled();
      expect(mocks.RestaurantMenuExtraItem.createEach).not.toHaveBeenCalled();
    });

    it('dryRun=true NO abre transacción', async () => {
      setupDryRunReads();
      await svc.duplicateMenu(dryRunParams);

      expect(sailsMock.transaction).not.toHaveBeenCalled();
      expect(sailsMock.sails.getDatastore).not.toHaveBeenCalled();
    });

    it('dryRun=true sigue ejecutando los SELECTs origen (lectura segura)', async () => {
      setupDryRunReads();
      await svc.duplicateMenu(dryRunParams);

      expect(mocks.RestaurantMenu.find).toHaveBeenCalledTimes(2); // destino + origen
      expect(mocks.RestaurantMenuItem.find).toHaveBeenCalledTimes(1);
      expect(mocks.Restaurantmenuextrasection.find).toHaveBeenCalledTimes(1);
      expect(mocks.RestaurantMenuExtraItem.find).toHaveBeenCalledTimes(1);
    });

    it('dryRun=true sigue validando 404 si el origen no existe', async () => {
      mocks.User.findOne.mockResolvedValueOnce(undefined);

      await expect(svc.duplicateMenu(dryRunParams)).rejects.toMatchObject({
        statusCode: 404
      });
      expect(mocks.RestaurantMenu.find).not.toHaveBeenCalled();
    });

    it('dryRun=true sigue validando 409 si el destino tiene menús', async () => {
      mocks.User.findOne
        .mockResolvedValueOnce(FIXTURES.userFrom)
        .mockResolvedValueOnce(FIXTURES.userTo);
      mocks.Restaurant.findOne
        .mockResolvedValueOnce(FIXTURES.restaurantFrom)
        .mockResolvedValueOnce(FIXTURES.restaurantTo);
      mocks.RestaurantMenu.find
        .mockReturnValueOnce(makeChainable([{ id: 9999 }])); // destino con datos

      await expect(svc.duplicateMenu(dryRunParams)).rejects.toMatchObject({
        statusCode: 409
      });
    });

    it('dryRun=true marca la respuesta con dryRun:true y mensaje claro', async () => {
      setupDryRunReads();
      const result = await svc.duplicateMenu(dryRunParams);

      expect(result.success).toBe(true);
      expect(result.dryRun).toBe(true);
      expect(result.message).toMatch(/Dry-run/i);
    });

    it('dryRun=true devuelve clonedCount con los conteos que se hubieran insertado', async () => {
      setupDryRunReads();
      const result = await svc.duplicateMenu(dryRunParams);

      expect(result.clonedCount).toEqual({
        menus: 2,
        items: 2,
        extraSections: 1,
        extraItems: 2
      });
    });

    it('dryRun=true devuelve preview con las filas que se hubieran insertado', async () => {
      setupDryRunReads();
      const result = await svc.duplicateMenu(dryRunParams);

      expect(result.preview).toBeDefined();
      expect(result.preview.menus).toEqual([
        expect.objectContaining({ name: 'Alitas', restaurant_id: FIXTURES.restaurantTo.id }),
        expect.objectContaining({ name: 'RIBS',   restaurant_id: FIXTURES.restaurantTo.id })
      ]);
      // Los previews NO incluyen `id` (eso lo asignaría la BD real).
      result.preview.menus.forEach(row => expect(row).not.toHaveProperty('id'));
      result.preview.items.forEach(row => expect(row).not.toHaveProperty('id'));
      result.preview.extraSections.forEach(row => expect(row).not.toHaveProperty('id'));
      result.preview.extraItems.forEach(row => expect(row).not.toHaveProperty('id'));
    });

    it('dryRun=true encadena IDs simulados a través de los 4 niveles correctamente', async () => {
      setupDryRunReads();
      const result = await svc.duplicateMenu(dryRunParams);

      // Los IDs simulados son negativos para evitar colisión con la BD real.
      const allSimulatedIds = [
        ...result.idMap.menus.map(([, n]) => n),
        ...result.idMap.items.map(([, n]) => n),
        ...result.idMap.extraSections.map(([, n]) => n),
        ...result.idMap.extraItems.map(([, n]) => n)
      ];
      allSimulatedIds.forEach(id => expect(id).toBeLessThan(0));

      // Las FKs en items deben apuntar a los menús simulados.
      const simulatedMenuIdById = new Map(result.idMap.menus); // oldMenuId → simulatedMenuId
      result.preview.items.forEach((item, i) => {
        const sourceItem = FIXTURES.sourceItems[i];
        const expectedMenuId = simulatedMenuIdById.get(sourceItem.restaurant_menu_id);
        expect(item.restaurant_menu_id).toBe(expectedMenuId);
      });

      // Las FKs en secciones deben apuntar a los items simulados.
      const simulatedItemIdById = new Map(result.idMap.items);
      result.preview.extraSections.forEach((section, i) => {
        const sourceSection = FIXTURES.sourceSections[i];
        const expectedItemId = simulatedItemIdById.get(sourceSection.restaurant_menu_item_id);
        expect(section.restaurant_menu_item_id).toBe(expectedItemId);
        // restaurant_id forzado al destino (mismo en real y dry-run).
        expect(section.restaurant_id).toBe(FIXTURES.restaurantTo.id);
      });

      // Las FKs en extras deben apuntar a las secciones simuladas.
      const simulatedSectionIdById = new Map(result.idMap.extraSections);
      result.preview.extraItems.forEach((extra, i) => {
        const sourceExtra = FIXTURES.sourceExtras[i];
        const expectedSectionId = simulatedSectionIdById.get(sourceExtra.restaurant_menu_extra_section_id);
        expect(extra.restaurant_menu_extra_section_id).toBe(expectedSectionId);
      });
    });

    it('dryRun=true respeta los edge cases de niveles vacíos', async () => {
      mocks.User.findOne
        .mockResolvedValueOnce(FIXTURES.userFrom)
        .mockResolvedValueOnce(FIXTURES.userTo);
      mocks.Restaurant.findOne
        .mockResolvedValueOnce(FIXTURES.restaurantFrom)
        .mockResolvedValueOnce(FIXTURES.restaurantTo);
      mocks.RestaurantMenu.find
        .mockReturnValueOnce(makeChainable([]))
        .mockReturnValueOnce(makeChainable(FIXTURES.sourceMenus));
      // Origen sin items → no hay niveles 3 ni 4
      mocks.RestaurantMenuItem.find.mockReturnValueOnce(makeChainable([]));

      const result = await svc.duplicateMenu(dryRunParams);

      expect(result.dryRun).toBe(true);
      expect(result.clonedCount).toEqual({
        menus: 2, items: 0, extraSections: 0, extraItems: 0
      });
      expect(result.preview.menus).toHaveLength(2);
      expect(result.preview.items).toEqual([]);
      expect(result.preview.extraSections).toEqual([]);
      expect(result.preview.extraItems).toEqual([]);
      expect(mocks.Restaurantmenuextrasection.find).not.toHaveBeenCalled();
    });

    it('dryRun=false (modo real) NO incluye preview en el response', async () => {
      setupHappyPath();
      const result = await svc.duplicateMenu({
        duplicateFrom: FIXTURES.emails.from,
        duplicateTo: FIXTURES.emails.to,
        dryRun: false
      });

      expect(result.dryRun).toBe(false);
      expect(result).not.toHaveProperty('preview');
    });

    it('omitir dryRun = comportamiento real (default false)', async () => {
      setupHappyPath();
      const result = await svc.duplicateMenu({
        duplicateFrom: FIXTURES.emails.from,
        duplicateTo: FIXTURES.emails.to
      });

      expect(result.dryRun).toBe(false);
      expect(result).not.toHaveProperty('preview');
      expect(mocks.RestaurantMenu.createEach).toHaveBeenCalledTimes(1);
    });
  });

  // -------------------------------------------------------------------------
  // compareMenus
  // -------------------------------------------------------------------------

  describe('compareMenus', () => {
    const params = { duplicateFrom: FIXTURES.emails.from, duplicateTo: FIXTURES.emails.to };

    /**
     * Configura los mocks para 2 árboles completos (origen + destino).
     * Las funciones internas `loadFullMenuTree` hacen 4 SELECTs por
     * restaurante (8 en total), short-circuiteando si encuentran un nivel
     * vacío.
     */
    function setupTrees(originTree, destTree) {
      mocks.User.findOne
        .mockResolvedValueOnce(FIXTURES.userFrom)
        .mockResolvedValueOnce(FIXTURES.userTo);
      mocks.Restaurant.findOne
        .mockResolvedValueOnce(FIXTURES.restaurantFrom)
        .mockResolvedValueOnce(FIXTURES.restaurantTo);

      // Origen
      mocks.RestaurantMenu.find.mockReturnValueOnce(makeChainable(originTree.menus));
      if (originTree.menus.length) {
        mocks.RestaurantMenuItem.find.mockReturnValueOnce(makeChainable(originTree.items));
      }
      if (originTree.items && originTree.items.length) {
        mocks.Restaurantmenuextrasection.find.mockReturnValueOnce(makeChainable(originTree.extraSections));
      }
      if (originTree.extraSections && originTree.extraSections.length) {
        mocks.RestaurantMenuExtraItem.find.mockReturnValueOnce(makeChainable(originTree.extraItems));
      }

      // Destino
      mocks.RestaurantMenu.find.mockReturnValueOnce(makeChainable(destTree.menus));
      if (destTree.menus.length) {
        mocks.RestaurantMenuItem.find.mockReturnValueOnce(makeChainable(destTree.items));
      }
      if (destTree.items && destTree.items.length) {
        mocks.Restaurantmenuextrasection.find.mockReturnValueOnce(makeChainable(destTree.extraSections));
      }
      if (destTree.extraSections && destTree.extraSections.length) {
        mocks.RestaurantMenuExtraItem.find.mockReturnValueOnce(makeChainable(destTree.extraItems));
      }
    }

    // -------- Validaciones (mismas que duplicateMenu) --------

    it('lanza 404 si userFrom no existe', async () => {
      mocks.User.findOne.mockResolvedValueOnce(undefined);
      await expect(svc.compareMenus(params)).rejects.toMatchObject({
        statusCode: 404,
        message: expect.stringContaining('principal')
      });
    });

    it('lanza 404 si userTo no existe', async () => {
      mocks.User.findOne
        .mockResolvedValueOnce(FIXTURES.userFrom)
        .mockResolvedValueOnce(undefined);
      await expect(svc.compareMenus(params)).rejects.toMatchObject({
        statusCode: 404,
        message: expect.stringContaining('secundario')
      });
    });

    // -------- Comparación con datos idénticos (clon perfecto) --------

    it('cuando origen y destino tienen exactamente la misma data → similarity 100%', async () => {
      // Mismo contenido de filas. Las FKs y los IDs van diferentes a propósito —
      // se ignoran al comparar.
      const originMenus = [
        { id: 3, name: 'Alitas', description: 'Las mas grandes',
          created: '2020-04-08 14:16:51', restaurant_id: 1000,
          image: 'a.png', active: 1, has_menu_item: 1, index: 0, deleted: 0 }
      ];
      const destMenus = [
        { id: 99, name: 'Alitas', description: 'Las mas grandes',
          created: '2020-04-08 14:16:51', restaurant_id: 2000,
          image: 'a.png', active: 1, has_menu_item: 1, index: 0, deleted: 0 }
      ];
      const originItems = [
        { id: 30, name: '7 Piezas', description: 'foo', price: 76,
          image: 'p.png', created: '2020-04-08', active: 1,
          restaurant_menu_id: 3, out_of_order: 0, index: 0, deleted: 0 }
      ];
      const destItems = [
        { id: 300, name: '7 Piezas', description: 'foo', price: 76,
          image: 'p.png', created: '2020-04-08', active: 1,
          restaurant_menu_id: 99, out_of_order: 0, index: 0, deleted: 0 }
      ];

      setupTrees(
        { menus: originMenus, items: originItems, extraSections: [], extraItems: [] },
        { menus: destMenus,   items: destItems,   extraSections: [], extraItems: [] }
      );

      const result = await svc.compareMenus(params);

      expect(result.success).toBe(true);
      expect(result.resultComparation.menus.similarity).toBe(100);
      expect(result.resultComparation.items.similarity).toBe(100);
      expect(result.resultComparation.extraSections.similarity).toBe(100);
      expect(result.resultComparation.extraItems.similarity).toBe(100);
      expect(result.resultComparation.overall.similarity).toBe(100);
      expect(result.resultComparation.overall.identical).toBe(true);
    });

    // -------- Comparación con datos parciales --------

    it('1 de 2 menús coincide → similarity 50% en menus', async () => {
      const origin = [
        { id: 1, name: 'A', description: 'a', created: 'x', restaurant_id: 1000, image: 'a', active: 1, has_menu_item: 1, index: 0, deleted: 0 },
        { id: 2, name: 'B', description: 'b', created: 'y', restaurant_id: 1000, image: 'b', active: 1, has_menu_item: 1, index: 1, deleted: 0 }
      ];
      const dest = [
        { id: 10, name: 'A', description: 'a', created: 'x', restaurant_id: 2000, image: 'a', active: 1, has_menu_item: 1, index: 0, deleted: 0 },
        // El 2º menú difiere del origen → no coincide
        { id: 20, name: 'C-distinto', description: 'b', created: 'y', restaurant_id: 2000, image: 'b', active: 1, has_menu_item: 1, index: 1, deleted: 0 }
      ];

      setupTrees(
        { menus: origin, items: [], extraSections: [], extraItems: [] },
        { menus: dest,   items: [], extraSections: [], extraItems: [] }
      );

      const result = await svc.compareMenus(params);

      expect(result.resultComparation.menus.matchingRecords).toBe(1);
      expect(result.resultComparation.menus.originCount).toBe(2);
      expect(result.resultComparation.menus.destCount).toBe(2);
      expect(result.resultComparation.menus.similarity).toBe(50);
      expect(result.resultComparation.overall.identical).toBe(false);
    });

    it('conteos distintos → similarity ponderada por max(origin,dest)', async () => {
      // Origen tiene 1 menú, destino tiene 2. El que comparten cuenta como 1
      // sobre max(1,2)=2 → 50%.
      const origin = [
        { id: 1, name: 'A', description: 'a', created: 'x', restaurant_id: 1000, image: 'a', active: 1, has_menu_item: 1, index: 0, deleted: 0 }
      ];
      const dest = [
        { id: 10, name: 'A', description: 'a', created: 'x', restaurant_id: 2000, image: 'a', active: 1, has_menu_item: 1, index: 0, deleted: 0 },
        { id: 20, name: 'EXTRA', description: 'extra', created: 'z', restaurant_id: 2000, image: 'z', active: 1, has_menu_item: 1, index: 1, deleted: 0 }
      ];

      setupTrees(
        { menus: origin, items: [], extraSections: [], extraItems: [] },
        { menus: dest,   items: [], extraSections: [], extraItems: [] }
      );

      const result = await svc.compareMenus(params);

      expect(result.resultComparation.menus.originCount).toBe(1);
      expect(result.resultComparation.menus.destCount).toBe(2);
      expect(result.resultComparation.menus.matchingRecords).toBe(1);
      expect(result.resultComparation.menus.similarity).toBe(50);
    });

    // -------- Comparación ignora ID + FKs --------

    it('NO compara id ni restaurant_id (FKs que difieren tras el clon)', async () => {
      // IDs y restaurant_id totalmente distintos. El resto idéntico → 100%.
      const origin = [
        { id: 999, name: 'X', description: 'x', created: 'd', restaurant_id: 5,   image: 'x', active: 1, has_menu_item: 1, index: 0, deleted: 0 }
      ];
      const dest = [
        { id: 1,   name: 'X', description: 'x', created: 'd', restaurant_id: 999, image: 'x', active: 1, has_menu_item: 1, index: 0, deleted: 0 }
      ];

      setupTrees(
        { menus: origin, items: [], extraSections: [], extraItems: [] },
        { menus: dest,   items: [], extraSections: [], extraItems: [] }
      );

      const result = await svc.compareMenus(params);
      expect(result.resultComparation.menus.similarity).toBe(100);
    });

    // -------- Manejo de duplicados --------

    it('no doble-cuenta cuando origen tiene duplicados pero destino solo uno', async () => {
      // Origen tiene 2 menús idénticos, destino solo 1. El match consume al
      // único de destino, así que solo 1 de los 2 origen tiene par → 1/max(2,1) = 50%.
      const row = (id) => ({
        id, name: 'X', description: 'x', created: 'd', restaurant_id: 1000,
        image: 'x', active: 1, has_menu_item: 1, index: 0, deleted: 0
      });
      const origin = [row(1), row(2)]; // dos copias
      const dest   = [{ ...row(99), restaurant_id: 2000 }];

      setupTrees(
        { menus: origin, items: [], extraSections: [], extraItems: [] },
        { menus: dest,   items: [], extraSections: [], extraItems: [] }
      );

      const result = await svc.compareMenus(params);

      expect(result.resultComparation.menus.matchingRecords).toBe(1);
      expect(result.resultComparation.menus.similarity).toBe(50);
    });

    // -------- Caso vacío --------

    it('ambos restaurantes sin menús → similarity 100% (vacío idéntico)', async () => {
      setupTrees(
        { menus: [], items: [], extraSections: [], extraItems: [] },
        { menus: [], items: [], extraSections: [], extraItems: [] }
      );

      const result = await svc.compareMenus(params);
      expect(result.resultComparation.menus.similarity).toBe(100);
      expect(result.resultComparation.overall.similarity).toBe(100);
      expect(result.resultComparation.overall.totalRecords).toBe(0);
    });

    // -------- Estructura del response --------

    it('response trae restaurantOrigin/Dest con la data cruda + 4 niveles', async () => {
      const origin = [{ id: 1, name: 'M', description: '', created: '', restaurant_id: 1000, image: '', active: 1, has_menu_item: 0, index: 0, deleted: 0 }];
      const dest   = [{ id: 2, name: 'M', description: '', created: '', restaurant_id: 2000, image: '', active: 1, has_menu_item: 0, index: 0, deleted: 0 }];

      setupTrees(
        { menus: origin, items: [], extraSections: [], extraItems: [] },
        { menus: dest,   items: [], extraSections: [], extraItems: [] }
      );

      const result = await svc.compareMenus(params);

      expect(result.restaurantOrigin).toEqual(expect.objectContaining({
        email: FIXTURES.emails.from,
        user_id: FIXTURES.userFrom.id,
        restaurant_id: FIXTURES.restaurantFrom.id,
        menus: origin,
        items: [],
        extraSections: [],
        extraItems: []
      }));
      expect(result.restaurantDest).toEqual(expect.objectContaining({
        email: FIXTURES.emails.to,
        user_id: FIXTURES.userTo.id,
        restaurant_id: FIXTURES.restaurantTo.id,
        menus: dest
      }));
    });

    it('NO abre transacción (operación read-only)', async () => {
      setupTrees(
        { menus: [], items: [], extraSections: [], extraItems: [] },
        { menus: [], items: [], extraSections: [], extraItems: [] }
      );

      await svc.compareMenus(params);

      expect(sailsMock.transaction).not.toHaveBeenCalled();
    });
  });
});
