¿Qué aprenderá?

Al finalizar este tutorial el estudiante estará en capacidad de realizar la implementación de la lógica de una entidad, utilizando Nest.js y el ORM TypeORM.

¿Qué necesita?

Para realizar este tutorial Ud. debe:

  1. Tener el ambiente de desarrollo instalado. Para esto ud puede usar su máquina virtual o su máquina propia.
  2. Haber implementado la persistencia de la aplicación. Si no lo ha hecho, diríjase a el tutorial de persistencia, el cual está disponible en esta URL:

https://misovirtual.virtual.uniandes.edu.co/codelabs/MISW4403_202212_Persistencia/index.html#0

Código

El código de este tutorial puede ser consultado en el siguiente repositorio:

https://github.com/MISW4403-Diseno-y-construccion-de-APIs/MISW4403_202214_Logica

Para la implementación de la lógica definiremos un servicio para cada una de las entidades. La convención de nombramiento de las clases de la lógica será el nombre de la entidad junto con el sufijo "Service".

Iniciaremos creando el servicio para museos. Para esto ejecute el comando nest g s museum. Esto creará el archivo src/museum/museum.service.ts y actualizará el módulo con la importación de esa nueva clase.

Dentro de la clase MuseumService definiremos el constructor que recibirá por parámetro una instancia del repositorio de la entidad MuseumEntity y lo inyectará al servicio.

 constructor(
       @InjectRepository(MuseumEntity)
       private readonly museumRepository: Repository<MuseumEntity>
   ){}

La clase Repository de la cual se crea la instancia museumRepository es una clase de TypeORM que nos permite acceder a la base de datos. Al hacer la generalización , la instancia que generamos nos permitirá acceder a la tabla de la entidad MuseumEntity en la base de datos. Esta clase entonces tendrá acceso a métodos para crear, obtener, actualizar y borrar objetos persistentes de la entidad.

Ahora abra el archivo src/museum/museum.module.ts y en el arreglo imports agregue lo siguiente:

imports: [TypeOrmModule.forFeature([MuseumEntity])],

Esto evitará que se genere un error en el servicio dado que se está usando un repositorio para la entidad MuseumEntity.

Con esto ya será suficiente para empezar a construir los métodos CRUD con los que contará el servicio.

Para obtener todos los museos almacenados en la base de datos, se definirá el método findAll en el servicio "MuseumService". A continuación se presenta la definición de dicho método:

  async findAll(): Promise<MuseumEntity[]> {
       return await this.museumRepository.find({ relations: ["artworks", "exhibitions"] });
   }

Como podemos ver, en el método se hace un llamado utilizando la instancia de la clase Repository de TypeORM. Esta clase tiene definido internamente el método find que retorna todas las entidades que se encuentran en el repositorio de museos.

Aspectos importantes a resaltar del método findAll:

De manera similar a la obtención de todos los museos, se definirá el método findOne para obtener un único museo dado su ID. La definición del método se encuentra a continuación:

  async findOne(id: string): Promise<MuseumEntity> {
       const museum: MuseumEntity = await this.museumRepository.findOne({where: {id}, relations: ["artworks", "exhibitions"] } );
       if (!museum)
         throw new BusinessLogicException("The museum with the given id was not found", BusinessError.NOT_FOUND);
  
       return museum;
   }

A diferencia del método findAll, este método recibe como parámetro el ID del museo que se desea obtener, y se utiliza el método findOne definido internamente en el repositorio de museos, el cual también recibe como parámetro el ID deseado.

Adcionalmente, se puede observar un manejo de excepciones en caso de no encontrar el museo con el ID dado por parámetro en el repositorio. Para este manejo de excepciones se define la enumeración BusinessError para definir el tipo de error y la función BusinessLogicException. El constructor de esta función recibe como parámetro el mensaje descriptivo del error y el tipo de error. Esto es importante en las consideraciones de diseño del API se espera que los clientes que consumen el API sean notificados de los errores con mensajes descriptivos y con códigos de error. Posteriormente cuando se implementen los controladores se indicará como mapear las excepciones de la lógica a códigos de error HTTP.

Para la función y la enumeración, en la carpeta src se creará una nueva carpeta denominada shared en la que se ubicarán los elementos que se utilicen de manera global entre todas las clases.

Dentro de esa carpeta, definimos otra carpeta denominada errors dentro de la cual se creará un archivo TypeScript denominado business-errors.ts con el siguiente contenido:

export function BusinessLogicException(message: string, type: number) {
  this.message = message;
  this.type = type;
}

export enum BusinessError {
  NOT_FOUND,
  PRECONDITION_FAILED,
  BAD_REQUEST
}
/* archivo: src/shared/errors/business-errors.ts */

Luego de crear el archivo anterior puede actualizar el archivo museum.service.ts para corregir los errores que aparecieron.

Para la creación de un nuevo museo en el servicio, se definirá el método create. Este método recibirá por parámetro la representación básica del recurso que se quiere crear; en este caso será una instancia de la clase MuseumEntity que se persistirá usando el método save de la clase Repository. A continuación se presenta el código del método:

 async create(museum: MuseumEntity): Promise<MuseumEntity> {
       return await this.museumRepository.save(museum);
   }

