diff --git a/src/common/pipes/builder-select-studio.pipe.ts b/src/common/pipes/builder-select-studio.pipe.ts new file mode 100644 index 0000000..7c84c3e --- /dev/null +++ b/src/common/pipes/builder-select-studio.pipe.ts @@ -0,0 +1,29 @@ +import { PipeTransform } from '@nestjs/common'; +import { fieldsMap } from 'graphql-fields-list'; +import { QueryStudioArg } from '~/graphql/types/args/query-studio.arg'; +import { NotFoundStudioError } from '~/graphql/types/dtos/studio/not-found-studio.error'; +import { + MapResultSelect, + reverseBooleanValueInObj, +} from '~/utils/tools/object'; + +export class BuilderSelectStudioPipe implements PipeTransform { + transform(value: any): MapResultSelect { + /* + * We don't want get fields from Error query and pass to Select TypeORM + */ + const fieldsToSkip = Object.getOwnPropertyNames( + new NotFoundStudioError({ + requestObject: new QueryStudioArg(), + }), + ); + + const mappedSelect = reverseBooleanValueInObj( + fieldsMap(value, { + skip: [...fieldsToSkip, '__typename', 'anime.pageInfo', 'pageInfo'], + }), + ); + + return mappedSelect; + } +} diff --git a/src/contracts/repositories/studio-repository.interface.ts b/src/contracts/repositories/studio-repository.interface.ts index db4e5f1..dba23e1 100644 --- a/src/contracts/repositories/studio-repository.interface.ts +++ b/src/contracts/repositories/studio-repository.interface.ts @@ -1,6 +1,13 @@ import { Studio } from '~/models/studio.model'; import { IBaseRepository } from './base-repository.interface'; +import { MapResultSelect } from '~/utils/tools/object'; +import { QueryStudioArg } from '~/graphql/types/args/query-studio.arg'; -export interface IStudioRepository extends IBaseRepository {} +export interface IStudioRepository extends IBaseRepository { + getStudioByConditions( + mapResultSelectParam: MapResultSelect, + queryAnimeArg: QueryStudioArg, + ): Promise; +} export const IStudioRepository = Symbol('IStudioRepository '); diff --git a/src/contracts/services/studio-service.interface.ts b/src/contracts/services/studio-service.interface.ts index 923e6ef..cd7c738 100644 --- a/src/contracts/services/studio-service.interface.ts +++ b/src/contracts/services/studio-service.interface.ts @@ -2,8 +2,21 @@ import { StudioEdge } from '~/models/studio-edge.model'; import { Studio } from '~/models/studio.model'; import { StudioConnection } from '~/models/sub-models/studio-sub-models/studio-connection.model'; import { IPaginateResult } from '../dtos'; +import { MapResultSelect } from '~/utils/tools/object'; +import { QueryStudioArg } from '~/graphql/types/args/query-studio.arg'; +import { Either } from '~/utils/tools/either'; +import { NotFoundStudioError } from '~/graphql/types/dtos/studio/not-found-studio.error'; -export interface IStudioService { +export interface IStudioExternalService { + getStudioByConditions( + mapResultSelect: MapResultSelect, + queryAnimeArg: QueryStudioArg, + ): Promise | Either>; +} + +export const IStudioExternalService = Symbol('IStudioExternalService'); + +export interface IStudioInternalService { saveManyStudio(studios: Partial[]): Promise; getStudioListV1( @@ -27,4 +40,4 @@ export interface IStudioService { ): Promise<(Partial & StudioConnection) | null>; } -export const IStudioService = Symbol('IStudioService'); +export const IStudioInternalService = Symbol('IStudioInternalService'); diff --git a/src/graphql/resolvers/studio.resolver.ts b/src/graphql/resolvers/studio.resolver.ts new file mode 100644 index 0000000..d213515 --- /dev/null +++ b/src/graphql/resolvers/studio.resolver.ts @@ -0,0 +1,83 @@ +import { + Args, + Info, + Parent, + Query, + ResolveField, + Resolver, +} from '@nestjs/graphql'; +import { StudioDto } from '~/common/dtos/studio-dtos/studio.dto'; +import { StudioResultUnion } from '../types/dtos/studio/studio.response'; +import { StudioActions } from '../types/enums/actions.enum'; +import { BuilderSelectStudioPipe } from '~/common/pipes/builder-select-studio.pipe'; +import { MapResultSelect } from '~/utils/tools/object'; +import { QueryStudioArg } from '../types/args/query-studio.arg'; +import { Inject } from '@nestjs/common'; +import { + IAnimeExternalService, + IStudioExternalService, +} from '~/contracts/services'; +import { InjectMapper } from '@automapper/nestjs'; +import { Mapper } from '@automapper/core'; +import { Studio } from '~/models/studio.model'; +import { nameof } from 'ts-simple-nameof'; +import { AnimeConnectionDto } from '~/common/dtos/anime-dtos/anime-connection.dto'; +import { QueryAnimeConnectionArg } from '../types/args/query-anime-connection.arg'; +import { AnimeConnection } from '~/models/sub-models/anime-sub-models'; + +@Resolver(() => StudioDto) +export class StudioResolver { + constructor( + @Inject(IStudioExternalService) + private readonly studioExternalService: IStudioExternalService, + + @Inject(IAnimeExternalService) + private readonly animeService: IAnimeExternalService, + + @InjectMapper() private readonly mapper: Mapper, + ) {} + + @Query(() => [StudioResultUnion], { name: StudioActions.Studio }) + public async getStudioInfo( + @Info(BuilderSelectStudioPipe) mapResultSelect: MapResultSelect, + @Args() queryAnimeArg: QueryStudioArg, + ) { + const result = await this.studioExternalService.getStudioByConditions( + mapResultSelect, + queryAnimeArg, + ); + + if (result.isError()) { + return [result.value]; + } + + return [this.mapper.map(result.value, Studio, StudioDto)]; + } + + @ResolveField( + nameof((s) => s.anime), + (returns) => AnimeConnectionDto, + { nullable: true }, + ) + public async getAnimeByCharacter( + @Parent() studioDto: StudioDto, + @Args() queryAnimeConnectionArg: QueryAnimeConnectionArg, + @Info(BuilderSelectStudioPipe) mapResultSelect: MapResultSelect, + ) { + if (!studioDto?.anime?.id) return null; + + const animeConnection = await this.animeService.getAnimeConnectionPage( + studioDto?.anime?.id, + mapResultSelect, + queryAnimeConnectionArg, + ); + + if (!animeConnection) return null; + + return this.mapper.map( + animeConnection, + AnimeConnection, + AnimeConnectionDto, + ); + } +} diff --git a/src/graphql/types/args/query-studio.arg.ts b/src/graphql/types/args/query-studio.arg.ts new file mode 100644 index 0000000..d4ffa14 --- /dev/null +++ b/src/graphql/types/args/query-studio.arg.ts @@ -0,0 +1,17 @@ +import { ArgsType, Field, Int, ObjectType } from '@nestjs/graphql'; + +@ArgsType() +@ObjectType() +export class QueryStudioArg { + @Field({ nullable: true }) + id?: string; + + @Field({ nullable: true }) + name?: string; + + @Field({ nullable: true }) + isAnimationStudio?: boolean; + + @Field(() => Int) + idAnilist: number; +} diff --git a/src/graphql/types/dtos/studio/not-found-studio.error.ts b/src/graphql/types/dtos/studio/not-found-studio.error.ts new file mode 100644 index 0000000..1f906c3 --- /dev/null +++ b/src/graphql/types/dtos/studio/not-found-studio.error.ts @@ -0,0 +1,16 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { ErrorResponse } from '../error-response.interface'; +import { QueryStudioArg } from '../../args/query-studio.arg'; + +@ObjectType({ + implements: [ErrorResponse], +}) +export class NotFoundStudioError extends ErrorResponse { + @Field(() => QueryStudioArg) + requestObject: QueryStudioArg; + + constructor(partial?: Partial) { + super('Studio not found'); + Object.assign(this, partial); + } +} diff --git a/src/graphql/types/dtos/studio/studio.response.ts b/src/graphql/types/dtos/studio/studio.response.ts new file mode 100644 index 0000000..6d6a086 --- /dev/null +++ b/src/graphql/types/dtos/studio/studio.response.ts @@ -0,0 +1,8 @@ +import { createUnionType } from '@nestjs/graphql'; +import { StudioDto } from '~/common/dtos/studio-dtos/studio.dto'; +import { NotFoundStudioError } from './not-found-studio.error'; + +export const StudioResultUnion = createUnionType({ + name: 'StudioResult', + types: () => [StudioDto, NotFoundStudioError], +}); diff --git a/src/graphql/types/enums/actions.enum.ts b/src/graphql/types/enums/actions.enum.ts index c5a7715..d516824 100644 --- a/src/graphql/types/enums/actions.enum.ts +++ b/src/graphql/types/enums/actions.enum.ts @@ -9,3 +9,7 @@ export enum CharacterActions { export enum StaffActions { Staff = 'Staff', } + +export enum StudioActions { + Studio = 'Studio', +} diff --git a/src/modules/media.module.ts b/src/modules/media.module.ts index 0c51fd9..df4fe4e 100644 --- a/src/modules/media.module.ts +++ b/src/modules/media.module.ts @@ -15,9 +15,10 @@ import { IAnimeTagService, ICharacterInternalService, IStaffInternalService, - IStudioService, + IStudioInternalService, ICharacterExternalService, IStaffExternalService, + IStudioExternalService, } from '~/contracts/services'; import { MediaResolver } from '~/graphql/resolvers/media.resolver'; import { Anime, Character, CharacterEdge, Staff, StaffEdge } from '~/models'; @@ -80,6 +81,7 @@ import { StudioProfile } from '~/common/mapper-profiles/studio-profile'; import { CharacterResolver } from '~/graphql/resolvers/character.resolver'; import { StaffResolver } from '~/graphql/resolvers/staff.resolver'; import { StaffRepository } from '~/repositories/staff.repository'; +import { StudioResolver } from '~/graphql/resolvers/studio.resolver'; const animeRepoProvider: Provider = { provide: IAnimeRepository, @@ -137,8 +139,12 @@ const studioRepositoryProvider: Provider = { provide: IStudioRepository, useClass: StudioRepository, }; -const studioServiceProvider: Provider = { - provide: IStudioService, +const studioInternalServiceProvider: Provider = { + provide: IStudioInternalService, + useClass: StudioService, +}; +const studioExternalServiceProvider: Provider = { + provide: IStudioExternalService, useClass: StudioService, }; @@ -195,6 +201,7 @@ const studioServiceProvider: Provider = { MediaResolver, CharacterResolver, StaffResolver, + StudioResolver, animeRepoProvider, animeServiceProvider, @@ -212,7 +219,8 @@ const studioServiceProvider: Provider = { staffInternalServiceProvider, staffExternalServiceProvider, - studioServiceProvider, + studioInternalServiceProvider, + studioExternalServiceProvider, studioRepositoryProvider, ], exports: [ @@ -231,7 +239,8 @@ const studioServiceProvider: Provider = { staffInternalServiceProvider, staffExternalServiceProvider, - studioServiceProvider, + studioInternalServiceProvider, + studioExternalServiceProvider, studioRepositoryProvider, ], }) diff --git a/src/repositories/studio.repository.ts b/src/repositories/studio.repository.ts index 8e034f9..5f025a1 100644 --- a/src/repositories/studio.repository.ts +++ b/src/repositories/studio.repository.ts @@ -3,7 +3,10 @@ import { Studio } from '~/models/studio.model'; import { BaseRepository } from './base.repository'; import { IStudioRepository } from '~/contracts/repositories'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { MapResultSelect } from '~/utils/tools/object'; +import { QueryStudioArg } from '~/graphql/types/args/query-studio.arg'; +import { QueryBuilderChainer } from './libs/query-builder-chainer'; @Injectable() export class StudioRepository @@ -13,7 +16,68 @@ export class StudioRepository constructor( @InjectRepository(Studio) private readonly studioRepository: Repository, + + private dataSource: DataSource, ) { super(studioRepository); } + + get studioAlias() { + return 'Studio'; + } + + get studioQueryBuilder() { + return this.dataSource + .getRepository(Studio) + .createQueryBuilder(this.studioAlias); + } + + public async getStudioByConditions( + mapResultSelectParam: MapResultSelect, + queryAnimeArg: QueryStudioArg, + ) { + const mapResultSelect = mapResultSelectParam as Record; + + const queryBuilder = this.createQueryStudioByConditionsBuilder( + mapResultSelect, + queryAnimeArg, + ); + + const studio = await queryBuilder.getOne(); + + return studio; + } + + private createQueryStudioByConditionsBuilder( + mapResultSelect: Record, + queryAnimeArg: QueryStudioArg, + ) { + const { id, idAnilist, isAnimationStudio, name } = queryAnimeArg; + + return ( + new QueryBuilderChainer(this.studioQueryBuilder) + // query studio scalar fields + .addSelect(mapResultSelect, this.studioAlias, true) + + // query studio.anime + .applyJoinConditionally( + !!mapResultSelect['anime'], + this.studioAlias, + 'anime', + ) + .addSelect(mapResultSelect['anime'], 'anime', false, ['nodes', 'edges']) + + // where filters + .applyWhereConditionally(this.studioAlias, 'id', id) + .applyWhereConditionally(this.studioAlias, 'idAnilist', idAnilist) + .applyWhereConditionally( + this.studioAlias, + 'isAnimationStudio', + isAnimationStudio, + ) + .applyWhereConditionally(this.studioAlias, 'name', name) + + .getQueryBuilder() + ); + } } diff --git a/src/services/anilist.service.ts b/src/services/anilist.service.ts index 9621b76..3d79ae2 100644 --- a/src/services/anilist.service.ts +++ b/src/services/anilist.service.ts @@ -14,7 +14,7 @@ import { IAnimeTagService, ICharacterInternalService, IStaffInternalService, - IStudioService, + IStudioInternalService, } from '~/contracts/services'; import { Anime, Character } from '~/models'; import { AnimeEdge } from '~/models/anime-edge.model'; @@ -70,8 +70,8 @@ export class AnilistService implements IAnilistService { @Inject(IAnimeGenreService) private readonly animeGenreService: IAnimeGenreService, - @Inject(IStudioService) - private readonly studioService: IStudioService, + @Inject(IStudioInternalService) + private readonly studioService: IStudioInternalService, @Inject(IAnimeTagService) private readonly animeTagService: IAnimeTagService, diff --git a/src/services/studio.service.ts b/src/services/studio.service.ts index 40f4b2d..06c0d9c 100644 --- a/src/services/studio.service.ts +++ b/src/services/studio.service.ts @@ -5,15 +5,24 @@ import { Repository } from 'typeorm'; import { LOGGER_CREATED } from '~/common/constants'; import { CreateLoggerDto } from '~/common/dtos'; import { IStudioRepository } from '~/contracts/repositories'; -import { IStudioService } from '~/contracts/services'; +import { + IStudioExternalService, + IStudioInternalService, +} from '~/contracts/services'; import { StudioEdge } from '~/models/studio-edge.model'; import { Studio } from '~/models/studio.model'; import { StudioConnection } from '~/models/sub-models/studio-sub-models/studio-connection.model'; import { IPaginateResult } from '../contracts/dtos/paginate-result.interface'; import { getMethodName } from '~/utils/tools/functions'; +import { MapResultSelect } from '~/utils/tools/object'; +import { QueryStudioArg } from '~/graphql/types/args/query-studio.arg'; +import { either } from '~/utils/tools/either'; +import { NotFoundStudioError } from '~/graphql/types/dtos/studio/not-found-studio.error'; @Injectable() -export class StudioService implements IStudioService { +export class StudioService + implements IStudioInternalService, IStudioExternalService +{ private readonly logger = new Logger(StudioService.name); constructor( @@ -26,6 +35,24 @@ export class StudioService implements IStudioService { private readonly eventEmitter: EventEmitter2, ) {} + public async getStudioByConditions( + mapResultSelect: MapResultSelect, + queryAnimeArg: QueryStudioArg, + ) { + const studio = await this.studioRepository.getStudioByConditions( + mapResultSelect, + queryAnimeArg, + ); + + if (!studio) { + return either.error( + new NotFoundStudioError({ requestObject: queryAnimeArg }), + ); + } + + return either.of(studio); + } + public async saveStudioConnection( studioConnection: Partial, ) { diff --git a/test/__mocks__/studio.data.ts b/test/__mocks__/studio.data.ts new file mode 100644 index 0000000..996fef1 --- /dev/null +++ b/test/__mocks__/studio.data.ts @@ -0,0 +1,370 @@ +export const StudioListDto = [ + { + id: '0387996b-de6e-4849-af99-2ed227ee6a5a', + name: 'Animax', + createdAt: '2024-05-17T12:51:01.058Z', + favorites: null, + idAnilist: 140, + isAnimationStudio: false, + siteUrl: null, + updatedAt: '2024-06-01T14:42:08.721Z', + anime: { + pageInfo: { + currentPage: 1, + hasNextPage: true, + lastPage: 3, + perPage: 10, + total: 28, + }, + edges: [ + { + characterName: null, + characterRole: null, + dubGroup: null, + favouriteOrder: null, + idAnilist: 320, + isMainStudio: false, + relationType: null, + roleNotes: null, + staffRole: null, + node: { + title: { + english: 'Love You Baby', + native: '愛してるぜ ベイベ★★', + romaji: 'Aishiteruze Baby★★', + vietnamese: null, + userPreferred: 'Aishiteruze Baby★★', + }, + id: '56b202fb-23e8-446e-8cfd-7c34a8e803ad', + coverImage: { + createdAt: '2024-04-30T06:59:32.705Z', + extraLarge: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx102-nacpPTHmjvXJ.png', + medium: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx102-nacpPTHmjvXJ.png', + large: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx102-nacpPTHmjvXJ.png', + }, + characters: { + id: '9951ae0a-08e8-4d02-ae39-8c4c404dbd98', + updatedAt: '2024-05-07T18:07:31.246Z', + }, + }, + }, + { + characterName: null, + characterRole: null, + dubGroup: null, + favouriteOrder: null, + idAnilist: 544, + isMainStudio: false, + relationType: null, + roleNotes: null, + staffRole: null, + node: { + title: { + english: 'Slam Dunk', + native: 'SLAM DUNK', + romaji: 'SLAM DUNK', + vietnamese: 'Cao Thủ Bóng Rổ Movie', + userPreferred: 'SLAM DUNK', + }, + id: 'ec749533-cc31-4d13-a3b7-9e24d102ba4a', + coverImage: { + createdAt: '2024-04-30T06:59:43.227Z', + extraLarge: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx170-cmD8A0vZsp6g.jpg', + medium: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx170-cmD8A0vZsp6g.jpg', + large: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx170-cmD8A0vZsp6g.jpg', + }, + characters: { + id: 'd23b0137-46f4-4fc9-93e2-2eae86adbac5', + updatedAt: '2024-05-07T18:12:07.771Z', + }, + }, + }, + { + characterName: null, + characterRole: null, + dubGroup: null, + favouriteOrder: null, + idAnilist: 738, + isMainStudio: false, + relationType: null, + roleNotes: null, + staffRole: null, + node: { + title: { + english: 'Case Closed', + native: '名探偵コナン', + romaji: 'Meitantei Conan', + vietnamese: 'Thám tử lừng danh Conan: Ngày thám tử bị teo nhỏ', + userPreferred: 'Meitantei Conan', + }, + id: '10140627-16dd-47f5-9d80-6e929f930838', + coverImage: { + createdAt: '2024-04-30T06:59:56.206Z', + extraLarge: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx235-XucEZpR3CRaV.jpg', + medium: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx235-XucEZpR3CRaV.jpg', + large: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx235-XucEZpR3CRaV.jpg', + }, + characters: { + id: '26d439ec-2dc0-4967-9856-0f3f0b9b79e0', + updatedAt: '2024-05-07T18:16:49.693Z', + }, + }, + }, + { + characterName: null, + characterRole: null, + dubGroup: null, + favouriteOrder: null, + idAnilist: 3398, + isMainStudio: false, + relationType: null, + roleNotes: null, + staffRole: null, + node: { + title: { + english: + 'Ghost in the Shell: Stand Alone Complex: Tachikomatic Days', + native: + '攻殻機動隊 STAND ALONE COMPLEX: Section9 Science File “タチコマな日々”', + romaji: + 'Koukaku Kidoutai: STAND ALONE COMPLEX - Section 9 Science File "Tachikoma na Hibi"', + vietnamese: null, + userPreferred: + 'Koukaku Kidoutai: STAND ALONE COMPLEX - Section 9 Science File "Tachikoma na Hibi"', + }, + id: 'f5a5051f-63f1-48a9-857a-2346e31a08cc', + coverImage: { + createdAt: '2024-04-30T07:02:45.380Z', + extraLarge: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/1335.jpg', + medium: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/1335.jpg', + large: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/1335.jpg', + }, + characters: { + id: 'd8fd0b87-d000-42a8-b898-cb7b3cfc9350', + updatedAt: '2024-05-07T19:06:12.467Z', + }, + }, + }, + { + characterName: null, + characterRole: null, + dubGroup: null, + favouriteOrder: null, + idAnilist: 3586, + isMainStudio: false, + relationType: null, + roleNotes: null, + staffRole: null, + node: { + title: { + english: 'Lupin the 3rd Part 2', + native: '新・ルパン三世', + romaji: 'Lupin III: Part II', + vietnamese: null, + userPreferred: 'Lupin III: Part II', + }, + id: '435aa9f5-461f-4fdc-88e2-9413806f0be4', + coverImage: { + createdAt: '2024-04-30T07:03:01.798Z', + extraLarge: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/1425.jpg', + medium: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/1425.jpg', + large: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/1425.jpg', + }, + characters: { + id: 'fd2a6357-82a2-47d2-bd36-d9a84ed73e23', + updatedAt: '2024-05-09T14:11:42.693Z', + }, + }, + }, + { + characterName: null, + characterRole: null, + dubGroup: null, + favouriteOrder: null, + idAnilist: 3777, + isMainStudio: false, + relationType: null, + roleNotes: null, + staffRole: null, + node: { + title: { + english: 'Black Jack', + native: 'ブラック・ジャック', + romaji: 'Black Jack', + vietnamese: 'Black Jack 21', + userPreferred: 'Black Jack', + }, + id: '3c8562be-a3dc-4084-ade8-b3f97619c5ce', + coverImage: { + createdAt: '2024-04-30T07:03:17.857Z', + extraLarge: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/1520-4AFl8cpVoRQU.jpg', + medium: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/1520-4AFl8cpVoRQU.jpg', + large: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/1520-4AFl8cpVoRQU.jpg', + }, + characters: { + id: 'a897ed15-85ba-4ab9-95d0-3e258d7c57eb', + updatedAt: '2024-05-07T19:14:19.553Z', + }, + }, + }, + { + characterName: null, + characterRole: null, + dubGroup: null, + favouriteOrder: null, + idAnilist: 9117, + isMainStudio: false, + relationType: null, + roleNotes: null, + staffRole: null, + node: { + title: { + english: null, + native: 'ドッとKONIちゃん', + romaji: 'Dotto KONI-chan', + vietnamese: null, + userPreferred: 'Dotto KONI-chan', + }, + id: 'f1442145-8027-4fbf-a184-2d3cda50d829', + coverImage: { + createdAt: '2024-04-30T07:03:43.375Z', + extraLarge: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b1684-U4NqR6YpQlN9.png', + medium: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b1684-U4NqR6YpQlN9.png', + large: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b1684-U4NqR6YpQlN9.png', + }, + characters: { + id: 'cc59b6f9-c40d-4efa-a919-731f47d1d6f6', + updatedAt: '2024-05-07T19:21:30.539Z', + }, + }, + }, + { + characterName: null, + characterRole: null, + dubGroup: null, + favouriteOrder: null, + idAnilist: 5409, + isMainStudio: false, + relationType: null, + roleNotes: null, + staffRole: null, + node: { + title: { + english: + 'Ghost in the Shell: Stand Alone Complex - The Laughing Man', + native: '攻殻機動隊 Stand Alone Complex - The Laughing Man', + romaji: + 'Koukaku Kidoutai: Stand Alone Complex - The Laughing Man', + vietnamese: null, + userPreferred: + 'Koukaku Kidoutai: Stand Alone Complex - The Laughing Man', + }, + id: '219c528d-8559-4ccd-b8ad-6eb998340e93', + coverImage: { + createdAt: '2024-04-30T07:05:40.095Z', + extraLarge: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx2449-YZaDA9hyf7Ku.jpg', + medium: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx2449-YZaDA9hyf7Ku.jpg', + large: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx2449-YZaDA9hyf7Ku.jpg', + }, + characters: { + id: '4dc66954-535a-4b26-bf11-12078a0dabbd', + updatedAt: '2024-05-09T14:29:32.822Z', + }, + }, + }, + { + characterName: null, + characterRole: null, + dubGroup: null, + favouriteOrder: null, + idAnilist: 6001, + isMainStudio: false, + relationType: null, + roleNotes: null, + staffRole: null, + node: { + title: { + english: 'Night Shift Nurse: Ren Nanase', + native: '七瀬恋 (アニメ)', + romaji: 'Nanase Ren', + vietnamese: null, + userPreferred: 'Nanase Ren', + }, + id: 'c782e70d-7b21-4b75-b4b1-2fcd5d53af98', + coverImage: { + createdAt: '2024-04-30T07:06:43.879Z', + extraLarge: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/2869.jpg', + medium: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/2869.jpg', + large: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/2869.jpg', + }, + characters: { + id: '60e46b4b-4a8f-4c4d-8bee-f825c536f1fb', + updatedAt: '2024-05-09T15:12:18.727Z', + }, + }, + }, + { + characterName: null, + characterRole: null, + dubGroup: null, + favouriteOrder: null, + idAnilist: 7869, + isMainStudio: false, + relationType: null, + roleNotes: null, + staffRole: null, + node: { + title: { + english: 'Tales of the Abyss', + native: 'テイルズ オブ ジ アビス', + romaji: 'Tales of the Abyss', + vietnamese: 'Tales of the Abyss', + userPreferred: 'Tales of the Abyss', + }, + id: '05ce2949-afe2-4cc7-b77f-f0352ae0a59d', + coverImage: { + createdAt: '2024-04-30T07:10:01.989Z', + extraLarge: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx4884-RKYr7ktcToPE.jpg', + medium: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx4884-RKYr7ktcToPE.jpg', + large: + 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx4884-RKYr7ktcToPE.jpg', + }, + characters: { + id: '5dd1a7ef-da38-49b1-bb33-2773e0d7db18', + updatedAt: '2024-05-09T15:40:17.625Z', + }, + }, + }, + ], + }, + }, +]; diff --git a/test/repositories/studio.repository.spec.ts b/test/repositories/studio.repository.spec.ts new file mode 100644 index 0000000..9bc0ebf --- /dev/null +++ b/test/repositories/studio.repository.spec.ts @@ -0,0 +1,75 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { mockDataSource } from '../__mocks__/data-source'; +import { DataSource } from 'typeorm'; +import { IStudioRepository } from '~/contracts/repositories'; +import { StudioRepository } from '~/repositories'; +import { MapResultSelect } from '~/utils/tools/object'; +import { QueryStudioArg } from '~/graphql/types/args/query-studio.arg'; +import { StudioListDto } from '../__mocks__/studio.data'; + +describe('StudioRepository', () => { + let studioRepository: IStudioRepository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: IStudioRepository, + useClass: StudioRepository, + }, + { + provide: DataSource, + useValue: mockDataSource, + }, + ], + }) + .useMocker((token) => { + return jest.fn(); + }) + .compile(); + + studioRepository = module.get(IStudioRepository); + mockDataSource.getRepository.mockReturnValue(mockDataSource); + mockDataSource.createQueryBuilder.mockReturnValue(mockDataSource); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('getStudioByConditions should return null', async () => { + // arrange + const mapResultSelectParam: MapResultSelect = {}; + const queryAnimeArg: QueryStudioArg = { + idAnilist: 140, + }; + mockDataSource.getOne.mockResolvedValue(null); + + // act + const result = await studioRepository.getStudioByConditions( + mapResultSelectParam, + queryAnimeArg, + ); + + // assert + expect(result).toEqual(null); + }); + + it('getStudioByConditions should return studio', async () => { + // arrange + const mapResultSelectParam: MapResultSelect = {}; + const queryAnimeArg: QueryStudioArg = { + idAnilist: 140, + }; + mockDataSource.getOne.mockResolvedValue(StudioListDto[0]); + + // act + const result = await studioRepository.getStudioByConditions( + mapResultSelectParam, + queryAnimeArg, + ); + + // assert + expect(result).toMatchObject(StudioListDto[0]); + }); +}); diff --git a/test/services/studio.service.spec.ts b/test/services/studio.service.spec.ts new file mode 100644 index 0000000..4d8fa3f --- /dev/null +++ b/test/services/studio.service.spec.ts @@ -0,0 +1,80 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { StudioListDto } from '../__mocks__/studio.data'; +import { IStudioRepository } from '~/contracts/repositories'; +import { IStudioExternalService } from '~/contracts/services'; +import { QueryStudioArg } from '~/graphql/types/args/query-studio.arg'; +import { NotFoundStudioError } from '~/graphql/types/dtos/studio/not-found-studio.error'; +import { StudioService } from '~/services/studio.service'; +import { either } from '~/utils/tools/either'; +import { MapResultSelect } from '~/utils/tools/object'; + +describe('StudioService', () => { + let service: IStudioExternalService; + + const mockStudioRepo = { + getStudioByConditions: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: IStudioExternalService, + useClass: StudioService, + }, + { + provide: IStudioRepository, + useValue: mockStudioRepo, + }, + ], + }) + .useMocker((token) => { + return jest.fn(); + }) + .compile(); + + service = module.get(IStudioExternalService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('getStudioByConditions should return not found studio error if not found studio', async () => { + // arrange + const mapResultSelect: MapResultSelect = {}; + const queryAnimeArg: QueryStudioArg = { + idAnilist: 140, + }; + mockStudioRepo.getStudioByConditions.mockResolvedValue(null); + + // act + const result = await service.getStudioByConditions( + mapResultSelect, + queryAnimeArg, + ); + + // assert + expect(result).toEqual( + either.error(new NotFoundStudioError({ requestObject: queryAnimeArg })), + ); + }); + + it('getStudioByConditions should return studio value if found studio', async () => { + // arrange + const mapResultSelect: MapResultSelect = {}; + const queryAnimeArg: QueryStudioArg = { + idAnilist: 140, + }; + mockStudioRepo.getStudioByConditions.mockResolvedValue(StudioListDto[0]); + + // act + const result = await service.getStudioByConditions( + mapResultSelect, + queryAnimeArg, + ); + + // assert + expect(result).toEqual(either.of(StudioListDto[0])); + }); +});