From 2e8cd13b536965dc72a4b0bbcac9db426c16d3be Mon Sep 17 00:00:00 2001 From: Fityan Date: Wed, 4 Oct 2023 08:32:50 +0700 Subject: [PATCH 1/5] feat: add `provinceCode` query of `/regencies` endpoint --- src/regency/__mocks__/regency.service.ts | 11 ++++++-- src/regency/regency.controller.spec.ts | 14 ++++++++++ src/regency/regency.dto.ts | 7 +++-- src/regency/regency.service.spec.ts | 35 +++++++++++++++++++++--- src/regency/regency.service.ts | 22 +++++++-------- 5 files changed, 69 insertions(+), 20 deletions(-) diff --git a/src/regency/__mocks__/regency.service.ts b/src/regency/__mocks__/regency.service.ts index 5361a29..d5979a0 100644 --- a/src/regency/__mocks__/regency.service.ts +++ b/src/regency/__mocks__/regency.service.ts @@ -1,8 +1,8 @@ -import { FindOptions } from '@/common/common.service'; import { sortArray } from '@/common/utils/array'; import { convertCoordinate } from '@/common/utils/coordinate'; import { SortOptions } from '@/sort/sort.service'; import { District, Island, Regency } from '@prisma/client'; +import { RegencyFindQueries } from '../regency.dto'; export class MockRegencyService { readonly regencies: readonly Regency[]; @@ -17,13 +17,18 @@ export class MockRegencyService { async find({ name = '', + provinceCode, sortBy = 'code', sortOrder, - }: FindOptions = {}) { - const res = this.regencies.filter((regency) => + }: RegencyFindQueries = {}) { + let res = this.regencies.filter((regency) => regency.name.toLowerCase().includes(name.toLowerCase()), ); + if (provinceCode) { + res = res.filter((regency) => regency.provinceCode === provinceCode); + } + return Promise.resolve({ data: sortArray(res, sortBy, sortOrder) }); } diff --git a/src/regency/regency.controller.spec.ts b/src/regency/regency.controller.spec.ts index d54f751..c25b9e3 100644 --- a/src/regency/regency.controller.spec.ts +++ b/src/regency/regency.controller.spec.ts @@ -113,6 +113,20 @@ describe('RegencyController', () => { ), ); }); + + it('should return regencies filtered by province code', async () => { + const provinceCode = '11'; + const filteredRegenciesByProvince = regencies.filter( + (p) => p.provinceCode === provinceCode, + ); + const { data } = await controller.find({ provinceCode }); + + for (const regency of data) { + expect(regency).toEqual(expect.objectContaining({ provinceCode })); + } + + expect(data.length).toEqual(filteredRegenciesByProvince.length); + }); }); describe('findByCode', () => { diff --git a/src/regency/regency.dto.ts b/src/regency/regency.dto.ts index eb777a1..db668c4 100644 --- a/src/regency/regency.dto.ts +++ b/src/regency/regency.dto.ts @@ -31,7 +31,10 @@ export class Regency { @IsNotEmpty() @IsNumberString() @Length(2, 2) - @ApiProperty({ example: '11' }) + @ApiProperty({ + description: 'The province code of the regency', + example: '11', + }) provinceCode: string; } @@ -41,7 +44,7 @@ export class RegencySortQuery extends SortQuery { } export class RegencyFindQueries extends IntersectionType( - PartialType(PickType(Regency, ['name'] as const)), + PartialType(PickType(Regency, ['name', 'provinceCode'] as const)), RegencySortQuery, PaginationQuery, ) {} diff --git a/src/regency/regency.service.spec.ts b/src/regency/regency.service.spec.ts index cb9dd3d..06679cd 100644 --- a/src/regency/regency.service.spec.ts +++ b/src/regency/regency.service.spec.ts @@ -6,6 +6,7 @@ import { IslandService } from '@/island/island.service'; import { District, Island, Regency } from '@prisma/client'; import { VillageService } from '@/village/village.service'; import { getDBProviderFeatures } from '@/common/utils/db'; +import { SortOrder } from '@/sort/sort.dto'; const regencies: readonly Regency[] = [ { code: '1101', name: 'KABUPATEN ACEH SELATAN', provinceCode: '11' }, @@ -102,7 +103,10 @@ describe('RegencyService', () => { const result = await service.find(); expect(paginatorSpy).toHaveBeenCalledTimes(1); - expect(paginatorSpy).toHaveBeenCalledWith(paginatorOptions); + expect(paginatorSpy).toHaveBeenCalledWith({ + ...paginatorOptions, + args: { where: {} }, + }); expect(result.data).toEqual(regencies); }); @@ -135,6 +139,26 @@ describe('RegencyService', () => { expect(result.data).toEqual(expectedRegencies); }); + it('should filter regencies by province code', async () => { + const provinceCode = '11'; + const expectedRegencies = regencies.filter((r) => + r.provinceCode.includes(provinceCode), + ); + + const paginatorSpy = vitest + .spyOn(prismaService, 'paginator') + .mockResolvedValue({ data: expectedRegencies }); + + const result = await service.find({ provinceCode }); + + expect(paginatorSpy).toHaveBeenCalledTimes(1); + expect(paginatorSpy).toHaveBeenCalledWith({ + ...paginatorOptions, + args: { where: { provinceCode } }, + }); + expect(result.data).toEqual(expectedRegencies); + }); + it('should sort regencies by name in ascending order by default', async () => { const expectedRegencies = [...regencies].sort((a, b) => a.name.localeCompare(b.name), @@ -149,7 +173,7 @@ describe('RegencyService', () => { expect(paginatorSpy).toHaveBeenCalledTimes(1); expect(paginatorSpy).toHaveBeenCalledWith({ ...paginatorOptions, - args: { orderBy: { name: 'asc' } }, + args: { where: {}, orderBy: { name: 'asc' } }, }); expect(result.data).toEqual(expectedRegencies); }); @@ -163,12 +187,15 @@ describe('RegencyService', () => { .spyOn(prismaService, 'paginator') .mockResolvedValue({ data: expectedRegencies }); - const result = await service.find({ sortBy: 'name', sortOrder: 'desc' }); + const result = await service.find({ + sortBy: 'name', + sortOrder: SortOrder.DESC, + }); expect(findManySpy).toHaveBeenCalledTimes(1); expect(findManySpy).toHaveBeenCalledWith({ ...paginatorOptions, - args: { orderBy: { name: 'desc' } }, + args: { where: {}, orderBy: { name: 'desc' } }, }); expect(result.data).toEqual(expectedRegencies); }); diff --git a/src/regency/regency.service.ts b/src/regency/regency.service.ts index 36fcf5f..098682f 100644 --- a/src/regency/regency.service.ts +++ b/src/regency/regency.service.ts @@ -1,4 +1,3 @@ -import { CommonService, FindOptions } from '@/common/common.service'; import { PaginationQuery } from '@/common/dto/pagination.dto'; import { PaginatedReturn } from '@/common/interceptor/paginate.interceptor'; import { getDBProviderFeatures } from '@/common/utils/db'; @@ -9,9 +8,10 @@ import { PrismaService } from '@/prisma/prisma.service'; import { SortOptions, SortService } from '@/sort/sort.service'; import { Injectable } from '@nestjs/common'; import { District, Island, Regency } from '@prisma/client'; +import { RegencyFindQueries } from './regency.dto'; @Injectable() -export class RegencyService implements CommonService { +export class RegencyService { readonly sorter: SortService; constructor( @@ -25,24 +25,24 @@ export class RegencyService implements CommonService { }); } - async find( - options?: FindOptions, - ): Promise> { - const { name, sortBy, sortOrder, page, limit } = options ?? {}; + async find(options?: RegencyFindQueries): Promise> { + const { name, sortBy, sortOrder, page, limit, provinceCode } = + options ?? {}; return this.prisma.paginator({ model: 'Regency', args: { - ...(name && { - where: { + where: { + ...(name && { name: { - contains: options.name, + contains: name, ...(getDBProviderFeatures()?.filtering?.insensitive && { mode: 'insensitive', }), }, - }, - }), + }), + ...(provinceCode && { provinceCode }), + }, ...((sortBy || sortOrder) && { orderBy: this.sorter.object({ sortBy, sortOrder }), }), From 405e70bad0c213e480dc0453fdd173f671a5861b Mon Sep 17 00:00:00 2001 From: Fityan Date: Wed, 4 Oct 2023 09:39:47 +0700 Subject: [PATCH 2/5] feat: add `regencyCode` query of `/districts` endpoint --- src/district/__mocks__/district.service.ts | 11 +++++-- src/district/district.controller.spec.ts | 14 ++++++++ src/district/district.dto.ts | 7 ++-- src/district/district.service.spec.ts | 37 +++++++++++++++++++--- src/district/district.service.ts | 17 +++++----- test/district.e2e-spec.ts | 15 +++++++++ 6 files changed, 83 insertions(+), 18 deletions(-) diff --git a/src/district/__mocks__/district.service.ts b/src/district/__mocks__/district.service.ts index 8597f89..96f79f0 100644 --- a/src/district/__mocks__/district.service.ts +++ b/src/district/__mocks__/district.service.ts @@ -1,7 +1,7 @@ -import { FindOptions } from '@/common/common.service'; import { sortArray } from '@/common/utils/array'; import { SortOptions } from '@/sort/sort.service'; import { District, Village } from '@prisma/client'; +import { DistrictFindQueries } from '../district.dto'; export class MockDistrictService { readonly districts: District[]; @@ -14,13 +14,18 @@ export class MockDistrictService { async find({ name = '', + regencyCode, sortBy = 'code', sortOrder, - }: FindOptions = {}) { - const res = this.districts.filter((district) => + }: DistrictFindQueries = {}) { + let res = this.districts.filter((district) => district.name.toLowerCase().includes(name.toLowerCase()), ); + if (regencyCode) { + res = res.filter((district) => district.regencyCode === regencyCode); + } + return Promise.resolve({ data: sortArray(res, sortBy, sortOrder) }); } diff --git a/src/district/district.controller.spec.ts b/src/district/district.controller.spec.ts index 185fbf3..6277273 100644 --- a/src/district/district.controller.spec.ts +++ b/src/district/district.controller.spec.ts @@ -115,6 +115,20 @@ describe('DistrictController', () => { ), ); }); + + it('should return districts filtered by regency code', async () => { + const regencyCode = '1101'; + const filteredDistrictsByRegencyCode = districts.filter( + (p) => p.regencyCode === regencyCode, + ); + const { data } = await controller.find({ regencyCode }); + + for (const district of data) { + expect(district).toEqual(expect.objectContaining({ regencyCode })); + } + + expect(data).toHaveLength(filteredDistrictsByRegencyCode.length); + }); }); describe('findByCode', () => { diff --git a/src/district/district.dto.ts b/src/district/district.dto.ts index 215122c..1eb31e4 100644 --- a/src/district/district.dto.ts +++ b/src/district/district.dto.ts @@ -27,7 +27,10 @@ export class District { @IsNotEmpty() @IsNumberString() @Length(4, 4) - @ApiProperty({ example: '1101' }) + @ApiProperty({ + description: 'The regency code of the district', + example: '1101', + }) regencyCode: string; } @@ -37,7 +40,7 @@ export class DistrictSortQuery extends SortQuery { } export class DistrictFindQueries extends IntersectionType( - PartialType(PickType(District, ['name'] as const)), + PartialType(PickType(District, ['name', 'regencyCode'] as const)), DistrictSortQuery, PaginationQuery, ) {} diff --git a/src/district/district.service.spec.ts b/src/district/district.service.spec.ts index bc0b1c4..e4b7709 100644 --- a/src/district/district.service.spec.ts +++ b/src/district/district.service.spec.ts @@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { DistrictService } from './district.service'; import { VillageService } from '@/village/village.service'; import { getDBProviderFeatures } from '@/common/utils/db'; +import { SortOrder } from '@/sort/sort.dto'; const districts: readonly District[] = [ { code: '110101', name: 'Bakongan', regencyCode: '1101' }, @@ -39,7 +40,7 @@ describe('DistrictService', () => { const paginatorOptions = { model: 'District', paginate: { page: undefined, limit: undefined }, - args: {}, + args: { where: {} }, }; it('should return all districts', async () => { @@ -92,12 +93,15 @@ describe('DistrictService', () => { .spyOn(prismaService, 'paginator') .mockResolvedValue({ data: expectedDistricts }); - const result = await service.find({ sortBy: 'name', sortOrder: 'asc' }); + const result = await service.find({ + sortBy: 'name', + sortOrder: SortOrder.ASC, + }); expect(paginatorSpy).toHaveBeenCalledTimes(1); expect(paginatorSpy).toHaveBeenCalledWith({ ...paginatorOptions, - args: { orderBy: { name: 'asc' } }, + args: { where: {}, orderBy: { name: 'asc' } }, }); expect(result.data).toEqual(expectedDistricts); }); @@ -111,12 +115,35 @@ describe('DistrictService', () => { .spyOn(prismaService, 'paginator') .mockResolvedValue({ data: expectedDistricts }); - const result = await service.find({ sortBy: 'name', sortOrder: 'desc' }); + const result = await service.find({ + sortBy: 'name', + sortOrder: SortOrder.DESC, + }); + + expect(paginatorSpy).toHaveBeenCalledTimes(1); + expect(paginatorSpy).toHaveBeenCalledWith({ + ...paginatorOptions, + args: { where: {}, orderBy: { name: 'desc' } }, + }); + expect(result.data).toEqual(expectedDistricts); + }); + + it('should return districts filtered by regency code', async () => { + const regencyCode = '1101'; + const expectedDistricts = districts.filter( + (d) => d.regencyCode === regencyCode, + ); + + const paginatorSpy = vitest + .spyOn(prismaService, 'paginator') + .mockResolvedValue({ data: expectedDistricts }); + + const result = await service.find({ regencyCode }); expect(paginatorSpy).toHaveBeenCalledTimes(1); expect(paginatorSpy).toHaveBeenCalledWith({ ...paginatorOptions, - args: { orderBy: { name: 'desc' } }, + args: { where: { regencyCode } }, }); expect(result.data).toEqual(expectedDistricts); }); diff --git a/src/district/district.service.ts b/src/district/district.service.ts index 4facf62..7db47c1 100644 --- a/src/district/district.service.ts +++ b/src/district/district.service.ts @@ -1,4 +1,3 @@ -import { CommonService, FindOptions } from '@/common/common.service'; import { PaginationQuery } from '@/common/dto/pagination.dto'; import { PaginatedReturn } from '@/common/interceptor/paginate.interceptor'; import { getDBProviderFeatures } from '@/common/utils/db'; @@ -7,9 +6,10 @@ import { SortOptions, SortService } from '@/sort/sort.service'; import { VillageService } from '@/village/village.service'; import { Injectable } from '@nestjs/common'; import { District, Village } from '@prisma/client'; +import { DistrictFindQueries } from './district.dto'; @Injectable() -export class DistrictService implements CommonService { +export class DistrictService { readonly sorter: SortService; constructor( @@ -23,24 +23,25 @@ export class DistrictService implements CommonService { } async find( - options?: FindOptions, + options?: DistrictFindQueries, ): Promise> { - const { name, page, limit, sortBy, sortOrder } = options ?? {}; + const { name, regencyCode, page, limit, sortBy, sortOrder } = options ?? {}; return this.prisma.paginator({ model: 'District', paginate: { page, limit }, args: { - ...(name && { - where: { + where: { + ...(name && { name: { contains: name, ...(getDBProviderFeatures()?.filtering?.insensitive && { mode: 'insensitive', }), }, - }, - }), + }), + ...(regencyCode && { regencyCode }), + }, ...((sortBy || sortOrder) && { orderBy: this.sorter.object({ sortBy, sortOrder }), }), diff --git a/test/district.e2e-spec.ts b/test/district.e2e-spec.ts index 4b9b137..2f9c4e8 100644 --- a/test/district.e2e-spec.ts +++ b/test/district.e2e-spec.ts @@ -63,6 +63,21 @@ describe('District (e2e)', () => { }); }); }); + + it('should return all districts match with the `regencyCode`', async () => { + const regencyCode = '1101'; + const districts = await tester.expectData( + `${baseUrl}?regencyCode=${regencyCode}`, + ); + + districts.forEach((district) => { + expect(district).toEqual({ + code: expect.stringMatching(districtRegex.code), + name: expect.stringMatching(districtRegex.name), + regencyCode, + }); + }); + }); }); describe(`GET ${baseUrl}/{code}`, () => { From 83f85cbec43f3ebbfafeed0dbc19affa1dd7061c Mon Sep 17 00:00:00 2001 From: Fityan Date: Wed, 4 Oct 2023 13:51:55 +0700 Subject: [PATCH 3/5] feat: add `districtCode` query of `/villages` endpoint --- src/village/__mocks__/village.service.ts | 11 +++++-- src/village/village.controller.spec.ts | 14 ++++++++ src/village/village.dto.ts | 7 ++-- src/village/village.service.spec.ts | 41 ++++++++++++++++++++---- src/village/village.service.ts | 20 ++++++------ test/village.e2e-spec.ts | 15 +++++++++ 6 files changed, 86 insertions(+), 22 deletions(-) diff --git a/src/village/__mocks__/village.service.ts b/src/village/__mocks__/village.service.ts index 9aa5cdf..59b2f34 100644 --- a/src/village/__mocks__/village.service.ts +++ b/src/village/__mocks__/village.service.ts @@ -1,6 +1,6 @@ -import { FindOptions } from '@/common/common.service'; import { sortArray } from '@/common/utils/array'; import { Village } from '@prisma/client'; +import { VillageFindQueries } from '../village.dto'; export class MockVillageService { readonly villages: Village[]; @@ -11,13 +11,18 @@ export class MockVillageService { async find({ name = '', + districtCode, sortBy = 'code', sortOrder, - }: FindOptions = {}) { - const res = this.villages.filter((village) => + }: VillageFindQueries = {}) { + let res = this.villages.filter((village) => village.name.toLowerCase().includes(name.toLowerCase()), ); + if (districtCode) { + res = res.filter((district) => district.districtCode === districtCode); + } + return Promise.resolve({ data: sortArray(res, sortBy, sortOrder) }); } diff --git a/src/village/village.controller.spec.ts b/src/village/village.controller.spec.ts index c7197e4..67a0cd7 100644 --- a/src/village/village.controller.spec.ts +++ b/src/village/village.controller.spec.ts @@ -106,6 +106,20 @@ describe('VillageController', () => { ), ); }); + + it('should return filtered villages by district code', async () => { + const districtCode = '110101'; + const expectedVillage = villages.filter( + (v) => v.districtCode === districtCode, + ); + const { data } = await controller.find({ districtCode }); + + for (const village of data) { + expect(village).toEqual(expect.objectContaining({ districtCode })); + } + + expect(data).toHaveLength(expectedVillage.length); + }); }); describe('findByCode', () => { diff --git a/src/village/village.dto.ts b/src/village/village.dto.ts index f8963a3..4d4d0d8 100644 --- a/src/village/village.dto.ts +++ b/src/village/village.dto.ts @@ -26,7 +26,10 @@ export class Village { @IsNotEmpty() @IsNumberString() @Length(6, 6) - @ApiProperty({ example: '110101' }) + @ApiProperty({ + description: 'The district code of the village', + example: '110101', + }) districtCode: string; } @@ -36,7 +39,7 @@ export class VillageSortQuery extends SortQuery { } export class VillageFindQueries extends IntersectionType( - PartialType(PickType(Village, ['name'] as const)), + PartialType(PickType(Village, ['name', 'districtCode'] as const)), VillageSortQuery, PaginationQuery, ) {} diff --git a/src/village/village.service.spec.ts b/src/village/village.service.spec.ts index 723c4e0..35c2e54 100644 --- a/src/village/village.service.spec.ts +++ b/src/village/village.service.spec.ts @@ -1,8 +1,9 @@ -import { VillageService } from './village.service'; -import { Village } from '@prisma/client'; import { getDBProviderFeatures } from '@/common/utils/db'; import { PrismaService } from '@/prisma/prisma.service'; +import { SortOrder } from '@/sort/sort.dto'; import { Test } from '@nestjs/testing'; +import { Village } from '@prisma/client'; +import { VillageService } from './village.service'; const villages: readonly Village[] = [ { code: '1101012001', name: 'Desa 1', districtCode: '110101' }, @@ -32,7 +33,7 @@ describe('VillageService', () => { const paginatorOptions = { model: 'Village', paginate: { page: undefined, limit: undefined }, - args: {}, + args: { where: {} }, }; it('should return all villages', async () => { @@ -86,12 +87,15 @@ describe('VillageService', () => { .mockResolvedValue({ data: expectedVillages }); const result = await service.find({ sortBy: 'name' }); - const result2 = await service.find({ sortBy: 'name', sortOrder: 'asc' }); + const result2 = await service.find({ + sortBy: 'name', + sortOrder: SortOrder.ASC, + }); expect(paginatorSpy).toHaveBeenCalledTimes(2); expect(paginatorSpy).toHaveBeenCalledWith({ ...paginatorOptions, - args: { orderBy: { name: 'asc' } }, + args: { where: {}, orderBy: { name: 'asc' } }, }); expect(result).toEqual(result2); expect(result.data).toEqual(expectedVillages); @@ -106,12 +110,35 @@ describe('VillageService', () => { .spyOn(prismaService, 'paginator') .mockResolvedValue({ data: expectedVillages }); - const result = await service.find({ sortBy: 'name', sortOrder: 'desc' }); + const result = await service.find({ + sortBy: 'name', + sortOrder: SortOrder.DESC, + }); + + expect(paginatorSpy).toHaveBeenCalledTimes(1); + expect(paginatorSpy).toHaveBeenCalledWith({ + ...paginatorOptions, + args: { where: {}, orderBy: { name: 'desc' } }, + }); + expect(result.data).toEqual(expectedVillages); + }); + + it('should return filtered villages by district code', async () => { + const districtCode = '110101'; + const expectedVillages = villages.filter( + (v) => v.districtCode === districtCode, + ); + + const paginatorSpy = vitest + .spyOn(prismaService, 'paginator') + .mockResolvedValue({ data: expectedVillages }); + + const result = await service.find({ districtCode }); expect(paginatorSpy).toHaveBeenCalledTimes(1); expect(paginatorSpy).toHaveBeenCalledWith({ ...paginatorOptions, - args: { orderBy: { name: 'desc' } }, + args: { where: { districtCode } }, }); expect(result.data).toEqual(expectedVillages); }); diff --git a/src/village/village.service.ts b/src/village/village.service.ts index f488c3f..bc553e9 100644 --- a/src/village/village.service.ts +++ b/src/village/village.service.ts @@ -1,13 +1,13 @@ -import { CommonService, FindOptions } from '@/common/common.service'; import { PaginatedReturn } from '@/common/interceptor/paginate.interceptor'; import { getDBProviderFeatures } from '@/common/utils/db'; import { PrismaService } from '@/prisma/prisma.service'; import { SortService } from '@/sort/sort.service'; import { Injectable } from '@nestjs/common'; import { Village } from '@prisma/client'; +import { VillageFindQueries } from './village.dto'; @Injectable() -export class VillageService implements CommonService { +export class VillageService { readonly sorter: SortService; constructor(private readonly prisma: PrismaService) { @@ -17,25 +17,25 @@ export class VillageService implements CommonService { }); } - async find( - options?: FindOptions, - ): Promise> { - const { name, page, limit, sortBy, sortOrder } = options ?? {}; + async find(options?: VillageFindQueries): Promise> { + const { name, districtCode, page, limit, sortBy, sortOrder } = + options ?? {}; return this.prisma.paginator({ model: 'Village', paginate: { page, limit }, args: { - ...(name && { - where: { + where: { + ...(name && { name: { contains: name, ...(getDBProviderFeatures()?.filtering?.insensitive && { mode: 'insensitive', }), }, - }, - }), + }), + ...(districtCode && { districtCode }), + }, ...((sortBy || sortOrder) && { orderBy: this.sorter.object({ sortBy, sortOrder }), }), diff --git a/test/village.e2e-spec.ts b/test/village.e2e-spec.ts index 3a936ec..918558b 100644 --- a/test/village.e2e-spec.ts +++ b/test/village.e2e-spec.ts @@ -63,6 +63,21 @@ describe('Village (e2e)', () => { }); }); }); + + it('should return all villages match with the `districtCode`', async () => { + const districtCode = '110101'; + const villages = await tester.expectData( + `${baseUrl}?districtCode=${districtCode}`, + ); + + villages.forEach((village) => { + expect(village).toEqual({ + code: expect.stringMatching(villageRegex.code), + name: expect.stringMatching(villageRegex.name), + districtCode, + }); + }); + }); }); describe(`GET ${baseUrl}/{code}`, () => { From d047a4cfc9bb2e0b5b9077db2a9c5ac9993bdd1a Mon Sep 17 00:00:00 2001 From: Fityan Date: Wed, 4 Oct 2023 16:34:54 +0700 Subject: [PATCH 4/5] feat: add `regencyCode` query of `/islands` endpoint Issue #176 --- src/island/__mocks__/island.service.ts | 15 ++++-- src/island/island.controller.spec.ts | 25 ++++++++++ src/island/island.dto.ts | 10 +++- src/island/island.service.spec.ts | 63 ++++++++++++++++++++++++-- src/island/island.service.ts | 22 +++++---- test/island.e2e-spec.ts | 39 ++++++++++++++++ 6 files changed, 154 insertions(+), 20 deletions(-) diff --git a/src/island/__mocks__/island.service.ts b/src/island/__mocks__/island.service.ts index 1606625..33653e0 100644 --- a/src/island/__mocks__/island.service.ts +++ b/src/island/__mocks__/island.service.ts @@ -1,7 +1,7 @@ -import { FindOptions } from '@/common/common.service'; import { sortArray } from '@/common/utils/array'; import { convertCoordinate } from '@/common/utils/coordinate'; import { Island } from '@prisma/client'; +import { IslandFindQueries } from '../island.dto'; export class MockIslandService { readonly islands: Island[]; @@ -18,13 +18,22 @@ export class MockIslandService { async find({ name = '', + regencyCode, sortBy = 'code', sortOrder, - }: FindOptions = {}) { - const res = this.islands.filter((island) => + }: IslandFindQueries = {}) { + let res = this.islands.filter((island) => island.name.toLowerCase().includes(name.toLowerCase()), ); + if (typeof regencyCode === 'string') { + res = res.filter((island) => + regencyCode === '' + ? island.regencyCode === null + : island.regencyCode === regencyCode, + ); + } + return Promise.resolve({ data: sortArray(res, sortBy, sortOrder) }); } diff --git a/src/island/island.controller.spec.ts b/src/island/island.controller.spec.ts index af45164..8611e2b 100644 --- a/src/island/island.controller.spec.ts +++ b/src/island/island.controller.spec.ts @@ -144,6 +144,31 @@ describe('IslandController', () => { ), ); }); + + it('should return islands filtered by regencyCode', async () => { + const regencyCode = '1101'; + const { data } = await controller.find({ regencyCode }); + + for (const island of data) { + expect(island).toEqual(expect.objectContaining({ regencyCode })); + } + + expect(data).toHaveLength( + islands.filter((island) => island.regencyCode === regencyCode).length, + ); + }); + + it('should return islands that does not belong to any regency', async () => { + const { data } = await controller.find({ regencyCode: '' }); + + for (const island of data) { + expect(island).toEqual(expect.objectContaining({ regencyCode: null })); + } + + expect(data).toHaveLength( + islands.filter((island) => island.regencyCode === null).length, + ); + }); }); describe('findByCode', () => { diff --git a/src/island/island.dto.ts b/src/island/island.dto.ts index 91a0007..3b45c57 100644 --- a/src/island/island.dto.ts +++ b/src/island/island.dto.ts @@ -12,6 +12,7 @@ import { IsOptional, IsString, Length, + ValidateIf, } from 'class-validator'; import { EqualsAny } from '../common/decorator/EqualsAny'; import { IsNotSymbol } from '../common/decorator/IsNotSymbol'; @@ -49,10 +50,15 @@ export class Island { }) name: string; + @ValidateIf((o) => o.regencyCode) @IsOptional() @IsNumberString() @Length(4, 4) - @ApiProperty({ example: '1101' }) + @ApiProperty({ + description: `The regency code of the island. + Providing an empty string will filter islands that are not part of any regency.`, + example: '1101', + }) regencyCode?: string; @ApiProperty({ example: 3.317622222222222 }) @@ -68,7 +74,7 @@ export class IslandSortQuery extends SortQuery { } export class IslandFindQueries extends IntersectionType( - PartialType(PickType(Island, ['name'] as const)), + PartialType(PickType(Island, ['name', 'regencyCode'] as const)), IslandSortQuery, PaginationQuery, ) {} diff --git a/src/island/island.service.spec.ts b/src/island/island.service.spec.ts index 659f461..23396d9 100644 --- a/src/island/island.service.spec.ts +++ b/src/island/island.service.spec.ts @@ -3,6 +3,7 @@ import { IslandService } from './island.service'; import { PrismaService } from '@/prisma/prisma.service'; import { Island } from '@prisma/client'; import { getDBProviderFeatures } from '@/common/utils/db'; +import { SortOrder } from '@/sort/sort.dto'; const islands: readonly Island[] = [ { @@ -45,6 +46,14 @@ const islands: readonly Island[] = [ name: 'Pulau Tengku Palsu', regencyCode: '1101', }, + { + code: '120040001', + coordinate: '01°45\'42.58" N 098°45\'09.03" E', + isOutermostSmall: false, + isPopulated: false, + name: 'Pulau Babi', + regencyCode: null, + }, ] as const; describe('IslandService', () => { @@ -68,7 +77,7 @@ describe('IslandService', () => { const paginatorOptions = { model: 'Island', paginate: { page: undefined, limit: undefined }, - args: {}, + args: { where: {} }, }; it('should return all islands', async () => { @@ -119,12 +128,15 @@ describe('IslandService', () => { .spyOn(prismaService, 'paginator') .mockResolvedValue({ data: expectedIslands }); - const result = await service.find({ sortBy: 'name', sortOrder: 'asc' }); + const result = await service.find({ + sortBy: 'name', + sortOrder: SortOrder.ASC, + }); expect(paginatorSpy).toHaveBeenCalledTimes(1); expect(paginatorSpy).toHaveBeenCalledWith({ ...paginatorOptions, - args: { orderBy: { name: 'asc' } }, + args: { where: {}, orderBy: { name: 'asc' } }, }); expect(result.data).toEqual(expectedIslands); }); @@ -138,12 +150,53 @@ describe('IslandService', () => { .spyOn(prismaService, 'paginator') .mockResolvedValue({ data: expectedIslands }); - const result = await service.find({ sortBy: 'name', sortOrder: 'desc' }); + const result = await service.find({ + sortBy: 'name', + sortOrder: SortOrder.DESC, + }); + + expect(paginatorSpy).toHaveBeenCalledTimes(1); + expect(paginatorSpy).toHaveBeenCalledWith({ + ...paginatorOptions, + args: { where: {}, orderBy: { name: 'desc' } }, + }); + expect(result.data).toEqual(expectedIslands); + }); + + it('should return filtered islands by regency code', async () => { + const regencyCode = '1101'; + const expectedIslands = islands.filter( + (i) => i.regencyCode === regencyCode, + ); + + const paginatorSpy = vitest + .spyOn(prismaService, 'paginator') + .mockResolvedValue({ data: expectedIslands }); + + const result = await service.find({ regencyCode }); + + expect(paginatorSpy).toHaveBeenCalledTimes(1); + expect(paginatorSpy).toHaveBeenCalledWith({ + ...paginatorOptions, + args: { where: { regencyCode } }, + }); + expect(result.data).toEqual(expectedIslands); + }); + + it('should return filtered islands that does not belongs to any regency', async () => { + const regencyCode = ''; + const expectedIslands = islands.filter((i) => i.regencyCode === null); + + const paginatorSpy = vitest + .spyOn(prismaService, 'paginator') + .mockResolvedValue({ data: expectedIslands }); + + const result = await service.find({ regencyCode }); expect(paginatorSpy).toHaveBeenCalledTimes(1); expect(paginatorSpy).toHaveBeenCalledWith({ ...paginatorOptions, - args: { orderBy: { name: 'desc' } }, + args: { where: { regencyCode: null } }, }); expect(result.data).toEqual(expectedIslands); }); diff --git a/src/island/island.service.ts b/src/island/island.service.ts index 4332777..a244914 100644 --- a/src/island/island.service.ts +++ b/src/island/island.service.ts @@ -1,15 +1,14 @@ -import { CommonService, FindOptions } from '@/common/common.service'; import { PaginatedReturn } from '@/common/interceptor/paginate.interceptor'; import { convertCoordinate } from '@/common/utils/coordinate'; import { getDBProviderFeatures } from '@/common/utils/db'; -import { Island as IslandDTO } from '@/island/island.dto'; +import { Island as IslandDTO, IslandFindQueries } from '@/island/island.dto'; import { PrismaService } from '@/prisma/prisma.service'; import { SortService } from '@/sort/sort.service'; import { Injectable } from '@nestjs/common'; import { Island } from '@prisma/client'; @Injectable() -export class IslandService implements CommonService { +export class IslandService { readonly sorter: SortService; constructor(private readonly prisma: PrismaService) { @@ -28,23 +27,26 @@ export class IslandService implements CommonService { return { ...island, latitude, longitude }; } - async find(options?: FindOptions): Promise> { - const { page, limit, name, sortBy, sortOrder } = options ?? {}; + async find(options?: IslandFindQueries): Promise> { + const { page, limit, name, regencyCode, sortBy, sortOrder } = options ?? {}; return this.prisma.paginator({ model: 'Island', paginate: { page, limit }, args: { - ...(name && { - where: { + where: { + ...(name && { name: { - contains: options.name, + contains: name, ...(getDBProviderFeatures()?.filtering?.insensitive && { mode: 'insensitive', }), }, - }, - }), + }), + ...(typeof regencyCode === 'string' && { + regencyCode: regencyCode === '' ? null : regencyCode, + }), + }, ...((sortBy || sortOrder) && { orderBy: this.sorter.object({ sortBy, sortOrder }), }), diff --git a/test/island.e2e-spec.ts b/test/island.e2e-spec.ts index 9d68485..49aa319 100644 --- a/test/island.e2e-spec.ts +++ b/test/island.e2e-spec.ts @@ -73,6 +73,45 @@ describe('Island (e2e)', () => { }); }); }); + + it('should return all islands match with the `regencyCode`', async () => { + const testRegencyCode = '1101'; + const islands = await tester.expectData( + `${baseUrl}?regencyCode=${testRegencyCode}`, + ); + + islands.forEach((island) => { + expect(island).toEqual({ + code: expect.stringMatching(islandRegex.code), + coordinate: expect.stringMatching(islandRegex.coordinate), + isOutermostSmall: expect.any(Boolean), + isPopulated: expect.any(Boolean), + latitude: expect.any(Number), + longitude: expect.any(Number), + name: expect.stringMatching(islandRegex.name), + regencyCode: testRegencyCode, + }); + }); + }); + + it('should return all islands that does not belong to any regency', async () => { + const islands = await tester.expectData( + `${baseUrl}?regencyCode`, + ); + + islands.forEach((island) => { + expect(island).toEqual({ + code: expect.stringMatching(islandRegex.code), + coordinate: expect.stringMatching(islandRegex.coordinate), + isOutermostSmall: expect.any(Boolean), + isPopulated: expect.any(Boolean), + latitude: expect.any(Number), + longitude: expect.any(Number), + name: expect.stringMatching(islandRegex.name), + regencyCode: null, + }); + }); + }); }); describe(`GET ${baseUrl}/{code}`, () => { From e4633af998cfc56bb31ab0f6e12027552ae4c75d Mon Sep 17 00:00:00 2001 From: Fityan Date: Wed, 4 Oct 2023 19:50:09 +0700 Subject: [PATCH 5/5] refactor: delete `CommonService` --- src/common/common.service.ts | 32 ---------------------- src/province/__mocks__/province.service.ts | 4 +-- src/province/province.service.spec.ts | 13 +++++---- src/province/province.service.ts | 6 ++-- 4 files changed, 12 insertions(+), 43 deletions(-) delete mode 100644 src/common/common.service.ts diff --git a/src/common/common.service.ts b/src/common/common.service.ts deleted file mode 100644 index d50816c..0000000 --- a/src/common/common.service.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { PaginationQuery } from '@/common/dto/pagination.dto'; -import { SortOptions, SortService } from '@/sort/sort.service'; -import { PaginatedReturn } from './interceptor/paginate.interceptor'; - -export type CommonData = Record & { - code: string; - name: string; -}; - -export type FindOptions = SortOptions & - PaginationQuery & { - name?: string; - }; - -export type CommonService = { - readonly sorter: SortService; - - /** - * If the name is empty, all data will be returned. - * Otherwise, it will only return the data with the matching name. - * - * The result can also paginated by return an object with `data` and `meta` properties. - */ - find(options: FindOptions): Promise>; - - /** - * Find a data by its code. - * - * @returns A data or `null`. - */ - findByCode(code: string): Promise; -}; diff --git a/src/province/__mocks__/province.service.ts b/src/province/__mocks__/province.service.ts index d0017d0..a403744 100644 --- a/src/province/__mocks__/province.service.ts +++ b/src/province/__mocks__/province.service.ts @@ -1,7 +1,7 @@ -import { FindOptions } from '@/common/common.service'; import { sortArray } from '@/common/utils/array'; import { SortOptions } from '@/sort/sort.service'; import { Province, Regency } from '@prisma/client'; +import { ProvinceFindQueries } from '../province.dto'; export class MockProvinceService { readonly provinces: readonly Province[]; @@ -16,7 +16,7 @@ export class MockProvinceService { name = '', sortBy = 'code', sortOrder, - }: FindOptions = {}) { + }: ProvinceFindQueries = {}) { const res = this.provinces.filter((province) => province.name.toLowerCase().includes(name.toLowerCase()), ); diff --git a/src/province/province.service.spec.ts b/src/province/province.service.spec.ts index 14deab4..ee3b083 100644 --- a/src/province/province.service.spec.ts +++ b/src/province/province.service.spec.ts @@ -1,12 +1,13 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { PrismaService } from '@/prisma/prisma.service'; -import { ProvinceService } from './province.service'; -import { RegencyService } from '@/regency/regency.service'; -import { Province, Regency } from '@prisma/client'; import { getDBProviderFeatures } from '@/common/utils/db'; import { DistrictService } from '@/district/district.service'; import { IslandService } from '@/island/island.service'; +import { PrismaService } from '@/prisma/prisma.service'; +import { RegencyService } from '@/regency/regency.service'; +import { SortOrder } from '@/sort/sort.dto'; import { VillageService } from '@/village/village.service'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Province, Regency } from '@prisma/client'; +import { ProvinceService } from './province.service'; const provinces: readonly Province[] = [ { code: '11', name: 'ACEH' }, @@ -129,7 +130,7 @@ describe('ProvinceService', () => { const result = await provinceService.find({ sortBy: 'name', - sortOrder: 'desc', + sortOrder: SortOrder.DESC, }); expect(paginatorSpy).toHaveBeenCalledTimes(1); diff --git a/src/province/province.service.ts b/src/province/province.service.ts index c0609c9..68ff26b 100644 --- a/src/province/province.service.ts +++ b/src/province/province.service.ts @@ -1,4 +1,3 @@ -import { CommonService, FindOptions } from '@/common/common.service'; import { PaginationQuery } from '@/common/dto/pagination.dto'; import { PaginatedReturn } from '@/common/interceptor/paginate.interceptor'; import { getDBProviderFeatures } from '@/common/utils/db'; @@ -7,9 +6,10 @@ import { RegencyService } from '@/regency/regency.service'; import { SortOptions, SortService } from '@/sort/sort.service'; import { Injectable } from '@nestjs/common'; import { Province, Regency } from '@prisma/client'; +import { ProvinceFindQueries } from './province.dto'; @Injectable() -export class ProvinceService implements CommonService { +export class ProvinceService { readonly sorter: SortService; constructor( @@ -23,7 +23,7 @@ export class ProvinceService implements CommonService { } async find( - options?: FindOptions, + options?: ProvinceFindQueries, ): Promise> { return this.prisma.paginator({ model: 'Province',