En este método también se pueden definir y verificar las reglas de negocio asociadas a la creación de un nuevo museo.

Para la actualización de un museo previamente persistido en la base de datos, se definirá el método update dentro del servicio MuseumService. Este método recibirá por parámetro el ID del museo que se desea actualizar y la entidad con toda la información actualizada del museo. La implementación del método se presenta a continuación:

  async update(id: string, museum: MuseumEntity): Promise<MuseumEntity> {
       const persistedMuseum: MuseumEntity = await this.museumRepository.findOne({where:{id}});
       if (!persistedMuseum)
         throw new BusinessLogicException("The museum with the given id was not found", BusinessError.NOT_FOUND);
       
       return await this.museumRepository.save({...persistedMuseum, ...museum});
   }

Se puede ver que antes de ejecutar el método que se encarga de actualizar la entidad, se ejecuta el método findOne de la clase repositorio, con el fin de verificar que un museo con el ID dado exista previamente.

Note que acá se hace uso del método save del repositorio. Esto ocurre porque el objeto persistedMuseum se ha traído de la base de datos, se dejan los mismos atributos originales y luego se actualizan por los nuevos valores que vienen del cliente (museum).

Finalmente, para borrar un museo se definirá el método delete en la clase MuseumService, cuya implementación se ve a continuación:

  async delete(id: string) {
       const museum: MuseumEntity = await this.museumRepository.findOne({where:{id}});
       if (!museum)
         throw new BusinessLogicException("The museum with the given id was not found", BusinessError.NOT_FOUND);
    
       await this.museumRepository.remove(museum);
   }

Este método recibe como único parámetro, el ID del museo que se desea eliminar. En este caso se utilizó el método findOne (al igual que en el método update) para encontrar el museo que se desea eliminar. Luego de esto, invoca al método remove de la clase Repository, que recibe toda la entidad que se desea eliminar y la borra de la base de datos.

A continuación se tiene la implementación completa de la clase MuseumService con todos los métodos implementados:

/* eslint-disable prettier/prettier */
/* archivo: src/museum/museum.service.ts */
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BusinessError, BusinessLogicException } from '../shared/errors/business-errors';
import { Repository } from 'typeorm';
import { MuseumEntity } from './museum.entity';

@Injectable()
export class MuseumService {
   constructor(
       @InjectRepository(MuseumEntity)
       private readonly museumRepository: Repository<MuseumEntity>
   ){}

   async findAll(): Promise<MuseumEntity[]> {
       return await this.museumRepository.find({ relations: ["artworks", "exhibitions"] });
   }

   async findOne(id: string): Promise<MuseumEntity> {
       const museum: MuseumEntity = await this.museumRepository.findOne({where: {id}, relations: ["artworks", "exhibitions"] } );
       if (!museum)
         throw new BusinessLogicException("The museum with the given id was not found", BusinessError.NOT_FOUND);
  
       return museum;
   }
  
   async create(museum: MuseumEntity): Promise<MuseumEntity> {
       return await this.museumRepository.save(museum);
   }

   async update(id: string, museum: MuseumEntity): Promise<MuseumEntity> {
       const persistedMuseum: MuseumEntity = await this.museumRepository.findOne({where:{id}});
       if (!persistedMuseum)
         throw new BusinessLogicException("The museum with the given id was not found", BusinessError.NOT_FOUND);
      
       museum.id = id; 
      
       return await this.museumRepository.save(museum);
   }

   async delete(id: string) {
       const museum: MuseumEntity = await this.museumRepository.findOne({where:{id}});
       if (!museum)
         throw new BusinessLogicException("The museum with the given id was not found", BusinessError.NOT_FOUND);
    
       await this.museumRepository.remove(museum);
   }
}
/* archivo: src/museum/museum.service.ts */

A continuación verificaremos el correcto funcionamiento de los métodos de la clase MuseumService. Las pruebas se especifican en el archivo museum.service.spec.ts el cual se encuentra dentro de la carpeta src/museum.

Iniciamos ejecutando la prueba por defecto. Para esto en una terminal ejecutamos el comando npm run test:watch; de este modo se ejecutan las pruebas en modo watch lo que significa que el servidor de pruebas estará pendiente de cambios en los archivos.

Cuando se ejecuta el comando podemos observar en la consola un error semejante a este:

El error anterior ocurre porque en el servicio se está inyectando un repositorio de TypeORM; por tanto en la prueba también se debe realizar esa configuración.

Como todas las pruebas de los demás servicios que se implementen usarán esa configuración, crearemos un nuevo archivo con las instrucciones.

En la carpeta src/shared cree una nueva carpeta denominada testing-utils y cree el archivo typeorm-testing-config.ts el cual tiene el siguiente código:

/* eslint-disable prettier/prettier */
/* archivo src/shared/testing-utils/typeorm-testing-config.ts*/
import { TypeOrmModule } from '@nestjs/typeorm';
import { ArtistEntity } from 'src/artist/artist.entity';
import { ArtworkEntity } from 'src/artwork/artwork.entity';
import { ExhibitionEntity } from 'src/exhibition/exhibition.entity';
import { ImageEntity } from 'src/image/image.entity';
import { MovementEntity } from 'src/movement/movement.entity';
import { MuseumEntity } from 'src/museum/museum.entity';
import { SponsorEntity } from 'src/sponsor/sponsor.entity';

