diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2f665908..9ca9546b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,10 +2,12 @@ name: CI on: push: - branches: [main] + branches: + - 'main' + - '*/*' pull_request: - branches: [main] - + branches: + - '**' workflow_dispatch: jobs: diff --git a/package.json b/package.json index aa26b75e..91cdb5f3 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,10 @@ "test:e2e:watch": "jest --config jest-e2e.config.ts --watch", "stryker": "stryker run", "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli", - "migration:run": "npm run typeorm -- migration:run -d ./src/config/typeorm.config.ts", - "migration:generate": "npm run typeorm -- migration:generate -d ./src/config/typeorm.config.ts ./src/migrations/migration", + "migration:run": "npm run typeorm -- migration:run -d ./src/config/database/typeorm.config.ts", + "migration:generate": "npm run typeorm -- migration:generate -d ./src/config/database/typeorm.config.ts ./src/migrations/migration", "migration:create": "npm run typeorm -- migration:create ./src/migrations/migration", - "migration:revert": "npm run typeorm -- migration:revert -d ./src/config/typeorm.config.ts", + "migration:revert": "npm run typeorm -- migration:revert -d ./src/config/database/typeorm.config.ts", "prepare": "husky install" }, "sideEffects": false, diff --git a/src/common/adapters/profile.adapter.ts b/src/common/adapters/profile.adapter.ts index 59d662cb..9d6376fe 100644 --- a/src/common/adapters/profile.adapter.ts +++ b/src/common/adapters/profile.adapter.ts @@ -6,6 +6,9 @@ export const adaptProfile = ({ email, images, country, + product, + type, + uri, external_urls: { spotify: href }, followers, }: SpotifyProfile): FormattedProfile => ({ @@ -15,5 +18,8 @@ export const adaptProfile = ({ images, country, href, + product, + type, + uri, followers: followers.total, }) diff --git a/src/common/mocks/image.mock.ts b/src/common/mocks/image.mock.ts new file mode 100644 index 00000000..8439dc52 --- /dev/null +++ b/src/common/mocks/image.mock.ts @@ -0,0 +1,20 @@ +import { SpotifyImage } from '../types/spotify' + +import { Image } from '@modules/images' + +export const spotifyImageMock: SpotifyImage = { + height: 300, + url: 'https://i.scdn.co/image/ab67616d00001e023f1900e26ff44e8821bd8350', + width: 300, +} + +export const imageMock: Image = { + id: '123', + ...spotifyImageMock, +} + +export const spotifyImagesMock = Array.from( + { length: 3 }, + () => spotifyImageMock +) +export const imagesMock = Array.from({ length: 3 }, () => imageMock) diff --git a/src/common/mocks/index.ts b/src/common/mocks/index.ts index 41ff7a84..b6e77c63 100644 --- a/src/common/mocks/index.ts +++ b/src/common/mocks/index.ts @@ -6,3 +6,5 @@ export * from './playback-state.mock' export * from './genres.mock' export * from './audio-features.mock' export * from './spotify-response.mock' +export * from './image.mock' +export * from './user.mock' diff --git a/src/common/mocks/profile.mock.ts b/src/common/mocks/profile.mock.ts index 39f54c85..81e46881 100644 --- a/src/common/mocks/profile.mock.ts +++ b/src/common/mocks/profile.mock.ts @@ -1,5 +1,9 @@ import { FormattedProfile, SpotifyProfile } from '../types/spotify' +import { imagesMock } from './image.mock' + +import { Profile } from '@modules/profiles' + export const spotifyProfileMock: SpotifyProfile = { country: 'US', display_name: 'Spotify User', @@ -46,6 +50,9 @@ export const formattedProfileMock: FormattedProfile = { displayName: 'Spotify User', email: 'spotify-user@example.com', followers: 0, + product: 'premium', + type: 'user', + uri: 'spotify:user:spotify-user', id: 'spotify-user', href: 'https://open.spotify.com/user/spotify-user', images: [ @@ -66,3 +73,11 @@ export const formattedProfileMock: FormattedProfile = { }, ], } + +export const profileMock: Profile = { + ...formattedProfileMock, + id: '1', + images: imagesMock, +} + +export const profilesMock = Array.from({ length: 3 }, () => profileMock) diff --git a/src/common/mocks/user.mock.ts b/src/common/mocks/user.mock.ts new file mode 100644 index 00000000..846ca6a4 --- /dev/null +++ b/src/common/mocks/user.mock.ts @@ -0,0 +1,11 @@ +import { profileMock } from './profile.mock' + +import { User } from '@modules/users' + +export const userMock: User = { + id: '1', + refreshToken: 'refreshToken', + profile: profileMock, +} + +export const usersMock = Array.from({ length: 3 }, () => userMock) diff --git a/src/common/types/spotify/profile.ts b/src/common/types/spotify/profile.ts index 07bacb1d..05384396 100644 --- a/src/common/types/spotify/profile.ts +++ b/src/common/types/spotify/profile.ts @@ -8,6 +8,9 @@ export interface FormattedProfile { country?: string email?: string href: string + product?: string + type: string + uri: string } export interface SpotifyProfile { diff --git a/src/main.ts b/src/main.ts index 3150f51d..419af707 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,8 @@ -import { NestFactory } from '@nestjs/core' +import { NestFactory, Reflector } from '@nestjs/core' import { ConfigService } from '@nestjs/config' import cookieParser from 'cookie-parser' import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' +import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common' import { Environment } from '@config/environment' import { AppModule } from '@modules/app' @@ -10,6 +11,7 @@ import { BEARER } from '@modules/auth/constants' async function bootstrap() { const app = await NestFactory.create(AppModule) + const reflector = app.get(Reflector) const configService = app.get(ConfigService) const documentConfig = new DocumentBuilder() @@ -42,8 +44,9 @@ async function bootstrap() { credentials: true, }) app.use(cookieParser()) + app.useGlobalPipes(new ValidationPipe()) + app.useGlobalInterceptors(new ClassSerializerInterceptor(reflector)) - await app.startAllMicroservices() await app.listen(+configService.get(Environment.PORT) || 4000) } bootstrap() diff --git a/src/migrations/1697539455661-migration.ts b/src/migrations/1697539455661-migration.ts new file mode 100644 index 00000000..a1663f5b --- /dev/null +++ b/src/migrations/1697539455661-migration.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class Migration1697539455661 implements MigrationInterface { + name = 'Migration1697539455661' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "image" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "height" integer NOT NULL, "width" integer NOT NULL, "url" character varying NOT NULL, CONSTRAINT "PK_d6db1ab4ee9ad9dbe86c64e4cc3" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TABLE "profile" ("id" character varying NOT NULL, "displayName" character varying NOT NULL, "followers" integer NOT NULL, "country" character varying, "email" character varying, "href" character varying NOT NULL, "product" character varying, "type" character varying NOT NULL, "uri" character varying NOT NULL, CONSTRAINT "PK_3dd8bfc97e4a77c70971591bdcb" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TABLE "user" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "refreshToken" character varying NOT NULL, CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TABLE "profile_images_image" ("profileId" character varying NOT NULL, "imageId" uuid NOT NULL, CONSTRAINT "PK_b41ff6ac84d6b21bbb4164b913a" PRIMARY KEY ("profileId", "imageId"))` + ) + await queryRunner.query( + `CREATE INDEX "IDX_8520f204d1054799bbcb21023d" ON "profile_images_image" ("profileId") ` + ) + await queryRunner.query( + `CREATE INDEX "IDX_baf570df58dbd868515e33fc25" ON "profile_images_image" ("imageId") ` + ) + await queryRunner.query( + `ALTER TABLE "profile_images_image" ADD CONSTRAINT "FK_8520f204d1054799bbcb21023dd" FOREIGN KEY ("profileId") REFERENCES "profile"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + await queryRunner.query( + `ALTER TABLE "profile_images_image" ADD CONSTRAINT "FK_baf570df58dbd868515e33fc252" FOREIGN KEY ("imageId") REFERENCES "image"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "profile_images_image" DROP CONSTRAINT "FK_baf570df58dbd868515e33fc252"` + ) + await queryRunner.query( + `ALTER TABLE "profile_images_image" DROP CONSTRAINT "FK_8520f204d1054799bbcb21023dd"` + ) + await queryRunner.query( + `DROP INDEX "public"."IDX_baf570df58dbd868515e33fc25"` + ) + await queryRunner.query( + `DROP INDEX "public"."IDX_8520f204d1054799bbcb21023d"` + ) + await queryRunner.query(`DROP TABLE "profile_images_image"`) + await queryRunner.query(`DROP TABLE "user"`) + await queryRunner.query(`DROP TABLE "profile"`) + await queryRunner.query(`DROP TABLE "image"`) + } +} diff --git a/src/modules/app/app.module.ts b/src/modules/app/app.module.ts index 2047e3af..da8ba551 100644 --- a/src/modules/app/app.module.ts +++ b/src/modules/app/app.module.ts @@ -3,16 +3,22 @@ import { ConfigModule, ConfigService } from '@nestjs/config' import { TypeOrmModule } from '@nestjs/typeorm' import { environmentSchema } from '@config/environment' +import { typeorm } from '@config/database' import { AuthModule } from '@modules/auth' import { StatisticsModule } from '@modules/statistics' import { PlayerModule } from '@modules/player' -import { typeorm } from '@config/database' +import { ImagesModule } from '@modules/images' +import { ProfilesModule } from '@modules/profiles' +import { UsersModule } from '@modules/users' @Module({ imports: [ AuthModule, StatisticsModule, PlayerModule, + ImagesModule, + ProfilesModule, + UsersModule, ConfigModule.forRoot({ isGlobal: true, envFilePath: './.env', diff --git a/src/modules/auth/auth.controller.spec.ts b/src/modules/auth/auth.controller.spec.ts index 0b665338..587861a0 100644 --- a/src/modules/auth/auth.controller.spec.ts +++ b/src/modules/auth/auth.controller.spec.ts @@ -8,13 +8,18 @@ import { AuthController } from './auth.controller' import { AuthService } from './auth.service' import { SecretData } from './dtos' -import { FormattedProfile } from '@common/types/spotify' +import { formattedProfileMock, profileMock, userMock } from '@common/mocks' +import { ProfilesRepository, ProfilesService } from '@modules/profiles' +import { UsersRepository } from '@modules/users' describe('AuthController', () => { const redirectUrl = 'http://test.com' let authController: AuthController let authService: AuthService + let profilesRepository: ProfilesRepository + let profilesService: ProfilesService + let usersRepository: UsersRepository beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -33,11 +38,32 @@ describe('AuthController', () => { get: vi.fn().mockReturnValue(redirectUrl), }, }, + { + provide: ProfilesService, + useValue: { + create: vi.fn(), + }, + }, + { + provide: ProfilesRepository, + useValue: { + findProfileById: vi.fn(), + }, + }, + { + provide: UsersRepository, + useValue: { + createUser: vi.fn(), + }, + }, ], }).compile() authController = module.get(AuthController) authService = module.get(AuthService) + profilesRepository = module.get(ProfilesRepository) + profilesService = module.get(ProfilesService) + usersRepository = module.get(UsersRepository) }) test('should be defined', () => { @@ -51,23 +77,83 @@ describe('AuthController', () => { expect(statusCode).toEqual(HttpStatus.PERMANENT_REDIRECT) }) - test('callback should return valid redirect path', async () => { + describe('callback', () => { + const code = 'code' const tokenResponse = { accessToken: '123', refreshToken: '456', expiresIn: 3600, } + const { accessToken, refreshToken } = tokenResponse - vi.spyOn(authService, 'token').mockReturnValue(of(tokenResponse)) + test('callback should return valid redirect path', async () => { + const tokenSpy = vi + .spyOn(authService, 'token') + .mockReturnValue(of(tokenResponse)) + const profileSpy = vi + .spyOn(authService, 'profile') + .mockReturnValue(of(formattedProfileMock)) + + expect(await authController.callback(code)).toEqual({ + url: `${redirectUrl}/api/authorize?${new URLSearchParams({ + accessToken, + refreshToken, + })}`, + statusCode: HttpStatus.PERMANENT_REDIRECT, + }) + expect(tokenSpy).toHaveBeenCalledWith({ code }) + expect(profileSpy).toHaveBeenCalledWith(accessToken) + }) - const { accessToken, refreshToken } = tokenResponse + test('should find profile by id', async () => { + vi.spyOn(authService, 'token').mockReturnValue(of(tokenResponse)) + vi.spyOn(authService, 'profile').mockReturnValue(of(formattedProfileMock)) + + const findProfileByIdSpy = vi + .spyOn(profilesRepository, 'findProfileById') + .mockResolvedValue(profileMock) + const createSpy = vi.spyOn(profilesService, 'create') + const createUserSpy = vi.spyOn(usersRepository, 'createUser') + + expect(await authController.callback(code)).toEqual({ + url: `${redirectUrl}/api/authorize?${new URLSearchParams({ + accessToken, + refreshToken, + })}`, + statusCode: HttpStatus.PERMANENT_REDIRECT, + }) + + expect(findProfileByIdSpy).toHaveBeenCalledWith(formattedProfileMock.id) + expect(createSpy).not.toHaveBeenCalled() + expect(createUserSpy).not.toHaveBeenCalled() + }) - expect(await authController.callback('code')).toEqual({ - url: `${redirectUrl}/api/authorize?${new URLSearchParams({ - accessToken, + test('should create profile and user', async () => { + vi.spyOn(authService, 'token').mockReturnValue(of(tokenResponse)) + vi.spyOn(authService, 'profile').mockReturnValue(of(formattedProfileMock)) + + const findProfileByIdSpy = vi.spyOn(profilesRepository, 'findProfileById') + const createSpy = vi + .spyOn(profilesService, 'create') + .mockResolvedValue(profileMock) + const createUserSpy = vi + .spyOn(usersRepository, 'createUser') + .mockResolvedValue(userMock) + + expect(await authController.callback(code)).toEqual({ + url: `${redirectUrl}/api/authorize?${new URLSearchParams({ + accessToken, + refreshToken, + })}`, + statusCode: HttpStatus.PERMANENT_REDIRECT, + }) + + expect(findProfileByIdSpy).toHaveBeenCalledWith(formattedProfileMock.id) + expect(createSpy).toHaveBeenCalledWith(formattedProfileMock) + expect(createUserSpy).toHaveBeenCalledWith({ + profile: profileMock, refreshToken, - })}`, - statusCode: HttpStatus.PERMANENT_REDIRECT, + }) }) }) @@ -85,26 +171,10 @@ describe('AuthController', () => { }) test('should return profile', async () => { - const profileMock: FormattedProfile = { - id: '123', - displayName: 'test', - email: 'email@test.com', - images: [ - { - url: 'http://test.com', - height: 100, - width: 100, - }, - ], - followers: 23, - country: 'BR', - href: 'http://test.com', - } - - vi.spyOn(authService, 'profile').mockReturnValue(of(profileMock)) + vi.spyOn(authService, 'profile').mockReturnValue(of(formattedProfileMock)) expect(await firstValueFrom(authController.profile('123'))).toEqual( - profileMock + formattedProfileMock ) }) }) diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index b230774c..b1681cd1 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -11,6 +11,8 @@ import { ProfileDto, SecretData } from './dtos' import { Environment } from '@config/environment' import { AuthenticationType } from '@modules/auth/enums' +import { UsersRepository } from '@modules/users' +import { ProfilesRepository, ProfilesService } from '@modules/profiles' const { SPOTIFY_CALLBACK_URL, @@ -24,7 +26,10 @@ const { export class AuthController { constructor( private readonly authService: AuthService, - private readonly configService: ConfigService + private readonly configService: ConfigService, + private readonly profilesService: ProfilesService, + private readonly profilesRepository: ProfilesRepository, + private readonly usersRepository: UsersRepository ) {} @Get('login') @@ -52,6 +57,23 @@ export class AuthController { this.authService.token({ code }) ) + const spotifyProfile = await firstValueFrom( + this.authService.profile(accessToken) + ) + + const foundProfile = await this.profilesRepository.findProfileById( + spotifyProfile.id + ) + + if (!foundProfile) { + const profile = await this.profilesService.create(spotifyProfile) + + await this.usersRepository.createUser({ + profile, + refreshToken, + }) + } + return { url: `${this.configService.get( CLIENT_CALLBACK_URL diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 1e08c5c8..5e4e4425 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -7,6 +7,8 @@ import { AuthService } from './auth.service' import { AuthController } from './auth.controller' import { Environment } from '@config/environment' +import { UsersModule } from '@modules/users' +import { ProfilesModule } from '@modules/profiles' @Module({ imports: [ @@ -31,6 +33,8 @@ import { Environment } from '@config/environment' }, inject: [ConfigService], }), + ProfilesModule, + UsersModule, ], providers: [AuthService], controllers: [AuthController], diff --git a/src/modules/auth/dtos/profile.dto.ts b/src/modules/auth/dtos/profile.dto.ts index 9fbbfd91..8155558d 100644 --- a/src/modules/auth/dtos/profile.dto.ts +++ b/src/modules/auth/dtos/profile.dto.ts @@ -24,4 +24,13 @@ export abstract class ProfileDto implements FormattedProfile { @ApiProperty() href: string + + @ApiProperty({ required: false }) + product?: string + + @ApiProperty() + type: string + + @ApiProperty() + uri: string } diff --git a/src/modules/images/dtos/create-image.dto.ts b/src/modules/images/dtos/create-image.dto.ts new file mode 100644 index 00000000..8849ade0 --- /dev/null +++ b/src/modules/images/dtos/create-image.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger' + +import { Image } from '../image.entity' + +export abstract class CreateImage implements Omit { + @ApiProperty({ type: Number }) + height: number + + @ApiProperty({ type: Number }) + width: number + + @ApiProperty() + url: string +} diff --git a/src/modules/images/dtos/index.ts b/src/modules/images/dtos/index.ts new file mode 100644 index 00000000..1ec2e07d --- /dev/null +++ b/src/modules/images/dtos/index.ts @@ -0,0 +1 @@ +export * from './create-image.dto' diff --git a/src/modules/images/image.entity.ts b/src/modules/images/image.entity.ts new file mode 100644 index 00000000..b6030b1b --- /dev/null +++ b/src/modules/images/image.entity.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm' + +import { SpotifyImage } from '~/common/types/spotify' + +@Entity() +export class Image implements SpotifyImage { + @PrimaryGeneratedColumn('uuid') + @ApiProperty() + id: string + + @Column('int') + @ApiProperty({ type: Number }) + height: number + + @Column('int') + @ApiProperty({ type: Number }) + width: number + + @Column() + @ApiProperty() + url: string +} diff --git a/src/modules/images/images.module.ts b/src/modules/images/images.module.ts new file mode 100644 index 00000000..a2889e90 --- /dev/null +++ b/src/modules/images/images.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { TypeOrmModule } from '@nestjs/typeorm' + +import { Image } from './image.entity' +import { ImagesRepository } from './images.repository' + +@Module({ + imports: [TypeOrmModule.forFeature([Image])], + providers: [ImagesRepository], + exports: [ImagesRepository], +}) +export class ImagesModule {} diff --git a/src/modules/images/images.repository.spec.ts b/src/modules/images/images.repository.spec.ts new file mode 100644 index 00000000..40c79c88 --- /dev/null +++ b/src/modules/images/images.repository.spec.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { Test, TestingModule } from '@nestjs/testing' +import { DataSource } from 'typeorm' + +import { ImagesRepository } from './images.repository' + +import { imageMock, imagesMock, spotifyImageMock } from '@common/mocks' + +describe('ImagesRepository', () => { + let imagesRepository: ImagesRepository + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ImagesRepository, + { + provide: DataSource, + useValue: { + createEntityManager: vi.fn(), + }, + }, + ], + }).compile() + + imagesRepository = module.get(ImagesRepository) + }) + + test('should be defined', () => { + expect(imagesRepository).toBeDefined() + }) + + test('should find all images', async () => { + vi.spyOn(imagesRepository, 'find').mockResolvedValue(imagesMock) + + const images = await imagesRepository.findImages() + + expect(images).toEqual(imagesMock) + expect(imagesRepository.find).toHaveBeenCalled() + }) + + test('should find image by id', async () => { + vi.spyOn(imagesRepository, 'findOneBy').mockResolvedValue(imageMock) + + const id = '1' + const image = await imagesRepository.findImage(id) + + expect(image).toEqual(imageMock) + expect(imagesRepository.findOneBy).toHaveBeenCalledWith({ id }) + }) + + test('should create image', async () => { + vi.spyOn(imagesRepository, 'create').mockReturnValue(imageMock) + vi.spyOn(imagesRepository, 'save').mockResolvedValue(imageMock) + + const image = await imagesRepository.createImage(spotifyImageMock) + + expect(image).toEqual(imageMock) + expect(imagesRepository.create).toHaveBeenCalledWith(spotifyImageMock) + expect(imagesRepository.save).toHaveBeenCalledWith(imageMock) + }) + + test('should update image', async () => { + vi.spyOn(imagesRepository, 'findOneBy').mockResolvedValue(imageMock) + vi.spyOn(imagesRepository, 'save').mockResolvedValue(imageMock) + + const id = '1' + const image = await imagesRepository.updateImage(id, spotifyImageMock) + + expect(image).toEqual(imageMock) + expect(imagesRepository.findOneBy).toHaveBeenCalledWith({ id }) + expect(imagesRepository.save).toHaveBeenCalledWith(imageMock) + }) + + test('should remove image', async () => { + vi.spyOn(imagesRepository, 'findOneBy').mockResolvedValue(imageMock) + vi.spyOn(imagesRepository, 'remove').mockResolvedValue(imageMock) + + const id = '1' + const image = await imagesRepository.removeImage(id) + + expect(image).toEqual(imageMock) + expect(imagesRepository.findOneBy).toHaveBeenCalledWith({ id }) + expect(imagesRepository.remove).toHaveBeenCalledWith(imageMock) + }) +}) diff --git a/src/modules/images/images.repository.ts b/src/modules/images/images.repository.ts new file mode 100644 index 00000000..f9c630e6 --- /dev/null +++ b/src/modules/images/images.repository.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common' +import { DataSource, Repository } from 'typeorm' + +import { Image } from './image.entity' +import { CreateImage } from './dtos' + +@Injectable() +export class ImagesRepository extends Repository { + constructor(private readonly dataSource: DataSource) { + super(Image, dataSource.createEntityManager()) + } + + findImages() { + return this.find() + } + + findImage(id: string) { + return this.findOneBy({ id }) + } + + async createImage(image: CreateImage) { + const imageEntity = this.create(image) + + return await this.save(imageEntity) + } + + async updateImage(id: string, image: Partial) { + const foundImage = await this.findOneBy({ id }) + + Object.assign(foundImage, image) + + return await this.save(foundImage) + } + + async removeImage(id: string) { + const image = await this.findOneBy({ id }) + + return await this.remove(image) + } +} diff --git a/src/modules/images/index.ts b/src/modules/images/index.ts new file mode 100644 index 00000000..a326e780 --- /dev/null +++ b/src/modules/images/index.ts @@ -0,0 +1,3 @@ +export * from './image.entity' +export * from './images.repository' +export * from './images.module' diff --git a/src/modules/profiles/dtos/create-profile.ts b/src/modules/profiles/dtos/create-profile.ts new file mode 100644 index 00000000..95530f0e --- /dev/null +++ b/src/modules/profiles/dtos/create-profile.ts @@ -0,0 +1,37 @@ +import { ApiProperty } from '@nestjs/swagger' + +import { Profile } from '../profile.entity' + +import { SpotifyImage } from '@common/types/spotify' + +export abstract class CreateProfile implements Omit { + @ApiProperty() + id: string + + @ApiProperty() + displayName: string + + @ApiProperty() + images: SpotifyImage[] + + @ApiProperty({ type: Number }) + followers: number + + @ApiProperty({ nullable: true }) + country?: string + + @ApiProperty({ nullable: true }) + email?: string + + @ApiProperty() + href: string + + @ApiProperty({ nullable: true }) + product?: string + + @ApiProperty() + type: string + + @ApiProperty() + uri: string +} diff --git a/src/modules/profiles/dtos/index.ts b/src/modules/profiles/dtos/index.ts new file mode 100644 index 00000000..b019150a --- /dev/null +++ b/src/modules/profiles/dtos/index.ts @@ -0,0 +1 @@ +export * from './create-profile' diff --git a/src/modules/profiles/index.ts b/src/modules/profiles/index.ts new file mode 100644 index 00000000..11500497 --- /dev/null +++ b/src/modules/profiles/index.ts @@ -0,0 +1,4 @@ +export * from './profile.entity' +export * from './profiles.repository' +export * from './profiles.module' +export * from './profiles.service' diff --git a/src/modules/profiles/profile.entity.ts b/src/modules/profiles/profile.entity.ts new file mode 100644 index 00000000..91a600d5 --- /dev/null +++ b/src/modules/profiles/profile.entity.ts @@ -0,0 +1,59 @@ +import { + Column, + Entity, + JoinTable, + ManyToMany, + PrimaryColumn, + Relation, +} from 'typeorm' +import { ApiProperty } from '@nestjs/swagger' + +import { Image } from '@modules/images' +import { FormattedProfile } from '@common/types/spotify' + +@Entity() +export class Profile implements FormattedProfile { + @PrimaryColumn() + @ApiProperty() + id: string + + @Column() + @ApiProperty() + displayName: string + + @ManyToMany('Image', 'profiles', { + cascade: true, + nullable: true, + }) + @JoinTable() + @ApiProperty({ type: Image, isArray: true }) + images: Relation[] + + @Column('int') + @ApiProperty({ type: Number }) + followers: number + + @Column({ nullable: true }) + @ApiProperty({ nullable: true }) + country?: string + + @Column({ nullable: true }) + @ApiProperty({ nullable: true }) + email?: string + + @Column() + @ApiProperty() + href: string + + @Column({ nullable: true }) + @ApiProperty({ nullable: true }) + product?: string + + @Column() + @ApiProperty() + type: string + + @Column() + @ApiProperty() + uri: string +} diff --git a/src/modules/profiles/profiles.module.ts b/src/modules/profiles/profiles.module.ts new file mode 100644 index 00000000..c9af49ee --- /dev/null +++ b/src/modules/profiles/profiles.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common' +import { TypeOrmModule } from '@nestjs/typeorm' + +import { Profile } from './profile.entity' +import { ProfilesRepository } from './profiles.repository' +import { ProfilesService } from './profiles.service' + +import { ImagesModule } from '@modules/images' + +@Module({ + imports: [TypeOrmModule.forFeature([Profile]), ImagesModule], + providers: [ProfilesRepository, ProfilesService], + exports: [ProfilesRepository, ProfilesService], +}) +export class ProfilesModule {} diff --git a/src/modules/profiles/profiles.repository.spec.ts b/src/modules/profiles/profiles.repository.spec.ts new file mode 100644 index 00000000..bdf0d8e8 --- /dev/null +++ b/src/modules/profiles/profiles.repository.spec.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { DataSource } from 'typeorm' +import { Test, TestingModule } from '@nestjs/testing' + +import { ProfilesRepository } from './profiles.repository' + +import { profileMock, profilesMock } from '@common/mocks' + +describe('ProfilesRepository', () => { + let profilesRepository: ProfilesRepository + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ProfilesRepository, + { + provide: DataSource, + useValue: { + createEntityManager: vi.fn(), + }, + }, + ], + }).compile() + + profilesRepository = module.get(ProfilesRepository) + }) + + test('should be defined', () => { + expect(profilesRepository).toBeDefined() + }) + + test('should find all profiles', async () => { + vi.spyOn(profilesRepository, 'find').mockResolvedValue(profilesMock) + + const profiles = await profilesRepository.findProfiles() + + expect(profiles).toEqual(profilesMock) + expect(profilesRepository.find).toHaveBeenCalled() + }) + + test('should find profile by id', async () => { + vi.spyOn(profilesRepository, 'findOne').mockResolvedValue(profileMock) + + const id = '1' + const profile = await profilesRepository.findProfileById(id) + + expect(profile).toEqual(profileMock) + expect(profilesRepository.findOne).toHaveBeenCalledWith({ + where: { id }, + relations: ['images'], + }) + }) + + test('should create profile', async () => { + vi.spyOn(profilesRepository, 'create').mockReturnValue(profileMock) + vi.spyOn(profilesRepository, 'save').mockResolvedValue(profileMock) + + const profile = await profilesRepository.createProfile(profileMock) + + expect(profile).toEqual(profileMock) + expect(profilesRepository.create).toHaveBeenCalledWith(profileMock) + expect(profilesRepository.save).toHaveBeenCalledWith(profileMock) + }) + + test('should update profile', async () => { + vi.spyOn(profilesRepository, 'findProfileById').mockResolvedValue( + profileMock + ) + vi.spyOn(profilesRepository, 'save').mockResolvedValue(profileMock) + + const id = '1' + const profile = await profilesRepository.updateProfile(id, profileMock) + + expect(profile).toEqual(profileMock) + expect(profilesRepository.findProfileById).toHaveBeenCalledWith(id) + expect(profilesRepository.save).toHaveBeenCalledWith(profileMock) + }) + + test('should remove profile', async () => { + vi.spyOn(profilesRepository, 'findProfileById').mockResolvedValue( + profileMock + ) + vi.spyOn(profilesRepository, 'remove').mockResolvedValue(profileMock) + + const id = '1' + const profile = await profilesRepository.removeProfile(id) + + expect(profile).toEqual(profileMock) + expect(profilesRepository.findProfileById).toHaveBeenCalledWith(id) + expect(profilesRepository.remove).toHaveBeenCalledWith(profileMock) + }) +}) diff --git a/src/modules/profiles/profiles.repository.ts b/src/modules/profiles/profiles.repository.ts new file mode 100644 index 00000000..8e40a702 --- /dev/null +++ b/src/modules/profiles/profiles.repository.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common' +import { DataSource, Repository } from 'typeorm' + +import { Profile } from './profile.entity' + +@Injectable() +export class ProfilesRepository extends Repository { + constructor(private readonly dataSource: DataSource) { + super(Profile, dataSource.createEntityManager()) + } + + findProfiles() { + return this.find({ + relations: ['images'], + }) + } + + findProfileById(id: string) { + return this.findOne({ where: { id }, relations: ['images'] }) + } + + async createProfile(profile: Profile) { + const profileEntity = this.create(profile) + + return await this.save(profileEntity) + } + + async updateProfile(id: string, profile: Partial) { + const foundProfile = await this.findProfileById(id) + + Object.assign(foundProfile, profile) + + return await this.save(foundProfile) + } + + async removeProfile(id: string) { + const profile = await this.findProfileById(id) + + return await this.remove(profile) + } +} diff --git a/src/modules/profiles/profiles.service.spec.ts b/src/modules/profiles/profiles.service.spec.ts new file mode 100644 index 00000000..efc300c4 --- /dev/null +++ b/src/modules/profiles/profiles.service.spec.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { Test, TestingModule } from '@nestjs/testing' +import { mock } from 'vitest-mock-extended' + +import { ProfilesService } from './profiles.service' +import { ProfilesRepository } from './profiles.repository' +import { CreateProfile } from './dtos' +import { Profile } from './profile.entity' + +import { Image, ImagesRepository } from '@modules/images' + +describe('ProfilesService', () => { + let profileService: ProfilesService + let profileRepository: ProfilesRepository + let imagesRepository: ImagesRepository + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ProfilesService, + { + provide: ProfilesRepository, + useValue: { + createProfile: vi.fn(), + }, + }, + { + provide: ImagesRepository, + useValue: { + createImage: vi.fn(), + }, + }, + ], + }).compile() + + profileService = module.get(ProfilesService) + profileRepository = module.get(ProfilesRepository) + imagesRepository = module.get(ImagesRepository) + }) + + test('should be defined', () => { + expect(profileService).toBeDefined() + }) + + test('should create profile', async () => { + const imageMock = mock() + const createProfileMock = mock({ + images: [imageMock], + }) + const profileMock = mock() + + const createProfileSpy = vi + .spyOn(profileRepository, 'createProfile') + .mockResolvedValue(profileMock) + const createImageSpy = vi + .spyOn(imagesRepository, 'createImage') + .mockResolvedValue(imageMock) + + expect(await profileService.create(createProfileMock)).toEqual(profileMock) + + expect(createProfileSpy).toHaveBeenCalled() + expect(createImageSpy).toHaveBeenCalled() + }) +}) diff --git a/src/modules/profiles/profiles.service.ts b/src/modules/profiles/profiles.service.ts new file mode 100644 index 00000000..8d076033 --- /dev/null +++ b/src/modules/profiles/profiles.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common' + +import { ProfilesRepository } from './profiles.repository' +import { CreateProfile } from './dtos' + +import { ImagesRepository } from '@modules/images' + +@Injectable() +export class ProfilesService { + constructor( + private readonly profilesRepository: ProfilesRepository, + private readonly imagesRepository: ImagesRepository + ) {} + + create({ images, ...newProfile }: CreateProfile) { + let imageEntities = [] + + images.map(async image => { + const newImage = await this.imagesRepository.createImage(image) + + imageEntities = [newImage] + }) + + return this.profilesRepository.createProfile({ + ...newProfile, + images: imageEntities, + }) + } +} diff --git a/src/modules/users/dtos/create-user.dto.ts b/src/modules/users/dtos/create-user.dto.ts new file mode 100644 index 00000000..5d5bf691 --- /dev/null +++ b/src/modules/users/dtos/create-user.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger' + +import { User } from '../user.entity' + +import { Profile } from '@modules/profiles' + +export abstract class CreateUser implements Omit { + @ApiProperty() + profile: Profile + + @ApiProperty() + refreshToken: string +} diff --git a/src/modules/users/dtos/index.ts b/src/modules/users/dtos/index.ts new file mode 100644 index 00000000..3405a442 --- /dev/null +++ b/src/modules/users/dtos/index.ts @@ -0,0 +1 @@ +export * from './create-user.dto' diff --git a/src/modules/users/index.ts b/src/modules/users/index.ts new file mode 100644 index 00000000..869024dc --- /dev/null +++ b/src/modules/users/index.ts @@ -0,0 +1,3 @@ +export * from './users.module' +export * from './user.entity' +export * from './users.repository' diff --git a/src/modules/users/user.entity.ts b/src/modules/users/user.entity.ts new file mode 100644 index 00000000..9590f563 --- /dev/null +++ b/src/modules/users/user.entity.ts @@ -0,0 +1,28 @@ +import { + Column, + Entity, + OneToOne, + PrimaryGeneratedColumn, + Relation, +} from 'typeorm' +import { Exclude } from 'class-transformer' +import { ApiProperty } from '@nestjs/swagger' + +import { Profile } from '@modules/profiles' + +@Entity() +export class User { + @PrimaryGeneratedColumn('uuid') + @ApiProperty() + id: string + + @OneToOne('Profile', 'user', { + cascade: true, + }) + @ApiProperty({ type: Profile }) + profile: Relation + + @Column() + @Exclude() + refreshToken: string +} diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts new file mode 100644 index 00000000..fb71ff41 --- /dev/null +++ b/src/modules/users/users.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { TypeOrmModule } from '@nestjs/typeorm' + +import { User } from './user.entity' +import { UsersRepository } from './users.repository' + +@Module({ + imports: [TypeOrmModule.forFeature([User])], + providers: [UsersRepository], + exports: [UsersRepository], +}) +export class UsersModule {} diff --git a/src/modules/users/users.repository.spec.ts b/src/modules/users/users.repository.spec.ts new file mode 100644 index 00000000..d10354a2 --- /dev/null +++ b/src/modules/users/users.repository.spec.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { Test, TestingModule } from '@nestjs/testing' +import { DataSource } from 'typeorm' + +import { UsersRepository } from './users.repository' + +import { userMock, usersMock } from '@common/mocks' + +describe('UsersRepository', () => { + let usersRepository: UsersRepository + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersRepository, + { + provide: DataSource, + useValue: { + createEntityManager: vi.fn(), + }, + }, + ], + }).compile() + + usersRepository = module.get(UsersRepository) + }) + + test('should be defined', () => { + expect(usersRepository).toBeDefined() + }) + + test('should find all users', async () => { + vi.spyOn(usersRepository, 'find').mockResolvedValue(usersMock) + + const users = await usersRepository.findUsers() + + expect(users).toEqual(usersMock) + expect(usersRepository.find).toHaveBeenCalled() + }) + + test('should find user by id', async () => { + vi.spyOn(usersRepository, 'findOne').mockResolvedValue(userMock) + + const id = '1' + const user = await usersRepository.findUser(id) + + expect(user).toEqual(userMock) + expect(usersRepository.findOne).toHaveBeenCalledWith({ + where: { id }, + relations: ['profile'], + }) + }) + + test('should create user', async () => { + vi.spyOn(usersRepository, 'create').mockReturnValue(userMock) + vi.spyOn(usersRepository, 'save').mockResolvedValue(userMock) + + const user = await usersRepository.createUser(userMock) + + expect(user).toEqual(userMock) + expect(usersRepository.create).toHaveBeenCalledWith(userMock) + expect(usersRepository.save).toHaveBeenCalledWith(userMock) + }) + + test('should update user', async () => { + vi.spyOn(usersRepository, 'findUser').mockResolvedValue(userMock) + vi.spyOn(usersRepository, 'save').mockResolvedValue(userMock) + + const id = '1' + const user = await usersRepository.updateUser(id, userMock) + + expect(user).toEqual(userMock) + expect(usersRepository.findUser).toHaveBeenCalledWith(id) + expect(usersRepository.save).toHaveBeenCalledWith(userMock) + }) + + test('should remove user', async () => { + vi.spyOn(usersRepository, 'findUser').mockResolvedValue(userMock) + vi.spyOn(usersRepository, 'remove').mockResolvedValue(userMock) + + const id = '1' + const user = await usersRepository.removeUser(id) + + expect(user).toEqual(userMock) + expect(usersRepository.findUser).toHaveBeenCalledWith(id) + expect(usersRepository.remove).toHaveBeenCalledWith(userMock) + }) +}) diff --git a/src/modules/users/users.repository.ts b/src/modules/users/users.repository.ts new file mode 100644 index 00000000..432d03d6 --- /dev/null +++ b/src/modules/users/users.repository.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common' +import { DataSource, Repository } from 'typeorm' + +import { User } from './user.entity' +import { CreateUser } from './dtos' + +@Injectable() +export class UsersRepository extends Repository { + constructor(private readonly dataSource: DataSource) { + super(User, dataSource.createEntityManager()) + } + + findUsers() { + return this.find({ + relations: ['profile'], + }) + } + + findUser(id: string) { + return this.findOne({ where: { id }, relations: ['profile'] }) + } + + createUser(user: CreateUser) { + const userEntity = this.create(user) + + return this.save(userEntity) + } + + async updateUser(id: string, user: Partial) { + const foundUser = await this.findUser(id) + + Object.assign(foundUser, user) + + return this.save(foundUser) + } + + async removeUser(id: string) { + const user = await this.findUser(id) + + return await this.remove(user) + } +}