diff --git a/.eslintrc.json b/.eslintrc.json index 622474c0..eddc29e9 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -135,8 +135,6 @@ "@typescript-eslint/no-var-requires": "warn", "@typescript-eslint/prefer-as-const": "warn", "@typescript-eslint/require-await": "warn", - "@typescript-eslint/restrict-plus-operands": "warn", - "@typescript-eslint/restrict-template-expressions": "warn", "@typescript-eslint/triple-slash-reference": "warn", "@typescript-eslint/unbound-method": "warn" } diff --git a/package.json b/package.json index 751f2a86..dc68aac2 100644 --- a/package.json +++ b/package.json @@ -111,5 +111,8 @@ "eslint --fix --report-unused-disable-directives --max-warnings=0 --ignore", "prettier --write --ignore-unknown" ] + }, + "prisma": { + "seed": "tsx prisma/seed.ts" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d853c17..3420bac2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.0' +lockfileVersion: '6.1' settings: autoInstallPeers: true diff --git a/prisma/migrations/20240719232325_add_new_columns_in_account/migration.sql b/prisma/migrations/20240719232325_add_new_columns_in_account/migration.sql new file mode 100644 index 00000000..e2ea31d6 --- /dev/null +++ b/prisma/migrations/20240719232325_add_new_columns_in_account/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - You are about to drop the column `avatarUrl` on the `accounts` table. All the data in the column will be lost. + - Added the required column `avatar_url` to the `accounts` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "accounts" DROP COLUMN "avatarUrl", +ADD COLUMN "avatar_url" TEXT NOT NULL, +ADD COLUMN "name" TEXT, +ADD COLUMN "social_user_id" TEXT, +ADD COLUMN "username" TEXT, +ALTER COLUMN "updated_at" DROP DEFAULT; diff --git a/prisma/migrations/20240722010704_rename_columns/migration.sql b/prisma/migrations/20240722010704_rename_columns/migration.sql new file mode 100644 index 00000000..1e11ab8a --- /dev/null +++ b/prisma/migrations/20240722010704_rename_columns/migration.sql @@ -0,0 +1,23 @@ +/* + Warnings: + + - You are about to drop the column `social_user_id` on the `accounts` table. All the data in the column will be lost. + - Added the required column `favorite` to the `accounts` table without a default value. This is not possible if the table is not empty. + - Made the column `social_media_id` on table `accounts` required. This step will fail if there are existing NULL values in that column. + +*/ +-- DropForeignKey +ALTER TABLE "accounts" DROP CONSTRAINT "accounts_social_media_id_fkey"; + +-- AlterTable +ALTER TABLE "accounts" DROP COLUMN "social_user_id", +ADD COLUMN "favorite" BOOLEAN NOT NULL, +ADD COLUMN "social_media_user_id" TEXT, +ALTER COLUMN "social_media_id" SET NOT NULL, +ALTER COLUMN "avatar_url" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "social_media" ALTER COLUMN "description" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "accounts" ADD CONSTRAINT "accounts_social_media_id_fkey" FOREIGN KEY ("social_media_id") REFERENCES "social_media"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b5161456..a23ef5cf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -30,12 +30,16 @@ model User { } model Account { - id String @id @default(uuid()) - avatarUrl String - userId String? @map("user_id") - socialMediaId Int? @map("social_media_id") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id String @id @default(uuid()) + name String? + username String? + socialMediaUserId String? @map("social_media_user_id") //Referes to the social media user id -> Twitter ID + avatarUrl String? @map("avatar_url") + userId String? @map("user_id") + favorite Boolean + socialMediaId Int @map("social_media_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") user User? @relation(fields: [userId], references: [id]) socialMedia SocialMedia? @relation(fields: [socialMediaId], references: [id]) @@ -49,7 +53,7 @@ model Token { authToken String? @map("auth_token") token String? issuedAt DateTime? @map("issued_at") - expireIn Int? @map("expire_in") + expiresIn Int? @map("expire_in") accountId String @unique @map("account_id") account Account? @relation(fields: [accountId], references: [id]) @@ -58,9 +62,9 @@ model Token { } model SocialMedia { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) name String - description String + description String? account Account[] diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 00000000..9456240d --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,32 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + await prisma.socialMedia.createMany({ + data: [ + { + description: '', + name: 'Twitter', + }, + { + description: '', + name: 'Instagram', + }, + { + description: '', + name: 'Facebook', + }, + ], + }); +} + +main() + .then(() => { + console.log('Seed executed'); + prisma.$disconnect(); + process.exit(0); + }) + .finally(() => { + prisma.$disconnect(); + }); diff --git a/src/config/env.ts b/src/config/env.ts index ebb300d6..b33ff4e8 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -32,5 +32,5 @@ switch (process.env['MODE']) { export const env = { HOSTNAME: process.env['HOSTNAME'] || 'localhost', PORT: process.env['PORT'] || 3000, - SECRET_KEY: process.env['SECRET_KEY'] || '321', + SECRET_KEY: process.env['SECRET_KEY'] || 'secret_key', } as Record; diff --git a/src/features/account/models/account-model.ts b/src/features/account/models/account-model.ts index 982580ce..abbefc4f 100644 --- a/src/features/account/models/account-model.ts +++ b/src/features/account/models/account-model.ts @@ -6,3 +6,10 @@ export type AccountModel = { updatedAt: Date; userId: null | string; }; + +export type UpsertParams = { + accessToken: string; + accountId: string; + authToken: string; + expiresIn: number; +}; diff --git a/src/features/account/repositories/account-repository/account-repository.ts b/src/features/account/repositories/account-repository/account-repository.ts index 2cf68b43..cceb5666 100644 --- a/src/features/account/repositories/account-repository/account-repository.ts +++ b/src/features/account/repositories/account-repository/account-repository.ts @@ -4,16 +4,20 @@ export class AccountRepository { create({ avatarUrl, socialMediaId, + socialMediaUserId, userId, }: { avatarUrl: string; socialMediaId: number; + socialMediaUserId: string; userId: string; }) { return database.account.create({ data: { avatarUrl, + favorite: false, socialMediaId, + socialMediaUserId, userId, }, }); @@ -45,6 +49,21 @@ export class AccountRepository { }); } + getAccountBySocialMedia({ + socialMediaUserId, + userId, + }: { + socialMediaUserId: string; + userId: string; + }) { + return database.account.findFirst({ + where: { + socialMediaUserId, + userId, + }, + }); + } + getAccounts(userId: string) { return database.account.findMany({ where: { diff --git a/src/features/account/repositories/token-repository/token-repository.test.ts b/src/features/account/repositories/token-repository/token-repository.test.ts new file mode 100644 index 00000000..15a4a83f --- /dev/null +++ b/src/features/account/repositories/token-repository/token-repository.test.ts @@ -0,0 +1,51 @@ +import { faker } from '@faker-js/faker/locale/pt_BR'; +import { prisma } from 'mocks/prisma'; + +import { TokenRepository } from './token-repository'; + +describe('[Repositories] TokenRepository', () => { + let sut: TokenRepository; + + beforeEach(() => { + sut = new TokenRepository(); + }); + + it('upsert', async () => { + const input = { + accessToken: faker.string.alpha(), + accountId: faker.string.alpha(), + authToken: faker.string.alpha(), + expiresIn: faker.number.int({ max: 100 }), + }; + + const expected = { + accountId: faker.string.alpha(), + authToken: faker.string.alpha(), + expiresIn: faker.number.int({ max: 100 }), + id: faker.number.int({ max: 100 }), + issuedAt: new Date(), + token: faker.string.alpha(), + }; + + prisma.token.upsert.mockResolvedValue(expected); + + const result = await sut.upsert(input); + + expect(result).toEqual(expected); + expect(prisma.token.upsert).toHaveBeenCalledWith({ + create: { + authToken: input.authToken, + expiresIn: input.expiresIn, + issuedAt: expect.any(Date), + token: input.accessToken, + }, + update: { + expiresIn: input.expiresIn, + token: input.accessToken, + }, + where: { + accountId: input.accountId, + }, + }); + }); +}); diff --git a/src/features/account/repositories/token-repository/token-repository.ts b/src/features/account/repositories/token-repository/token-repository.ts new file mode 100644 index 00000000..0127055b --- /dev/null +++ b/src/features/account/repositories/token-repository/token-repository.ts @@ -0,0 +1,22 @@ +import type { UpsertParams } from '@/features/account/models/account-model'; +import { database } from '@/shared/infra/database/database'; + +export class TokenRepository { + upsert({ accessToken, accountId, authToken, expiresIn }: UpsertParams) { + return database.token.upsert({ + create: { + authToken, + expiresIn, + issuedAt: new Date(), + token: accessToken, + }, + update: { + expiresIn, + token: accessToken, + }, + where: { + accountId, + }, + }); + } +} diff --git a/src/features/account/services/get-user-accounts-service.ts b/src/features/account/services/get-user-accounts-service.ts index ec558139..3c167255 100644 --- a/src/features/account/services/get-user-accounts-service.ts +++ b/src/features/account/services/get-user-accounts-service.ts @@ -9,7 +9,7 @@ type Input = { type Output = { accounts: { - avatarUrl: string; + avatarUrl: null | string; id: string; socialMedia: { id: number; diff --git a/src/features/auth/models/auth-login-models.ts b/src/features/auth/models/auth-login-models.ts index d1883a3f..43a1bd50 100644 --- a/src/features/auth/models/auth-login-models.ts +++ b/src/features/auth/models/auth-login-models.ts @@ -2,3 +2,8 @@ export type AuthLoginModel = { password: string; username: string; }; + +export type FindUserByCredentialsParams = { + password: string; + username: string; +}; diff --git a/src/features/auth/repositories/auth-repository/auth-repository.ts b/src/features/auth/repositories/auth-repository/auth-repository.ts index 5af7b5d0..5c7ea867 100644 --- a/src/features/auth/repositories/auth-repository/auth-repository.ts +++ b/src/features/auth/repositories/auth-repository/auth-repository.ts @@ -1,11 +1,6 @@ +import type { FindUserByCredentialsParams } from '@/features/auth/models/auth-login-models'; import { database } from '@/shared/infra/database/database'; -// TODO: Move this type to a folder -type FindUserByCredentialsParams = { - password: string; - username: string; -}; - export class AuthRepository { findUserByCredentials({ password, username }: FindUserByCredentialsParams) { return database.user.findFirst({ diff --git a/src/features/auth/services/auth-login-service.ts b/src/features/auth/services/auth-login-service.ts index 10d90384..f66366e8 100644 --- a/src/features/auth/services/auth-login-service.ts +++ b/src/features/auth/services/auth-login-service.ts @@ -35,7 +35,11 @@ export class AuthLoginService implements Service { throw new InvalidCredentialsError(); } - const token = this.jwt.createToken({ userId: user.id }); + const token = this.jwt.createToken({ + name: user.name || '', + userId: user.id, + username: user.username, + }); return { token, diff --git a/src/features/auth/services/auth-token-validation-service.ts b/src/features/auth/services/auth-token-validation-service.ts index 0f3c5949..ae2d9bdc 100644 --- a/src/features/auth/services/auth-token-validation-service.ts +++ b/src/features/auth/services/auth-token-validation-service.ts @@ -1,7 +1,7 @@ import type { UserRepository } from '@/features/user/repositories/user-repository'; import { EmailAlreadyActiveError } from '@/shared/errors/email-already-active-error'; import { UserNotFound } from '@/shared/errors/user-not-found-error'; -import type { JWTHelper, TokenPayload } from '@/shared/infra/jwt/jwt'; +import type { JWTHelper } from '@/shared/infra/jwt/jwt'; import type { Service } from '@/shared/protocols/service'; type Input = { @@ -15,7 +15,7 @@ export class AuthTokenValidationService implements Service { ) {} async execute({ token }: Input) { - const payload = this.jwt.parseToken(token) as TokenPayload; + const payload = this.jwt.parseToken(token); const user = await this.userReposiotry.findById(payload.userId); diff --git a/src/features/social-media/models/social-media-model.ts b/src/features/social-media/models/social-media-model.ts index 8d4f3ad0..e2e786a5 100644 --- a/src/features/social-media/models/social-media-model.ts +++ b/src/features/social-media/models/social-media-model.ts @@ -1,5 +1,5 @@ export type SocialMediaModel = { - description: string; + description: null | string; id: number; name: string; }; diff --git a/src/features/twitter/controllers/twitter-controller.test.ts b/src/features/twitter/controllers/twitter-controller.test.ts new file mode 100644 index 00000000..d93b3ca1 --- /dev/null +++ b/src/features/twitter/controllers/twitter-controller.test.ts @@ -0,0 +1,83 @@ +import type { Request, Response } from 'express'; +import { mock, mockDeep } from 'vitest-mock-extended'; + +import type { Logger } from '@/shared/infra/logger/logger'; +import { loggerMock } from '@/shared/test-helpers/mocks/logger.mock'; +import { accountRepositoryMock } from '@/shared/test-helpers/mocks/repositories/account-repository.mock'; +import { tokenRepositoryMock } from '@/shared/test-helpers/mocks/repositories/token-repository.mock'; + +import { AuthorizeTwitterService } from '../services/authorize-twitter-service'; +import type { TwitterService } from '../services/twitter-service'; +import { TwitterController } from './twitter-controller'; + +const makeSut = () => { + const mockLogger: Logger = mock(loggerMock); + const twitterServiceMock = mock({ + getTwitterOAuthToken: vi.fn(), + getTwitterUser: vi.fn(), + }); + + const authorizeTwitterService = mock( + new AuthorizeTwitterService( + mockLogger, + twitterServiceMock, + accountRepositoryMock, + tokenRepositoryMock + ) + ); + + const authController = new TwitterController(authorizeTwitterService); + + const req = mockDeep(); + const res = { + json: vi.fn(), + send: vi.fn(), + status: vi.fn().mockReturnThis(), + } as unknown as Response; + const next = vi.fn(); + + return { + authController, + authorizeTwitterService, + mockLogger, + next, + req, + res, + twitterServiceMock, + }; +}; + +describe('[Controller] Twitter', () => { + describe('callback', () => { + it('should be return code', async () => { + const { authController, authorizeTwitterService, next, req, res } = + makeSut(); + + const spyAuthorizeTwitter = vi + .spyOn(authorizeTwitterService, 'execute') + .mockReturnThis(); + req.query = { code: '123', state: '123' }; + + await authController.callback(req, res, next); + + expect(spyAuthorizeTwitter).toHaveBeenCalledWith({ + code: '123', + state: '123', + }); + expect(res.send).toHaveBeenCalled(); + }); + }); + + describe('login', () => { + it('should be return 401', () => { + const { authController, next, req, res } = makeSut(); + + req.headers.authorization = undefined; + + authController.login(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ message: 'Unauthorized' }); + }); + }); +}); diff --git a/src/features/twitter/controllers/twitter-controller.ts b/src/features/twitter/controllers/twitter-controller.ts new file mode 100644 index 00000000..0ac6461c --- /dev/null +++ b/src/features/twitter/controllers/twitter-controller.ts @@ -0,0 +1,39 @@ +import jwt from 'jsonwebtoken'; + +import type { TokenPayload } from '@/shared/infra/jwt/jwt'; +import type { Controller } from '@/shared/protocols/controller'; +import type { AsyncRequestHandler } from '@/shared/protocols/handlers'; + +import { generateAuthURL } from '../helpers/generate-auth-url'; +import type { AuthorizeTwitterService } from '../services/authorize-twitter-service'; + +export class TwitterController implements Controller { + callback: AsyncRequestHandler = async (req, res) => { + const query = req.query; + + await this.authorizeTwitter.execute({ + code: String(query.code), + state: String(query.state), + }); + + return res.send(); + }; + + login: AsyncRequestHandler = (req, res) => { + const authorization = req.headers.authorization; + + if (!authorization) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const [, token] = authorization.split(' '); + + const payload = jwt.verify(token, 'secret_key') as TokenPayload; + + const url = generateAuthURL({ id: payload.userId }); + + return res.json(url); + }; + + constructor(private readonly authorizeTwitter: AuthorizeTwitterService) {} +} diff --git a/src/features/twitter/doc.md b/src/features/twitter/doc.md new file mode 100644 index 00000000..51bbceae --- /dev/null +++ b/src/features/twitter/doc.md @@ -0,0 +1,9 @@ +### Manage Tweets + + + +### OAuth2 + + + + diff --git a/src/features/twitter/helpers/generate-auth-url.ts b/src/features/twitter/helpers/generate-auth-url.ts new file mode 100644 index 00000000..e2298ccd --- /dev/null +++ b/src/features/twitter/helpers/generate-auth-url.ts @@ -0,0 +1,21 @@ +import 'dotenv/config'; + +type Input = { + id: string; +}; + +export function generateAuthURL({ id }: Input) { + const baseUrl = 'https://twitter.com/i/oauth2/authorize'; + const clientId = process.env.TWITTER_CLIENT_ID!; + + const params = new URLSearchParams({ + client_id: clientId, + code_challenge: '-a4-ROPIVaUBVj1qqB2O6eN_qSC0WvET0EdUEhSFqrI', + code_challenge_method: 'S256', + redirect_uri: `http://www.localhost:3000/api/twitter/callback`, + response_type: 'code', + state: id, + }); + + return `${baseUrl}?${params.toString()}&scope=tweet.write%20tweet.read%20users.read`; +} diff --git a/src/features/twitter/models/twitter-models.ts b/src/features/twitter/models/twitter-models.ts new file mode 100644 index 00000000..bfefcff2 --- /dev/null +++ b/src/features/twitter/models/twitter-models.ts @@ -0,0 +1,12 @@ +export type TwitterTokenResponse = { + access_token: string; + expires_in: 7200; + scope: string; + token_type: 'bearer'; +}; + +export type TwitterUser = { + id: string; + name: string; + username: string; +}; diff --git a/src/features/twitter/routes/twitter-controller.factory.ts b/src/features/twitter/routes/twitter-controller.factory.ts new file mode 100644 index 00000000..e96f7dc1 --- /dev/null +++ b/src/features/twitter/routes/twitter-controller.factory.ts @@ -0,0 +1,36 @@ +/* istanbul ignore file -- @preserve */ +import { AccountRepository } from '@/features/account/repositories/account-repository/account-repository'; +import { TokenRepository } from '@/features/account/repositories/token-repository/token-repository'; +import { AxiosHandler } from '@/shared/infra/http/axios-http'; +import { Logger } from '@/shared/infra/logger/logger'; + +import { TwitterController } from '../controllers/twitter-controller'; +import { AuthorizeTwitterService } from '../services/authorize-twitter-service'; +import { TwitterService } from '../services/twitter-service'; + +export function twitterControllerFactory() { + const axiosAdapter = new AxiosHandler({ + baseURL: 'https://api.twitter.com', + }); + + const twitterService = new TwitterService( + new Logger({ service: 'twitterService' }), + axiosAdapter + ); + + const tokenRepository = new TokenRepository(); + const accountRepository = new AccountRepository(); + + const authorizeTwitterService = new AuthorizeTwitterService( + new Logger({ service: 'authorizeTwitterService' }), + twitterService, + accountRepository, + tokenRepository + ); + + const twitterController = new TwitterController(authorizeTwitterService); + + return { + twitterController, + }; +} diff --git a/src/features/twitter/routes/twitter-routes.ts b/src/features/twitter/routes/twitter-routes.ts new file mode 100644 index 00000000..693433ea --- /dev/null +++ b/src/features/twitter/routes/twitter-routes.ts @@ -0,0 +1,16 @@ +/* istanbul ignore file -- @preserve */ +import { Router } from 'express'; + +import { twitterControllerFactory } from './twitter-controller.factory'; + +const router = Router(); + +const { twitterController } = twitterControllerFactory(); + +router.get('/login', twitterController.login); +router.get('/callback', twitterController.callback); + +export default { + prefix: 'twitter', + router, +}; diff --git a/src/features/twitter/services/authorize-twitter-service.test.ts b/src/features/twitter/services/authorize-twitter-service.test.ts new file mode 100644 index 00000000..b85c9cf0 --- /dev/null +++ b/src/features/twitter/services/authorize-twitter-service.test.ts @@ -0,0 +1,80 @@ +import { faker } from '@faker-js/faker'; +import { mock } from 'vitest-mock-extended'; + +import type { AccountRepository } from '@/features/account/repositories/account-repository/account-repository'; +import type { TokenRepository } from '@/features/account/repositories/token-repository/token-repository'; +import type { Logger } from '@/shared/infra/logger/logger'; +import { loggerMock } from '@/shared/test-helpers/mocks/logger.mock'; + +import { AuthorizeTwitterService } from './authorize-twitter-service'; +import type { TwitterService } from './twitter-service'; + +describe('[Service] Authorize Twitter', () => { + let sut: AuthorizeTwitterService; + const mockLogger = mock(loggerMock); + const mockTwitterService = mock(); + const mockAccountRepository = mock(); + const mockTokenRepository = mock(); + + beforeEach(() => { + sut = new AuthorizeTwitterService( + mockLogger, + mockTwitterService, + mockAccountRepository, + mockTokenRepository + ); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + it('success', async () => { + const spyLogger = vi.spyOn(mockLogger, 'info'); + + mockTwitterService.getTwitterOAuthToken.mockResolvedValueOnce({ + access_token: 'access_token', + expires_in: 7200, + scope: '123', + token_type: 'bearer', + }); + + mockTwitterService.getTwitterUser.mockResolvedValueOnce({ + id: '123', + name: 'name', + username: 'username', + }); + + mockAccountRepository.getAccountBySocialMedia.mockResolvedValueOnce({ + avatarUrl: faker.image.avatar(), + createdAt: faker.date.past(), + favorite: faker.datatype.boolean(), + id: faker.string.uuid(), + name: faker.person.firstName(), + socialMediaId: faker.number.int(), + socialMediaUserId: faker.string.uuid(), + updatedAt: faker.date.recent(), + userId: faker.string.uuid(), + username: faker.internet.userName(), + }); + + const input = { + code: '123', + state: '123', + }; + + await sut.execute(input); + + expect(spyLogger).toHaveBeenCalledWith( + 'Inicialize authorize twitter service' + ); + expect(mockTwitterService.getTwitterOAuthToken).toHaveBeenCalledWith( + input.code + ); + expect(mockTwitterService.getTwitterUser).toHaveBeenCalledWith( + 'access_token' + ); + expect(mockAccountRepository.getAccountBySocialMedia).toHaveBeenCalled(); + expect(mockAccountRepository.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/twitter/services/authorize-twitter-service.ts b/src/features/twitter/services/authorize-twitter-service.ts new file mode 100644 index 00000000..14c693aa --- /dev/null +++ b/src/features/twitter/services/authorize-twitter-service.ts @@ -0,0 +1,57 @@ +import type { AccountRepository } from '@/features/account/repositories/account-repository/account-repository'; +import type { TokenRepository } from '@/features/account/repositories/token-repository/token-repository'; +import type { Logger } from '@/shared/infra/logger/logger'; +import type { Service } from '@/shared/protocols/service'; + +import type { TwitterService } from './twitter-service'; + +type Input = { + code: string; + state: string; +}; + +export class AuthorizeTwitterService implements Service { + constructor( + private readonly logger: Logger, + private readonly twitterService: TwitterService, + private readonly accountRepository: AccountRepository, + private readonly tokenRepository: TokenRepository + ) {} + + async execute({ code, state }: Input) { + this.logger.info('Inicialize authorize twitter service'); + const userId = state; + + const twitterOAuthToken = + await this.twitterService.getTwitterOAuthToken(code); + + if (!twitterOAuthToken) { + throw new Error('Erro'); + } + + const twitterUser = await this.twitterService.getTwitterUser( + twitterOAuthToken.access_token + ); + + let accountExist = await this.accountRepository.getAccountBySocialMedia({ + socialMediaUserId: twitterUser.id, + userId, + }); + + if (!accountExist) { + accountExist = await this.accountRepository.create({ + avatarUrl: '', + socialMediaId: 1, + socialMediaUserId: twitterUser.id, + userId: userId, + }); + } + + await this.tokenRepository.upsert({ + accessToken: twitterOAuthToken.access_token, + accountId: accountExist.id, + authToken: twitterOAuthToken.token_type, + expiresIn: twitterOAuthToken.expires_in, + }); + } +} diff --git a/src/features/twitter/services/twitter-service.test.ts b/src/features/twitter/services/twitter-service.test.ts new file mode 100644 index 00000000..b064c273 --- /dev/null +++ b/src/features/twitter/services/twitter-service.test.ts @@ -0,0 +1,99 @@ +import { faker } from '@faker-js/faker'; +import { mock } from 'vitest-mock-extended'; + +import type { HttpAdapter } from '@/shared/infra/http/http-adapter'; +import type { Logger } from '@/shared/infra/logger/logger'; +import { httpAdapterMock } from '@/shared/test-helpers/mocks/http-adapter.mock'; +import { loggerMock } from '@/shared/test-helpers/mocks/logger.mock'; + +import { TwitterService } from './twitter-service'; + +describe('[Service] Twitter Service', () => { + let sut: TwitterService; + const mockLogger = mock(loggerMock); + const mockHttp = mock(httpAdapterMock); + + beforeEach(() => { + sut = new TwitterService(mockLogger, mockHttp); + }); + + describe('getTwitterOAuthToken', () => { + it('return data with success', async () => { + const input = faker.string.alpha({ length: 10 }); + + mockHttp.post.mockResolvedValueOnce({ + data: { + access_token: faker.string.alpha({ length: 10 }), + expires_in: 7200, + scope: faker.string.alpha({ length: 10 }), + token_type: 'bearer', + }, + status: 200, + statusText: 'OK', + }); + + const result = await sut.getTwitterOAuthToken(input); + + expect(mockLogger.info).toBeCalled(); + expect(result).toEqual({ + access_token: expect.any(String), + expires_in: 7200, + scope: expect.any(String), + token_type: 'bearer', + }); + }); + + it('error on post', async () => { + const input = faker.string.alpha({ length: 10 }); + + mockHttp.post.mockRejectedValueOnce(new Error('Error on post')); + + await expect(sut.getTwitterOAuthToken(input)).rejects.toThrowError( + 'Error on post' + ); + expect(mockLogger.error).toBeCalledWith( + 'Error on getTwitterOAuthToken in twitter service -Error: Error on post' + ); + }); + }); + + describe('getTwitterUser', () => { + it('return data with success', async () => { + const input = faker.string.alpha({ length: 10 }); + + mockHttp.get.mockResolvedValueOnce({ + data: { + data: { + id: faker.string.alpha(), + name: faker.string.alpha(), + username: faker.internet.userName(), + }, + }, + status: 200, + statusText: 'OK', + }); + + const result = await sut.getTwitterUser(input); + + expect(mockLogger.info).toBeCalled(); + expect(result).toEqual({ + id: expect.any(String), + name: expect.any(String), + username: expect.any(String), + }); + }); + + it('error on get', async () => { + const input = faker.string.alpha({ length: 10 }); + + mockHttp.get.mockRejectedValueOnce(new Error('Error on get')); + + await expect(sut.getTwitterUser(input)).rejects.toThrowError( + 'Error on get' + ); + expect(mockLogger.error).toBeCalledWith( + 'Error on getTwitterUser in twitter service -Error: Error on get' + ); + }); + }); +}); diff --git a/src/features/twitter/services/twitter-service.ts b/src/features/twitter/services/twitter-service.ts new file mode 100644 index 00000000..6164888b --- /dev/null +++ b/src/features/twitter/services/twitter-service.ts @@ -0,0 +1,74 @@ +import type { HttpAdapter } from '@/shared/infra/http/http-adapter'; +import type { Logger } from '@/shared/infra/logger/logger'; + +import type { + TwitterTokenResponse, + TwitterUser, +} from '../models/twitter-models'; + +export class TwitterService { + constructor( + private readonly logger: Logger, + private readonly http: HttpAdapter + ) {} + + async getTwitterOAuthToken(code: string) { + this.logger.info('Inicialize getTwitterOAuthToken in twitter service'); + const clientId = process.env.TWITTER_CLIENT_ID!; + + const basicAuth = Buffer.from( + `${process.env.TWITTER_CLIENT_SECRET}:${clientId}`, + 'utf8' + ).toString('base64'); + + const twitterOauthTokenParams = { + client_id: clientId, + code_verifier: 'gti48Qxg-ORDSTLlHs_QkOyNwOx8g5Be6A2FFh7iJDA', + grant_type: 'authorization_code', + redirect_uri: `http://www.localhost:3000/api/twitter/callback`, + }; + + try { + const { data } = await this.http.post({ + config: { + headers: { + Authorization: `Basic ${basicAuth}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + data: { + code, + ...twitterOauthTokenParams, + }, + url: '/2/oauth2/token', + }); + + return data; + } catch (err) { + this.logger.error( + `Error on getTwitterOAuthToken in twitter service -${err}` + ); + throw err; + } + } + + async getTwitterUser(accessToken: string) { + try { + this.logger.info('Inicialize getTwitterUser in twitter service'); + const { data } = await this.http.get<{ data: TwitterUser }>({ + config: { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }, + url: '/2/users/me', + }); + + return data.data ?? null; + } catch (err) { + this.logger.error(`Error on getTwitterUser in twitter service -${err}`); + throw err; + } + } +} diff --git a/src/middlewares/auth/auth-jwt.test.ts b/src/middlewares/auth/auth-jwt.test.ts index f71ad08e..e0939fb2 100644 --- a/src/middlewares/auth/auth-jwt.test.ts +++ b/src/middlewares/auth/auth-jwt.test.ts @@ -1,71 +1,52 @@ +import { faker } from '@faker-js/faker'; import type { Request, Response } from 'express'; +import { mock } from 'vitest-mock-extended'; import type { UserRepository } from '@/features/user/repositories/user-repository'; import { JWTHelper } from '@/shared/infra/jwt/jwt'; +import { userRepositoryMock } from '@/shared/test-helpers/mocks/repositories/user-repository.mock'; import { AuthenticationJWT } from './auth-jwt'; const secretKey = '321'; -const makeSut = () => { - class UserRepositoryStub implements UserRepository { - create({ email, name, password, username }: any) { - return Promise.resolve({ - createdAt: new Date(2024, 5, 1), - deletedAt: null, - email, - id: 'valid_id', - name, - password, - updatedAt: new Date(2024, 5, 1), - username, - }); - } - - findById(id: string): Promise<{ - email: string; - id: string; - name: null | string; - username: string; - } | null> { - const user = { - email: 'fake@example.com', - id: 'fakeUserId', - name: 'FakeName', - username: 'FakeUserName', - }; - if (id === 'fakeUserId') { - return Promise.resolve(user); - } - return Promise.resolve(null); - } - } - - const userRepository = new UserRepositoryStub(); - const jwtHelper = new JWTHelper(secretKey as string); - const auth = new AuthenticationJWT(jwtHelper, userRepository); - - return { auth, jwtHelper, userRepository }; -}; - -describe('jwtAuth middleware', () => { +describe('[MIDDLEWARE] JWT', () => { + let sut: AuthenticationJWT; let req: Partial; let res: Partial; let next: ReturnType; - const { auth, jwtHelper } = makeSut(); + + const jwtHelper = new JWTHelper(secretKey as string); + const userRepoMock = mock(userRepositoryMock); beforeEach(() => { req = { headers: { authorization: 'Bearer' } }; res = { json: vi.fn(), status: vi.fn().mockReturnThis() }; next = vi.fn(); + + new JWTHelper(secretKey as string); + + sut = new AuthenticationJWT(jwtHelper, userRepoMock); }); it('should call next if token is valid and user is found', async () => { - const token = jwtHelper.createToken({ userId: 'fakeUserId' }); + const token = jwtHelper.createToken({ + name: faker.person.fullName(), + userId: faker.string.uuid(), + username: faker.person.fullName(), + }); req = { headers: { authorization: `Bearer ${token}` } }; - await auth.jwtAuth(req as Request, res as Response, next); + userRepoMock.findById.mockResolvedValueOnce({ + email: faker.internet.email(), + id: faker.string.uuid(), + isActive: true, + name: faker.person.fullName(), + username: faker.internet.userName(), + }); + + await sut.jwtAuth(req as Request, res as Response, next); expect(res.json).not.toHaveBeenCalled(); expect(res.status).not.toHaveBeenCalled(); @@ -75,7 +56,7 @@ describe('jwtAuth middleware', () => { it('should return status code 401 with error message token missing', async () => { req.headers!.authorization = undefined; - await auth.jwtAuth(req as Request, res as Response, next); + await sut.jwtAuth(req as Request, res as Response, next); expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ error: 'Token missing' }); @@ -87,7 +68,7 @@ describe('jwtAuth middleware', () => { req = { headers: { authorization: `Bearer ${token}` } }; - await auth.jwtAuth(req as Request, res as Response, next); + await sut.jwtAuth(req as Request, res as Response, next); expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ error: 'Invalid token' }); @@ -95,11 +76,18 @@ describe('jwtAuth middleware', () => { }); it('should return status code 401 with error message invalid user', async () => { - const token = jwtHelper.createToken({ userId: '2' }); + const token = jwtHelper.createToken({ + name: 'fakeName', + userId: 'fakeUserId', + username: 'fakeUsername', + }); req = { headers: { authorization: `Bearer ${token}` } }; - await auth.jwtAuth(req as Request, res as Response, next); + await sut.jwtAuth(req as Request, res as Response, next); + + userRepoMock.findById.mockResolvedValueOnce(null); + expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ error: 'Invalid user' }); expect(next).not.toHaveBeenCalled(); @@ -113,7 +101,8 @@ describe('jwtAuth middleware', () => { req = { headers: { authorization: 'Bearer 23123' } }; - await auth.jwtAuth(req as Request, res as Response, next); + await sut.jwtAuth(req as Request, res as Response, next); + expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith({ error: 'Internal Server Error' }); expect(next).not.toHaveBeenCalled(); diff --git a/src/middlewares/auth/auth-jwt.ts b/src/middlewares/auth/auth-jwt.ts index ed3edaf0..3818f7f4 100644 --- a/src/middlewares/auth/auth-jwt.ts +++ b/src/middlewares/auth/auth-jwt.ts @@ -1,6 +1,6 @@ import type { UserRepository } from '@/features/user/repositories/user-repository'; import { InvalidTokenException } from '@/shared/errors/invalid-token-exception'; -import { type JWTHelper, type TokenPayload } from '@/shared/infra/jwt/jwt'; +import { type JWTHelper } from '@/shared/infra/jwt/jwt'; import type { AsyncRequestHandler } from '@/shared/protocols/handlers'; export class AuthenticationJWT { @@ -11,10 +11,10 @@ export class AuthenticationJWT { return res.status(401).json({ error: 'Token missing' }); } - const payload = this.jwtHelper.parseToken(token) as TokenPayload; + const { userId } = this.jwtHelper.parseToken(token); - const userId = payload.userId; const user = await this.userRepository.findById(userId); + console.log('asdasdasd'); if (!user) { return res.status(401).json({ error: 'Invalid user' }); } @@ -24,7 +24,6 @@ export class AuthenticationJWT { if (error instanceof InvalidTokenException) { return res.status(401).json({ error: 'Invalid token' }); } - console.error('Erro durante a autenticaĆ§Ć£o:', error); return res.status(500).json({ error: 'Internal Server Error' }); } }; diff --git a/src/shared/infra/http/axios-http.ts b/src/shared/infra/http/axios-http.ts new file mode 100644 index 00000000..b1bc2e3c --- /dev/null +++ b/src/shared/infra/http/axios-http.ts @@ -0,0 +1,35 @@ +import type { AxiosRequestConfig } from 'axios'; +import axios, { Axios } from 'axios'; + +import type { HttpAdapter, HttpRequest } from './http-adapter'; + +export class AxiosHandler implements HttpAdapter { + private readonly api: Axios; + + constructor(config?: AxiosRequestConfig) { + this.api = new Axios({ + transformRequest: axios.defaults.transformRequest, + transformResponse: axios.defaults.transformResponse, + validateStatus: (status) => status >= 200 && status < 300, + ...config, + }); + } + + async get({ + config, + url, + }: { + config?: AxiosRequestConfig; + url: string; + }): Promise { + const response: T = await this.api.get(url, config); + + return response; + } + + async post({ config, data, url }: HttpRequest): Promise { + const response: T = await this.api.post(url, data, config); + + return response; + } +} diff --git a/src/shared/infra/http/http-adapter.ts b/src/shared/infra/http/http-adapter.ts new file mode 100644 index 00000000..1d47e9b3 --- /dev/null +++ b/src/shared/infra/http/http-adapter.ts @@ -0,0 +1,29 @@ +export abstract class HttpAdapter { + abstract get(data: HttpRequest): Promise>; + abstract post(data: HttpRequest): Promise>; +} + +export interface HttpRequest { + config?: { + headers?: { + Authorization?: string; + 'Content-Type'?: string; + }; + params?: object; + }; + data?: T; + responseType?: ResponseType; + url: string; +} + +export interface HttpResponse { + config?: object; + data: T; + headers?: { + 'Content-Length'?: string; + 'Content-Type'?: string; + }; + request?: object; + status: number; + statusText: string; +} diff --git a/src/shared/infra/jwt/jwt.test.ts b/src/shared/infra/jwt/jwt.test.ts index 28969ea8..8603b8f7 100644 --- a/src/shared/infra/jwt/jwt.test.ts +++ b/src/shared/infra/jwt/jwt.test.ts @@ -4,7 +4,11 @@ import { JWTHelper } from './jwt'; describe('JWTHelper', () => { const secretKey = '123'; const jwt = new JWTHelper(secretKey); - const payload = { userId: '0321' }; + const payload: TokenPayload = { + name: 'John Doe', + userId: '0321', + username: 'johndoe', + }; describe('createToken', () => { it('should create a valid token', () => { @@ -34,7 +38,7 @@ describe('JWTHelper', () => { describe('parseToken', () => { it('should parse a valid token', () => { const token = jwt.createToken(payload); - const parsedPayload = jwt.parseToken(token) as TokenPayload; + const parsedPayload = jwt.parseToken(token); expect(parsedPayload?.userId).toBe(payload.userId); }); diff --git a/src/shared/infra/jwt/jwt.ts b/src/shared/infra/jwt/jwt.ts index 9132f70e..e2b8192f 100644 --- a/src/shared/infra/jwt/jwt.ts +++ b/src/shared/infra/jwt/jwt.ts @@ -3,17 +3,19 @@ import jwt from 'jsonwebtoken'; import { InvalidTokenException } from '@/shared/errors/invalid-token-exception'; export interface TokenPayload { + name: string; userId: string; + username: string; } export class JWTHelper { constructor(private readonly secretKey: string) {} - createToken(token: TokenPayload, expiresIn: string = '1h'): string { - return jwt.sign(token, this.secretKey, { expiresIn }); + createToken(payload: TokenPayload, expiresIn: string = '1h'): string { + return jwt.sign(payload, this.secretKey, { expiresIn }); } - parseToken(token: string): Error | TokenPayload { + parseToken(token: string): TokenPayload { try { const payload = jwt.verify(token, this.secretKey) as TokenPayload; @@ -24,7 +26,7 @@ export class JWTHelper { } } - refreshToken(token: string): Error | string { + refreshToken(token: string): string { try { const payload = this.parseToken(token); return jwt.sign(payload, this.secretKey); diff --git a/src/shared/infra/logger/logger.ts b/src/shared/infra/logger/logger.ts new file mode 100644 index 00000000..fb3127c3 --- /dev/null +++ b/src/shared/infra/logger/logger.ts @@ -0,0 +1,37 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as winston from 'winston'; + +type Config = { service: string }; + +export class Logger { + logger: winston.Logger; + + constructor(private readonly config: Config) { + this.logger = winston + .createLogger({ + defaultMeta: { + service: this.config.service, + source: 'development', + }, + format: winston.format.json(), + level: 'info', + }) + .add( + new winston.transports.Console({ + format: winston.format.json(), + }) + ); + } + + error(data: any) { + this.logger.error(data); + } + + info(data: any) { + this.logger.info(data); + } + + warn(data: any) { + this.logger.warn(data); + } +} diff --git a/src/shared/protocols/http-client.ts b/src/shared/protocols/http-client.ts index 7d8a5865..8bd54866 100644 --- a/src/shared/protocols/http-client.ts +++ b/src/shared/protocols/http-client.ts @@ -1,17 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -export type HttpClientRequest = { - body?: any; - headers?: any; - method: HttpMethod; - url: string; -}; - -export interface HttpClient { - request: (data: HttpClientRequest) => Promise>; -} - -export type HttpMethod = 'delete' | 'get' | 'post' | 'put'; - export enum HttpStatusCode { badRequest = 400, conflict = 409, @@ -23,8 +9,3 @@ export enum HttpStatusCode { serverError = 500, unauthorized = 401, } - -export type HttpClientResponse = { - body?: T; - statusCode: HttpStatusCode; -}; diff --git a/src/shared/protocols/service.ts b/src/shared/protocols/service.ts index c8ae23ed..33a20158 100644 --- a/src/shared/protocols/service.ts +++ b/src/shared/protocols/service.ts @@ -1,3 +1,3 @@ export interface Service { - execute(params: P): Promise; + execute(params: P): Promise | R; } diff --git a/src/shared/test-helpers/mocks/http-adapter.mock.ts b/src/shared/test-helpers/mocks/http-adapter.mock.ts new file mode 100644 index 00000000..7f2ff00a --- /dev/null +++ b/src/shared/test-helpers/mocks/http-adapter.mock.ts @@ -0,0 +1,6 @@ +import { vi } from 'vitest'; + +export const httpAdapterMock = { + get: vi.fn(), + post: vi.fn(), +}; diff --git a/src/shared/test-helpers/mocks/logger.mock.ts b/src/shared/test-helpers/mocks/logger.mock.ts new file mode 100644 index 00000000..15f7e032 --- /dev/null +++ b/src/shared/test-helpers/mocks/logger.mock.ts @@ -0,0 +1,7 @@ +import { vi } from 'vitest'; + +export const loggerMock = { + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), +}; diff --git a/src/shared/test-helpers/mocks/repositories/account-repository.mock.ts b/src/shared/test-helpers/mocks/repositories/account-repository.mock.ts index bce0d7cf..fdfc8f03 100644 --- a/src/shared/test-helpers/mocks/repositories/account-repository.mock.ts +++ b/src/shared/test-helpers/mocks/repositories/account-repository.mock.ts @@ -4,5 +4,6 @@ export const accountRepositoryMock = { create: vi.fn(), deleteAccountsBySocialMediaId: vi.fn(), findAccountsByUserId: vi.fn(), + getAccountBySocialMedia: vi.fn(), getAccounts: vi.fn(), }; diff --git a/src/shared/test-helpers/mocks/repositories/token-repository.mock.ts b/src/shared/test-helpers/mocks/repositories/token-repository.mock.ts new file mode 100644 index 00000000..e2e24fe5 --- /dev/null +++ b/src/shared/test-helpers/mocks/repositories/token-repository.mock.ts @@ -0,0 +1,5 @@ +import { vi } from 'vitest'; + +export const tokenRepositoryMock = { + upsert: vi.fn(), +}; diff --git a/tsconfig.json b/tsconfig.json index 62432019..54b4002b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,6 @@ { "compilerOptions": { - "types": [ - "vitest/globals" - ], + "types": ["vitest/globals"], "target": "ES2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, "module": "ES2020" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, "outDir": "./build" /* Redirect output structure to the directory. */, @@ -26,12 +24,8 @@ // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ "baseUrl": "./" /* Base directory to resolve non-absolute module names. */, "paths": { - "@/*": [ - "./src/*" - ], - "mocks/*": [ - "__mocks__/*" - ] + "@/*": ["./src/*"], + "mocks/*": ["__mocks__/*"] }, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "typeRoots": [], /* List of folders to include type definitions from. */