export const TypeOrmTestingConfig = () => [
 TypeOrmModule.forRoot({
   type: 'sqlite',
   database: ':memory:',
   dropSchema: true,
   entities: [ArtistEntity, ArtworkEntity, ExhibitionEntity, ImageEntity, MovementEntity, MuseumEntity, SponsorEntity],
   synchronize: true,
   keepConnectionAlive: true
 }),
 TypeOrmModule.forFeature([ArtistEntity, ArtworkEntity, ExhibitionEntity, ImageEntity, MovementEntity, MuseumEntity, SponsorEntity]),
];
/* archivo src/shared/testing-utils/typeorm-testing-config.ts*/

La constante TypeOrmTestingConfig hace a la configuración de TypeORM para las pruebas. Tenga en cuenta que las pruebas de los servicios incluyen la persistencia de objetos en una base de datos; por tanto para usar un entorno de pruebas y facilitar las tareas se ha optado por usar sqlite3, una base de datos en memoria. En la configuración tenemos lo siguiente:

Como tenemos una referencia a una base de datos sqlite debemos instalar la dependencia correspondiente; entonces, en una terminal ejecute el comando npm install sqlite3 --save.

Ahora abra el archivo src/museum/museum.service.spec.ts e incluya el siguiente código:

/* eslint-disable prettier/prettier */
/*archivo src/museum/museum.service.spec.ts*/
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TypeOrmTestingConfig } from '../shared/testing-utils/typeorm-testing-config';
import { MuseumEntity } from './museum.entity';
import { MuseumService } from './museum.service';

describe('MuseumService', () => {
 let service: MuseumService;
 let repository: Repository<MuseumEntity>;

 beforeEach(async () => {
   const module: TestingModule = await Test.createTestingModule({
     imports: [...TypeOrmTestingConfig()],
     providers: [MuseumService],
   }).compile();

   service = module.get<MuseumService>(MuseumService);
   repository = module.get<Repository<MuseumEntity>>(getRepositoryToken(MuseumEntity));
 });
  
 it('should be defined', () => {
   expect(service).toBeDefined();
 });

});

/*archivo src/museum/museum.service.spec.ts*/

Hemos incluido en el arreglo imports la referencia a TypeOrmTestingConfig declarada anteriormente. También agregamos una nueva variable repository para tener acceso al repositorio de MuseumEntity. Esta variable se inicializa dentro del método beforeEach, el cual se ejecuta antes de cada prueba.

Como instalamos una nueva dependencia debemos parar el proceso de pruebas y ejecutarlo nuevamente (npm run test:watch). Ahora las pruebas por defecto deben ejecutarse correctamente.

Prueba método findAll

En esta prueba verificaremos que el servicio retorne todas las entidades de la base de datos. Necesitamos entonces crear un conjunto de datos iniciales de prueba. Definiremos una función seedDatabase que insertará esos datos de prueba. Este es el contenido de la función:

const seedDatabase = async () => {
   repository.clear();
   museumsList = [];
   for(let i = 0; i < 5; i++){
       const museum: MuseumEntity = await repository.save({
       name: faker.company.name(),
       description: faker.lorem.sentence(),
       address: faker.address.secondaryAddress(),
       city: faker.address.city(),
       image: faker.image.imageUrl()})
       museumsList.push(museum);
   }
 }

Esta función inicia borrando los datos del repositorio. Luego inicializa un arreglo de museos (museumList) que usaremos para guardar temporalmente los museos. Esta variable se crea en el bloque describe para que sea accesible por todos los specs. Posteriormente, en un bucle inserta 5 objetos en la base de datos. Para facilitar la generación de datos aleatorios usaremos la librería faker la cual se instala con el comando npm install @faker-js/faker --save-dev. Para usarla, al comienzo del archivo se importa así:

import { faker } from '@faker-js/faker';

La función se invocará en el método beforeEach así:

beforeEach(async () => {
   const module: TestingModule = await Test.createTestingModule({
     imports: [...TypeOrmTestingConfig()],
     providers: [MuseumService],
   }).compile();

   service = module.get<MuseumService>(MuseumService);
   repository = module.get<Repository<MuseumEntity>>(getRepositoryToken(MuseumEntity));
  await seedDatabase();
 });

Ahora crearemos un nuevo spec que invocará al método findAll del servicio y se espera que retorne una lista no nula con un tamaño igual a la lista museumList.

El siguiente es el código de ese spec:

it('findAll should return all museums', async () => {
   const museums: MuseumEntity[] = await service.findAll();
   expect(museums).not.toBeNull();
   expect(museums).toHaveLength(museumsList.length);
 });

Prueba método findOne

En este método tomaremos el primer museo almacenado en la lista el cual tiene asignado un ID por la base de datos. Luego con ayuda del servicio buscaremos ese museo en la base de datos. El objeto retornado por el museo no debe ser nulo y sus atributos deben coincidir con los del museo almacenado en la lista.

Este es el código de la prueba:

 it('findOne should return a museum by id', async () => {
   const storedMuseum: MuseumEntity = museumsList[0];
   const museum: MuseumEntity = await service.findOne(storedMuseum.id);
   expect(museum).not.toBeNull();
   expect(museum.name).toEqual(storedMuseum.name)
   expect(museum.description).toEqual(storedMuseum.description)
   expect(museum.address).toEqual(storedMuseum.address)
   expect(museum.city).toEqual(storedMuseum.city)
   expect(museum.image).toEqual(storedMuseum.image)
 });

Prueba método findOne: museo no existente

Cuando se busca un museo que no existe el método findOne de la lógica debe lanzar una excepción. Este comportamiento también debemos verificarlo en las pruebas.

Agregaremos un nuevo spec que se encarga de probar el comportamiento. Este es el código:

it('findOne should throw an exception for an invalid museum', async () => {
   await expect(() => service.findOne("0")).rejects.toHaveProperty("message", "The museum with the given id was not found")
 });

En este caso la sintaxis cambia un poco. Cuando se quieren capturar excepciones en métodos asíncronos en el expect se pasa una función anónima. La función tiene como contenido el llamado al método findOne del servicio y recibe como parámetro el valor "0" que pertenece a un UUID no válido. Se espera que la función retorne una excepción (rejects) junto con el mensaje "The museum with the given id was not found".

Prueba método create

Acá se probará que un nuevo museo se cree correctamente en la base de datos. Iniciamos definiendo los datos para un nuevo museo; luego se persiste con ayuda del método create del servicio. Este método retornará el nuevo museo creado al que se le ha asignado un nuevo id. Por tanto se espera que el retorno del método no sea nulo.

Usando el repositorio se buscará el museo por id. Este museo debe existir y los valores de cada atributo deben coincidir con el museo creado.

El siguiente es el código del spec:

it('create should return a new museum', async () => {
   const museum: MuseumEntity = {
     id: "",
     name: faker.company.companyName(),
     description: faker.lorem.sentence(),
     address: faker.address.secondaryAddress(),
     city: faker.address.city(),
     image: faker.image.imageUrl(),
     exhibitions: [],
     artworks: []
   }

   const newMuseum: MuseumEntity = await service.create(museum);
   expect(newMuseum).not.toBeNull();

   const storedMuseum: MuseumEntity = await repository.findOne({where: {id: newMuseum.id}})
   expect(storedMuseum).not.toBeNull();
   expect(storedMuseum.name).toEqual(newMuseum.name)
   expect(storedMuseum.description).toEqual(newMuseum.description)
   expect(storedMuseum.address).toEqual(newMuseum.address)
   expect(storedMuseum.city).toEqual(newMuseum.city)
   expect(storedMuseum.image).toEqual(newMuseum.image)
 });

Prueba método update

Acá se probará que un museo se actualice correctamente en la base de datos.

it('update should modify a museum', async () => {
   const museum: MuseumEntity = museumsList[0];
   museum.name = "New name";
   museum.address = "New address";
    const updatedMuseum: MuseumEntity = await service.update(museum.id, museum);
   expect(updatedMuseum).not.toBeNull();
    const storedMuseum: MuseumEntity = await repository.findOne({ where: { id: museum.id } })
   expect(storedMuseum).not.toBeNull();
   expect(storedMuseum.name).toEqual(museum.name)
   expect(storedMuseum.address).toEqual(museum.address)
 });

El método inicia tomando el museo que se encuentra en la primera posición de la lista y lo asigna a la variable museum. Luego se modifican el nombre y la dirección. Se llama al método del servicio que actualiza un museo. Este método recibe el id del museo que se quiere actualizar y los nuevos datos del museo.

Se espera que el método no retorne nulo. Luego, con ayuda del repositorio se busca en la base de datos el museo, se verifica que exista y que los atributos nombre y dirección se hayan actualizado.

Prueba método update: museo no existente

En esta prueba se espera que el método update lance una excepción cuando se intenta actualizar un museo que no existe.

it('update should throw an exception for an invalid museum', async () => {
   let museum: MuseumEntity = museumsList[0];
   museum = {
     ...museum, name: "New name", address: "New address"
   }
   await expect(() => service.update("0", museum)).rejects.toHaveProperty("message", "The museum with the given id was not found")
 });

Al igual que en la prueba anterior el método inicia tomando el museo que se encuentra en la primera posición de la lista y lo asigna a la variable museum. Luego se hace una copia de todos los atributos pero se modifican el nombre y la dirección. Se llama al método del servicio que actualiza un museo. Este método recibe el id un museo que no existe (el museo con el id "0") y los nuevos datos del museo.

Se espera que el método lance una excepción (rejects) con un mensaje descriptivo del error.

Prueba método delete

En esta prueba se verifica que el método delete del servicio elimine correctamente un museo.

it('delete should remove a museum', async () => {
   const museum: MuseumEntity = museumsList[0];
   await service.delete(museum.id);
    const deletedMuseum: MuseumEntity = await repository.findOne({ where: { id: museum.id } })
   expect(deletedMuseum).toBeNull();
 });

Se inicia asignando a la variable museum el primer museo de la lista. Luego se llama al método delete del servicio pasando el id de ese museo. En el siguiente paso, se busca en la base de datos el museo; como este ya fue eliminado se espera que valor de la variable deletedMuseum sea null.

Prueba método delete: museo no existente

En esta prueba se espera que el servicio lance una excepción cuando se intenta borrar un museo que no existe. El siguiente es el código del spec:

it('delete should throw an exception for an invalid museum', async () => {
   const museum: MuseumEntity = museumsList[0];
   await expect(() => service.delete("0")).rejects.toHaveProperty("message", "The museum with the given id was not found")
 });

En el siguiente enlace puede encontrar todo el contenido para la prueba del servicio de museos:

https://github.com/MISW4403-Diseno-y-construccion-de-APIs/MISW4403_202214_Logica/blob/master/src/museum/museum.service.spec.ts

Para el manejo de las asociaciones y con el fin de hacer más mantenible el código se ha decidido implementar un servicio adicional al servicio de cada una de las entidades de la asociación. Para ejemplificar esto, usaremos la asociación OneToMany que tiene la clase MuseumEntity con la clase ArtworkEntity.

Inicialmente debemos crear un módulo para la asociación, esto lo hacemos con el comando nest g mo museum-artwork. De igual forma crearemos un servicio con el comando nest g s museum-artwork.

Se creará en el archivo src/museum-artwork/museum-artwork.service.ts una nueva clase para el servicio, denominada MuseumArtworkService.

Dado que esta clase representa una asociación entre las entidades Museum y Artwork, dentro del constructor se debe hacer la inyección del repositorio de cada una de estas clases.

  constructor(
       @InjectRepository(MuseumEntity)
       private readonly museumRepository: Repository<MuseumEntity>,
   
       @InjectRepository(ArtworkEntity)
       private readonly artworkRepository: Repository<ArtworkEntity>
   ) {}

Ahora se debe actualizar el archivo del módulo para incluir la referencia a TypeORM y las dos entidades que requiere el servicio:

/*archivo src/museum-artwork/museum-artwork.module.ts*/
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MuseumEntity } from './museum.entity';
import { MuseumService } from './museum.service';

@Module({
 imports: [TypeOrmModule.forFeature([MuseumEntity])],
 providers: [MuseumService],
})
export class MuseumModule {}

Como se mencionó en el tutorial de persistencia, en las asociaciones ManyToOne y OneToMany la entidad que es dueña de la asociación es aquella que tiene el atributo con cardinalidad muchos, en este caso la dueña de la asociación es MuseumEntity, dado que tiene un atributo artworks que es un arreglo.

Dentro de este servicio, se definirán 5 métodos que son las principales acciones que se podrían ejecutar en esta asociación. Estos métodos son:

La implementación completa de la clase con estos métodos se vería de la siguiente forma:

/* archivo: src/museum-artwork/museum-artwork.service.ts */
/* eslint-disable prettier/prettier */
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ArtworkEntity } from '../artwork/artwork.entity';
import { MuseumEntity } from '../museum/museum.entity';
import { Repository } from 'typeorm';
import { BusinessError, BusinessLogicException } from '../shared/errors/business-errors';

@Injectable()
export class MuseumArtworkService {
   constructor(
       @InjectRepository(MuseumEntity)
       private readonly museumRepository: Repository<MuseumEntity>,
   
       @InjectRepository(ArtworkEntity)
       private readonly artworkRepository: Repository<ArtworkEntity>
   ) {}

   async addArtworkMuseum(museumId: string, artworkId: string): Promise<MuseumEntity> {
       const artwork: ArtworkEntity = await this.artworkRepository.findOne({where: {id: artworkId}});
       if (!artwork)
         throw new BusinessLogicException("The artwork with the given id was not found", BusinessError.NOT_FOUND);
     
       const museum: MuseumEntity = await this.museumRepository.findOne({where: {id: museumId}, relations: ["artworks", "exhibitions"]})
       if (!museum)
         throw new BusinessLogicException("The museum with the given id was not found", BusinessError.NOT_FOUND);
   
       museum.artworks = [...museum.artworks, artwork];
       return await this.museumRepository.save(museum);
     }
   
   async findArtworkByMuseumIdArtworkId(museumId: string, artworkId: string): Promise<ArtworkEntity> {
       const artwork: ArtworkEntity = await this.artworkRepository.findOne({where: {id: artworkId}});
       if (!artwork)
         throw new BusinessLogicException("The artwork with the given id was not found", BusinessError.NOT_FOUND)
      
       const museum: MuseumEntity = await this.museumRepository.findOne({where: {id: museumId}, relations: ["artworks"]});
       if (!museum)
         throw new BusinessLogicException("The museum with the given id was not found", BusinessError.NOT_FOUND)
  
       const museumArtwork: ArtworkEntity = museum.artworks.find(e => e.id === artwork.id);
  
       if (!museumArtwork)
         throw new BusinessLogicException("The artwork with the given id is not associated to the museum", BusinessError.PRECONDITION_FAILED)
  
       return museumArtwork;
   }
   
   async findArtworksByMuseumId(museumId: string): Promise<ArtworkEntity[]> {
       const museum: MuseumEntity = await this.museumRepository.findOne({where: {id: museumId}, relations: ["artworks"]});
       if (!museum)
         throw new BusinessLogicException("The museum with the given id was not found", BusinessError.NOT_FOUND)
      
       return museum.artworks;
   }
   
   async associateArtworksMuseum(museumId: string, artworks: ArtworkEntity[]): Promise<MuseumEntity> {
       const museum: MuseumEntity = await this.museumRepository.findOne({where: {id: museumId}, relations: ["artworks"]});
   
       if (!museum)
         throw new BusinessLogicException("The museum with the given id was not found", BusinessError.NOT_FOUND)
   
       for (let i = 0; i < artworks.length; i++) {
         const artwork: ArtworkEntity = await this.artworkRepository.findOne({where: {id: artworks[i].id}});
         if (!artwork)
           throw new BusinessLogicException("The artwork with the given id was not found", BusinessError.NOT_FOUND)
       }
   
       museum.artworks = artworks;
       return await this.museumRepository.save(museum);
     }
   
   async deleteArtworkMuseum(museumId: string, artworkId: string){
       const artwork: ArtworkEntity = await this.artworkRepository.findOne({where: {id: artworkId}});
       if (!artwork)
         throw new BusinessLogicException("The artwork with the given id was not found", BusinessError.NOT_FOUND)
   
       const museum: MuseumEntity = await this.museumRepository.findOne({where: {id: museumId}, relations: ["artworks"]});
       if (!museum)
         throw new BusinessLogicException("The museum with the given id was not found", BusinessError.NOT_FOUND)
   
       const museumArtwork: ArtworkEntity = museum.artworks.find(e => e.id === artwork.id);
   
       if (!museumArtwork)
           throw new BusinessLogicException("The artwork with the given id is not associated to the museum", BusinessError.PRECONDITION_FAILED)

       museum.artworks = museum.artworks.filter(e => e.id !== artworkId);
       await this.museumRepository.save(museum);
   }  
}

En el archivo src/museum-artwork/museum-artwork.service.spec.ts se especifican las pruebas para los métodos del servicio que se encarga de manejar las asociaciones.

Siguiendo el mismo esquema de las pruebas anteriores, se definen las siguientes variables a nivel de la suite de pruebas:

let service: MuseumArtworkService;
let museumRepository: Repository<MuseumEntity>;
let artworkRepository: Repository<ArtworkEntity>;
let museum: MuseumEntity;
let artworksList : ArtworkEntity[];

Tenemos la referencia al servicio que se va a probar, los repositorios para consultar información de los museos y de las obras de arte, una variable que representa un museo y un listado de obras de arte que posteriormente se agregarán a un museo.

En la función seedDatabase se borran los datos de obras de arte y museos. Luego se crea un conjunto de obras de arte que se persisten y se agregan a la lista artworksList. Finalmente se crea un museo al que se le agregan las obras de arte previamente creadas.

const seedDatabase = async () => {
   artworkRepository.clear();
   museumRepository.clear();

   artworksList = [];
   for(let i = 0; i < 5; i++){
       const artwork: ArtworkEntity = await artworkRepository.save({
         name: faker.company.companyName(),
         year: parseInt(faker.random.numeric()),
         description: faker.lorem.sentence(),
         type: "Painting",
         mainImage: faker.image.imageUrl()
       })
       artworksList.push(artwork);
   }

   museum = await museumRepository.save({
     name: faker.company.companyName(),
     description: faker.lorem.sentence(),
     address: faker.address.secondaryAddress(),
     city: faker.address.city(),
     image: faker.image.imageUrl(),
     artworks: artworksList
   })
 }

Prueba del método addArtworkMuseum

it('addArtworkMuseum should add an artwork to a museum', async () => {
   const newArtwork: ArtworkEntity = await artworkRepository.save({
     name: faker.company.companyName(),
     year: parseInt(faker.random.numeric()),
     description: faker.lorem.sentence(),
     type: "Painting",
     mainImage: faker.image.imageUrl()
   });

   const newMuseum: MuseumEntity = await museumRepository.save({
     name: faker.company.companyName(),
     description: faker.lorem.sentence(),
     address: faker.address.secondaryAddress(),
     city: faker.address.city(),
     image: faker.image.imageUrl()
   })

   const result: MuseumEntity = await service.addArtworkMuseum(newMuseum.id, newArtwork.id);
  
   expect(result.artworks.length).toBe(1);
   expect(result.artworks[0]).not.toBeNull();
   expect(result.artworks[0].name).toBe(newArtwork.name)
   expect(result.artworks[0].year).toBe(newArtwork.year)
   expect(result.artworks[0].description).toBe(newArtwork.description)
   expect(result.artworks[0].type).toBe(newArtwork.type)
   expect(result.artworks[0].mainImage).toBe(newArtwork.mainImage)
 });

Se inicia creando una obra de arte que se persiste. Luego se crea un museo que también se persiste. Se llama al método del servicio que agrega una obra de arte a un museo. Si todo está correcto el método retornará una entidad correspondiente al museo actualizado. Este museo tendrá una lista en la cual hay solo una obra de arte. Ese elemento no puede ser nulo y los atributos deben corresponder a los que se usaron para crear la obra.

Prueba del método addArtworkMuseum: obra de arte inválida

Al intentar agregar una obra de arte que no existe a un museo se espera que el método del servicio lance una excepción.

it('addArtworkMuseum should thrown exception for an invalid artwork', async () => {
   const newMuseum: MuseumEntity = await museumRepository.save({
     name: faker.company.companyName(),
     description: faker.lorem.sentence(),
     address: faker.address.secondaryAddress(),
     city: faker.address.city(),
     image: faker.image.imageUrl()
   })

   await expect(() => service.addArtworkMuseum(newMuseum.id, "0")).rejects.toHaveProperty("message", "The artwork with the given id was not found");
 });

Prueba del método addArtworkMuseum: museo inválido

Al intentar agregar una obra de arte a un museo que no existe se espera que el método del servicio lance una excepción.

it('addArtworkMuseum should throw an exception for an invalid museum', async () => {
   const newArtwork: ArtworkEntity = await artworkRepository.save({
     name: faker.company.companyName(),
     year: parseInt(faker.random.numeric()),
     description: faker.lorem.sentence(),
     type: "Painting",
     mainImage: faker.image.imageUrl()
   });

   await expect(() => service.addArtworkMuseum("0", newArtwork.id)).rejects.toHaveProperty("message", "The museum with the given id was not found");
 });

Prueba del método findArtworkByMuseumIdArtworkId

Este método prueba que se obtenga una obra de arte de un museo. Se toma el primer museo de la lista. Luego se invoca al método del servicio pasando el id de un museo existente y el id de una obra que está asociada al museo. El resultado no puede ser nulo y los atributos de la obra de arte deben corresponder con el primer elemento de la lista.

 it('findArtworkByMuseumIdArtworkId should return artwork by museum', async () => {
   const artwork: ArtworkEntity = artworksList[0];
   const storedArtwork: ArtworkEntity = await service.findArtworkByMuseumIdArtworkId(museum.id, artwork.id, )
   expect(storedArtwork).not.toBeNull();
   expect(storedArtwork.name).toBe(artwork.name);
   expect(storedArtwork.year).toBe(artwork.year);
   expect(storedArtwork.description).toBe(artwork.description);
   expect(storedArtwork.type).toBe(artwork.type);
   expect(storedArtwork.mainImage).toBe(artwork.mainImage);
 });

Prueba del método findArtworkByMuseumIdArtworkId: obra de arte inválida

Se espera que el método lance una excepción cuando se intenta buscar en un museo una obra de arte que no existe.

it('findArtworkByMuseumIdArtworkId should throw an exception for an invalid artwork', async () => {
   await expect(()=> service.findArtworkByMuseumIdArtworkId(museum.id, "0")).rejects.toHaveProperty("message", "The artwork with the given id was not found");
 });

Prueba del método findArtworkByMuseumIdArtworkId: museo inválido

Se espera que el método lance una excepción cuando se intenta buscar en un museo que no existe una obra de arte.

it('findArtworkByMuseumIdArtworkId should throw an exception for an invalid museum', async () => {
   const artwork: ArtworkEntity = artworksList[0];
   await expect(()=> service.findArtworkByMuseumIdArtworkId("0", artwork.id)).rejects.toHaveProperty("message", "The museum with the given id was not found");
 });

Prueba del método findArtworkByMuseumIdArtworkId: obra no asociada al museo

Se inicia creando una nueva obra de arte, la cual por defecto no está asociada a ningún museo. Luego se invoca al método del servicio que deberá lanzar una excepción.

it('findArtworkByMuseumIdArtworkId should throw an exception for an artwork not associated to the museum', async () => {
   const newArtwork: ArtworkEntity = await artworkRepository.save({
     name: faker.company.companyName(),
     year: parseInt(faker.random.numeric()),
     description: faker.lorem.sentence(),
     type: "Painting",
     mainImage: faker.image.imageUrl()
   });

   await expect(()=> service.findArtworkByMuseumIdArtworkId(museum.id, newArtwork.id)).rejects.toHaveProperty("message", "The artwork with the given id is not associated to the museum");
 });

Prueba del método findArtworksByMuseumId

Se verifica que el método retorna todas las obras de arte un museo. En este caso se espera que el museo tenga un total de 5 obras de arte que fueron las asignadas en la configuración de la prueba.

it('findArtworksByMuseumId should return artworks by museum', async ()=>{
   const artworks: ArtworkEntity[] = await service.findArtworksByMuseumId(museum.id);
   expect(artworks.length).toBe(5)
 });

Prueba del método findArtworksByMuseumId: museo inválido

Se espera que el método lance una excepción cuando se intenta obtener las obras de arte de un museo que no existe.

it('findArtworksByMuseumId should throw an exception for an invalid museum', async () => {
   await expect(()=> service.findArtworksByMuseumId("0")).rejects.toHaveProperty("message", "The museum with the given id was not found");
 });

Prueba del método associateArtworksMuseum

Este método reemplaza las obras de arte de un museo por una nueva lista. Se incia creando un nueva obra de arte. Se invoca al método del servicio pasando el id de un museo y un arreglo de obras de arte el cual contiene la obra previamente creada. Se verifica que ahora el número de obras del museo no sea 5 si no 1 y que los atributos de esa obra correspondan con los que se usaron para crearla.

it('associateArtworksMuseum should update artworks list for a museum', async () => {
   const newArtwork: ArtworkEntity = await artworkRepository.save({
     name: faker.company.companyName(),
     year: parseInt(faker.random.numeric()),
     description: faker.lorem.sentence(),
     type: "Painting",
     mainImage: faker.image.imageUrl()
   });

   const updatedMuseum: MuseumEntity = await service.associateArtworksMuseum(museum.id, [newArtwork]);
   expect(updatedMuseum.artworks.length).toBe(1);

   expect(updatedMuseum.artworks[0].name).toBe(newArtwork.name);
   expect(updatedMuseum.artworks[0].year).toBe(newArtwork.year);
   expect(updatedMuseum.artworks[0].description).toBe(newArtwork.description);
   expect(updatedMuseum.artworks[0].type).toBe(newArtwork.type);
   expect(updatedMuseum.artworks[0].mainImage).toBe(newArtwork.mainImage);
 });

Prueba del método associateArtworksMuseum: museo inválido

Este método lanza una excepción al intentar asociar un conjunto de obras a un museo que no existe.

it('associateArtworksMuseum should throw an exception for an invalid museum', async () => {
   const newArtwork: ArtworkEntity = await artworkRepository.save({
     name: faker.company.companyName(),
     year: parseInt(faker.random.numeric()),
     description: faker.lorem.sentence(),
     type: "Painting",
     mainImage: faker.image.imageUrl()
   });

   await expect(()=> service.associateArtworksMuseum("0", [newArtwork])).rejects.toHaveProperty("message", "The museum with the given id was not found");
 });

Prueba del método associateArtworksMuseum: obra inválida

Este método lanza una excepción al intentar asociar un conjunto de obras que no existen a un museo.

it('associateArtworksMuseum should throw an exception for an invalid artwork', async () => {
   const newArtwork: ArtworkEntity = artworksList[0];
   newArtwork.id = "0";

   await expect(()=> service.associateArtworksMuseum(museum.id, [newArtwork])).rejects.toHaveProperty("message", "The artwork with the given id was not found");
 });

Prueba del método deleteArtworkToMuseum

En este método se borra una obra de un museo. Se toma como referencia un museo existente y una obra asociada al museo. Luego de invocar al método de la lógica se espera que la obra de arte no esté asociada.

it('deleteArtworkToMuseum should remove an artwork from a museum', async () => {
   const artwork: ArtworkEntity = artworksList[0];
  
   await service.deleteArtworkMuseum(museum.id, artwork.id);

   const storedMuseum: MuseumEntity = await museumRepository.findOne({where: {id: museum.id}, relations: ["artworks"]});
   const deletedArtwork: ArtworkEntity = storedMuseum.artworks.find(a => a.id === artwork.id);

   expect(deletedArtwork).toBeUndefined();

 });

Prueba del método deleteArtworkToMuseum: obra inválida

Se espera que el método lance una excepción al intentar borrar una obra que no existe de un museo.

it('deleteArtworkToMuseum should thrown an exception for an invalid artwork', async () => {
   await expect(()=> service.deleteArtworkMuseum(museum.id, "0")).rejects.toHaveProperty("message", "The artwork with the given id was not found");
 });

Prueba del método deleteArtworkToMuseum: museo inválido

Se espera que el método lance una excepción al intentar borrar una obra de un museo que no existe.

it('deleteArtworkToMuseum should thrown an exception for an invalid museum', async () => {
   const artwork: ArtworkEntity = artworksList[0];
   await expect(()=> service.deleteArtworkMuseum("0", artwork.id)).rejects.toHaveProperty("message", "The museum with the given id was not found");
 });

Prueba del método deleteArtworkToMuseum: obra no asociada al museo

Se espera que el método lance una excepción al intentar borrar una obra que aunque existe no está asociada a un museo.

it('deleteArtworkToMuseum should thrown an exception for an non asocciated artwork', async () => {
   const newArtwork: ArtworkEntity = await artworkRepository.save({
     name: faker.company.companyName(),
     year: parseInt(faker.random.numeric()),
     description: faker.lorem.sentence(),
     type: "Painting",
     mainImage: faker.image.imageUrl()
   });

   await expect(()=> service.deleteArtworkMuseum(museum.id, newArtwork.id)).rejects.toHaveProperty("message", "The artwork with the given id is not associated to the museum");
 });

El contenido de toda la suite de pruebas puede ser consultado en este enlace:

https://github.com/MISW4403-Diseno-y-construccion-de-APIs/MISW4403_202214_Logica/blob/master/src/museum-artwork/museum-artwork.service.spec.ts